use axum::Json;
use axum::Router;
use axum::extract::Request;
use axum::http::{StatusCode, header};
use axum::middleware::{self, Next};
use axum::response::IntoResponse;
use axum::response::Response;
use axum::routing::get;
use std::net::SocketAddr;
use std::sync::{Arc, OnceLock};
use tower_http::trace::TraceLayer;
use crate::surfaces::SurfaceConfig;
use crate::tls::PeerClientCert;
#[cfg(feature = "kwt")]
use crate::kwt_access::KwtAccessConfig;
use crate::state::AppState;
static KWT_RATE: OnceLock<Arc<crate::rate_limit::IpRateLimiter>> = OnceLock::new();
fn kwt_rate_limiter() -> Arc<crate::rate_limit::IpRateLimiter> {
KWT_RATE
.get_or_init(|| {
Arc::new(crate::rate_limit::IpRateLimiter::from_env(
"NAUTALID_KWT_RATE_LIMIT",
120,
60,
))
})
.clone()
}
#[derive(Clone)]
pub struct RouterBuilder {
state: AppState,
public: Router<AppState>,
#[cfg(feature = "kwt")]
protected: Router<AppState>,
}
impl RouterBuilder {
#[must_use]
pub fn new(state: AppState) -> Self {
Self {
state,
public: Router::new(),
#[cfg(feature = "kwt")]
protected: Router::new(),
}
}
#[must_use]
pub fn merge_public(mut self, router: Router<AppState>) -> Self {
self.public = self.public.merge(router);
self
}
#[cfg(feature = "kwt")]
#[must_use]
pub fn merge_protected(mut self, router: Router<AppState>) -> Self {
self.protected = self.protected.merge(router);
self
}
#[must_use]
pub fn state(&self) -> &AppState {
&self.state
}
pub fn build(self, surface_config: &SurfaceConfig) -> Router {
let public = crate::surfaces::apply(self.public, &self.state, surface_config);
build_router(
self.state,
public,
#[cfg(feature = "kwt")]
self.protected,
)
}
}
pub fn router(state: AppState) -> Router {
let surfaces = crate::surfaces::SurfaceConfig::from_env();
RouterBuilder::new(state).build(&surfaces)
}
fn build_router(
state: AppState,
public_ext: Router<AppState>,
#[cfg(feature = "kwt")] protected_ext: Router<AppState>,
) -> Router {
let mut app = Router::new()
.route("/health", get(health))
.route("/ready", get(ready))
.route("/metrics", get(metrics))
.route("/tls/client-cert", get(tls_client_cert));
app = app.merge(public_ext);
#[cfg(feature = "image-trust")]
{
app = app.route(
"/.well-known/nautalid-image-trust.json",
get(well_known_attestation),
);
}
#[cfg(feature = "kwt")]
{
let kwt = state.kwt_access.clone();
let protected_core = Router::new()
.route("/status", get(protected_status))
.merge(protected_ext)
.layer(middleware::from_fn(move |req: Request, next: Next| {
let kwt = kwt.clone();
async move { image_trust_access_gate(req, next, kwt).await }
}));
app = app.nest("/v1/protected", protected_core);
}
#[cfg(feature = "filter")]
{
app = crate::filter::apply(app, state.filter.clone());
}
let (body_limit, timeout) = crate::http_limits::layers();
app.layer(middleware::from_fn(record_http_metrics))
.layer(TraceLayer::new_for_http())
.layer(timeout)
.layer(body_limit)
.layer(middleware::from_fn(crate::http_limits::cap_response_body))
.with_state(state)
}
async fn record_http_metrics(req: Request, next: Next) -> Response {
let res = next.run(req).await;
crate::metrics::record_http_status(res.status().as_u16());
res
}
async fn health() -> &'static str {
"ok"
}
#[derive(serde::Serialize)]
struct TlsClientCertResponse {
presented: bool,
chain_len: usize,
}
async fn tls_client_cert(cert: Result<PeerClientCert, ()>) -> Result<Json<TlsClientCertResponse>, StatusCode> {
let cert = cert.unwrap_or_else(|_| PeerClientCert(Arc::new(Vec::new())));
if cert.is_empty() {
return Err(StatusCode::UNAUTHORIZED);
}
Ok(Json(TlsClientCertResponse {
presented: true,
chain_len: cert.0.len(),
}))
}
#[derive(serde::Serialize)]
struct ReadyResponse {
status: &'static str,
#[cfg(feature = "kwt")]
#[serde(skip_serializing_if = "Option::is_none")]
lid: Option<LidReady>,
}
#[cfg(feature = "kwt")]
#[derive(serde::Serialize)]
struct LidReady {
host_running: bool,
data_plane_expected: bool,
}
async fn ready(
axum::extract::State(state): axum::extract::State<AppState>,
) -> Result<Json<ReadyResponse>, StatusCode> {
#[cfg(feature = "filter-control")]
if !crate::readiness::bus_ready() {
return Err(StatusCode::SERVICE_UNAVAILABLE);
}
#[cfg(feature = "kwt")]
{
let expected = crate::kwt_replay::AetherRuntime::data_plane_expected_from_env();
let host_running = state
.aether_runtime
.as_ref()
.is_some_and(|runtime| runtime.host_status().running);
if expected && !host_running {
return Err(StatusCode::SERVICE_UNAVAILABLE);
}
return Ok(Json(ReadyResponse {
status: "ok",
lid: Some(LidReady {
host_running,
data_plane_expected: expected,
}),
}));
}
#[cfg(not(feature = "kwt"))]
Ok(Json(ReadyResponse { status: "ok" }))
}
async fn metrics(
axum::extract::State(state): axum::extract::State<AppState>,
req: Request,
) -> Result<impl IntoResponse, StatusCode> {
if !crate::metrics::enabled_from_env() {
return Err(StatusCode::NOT_FOUND);
}
if !crate::admin::bearer_authorized(&req) {
return Err(StatusCode::UNAUTHORIZED);
}
let detailed = crate::admin::bearer_authorized(&req) || !crate::admin::gate_enabled();
#[cfg(feature = "filter-control")]
let bus_ready = crate::readiness::bus_ready();
#[cfg(not(feature = "filter-control"))]
let bus_ready = true;
#[cfg(feature = "kwt")]
let kwt_replay_backend = state
.kwt_access
.as_ref()
.map(|k| k.jti_store.backend_label())
.unwrap_or("none");
#[cfg(not(feature = "kwt"))]
let kwt_replay_backend = "none";
let body =
crate::metrics::render_prometheus(bus_ready, None, Some(kwt_replay_backend), detailed);
Ok((
[(
header::CONTENT_TYPE,
"text/plain; version=0.0.4; charset=utf-8",
)],
body,
))
}
#[cfg(feature = "image-trust")]
async fn well_known_attestation(
axum::extract::State(state): axum::extract::State<AppState>,
) -> impl IntoResponse {
match &state.image_trust_attestation {
Some(json) => (
[(header::CONTENT_TYPE, "application/json; charset=utf-8")],
json.to_string(),
)
.into_response(),
None => (
StatusCode::NOT_FOUND,
"Nautalid image-trust not configured (set IMAGE_TRUST_ENVELOPE to signed ECDSA envelope JSON)",
)
.into_response(),
}
}
#[cfg(feature = "kwt")]
#[derive(serde::Serialize)]
struct ProtectedStatus {
access: &'static str,
}
#[cfg(feature = "kwt")]
async fn protected_status() -> Json<ProtectedStatus> {
Json(ProtectedStatus { access: "kwt-ok" })
}
#[cfg(feature = "kwt")]
async fn image_trust_access_gate(
req: Request,
next: Next,
kwt_cfg: Option<Arc<KwtAccessConfig>>,
) -> Result<axum::response::Response, StatusCode> {
if let Some(ip) = req
.extensions()
.get::<axum::extract::ConnectInfo<SocketAddr>>()
.map(|c| c.0.ip())
{
if !kwt_rate_limiter().allow(ip) {
return Err(StatusCode::TOO_MANY_REQUESTS);
}
}
let Some(cfg) = kwt_cfg.as_ref() else {
crate::metrics::record_kwt_auth_failure();
return Err(StatusCode::UNAUTHORIZED);
};
let Some(token) = extract_kwt_credential(&req) else {
crate::metrics::record_kwt_auth_failure();
return Err(StatusCode::UNAUTHORIZED);
};
let store = cfg.jti_store.clone();
let validated = kwt::token::KwtToken::validate_with(
token,
&cfg.master_key,
cfg.audience.as_str(),
move |jti| store.check_and_record(jti),
)
.map_err(|_| {
crate::metrics::record_kwt_auth_failure();
StatusCode::UNAUTHORIZED
})?;
if !crate::kwt_access::claims_authorized(&validated.claims, cfg) {
crate::metrics::record_kwt_auth_failure();
return Err(StatusCode::FORBIDDEN);
}
crate::metrics::record_kwt_auth_success();
Ok(next.run(req).await)
}
#[cfg(feature = "kwt")]
fn extract_kwt_credential(req: &Request) -> Option<&str> {
if let Some(v) = req.headers().get("x-kwt").and_then(|h| h.to_str().ok()) {
let t = v.trim();
return (!t.is_empty()).then_some(t);
}
let auth = req.headers().get(header::AUTHORIZATION)?.to_str().ok()?;
let rest = auth
.strip_prefix("KWT ")
.or_else(|| auth.strip_prefix("kwt "))?;
let t = rest.trim();
(!t.is_empty()).then_some(t)
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::Request;
use std::sync::Mutex;
use tower::ServiceExt;
static HTTP_LIMIT_ENV_LOCK: Mutex<()> = Mutex::new(());
#[tokio::test]
async fn health_ok() {
let app = router(AppState::empty());
let res = app
.oneshot(Request::get("/health").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
}
#[tokio::test]
async fn router_includes_response_cap_middleware() {
let _guard = HTTP_LIMIT_ENV_LOCK.lock().expect("http limit env lock");
unsafe {
std::env::set_var("NAUTALID_HTTP_BODY_LIMIT_BYTES", "25");
}
let app = router(AppState::empty());
let res = app
.oneshot(Request::get("/health").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
unsafe {
std::env::remove_var("NAUTALID_HTTP_BODY_LIMIT_BYTES");
}
}
#[tokio::test]
async fn metrics_prometheus_text() {
#[cfg(feature = "filter-control")]
crate::readiness::set_bus_ready(true);
let app = router(AppState::empty());
let res = app
.oneshot(Request::get("/metrics").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap();
let text = String::from_utf8(body.to_vec()).unwrap();
assert!(text.contains("nautalid_http_requests_total"));
}
#[tokio::test]
async fn metrics_returns_401_without_bearer_when_gate_enabled() {
if !crate::admin::gate_enabled() {
return;
}
#[cfg(feature = "filter-control")]
crate::readiness::set_bus_ready(true);
let app = router(AppState::empty());
let res = app
.oneshot(Request::get("/metrics").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn metrics_returns_200_with_valid_bearer_when_gate_enabled() {
let Some(expected) = crate::security::admin_token() else {
return;
};
if !crate::admin::gate_enabled() {
return;
}
#[cfg(feature = "filter-control")]
crate::readiness::set_bus_ready(true);
let app = router(AppState::empty());
let mut req = Request::get("/metrics").body(Body::empty()).unwrap();
req.headers_mut().insert(
axum::http::header::AUTHORIZATION,
format!("Bearer {expected}").parse().expect("header"),
);
let res = app.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
}
#[tokio::test]
async fn ready_ok_json() {
#[cfg(feature = "filter-control")]
crate::readiness::set_bus_ready(true);
let app = router(AppState::empty());
let res = app
.oneshot(Request::get("/ready").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
}
#[tokio::test]
async fn tls_client_cert_empty_without_mtls() {
let app = router(AppState::empty());
let res = app
.oneshot(
Request::get("/tls/client-cert")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
}
#[cfg(feature = "kwt")]
mod kwt_tests {
use super::*;
use kwt::codec::{self, Role, Scope};
use kwt::crypto::MasterKey;
use kwt::token::KwtToken;
#[tokio::test]
async fn protected_requires_kwt_master_key() {
let app = router(AppState::empty());
let res = app
.oneshot(
Request::get("/v1/protected/status")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn protected_accepts_kwt() {
let key = MasterKey::generate().unwrap();
let mut claims = codec::new_claims("svc-test", "nautalid", 3600).unwrap();
claims.roles.push(Role::Service);
claims.scopes.push(Scope::Admin);
let token = KwtToken::issue(&claims, &key).unwrap();
let cfg = KwtAccessConfig {
master_key: key.clone(),
audience: "nautalid".into(),
jti_store: std::sync::Arc::new(crate::kwt_replay::test_jti_store()),
required_roles: vec![Role::Service],
required_scopes: Vec::new(),
};
let app = router(AppState::empty().with_kwt(cfg));
let req = Request::get("/v1/protected/status")
.header(header::AUTHORIZATION, format!("KWT {token}"))
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
}
#[tokio::test]
async fn protected_rejects_wrong_role() {
let key = MasterKey::generate().unwrap();
let mut claims = codec::new_claims("svc-viewer", "nautalid", 3600).unwrap();
claims.roles.push(Role::Viewer);
let token = KwtToken::issue(&claims, &key).unwrap();
let cfg = KwtAccessConfig {
master_key: key,
audience: "nautalid".into(),
jti_store: std::sync::Arc::new(crate::kwt_replay::test_jti_store()),
required_roles: vec![Role::Service],
required_scopes: Vec::new(),
};
let app = router(AppState::empty().with_kwt(cfg));
let req = Request::get("/v1/protected/status")
.header(header::AUTHORIZATION, format!("KWT {token}"))
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn protected_rejects_replayed_kwt() {
let key = MasterKey::generate().unwrap();
let mut claims = codec::new_claims("svc-replay", "nautalid", 3600).unwrap();
claims.roles.push(Role::Service);
let token = KwtToken::issue(&claims, &key).unwrap();
let cfg = KwtAccessConfig {
master_key: key,
audience: "nautalid".into(),
jti_store: std::sync::Arc::new(crate::kwt_replay::test_jti_store()),
required_roles: vec![Role::Service],
required_scopes: Vec::new(),
};
let app = router(AppState::empty().with_kwt(cfg));
let mk_req = || {
Request::get("/v1/protected/status")
.header(header::AUTHORIZATION, format!("KWT {token}"))
.body(Body::empty())
.unwrap()
};
let res = app.clone().oneshot(mk_req()).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let res = app.oneshot(mk_req()).await.unwrap();
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
}
}
#[tokio::test]
async fn builder_merge_public_route() {
async fn public_extra() -> &'static str {
"extra"
}
let surfaces = SurfaceConfig::from_env();
let app = RouterBuilder::new(AppState::empty())
.merge_public(Router::new().route("/extra", get(public_extra)))
.build(&surfaces);
let res = app
.oneshot(Request::get("/extra").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
}
#[cfg(feature = "filter")]
#[tokio::test]
async fn builder_applies_filter_layer() {
use crate::filter::{FilterPolicy, FilterRule, FilterStore};
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
let denied = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 99));
let policy = FilterPolicy {
deny: vec![FilterRule::Ip(denied)],
allow: Vec::new(),
};
let state = AppState::empty().with_filter(FilterStore::new(policy));
let surfaces = SurfaceConfig::from_env();
let app = RouterBuilder::new(state).build(&surfaces);
let mut req = Request::get("/health").body(Body::empty()).unwrap();
req.extensions_mut()
.insert(axum::extract::ConnectInfo(SocketAddr::new(denied, 4000)));
let res = app.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::FORBIDDEN);
}
}