mirror of
https://github.com/telemt/telemt.git
synced 2026-11-05 23:32:00 +03:00
WEB Knobs in API
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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?;
|
||||
|
||||
@@ -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(¶meterized).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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>");
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user