445 lines
18 KiB
Rust
445 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.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())
|
|
});
|
|
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}"),
|
|
)
|
|
}
|