use std::collections::HashMap; use std::hash::{Hash, Hasher}; use std::net::SocketAddr; use std::sync::OnceLock; use admin::AdminService; use anyhow::Context; use axum::Router; use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, Uri, header}; use axum::response::IntoResponse; use config::scoped::{AdminhostConfig, ScopedConfig}; use session::{SessionLayer, store}; use tokio::net::TcpListener; use tower_http::limit::RequestBodyLimitLayer; use tower_http::trace::{DefaultMakeSpan, DefaultOnResponse, TraceLayer}; use tracing::{Level, info, warn}; const MAX_REQUEST_BODY_BYTES: usize = 2 * 1024 * 1024; const CACHE_IMMUTABLE: &str = "public, max-age=31536000, immutable"; const CACHE_NO_CACHE: &str = "no-cache"; const CACHE_REVALIDATE: &str = "public, max-age=3600, must-revalidate"; fn build_cors_layer(config: &config::admin::AdminConfig) -> tower_http::cors::CorsLayer { use axum::http::HeaderName; use tower_http::cors::{AllowOrigin, CorsLayer}; if config.cors_allow_origins.is_empty() { return CorsLayer::new(); } let origins: Vec = config .cors_allow_origins .iter() .filter_map(|origin| origin.parse().ok()) .collect(); CorsLayer::new() .allow_origin(AllowOrigin::list(origins)) .allow_credentials(config.cors_allow_credentials()) .allow_methods([ Method::GET, Method::POST, Method::PUT, Method::PATCH, Method::DELETE, Method::OPTIONS, ]) .allow_headers([ header::CONTENT_TYPE, header::AUTHORIZATION, HeaderName::from_static("x-request-id"), ]) } fn admin_session_config(cfg: &AdminhostConfig) -> config::session::SessionConfig { let mut session = cfg.session.clone(); session.cookie_name = Some(cfg.admin.session_cookie_name().to_string()); session.table_name = Some(cfg.admin.session_table_name().to_string()); session } #[tokio::main] async fn main() -> anyhow::Result<()> { export::telemetry::init_tracing(); let cfg = AdminhostConfig::load().context("loading configuration")?; if !cfg.admin.enabled() { info!("adminhost is disabled by config; exiting"); return Ok(()); } info!("connecting to database / redis / storage / email"); let state = export::AppState::with_config(cfg.clone().into_app_config()) .await .context("building AppState")?; info!("dependencies ready"); let health = export::health::router_with(Some(state.database.clone()), state.redis.clone()); let session_store = store::connect(state.redis.clone(), &admin_session_config(&cfg)) .await .context("connecting admin session store")?; let service = AdminService::new(state); match service.seed_admin_if_empty().await { Ok(Some(admin_id)) => info!(%admin_id, "initial admin account seeded"), Ok(None) => {} Err(error) => warn!(%error, "admin seed failed"), } let api = admin::routes::router(service.clone()).with_state(service); let doc = admin::openapi::api_doc(); let router: Router = Router::new() .nest("/api/v1", api) .merge(health) .merge(utoipa_swagger_ui::SwaggerUi::new("/api/docs").url("/api/docs/openapi.json", doc)) .fallback(spa_fallback) .layer(build_cors_layer(&cfg.admin)) .layer(SessionLayer::new(session_store)) .layer(RequestBodyLimitLayer::new(MAX_REQUEST_BODY_BYTES)) .layer( TraceLayer::new_for_http() .make_span_with( DefaultMakeSpan::new() .level(Level::INFO) .include_headers(false), ) .on_response(DefaultOnResponse::new().level(Level::INFO)), ) .layer(export::middleware::AppTraceLayer::new()); let bind_addr = cfg.admin.bind_addr().to_string(); let port = cfg.admin.port(); let addr: SocketAddr = format!("{bind_addr}:{port}") .parse() .with_context(|| format!("parsing bind address {bind_addr}:{port}"))?; let listener = TcpListener::bind(addr) .await .with_context(|| format!("binding {addr}"))?; info!(%addr, "adminhost listening"); axum::serve(listener, router.into_make_service()) .with_graceful_shutdown(export::telemetry::shutdown_signal()) .await .context("axum server error")?; info!("adminhost shut down"); Ok(()) } async fn spa_fallback(method: Method, uri: Uri, headers: HeaderMap) -> axum::response::Response { if !matches!(method, Method::GET | Method::HEAD) { return StatusCode::METHOD_NOT_ALLOWED.into_response(); } let path = uri.path(); if path.starts_with("/api") { return StatusCode::NOT_FOUND.into_response(); } let asset = match admin_web::file(path) { Some(asset) => asset, None => { if path.rsplit('/').next().is_some_and(|seg| seg.contains('.')) { return StatusCode::NOT_FOUND.into_response(); } admin_web::index() } }; serve_asset(asset, &headers) } fn serve_asset(asset: admin_web::Asset, headers: &HeaderMap) -> axum::response::Response { let etag = etags() .get(asset.path) .cloned() .unwrap_or_else(|| etag_of(asset.content)); let cache_control = HeaderValue::from_static(cache_control(asset.path)); if let Some(if_none_match) = headers.get(header::IF_NONE_MATCH) && etag_matches(if_none_match, &etag) { return ( StatusCode::NOT_MODIFIED, [(header::CACHE_CONTROL, cache_control), (header::ETAG, etag)], ) .into_response(); } ( [ (header::CONTENT_TYPE, HeaderValue::from_static(asset.mime)), (header::CACHE_CONTROL, cache_control), (header::ETAG, etag), ], asset.content, ) .into_response() } fn etags() -> &'static HashMap<&'static str, HeaderValue> { static ETAGS: OnceLock> = OnceLock::new(); ETAGS.get_or_init(|| { let mut map = HashMap::new(); let index = admin_web::index(); map.insert(index.path, etag_of(index.content)); for path in admin_web::paths() { if let Some(asset) = admin_web::file(path) { map.insert(asset.path, etag_of(asset.content)); } } map }) } fn etag_of(content: &[u8]) -> HeaderValue { let mut hasher = std::collections::hash_map::DefaultHasher::new(); content.hash(&mut hasher); hasher.write_u64(content.len() as u64); HeaderValue::from_maybe_shared(format!("W/\"{:016x}\"", hasher.finish()).into_bytes()) .expect("valid etag header") } fn cache_control(path: &str) -> &'static str { if path.starts_with("assets/") { CACHE_IMMUTABLE } else if path == "index.html" { CACHE_NO_CACHE } else { CACHE_REVALIDATE } } fn etag_matches(if_none_match: &HeaderValue, etag: &HeaderValue) -> bool { let Ok(value) = if_none_match.to_str() else { return false; }; let Ok(target) = etag.to_str() else { return false; }; fn strong(value: &str) -> &str { value.strip_prefix("W/").unwrap_or(value) } value .split(',') .map(str::trim) .any(|candidate| candidate == "*" || strong(candidate) == strong(target)) }