Ctrl K
ComunidadGuías…

src/api/mod.rs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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<AdminState>) -> 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/<hash>/` 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 `<script>` tag
/// that loaded the entry point was not.
fn build_widget_routes(static_dir: &str, cors: CorsLayer) -> Router {
    let Some(bundle) = assets::WidgetBundle::load(static_dir) else {
        // No verifiable bundle. Answer the entry point with a 503 rather than
        // a 404: the URL is right, the server just can't serve it, and an
        // operator debugging a bad STATIC_DIR should be able to tell those
        // apart. `/static/` keeps working, so embeds on the legacy path are
        // unaffected.
        return Router::new().route("/v1/widget.js", get(assets::unavailable));
    };

    let mount = bundle.mount_path();
    tracing::info!(
        version = %bundle.version,
        mount = %mount,
        "widget bundle mounted"
    );

    let bundle = Arc::new(bundle);
    Router::new()
        .route(
            "/v1/widget.js",
            get(move || {
                let bundle = bundle.clone();
                async move { bundle.respond() }
            }),
        )
        .nest_service(
            &mount,
            ServiceBuilder::new()
                .layer(SetResponseHeaderLayer::if_not_present(
                    header::CACHE_CONTROL,
                    HeaderValue::from_static(assets::IMMUTABLE_CACHE_CONTROL),
                ))
                .service(ServeDir::new(static_dir)),
        )
        .layer(cors)
}

/// Deadline for a single request once it reaches the service: body read plus
/// handler execution. Generous enough for a queued Argon2id verification behind
/// a full permit set, short enough that a stalled client can't pin resources.
const REQUEST_TIMEOUT_SECS: u64 = 15;

/// Deadline for streaming in a request body.
const BODY_READ_TIMEOUT_SECS: u64 = 10;

/// Body cap for `/v1/verify`. A widget token plus a behaviour blob is a few
/// hundred bytes; this leaves an order of magnitude of headroom.
const VERIFY_BODY_LIMIT_BYTES: usize = 8 * 1024;

async fn healthz() -> (StatusCode, &'static str) {
    (StatusCode::OK, "ok")
}

async fn landing(path: String) -> Response {
    match tokio::fs::read_to_string(&path).await {
        Ok(body) => Html(body).into_response(),
        Err(_) => (StatusCode::NOT_FOUND, "landing page not found").into_response(),
    }
}

/// Build the CORS layer for the public puzzle endpoint and the widget assets.
///
/// Always wildcard. The service is cookie-free, so these requests are
/// non-credentialed and `Access-Control-Allow-Origin: *` is valid; the puzzle
/// body is a public PoW challenge and the assets are public scripts, so there
/// is nothing an origin restriction here would protect.
///
/// Which origins may use a *site key* is the per-site `allowed_origins` list,
/// enforced in `get_puzzle` with a 403. Keeping CORS open is what lets the
/// widget read that 403 and tell the integrator what to fix. A process-wide
/// allowlist (`CORS_ALLOWED_ORIGINS`) used to sit in front of the per-site
/// one; an origin missing from *that* list saw only an opaque network error
/// while the server logged a healthy 200, which is how three integrators lost
/// the widget for a week without a single server-side signal.
fn build_cors_layer() -> CorsLayer {
    CorsLayer::new()
        .allow_origin(AllowOrigin::any())
        .allow_methods([Method::GET, Method::OPTIONS])
        .allow_headers([header::CONTENT_TYPE])
}