mirror of
https://github.com/telemt/telemt.git
synced 2026-11-01 05:12:02 +03:00
API for WEB: bounded lifecycle and overload observability added
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
This commit is contained in:
@@ -216,6 +216,11 @@ impl ProxyConfig {
|
||||
runtime_web::rebuild(self)
|
||||
}
|
||||
|
||||
/// Revalidates decoy separation after restart-only listener fields are resolved.
|
||||
pub(crate) fn validate_web_decoy_listener_separation(&self) -> Result<()> {
|
||||
validate_web::validate_decoy_listener_separation(self)
|
||||
}
|
||||
|
||||
pub(crate) fn runtime_user_auth(&self) -> Option<&UserAuthSnapshot> {
|
||||
self.runtime_user_auth.as_deref()
|
||||
}
|
||||
|
||||
@@ -265,6 +265,7 @@ const WEB_CONFIG_KEYS: &[&str] = &[
|
||||
"carriers",
|
||||
"carrier_learning",
|
||||
"carrier_negotiation_aggressiveness",
|
||||
"http_connection_capacity_action",
|
||||
"debug",
|
||||
"limits",
|
||||
"timeouts",
|
||||
@@ -278,6 +279,7 @@ const WEB_LIMITS_CONFIG_KEYS: &[&str] = &[
|
||||
"carrier_batch_bytes",
|
||||
"max_frames_per_body",
|
||||
"max_http_connections",
|
||||
"max_http_overload_connections",
|
||||
"max_http_handlers",
|
||||
"max_lane_open_waits_per_session",
|
||||
"pending_bytes_per_lane",
|
||||
@@ -354,6 +356,7 @@ const WEB_TIMEOUTS_CONFIG_KEYS: &[&str] = &[
|
||||
"bootstrap_lifetime_secs",
|
||||
"reconnect_grace_secs",
|
||||
"http_idle_secs",
|
||||
"http_overload_timeout_ms",
|
||||
"shutdown_secs",
|
||||
"decoy_header_secs",
|
||||
];
|
||||
|
||||
@@ -83,9 +83,59 @@ pub(super) fn validate(config: &mut ProxyConfig) -> Result<()> {
|
||||
timeouts::validate(&config.web.timeouts)?;
|
||||
websocket::validate(&carriers, &config.web.limits, &config.web.timeouts)?;
|
||||
validate_vhosts(config)?;
|
||||
validate_decoy_listener_separation(config)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rejects a direct decoy recursion into an effective WEB listener.
|
||||
pub(super) fn validate_decoy_listener_separation(config: &ProxyConfig) -> Result<()> {
|
||||
let web_listeners = config
|
||||
.server
|
||||
.listeners
|
||||
.iter()
|
||||
.filter(|listener| listener.transport == ListenerTransport::Web)
|
||||
.filter(|listener| {
|
||||
(listener.ip.is_ipv4() && config.network.ipv4)
|
||||
|| (listener.ip.is_ipv6() && config.network.ipv6 != Some(false))
|
||||
})
|
||||
.map(|listener| SocketAddr::new(listener.ip, listener.port.unwrap_or(config.server.port)))
|
||||
.collect::<Vec<_>>();
|
||||
for (vhost_idx, vhost) in config.web.vhosts.iter().enumerate() {
|
||||
let WebDecoyConfig::HttpUpstream { upstream } = &vhost.decoy else {
|
||||
continue;
|
||||
};
|
||||
let parsed = url::Url::parse(upstream).map_err(|error| {
|
||||
ProxyError::Config(format!(
|
||||
"web.vhosts[{vhost_idx}].decoy.upstream is invalid: {error}"
|
||||
))
|
||||
})?;
|
||||
let Some(port) = parsed.port_or_known_default() else {
|
||||
continue;
|
||||
};
|
||||
let upstream_ip = match parsed.host() {
|
||||
Some(url::Host::Ipv4(ip)) => IpAddr::V4(ip),
|
||||
Some(url::Host::Ipv6(ip)) => IpAddr::V6(ip),
|
||||
_ => continue,
|
||||
};
|
||||
let upstream_addr = SocketAddr::new(upstream_ip, port);
|
||||
if web_listeners
|
||||
.iter()
|
||||
.any(|listener| listener_covers(*listener, upstream_addr))
|
||||
{
|
||||
return config_error(&format!(
|
||||
"web.vhosts[{vhost_idx}].decoy upstream overlaps WEB listener {upstream_addr}"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn listener_covers(listener: SocketAddr, target: SocketAddr) -> bool {
|
||||
listener.port() == target.port()
|
||||
&& (listener.ip() == target.ip()
|
||||
|| (listener.ip().is_unspecified() && listener.is_ipv4() == target.is_ipv4()))
|
||||
}
|
||||
|
||||
fn validate_web_listener(
|
||||
config: &ProxyConfig,
|
||||
idx: usize,
|
||||
@@ -169,6 +219,10 @@ fn validate_limits(limits: &WebLimitsConfig) -> Result<()> {
|
||||
|
||||
let positive = [
|
||||
("max_http_connections", limits.max_http_connections),
|
||||
(
|
||||
"max_http_overload_connections",
|
||||
limits.max_http_overload_connections,
|
||||
),
|
||||
("max_http_handlers", limits.max_http_handlers),
|
||||
(
|
||||
"max_lane_open_waits_per_session",
|
||||
@@ -222,6 +276,10 @@ fn validate_limits(limits: &WebLimitsConfig) -> Result<()> {
|
||||
}
|
||||
for (field, value) in [
|
||||
("max_http_connections", limits.max_http_connections),
|
||||
(
|
||||
"max_http_overload_connections",
|
||||
limits.max_http_overload_connections,
|
||||
),
|
||||
("max_http_handlers", limits.max_http_handlers),
|
||||
("max_body_readers", limits.max_body_readers),
|
||||
("max_body_bytes_global", limits.max_body_bytes_global),
|
||||
|
||||
@@ -5,6 +5,7 @@ const WEB_DEBUG_STATUS_PAGE_BYTES: usize = 8 * 1024 * 1024;
|
||||
const WEB_DEBUG_GROUP_SCRATCH_BYTES: usize = 4 * 1024 * 1024;
|
||||
const WEB_CARRIER_LEARNING_ENTRY_BYTES: usize = 512;
|
||||
const WEB_LANE_STATE_BYTES: usize = 512;
|
||||
const WEB_OVERLOAD_CONNECTION_BYTES: usize = 4 * 1024;
|
||||
|
||||
/// Validates process-wide body, header, queue, static, and debug reservations.
|
||||
pub(super) fn validate(limits: &WebLimitsConfig) -> Result<()> {
|
||||
@@ -30,6 +31,12 @@ pub(super) fn validate(limits: &WebLimitsConfig) -> Result<()> {
|
||||
.ok_or_else(|| {
|
||||
ProxyError::Config("web.limits HTTP header reservations overflow usize".to_string())
|
||||
})?;
|
||||
let overload_connection_reservation = limits
|
||||
.max_http_overload_connections
|
||||
.checked_mul(WEB_OVERLOAD_CONNECTION_BYTES)
|
||||
.ok_or_else(|| {
|
||||
ProxyError::Config("web.limits HTTP overload reservations overflow usize".to_string())
|
||||
})?;
|
||||
let debug_ring_index = limits
|
||||
.debug_records_capacity
|
||||
.checked_mul(std::mem::size_of::<usize>())
|
||||
@@ -77,6 +84,7 @@ pub(super) fn validate(limits: &WebLimitsConfig) -> Result<()> {
|
||||
.and_then(|value| value.checked_add(carrier_learning_reservation))
|
||||
.and_then(|value| value.checked_add(lane_state_reservation))
|
||||
.and_then(|value| value.checked_add(http_header_reservation))
|
||||
.and_then(|value| value.checked_add(overload_connection_reservation))
|
||||
.ok_or_else(|| ProxyError::Config("web.limits byte ceilings overflow usize".to_string()))?;
|
||||
if reserved > limits.memory_envelope_bytes
|
||||
|| limits.memory_envelope_bytes > MAX_WEB_MEMORY_ENVELOPE_BYTES
|
||||
|
||||
@@ -2,6 +2,9 @@ use super::*;
|
||||
|
||||
/// Validates WEB request, learning, and lifecycle timeouts.
|
||||
pub(super) fn validate(timeouts: &WebTimeoutsConfig) -> Result<()> {
|
||||
if !(1..=60_000).contains(&timeouts.http_overload_timeout_ms) {
|
||||
return config_error("web.timeouts.http_overload_timeout_ms must be within [1, 60000]");
|
||||
}
|
||||
let values = [
|
||||
("header_secs", timeouts.header_secs),
|
||||
("body_secs", timeouts.body_secs),
|
||||
|
||||
@@ -56,6 +56,78 @@ fn web_config_builds_canonical_runtime_snapshot() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_http_connection_capacity_policy_is_bounded_and_configurable() {
|
||||
let configured = WEB_CONFIG
|
||||
.replace(
|
||||
"carrier = \"https-lanes\"",
|
||||
"carrier = \"https-lanes\"\nhttp_connection_capacity_action = \"wait\"",
|
||||
)
|
||||
.replace(
|
||||
"[[web.vhosts]]",
|
||||
"[web.limits]\nmax_http_overload_connections = 23\n\n[web.timeouts]\nhttp_overload_timeout_ms = 731\n\n[[web.vhosts]]",
|
||||
);
|
||||
let config = load_config_from_temp_toml(&configured);
|
||||
|
||||
assert_eq!(
|
||||
config.web.http_connection_capacity_action,
|
||||
WebHttpConnectionCapacityAction::Wait
|
||||
);
|
||||
assert_eq!(config.web.limits.max_http_overload_connections, 23);
|
||||
assert_eq!(config.web.timeouts.http_overload_timeout_ms, 731);
|
||||
|
||||
let defaults = ProxyConfig::default();
|
||||
assert_eq!(
|
||||
defaults.web.http_connection_capacity_action,
|
||||
WebHttpConnectionCapacityAction::Drop
|
||||
);
|
||||
assert_eq!(defaults.web.limits.max_http_overload_connections, 64);
|
||||
assert_eq!(defaults.web.timeouts.http_overload_timeout_ms, 250);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_http_connection_capacity_policy_rejects_unknown_or_unbounded_values() {
|
||||
let unknown = WEB_CONFIG.replace(
|
||||
"carrier = \"https-lanes\"",
|
||||
"carrier = \"https-lanes\"\nhttp_connection_capacity_action = \"queue\"",
|
||||
);
|
||||
assert!(load_config_error_from_temp_toml(&unknown).contains("http_connection_capacity_action"));
|
||||
|
||||
for timeout in [0, 60_001] {
|
||||
let invalid = WEB_CONFIG.replace(
|
||||
"[[web.vhosts]]",
|
||||
&format!("[web.timeouts]\nhttp_overload_timeout_ms = {timeout}\n\n[[web.vhosts]]"),
|
||||
);
|
||||
assert!(
|
||||
load_config_error_from_temp_toml(&invalid)
|
||||
.contains("web.timeouts.http_overload_timeout_ms")
|
||||
);
|
||||
}
|
||||
|
||||
let no_overload_slots = WEB_CONFIG.replace(
|
||||
"[[web.vhosts]]",
|
||||
"[web.limits]\nmax_http_overload_connections = 0\n\n[[web.vhosts]]",
|
||||
);
|
||||
assert!(
|
||||
load_config_error_from_temp_toml(&no_overload_slots)
|
||||
.contains("web.limits.max_http_overload_connections")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_decoy_rejects_direct_and_wildcard_listener_loops() {
|
||||
let direct = WEB_CONFIG.replace("http://127.0.0.1:18081", "http://127.0.0.1:18080");
|
||||
assert!(
|
||||
load_config_error_from_temp_toml(&direct).contains("decoy upstream overlaps WEB listener")
|
||||
);
|
||||
|
||||
let wildcard = direct.replace("ip = \"127.0.0.1\"", "ip = \"0.0.0.0\"");
|
||||
assert!(
|
||||
load_config_error_from_temp_toml(&wildcard)
|
||||
.contains("decoy upstream overlaps WEB listener")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_profile_user_labels_are_bounded_for_runtime_status() {
|
||||
let user = "a".repeat(65);
|
||||
|
||||
+3
-2
@@ -53,8 +53,9 @@ pub use server::{
|
||||
};
|
||||
#[allow(unused_imports)]
|
||||
pub use web::{
|
||||
WebCarrierNegotiationAggressiveness, WebConfig, WebDecoyConfig, WebLimitsConfig,
|
||||
WebProfileConfig, WebSecretMode, WebTimeoutsConfig, WebVhostConfig,
|
||||
WebCarrierNegotiationAggressiveness, WebConfig, WebDecoyConfig,
|
||||
WebHttpConnectionCapacityAction, WebLimitsConfig, WebProfileConfig, WebSecretMode,
|
||||
WebTimeoutsConfig, WebVhostConfig,
|
||||
};
|
||||
pub(crate) use web::{
|
||||
WebRuntimeConfig, WebRuntimeDecoy, WebRuntimeProfile, WebRuntimeVhost, WebStaticAsset,
|
||||
|
||||
@@ -12,6 +12,9 @@ use super::web_debug::WebDebugConfig;
|
||||
// Serialized WEB defaults remain separate from the runtime data model.
|
||||
mod defaults;
|
||||
use defaults::*;
|
||||
// Accepted-socket overload policy remains separate from the bulky WEB data model.
|
||||
mod overload;
|
||||
pub use overload::WebHttpConnectionCapacityAction;
|
||||
|
||||
/// Client-facing secret representation used to derive a WEB capability.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
@@ -95,6 +98,9 @@ pub struct WebLimitsConfig {
|
||||
/// Process-wide accepted WEB HTTP connection ceiling.
|
||||
#[serde(default = "default_web_max_http_connections")]
|
||||
pub max_http_connections: usize,
|
||||
/// Accepted overload sockets allowed to wait or emit a retryable response.
|
||||
#[serde(default = "default_web_max_http_overload_connections")]
|
||||
pub max_http_overload_connections: usize,
|
||||
/// Process-wide concurrently executing HTTP handler ceiling.
|
||||
#[serde(default = "default_web_max_http_handlers")]
|
||||
pub max_http_handlers: usize,
|
||||
@@ -226,6 +232,7 @@ impl Default for WebLimitsConfig {
|
||||
carrier_batch_bytes: default_web_carrier_batch_bytes(),
|
||||
max_frames_per_body: default_web_max_frames_per_body(),
|
||||
max_http_connections: default_web_max_http_connections(),
|
||||
max_http_overload_connections: default_web_max_http_overload_connections(),
|
||||
max_http_handlers: default_web_max_http_handlers(),
|
||||
max_lane_open_waits_per_session: default_web_max_lane_open_waits_per_session(),
|
||||
pending_bytes_per_lane: default_web_pending_bytes_per_lane(),
|
||||
@@ -333,6 +340,9 @@ pub struct WebTimeoutsConfig {
|
||||
/// Maximum idle lifetime of a WEB HTTP keep-alive connection.
|
||||
#[serde(default = "default_web_http_idle_secs")]
|
||||
pub http_idle_secs: u64,
|
||||
/// Per-phase wait or response deadline for accepted HTTP overload sockets.
|
||||
#[serde(default = "default_web_http_overload_timeout_ms")]
|
||||
pub http_overload_timeout_ms: u64,
|
||||
/// Maximum graceful wait for WEB connections and process-owned tasks.
|
||||
#[serde(default = "default_web_shutdown_secs")]
|
||||
pub shutdown_secs: u64,
|
||||
@@ -364,6 +374,7 @@ impl Default for WebTimeoutsConfig {
|
||||
bootstrap_lifetime_secs: default_web_bootstrap_lifetime_secs(),
|
||||
reconnect_grace_secs: default_web_reconnect_grace_secs(),
|
||||
http_idle_secs: default_web_http_idle_secs(),
|
||||
http_overload_timeout_ms: default_web_http_overload_timeout_ms(),
|
||||
shutdown_secs: default_web_shutdown_secs(),
|
||||
decoy_header_secs: default_web_decoy_header_timeout_secs(),
|
||||
}
|
||||
@@ -401,6 +412,9 @@ pub struct WebConfig {
|
||||
/// Controls the evidence thresholds used by automatic carrier ranking.
|
||||
#[serde(default)]
|
||||
pub carrier_negotiation_aggressiveness: WebCarrierNegotiationAggressiveness,
|
||||
/// Action applied when accepted HTTP connection capacity is exhausted.
|
||||
#[serde(default)]
|
||||
pub http_connection_capacity_action: WebHttpConnectionCapacityAction,
|
||||
/// Hard process and protocol limits.
|
||||
#[serde(default)]
|
||||
pub limits: WebLimitsConfig,
|
||||
@@ -447,6 +461,7 @@ impl Default for WebConfig {
|
||||
carriers: WebCarriers::default(),
|
||||
carrier_learning: default_web_carrier_learning(),
|
||||
carrier_negotiation_aggressiveness: WebCarrierNegotiationAggressiveness::default(),
|
||||
http_connection_capacity_action: WebHttpConnectionCapacityAction::default(),
|
||||
limits: WebLimitsConfig::default(),
|
||||
debug: WebDebugConfig::default(),
|
||||
timeouts: WebTimeoutsConfig::default(),
|
||||
|
||||
@@ -40,6 +40,7 @@ usize_default!(default_web_max_frame_payload_bytes, 1024 * 1024);
|
||||
usize_default!(default_web_carrier_batch_bytes, 2 * 1024 * 1024);
|
||||
usize_default!(default_web_max_frames_per_body, 4096);
|
||||
usize_default!(default_web_max_http_connections, 1024);
|
||||
usize_default!(default_web_max_http_overload_connections, 64);
|
||||
usize_default!(default_web_max_http_handlers, 512);
|
||||
usize_default!(default_web_max_lane_open_waits_per_session, 16);
|
||||
usize_default!(default_web_pending_bytes_per_lane, 8 * 1024 * 1024);
|
||||
@@ -105,5 +106,6 @@ pub(super) fn default_web_carrier_learning() -> bool {
|
||||
u64_default!(default_web_bootstrap_lifetime_secs, 120);
|
||||
u64_default!(default_web_reconnect_grace_secs, 120);
|
||||
u64_default!(default_web_http_idle_secs, 75);
|
||||
u64_default!(default_web_http_overload_timeout_ms, 250);
|
||||
u64_default!(default_web_shutdown_secs, 15);
|
||||
u64_default!(default_web_decoy_header_timeout_secs, 30);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Action applied after an accepted WEB socket finds HTTP connection capacity exhausted.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum WebHttpConnectionCapacityAction {
|
||||
/// Close the accepted socket without emitting an HTTP response.
|
||||
#[default]
|
||||
Drop,
|
||||
/// Wait for ordinary HTTP connection capacity under the overload deadline.
|
||||
Wait,
|
||||
/// Emit a bounded retryable HTTP response without parsing the request.
|
||||
Respond,
|
||||
}
|
||||
Reference in New Issue
Block a user