Files
RustProxy/src/admin/status.rs
T
2026-10-08 09:42:10 +02:00

456 lines
18 KiB
Rust

fn status_payload(state: &App) -> Value {
let snapshot = state.config.load_full();
let listeners = json!({
"default_http":state.startup_listeners.0.to_string(),
"default_https":state.startup_listeners.1.map(|v| v.to_string()),
"locked_http":state.startup_locks.http.map(|v| v.to_string()),
"locked_https":state.startup_locks.https.map(|v| v.to_string()),
"active_endpoints":state.active_listeners,
"desired_endpoints":snapshot.listeners,
"named_endpoints":snapshot.envelope.config.listeners.endpoints,
"restart_required":snapshot.listeners != state.active_listeners
});
let mut features = serde_json::Map::new();
for name in [
"http1",
"streaming",
"hot_reload",
"sqlite",
"admin_accounts",
"acl",
"headers",
"live_sse",
"http2",
"tls",
"websocket",
"dns_backends",
"https_backends",
"cache",
"compression",
"system_diagnostics",
"language_packs",
"vhosts",
"redirect_hosts",
"not_found_hosts",
"ip_bindings",
"backend_pools",
"health_checks",
"sticky_sessions",
"security_headers",
"http3",
"tinyauth",
"trusted_forwarding",
"named_listeners",
"stream_proxy",
"certificate_library",
"vhost_server_header",
"diagnostic_header_controls",
"cache_dashboard",
"transform_dashboard",
"cache_reasons",
"acme_http01",
"acme_cloudflare",
"acme_ovh",
"prometheus_exporter",
"vhosts_only",
"acme_rfc2136",
"certificate_upload",
"vhost_cache",
"selective_purge",
"custom_pages",
"rate_limit",
"country_access",
"method_policy",
"location_access_policy",
"configuration_history",
"cloudflare_client_ip",
"server_timing",
"static_cookie_cache",
"per_vhost_http3",
"structured_log_sinks",
"admin_https",
"tls_policy",
"cache_resident_age",
] {
features.insert(name.into(), Value::Bool(true));
}
features.insert(
"tls_enabled".into(),
Value::Bool(state.active_listeners.iter().any(|l| l.tls)),
);
features.insert("docker".into(), Value::Bool(true));
let mut payload = json!({"version":env!("CARGO_PKG_VERSION"), "project":"RustProxy", "mode":"standalone",
"auth":"argon2id_cookie_session", "uptime_seconds":state.started.elapsed().as_secs(),
"revision":snapshot.envelope.revision, "proxy_listen":state.policy.proxy_addr.to_string(),
"admin_listen":state.policy.admin_addr.to_string(), "tls_listen":state.policy.tls_addr.map(|v| v.to_string()),
"certificate_bindings":snapshot.envelope.config.tls.certificates.len(),
"vhosts":snapshot.envelope.config.vhosts.len(),
"redirect_hosts":snapshot.envelope.config.redirects.len(),
"not_found_hosts":snapshot.envelope.config.not_found_hosts.len()});
payload["listeners"] = listeners;
let stream_metrics = state
.active_streams
.iter()
.filter_map(|stream| {
state
.stream_metrics
.get(&stream.id)
.map(|metrics| (stream.id.clone(), metrics.view()))
})
.collect::<std::collections::BTreeMap<_, _>>();
payload["streams"] = json!({
"active": state.active_streams,
"desired": snapshot.envelope.config.streams,
"restart_required": snapshot.envelope.config.streams != state.active_streams,
"metrics": stream_metrics
});
payload["prometheus"] = json!({"active": state.active_prometheus,
"desired": snapshot.envelope.config.prometheus,
"restart_required": snapshot.envelope.config.prometheus.restart_differs(&state.active_prometheus)});
let mut desired_admin = snapshot.envelope.config.administration.clone();
desired_admin.listen = Some(desired_admin.resolved_listen(state.policy.admin_addr));
if let Some(id) = desired_admin.certificate_id.as_deref() {
if let Some(certificate) = snapshot
.envelope
.config
.tls
.certificates
.iter()
.find(|certificate| certificate.id == id)
{
desired_admin
.allowed_hosts
.extend(certificate.hosts.clone());
desired_admin.allowed_hosts.sort();
desired_admin.allowed_hosts.dedup();
}
}
let desired_admin_certificate_files = desired_admin
.certificate_id
.as_deref()
.and_then(|id| {
snapshot
.envelope
.config
.tls
.certificates
.iter()
.find(|certificate| certificate.id == id)
})
.map(|certificate| (certificate.cert_file.clone(), certificate.key_file.clone()));
let administration_restart_required = desired_admin
.restart_differs(&state.active_administration)
|| (state.active_administration.tls
&& snapshot.envelope.config.tls.policy != state.active_administration_tls_policy)
|| (state.active_administration.tls
&& desired_admin_certificate_files != state.active_administration_certificate_files);
payload["administration"] = json!({
"active": state.active_administration,
"desired": desired_admin,
"restart_required": administration_restart_required,
"listen_locked": state.startup_locks.administration.is_some(),
"locked_listen": state.startup_locks.administration.map(|value| value.to_string())
});
payload["features"] = Value::Object(features);
let cache_view = state.cache.view();
let transform_resources = crate::transform::resource_usage(&snapshot.envelope.config.transform);
payload["cache"] = json!(&cache_view);
payload["transform_resources"] = json!(&transform_resources);
payload["geoip"] = json!(state.geoip.load().view());
let system = state.diagnostics.view();
let cache_ram_bytes =
(cache_view.hot_allocated_bytes as u64).saturating_add(cache_view.transient_bytes);
let non_cache_rss_bytes = system
.process_rss_bytes
.map(|rss| rss.saturating_sub(cache_ram_bytes));
payload["memory_accounting"] = json!({
"process_rss_bytes": system.process_rss_bytes,
"cache_hot_bytes": cache_view.hot_allocated_bytes,
"cache_hot_limit_bytes": cache_view.hot_limit_bytes,
"cache_transient_bytes": cache_view.transient_bytes,
"cache_transient_limit_bytes": cache_view.transient_limit_bytes,
"cache_total_bytes": cache_ram_bytes,
"transform_reserved_bytes": transform_resources.memory_reserved_bytes,
"transform_budget_bytes": transform_resources.memory_budget_bytes,
"transform_inflight": transform_resources.inflight,
"transform_max_inflight": transform_resources.max_inflight,
"non_cache_rss_bytes": non_cache_rss_bytes,
"non_cache_rss_includes_transform": true,
"method": "rss_minus_accounted_cache; transform reservation is an overlapping estimate"
});
payload["runtime"] = json!({
"platform": system.platform,
"statistics_source": system.source,
"data_directory": state.policy.data_dir.as_ref().map(|path| path.display().to_string()),
"database_path": state.policy.data_dir.as_ref().map(|path| path.join("config.sqlite3").display().to_string()),
"certificate_runtime": state.acme.runtime_view()
});
payload["system"] = json!(system);
payload["backend_pools"] = json!(snapshot.pools.iter().map(|p| p.view()).collect::<Vec<_>>());
payload["vhost_metrics"] = json!(snapshot
.vhost_metrics
.iter()
.map(|(id, metrics)| (id.clone(), metrics.view()))
.collect::<std::collections::BTreeMap<_, _>>());
let mut backend_pool_domains = std::collections::BTreeMap::new();
for agent in &snapshot.envelope.config.docker_agents.agents {
for assignment in agent
.assignments
.iter()
.filter(|assignment| assignment.enabled)
{
if let Ok(hosts) = crate::docker::assignment_hosts(assignment, &agent.base_domain) {
if let Some(host) = hosts.first() {
backend_pool_domains.insert(
crate::docker::generated_id(&agent.id, &assignment.service_key),
host.clone(),
);
}
}
}
}
payload["backend_pool_domains"] = json!(backend_pool_domains);
payload["backends"] = json!(snapshot
.envelope
.config
.vhosts
.iter()
.map(|h| h.backends.len())
.sum::<usize>());
payload["routes"] = json!(snapshot
.envelope
.config
.vhosts
.iter()
.map(|h| h.routes.len())
.sum::<usize>());
payload["enabled_routes"] = json!(snapshot
.envelope
.config
.vhosts
.iter()
.filter(|h| h.enabled)
.map(|h| h.routes.iter().filter(|r| r.enabled).count())
.sum::<usize>());
payload["metrics"] = json!(state.metrics.view());
payload["transform_metrics"] = json!(state.transform_metrics.total());
payload["logs_dropped"] = json!(state.logs.dropped.load(Ordering::Relaxed));
payload["software_update"] = json!(state.software_update.snapshot());
payload["logging"] = json!({
"settings": &snapshot.envelope.config.logging,
"sinks": state.logs.stats(),
"error_file": crate::logging::error_log_stats(),
});
payload["tls"] = json!({
"policy": &snapshot.envelope.config.tls.policy,
"cipher_suites": crate::tls::cipher_suite_names(),
"effective_cipher_suites": snapshot.envelope.config.tls.policy.effective_cipher_suites()
});
payload
}
async fn status(State(state): State<Arc<App>>) -> Json<Value> {
Json(status_payload(&state))
}
async fn backend_status(State(state): State<Arc<App>>) -> Json<Value> {
let snapshot = state.config.load_full();
Json(
json!({"pools":snapshot.pools.iter().map(|p| p.view()).collect::<Vec<_>>(),
"active_frontends":state.active_listeners, "desired_frontends":snapshot.listeners}),
)
}
fn docker_runtime_views(state: &Arc<App>) -> (Vec<Value>, Vec<Value>) {
let snapshot = state.config.load_full();
let mut routes = Vec::new();
let mut ids = std::collections::HashSet::new();
for agent in &snapshot.envelope.config.docker_agents.agents {
for assignment in &agent.assignments {
let pool_id = crate::docker::generated_id(&agent.id, &assignment.service_key);
ids.insert(pool_id.clone());
routes.push(json!({"agent_id": &agent.id, "service_key": &assignment.service_key, "pool_id": pool_id}));
}
}
let pools = snapshot
.pools
.iter()
.filter(|pool| ids.contains(&pool.id))
.map(|pool| pool.view())
.collect();
(pools, routes)
}
async fn docker_status(State(state): State<Arc<App>>) -> Json<Value> {
let (pools, pool_routes) = docker_runtime_views(&state);
Json(
json!({"observations": state.docker.view().await, "pools": pools, "pool_routes": pool_routes}),
)
}
async fn docker_refresh(State(state): State<Arc<App>>) -> Result<Json<Value>, ApiError> {
let observations = state.refresh_docker().await?;
let (pools, pool_routes) = docker_runtime_views(&state);
Ok(Json(
json!({"observations": observations, "pools": pools, "pool_routes": pool_routes}),
))
}
async fn certificate_inventory(State(state): State<Arc<App>>) -> Json<Value> {
let snapshot = state.config.load_full();
let config = &snapshot.envelope.config;
let configured_certificates = config.tls.certificates.clone();
let certificate_directory = state.policy.cert_dir.clone();
// Inspect only public certificate files on a blocking worker; never read keys.
// Live PEM coverage wins so in-place renewals are reflected immediately; stored
// metadata is only a fallback when the public certificate file cannot be read.
let inspected_metadata = tokio::task::spawn_blocking(move || {
configured_certificates
.into_iter()
.filter_map(|cert| {
certificate_directory.as_deref().and_then(|directory| {
crate::tls::certificate_metadata(directory, &cert.cert_file)
.ok()
.map(|metadata| (cert.id, metadata))
})
})
.collect::<std::collections::HashMap<_, _>>()
})
.await
.unwrap_or_default();
let renewals = state.acme.renewal_view();
let entries: Vec<_> = config.tls.certificates.iter().map(|cert| {
let assigned: Vec<_> = config.vhosts.iter().filter(|v|v.certificate_id.as_deref()==Some(cert.id.as_str()))
.map(|v|json!({"id":v.id,"hosts":v.frontend.hosts})).collect();
let metadata = inspected_metadata.get(&cert.id);
let covered_hosts = metadata.map(|value| value.covered_hosts.clone()).unwrap_or_else(|| cert.covered_hosts.clone());
let renewal = renewals.get(&cert.id);
json!({"id":cert.id,"hosts":cert.hosts,"covered_hosts":covered_hosts,"cert_file":cert.cert_file,"key_file":cert.key_file,
"issuer":cert.issuer,"certificate_issuer":metadata.and_then(|value| value.issuer.clone()),
"not_before":metadata.and_then(|value| value.not_before.clone()),"not_after":metadata.and_then(|value| value.not_after.clone()),
"not_before_unix":metadata.and_then(|value| value.not_before_unix),"not_after_unix":metadata.and_then(|value| value.not_after_unix),
"days_remaining":metadata.and_then(|value| value.days_remaining),"expiry_status":metadata.map(|value| value.expiry_status).unwrap_or("unknown"),
"renewal":renewal,"renewal_status":renewal.map(|value| value.status.as_str()),
"last_renewal_attempt":renewal.map(|value| value.last_checked).filter(|value| *value > 0),
"last_renewal_error":renewal.filter(|value| value.status == "failed").map(|value| value.message.as_str()),
"default":config.tls.default_certificate.as_deref()==Some(cert.id.as_str()),"assigned_vhosts":assigned})
}).collect();
Json(
json!({"revision":snapshot.envelope.revision,"certificates":entries,"acme":true, "providers":["http01", "cloudflare", "ovh", "rfc2136"]}),
)
}
async fn configuration(State(state): State<Arc<App>>) -> Json<crate::config::Envelope> {
Json(state.config.load().envelope.clone())
}
#[derive(Deserialize)]
struct ConfigHistoryQuery {
revision: Option<u64>,
limit: Option<usize>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ConfigHistoryRestore {
revision: u64,
expected_revision: u64,
}
async fn config_history(
State(state): State<Arc<App>>,
Query(query): Query<ConfigHistoryQuery>,
) -> Result<Json<Value>, ApiError> {
if let Some(revision) = query.revision {
let detail = state
.store
.configuration_revision(revision)
.await
.map_err(auth::internal)?
.ok_or_else(|| {
ApiError(
StatusCode::NOT_FOUND,
"configuration revision not found".into(),
)
})?;
return Ok(Json(json!({"revision": detail})));
}
let limit = query.limit.unwrap_or(512).clamp(1, 512);
let revisions = state
.store
.configuration_history(limit)
.await
.map_err(auth::internal)?;
Ok(Json(json!({"revisions": revisions})))
}
async fn restore_config_history(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
Json(input): Json<ConfigHistoryRestore>,
) -> Result<Json<crate::config::Envelope>, ApiError> {
let current_revision = state.config.load().envelope.revision;
if current_revision != input.expected_revision {
return Err(ApiError(
StatusCode::CONFLICT,
"configuration changed; reload before restoring".into(),
));
}
if input.revision >= current_revision {
return Err(ApiError(
StatusCode::BAD_REQUEST,
"only an older configuration revision can be restored".into(),
));
}
let detail = state
.store
.configuration_revision(input.revision)
.await
.map_err(auth::internal)?
.ok_or_else(|| {
ApiError(
StatusCode::NOT_FOUND,
"configuration revision not found".into(),
)
})?;
state
.restore_as(
Update {
expected_revision: input.expected_revision,
config: detail.config,
},
session.username,
input.revision,
)
.await
.map(Json)
}
async fn update(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
Json(input): Json<Update>,
) -> Result<Json<crate::config::Envelope>, ApiError> {
state.update_as(input, session.username).await.map(Json)
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct SectionUpdate {
expected_revision: u64,
value: Value,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ResponseSection {
server_header: crate::settings::ServerHeader,
diagnostic_headers: crate::settings::DiagnosticHeaders,
compression: crate::compression::Settings,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct LoggingSection {
logging: crate::logging::Settings,
error_log: crate::logging::ErrorLogSettings,
}
fn invalid_section(section: &str, error: serde_json::Error) -> ApiError {
ApiError(
StatusCode::BAD_REQUEST,
format!("invalid {section} configuration: {error}"),
)
}