Files
RustProxy/src/admin/router.rs
T
2026-10-14 11:51:37 +02:00

360 lines
14 KiB
Rust

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<String> {
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<String> = 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<String> = 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<App>) -> 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<Arc<App>>, 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<CertificateApiOperation> {
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()))
}