Add caching headers, admin routes, and configurable cache max-age support

- Introduced caching headers (`Cache-Control`, `ETag`, `Last-Modified`) for file responses to improve client-side caching.
- Added HTTP admin routes for health check and authentication validation.
- Made `cache_max_age` configurable via server configuration with a default of 3600 seconds.
- Enhanced file handler with metadata-based `Last-Modified` computation.
- Updated integration tests to cover new functionality and ensure correctness.
This commit is contained in:
Till Wegmueller 2025-12-09 20:23:00 +01:00
parent 3457b4acba
commit 81f00a0624
No known key found for this signature in database
10 changed files with 150 additions and 9 deletions

1
Cargo.lock generated
View file

@ -2109,6 +2109,7 @@ dependencies = [
"dirs",
"flate2",
"http-body-util",
"httpdate",
"hyper",
"knuffel",
"libips",

View file

@ -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"

View file

@ -28,6 +28,9 @@ pub struct ServerConfig {
pub max_connections: Option<usize>,
#[knuffel(child, unwrap(argument))]
pub reuseport: Option<bool>,
/// Default max-age for Cache-Control headers (seconds)
#[knuffel(child, unwrap(argument))]
pub cache_max_age: Option<u64>,
#[knuffel(child, unwrap(argument))]
pub tls_cert: Option<PathBuf>,
#[knuffel(child, unwrap(argument))]
@ -62,6 +65,9 @@ pub struct PublishersConfig {
pub struct AdminConfig {
#[knuffel(child, unwrap(argument))]
pub unix_socket: Option<PathBuf>,
/// If true, require Authorization on /admin/health as well
#[knuffel(child, unwrap(argument))]
pub require_auth_for_health: Option<bool>,
}
#[derive(Debug, knuffel::Decode, Clone)]

View file

@ -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<Arc<DepotRepo>>,
) -> 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<Arc<DepotRepo>>,
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()
}

View file

@ -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<Arc<DepotRepo>>,
@ -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::<std::path::PathBuf>().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())),
}
}

View file

@ -2,3 +2,4 @@ pub mod server;
pub mod routes;
pub mod handlers;
pub mod middleware;
pub mod admin;

View file

@ -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<DepotRepo>) -> 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)
}

View file

@ -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,
},

View file

@ -8,13 +8,18 @@ use std::sync::Mutex;
pub struct DepotRepo {
pub backend: Mutex<FileBackend>,
pub root: PathBuf,
pub cache_max_age: u64,
}
impl DepotRepo {
pub fn new(config: &Config) -> Result<Self> {
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<PathBuf> {
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<PathBuf> {
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
}

View file

@ -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,
};