mirror of
https://codeberg.org/Toasterson/ips.git
synced 2026-04-10 13:20:42 +00:00
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:
parent
3457b4acba
commit
81f00a0624
10 changed files with 150 additions and 9 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -2109,6 +2109,7 @@ dependencies = [
|
||||||
"dirs",
|
"dirs",
|
||||||
"flate2",
|
"flate2",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
|
"httpdate",
|
||||||
"hyper",
|
"hyper",
|
||||||
"knuffel",
|
"knuffel",
|
||||||
"libips",
|
"libips",
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ nix = { version = "0.30", features = ["signal", "process", "user", "fs"] }
|
||||||
sha1 = "0.10"
|
sha1 = "0.10"
|
||||||
chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
|
chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
|
||||||
flate2 = "1"
|
flate2 = "1"
|
||||||
|
httpdate = "1"
|
||||||
|
|
||||||
# Telemetry
|
# Telemetry
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,9 @@ pub struct ServerConfig {
|
||||||
pub max_connections: Option<usize>,
|
pub max_connections: Option<usize>,
|
||||||
#[knuffel(child, unwrap(argument))]
|
#[knuffel(child, unwrap(argument))]
|
||||||
pub reuseport: Option<bool>,
|
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))]
|
#[knuffel(child, unwrap(argument))]
|
||||||
pub tls_cert: Option<PathBuf>,
|
pub tls_cert: Option<PathBuf>,
|
||||||
#[knuffel(child, unwrap(argument))]
|
#[knuffel(child, unwrap(argument))]
|
||||||
|
|
@ -62,6 +65,9 @@ pub struct PublishersConfig {
|
||||||
pub struct AdminConfig {
|
pub struct AdminConfig {
|
||||||
#[knuffel(child, unwrap(argument))]
|
#[knuffel(child, unwrap(argument))]
|
||||||
pub unix_socket: Option<PathBuf>,
|
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)]
|
#[derive(Debug, knuffel::Decode, Clone)]
|
||||||
|
|
|
||||||
57
pkg6depotd/src/http/admin.rs
Normal file
57
pkg6depotd/src/http/admin.rs
Normal 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()
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State, Request},
|
extract::{Path, State, Request},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
|
http::header,
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tower_http::services::ServeFile;
|
use tower_http::services::ServeFile;
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
use crate::repo::DepotRepo;
|
use crate::repo::DepotRepo;
|
||||||
use crate::errors::DepotError;
|
use crate::errors::DepotError;
|
||||||
|
use std::fs;
|
||||||
|
use httpdate::fmt_http_date;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
pub async fn get_file(
|
pub async fn get_file(
|
||||||
State(repo): State<Arc<DepotRepo>>,
|
State(repo): State<Arc<DepotRepo>>,
|
||||||
|
|
@ -20,7 +24,29 @@ pub async fn get_file(
|
||||||
let result = service.oneshot(req).await;
|
let result = service.oneshot(req).await;
|
||||||
|
|
||||||
match result {
|
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())),
|
Err(e) => Err(DepotError::Server(e.to_string())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,3 +2,4 @@ pub mod server;
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
pub mod handlers;
|
pub mod handlers;
|
||||||
pub mod middleware;
|
pub mod middleware;
|
||||||
|
pub mod admin;
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,25 @@
|
||||||
use axum::{
|
use axum::{
|
||||||
routing::get,
|
routing::{get, post, head},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use crate::repo::DepotRepo;
|
use crate::repo::DepotRepo;
|
||||||
use crate::http::handlers::{versions, catalog, manifest, file, info, publisher};
|
use crate::http::handlers::{versions, catalog, manifest, file, info, publisher};
|
||||||
|
use crate::http::admin;
|
||||||
|
|
||||||
pub fn app_router(state: Arc<DepotRepo>) -> Router {
|
pub fn app_router(state: Arc<DepotRepo>) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/versions/0/", get(versions::get_versions))
|
.route("/versions/0/", get(versions::get_versions))
|
||||||
.route("/{publisher}/catalog/1/{filename}", get(catalog::get_catalog_v1))
|
.route("/{publisher}/catalog/1/{filename}", get(catalog::get_catalog_v1).head(catalog::get_catalog_v1))
|
||||||
.route("/{publisher}/manifest/0/{fmri}", get(manifest::get_manifest))
|
.route("/{publisher}/manifest/0/{fmri}", get(manifest::get_manifest).head(manifest::get_manifest))
|
||||||
.route("/{publisher}/manifest/1/{fmri}", get(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))
|
.route("/{publisher}/file/0/{algo}/{digest}", get(file::get_file).head(file::get_file))
|
||||||
.route("/{publisher}/file/1/{algo}/{digest}", get(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}/info/0/{fmri}", get(info::get_info))
|
||||||
.route("/{publisher}/publisher/0", get(publisher::get_publisher_v0))
|
.route("/{publisher}/publisher/0", get(publisher::get_publisher_v0))
|
||||||
.route("/{publisher}/publisher/1", get(publisher::get_publisher_v1))
|
.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)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ pub async fn run() -> Result<()> {
|
||||||
workers: None,
|
workers: None,
|
||||||
max_connections: None,
|
max_connections: None,
|
||||||
reuseport: None,
|
reuseport: None,
|
||||||
|
cache_max_age: Some(3600),
|
||||||
tls_cert: None,
|
tls_cert: None,
|
||||||
tls_key: None,
|
tls_key: None,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,18 @@ use std::sync::Mutex;
|
||||||
pub struct DepotRepo {
|
pub struct DepotRepo {
|
||||||
pub backend: Mutex<FileBackend>,
|
pub backend: Mutex<FileBackend>,
|
||||||
pub root: PathBuf,
|
pub root: PathBuf,
|
||||||
|
pub cache_max_age: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DepotRepo {
|
impl DepotRepo {
|
||||||
pub fn new(config: &Config) -> Result<Self> {
|
pub fn new(config: &Config) -> Result<Self> {
|
||||||
let root = config.repository.root.clone();
|
let root = config.repository.root.clone();
|
||||||
let backend = FileBackend::open(&root).map_err(DepotError::Repo)?;
|
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 {
|
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)
|
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> {
|
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)))?;
|
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)
|
backend.get_catalog_file_path(publisher, filename).map_err(DepotError::Repo)
|
||||||
|
|
@ -47,3 +71,22 @@ impl DepotRepo {
|
||||||
backend.get_info().map_err(DepotError::Repo)
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ async fn test_depot_server() {
|
||||||
workers: None,
|
workers: None,
|
||||||
max_connections: None,
|
max_connections: None,
|
||||||
reuseport: None,
|
reuseport: None,
|
||||||
|
cache_max_age: Some(3600),
|
||||||
tls_cert: None,
|
tls_cert: None,
|
||||||
tls_key: None,
|
tls_key: None,
|
||||||
},
|
},
|
||||||
|
|
@ -210,7 +211,7 @@ async fn test_ini_only_repo_serving_catalog() {
|
||||||
|
|
||||||
// Start depot server
|
// Start depot server
|
||||||
let config = Config {
|
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()) },
|
repository: RepositoryConfig { root: repo_path.clone(), mode: Some("readonly".to_string()) },
|
||||||
telemetry: None, publishers: None, admin: None, oauth2: None,
|
telemetry: None, publishers: None, admin: None, oauth2: None,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue