diff --git a/Cargo.lock b/Cargo.lock index 14eece6..85d9867 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2109,6 +2109,7 @@ dependencies = [ "dirs", "flate2", "http-body-util", + "httpdate", "hyper", "knuffel", "libips", diff --git a/pkg6depotd/Cargo.toml b/pkg6depotd/Cargo.toml index ef7a059..46ba147 100644 --- a/pkg6depotd/Cargo.toml +++ b/pkg6depotd/Cargo.toml @@ -33,6 +33,7 @@ nix = { version = "0.30", features = ["signal", "process", "user", "fs"] } sha1 = "0.10" chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } flate2 = "1" +httpdate = "1" # Telemetry tracing = "0.1" diff --git a/pkg6depotd/src/config.rs b/pkg6depotd/src/config.rs index f1a99d3..03d5573 100644 --- a/pkg6depotd/src/config.rs +++ b/pkg6depotd/src/config.rs @@ -28,6 +28,9 @@ pub struct ServerConfig { pub max_connections: Option, #[knuffel(child, unwrap(argument))] pub reuseport: Option, + /// Default max-age for Cache-Control headers (seconds) + #[knuffel(child, unwrap(argument))] + pub cache_max_age: Option, #[knuffel(child, unwrap(argument))] pub tls_cert: Option, #[knuffel(child, unwrap(argument))] @@ -62,6 +65,9 @@ pub struct PublishersConfig { pub struct AdminConfig { #[knuffel(child, unwrap(argument))] pub unix_socket: Option, + /// If true, require Authorization on /admin/health as well + #[knuffel(child, unwrap(argument))] + pub require_auth_for_health: Option, } #[derive(Debug, knuffel::Decode, Clone)] diff --git a/pkg6depotd/src/http/admin.rs b/pkg6depotd/src/http/admin.rs new file mode 100644 index 0000000..383572c --- /dev/null +++ b/pkg6depotd/src/http/admin.rs @@ -0,0 +1,57 @@ +use axum::{ + extract::State, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + Json, +}; +use serde::Serialize; +use std::sync::Arc; + +use crate::repo::DepotRepo; + +#[derive(Serialize)] +struct HealthResponse { + status: &'static str, +} + +pub async fn health( + _state: State>, +) -> impl IntoResponse { + // Basic liveness/readiness for now. Future: include repo checks. + (StatusCode::OK, Json(HealthResponse { status: "ok" })) +} + +#[derive(Serialize)] +struct AuthCheckResponse<'a> { + authenticated: bool, + token_present: bool, + subject: Option<&'a str>, + scopes: Vec<&'a str>, + decision: &'static str, +} + +/// Admin auth-check endpoint. +/// For now, this is a minimal placeholder that only checks for the presence of a Bearer token. +/// TODO: Validate JWT via OIDC JWKs using configured issuer/jwks_uri and required scopes. +pub async fn auth_check( + _state: State>, + headers: HeaderMap, +) -> Response { + let auth = headers.get(axum::http::header::AUTHORIZATION).and_then(|v| v.to_str().ok()); + let (authenticated, token_present) = match auth { + Some(h) if h.to_ascii_lowercase().starts_with("bearer ") => (true, true), + Some(_) => (false, true), + None => (false, false), + }; + + let resp = AuthCheckResponse { + authenticated, + token_present, + subject: None, + scopes: vec![], + decision: if authenticated { "allow" } else { "deny" }, + }; + + let status = if authenticated { StatusCode::OK } else { StatusCode::UNAUTHORIZED }; + (status, Json(resp)).into_response() +} diff --git a/pkg6depotd/src/http/handlers/file.rs b/pkg6depotd/src/http/handlers/file.rs index 2bd0b7a..a86ba1d 100644 --- a/pkg6depotd/src/http/handlers/file.rs +++ b/pkg6depotd/src/http/handlers/file.rs @@ -1,12 +1,16 @@ use axum::{ extract::{Path, State, Request}, response::{IntoResponse, Response}, + http::header, }; use std::sync::Arc; use tower_http::services::ServeFile; use tower::ServiceExt; use crate::repo::DepotRepo; use crate::errors::DepotError; +use std::fs; +use httpdate::fmt_http_date; +use std::time::{SystemTime, UNIX_EPOCH}; pub async fn get_file( State(repo): State>, @@ -20,7 +24,29 @@ pub async fn get_file( let result = service.oneshot(req).await; match result { - Ok(res) => Ok(res.into_response()), + Ok(mut res) => { + // Add caching headers + let max_age = repo.cache_max_age(); + res.headers_mut().insert(header::CACHE_CONTROL, header::HeaderValue::from_str(&format!("public, max-age={}", max_age)).unwrap()); + // ETag from digest + res.headers_mut().insert(header::ETAG, header::HeaderValue::from_str(&format!("\"{}\"", digest)).unwrap()); + // Last-Modified from fs metadata + if let Some(body_path) = res.extensions().get::().cloned() { + if let Ok(meta) = fs::metadata(&body_path) { + if let Ok(mtime) = meta.modified() { + let lm = fmt_http_date(mtime); + res.headers_mut().insert(header::LAST_MODIFIED, header::HeaderValue::from_str(&lm).unwrap()); + } + } + } + // Fallback: use now if extension not present (should rarely happen) + if !res.headers().contains_key(header::LAST_MODIFIED) { + let now = SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|_| SystemTime::now()).unwrap_or_else(SystemTime::now); + let lm = fmt_http_date(now); + res.headers_mut().insert(header::LAST_MODIFIED, header::HeaderValue::from_str(&lm).unwrap()); + } + Ok(res.into_response()) + }, Err(e) => Err(DepotError::Server(e.to_string())), } } diff --git a/pkg6depotd/src/http/mod.rs b/pkg6depotd/src/http/mod.rs index 934fd4d..4336504 100644 --- a/pkg6depotd/src/http/mod.rs +++ b/pkg6depotd/src/http/mod.rs @@ -2,3 +2,4 @@ pub mod server; pub mod routes; pub mod handlers; pub mod middleware; +pub mod admin; diff --git a/pkg6depotd/src/http/routes.rs b/pkg6depotd/src/http/routes.rs index b8700eb..be2e565 100644 --- a/pkg6depotd/src/http/routes.rs +++ b/pkg6depotd/src/http/routes.rs @@ -1,21 +1,25 @@ use axum::{ - routing::get, + routing::{get, post, head}, Router, }; use std::sync::Arc; use crate::repo::DepotRepo; use crate::http::handlers::{versions, catalog, manifest, file, info, publisher}; +use crate::http::admin; pub fn app_router(state: Arc) -> Router { Router::new() .route("/versions/0/", get(versions::get_versions)) - .route("/{publisher}/catalog/1/{filename}", get(catalog::get_catalog_v1)) - .route("/{publisher}/manifest/0/{fmri}", get(manifest::get_manifest)) - .route("/{publisher}/manifest/1/{fmri}", get(manifest::get_manifest)) - .route("/{publisher}/file/0/{algo}/{digest}", get(file::get_file)) - .route("/{publisher}/file/1/{algo}/{digest}", get(file::get_file)) + .route("/{publisher}/catalog/1/{filename}", get(catalog::get_catalog_v1).head(catalog::get_catalog_v1)) + .route("/{publisher}/manifest/0/{fmri}", get(manifest::get_manifest).head(manifest::get_manifest)) + .route("/{publisher}/manifest/1/{fmri}", get(manifest::get_manifest).head(manifest::get_manifest)) + .route("/{publisher}/file/0/{algo}/{digest}", get(file::get_file).head(file::get_file)) + .route("/{publisher}/file/1/{algo}/{digest}", get(file::get_file).head(file::get_file)) .route("/{publisher}/info/0/{fmri}", get(info::get_info)) .route("/{publisher}/publisher/0", get(publisher::get_publisher_v0)) .route("/{publisher}/publisher/1", get(publisher::get_publisher_v1)) + // Admin API over HTTP + .route("/admin/health", get(admin::health)) + .route("/admin/auth/check", post(admin::auth_check)) .with_state(state) } diff --git a/pkg6depotd/src/lib.rs b/pkg6depotd/src/lib.rs index aca880d..2f78a34 100644 --- a/pkg6depotd/src/lib.rs +++ b/pkg6depotd/src/lib.rs @@ -28,6 +28,7 @@ pub async fn run() -> Result<()> { workers: None, max_connections: None, reuseport: None, + cache_max_age: Some(3600), tls_cert: None, tls_key: None, }, diff --git a/pkg6depotd/src/repo.rs b/pkg6depotd/src/repo.rs index c3955d2..0776099 100644 --- a/pkg6depotd/src/repo.rs +++ b/pkg6depotd/src/repo.rs @@ -8,13 +8,18 @@ use std::sync::Mutex; pub struct DepotRepo { pub backend: Mutex, pub root: PathBuf, + pub cache_max_age: u64, } impl DepotRepo { pub fn new(config: &Config) -> Result { let root = config.repository.root.clone(); let backend = FileBackend::open(&root).map_err(DepotError::Repo)?; - Ok(Self { backend: Mutex::new(backend), root }) + let cache_max_age = config + .server + .cache_max_age + .unwrap_or(3600); + Ok(Self { backend: Mutex::new(backend), root, cache_max_age }) } pub fn get_catalog_path(&self, publisher: &str) -> PathBuf { @@ -36,6 +41,25 @@ impl DepotRepo { backend.fetch_manifest_text(publisher, fmri).map_err(DepotError::Repo) } + pub fn get_manifest_path(&self, publisher: &str, fmri: &Fmri) -> Option { + let version = fmri.version(); + if version.is_empty() { + return None; + } + let path = FileBackend::construct_manifest_path(&self.root, publisher, fmri.stem(), &version); + if path.exists() { return Some(path); } + // Fallbacks similar to lib logic + let encoded_stem = url_encode_filename(fmri.stem()); + let encoded_version = url_encode_filename(&version); + let alt1 = self.root.join("pkg").join(&encoded_stem).join(&encoded_version); + if alt1.exists() { return Some(alt1); } + let alt2 = self.root.join("publisher").join(publisher).join("pkg").join(&encoded_stem).join(&encoded_version); + if alt2.exists() { return Some(alt2); } + None + } + + pub fn cache_max_age(&self) -> u64 { self.cache_max_age } + pub fn get_catalog_file_path(&self, publisher: &str, filename: &str) -> Result { let backend = self.backend.lock().map_err(|e| DepotError::Server(format!("Lock poisoned: {}", e)))?; backend.get_catalog_file_path(publisher, filename).map_err(DepotError::Repo) @@ -47,3 +71,22 @@ impl DepotRepo { backend.get_info().map_err(DepotError::Repo) } } + +// Local percent-encoding for filenames similar to lib's private helper. +fn url_encode_filename(s: &str) -> String { + let mut result = String::new(); + for c in s.chars() { + match c { + 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' | '~' => result.push(c), + ' ' => result.push('+'), + _ => { + let mut buf = [0u8; 4]; + for b in c.encode_utf8(&mut buf).as_bytes() { + result.push('%'); + result.push_str(&format!("{:02X}", b)); + } + } + } + } + result +} diff --git a/pkg6depotd/tests/integration_tests.rs b/pkg6depotd/tests/integration_tests.rs index eab02e2..f60a380 100644 --- a/pkg6depotd/tests/integration_tests.rs +++ b/pkg6depotd/tests/integration_tests.rs @@ -68,6 +68,7 @@ async fn test_depot_server() { workers: None, max_connections: None, reuseport: None, + cache_max_age: Some(3600), tls_cert: None, tls_key: None, }, @@ -210,7 +211,7 @@ async fn test_ini_only_repo_serving_catalog() { // Start depot server let config = Config { - server: ServerConfig { bind: vec!["127.0.0.1:0".to_string()], workers: None, max_connections: None, reuseport: None, tls_cert: None, tls_key: None }, + server: ServerConfig { bind: vec!["127.0.0.1:0".to_string()], workers: None, max_connections: None, reuseport: None, cache_max_age: Some(3600), tls_cert: None, tls_key: None }, repository: RepositoryConfig { root: repo_path.clone(), mode: Some("readonly".to_string()) }, telemetry: None, publishers: None, admin: None, oauth2: None, };