pub mod assets; pub mod extract; pub mod handlers; pub mod state; pub mod types; use std::sync::Arc; use std::time::Duration; use axum::Router; use axum::extract::DefaultBodyLimit; use axum::http::HeaderValue; use axum::http::Method; use axum::http::StatusCode; use axum::http::header; use axum::response::{Html, IntoResponse, Response}; use axum::routing::{get, post}; use tower::ServiceBuilder; use tower_http::cors::{AllowOrigin, CorsLayer}; use tower_http::services::ServeDir; use tower_http::set_header::SetResponseHeaderLayer; use tower_http::timeout::{RequestBodyTimeoutLayer, TimeoutLayer}; use tower_http::trace::TraceLayer; use crate::dashboard::routes::AdminState; use state::SharedState; pub fn router(state: SharedState, admin: Option) -> Router { let cors = build_cors_layer(); let static_dir = state.config.static_dir.clone(); let landing_path = format!("{static_dir}/landing.html"); let bundle = build_widget_routes(&static_dir, cors.clone()); // Public CORS-enabled surface: just the puzzle endpoint. Browser widgets // hosted on a different origin from the captcha service need to fetch it. let public = Router::new() .route("/v1/puzzle", get(handlers::get_puzzle)) .with_state(state.clone()) .layer(cors.clone()); // Server-to-server / provisioning surface: NO CORS. Browsers can't // reach these from a different origin (same-origin policy blocks the // response), which is what we want for `/v1/verify` (site secret) and // `/v1/sites` (admin token). let internal = Router::new() .route( "/v1/verify", post(handlers::verify) // A legitimate verify body is a hex token plus a small behaviour // blob — a few hundred bytes. Axum's 2 MB default let an // unauthenticated caller force a 2 MB read plus a ~1 MB // `hex::decode` allocation per request before the bearer check // ever runs; 8 KiB is generous for the real payload. .layer(DefaultBodyLimit::max(VERIFY_BODY_LIMIT_BYTES)), ) // Same credential as `/v1/verify`, same no-CORS surface: only the // backend that verified a submission knows what became of it. .route("/v1/feedback", post(handlers::feedback)) .route("/v1/sites", post(handlers::create_site)) // Outage attestation lives on the main router, not the dashboard's: // failover must work without ADMIN_DB_PATH. This is how an operator // (or the external monitor) records an outage the process could not // self-observe — it was healthy the whole time while TLS, DNS, or the // reverse proxy in front of it made the widget unreachable. .route( "/v1/admin/outages", post(handlers::declare_outage).get(handlers::list_outages), ) .with_state(state); let mut app = Router::new() // Neutral instance page at `/`. Static file read on each request — // tiny overhead, lets operators edit `static/landing.html` without // recompiling. Failing to read falls back to a redirect to `/static/`. .route("/", get(move || landing(landing_path.clone()))) // Liveness probe for load balancers / orchestrators. No auth, no // state read — returns immediately. We deliberately don't expose // dependency health (SQLite, etc.) here: a degraded backend // shouldn't pull the pod out of rotation, and the dashboard already // surfaces store errors via tracing. .route("/healthz", get(healthz)) .merge(public) .merge(internal) .merge(bundle) // Legacy unversioned assets. This is the embed path integrators used // before `/v1/widget.js` existed, and the only path that serves the // operator-facing pages (`admin.html`, `testsite.html`, the info // pages), so it stays mounted indefinitely. It gets the entry point's // short TTL rather than the immutable one — nothing here is // content-addressed, so a long TTL would be the very staleness the // hashed bundle exists to avoid. .nest_service( "/static", ServiceBuilder::new() .layer(cors) .layer(SetResponseHeaderLayer::if_not_present( header::CACHE_CONTROL, HeaderValue::from_static(assets::LEGACY_CACHE_CONTROL), )) .service(ServeDir::new(&static_dir)), ); if let Some(admin) = admin { // Admin routes are bearer-protected and not CORS-enabled either. app = app.merge(crate::dashboard::routes::router(admin)); } // Outermost layers, applied to every route. // // Neither hyper nor axum imposes any deadline by default, so before this a // request that never finished was held open indefinitely — one task, one fd // and one read buffer per connection, for free, until the process ran out of // descriptors. // // Scope worth being precise about: these bound the time from when the // request reaches the service (body read + handler). They do NOT bound a // client that dribbles out its *headers*, because hyper parses those before // the tower stack is entered. Classic slowloris therefore still has to be // handled at the reverse proxy — see the deployment notes. app.layer(TimeoutLayer::with_status_code( StatusCode::REQUEST_TIMEOUT, Duration::from_secs(REQUEST_TIMEOUT_SECS), )) .layer(RequestBodyTimeoutLayer::new(Duration::from_secs( BODY_READ_TIMEOUT_SECS, ))) .layer(TraceLayer::new_for_http()) } /// Mount the versioned widget bundle: the stable `/v1/widget.js` entry point /// plus the immutable `/assets//` directory it points at. /// /// CORS is applied at the router level so it covers both the entry point and /// the nested asset service — the widget `fetch()`es its worker cross-origin /// (to blob-wrap it), which is a CORS request even though the `