Files
nora/nora-registry/src/main.rs
DevITWay 5fc4237ac5 feat: add Docker image metadata support
- Store metadata (.meta.json) alongside manifests with:
  - push_timestamp, last_pulled, downloads counter
  - size_bytes, os, arch, variant
  - layers list with digest and size
- Update metadata on manifest pull (increment downloads, update last_pulled)
- Extract OS/arch from config blob on push
- Extend UI API TagInfo with metadata fields
- Add public_url config option for pull commands
- Add Docker upstream proxy with auth support
- Add raw repository support
- Bump version to 0.2.12
2026-01-30 15:52:29 +00:00

368 lines
11 KiB
Rust

mod activity_log;
mod auth;
mod backup;
mod config;
mod dashboard_metrics;
mod error;
mod health;
mod metrics;
mod migrate;
mod openapi;
mod rate_limit;
mod registry;
mod request_id;
mod secrets;
mod storage;
mod tokens;
mod ui;
mod validation;
use axum::{extract::DefaultBodyLimit, middleware, Router};
use clap::{Parser, Subcommand};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Instant;
use tokio::signal;
use tracing::{error, info, warn};
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
use activity_log::ActivityLog;
use auth::HtpasswdAuth;
use config::{Config, StorageMode};
use dashboard_metrics::DashboardMetrics;
pub use storage::Storage;
use tokens::TokenStore;
#[derive(Parser)]
#[command(name = "nora", version, about = "Multi-protocol artifact registry")]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
enum Commands {
/// Start the registry server (default)
Serve,
/// Backup all artifacts to a tar.gz file
Backup {
/// Output file path (e.g., backup.tar.gz)
#[arg(short, long)]
output: PathBuf,
},
/// Restore artifacts from a backup file
Restore {
/// Input backup file path
#[arg(short, long)]
input: PathBuf,
},
/// Migrate artifacts between storage backends
Migrate {
/// Source storage: local or s3
#[arg(long)]
from: String,
/// Destination storage: local or s3
#[arg(long)]
to: String,
/// Dry run - show what would be migrated without copying
#[arg(long, default_value = "false")]
dry_run: bool,
},
}
pub struct AppState {
pub storage: Storage,
pub config: Config,
pub start_time: Instant,
pub auth: Option<HtpasswdAuth>,
pub tokens: Option<TokenStore>,
pub metrics: DashboardMetrics,
pub activity: ActivityLog,
pub docker_auth: registry::DockerAuth,
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
// Initialize logging (JSON for server, plain for CLI commands)
let is_server = matches!(cli.command, None | Some(Commands::Serve));
init_logging(is_server);
let config = Config::load();
// Initialize storage based on mode
let storage = match config.storage.mode {
StorageMode::Local => {
if is_server {
info!(path = %config.storage.path, "Using local storage");
}
Storage::new_local(&config.storage.path)
}
StorageMode::S3 => {
if is_server {
info!(
s3_url = %config.storage.s3_url,
bucket = %config.storage.bucket,
"Using S3 storage"
);
}
Storage::new_s3(&config.storage.s3_url, &config.storage.bucket)
}
};
// Dispatch to command
match cli.command {
None | Some(Commands::Serve) => {
run_server(config, storage).await;
}
Some(Commands::Backup { output }) => {
if let Err(e) = backup::create_backup(&storage, &output).await {
error!("Backup failed: {}", e);
std::process::exit(1);
}
}
Some(Commands::Restore { input }) => {
if let Err(e) = backup::restore_backup(&storage, &input).await {
error!("Restore failed: {}", e);
std::process::exit(1);
}
}
Some(Commands::Migrate { from, to, dry_run }) => {
let source = match from.as_str() {
"local" => Storage::new_local(&config.storage.path),
"s3" => Storage::new_s3(&config.storage.s3_url, &config.storage.bucket),
_ => {
error!("Invalid source: '{}'. Use 'local' or 's3'", from);
std::process::exit(1);
}
};
let dest = match to.as_str() {
"local" => Storage::new_local(&config.storage.path),
"s3" => Storage::new_s3(&config.storage.s3_url, &config.storage.bucket),
_ => {
error!("Invalid destination: '{}'. Use 'local' or 's3'", to);
std::process::exit(1);
}
};
if from == to {
error!("Source and destination cannot be the same");
std::process::exit(1);
}
let options = migrate::MigrateOptions { dry_run };
if let Err(e) = migrate::migrate(&source, &dest, options).await {
error!("Migration failed: {}", e);
std::process::exit(1);
}
}
}
}
fn init_logging(json_format: bool) {
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
if json_format {
tracing_subscriber::registry()
.with(env_filter)
.with(fmt::layer().json().with_target(true))
.init();
} else {
tracing_subscriber::registry()
.with(env_filter)
.with(fmt::layer().with_target(false))
.init();
}
}
async fn run_server(config: Config, storage: Storage) {
let start_time = Instant::now();
// Log rate limiting configuration
info!(
auth_rps = config.rate_limit.auth_rps,
auth_burst = config.rate_limit.auth_burst,
upload_rps = config.rate_limit.upload_rps,
upload_burst = config.rate_limit.upload_burst,
general_rps = config.rate_limit.general_rps,
general_burst = config.rate_limit.general_burst,
"Rate limiting configured"
);
// Initialize secrets provider
let secrets_provider = match secrets::create_secrets_provider(&config.secrets) {
Ok(provider) => {
info!(
provider = provider.provider_name(),
clear_env = config.secrets.clear_env,
"Secrets provider initialized"
);
Some(provider)
}
Err(e) => {
warn!(error = %e, "Failed to initialize secrets provider, using defaults");
None
}
};
// Store secrets provider for future use (S3 credentials, etc.)
let _secrets = secrets_provider;
// Load auth if enabled
let auth = if config.auth.enabled {
let path = Path::new(&config.auth.htpasswd_file);
match HtpasswdAuth::from_file(path) {
Some(auth) => {
info!(users = auth.list_users().len(), "Auth enabled");
Some(auth)
}
None => {
warn!(file = %config.auth.htpasswd_file, "Auth enabled but htpasswd file not found or empty");
None
}
}
} else {
None
};
// Initialize token store if auth is enabled
let tokens = if config.auth.enabled {
let token_path = Path::new(&config.auth.token_storage);
info!(path = %config.auth.token_storage, "Token storage initialized");
Some(TokenStore::new(token_path))
} else {
None
};
// Create rate limiters before moving config to state
let auth_limiter = rate_limit::auth_rate_limiter(&config.rate_limit);
let upload_limiter = rate_limit::upload_rate_limiter(&config.rate_limit);
let general_limiter = rate_limit::general_rate_limiter(&config.rate_limit);
// Initialize Docker auth with proxy timeout
let docker_auth = registry::DockerAuth::new(config.docker.proxy_timeout);
let state = Arc::new(AppState {
storage,
config,
start_time,
auth,
tokens,
metrics: DashboardMetrics::new(),
activity: ActivityLog::new(50),
docker_auth,
});
// Token routes with strict rate limiting (brute-force protection)
let auth_routes = auth::token_routes().layer(auth_limiter);
// Registry routes with upload rate limiting
let registry_routes = Router::new()
.merge(registry::docker_routes())
.merge(registry::maven_routes())
.merge(registry::npm_routes())
.merge(registry::cargo_routes())
.merge(registry::pypi_routes())
.merge(registry::raw_routes())
.layer(upload_limiter);
// Routes WITHOUT rate limiting (health, metrics, UI)
let public_routes = Router::new()
.merge(health::routes())
.merge(metrics::routes())
.merge(ui::routes())
.merge(openapi::routes());
// Routes WITH rate limiting
let rate_limited_routes = Router::new()
.merge(auth_routes)
.merge(registry_routes)
.layer(general_limiter);
let app = Router::new()
.merge(public_routes)
.merge(rate_limited_routes)
.layer(DefaultBodyLimit::max(100 * 1024 * 1024)) // 100MB default body limit
.layer(middleware::from_fn(request_id::request_id_middleware))
.layer(middleware::from_fn(metrics::metrics_middleware))
.layer(middleware::from_fn_with_state(
state.clone(),
auth::auth_middleware,
))
.with_state(state.clone());
let addr = format!("{}:{}", state.config.server.host, state.config.server.port);
let listener = tokio::net::TcpListener::bind(&addr)
.await
.expect("Failed to bind");
info!(
address = %addr,
version = env!("CARGO_PKG_VERSION"),
storage = state.storage.backend_name(),
auth_enabled = state.auth.is_some(),
"Nora started"
);
info!(
health = "/health",
ready = "/ready",
metrics = "/metrics",
ui = "/ui/",
api_docs = "/api-docs",
docker = "/v2/",
maven = "/maven2/",
npm = "/npm/",
cargo = "/cargo/",
pypi = "/simple/",
raw = "/raw/",
"Available endpoints"
);
// Graceful shutdown on SIGTERM/SIGINT
axum::serve(
listener,
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.with_graceful_shutdown(shutdown_signal())
.await
.expect("Server error");
info!(
uptime_seconds = state.start_time.elapsed().as_secs(),
"Nora shutdown complete"
);
}
/// Wait for shutdown signal (SIGTERM or SIGINT)
async fn shutdown_signal() {
let ctrl_c = async {
signal::ctrl_c()
.await
.expect("Failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
signal::unix::signal(signal::unix::SignalKind::terminate())
.expect("Failed to install SIGTERM handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {
info!("Received SIGINT, starting graceful shutdown...");
}
_ = terminate => {
info!("Received SIGTERM, starting graceful shutdown...");
}
}
}