feat: configurable body limit + Docker delete API

- Add body_limit_mb to ServerConfig (default 2048MB, env NORA_BODY_LIMIT_MB)
- Replace hardcoded 100MB DefaultBodyLimit with config value
- Add DELETE /v2/{name}/manifests/{reference} endpoint (Docker Registry V2 spec)
- Add DELETE /v2/{name}/blobs/{digest} endpoint
- Add namespace-qualified variants for both DELETE endpoints
- Return 202 Accepted on success, 404 with MANIFEST_UNKNOWN/BLOB_UNKNOWN errors
- Audit log integration for delete operations

Fixes: 413 Payload Too Large on Docker push >100MB
This commit is contained in:
2026-03-03 22:25:41 +00:00
parent 8da4c4278a
commit 8278297b4a
3 changed files with 144 additions and 2 deletions

View File

@@ -36,6 +36,13 @@ pub struct ServerConfig {
/// Public URL for generating pull commands (e.g., "registry.example.com") /// Public URL for generating pull commands (e.g., "registry.example.com")
#[serde(default)] #[serde(default)]
pub public_url: Option<String>, pub public_url: Option<String>,
/// 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)] #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
@@ -330,6 +337,11 @@ impl Config {
if let Ok(val) = env::var("NORA_PUBLIC_URL") { if let Ok(val) = env::var("NORA_PUBLIC_URL") {
self.server.public_url = if val.is_empty() { None } else { Some(val) }; 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 // Storage config
if let Ok(val) = env::var("NORA_STORAGE_MODE") { if let Ok(val) = env::var("NORA_STORAGE_MODE") {
@@ -483,6 +495,7 @@ impl Default for Config {
host: String::from("127.0.0.1"), host: String::from("127.0.0.1"),
port: 4000, port: 4000,
public_url: None, public_url: None,
body_limit_mb: 2048,
}, },
storage: StorageConfig { storage: StorageConfig {
mode: StorageMode::Local, mode: StorageMode::Local,

View File

@@ -347,7 +347,9 @@ async fn run_server(config: Config, storage: Storage) {
let app = Router::new() let app = Router::new()
.merge(public_routes) .merge(public_routes)
.merge(app_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(request_id::request_id_middleware))
.layer(middleware::from_fn(metrics::metrics_middleware)) .layer(middleware::from_fn(metrics::metrics_middleware))
.layer(middleware::from_fn_with_state( .layer(middleware::from_fn_with_state(
@@ -366,6 +368,7 @@ async fn run_server(config: Config, storage: Storage) {
version = env!("CARGO_PKG_VERSION"), version = env!("CARGO_PKG_VERSION"),
storage = state.storage.backend_name(), storage = state.storage.backend_name(),
auth_enabled = state.auth.is_some(), auth_enabled = state.auth.is_some(),
body_limit_mb = state.config.server.body_limit_mb,
"Nora started" "Nora started"
); );

View File

@@ -12,7 +12,7 @@ use axum::{
extract::{Path, State}, extract::{Path, State},
http::{header, HeaderName, StatusCode}, http::{header, HeaderName, StatusCode},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
routing::{get, head, patch, put}, routing::{delete, get, head, patch, put},
Json, Router, Json, Router,
}; };
use parking_lot::RwLock; use parking_lot::RwLock;
@@ -65,6 +65,8 @@ pub fn routes() -> Router<Arc<AppState>> {
) )
.route("/v2/{name}/manifests/{reference}", get(get_manifest)) .route("/v2/{name}/manifests/{reference}", get(get_manifest))
.route("/v2/{name}/manifests/{reference}", put(put_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)) .route("/v2/{name}/tags/list", get(list_tags))
// Two-segment name routes (e.g., /v2/library/alpine/...) // Two-segment name routes (e.g., /v2/library/alpine/...)
.route("/v2/{ns}/{name}/blobs/{digest}", head(check_blob_ns)) .route("/v2/{ns}/{name}/blobs/{digest}", head(check_blob_ns))
@@ -85,6 +87,11 @@ pub fn routes() -> Router<Arc<AppState>> {
"/v2/{ns}/{name}/manifests/{reference}", "/v2/{ns}/{name}/manifests/{reference}",
put(put_manifest_ns), 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)) .route("/v2/{ns}/{name}/tags/list", get(list_tags_ns))
} }
@@ -530,6 +537,109 @@ async fn list_tags(State(state): State<Arc<AppState>>, Path(name): Path<String>)
(StatusCode::OK, Json(json!({"name": name, "tags": tags}))).into_response() (StatusCode::OK, Json(json!({"name": name, "tags": tags}))).into_response()
} }
// ============================================================================
// Delete handlers (Docker Registry V2 spec)
// ============================================================================
async fn delete_manifest(
State(state): State<Arc<AppState>>,
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<Arc<AppState>>,
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) // Namespace handlers (for two-segment names like library/alpine)
// These combine ns/name into a single name and delegate to the main handlers // 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 list_tags(state, Path(full_name)).await
} }
async fn delete_manifest_ns(
state: State<Arc<AppState>>,
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<Arc<AppState>>,
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 /// Fetch a blob from an upstream Docker registry
async fn fetch_blob_from_upstream( async fn fetch_blob_from_upstream(
client: &reqwest::Client, client: &reqwest::Client,