async fn geoip_update( State(state): State>, Extension(session): Extension, ) -> Result, ApiError> { let result = crate::geoip::update_now(&state).await.map_err(|error| { tracing::warn!(error = %format!("{error:#}"), "manual GeoIP update failed"); ApiError( StatusCode::BAD_GATEWAY, format!("GeoIP update failed: {error:#}"), ) })?; state .store .audit( &session.username, "geoip.update", &format!( "outcome={}; files={}", result.outcome, if result.updated_files.is_empty() { "none".to_string() } else { result.updated_files.join(",") } ), ) .await .map_err(auth::internal)?; Ok(Json(result)) } async fn transform_status(State(state): State>) -> Json { let snapshot = state.config.load_full(); let global = &snapshot.envelope.config.transform; let vhosts: Vec<_> = snapshot .envelope .config .vhosts .iter() .map(|vhost| { json!({ "id": vhost.id, "hosts": vhost.frontend.hosts, "settings": vhost.transform.resolve(global).ok() }) }) .collect(); Json(json!({ "settings": global, "resources": crate::transform::resource_usage(global), "metrics": state.transform_metrics.view(), "vhosts": vhosts })) } #[derive(Default, Deserialize)] #[serde(default, deny_unknown_fields)] struct PurgeScope { vhost: Option, } async fn cache_purge( State(state): State>, Extension(session): Extension, Query(scope): Query, ) -> Result, ApiError> { if let Some(id) = &scope.vhost { if !state .config .load() .envelope .config .vhosts .iter() .any(|host| &host.id == id) { return Err(ApiError( StatusCode::NOT_FOUND, "unknown vhost cache scope".into(), )); } } let permit = state .cache_invalidation_slots .clone() .try_acquire_owned() .map_err(|_| { ApiError( StatusCode::TOO_MANY_REQUESTS, "cache maintenance busy".into(), ) })?; let detail = format!( "scope={}; in-flight fills invalidated", scope.vhost.as_deref().unwrap_or("all") ); state .store .audit(&session.username, "cache.purge", &detail) .await .map_err(auth::internal)?; let cache = state.cache.clone(); let vhost = scope.vhost.clone(); let purged = tokio::task::spawn_blocking(move || { let _permit = permit; match &vhost { Some(id) => cache.purge_scope(id), None => cache.purge(None), } }) .await .map_err(auth::internal)?; Ok(Json( json!({"purged_entries":purged, "vhost":scope.vhost, "metrics":state.cache.view()}), )) } async fn cache_invalidate( State(state): State>, Extension(session): Extension, input: Result, axum::extract::rejection::JsonRejection>, ) -> Result, ApiError> { let Json(request) = input.map_err(public::invalid_json)?; if let Some(id) = &request.vhost { if !state .config .load() .envelope .config .vhosts .iter() .any(|host| &host.id == id) { return Err(ApiError( StatusCode::NOT_FOUND, "unknown vhost cache scope".into(), )); } } let permit = state .cache_invalidation_slots .clone() .try_acquire_owned() .map_err(|_| { ApiError( StatusCode::TOO_MANY_REQUESTS, "cache maintenance busy".into(), ) })?; let detail = format!( "operation={:?}; selector={:?}; vhost={}; host={}; value={:?}", request.operation, request.selector, request.vhost.as_deref().unwrap_or("all"), request.host.as_deref().unwrap_or("any"), request.value ); state .store .audit(&session.username, "cache.invalidate", &detail) .await .map_err(auth::internal)?; let cache = state.cache.clone(); let result = tokio::task::spawn_blocking(move || { let _permit = permit; cache.invalidate(request) }) .await .map_err(auth::internal)? .map_err(|error| ApiError(StatusCode::UNPROCESSABLE_ENTITY, error.to_string()))?; Ok(Json(json!({ "result": result, "metrics": state.cache.view(), "bans": state.cache.bans() }))) } async fn cache_bans(State(state): State>) -> Json { Json(json!({"bans": state.cache.bans()})) } async fn cache_ban_delete( State(state): State>, Extension(session): Extension, Path(id): Path, ) -> Result, ApiError> { if !state.cache.bans().iter().any(|rule| rule.id == id) { return Err(ApiError( StatusCode::NOT_FOUND, "unknown cache BAN rule".into(), )); } state .store .audit( &session.username, "cache.ban.remove", &format!("ban_id={id}"), ) .await .map_err(auth::internal)?; if !state.cache.remove_ban(id) { return Err(ApiError( StatusCode::NOT_FOUND, "unknown cache BAN rule".into(), )); } Ok(Json(json!({"removed": id, "bans": state.cache.bans()}))) } async fn active_tunnels(State(state): State>) -> Json { Json(json!({"tunnels": state.tunnel_registry.list()})) } #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct KillTunnelRequest { #[serde(default)] reason: Option, } async fn kill_tunnel( State(state): State>, Extension(session): Extension, Path(id): Path, input: Result, axum::extract::rejection::JsonRejection>, ) -> Result, ApiError> { let Json(input) = input.map_err(public::invalid_json)?; let reason = input .reason .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("administrator request"); if reason.len() > 256 || reason.chars().any(char::is_control) { return Err(ApiError( StatusCode::UNPROCESSABLE_ENTITY, "invalid tunnel close reason".into(), )); } let Some(tunnel) = state.tunnel_registry.kill(id) else { return Err(ApiError( StatusCode::NOT_FOUND, "unknown active tunnel".into(), )); }; state .store .audit( &session.username, "tunnel.kill", &format!( "tunnel_id={}; client={}; host={}; upstream={}; protocol={}; reason={}", tunnel.id, tunnel.client, tunnel.host, tunnel.upstream, tunnel.protocol, reason ), ) .await .map_err(auth::internal)?; Ok(Json(json!({"closed": tunnel, "reason": reason}))) } async fn cache_cold_cleanup( State(state): State>, Extension(session): Extension, input: Result, axum::extract::rejection::JsonRejection>, ) -> Result<(StatusCode, Json), ApiError> { let Json(request) = input.map_err(public::invalid_json)?; let detail = format!( "unused_for_seconds={:?}; oldest={:?}; hits_zero={}", request.unused_for_seconds, request.oldest, request.hits_zero ); let job = state .cache .start_cold_cleanup(request) .map_err(|error| ApiError(StatusCode::UNPROCESSABLE_ENTITY, error.to_string()))?; state .store .audit(&session.username, "cache.cold_cleanup", &detail) .await .map_err(auth::internal)?; Ok((StatusCode::ACCEPTED, Json(json!({"job": job})))) } async fn cache_cold_cleanup_job( State(state): State>, Path(id): Path, ) -> Result, ApiError> { let job = state.cache.cold_cleanup_job(id).ok_or_else(|| { ApiError( StatusCode::NOT_FOUND, "unknown cold-cache cleanup job".into(), ) })?; Ok(Json(json!({"job": job}))) } #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct CertificateUpload { cert_pem: String, key_pem: String, hosts: Vec, } async fn certificate_upload( State(state): State>, Extension(session): Extension, Json(input): Json, ) -> Result, ApiError> { let key = zeroize::Zeroizing::new(input.key_pem); let directory = state.policy.cert_dir.clone().ok_or_else(|| { ApiError( StatusCode::CONFLICT, "certificate storage is unavailable; configure the certificate directory".into(), ) })?; let permit = state .certificate_slots .clone() .try_acquire_owned() .map_err(|_| { ApiError( StatusCode::TOO_MANY_REQUESTS, "certificate upload queue is full".into(), ) })?; let certificate = tokio::task::spawn_blocking(move || { let _permit = permit; crate::tls::upload( &directory, input.cert_pem.as_bytes(), key.as_bytes(), input.hosts, ) }) .await .map_err(|_| { ApiError( StatusCode::INTERNAL_SERVER_ERROR, "certificate upload task failed".into(), ) })? .map_err(|e| ApiError(StatusCode::UNPROCESSABLE_ENTITY, format!("{e:#}")))?; state .store .audit(&session.username, "certificate.upload", &certificate.id) .await .map_err(auth::internal)?; Ok(Json(json!({"certificate":certificate, "staged":true}))) } #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct CertificateDeploy { certificate_id: String, cert_pem: String, key_pem: String, }