360 lines
14 KiB
Rust
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()))
|
|
}
|