WEB Knobs in API

Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
This commit is contained in:
Alexey
2026-08-27 21:42:14 +03:00
parent d41a8c3220
commit cf0cd08387
43 changed files with 3399 additions and 196 deletions
+58
View File
@@ -87,6 +87,64 @@ async fn read_managed_config_strips_access() {
);
}
#[tokio::test]
async fn read_managed_config_exposes_web_without_runtime_or_access_secrets() {
let (path, _directory) = temp_config(concat!(
"[web]\nenabled = false\ncarrier = \"https\"\n",
"[web.debug]\nenabled = true\ndefault_window_secs = 180\n",
"[access.users]\nbob = \"00000000000000000000000000000000\"\n",
));
let (value, _revision) = read_managed_config(&path).await.unwrap();
let table = value.as_table().unwrap();
assert!(table.contains_key("web"));
assert!(table["web"].get("debug").is_some());
assert!(table["web"].get("runtime").is_none());
assert!(!table.contains_key("access"));
}
#[tokio::test]
async fn patch_web_debug_is_hot_and_limits_are_process_deferred() {
let (path, _directory) = temp_config("[web]\nenabled = false\n");
let debug_patch: Json = serde_json::json!({
"web": {"debug": {"enabled": true, "capture_headers": false}}
});
let debug = apply_patch_to_path(&path, &debug_patch, None)
.await
.unwrap();
assert!(!debug.process_restart_required);
assert!(debug.changed.iter().any(|section| section == "web"));
let limits_patch: Json = serde_json::json!({
"web": {"limits": {"max_http_connections": 2049}}
});
let limits = apply_patch_to_path(&path, &limits_patch, None)
.await
.unwrap();
assert!(limits.process_restart_required);
assert!(
limits
.deferred_process_fields
.iter()
.any(|field| field == "web.limits")
);
}
#[tokio::test]
async fn invalid_web_patch_does_not_modify_the_source() {
let (path, _directory) = temp_config("[web]\nenabled = false\n");
let original = tokio::fs::read_to_string(&path).await.unwrap();
let patch: Json = serde_json::json!({
"web": {"debug": {"default_window_secs": 181, "max_window_secs": 180}}
});
let error = apply_patch_to_path(&path, &patch, None).await.unwrap_err();
assert_eq!(error.status, hyper::StatusCode::BAD_REQUEST);
assert_eq!(tokio::fs::read_to_string(&path).await.unwrap(), original);
}
#[tokio::test]
async fn read_managed_config_returns_only_editable_sections() {
// Full server (api/port) and network must not leak. Listeners-only server
+7 -6
View File
@@ -262,12 +262,12 @@ pub(super) async fn load_config_from_disk(config_path: &Path) -> Result<ProxyCon
.map_err(|e| ApiFailure::internal(format!("failed to load config: {}", e)))
}
pub(super) async fn load_config_for_reload(config_path: &Path) -> Result<ProxyConfig, ApiFailure> {
let config_path = config_path.to_path_buf();
tokio::task::spawn_blocking(move || ProxyConfig::load(config_path))
.await
.map_err(|error| ApiFailure::internal(format!("failed to join config loader: {}", error)))?
.map_err(|error| ApiFailure::bad_request(format!("invalid runtime config: {}", error)))
pub(super) async fn load_config_for_reload(
config_path: &Path,
) -> Result<(ProxyConfig, String), ApiFailure> {
let loaded = load_config_snapshot(config_path, true).await?;
let revision = compute_snapshot_revision(&loaded);
Ok((loaded.config, revision))
}
#[allow(dead_code)]
@@ -307,6 +307,7 @@ pub(super) const EDITABLE_SECTIONS: &[&str] = &[
"censorship",
"upstreams",
"dc_overrides",
"web",
];
/// Nested fields under `[server]` that may be read/patched via the config API.
+41 -4
View File
@@ -30,6 +30,7 @@ use crate::startup::StartupTracker;
use crate::stats::Stats;
use crate::transport::UpstreamManager;
use crate::transport::middle_proxy::MePool;
use crate::web::control::WebRuntimePublication;
use crate::web::trace::WebTraceStore;
mod config_edit;
@@ -48,6 +49,8 @@ mod runtime_stats;
mod runtime_watch;
mod runtime_zero;
mod users;
// WEB runtime status and bounded controls remain separate from general API DTOs.
mod web_runtime;
mod web_status;
use config_store::{
@@ -125,6 +128,7 @@ pub(super) struct ApiShared {
pub(super) reload_control: ReloadControl,
pub(super) active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
pub(super) web_trace: Arc<WebTraceStore>,
pub(super) web_runtime_rx: watch::Receiver<WebRuntimePublication>,
}
impl ApiShared {
@@ -159,6 +163,7 @@ impl ApiShared {
reload_control: self.reload_control.clone(),
active_runtime: self.active_runtime.clone(),
web_trace: self.web_trace.clone(),
web_runtime_rx: self.web_runtime_rx.clone(),
}
}
}
@@ -196,9 +201,15 @@ async fn submit_reload_from_disk(
request: ReloadRequest,
) -> Result<(ReloadAccepted, String), ApiFailure> {
let _guard = mutation_lock.lock().await;
ensure_expected_revision(config_path, expected_revision).await?;
let revision = current_revision(config_path).await?;
let config = Arc::new(load_config_for_reload(config_path).await?);
let (config, revision) = load_config_for_reload(config_path).await?;
if expected_revision.is_some_and(|expected| expected != revision) {
return Err(ApiFailure::new(
StatusCode::CONFLICT,
"revision_conflict",
"Config revision mismatch",
));
}
let config = Arc::new(config);
let accepted = reload_control
.submit(config, revision.clone(), request)
.await
@@ -218,6 +229,9 @@ async fn submit_reload_from_disk(
}
fn allowed_methods_for_path(path: &str) -> Option<&'static str> {
if let Some(allow) = web_runtime::allowed_methods(path) {
return Some(allow);
}
match path {
"/v1/health"
| "/v1/health/ready"
@@ -285,6 +299,7 @@ pub async fn serve(
mut active_runtime_rx: watch::Receiver<Option<Arc<ArcSwap<RuntimeGeneration>>>>,
mut runtime_watch_rx: watch::Receiver<Option<RuntimeWatchState>>,
web_trace: Arc<WebTraceStore>,
web_runtime_rx: watch::Receiver<WebRuntimePublication>,
) {
let active_runtime = loop {
if let Some(active_runtime) = active_runtime_rx.borrow().clone() {
@@ -351,6 +366,7 @@ pub async fn serve(
reload_control,
active_runtime,
web_trace,
web_runtime_rx,
});
spawn_runtime_watchers(
@@ -498,9 +514,30 @@ async fn handle(
let body_limit = api_cfg.request_body_limit_bytes;
let result: Result<Response<Full<Bytes>>, ApiFailure> = async {
if web_runtime::is_route(normalized_path) {
let web_mutation = method == Method::POST;
let result = web_runtime::handle(
method,
normalized_path,
query.as_deref(),
req,
shared.as_ref(),
cfg.as_ref(),
request_id,
body_limit,
)
.await;
if web_mutation && let Err(error) = &result {
shared.runtime_events.record(
"api.web.control.failed",
format!("path={} code={}", normalized_path, error.code),
);
}
return result;
}
match (method.as_str(), normalized_path) {
("GET", "/web-status") => {
Ok(web_status::render(query.as_deref(), &shared.web_trace, &cfg.web.debug).await)
Ok(web_status::render(query.as_deref(), &shared.web_trace).await)
}
("GET", "/v1/health") => {
let revision = current_revision(&shared.config_path).await?;
+444
View File
@@ -0,0 +1,444 @@
use std::sync::Arc;
use std::time::Instant;
use http_body_util::Full;
use hyper::body::{Bytes, Incoming};
use hyper::header::CONTENT_TYPE;
use hyper::{Method, Request, Response, StatusCode};
use serde::Serialize;
use super::config_store::current_revision;
use super::http_utils::{read_json, success_response};
use super::model::ApiFailure;
use super::{ALLOW_GET, ALLOW_POST, ApiShared};
use crate::config::ProxyConfig;
use crate::web::control::{WebRuntimeLifecycle, WebRuntimePublication};
use crate::web::manager::{ControlError, SessionDetail, WebProcessRuntime};
// Exact JSON DTOs and strict query parsing stay independent from route dispatch.
mod request;
use request::{
CloseRequest, RuntimeInstanceRequest, parse_session_query, parse_session_ref,
valid_runtime_instance,
};
const STATUS_PATH: &str = "/v1/runtime/web/status";
const SESSIONS_PATH: &str = "/v1/runtime/web/sessions";
const CLOSE_PATH: &str = "/v1/runtime/web/sessions/close";
const DEBUG_CLEAR_PATH: &str = "/v1/runtime/web/debug/clear";
const LEARNING_RESET_PATH: &str = "/v1/runtime/web/carrier-learning/reset";
const SESSION_DETAIL_PREFIX: &str = "/v1/runtime/web/sessions/";
const OPERATION_PREFIX: &str = "/v1/runtime/web/operations/";
const MAX_CONTROL_BODY_BYTES: usize = 64 * 1024;
/// Returns the exact allowed method set for a WEB runtime route.
pub(super) fn allowed_methods(path: &str) -> Option<&'static str> {
match path {
STATUS_PATH | SESSIONS_PATH => Some(ALLOW_GET),
CLOSE_PATH | DEBUG_CLEAR_PATH | LEARNING_RESET_PATH => Some(ALLOW_POST),
_ if detail_ref(path).is_some() || operation_ref(path).is_some() => Some(ALLOW_GET),
_ => None,
}
}
/// Returns whether the normalized API path belongs to WEB runtime control.
pub(super) fn is_route(path: &str) -> bool {
allowed_methods(path).is_some()
}
/// Dispatches one authenticated WEB runtime status or control request.
pub(super) async fn handle(
method: Method,
path: &str,
query: Option<&str>,
request: Request<Incoming>,
shared: &ApiShared,
config: &ProxyConfig,
_request_id: u64,
body_limit: usize,
) -> Result<Response<Full<Bytes>>, ApiFailure> {
let revision = current_revision(&shared.config_path).await?;
match (method.as_str(), path) {
("GET", STATUS_PATH) => {
reject_query(query)?;
let publication = shared.web_runtime_rx.borrow().clone();
let runtime = publication.runtime.upgrade();
let data = WebStatusData::new(publication, runtime.as_deref(), config.web.enabled);
Ok(success_response(StatusCode::OK, data, revision))
}
("GET", SESSIONS_PATH) => {
let runtime = readable_runtime(shared)?;
let request = parse_session_query(&runtime, query)?;
let page = runtime.list_sessions(request);
Ok(success_response(StatusCode::OK, page, revision))
}
("GET", _) if detail_ref(path).is_some() => {
reject_query(query)?;
let runtime = readable_runtime(shared)?;
let session_ref = detail_ref(path).expect("route guard checked detail reference");
let trace_session_id = parse_session_ref(&runtime, session_ref)?;
match runtime.session_detail(trace_session_id) {
SessionDetail::Active(row) => Ok(success_response(StatusCode::OK, row, revision)),
SessionDetail::Gone { attempt } => Ok(success_response(
StatusCode::GONE,
GoneSessionData {
session_ref: session_ref.to_string(),
state: "closed",
attempt,
},
revision,
)),
SessionDetail::Busy => Err(snapshot_busy()),
SessionDetail::NotFound => Err(ApiFailure::new(
StatusCode::NOT_FOUND,
"web_session_not_found",
"WEB session was not found",
)),
}
}
("GET", _) if operation_ref(path).is_some() => {
reject_query(query)?;
let runtime = readable_runtime(shared)?;
let operation_id = operation_ref(path).expect("route guard checked operation id");
let status = runtime
.control_operation(operation_id)
.map_err(control_failure)?;
Ok(success_response(StatusCode::OK, status, revision))
}
("POST", CLOSE_PATH) => {
require_mutable(config)?;
reject_query(query)?;
require_json_content_type(&request)?;
let request = read_json::<CloseRequest>(
request.into_body(),
body_limit.min(MAX_CONTROL_BODY_BYTES),
)
.await?;
let runtime = control_runtime(shared)?;
require_runtime_instance(&runtime, &request.runtime_instance)?;
let selector = request.selector.resolve(&runtime)?;
let status = runtime
.start_close_operation(&request.runtime_instance, selector)
.map_err(control_failure)?;
shared.runtime_events.record(
"api.web.sessions.close.accepted",
format!(
"operation_id={} requested={}",
status.operation_id, status.requested
),
);
Ok(success_response(StatusCode::ACCEPTED, status, revision))
}
("POST", DEBUG_CLEAR_PATH) => {
require_mutable(config)?;
reject_query(query)?;
require_json_content_type(&request)?;
let request = read_json::<RuntimeInstanceRequest>(
request.into_body(),
body_limit.min(MAX_CONTROL_BODY_BYTES),
)
.await?;
let runtime = control_runtime(shared)?;
require_runtime_instance(&runtime, &request.runtime_instance)?;
let outcome = runtime.clear_debug().map_err(control_failure)?;
let data = DebugClearData {
runtime_instance: runtime.runtime_instance().to_string(),
records_cleared: outcome.records_cleared,
leased_bytes: outcome.leased_bytes,
epoch: outcome.epoch,
};
shared.runtime_events.record(
"api.web.debug.clear.ok",
format!("records={} epoch={}", data.records_cleared, data.epoch),
);
Ok(success_response(StatusCode::OK, data, revision))
}
("POST", LEARNING_RESET_PATH) => {
require_mutable(config)?;
reject_query(query)?;
require_json_content_type(&request)?;
let request = read_json::<RuntimeInstanceRequest>(
request.into_body(),
body_limit.min(MAX_CONTROL_BODY_BYTES),
)
.await?;
let runtime = control_runtime(shared)?;
require_runtime_instance(&runtime, &request.runtime_instance)?;
let outcome = runtime
.reset_carrier_learning()
.map_err(|_| runtime_unavailable(WebRuntimeLifecycle::Draining))?;
let data = LearningResetData {
runtime_instance: runtime.runtime_instance().to_string(),
entries_cleared: outcome.entries_cleared,
epoch: outcome.epoch,
};
shared.runtime_events.record(
"api.web.carrier_learning.reset.ok",
format!("entries={} epoch={}", data.entries_cleared, data.epoch),
);
Ok(success_response(StatusCode::OK, data, revision))
}
_ => Err(ApiFailure::method_not_allowed(
allowed_methods(path).unwrap_or(ALLOW_GET),
)),
}
}
#[derive(Serialize)]
struct WebStatusData {
lifecycle: &'static str,
lifecycle_epoch: u64,
lifecycle_age_ms: u64,
available: bool,
#[serde(skip_serializing_if = "Option::is_none")]
reason: Option<&'static str>,
listeners: Vec<String>,
effective_config_enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
runtime: Option<crate::web::manager::WebRuntimeStatus>,
}
impl WebStatusData {
fn new(
publication: WebRuntimePublication,
runtime: Option<&WebProcessRuntime>,
effective_config_enabled: bool,
) -> Self {
let available = runtime.is_some()
&& matches!(
publication.lifecycle,
WebRuntimeLifecycle::Running | WebRuntimeLifecycle::Draining
);
let reason = if available {
None
} else {
Some(match publication.lifecycle {
WebRuntimeLifecycle::Starting => "starting",
WebRuntimeLifecycle::NoWebListener => "no_web_listener",
WebRuntimeLifecycle::Running => "runtime_released",
WebRuntimeLifecycle::Draining => "runtime_released",
WebRuntimeLifecycle::Drained => "drained",
WebRuntimeLifecycle::DeadlineExceeded => "deadline_exceeded",
})
};
Self {
lifecycle: publication.lifecycle.as_str(),
lifecycle_epoch: publication.epoch,
lifecycle_age_ms: millis(Instant::now().saturating_duration_since(publication.since)),
available,
reason,
listeners: publication
.listeners
.iter()
.map(ToString::to_string)
.collect(),
effective_config_enabled,
runtime: runtime.map(WebProcessRuntime::try_status),
}
}
}
#[derive(Serialize)]
struct GoneSessionData {
session_ref: String,
state: &'static str,
attempt: u8,
}
#[derive(Serialize)]
struct DebugClearData {
runtime_instance: String,
records_cleared: usize,
leased_bytes: usize,
epoch: u64,
}
#[derive(Serialize)]
struct LearningResetData {
runtime_instance: String,
entries_cleared: usize,
epoch: u64,
}
fn readable_runtime(shared: &ApiShared) -> Result<Arc<WebProcessRuntime>, ApiFailure> {
let publication = shared.web_runtime_rx.borrow().clone();
if !matches!(
publication.lifecycle,
WebRuntimeLifecycle::Running | WebRuntimeLifecycle::Draining
) {
return Err(runtime_unavailable(publication.lifecycle));
}
publication
.runtime
.upgrade()
.ok_or_else(|| runtime_unavailable(publication.lifecycle))
}
fn control_runtime(shared: &ApiShared) -> Result<Arc<WebProcessRuntime>, ApiFailure> {
let publication = shared.web_runtime_rx.borrow().clone();
if publication.lifecycle != WebRuntimeLifecycle::Running {
return Err(runtime_unavailable(publication.lifecycle));
}
publication
.runtime
.upgrade()
.ok_or_else(|| runtime_unavailable(publication.lifecycle))
}
fn runtime_unavailable(lifecycle: WebRuntimeLifecycle) -> ApiFailure {
ApiFailure::new(
StatusCode::SERVICE_UNAVAILABLE,
"web_runtime_unavailable",
format!("WEB runtime is unavailable: {}", lifecycle.as_str()),
)
}
fn require_mutable(config: &ProxyConfig) -> Result<(), ApiFailure> {
if config.server.api.read_only {
return Err(ApiFailure::new(
StatusCode::FORBIDDEN,
"read_only",
"API runs in read-only mode",
));
}
Ok(())
}
fn require_json_content_type<B>(request: &Request<B>) -> Result<(), ApiFailure> {
let mut values = request.headers().get_all(CONTENT_TYPE).iter();
let exact = values
.next()
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value == "application/json")
&& values.next().is_none();
if !exact {
return Err(ApiFailure::new(
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"unsupported_media_type",
"Content-Type must be exactly application/json",
));
}
Ok(())
}
fn require_runtime_instance(
runtime: &WebProcessRuntime,
runtime_instance: &str,
) -> Result<(), ApiFailure> {
if !valid_runtime_instance(runtime_instance) {
return Err(ApiFailure::bad_request(
"runtime_instance must be 32 lowercase hexadecimal characters",
));
}
if runtime.runtime_instance() != runtime_instance {
return Err(ApiFailure::new(
StatusCode::CONFLICT,
"web_runtime_mismatch",
"WEB runtime instance no longer matches",
));
}
Ok(())
}
fn control_failure(error: ControlError) -> ApiFailure {
match error {
ControlError::StaleInstance => ApiFailure::new(
StatusCode::CONFLICT,
"web_runtime_mismatch",
"WEB runtime instance no longer matches",
),
ControlError::InvalidSelector | ControlError::InvalidOperation => {
ApiFailure::bad_request("Invalid WEB control request")
}
ControlError::IssuanceEnabled => ApiFailure::new(
StatusCode::CONFLICT,
"web_issuance_enabled",
"Close-all requires effective WEB issuance to be disabled",
),
ControlError::OperationInProgress => ApiFailure::new(
StatusCode::CONFLICT,
"web_operation_in_progress",
"Another WEB close operation is active",
),
ControlError::OperationNotFound => ApiFailure::new(
StatusCode::NOT_FOUND,
"web_operation_not_found",
"WEB control operation was not found",
),
ControlError::Closed => runtime_unavailable(WebRuntimeLifecycle::Draining),
}
}
fn snapshot_busy() -> ApiFailure {
ApiFailure::new(
StatusCode::SERVICE_UNAVAILABLE,
"web_snapshot_busy",
"WEB runtime snapshot is temporarily busy",
)
}
fn reject_query(query: Option<&str>) -> Result<(), ApiFailure> {
if query.is_some_and(|query| !query.is_empty()) {
return Err(ApiFailure::bad_request(
"This endpoint does not accept query parameters",
));
}
Ok(())
}
fn detail_ref(path: &str) -> Option<&str> {
path.strip_prefix(SESSION_DETAIL_PREFIX)
.filter(|value| !value.is_empty() && !value.contains('/') && *value != "close")
}
fn operation_ref(path: &str) -> Option<&str> {
path.strip_prefix(OPERATION_PREFIX)
.filter(|value| !value.is_empty() && !value.contains('/'))
}
fn millis(duration: std::time::Duration) -> u64 {
duration.as_millis().min(u128::from(u64::MAX)) as u64
}
#[cfg(test)]
mod tests {
use super::*;
use hyper::header::HeaderValue;
#[test]
fn route_table_keeps_status_read_only_and_controls_post_only() {
assert_eq!(allowed_methods(STATUS_PATH), Some(ALLOW_GET));
assert_eq!(allowed_methods(SESSIONS_PATH), Some(ALLOW_GET));
assert_eq!(allowed_methods(CLOSE_PATH), Some(ALLOW_POST));
assert_eq!(allowed_methods(DEBUG_CLEAR_PATH), Some(ALLOW_POST));
assert_eq!(allowed_methods(LEARNING_RESET_PATH), Some(ALLOW_POST));
assert_eq!(
allowed_methods("/v1/runtime/web/sessions/ws1.instance.0000000000000001"),
Some(ALLOW_GET)
);
}
#[test]
fn control_content_type_is_exact_and_single() {
let exact = Request::builder()
.header(CONTENT_TYPE, "application/json")
.body(())
.unwrap();
assert!(require_json_content_type(&exact).is_ok());
let parameterized = Request::builder()
.header(CONTENT_TYPE, "application/json; charset=utf-8")
.body(())
.unwrap();
assert!(require_json_content_type(&parameterized).is_err());
let mut duplicated = Request::builder()
.header(CONTENT_TYPE, "application/json")
.body(())
.unwrap();
duplicated
.headers_mut()
.append(CONTENT_TYPE, HeaderValue::from_static("application/json"));
assert!(require_json_content_type(&duplicated).is_err());
}
}
+391
View File
@@ -0,0 +1,391 @@
use std::collections::BTreeSet;
use std::net::IpAddr;
use hyper::StatusCode;
use serde::Deserialize;
use crate::config::WebCarrier;
use crate::web::manager::{
CloseOperationSelector, SessionFilter, SessionListRequest, SessionRefError, WebProcessRuntime,
};
use super::super::model::ApiFailure;
const DEFAULT_SESSION_LIMIT: usize = 50;
const MAX_SESSION_LIMIT: usize = 200;
/// Exact process-instance fence for one runtime mutation.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct RuntimeInstanceRequest {
/// Random process identifier copied from WEB runtime status.
pub(super) runtime_instance: String,
}
/// One process-fenced asynchronous close request.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct CloseRequest {
/// Random process identifier copied from WEB runtime status.
pub(super) runtime_instance: String,
/// Exact point-in-time close selector.
pub(super) selector: CloseSelectorRequest,
}
/// Strict tagged selector accepted by the WEB close endpoint.
#[derive(Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub(super) enum CloseSelectorRequest {
/// Closes an explicit bounded set of logical sessions.
Refs {
/// Unique current-instance opaque session references.
session_refs: Vec<String>,
},
/// Closes the point-in-time sessions matching every supplied field.
Filter {
#[serde(default)]
session_ref: Option<String>,
#[serde(default)]
ip: Option<String>,
#[serde(default)]
host: Option<String>,
#[serde(default)]
user: Option<String>,
#[serde(default)]
user_agent_id: Option<String>,
#[serde(default)]
key_id: Option<String>,
#[serde(default)]
carrier: Option<WebCarrier>,
#[serde(default)]
state: Option<String>,
},
/// Closes every point-in-time session below the submission high-water mark.
All {},
}
impl CloseSelectorRequest {
/// Validates external identifiers and resolves them to manager-owned values.
pub(super) fn resolve(
self,
runtime: &WebProcessRuntime,
) -> Result<CloseOperationSelector, ApiFailure> {
match self {
Self::Refs { session_refs } => resolve_refs(runtime, session_refs),
Self::Filter {
session_ref,
ip,
host,
user,
user_agent_id,
key_id,
carrier,
state,
} => {
validate_filter_strings(
host.as_deref(),
user.as_deref(),
key_id.as_deref(),
state.as_deref(),
)?;
let trace_session_id = session_ref
.as_deref()
.map(|value| parse_session_ref(runtime, value))
.transpose()?;
let client_ip = ip.as_deref().map(parse_canonical_ip).transpose()?;
let filter = SessionFilter {
trace_session_id,
client_ip,
host,
user,
user_agent_id: user_agent_id
.as_deref()
.map(parse_user_agent_id)
.transpose()?,
key_id,
carrier,
state,
};
if filter.is_empty() {
return Err(ApiFailure::bad_request(
"filter selector requires at least one filter",
));
}
Ok(CloseOperationSelector::Filter(filter))
}
Self::All {} => Ok(CloseOperationSelector::All),
}
}
}
fn resolve_refs(
runtime: &WebProcessRuntime,
session_refs: Vec<String>,
) -> Result<CloseOperationSelector, ApiFailure> {
if session_refs.is_empty() || session_refs.len() > 200 {
return Err(ApiFailure::bad_request(
"session_refs must contain 1..200 references",
));
}
let mut resolved = Vec::with_capacity(session_refs.len());
let mut unique = BTreeSet::new();
for session_ref in session_refs {
let id = parse_session_ref(runtime, &session_ref)?;
if !unique.insert(id) {
return Err(ApiFailure::bad_request(
"session_refs must not contain duplicates",
));
}
resolved.push(id);
}
Ok(CloseOperationSelector::Refs(resolved))
}
/// Parses one duplicate-free bounded session-list query.
pub(super) fn parse_session_query(
runtime: &WebProcessRuntime,
raw: Option<&str>,
) -> Result<SessionListRequest, ApiFailure> {
let mut limit = DEFAULT_SESSION_LIMIT;
let mut cursor = None;
let mut filter = SessionFilter::default();
let mut seen = BTreeSet::new();
for (name, value) in url::form_urlencoded::parse(raw.unwrap_or_default().as_bytes()) {
if !seen.insert(name.to_string()) {
return Err(ApiFailure::bad_request(format!("{} must not repeat", name)));
}
match name.as_ref() {
"limit" => {
limit = value
.parse::<usize>()
.ok()
.filter(|value| (1..=MAX_SESSION_LIMIT).contains(value))
.ok_or_else(|| ApiFailure::bad_request("limit must be within 1..200"))?;
}
"cursor" => cursor = Some(parse_session_ref(runtime, &value)?),
"session_ref" => {
let id = parse_session_ref(runtime, &value)?;
filter.trace_session_id = Some(id);
cursor = id.checked_sub(1);
limit = 1;
}
"ip" => {
filter.client_ip = Some(parse_canonical_ip(&value)?);
}
"host" => filter.host = Some(value.into_owned()),
"user" => filter.user = Some(value.into_owned()),
"user_agent_id" => filter.user_agent_id = Some(parse_user_agent_id(&value)?),
"key_id" => filter.key_id = Some(value.into_owned()),
"carrier" => filter.carrier = Some(parse_carrier(&value)?),
"state" => filter.state = Some(value.into_owned()),
_ => {
return Err(ApiFailure::bad_request(format!(
"unknown query field `{}`",
name
)));
}
}
}
if filter.trace_session_id.is_some() && (seen.contains("cursor") || seen.contains("limit")) {
return Err(ApiFailure::bad_request(
"session_ref must not be combined with cursor or limit",
));
}
validate_filter_strings(
filter.host.as_deref(),
filter.user.as_deref(),
filter.key_id.as_deref(),
filter.state.as_deref(),
)?;
Ok(SessionListRequest {
limit,
cursor,
filter,
})
}
/// Maps one opaque session-reference failure to the stable API error contract.
pub(super) fn parse_session_ref(
runtime: &WebProcessRuntime,
session_ref: &str,
) -> Result<u64, ApiFailure> {
runtime
.parse_session_ref(session_ref)
.map_err(|error| match error {
SessionRefError::Invalid => ApiFailure::bad_request("Invalid WEB session reference"),
SessionRefError::StaleInstance => ApiFailure::new(
StatusCode::CONFLICT,
"web_runtime_mismatch",
"WEB session reference belongs to another runtime instance",
),
})
}
fn parse_carrier(value: &str) -> Result<WebCarrier, ApiFailure> {
WebCarrier::ALL
.into_iter()
.find(|carrier| carrier.as_str() == value)
.ok_or_else(|| {
ApiFailure::bad_request(
"carrier must be https, https-lanes, websocket, or websocket-lanes",
)
})
}
fn validate_filter_strings(
host: Option<&str>,
user: Option<&str>,
key_id: Option<&str>,
state: Option<&str>,
) -> Result<(), ApiFailure> {
if host.is_some_and(|value| value.is_empty() || value.len() > 253) {
return Err(ApiFailure::bad_request("host must contain 1..253 bytes"));
}
if user.is_some_and(|value| value.is_empty() || value.len() > 64) {
return Err(ApiFailure::bad_request("user must contain 1..64 bytes"));
}
if key_id.is_some_and(|value| !lower_hex(value, 16)) {
return Err(ApiFailure::bad_request(
"key_id must be 16 lowercase hexadecimal characters",
));
}
if state.is_some_and(|value| {
!matches!(
value,
"provisional"
| "replacing"
| "committed"
| "healthy"
| "closing"
| "superseded"
| "closed"
)
}) {
return Err(ApiFailure::bad_request("Invalid WEB session state"));
}
Ok(())
}
fn parse_user_agent_id(value: &str) -> Result<[u8; 16], ApiFailure> {
if !lower_hex(value, 32) {
return Err(ApiFailure::bad_request(
"user_agent_id must be 32 lowercase hexadecimal characters",
));
}
let mut id = [0; 16];
hex::decode_to_slice(value, &mut id).map_err(|_| {
ApiFailure::bad_request("user_agent_id must be 32 lowercase hexadecimal characters")
})?;
Ok(id)
}
fn lower_hex(value: &str, length: usize) -> bool {
value.len() == length
&& value
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
}
/// Returns whether a process instance uses its canonical lowercase form.
pub(super) fn valid_runtime_instance(value: &str) -> bool {
lower_hex(value, 32)
}
fn parse_canonical_ip(value: &str) -> Result<IpAddr, ApiFailure> {
let ip = value
.parse::<IpAddr>()
.map_err(|_| ApiFailure::bad_request("ip must be a canonical IP address"))?;
if ip.to_string() != value {
return Err(ApiFailure::bad_request("ip must use canonical formatting"));
}
Ok(ip)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn filter_identifiers_are_exact_lowercase_hex() {
assert!(lower_hex("0123456789abcdef", 16));
assert!(!lower_hex("0123456789ABCDEF", 16));
assert!(!lower_hex("0123", 16));
assert!(valid_runtime_instance("0123456789abcdef0123456789abcdef"));
assert!(!valid_runtime_instance("0123456789ABCDEF0123456789ABCDEF"));
}
#[test]
fn mutation_dtos_reject_unknown_fields() {
let runtime_instance = "0123456789abcdef0123456789abcdef";
assert!(
serde_json::from_value::<RuntimeInstanceRequest>(serde_json::json!({
"runtime_instance": runtime_instance,
"extra": true,
}))
.is_err()
);
assert!(
serde_json::from_value::<CloseRequest>(serde_json::json!({
"runtime_instance": runtime_instance,
"selector": {"kind": "all", "extra": true},
}))
.is_err()
);
}
#[test]
fn filter_ips_require_canonical_text() {
assert!(parse_canonical_ip("2001:db8::1").is_ok());
assert!(parse_canonical_ip("2001:0db8::1").is_err());
}
#[test]
fn state_filter_accepts_every_emitted_session_state() {
for state in [
"provisional",
"replacing",
"committed",
"healthy",
"closing",
"superseded",
"closed",
] {
assert!(
validate_filter_strings(None, None, None, Some(state)).is_ok(),
"state {state} must be accepted"
);
}
}
#[tokio::test]
async fn exact_session_query_rejects_pagination_fields_in_any_order() {
let generation = crate::maestro::generation::test_runtime_generation(
1,
crate::config::ProxyConfig::default(),
);
let runtime = WebProcessRuntime::start(std::sync::Arc::new(arc_swap::ArcSwap::from(
generation.clone(),
)));
let session_ref = runtime.session_ref(1);
let cursor = runtime.session_ref(2);
assert!(
parse_session_query(
&runtime,
Some(&format!("session_ref={session_ref}&cursor={cursor}")),
)
.is_err()
);
assert!(
parse_session_query(
&runtime,
Some(&format!("limit=2&session_ref={session_ref}")),
)
.is_err()
);
runtime.shutdown().await;
generation.stop_sessions().await;
generation.stop_background_tasks().await;
}
}
+2 -4
View File
@@ -41,10 +41,9 @@ impl AsRef<[u8]> for RenderedPage {
pub(super) async fn render(
raw_query: Option<&str>,
store: &Arc<WebTraceStore>,
policy: &WebDebugConfig,
) -> Response<Full<Bytes>> {
store.apply_policy(policy);
let query = match parse_query(raw_query, policy) {
let status = store.status();
let query = match parse_query(raw_query, &status.policy) {
Ok(query) => query,
Err(error) => return html_error(StatusCode::BAD_REQUEST, "Invalid query", &error),
};
@@ -62,7 +61,6 @@ pub(super) async fn render(
0
};
let records = store.snapshot_matching(|record| record_matches(record, &query, since_millis));
let status = store.status();
let mut html = String::with_capacity(MAX_PAGE_BYTES);
push_page_start(&mut html);
html.push_str("<h1>WEB status</h1>");
+22 -5
View File
@@ -46,7 +46,6 @@ async fn renderer_filters_groups_and_sets_control_plane_security_headers() {
let response = render(
Some("ip=192.0.2.40&session=42&key=0123456789abcdef&group_by=ip&group_by=key"),
&store,
&policy,
)
.await;
assert_eq!(response.status(), StatusCode::OK);
@@ -69,18 +68,36 @@ async fn render_permits_remain_owned_by_inflight_response_bodies() {
let limits = crate::config::WebLimitsConfig::default();
let store = WebTraceStore::new(policy.clone(), &limits);
let first = render(None, &store, &policy).await;
let second = render(None, &store, &policy).await;
let busy = render(None, &store, &policy).await;
let first = render(None, &store).await;
let second = render(None, &store).await;
let busy = render(None, &store).await;
assert_eq!(busy.status(), StatusCode::SERVICE_UNAVAILABLE);
drop(first);
let admitted = render(None, &store, &policy).await;
let admitted = render(None, &store).await;
assert_eq!(admitted.status(), StatusCode::OK);
drop(second);
drop(admitted);
}
#[tokio::test]
async fn stale_renderer_cannot_restore_an_old_debug_policy() {
let stale_policy = WebDebugConfig::default();
let active_policy = WebDebugConfig {
enabled: true,
capture_headers: false,
..Default::default()
};
let limits = crate::config::WebLimitsConfig::default();
let store = WebTraceStore::new(stale_policy.clone(), &limits);
store.apply_policy(2, &active_policy);
let response = render(None, &store).await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(store.status().policy.as_ref(), &active_policy);
}
#[test]
fn page_truncation_preserves_utf8_boundary_and_cap() {
let mut html = "\u{044f}".repeat(MAX_PAGE_BYTES);