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::>(); 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::>()); payload["vhost_metrics"] = json!(snapshot .vhost_metrics .iter() .map(|(id, metrics)| (id.clone(), metrics.view())) .collect::>()); 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::()); payload["routes"] = json!(snapshot .envelope .config .vhosts .iter() .map(|h| h.routes.len()) .sum::()); payload["enabled_routes"] = json!(snapshot .envelope .config .vhosts .iter() .filter(|h| h.enabled) .map(|h| h.routes.iter().filter(|r| r.enabled).count()) .sum::()); 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>) -> Json { Json(status_payload(&state)) } async fn backend_status(State(state): State>) -> Json { let snapshot = state.config.load_full(); Json( json!({"pools":snapshot.pools.iter().map(|p| p.view()).collect::>(), "active_frontends":state.active_listeners, "desired_frontends":snapshot.listeners}), ) } fn docker_runtime_views(state: &Arc) -> (Vec, Vec) { 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>) -> Json { 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>) -> Result, 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>) -> Json { 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::>() }) .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>) -> Json { Json(state.config.load().envelope.clone()) } #[derive(Deserialize)] struct ConfigHistoryQuery { revision: Option, limit: Option, } #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct ConfigHistoryRestore { revision: u64, expected_revision: u64, } async fn config_history( State(state): State>, Query(query): Query, ) -> Result, 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>, Extension(session): Extension, Json(input): Json, ) -> Result, 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>, Extension(session): Extension, Json(input): Json, ) -> Result, 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}"), ) }