diff --git a/nora-registry/src/config.rs b/nora-registry/src/config.rs index 0c1eeec..767c240 100644 --- a/nora-registry/src/config.rs +++ b/nora-registry/src/config.rs @@ -36,6 +36,13 @@ pub struct ServerConfig { /// Public URL for generating pull commands (e.g., "registry.example.com") #[serde(default)] pub public_url: Option, + /// Maximum request body size in MB (default: 2048 = 2GB) + #[serde(default = "default_body_limit_mb")] + pub body_limit_mb: usize, +} + +fn default_body_limit_mb() -> usize { + 2048 // 2GB - enough for any Docker image } #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] @@ -330,6 +337,11 @@ impl Config { if let Ok(val) = env::var("NORA_PUBLIC_URL") { self.server.public_url = if val.is_empty() { None } else { Some(val) }; } + if let Ok(val) = env::var("NORA_BODY_LIMIT_MB") { + if let Ok(mb) = val.parse() { + self.server.body_limit_mb = mb; + } + } // Storage config if let Ok(val) = env::var("NORA_STORAGE_MODE") { @@ -483,6 +495,7 @@ impl Default for Config { host: String::from("127.0.0.1"), port: 4000, public_url: None, + body_limit_mb: 2048, }, storage: StorageConfig { mode: StorageMode::Local, diff --git a/nora-registry/src/main.rs b/nora-registry/src/main.rs index 32bfc55..d07261c 100644 --- a/nora-registry/src/main.rs +++ b/nora-registry/src/main.rs @@ -347,7 +347,9 @@ async fn run_server(config: Config, storage: Storage) { let app = Router::new() .merge(public_routes) .merge(app_routes) - .layer(DefaultBodyLimit::max(100 * 1024 * 1024)) // 100MB default body limit + .layer(DefaultBodyLimit::max( + state.config.server.body_limit_mb * 1024 * 1024, + )) .layer(middleware::from_fn(request_id::request_id_middleware)) .layer(middleware::from_fn(metrics::metrics_middleware)) .layer(middleware::from_fn_with_state( @@ -366,6 +368,7 @@ async fn run_server(config: Config, storage: Storage) { version = env!("CARGO_PKG_VERSION"), storage = state.storage.backend_name(), auth_enabled = state.auth.is_some(), + body_limit_mb = state.config.server.body_limit_mb, "Nora started" ); diff --git a/nora-registry/src/registry/docker.rs b/nora-registry/src/registry/docker.rs index afbab3e..ce44fbf 100644 --- a/nora-registry/src/registry/docker.rs +++ b/nora-registry/src/registry/docker.rs @@ -12,7 +12,7 @@ use axum::{ extract::{Path, State}, http::{header, HeaderName, StatusCode}, response::{IntoResponse, Response}, - routing::{get, head, patch, put}, + routing::{delete, get, head, patch, put}, Json, Router, }; use parking_lot::RwLock; @@ -65,6 +65,8 @@ pub fn routes() -> Router> { ) .route("/v2/{name}/manifests/{reference}", get(get_manifest)) .route("/v2/{name}/manifests/{reference}", put(put_manifest)) + .route("/v2/{name}/manifests/{reference}", delete(delete_manifest)) + .route("/v2/{name}/blobs/{digest}", delete(delete_blob)) .route("/v2/{name}/tags/list", get(list_tags)) // Two-segment name routes (e.g., /v2/library/alpine/...) .route("/v2/{ns}/{name}/blobs/{digest}", head(check_blob_ns)) @@ -85,6 +87,11 @@ pub fn routes() -> Router> { "/v2/{ns}/{name}/manifests/{reference}", put(put_manifest_ns), ) + .route( + "/v2/{ns}/{name}/manifests/{reference}", + delete(delete_manifest_ns), + ) + .route("/v2/{ns}/{name}/blobs/{digest}", delete(delete_blob_ns)) .route("/v2/{ns}/{name}/tags/list", get(list_tags_ns)) } @@ -530,6 +537,109 @@ async fn list_tags(State(state): State>, Path(name): Path) (StatusCode::OK, Json(json!({"name": name, "tags": tags}))).into_response() } +// ============================================================================ +// Delete handlers (Docker Registry V2 spec) +// ============================================================================ + +async fn delete_manifest( + State(state): State>, + Path((name, reference)): Path<(String, String)>, +) -> Response { + if let Err(e) = validate_docker_name(&name) { + return (StatusCode::BAD_REQUEST, e.to_string()).into_response(); + } + if let Err(e) = validate_docker_reference(&reference) { + return (StatusCode::BAD_REQUEST, e.to_string()).into_response(); + } + + let key = format!("docker/{}/manifests/{}.json", name, reference); + + // If reference is a tag, also delete digest-keyed copy + let is_tag = !reference.starts_with("sha256:"); + if is_tag { + if let Ok(data) = state.storage.get(&key).await { + use sha2::Digest; + let digest = format!("sha256:{:x}", sha2::Sha256::digest(&data)); + let digest_key = format!("docker/{}/manifests/{}.json", name, digest); + let _ = state.storage.delete(&digest_key).await; + let digest_meta = format!("docker/{}/manifests/{}.meta.json", name, digest); + let _ = state.storage.delete(&digest_meta).await; + } + } + + // Delete manifest + match state.storage.delete(&key).await { + Ok(()) => { + // Delete associated metadata + let meta_key = format!("docker/{}/manifests/{}.meta.json", name, reference); + let _ = state.storage.delete(&meta_key).await; + + state.audit.log(AuditEntry::new( + "delete", + "api", + &format!("{}:{}", name, reference), + "docker", + "manifest", + )); + state.repo_index.invalidate("docker"); + tracing::info!(name = %name, reference = %reference, "Docker manifest deleted"); + StatusCode::ACCEPTED.into_response() + } + Err(crate::storage::StorageError::NotFound) => ( + StatusCode::NOT_FOUND, + Json(json!({ + "errors": [{ + "code": "MANIFEST_UNKNOWN", + "message": "manifest unknown", + "detail": { "name": name, "reference": reference } + }] + })), + ) + .into_response(), + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} + +async fn delete_blob( + State(state): State>, + Path((name, digest)): Path<(String, String)>, +) -> Response { + if let Err(e) = validate_docker_name(&name) { + return (StatusCode::BAD_REQUEST, e.to_string()).into_response(); + } + if let Err(e) = validate_digest(&digest) { + return (StatusCode::BAD_REQUEST, e.to_string()).into_response(); + } + + let key = format!("docker/{}/blobs/{}", name, digest); + match state.storage.delete(&key).await { + Ok(()) => { + state.audit.log(AuditEntry::new( + "delete", + "api", + &format!("{}@{}", name, &digest[..19.min(digest.len())]), + "docker", + "blob", + )); + state.repo_index.invalidate("docker"); + tracing::info!(name = %name, digest = %digest, "Docker blob deleted"); + StatusCode::ACCEPTED.into_response() + } + Err(crate::storage::StorageError::NotFound) => ( + StatusCode::NOT_FOUND, + Json(json!({ + "errors": [{ + "code": "BLOB_UNKNOWN", + "message": "blob unknown to registry", + "detail": { "digest": digest } + }] + })), + ) + .into_response(), + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} + // ============================================================================ // Namespace handlers (for two-segment names like library/alpine) // These combine ns/name into a single name and delegate to the main handlers @@ -599,6 +709,22 @@ async fn list_tags_ns( list_tags(state, Path(full_name)).await } +async fn delete_manifest_ns( + state: State>, + Path((ns, name, reference)): Path<(String, String, String)>, +) -> Response { + let full_name = format!("{}/{}", ns, name); + delete_manifest(state, Path((full_name, reference))).await +} + +async fn delete_blob_ns( + state: State>, + Path((ns, name, digest)): Path<(String, String, String)>, +) -> Response { + let full_name = format!("{}/{}", ns, name); + delete_blob(state, Path((full_name, digest))).await +} + /// Fetch a blob from an upstream Docker registry async fn fetch_blob_from_upstream( client: &reqwest::Client,