mod documentation; mod panel_assets; mod panel_content; mod public; // Embedded asset URLs change with every build, independent of Cargo package version. // Only an exact identifier from this binary is accepted; arbitrary suffixes are 404. fn asset_build_id() -> &'static str { include_str!(concat!(env!("OUT_DIR"), "/asset-build-id.txt")) } fn fingerprinted_path(path: &str, prefix: &str) -> Option { let name = path.strip_prefix(prefix)?; let (stem, extension) = name.rsplit_once('.')?; if extension != "js" && extension != "css" { return None; } let (base, token) = stem.rsplit_once('_')?; if token != asset_build_id() { return None; } Some(format!("{prefix}{base}.{extension}")) } use crate::{ auth::{self, Session}, config::Update, runtime::{ApiError, App}, server::{PeerAddr, Transport}, }; use axum::{ extract::{DefaultBodyLimit, Path, Query, Request, State}, middleware::{self, Next}, response::{ sse::{Event, KeepAlive, Sse}, IntoResponse, Response, }, routing::{delete, get, post, put}, Extension, Json, Router, }; use http::{ header::{AUTHORIZATION, HOST, ORIGIN, SET_COOKIE}, HeaderMap, HeaderValue, Method, StatusCode, Version, }; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::{ convert::Infallible, net::IpAddr, sync::{atomic::Ordering, Arc}, time::Duration, }; fn admin_app_javascript() -> &'static str { static ASSET: std::sync::LazyLock = std::sync::LazyLock::new(|| { [ include_str!("web/app/core.js"), include_str!("web/app/session-resume.js"), include_str!("web/app/refresh.js"), include_str!("web/app/rate-sampling.js"), "const ADMIN_DASHBOARD_WIDGET_REGISTRY = Object.freeze(", include_str!("web/dashboard-widgets.json"), ");", include_str!("web/app/preferences.js"), include_str!("web/app/dashboard-layout-editor.js"), include_str!("web/app/dashboard-inline-layout.js"), include_str!("web/app/stream-dashboard.js"), include_str!("web/app/backend-dashboard.js"), include_str!("web/app/overview-extra-metrics.js"), include_str!("web/app/overview-wide-metrics.js"), include_str!("web/app/overview-masonry.js"), include_str!("web/app/config-history.js"), include_str!("web/app/runtime.js"), include_str!("web/app/settings.js"), include_str!("web/management.js"), include_str!("web/drafts.js"), include_str!("web/integrations.js"), admin_vhosts_javascript(), include_str!("web/web-server-profiles.js"), include_str!("web/web-server.js"), include_str!("web/docker.js"), include_str!("web/streams.js"), include_str!("web/redirects.js"), include_str!("web/host404.js"), include_str!("web/traffic-panel.js"), include_str!("web/cache-panel.js"), include_str!("web/transform-panel.js"), include_str!("web/acme.js"), include_str!("web/settings.js"), include_str!("web/certificate-api.js"), include_str!("web/file-logs.js"), ] .join("\n") }); ASSET.as_str() } fn admin_vhosts_javascript() -> &'static str { static ASSET: std::sync::LazyLock = std::sync::LazyLock::new(|| { [ "(() => {", include_str!("web/vhosts/core.js"), include_str!("web/vhosts/protocol.js"), include_str!("web/vhosts/health.js"), include_str!("web/vhosts/rates.js"), include_str!("web/vhosts/editor.js"), include_str!("web/vhosts/render.js"), "})();", ] .join("\n") }); ASSET.as_str() } pub fn router(state: Arc) -> Router { let authentication = Router::new() .route("/api/auth/status", get(auth_status)) .route("/api/auth/setup", post(setup)) .route("/api/auth/login", post(login)) .route("/api/auth/session", get(session)) .route("/api/auth/logout", post(logout)) .route("/api/auth/logout-all", post(logout_all)) .route("/api/auth/password", post(password)) .layer(DefaultBodyLimit::max(4096)); Router::new() .merge(authentication) .route("/", get(public::index)) .route("/overview", get(public::index)) .route("/vhosts", get(public::index)) .route("/web-server", get(public::index)) .route("/docker-vhosts", get(public::index)) .route("/redirects", get(public::index)) .route("/404-hosts", get(public::index)) .route("/streams", get(public::index)) .route("/acl-profiles", get(public::index)) .route("/configuration", get(public::index)) .route("/tls", get(public::index)) .route("/traffic", get(public::index)) .route("/cache", get(public::index)) .route("/transform", get(public::index)) .route("/logging", get(public::index)) .route("/logs", get(public::index)) .route("/settings", get(public::index)) .route("/assets/{fingerprinted}", get(panel_assets::serve)) .route("/assets/login-langs.json", get(panel_assets::serve)) .route("/docs", get(documentation::index)) .route("/docs/", get(documentation::index)) .route("/docs/openapi.json", get(documentation::schema)) .route("/docs/content.json", get(documentation::content)) .route("/docs/{fingerprinted}", get(documentation::fingerprinted)) .route( "/docs/licenses.txt", get(|| async { documentation::asset("licenses.txt") }), ) .route("/assets/langs.json", get(panel_assets::serve)) .route("/favicon.svg", get(panel_assets::serve)) .route("/healthz", get(|| async { Json(json!({"status":"ok"})) })) .route("/api/status", get(status)) .route("/api/system-info", get(system_info)) .route("/api/backends", get(backend_status)) .route("/api/docker", get(docker_status)) .route("/api/docker/refresh", post(docker_refresh)) .route("/api/certificates", get(certificate_inventory)) .route( "/api/basic-auth/hash", post(basic_hash).layer(DefaultBodyLimit::max(4096)), ) .route("/api/acme", get(acme_status)) .route( "/api/acme/renew", post(acme_renew).layer(DefaultBodyLimit::max(4096)), ) .route( "/api/acme/issue", post(acme_issue).layer(DefaultBodyLimit::max(16384)), ) .route( "/api/certificates/upload", post(certificate_upload).layer(DefaultBodyLimit::max(300 * 1024)), ) .route( "/api/certificates/deploy", post(certificate_deploy).layer(DefaultBodyLimit::max(300 * 1024)), ) .route( "/api/certificates/reload", post(certificate_reload).layer(DefaultBodyLimit::max(4096)), ) .route( "/api/certificates/{certificate_id}/reload", post(certificate_reload_one).layer(DefaultBodyLimit::max(4096)), ) .route( "/api/certificate-api", get(certificate_api_status) .put(certificate_api_rotate) .delete(certificate_api_revoke_all) .layer(DefaultBodyLimit::max(4096)), ) .route( "/api/certificate-api/{key_id}", get(certificate_api_detail) .patch(certificate_api_edit) .delete(certificate_api_revoke) .layer(DefaultBodyLimit::max(4096)), ) .route( "/api/certificate-api/{key_id}/regenerate", post(certificate_api_regenerate).layer(DefaultBodyLimit::max(4096)), ) .route("/api/traffic", get(traffic_status)) .route("/api/cache", get(cache_status)) .route("/api/transform", get(transform_status)) .route("/api/geoip/update", post(geoip_update)) .route("/api/cache/purge", post(cache_purge)) .route( "/api/cache/invalidate", post(cache_invalidate).layer(DefaultBodyLimit::max(16 * 1024)), ) .route("/api/cache/bans", get(cache_bans)) .route("/api/cache/bans/{id}", delete(cache_ban_delete)) .route("/api/tunnels", get(active_tunnels)) .route( "/api/tunnels/{id}/kill", post(kill_tunnel).layer(DefaultBodyLimit::max(4096)), ) .route( "/api/cache/cold/cleanup", post(cache_cold_cleanup).layer(DefaultBodyLimit::max(4096)), ) .route("/api/cache/cold/cleanup/{id}", get(cache_cold_cleanup_job)) .route("/api/config", get(configuration).merge(put(update))) .route("/api/config/history", get(config_history)) .route( "/api/config/history/restore", post(restore_config_history).layer(DefaultBodyLimit::max(4096)), ) .route("/api/config/{section}", put(update_section)) .route("/api/logs", get(logs)) .route("/api/live-logs", get(live_logs_stream)) .route("/api/file-logs", get(file_logs)) .route("/api/file-logs/query", get(file_logs_query)) .route("/api/preferences", get(preferences).put(update_preferences)) .route("/api/audit", get(audit)) .route("/api/events", get(events)) .route("/metrics", get(metrics)) .route("/api/metrics", get(metrics)) .fallback(|| async { reject(StatusCode::NOT_FOUND, "not_found") }) .layer(DefaultBodyLimit::max( crate::config::MAX_CONFIG_BYTES + 1024, )) .layer(middleware::from_fn_with_state(state.clone(), guard)) .layer(middleware::from_fn_with_state(state.clone(), observe_admin)) .with_state(state) } // Separate from proxy exchanges: measure only time to admin response headers, // including access denials. SSE stream lifetime is intentionally not included. async fn observe_admin(State(state): State>, request: Request, next: Next) -> Response { use std::sync::atomic::Ordering; state.metrics.admin_requests.fetch_add(1, Ordering::Relaxed); let started = std::time::Instant::now(); let response = next.run(request).await; let class = (response.status().as_u16() as usize / 100).clamp(1, 5) - 1; state.metrics.admin_status[class].fetch_add(1, Ordering::Relaxed); state.metrics.admin_header_duration_us.fetch_add( started.elapsed().as_micros().min(u64::MAX as u128) as u64, Ordering::Relaxed, ); state .metrics .admin_response_headers .fetch_add(1, Ordering::Relaxed); response } fn reject(status: StatusCode, message: &str) -> Response { (status, Json(json!({"error":message}))).into_response() } fn secure(mut response: Response) -> Response { let h = response.headers_mut(); h.insert( "cache-control", HeaderValue::from_static("private, no-store, no-cache, no-transform"), ); h.insert( "x-content-type-options", HeaderValue::from_static("nosniff"), ); h.insert("referrer-policy", HeaderValue::from_static("no-referrer")); h.insert("x-frame-options", HeaderValue::from_static("DENY")); h.insert("content-security-policy", HeaderValue::from_static("default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")); h.insert( "permissions-policy", HeaderValue::from_static("camera=(), microphone=(), geolocation=()"), ); // Do not let downstream middleware add implementation/version fingerprints. h.remove("server"); h.remove("x-powered-by"); response } // This is a method-and-path allowlist, NOT a path prefix. Everything else, // including unknown routes, schema, metrics, assets and method mismatches, // requires an active administrator session before extractors read a body. fn panel_document_path(path: &str) -> bool { matches!( path, "/" | "/overview" | "/vhosts" | "/web-server" | "/docker-vhosts" | "/redirects" | "/404-hosts" | "/streams" | "/acl-profiles" | "/configuration" | "/tls" | "/traffic" | "/cache" | "/transform" | "/logging" | "/settings" ) } fn anonymous_allowed(method: &Method, path: &str) -> bool { if method == Method::GET || method == Method::HEAD { panel_document_path(path) || fingerprinted_path(path, "/assets/").is_some_and(|base| { matches!(base.as_str(), "/assets/login.js" | "/assets/style.css") }) || matches!( path, "/assets/login-langs.json" | "/favicon.svg" | "/healthz" | "/api/auth/status" ) } else { method == Method::POST && matches!(path, "/api/auth/setup" | "/api/auth/login") } } const CERTIFICATE_API_KEY_HEADER: &str = "x-rustproxy-certificate-key"; #[derive(Clone, Debug)] enum CertificateApiOperation { Deploy, ReloadAll, ReloadOne(String), } fn certificate_api_operation(method: &Method, path: &str) -> Option { if method != Method::POST { return None; } if path == "/api/certificates/deploy" { return Some(CertificateApiOperation::Deploy); } if path == "/api/certificates/reload" { return Some(CertificateApiOperation::ReloadAll); } path.strip_prefix("/api/certificates/") .and_then(|tail| tail.strip_suffix("/reload")) .filter(|certificate_id| crate::config::valid_id(certificate_id)) .map(|certificate_id| CertificateApiOperation::ReloadOne(certificate_id.to_owned())) }