mirror of
https://github.com/telemt/telemt.git
synced 2026-10-31 21:02:01 +03:00
WEB
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com> Co-Authored-By: John Preston <17900494+john-preston@users.noreply.github.com>
This commit is contained in:
Generated
+1
-1
@@ -2900,7 +2900,7 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "telemt"
|
name = "telemt"
|
||||||
version = "3.5.0"
|
version = "3.5.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "telemt"
|
name = "telemt"
|
||||||
version = "3.5.0"
|
version = "3.5.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
@@ -72,7 +72,7 @@ anyhow = "1.0.102"
|
|||||||
reqwest = { version = "0.13.4", features = ["rustls"], default-features = false }
|
reqwest = { version = "0.13.4", features = ["rustls"], default-features = false }
|
||||||
notify = "8.2.0"
|
notify = "8.2.0"
|
||||||
ipnetwork = { version = "0.21.1", features = ["serde"] }
|
ipnetwork = { version = "0.21.1", features = ["serde"] }
|
||||||
hyper = { version = "1.10.1", features = ["server", "http1"] }
|
hyper = { version = "1.10.1", features = ["client", "server", "http1"] }
|
||||||
hyper-util = { version = "0.1.20", features = ["tokio", "server-auto"] }
|
hyper-util = { version = "0.1.20", features = ["tokio", "server-auto"] }
|
||||||
http-body-util = "0.1.3"
|
http-body-util = "0.1.3"
|
||||||
httpdate = "1.0.3"
|
httpdate = "1.0.3"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
-34
@@ -1,34 +0,0 @@
|
|||||||
### 3.0.0 Anschluss
|
|
||||||
- **Middle Proxy now is stable**, confirmed on canary-deploy over ~20 users
|
|
||||||
- Ad-tag now is working
|
|
||||||
- DC=203/CDN now is working over ME
|
|
||||||
- `getProxyConfig` and `ProxySecret` are automated
|
|
||||||
- Version order is now in format `3.0.0` - without Windows-style "microfixes"
|
|
||||||
|
|
||||||
### 3.0.1 Kabelsammler
|
|
||||||
- Handshake timeouts fixed
|
|
||||||
- Connectivity logging refactored
|
|
||||||
- Docker: tmpfs for ProxyConfig and ProxySecret
|
|
||||||
- Public Host and Port in config
|
|
||||||
- ME Relays Head-of-Line Blocking fixed
|
|
||||||
- ME Ping
|
|
||||||
|
|
||||||
### 3.0.2 Microtrencher
|
|
||||||
- New [network] section
|
|
||||||
- ME Fixes
|
|
||||||
- Small bugs coverage
|
|
||||||
|
|
||||||
### 3.0.3 Ausrutscher
|
|
||||||
- ME as stateful, no conn-id migration
|
|
||||||
- No `flush()` on datapath after RpcWriter
|
|
||||||
- Hightech parser for IPv6 without regexp
|
|
||||||
- `nat_probe = true` by default
|
|
||||||
- Timeout for `recv()` in STUN-client
|
|
||||||
- ConnRegistry review
|
|
||||||
- Dualstack emergency reconnect
|
|
||||||
|
|
||||||
### 3.0.4 Schneeflecken
|
|
||||||
- Only WARN and Links in Normal log
|
|
||||||
- Consistent IP-family detection
|
|
||||||
- Includes for config
|
|
||||||
- `nonce_frame_hex` in log only with `DEBUG`
|
|
||||||
@@ -337,9 +337,15 @@ pub(super) fn overlay_hot_fields(old: &ProxyConfig, new: &ProxyConfig) -> ProxyC
|
|||||||
cfg.access.user_max_unique_ips_global_each = new.access.user_max_unique_ips_global_each;
|
cfg.access.user_max_unique_ips_global_each = new.access.user_max_unique_ips_global_each;
|
||||||
cfg.access.user_max_unique_ips_mode = new.access.user_max_unique_ips_mode;
|
cfg.access.user_max_unique_ips_mode = new.access.user_max_unique_ips_mode;
|
||||||
cfg.access.user_max_unique_ips_window_secs = new.access.user_max_unique_ips_window_secs;
|
cfg.access.user_max_unique_ips_window_secs = new.access.user_max_unique_ips_window_secs;
|
||||||
|
let process_limits = cfg.web.limits.clone();
|
||||||
|
cfg.web = new.web.clone();
|
||||||
|
cfg.web.limits = process_limits;
|
||||||
if cfg.rebuild_runtime_user_auth().is_err() {
|
if cfg.rebuild_runtime_user_auth().is_err() {
|
||||||
cfg.runtime_user_auth = None;
|
cfg.runtime_user_auth = None;
|
||||||
}
|
}
|
||||||
|
if cfg.rebuild_runtime_web().is_err() {
|
||||||
|
cfg.web = old.web.clone();
|
||||||
|
}
|
||||||
|
|
||||||
cfg
|
cfg
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ fn listener_synlimit_fields_are_process_owned() {
|
|||||||
let mut old = sample_config();
|
let mut old = sample_config();
|
||||||
old.server.listeners.push(ListenerConfig {
|
old.server.listeners.push(ListenerConfig {
|
||||||
ip: "0.0.0.0".parse().unwrap(),
|
ip: "0.0.0.0".parse().unwrap(),
|
||||||
|
transport: crate::config::ListenerTransport::Mtproxy,
|
||||||
port: Some(443),
|
port: Some(443),
|
||||||
client_mss: None,
|
client_mss: None,
|
||||||
synlimit: SynLimitMode::Iptables,
|
synlimit: SynLimitMode::Iptables,
|
||||||
@@ -138,6 +139,8 @@ fn listener_synlimit_fields_are_process_owned() {
|
|||||||
announce_ip: None,
|
announce_ip: None,
|
||||||
proxy_protocol: None,
|
proxy_protocol: None,
|
||||||
reuse_allow: false,
|
reuse_allow: false,
|
||||||
|
web_client_ip_source: crate::config::WebClientIpSource::XForwardedFor,
|
||||||
|
web_trusted_proxy_cidrs: Vec::new(),
|
||||||
});
|
});
|
||||||
let mut new = old.clone();
|
let mut new = old.clone();
|
||||||
new.server.port = 8443;
|
new.server.port = 8443;
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ mod includes;
|
|||||||
mod strict_keys;
|
mod strict_keys;
|
||||||
// Precomputed user authentication data for handshake hot paths.
|
// Precomputed user authentication data for handshake hot paths.
|
||||||
mod runtime_auth;
|
mod runtime_auth;
|
||||||
|
// Validated immutable WEB configuration and static-site snapshots.
|
||||||
|
mod runtime_web;
|
||||||
// Post-deserialization validation helpers.
|
// Post-deserialization validation helpers.
|
||||||
mod decode;
|
mod decode;
|
||||||
mod effective;
|
mod effective;
|
||||||
@@ -30,6 +32,7 @@ mod validate_core;
|
|||||||
mod validate_me;
|
mod validate_me;
|
||||||
mod validate_runtime;
|
mod validate_runtime;
|
||||||
mod validate_server;
|
mod validate_server;
|
||||||
|
mod validate_web;
|
||||||
mod validation;
|
mod validation;
|
||||||
|
|
||||||
use self::includes::{hash_rendered_snapshot, normalize_config_path, preprocess_includes};
|
use self::includes::{hash_rendered_snapshot, normalize_config_path, preprocess_includes};
|
||||||
@@ -96,6 +99,10 @@ pub struct ProxyConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub server: ServerConfig,
|
pub server: ServerConfig,
|
||||||
|
|
||||||
|
/// WEB carrier ingress and public-site fallback configuration.
|
||||||
|
#[serde(default)]
|
||||||
|
pub web: WebConfig,
|
||||||
|
|
||||||
/// Timeout values used by client, fallback, and upstream operations.
|
/// Timeout values used by client, fallback, and upstream operations.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub timeouts: TimeoutsConfig,
|
pub timeouts: TimeoutsConfig,
|
||||||
@@ -204,6 +211,11 @@ impl ProxyConfig {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rebuilds validated WEB capabilities and immutable decoy snapshots.
|
||||||
|
pub(crate) fn rebuild_runtime_web(&mut self) -> Result<()> {
|
||||||
|
runtime_web::rebuild(self)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn runtime_user_auth(&self) -> Option<&UserAuthSnapshot> {
|
pub(crate) fn runtime_user_auth(&self) -> Option<&UserAuthSnapshot> {
|
||||||
self.runtime_user_auth.as_deref()
|
self.runtime_user_auth.as_deref()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ pub(super) fn apply(config: &mut ProxyConfig) -> Result<()> {
|
|||||||
if let Ok(ipv4) = ipv4_str.parse::<IpAddr>() {
|
if let Ok(ipv4) = ipv4_str.parse::<IpAddr>() {
|
||||||
config.server.listeners.push(ListenerConfig {
|
config.server.listeners.push(ListenerConfig {
|
||||||
ip: ipv4,
|
ip: ipv4,
|
||||||
|
transport: ListenerTransport::Mtproxy,
|
||||||
port: Some(config.server.port),
|
port: Some(config.server.port),
|
||||||
client_mss: None,
|
client_mss: None,
|
||||||
synlimit: SynLimitMode::default(),
|
synlimit: SynLimitMode::default(),
|
||||||
@@ -135,6 +136,8 @@ pub(super) fn apply(config: &mut ProxyConfig) -> Result<()> {
|
|||||||
announce_ip: None,
|
announce_ip: None,
|
||||||
proxy_protocol: None,
|
proxy_protocol: None,
|
||||||
reuse_allow: false,
|
reuse_allow: false,
|
||||||
|
web_client_ip_source: WebClientIpSource::XForwardedFor,
|
||||||
|
web_trusted_proxy_cidrs: Vec::new(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if let Some(ipv6_str) = &config.server.listen_addr_ipv6
|
if let Some(ipv6_str) = &config.server.listen_addr_ipv6
|
||||||
@@ -142,6 +145,7 @@ pub(super) fn apply(config: &mut ProxyConfig) -> Result<()> {
|
|||||||
{
|
{
|
||||||
config.server.listeners.push(ListenerConfig {
|
config.server.listeners.push(ListenerConfig {
|
||||||
ip: ipv6,
|
ip: ipv6,
|
||||||
|
transport: ListenerTransport::Mtproxy,
|
||||||
port: Some(config.server.port),
|
port: Some(config.server.port),
|
||||||
client_mss: None,
|
client_mss: None,
|
||||||
synlimit: SynLimitMode::default(),
|
synlimit: SynLimitMode::default(),
|
||||||
@@ -157,6 +161,8 @@ pub(super) fn apply(config: &mut ProxyConfig) -> Result<()> {
|
|||||||
announce_ip: None,
|
announce_ip: None,
|
||||||
proxy_protocol: None,
|
proxy_protocol: None,
|
||||||
reuse_allow: false,
|
reuse_allow: false,
|
||||||
|
web_client_ip_source: WebClientIpSource::XForwardedFor,
|
||||||
|
web_trusted_proxy_cidrs: Vec::new(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,5 +217,6 @@ pub(super) fn apply(config: &mut ProxyConfig) -> Result<()> {
|
|||||||
validate_logging_config(&config.logging)?;
|
validate_logging_config(&config.logging)?;
|
||||||
validate_upstreams(config)?;
|
validate_upstreams(config)?;
|
||||||
config.rebuild_runtime_user_auth()?;
|
config.rebuild_runtime_user_auth()?;
|
||||||
|
config.rebuild_runtime_web()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ pub(super) fn load_source_graph(graph: ConfigSourceGraph) -> Result<LoadedConfig
|
|||||||
validate_runtime::validate(&mut config)?;
|
validate_runtime::validate(&mut config)?;
|
||||||
validate_me::validate(&mut config)?;
|
validate_me::validate(&mut config)?;
|
||||||
validate_server::validate(&mut config)?;
|
validate_server::validate(&mut config)?;
|
||||||
|
validate_web::validate(&mut config)?;
|
||||||
effective::apply(&mut config)?;
|
effective::apply(&mut config)?;
|
||||||
Ok(LoadedConfig {
|
Ok(LoadedConfig {
|
||||||
config,
|
config,
|
||||||
|
|||||||
@@ -0,0 +1,414 @@
|
|||||||
|
use std::collections::{BTreeMap, HashSet};
|
||||||
|
use std::fs;
|
||||||
|
use std::io::Read;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
|
|
||||||
|
use bytes::Bytes;
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const WEB_CAPABILITY_CONTEXT: &[u8] = b"tdesktop-web-proxy-bridge-v1\n";
|
||||||
|
const MAX_WEB_STATIC_DEPTH: usize = 64;
|
||||||
|
|
||||||
|
/// Builds the immutable WEB routing and decoy snapshot for one generation.
|
||||||
|
pub(super) fn rebuild(config: &mut ProxyConfig) -> Result<()> {
|
||||||
|
let auth = config.runtime_user_auth().ok_or_else(|| {
|
||||||
|
ProxyError::Config("WEB runtime requires the user authentication snapshot".to_string())
|
||||||
|
})?;
|
||||||
|
let mut runtime_vhosts = BTreeMap::new();
|
||||||
|
let mut runtime_profiles = Vec::new();
|
||||||
|
let mut static_files = 0usize;
|
||||||
|
let mut static_bytes = 0usize;
|
||||||
|
|
||||||
|
for vhost in &config.web.vhosts {
|
||||||
|
let decoy = build_decoy(
|
||||||
|
vhost,
|
||||||
|
&config.web.limits,
|
||||||
|
&mut static_files,
|
||||||
|
&mut static_bytes,
|
||||||
|
)?;
|
||||||
|
let mut profiles = Vec::with_capacity(vhost.profiles.len());
|
||||||
|
let mut capabilities = HashSet::with_capacity(vhost.profiles.len());
|
||||||
|
for profile in &vhost.profiles {
|
||||||
|
let user_id = auth.user_id_by_name(&profile.user).ok_or_else(|| {
|
||||||
|
ProxyError::Config(format!(
|
||||||
|
"WEB profile references unknown access user `{}`",
|
||||||
|
profile.user
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let auth_entry = auth.entry_by_id(user_id).ok_or_else(|| {
|
||||||
|
ProxyError::Config("WEB profile user snapshot is inconsistent".to_string())
|
||||||
|
})?;
|
||||||
|
let (client_secret, client_secret_len) =
|
||||||
|
client_secret(auth_entry.secret, profile.secret_mode);
|
||||||
|
let capability = derive_web_capability(
|
||||||
|
&client_secret[..client_secret_len],
|
||||||
|
vhost.host.as_bytes(),
|
||||||
|
)?;
|
||||||
|
if !capabilities.insert(capability) {
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"WEB vhost `{}` contains profiles with the same client capability",
|
||||||
|
vhost.host
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let runtime_profile = Arc::new(WebRuntimeProfile {
|
||||||
|
host: vhost.host.clone(),
|
||||||
|
public_addr: vhost.public_addr,
|
||||||
|
user: profile.user.clone(),
|
||||||
|
secret_mode: profile.secret_mode,
|
||||||
|
capability,
|
||||||
|
max_sessions: profile
|
||||||
|
.max_sessions
|
||||||
|
.unwrap_or(config.web.limits.max_sessions_global),
|
||||||
|
max_streams: profile
|
||||||
|
.max_streams
|
||||||
|
.unwrap_or(config.web.limits.max_streams_global),
|
||||||
|
max_streams_per_session: profile
|
||||||
|
.max_streams_per_session
|
||||||
|
.unwrap_or(config.web.limits.max_streams_per_session),
|
||||||
|
});
|
||||||
|
profiles.push(Arc::clone(&runtime_profile));
|
||||||
|
runtime_profiles.push(runtime_profile);
|
||||||
|
}
|
||||||
|
runtime_vhosts.insert(
|
||||||
|
vhost.host.clone(),
|
||||||
|
Arc::new(WebRuntimeVhost {
|
||||||
|
host: vhost.host.clone(),
|
||||||
|
decoy,
|
||||||
|
decoy_header_secs: config.web.timeouts.decoy_header_secs,
|
||||||
|
profiles,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
config.web.runtime = Some(Arc::new(WebRuntimeConfig {
|
||||||
|
vhosts: runtime_vhosts,
|
||||||
|
profiles: runtime_profiles,
|
||||||
|
}));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derives the Telegram Desktop WEB capability for one exact secret and host.
|
||||||
|
pub(crate) fn derive_web_capability(secret: &[u8], host: &[u8]) -> Result<[u8; 32]> {
|
||||||
|
let mut mac = Hmac::<Sha256>::new_from_slice(secret).map_err(|_| {
|
||||||
|
ProxyError::Config("WEB capability secret must not be empty".to_string())
|
||||||
|
})?;
|
||||||
|
mac.update(WEB_CAPABILITY_CONTEXT);
|
||||||
|
mac.update(host);
|
||||||
|
Ok(mac.finalize().into_bytes().into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client_secret(secret: [u8; 16], mode: WebSecretMode) -> ([u8; 17], usize) {
|
||||||
|
let mut client_secret = [0u8; 17];
|
||||||
|
match mode {
|
||||||
|
WebSecretMode::Plain => {
|
||||||
|
client_secret[..16].copy_from_slice(&secret);
|
||||||
|
(client_secret, 16)
|
||||||
|
}
|
||||||
|
WebSecretMode::Dd => {
|
||||||
|
client_secret[0] = 0xdd;
|
||||||
|
client_secret[1..].copy_from_slice(&secret);
|
||||||
|
(client_secret, 17)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_decoy(
|
||||||
|
vhost: &WebVhostConfig,
|
||||||
|
limits: &WebLimitsConfig,
|
||||||
|
static_files: &mut usize,
|
||||||
|
static_bytes: &mut usize,
|
||||||
|
) -> Result<WebRuntimeDecoy> {
|
||||||
|
match &vhost.decoy {
|
||||||
|
WebDecoyConfig::HttpUpstream { upstream } => {
|
||||||
|
let parsed = url::Url::parse(upstream).map_err(|error| {
|
||||||
|
ProxyError::Config(format!(
|
||||||
|
"WEB decoy upstream for `{}` is invalid: {error}",
|
||||||
|
vhost.host
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let ip = match parsed.host() {
|
||||||
|
Some(url::Host::Ipv4(ip)) => std::net::IpAddr::V4(ip),
|
||||||
|
Some(url::Host::Ipv6(ip)) => std::net::IpAddr::V6(ip),
|
||||||
|
_ => {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"WEB decoy host must be an IP literal".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let host = ip.to_string();
|
||||||
|
let port = parsed.port_or_known_default().ok_or_else(|| {
|
||||||
|
ProxyError::Config("WEB decoy port cannot be resolved".to_string())
|
||||||
|
})?;
|
||||||
|
let authority = match (ip, parsed.port()) {
|
||||||
|
(std::net::IpAddr::V6(_), Some(_)) => format!("[{host}]:{port}"),
|
||||||
|
(std::net::IpAddr::V6(_), None) => format!("[{host}]"),
|
||||||
|
(std::net::IpAddr::V4(_), Some(_)) => format!("{host}:{port}"),
|
||||||
|
(std::net::IpAddr::V4(_), None) => host.clone(),
|
||||||
|
};
|
||||||
|
Ok(WebRuntimeDecoy::HttpUpstream {
|
||||||
|
addr: SocketAddr::new(ip, port),
|
||||||
|
authority,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
WebDecoyConfig::StaticDirectory { directory, index } => {
|
||||||
|
let site = load_static_site(
|
||||||
|
directory,
|
||||||
|
index,
|
||||||
|
limits,
|
||||||
|
static_files,
|
||||||
|
static_bytes,
|
||||||
|
)?;
|
||||||
|
Ok(WebRuntimeDecoy::StaticDirectory(Arc::new(site)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_static_site(
|
||||||
|
root: &Path,
|
||||||
|
index: &str,
|
||||||
|
limits: &WebLimitsConfig,
|
||||||
|
total_files: &mut usize,
|
||||||
|
total_bytes: &mut usize,
|
||||||
|
) -> Result<WebStaticSite> {
|
||||||
|
let root_metadata = fs::symlink_metadata(root).map_err(|error| {
|
||||||
|
ProxyError::Config(format!(
|
||||||
|
"failed to inspect WEB static directory `{}`: {error}",
|
||||||
|
root.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() {
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"WEB static directory `{}` must be a real directory, not a symlink",
|
||||||
|
root.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let canonical_root = fs::canonicalize(root).map_err(|error| {
|
||||||
|
ProxyError::Config(format!(
|
||||||
|
"failed to canonicalize WEB static directory `{}`: {error}",
|
||||||
|
root.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let mut assets = BTreeMap::new();
|
||||||
|
load_static_directory(
|
||||||
|
&canonical_root,
|
||||||
|
&canonical_root,
|
||||||
|
&mut assets,
|
||||||
|
total_files,
|
||||||
|
total_bytes,
|
||||||
|
limits,
|
||||||
|
0,
|
||||||
|
)?;
|
||||||
|
if !assets.contains_key(&format!("/{index}")) {
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"WEB static directory `{}` does not contain index `{index}`",
|
||||||
|
root.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(WebStaticSite {
|
||||||
|
assets,
|
||||||
|
index: index.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_static_directory(
|
||||||
|
root: &Path,
|
||||||
|
directory: &Path,
|
||||||
|
assets: &mut BTreeMap<String, WebStaticAsset>,
|
||||||
|
total_files: &mut usize,
|
||||||
|
total_bytes: &mut usize,
|
||||||
|
limits: &WebLimitsConfig,
|
||||||
|
depth: usize,
|
||||||
|
) -> Result<()> {
|
||||||
|
let entries = fs::read_dir(directory).map_err(|error| {
|
||||||
|
ProxyError::Config(format!(
|
||||||
|
"failed to read WEB static directory `{}`: {error}",
|
||||||
|
directory.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
for entry in entries {
|
||||||
|
let entry = entry.map_err(|error| {
|
||||||
|
ProxyError::Config(format!("failed to read WEB static entry: {error}"))
|
||||||
|
})?;
|
||||||
|
if *total_files >= limits.max_static_files {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"WEB static entries exceed process-wide web.limits.max_static_files"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
*total_files += 1;
|
||||||
|
let path = entry.path();
|
||||||
|
let file_type = entry.file_type().map_err(|error| {
|
||||||
|
ProxyError::Config(format!(
|
||||||
|
"failed to inspect WEB static entry `{}`: {error}",
|
||||||
|
path.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if file_type.is_symlink() {
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"WEB static entry `{}` must not be a symlink",
|
||||||
|
path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if file_type.is_dir() {
|
||||||
|
if depth >= MAX_WEB_STATIC_DEPTH {
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"WEB static directory `{}` exceeds the maximum nesting depth",
|
||||||
|
path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
load_static_directory(
|
||||||
|
root,
|
||||||
|
&path,
|
||||||
|
assets,
|
||||||
|
total_files,
|
||||||
|
total_bytes,
|
||||||
|
limits,
|
||||||
|
depth + 1,
|
||||||
|
)?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !file_type.is_file() {
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"WEB static entry `{}` must be a regular file",
|
||||||
|
path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let mut options = fs::OpenOptions::new();
|
||||||
|
options.read(true);
|
||||||
|
#[cfg(unix)]
|
||||||
|
options.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW);
|
||||||
|
let file = options.open(&path).map_err(|error| {
|
||||||
|
ProxyError::Config(format!(
|
||||||
|
"failed to open WEB static file `{}`: {error}",
|
||||||
|
path.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let metadata = file.metadata().map_err(|error| {
|
||||||
|
ProxyError::Config(format!(
|
||||||
|
"failed to inspect WEB static file `{}`: {error}",
|
||||||
|
path.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if !metadata.is_file() {
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"WEB static entry `{}` changed before it was opened",
|
||||||
|
path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let file_len = usize::try_from(metadata.len()).map_err(|_| {
|
||||||
|
ProxyError::Config(format!("WEB static file `{}` is too large", path.display()))
|
||||||
|
})?;
|
||||||
|
if file_len > limits.max_static_file_bytes {
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"WEB static file `{}` exceeds web.limits.max_static_file_bytes",
|
||||||
|
path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
*total_bytes = total_bytes.checked_add(file_len).ok_or_else(|| {
|
||||||
|
ProxyError::Config("WEB static snapshot byte count overflowed usize".to_string())
|
||||||
|
})?;
|
||||||
|
if *total_bytes > limits.max_static_bytes {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"WEB static snapshots exceed process-wide web.limits.max_static_bytes"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let relative = path.strip_prefix(root).map_err(|_| {
|
||||||
|
ProxyError::Config("WEB static path escaped its configured root".to_string())
|
||||||
|
})?;
|
||||||
|
let route = static_route(relative)?;
|
||||||
|
let mut body = Vec::with_capacity(file_len);
|
||||||
|
file.take(limits.max_static_file_bytes as u64 + 1)
|
||||||
|
.read_to_end(&mut body)
|
||||||
|
.map_err(|error| {
|
||||||
|
ProxyError::Config(format!(
|
||||||
|
"failed to read WEB static file `{}`: {error}",
|
||||||
|
path.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if body.len() != file_len {
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"WEB static file `{}` changed while its snapshot was built",
|
||||||
|
path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let etag = format!("\"{}\"", hex::encode(Sha256::digest(&body)));
|
||||||
|
assets.insert(
|
||||||
|
route,
|
||||||
|
WebStaticAsset {
|
||||||
|
body: Bytes::from(body),
|
||||||
|
content_type: static_content_type(&path),
|
||||||
|
etag,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn static_route(relative: &Path) -> Result<String> {
|
||||||
|
let mut route = String::new();
|
||||||
|
for component in relative.components() {
|
||||||
|
let std::path::Component::Normal(component) = component else {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"WEB static path contains an unsafe component".to_string(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let component = component.to_str().ok_or_else(|| {
|
||||||
|
ProxyError::Config("WEB static file names must be valid UTF-8".to_string())
|
||||||
|
})?;
|
||||||
|
route.push('/');
|
||||||
|
route.push_str(component);
|
||||||
|
}
|
||||||
|
Ok(route)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn static_content_type(path: &Path) -> &'static str {
|
||||||
|
match path.extension().and_then(|extension| extension.to_str()) {
|
||||||
|
Some("html") | Some("htm") => "text/html; charset=utf-8",
|
||||||
|
Some("css") => "text/css; charset=utf-8",
|
||||||
|
Some("js") | Some("mjs") => "text/javascript; charset=utf-8",
|
||||||
|
Some("json") => "application/json",
|
||||||
|
Some("txt") => "text/plain; charset=utf-8",
|
||||||
|
Some("svg") => "image/svg+xml",
|
||||||
|
Some("png") => "image/png",
|
||||||
|
Some("jpg") | Some("jpeg") => "image/jpeg",
|
||||||
|
Some("gif") => "image/gif",
|
||||||
|
Some("webp") => "image/webp",
|
||||||
|
Some("ico") => "image/x-icon",
|
||||||
|
Some("woff") => "font/woff",
|
||||||
|
Some("woff2") => "font/woff2",
|
||||||
|
Some("wasm") => "application/wasm",
|
||||||
|
_ => "application/octet-stream",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use base64::Engine as _;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn capability_matches_reference_vectors() {
|
||||||
|
let secret = hex::decode("000102030405060708090a0b0c0d0e0f").unwrap();
|
||||||
|
let plain = derive_web_capability(&secret, b"proxy.example.com").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(plain),
|
||||||
|
"MHLEY5PmW1GWqJkSrlmJpvJUiLhBH_QKy6yKg8a0JPk"
|
||||||
|
);
|
||||||
|
let mut dd_secret = vec![0xdd];
|
||||||
|
dd_secret.extend_from_slice(&secret);
|
||||||
|
let dd = derive_web_capability(&dd_secret, b"proxy.example.com").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(dd),
|
||||||
|
"IpJrt3e7sKtzPyoXy6w-Zj6GGEvsvclN66JzQEfPYLA"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
-293
@@ -7,6 +7,7 @@ const TOP_LEVEL_CONFIG_KEYS: &[&str] = &[
|
|||||||
"logging",
|
"logging",
|
||||||
"network",
|
"network",
|
||||||
"server",
|
"server",
|
||||||
|
"web",
|
||||||
"timeouts",
|
"timeouts",
|
||||||
"censorship",
|
"censorship",
|
||||||
"access",
|
"access",
|
||||||
@@ -238,6 +239,7 @@ const CONNTRACK_CONTROL_CONFIG_KEYS: &[&str] = &[
|
|||||||
|
|
||||||
const LISTENER_CONFIG_KEYS: &[&str] = &[
|
const LISTENER_CONFIG_KEYS: &[&str] = &[
|
||||||
"ip",
|
"ip",
|
||||||
|
"transport",
|
||||||
"port",
|
"port",
|
||||||
"client_mss",
|
"client_mss",
|
||||||
"synlimit",
|
"synlimit",
|
||||||
@@ -253,6 +255,70 @@ const LISTENER_CONFIG_KEYS: &[&str] = &[
|
|||||||
"announce_ip",
|
"announce_ip",
|
||||||
"proxy_protocol",
|
"proxy_protocol",
|
||||||
"reuse_allow",
|
"reuse_allow",
|
||||||
|
"web_client_ip_source",
|
||||||
|
"web_trusted_proxy_cidrs",
|
||||||
|
];
|
||||||
|
|
||||||
|
const WEB_CONFIG_KEYS: &[&str] = &["enabled", "limits", "timeouts", "vhosts"];
|
||||||
|
|
||||||
|
const WEB_LIMITS_CONFIG_KEYS: &[&str] = &[
|
||||||
|
"max_header_bytes",
|
||||||
|
"max_body_bytes",
|
||||||
|
"max_frame_payload_bytes",
|
||||||
|
"carrier_batch_bytes",
|
||||||
|
"max_frames_per_body",
|
||||||
|
"max_http_connections",
|
||||||
|
"max_http_handlers",
|
||||||
|
"max_body_readers",
|
||||||
|
"max_body_bytes_global",
|
||||||
|
"max_sessions_global",
|
||||||
|
"max_sessions_per_ip",
|
||||||
|
"max_streams_per_session",
|
||||||
|
"max_streams_global",
|
||||||
|
"max_stream_handshakes",
|
||||||
|
"max_tombstones_per_session",
|
||||||
|
"pending_bytes_per_session",
|
||||||
|
"pending_bytes_global",
|
||||||
|
"pending_items_per_session",
|
||||||
|
"pending_items_global",
|
||||||
|
"control_bytes_per_session",
|
||||||
|
"control_bytes_global",
|
||||||
|
"max_bootstraps_global",
|
||||||
|
"max_bootstraps_per_ip",
|
||||||
|
"max_vhosts",
|
||||||
|
"max_profiles",
|
||||||
|
"max_static_files",
|
||||||
|
"max_static_file_bytes",
|
||||||
|
"max_static_bytes",
|
||||||
|
"memory_envelope_bytes",
|
||||||
|
"new_bootstraps_per_minute",
|
||||||
|
"new_bootstraps_burst",
|
||||||
|
"new_sessions_per_minute",
|
||||||
|
"new_sessions_burst",
|
||||||
|
"new_streams_per_minute",
|
||||||
|
"new_streams_burst",
|
||||||
|
];
|
||||||
|
|
||||||
|
const WEB_TIMEOUTS_CONFIG_KEYS: &[&str] = &[
|
||||||
|
"header_secs",
|
||||||
|
"body_secs",
|
||||||
|
"stream_handshake_secs",
|
||||||
|
"long_poll_secs",
|
||||||
|
"bootstrap_lifetime_secs",
|
||||||
|
"reconnect_grace_secs",
|
||||||
|
"http_idle_secs",
|
||||||
|
"shutdown_secs",
|
||||||
|
"decoy_header_secs",
|
||||||
|
];
|
||||||
|
|
||||||
|
const WEB_VHOST_CONFIG_KEYS: &[&str] = &["host", "public_addr", "decoy", "profiles"];
|
||||||
|
const WEB_DECOY_CONFIG_KEYS: &[&str] = &["mode", "upstream", "directory", "index"];
|
||||||
|
const WEB_PROFILE_CONFIG_KEYS: &[&str] = &[
|
||||||
|
"user",
|
||||||
|
"secret_mode",
|
||||||
|
"max_sessions",
|
||||||
|
"max_streams",
|
||||||
|
"max_streams_per_session",
|
||||||
];
|
];
|
||||||
|
|
||||||
const TIMEOUTS_CONFIG_KEYS: &[&str] = &[
|
const TIMEOUTS_CONFIG_KEYS: &[&str] = &[
|
||||||
@@ -366,300 +432,12 @@ const LOGGING_CONFIG_KEYS: &[&str] = &[
|
|||||||
"max_age_secs",
|
"max_age_secs",
|
||||||
];
|
];
|
||||||
|
|
||||||
#[derive(Debug)]
|
// Recursive table traversal and key suggestion logic.
|
||||||
struct UnknownConfigKey {
|
mod check;
|
||||||
path: String,
|
|
||||||
suggestion: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn table_at<'a>(value: &'a toml::Value, path: &[&str]) -> Option<&'a toml::Table> {
|
|
||||||
let mut current = value;
|
|
||||||
for segment in path {
|
|
||||||
current = current.get(*segment)?;
|
|
||||||
}
|
|
||||||
current.as_table()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_strict_config(parsed_toml: &toml::Value) -> bool {
|
|
||||||
table_at(parsed_toml, &["general"])
|
|
||||||
.and_then(|table| table.get("config_strict"))
|
|
||||||
.and_then(toml::Value::as_bool)
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn known_config_keys_for_suggestion() -> Vec<&'static str> {
|
|
||||||
let mut keys = Vec::new();
|
|
||||||
for group in [
|
|
||||||
TOP_LEVEL_CONFIG_KEYS,
|
|
||||||
GENERAL_CONFIG_KEYS,
|
|
||||||
NETWORK_CONFIG_KEYS,
|
|
||||||
SERVER_CONFIG_KEYS,
|
|
||||||
API_CONFIG_KEYS,
|
|
||||||
CONNTRACK_CONTROL_CONFIG_KEYS,
|
|
||||||
LISTENER_CONFIG_KEYS,
|
|
||||||
TIMEOUTS_CONFIG_KEYS,
|
|
||||||
CENSORSHIP_CONFIG_KEYS,
|
|
||||||
TLS_FETCH_CONFIG_KEYS,
|
|
||||||
ACCESS_CONFIG_KEYS,
|
|
||||||
RATE_LIMIT_BPS_CONFIG_KEYS,
|
|
||||||
UPSTREAM_CONFIG_KEYS,
|
|
||||||
PROXY_MODES_CONFIG_KEYS,
|
|
||||||
TELEMETRY_CONFIG_KEYS,
|
|
||||||
LINKS_CONFIG_KEYS,
|
|
||||||
LOGGING_CONFIG_KEYS,
|
|
||||||
] {
|
|
||||||
keys.extend_from_slice(group);
|
|
||||||
}
|
|
||||||
keys
|
|
||||||
}
|
|
||||||
|
|
||||||
fn levenshtein_distance(a: &str, b: &str) -> usize {
|
|
||||||
let b_chars: Vec<char> = b.chars().collect();
|
|
||||||
let mut prev: Vec<usize> = (0..=b_chars.len()).collect();
|
|
||||||
let mut curr = vec![0usize; b_chars.len() + 1];
|
|
||||||
|
|
||||||
for (i, ca) in a.chars().enumerate() {
|
|
||||||
curr[0] = i + 1;
|
|
||||||
for (j, cb) in b_chars.iter().enumerate() {
|
|
||||||
let replace = if ca == *cb { prev[j] } else { prev[j] + 1 };
|
|
||||||
curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(replace);
|
|
||||||
}
|
|
||||||
std::mem::swap(&mut prev, &mut curr);
|
|
||||||
}
|
|
||||||
|
|
||||||
prev[b_chars.len()]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn unknown_key_suggestion(key: &str, known_keys: &[&'static str]) -> Option<String> {
|
|
||||||
let normalized = key.to_ascii_lowercase();
|
|
||||||
let mut best: Option<(&str, usize)> = None;
|
|
||||||
for known in known_keys {
|
|
||||||
let distance = levenshtein_distance(&normalized, known);
|
|
||||||
let is_better = match best {
|
|
||||||
Some((_, best_distance)) => distance < best_distance,
|
|
||||||
None => true,
|
|
||||||
};
|
|
||||||
if distance <= 4 && is_better {
|
|
||||||
best = Some((known, distance));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
best.map(|(known, _)| known.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn push_unknown_keys(
|
|
||||||
unknown: &mut Vec<UnknownConfigKey>,
|
|
||||||
known_for_suggestion: &[&'static str],
|
|
||||||
path: &str,
|
|
||||||
table: &toml::Table,
|
|
||||||
allowed: &[&str],
|
|
||||||
) {
|
|
||||||
for key in table.keys() {
|
|
||||||
if !allowed.contains(&key.as_str()) {
|
|
||||||
let full_path = if path.is_empty() {
|
|
||||||
key.clone()
|
|
||||||
} else {
|
|
||||||
format!("{path}.{key}")
|
|
||||||
};
|
|
||||||
unknown.push(UnknownConfigKey {
|
|
||||||
path: full_path,
|
|
||||||
suggestion: unknown_key_suggestion(key, known_for_suggestion),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn check_known_table(
|
|
||||||
parsed_toml: &toml::Value,
|
|
||||||
unknown: &mut Vec<UnknownConfigKey>,
|
|
||||||
known_for_suggestion: &[&'static str],
|
|
||||||
path: &[&str],
|
|
||||||
allowed: &[&str],
|
|
||||||
) {
|
|
||||||
if let Some(table) = table_at(parsed_toml, path) {
|
|
||||||
push_unknown_keys(
|
|
||||||
unknown,
|
|
||||||
known_for_suggestion,
|
|
||||||
&path.join("."),
|
|
||||||
table,
|
|
||||||
allowed,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn check_nested_table_value(
|
|
||||||
unknown: &mut Vec<UnknownConfigKey>,
|
|
||||||
known_for_suggestion: &[&'static str],
|
|
||||||
path: String,
|
|
||||||
value: &toml::Value,
|
|
||||||
allowed: &[&str],
|
|
||||||
) {
|
|
||||||
if let Some(table) = value.as_table() {
|
|
||||||
push_unknown_keys(unknown, known_for_suggestion, &path, table, allowed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn collect_unknown_config_keys(parsed_toml: &toml::Value) -> Vec<UnknownConfigKey> {
|
|
||||||
let known_for_suggestion = known_config_keys_for_suggestion();
|
|
||||||
let mut unknown = Vec::new();
|
|
||||||
|
|
||||||
if let Some(root) = parsed_toml.as_table() {
|
|
||||||
push_unknown_keys(
|
|
||||||
&mut unknown,
|
|
||||||
&known_for_suggestion,
|
|
||||||
"",
|
|
||||||
root,
|
|
||||||
TOP_LEVEL_CONFIG_KEYS,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
check_known_table(
|
|
||||||
parsed_toml,
|
|
||||||
&mut unknown,
|
|
||||||
&known_for_suggestion,
|
|
||||||
&["general"],
|
|
||||||
GENERAL_CONFIG_KEYS,
|
|
||||||
);
|
|
||||||
check_known_table(
|
|
||||||
parsed_toml,
|
|
||||||
&mut unknown,
|
|
||||||
&known_for_suggestion,
|
|
||||||
&["general", "modes"],
|
|
||||||
PROXY_MODES_CONFIG_KEYS,
|
|
||||||
);
|
|
||||||
check_known_table(
|
|
||||||
parsed_toml,
|
|
||||||
&mut unknown,
|
|
||||||
&known_for_suggestion,
|
|
||||||
&["general", "telemetry"],
|
|
||||||
TELEMETRY_CONFIG_KEYS,
|
|
||||||
);
|
|
||||||
check_known_table(
|
|
||||||
parsed_toml,
|
|
||||||
&mut unknown,
|
|
||||||
&known_for_suggestion,
|
|
||||||
&["general", "links"],
|
|
||||||
LINKS_CONFIG_KEYS,
|
|
||||||
);
|
|
||||||
check_known_table(
|
|
||||||
parsed_toml,
|
|
||||||
&mut unknown,
|
|
||||||
&known_for_suggestion,
|
|
||||||
&["logging"],
|
|
||||||
LOGGING_CONFIG_KEYS,
|
|
||||||
);
|
|
||||||
check_known_table(
|
|
||||||
parsed_toml,
|
|
||||||
&mut unknown,
|
|
||||||
&known_for_suggestion,
|
|
||||||
&["network"],
|
|
||||||
NETWORK_CONFIG_KEYS,
|
|
||||||
);
|
|
||||||
check_known_table(
|
|
||||||
parsed_toml,
|
|
||||||
&mut unknown,
|
|
||||||
&known_for_suggestion,
|
|
||||||
&["server"],
|
|
||||||
SERVER_CONFIG_KEYS,
|
|
||||||
);
|
|
||||||
check_known_table(
|
|
||||||
parsed_toml,
|
|
||||||
&mut unknown,
|
|
||||||
&known_for_suggestion,
|
|
||||||
&["server", "api"],
|
|
||||||
API_CONFIG_KEYS,
|
|
||||||
);
|
|
||||||
check_known_table(
|
|
||||||
parsed_toml,
|
|
||||||
&mut unknown,
|
|
||||||
&known_for_suggestion,
|
|
||||||
&["server", "admin_api"],
|
|
||||||
API_CONFIG_KEYS,
|
|
||||||
);
|
|
||||||
check_known_table(
|
|
||||||
parsed_toml,
|
|
||||||
&mut unknown,
|
|
||||||
&known_for_suggestion,
|
|
||||||
&["server", "conntrack_control"],
|
|
||||||
CONNTRACK_CONTROL_CONFIG_KEYS,
|
|
||||||
);
|
|
||||||
check_known_table(
|
|
||||||
parsed_toml,
|
|
||||||
&mut unknown,
|
|
||||||
&known_for_suggestion,
|
|
||||||
&["timeouts"],
|
|
||||||
TIMEOUTS_CONFIG_KEYS,
|
|
||||||
);
|
|
||||||
check_known_table(
|
|
||||||
parsed_toml,
|
|
||||||
&mut unknown,
|
|
||||||
&known_for_suggestion,
|
|
||||||
&["censorship"],
|
|
||||||
CENSORSHIP_CONFIG_KEYS,
|
|
||||||
);
|
|
||||||
check_known_table(
|
|
||||||
parsed_toml,
|
|
||||||
&mut unknown,
|
|
||||||
&known_for_suggestion,
|
|
||||||
&["censorship", "tls_fetch"],
|
|
||||||
TLS_FETCH_CONFIG_KEYS,
|
|
||||||
);
|
|
||||||
check_known_table(
|
|
||||||
parsed_toml,
|
|
||||||
&mut unknown,
|
|
||||||
&known_for_suggestion,
|
|
||||||
&["access"],
|
|
||||||
ACCESS_CONFIG_KEYS,
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Some(listeners) = table_at(parsed_toml, &["server"])
|
|
||||||
.and_then(|table| table.get("listeners"))
|
|
||||||
.and_then(toml::Value::as_array)
|
|
||||||
{
|
|
||||||
for (idx, listener) in listeners.iter().enumerate() {
|
|
||||||
check_nested_table_value(
|
|
||||||
&mut unknown,
|
|
||||||
&known_for_suggestion,
|
|
||||||
format!("server.listeners[{idx}]"),
|
|
||||||
listener,
|
|
||||||
LISTENER_CONFIG_KEYS,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(upstreams) = parsed_toml.get("upstreams").and_then(toml::Value::as_array) {
|
|
||||||
for (idx, upstream) in upstreams.iter().enumerate() {
|
|
||||||
check_nested_table_value(
|
|
||||||
&mut unknown,
|
|
||||||
&known_for_suggestion,
|
|
||||||
format!("upstreams[{idx}]"),
|
|
||||||
upstream,
|
|
||||||
UPSTREAM_CONFIG_KEYS,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for access_map in ["user_rate_limits", "cidr_rate_limits"] {
|
|
||||||
if let Some(table) = table_at(parsed_toml, &["access"])
|
|
||||||
.and_then(|access| access.get(access_map))
|
|
||||||
.and_then(toml::Value::as_table)
|
|
||||||
{
|
|
||||||
for (entry_name, value) in table {
|
|
||||||
check_nested_table_value(
|
|
||||||
&mut unknown,
|
|
||||||
&known_for_suggestion,
|
|
||||||
format!("access.{access_map}.{entry_name}"),
|
|
||||||
value,
|
|
||||||
RATE_LIMIT_BPS_CONFIG_KEYS,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/// Rejects or reports unknown configuration keys according to strict mode.
|
||||||
pub(super) fn handle_unknown_config_keys(parsed_toml: &toml::Value) -> Result<()> {
|
pub(super) fn handle_unknown_config_keys(parsed_toml: &toml::Value) -> Result<()> {
|
||||||
let unknown = collect_unknown_config_keys(parsed_toml);
|
let unknown = check::collect_unknown_config_keys(parsed_toml);
|
||||||
if unknown.is_empty() {
|
if unknown.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -676,7 +454,7 @@ pub(super) fn handle_unknown_config_keys(parsed_toml: &toml::Value) -> Result<()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if is_strict_config(parsed_toml) {
|
if check::is_strict_config(parsed_toml) {
|
||||||
let mut paths = Vec::with_capacity(unknown.len());
|
let mut paths = Vec::with_capacity(unknown.len());
|
||||||
for item in unknown {
|
for item in unknown {
|
||||||
if let Some(suggestion) = item.suggestion {
|
if let Some(suggestion) = item.suggestion {
|
||||||
|
|||||||
@@ -0,0 +1,362 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
/// One rejected configuration path and its optional nearest known key.
|
||||||
|
pub(super) struct UnknownConfigKey {
|
||||||
|
/// Fully qualified configuration path.
|
||||||
|
pub(super) path: String,
|
||||||
|
/// Nearest known key when edit distance is sufficiently small.
|
||||||
|
pub(super) suggestion: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn table_at<'a>(value: &'a toml::Value, path: &[&str]) -> Option<&'a toml::Table> {
|
||||||
|
let mut current = value;
|
||||||
|
for segment in path {
|
||||||
|
current = current.get(*segment)?;
|
||||||
|
}
|
||||||
|
current.as_table()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads strict-key enforcement without deserializing the full configuration.
|
||||||
|
pub(super) fn is_strict_config(parsed_toml: &toml::Value) -> bool {
|
||||||
|
table_at(parsed_toml, &["general"])
|
||||||
|
.and_then(|table| table.get("config_strict"))
|
||||||
|
.and_then(toml::Value::as_bool)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn known_config_keys_for_suggestion() -> Vec<&'static str> {
|
||||||
|
let mut keys = Vec::new();
|
||||||
|
for group in [
|
||||||
|
TOP_LEVEL_CONFIG_KEYS,
|
||||||
|
GENERAL_CONFIG_KEYS,
|
||||||
|
NETWORK_CONFIG_KEYS,
|
||||||
|
SERVER_CONFIG_KEYS,
|
||||||
|
API_CONFIG_KEYS,
|
||||||
|
CONNTRACK_CONTROL_CONFIG_KEYS,
|
||||||
|
LISTENER_CONFIG_KEYS,
|
||||||
|
WEB_CONFIG_KEYS,
|
||||||
|
WEB_LIMITS_CONFIG_KEYS,
|
||||||
|
WEB_TIMEOUTS_CONFIG_KEYS,
|
||||||
|
WEB_VHOST_CONFIG_KEYS,
|
||||||
|
WEB_DECOY_CONFIG_KEYS,
|
||||||
|
WEB_PROFILE_CONFIG_KEYS,
|
||||||
|
TIMEOUTS_CONFIG_KEYS,
|
||||||
|
CENSORSHIP_CONFIG_KEYS,
|
||||||
|
TLS_FETCH_CONFIG_KEYS,
|
||||||
|
ACCESS_CONFIG_KEYS,
|
||||||
|
RATE_LIMIT_BPS_CONFIG_KEYS,
|
||||||
|
UPSTREAM_CONFIG_KEYS,
|
||||||
|
PROXY_MODES_CONFIG_KEYS,
|
||||||
|
TELEMETRY_CONFIG_KEYS,
|
||||||
|
LINKS_CONFIG_KEYS,
|
||||||
|
LOGGING_CONFIG_KEYS,
|
||||||
|
] {
|
||||||
|
keys.extend_from_slice(group);
|
||||||
|
}
|
||||||
|
keys
|
||||||
|
}
|
||||||
|
|
||||||
|
fn levenshtein_distance(a: &str, b: &str) -> usize {
|
||||||
|
let b_chars: Vec<char> = b.chars().collect();
|
||||||
|
let mut prev: Vec<usize> = (0..=b_chars.len()).collect();
|
||||||
|
let mut curr = vec![0usize; b_chars.len() + 1];
|
||||||
|
|
||||||
|
for (i, ca) in a.chars().enumerate() {
|
||||||
|
curr[0] = i + 1;
|
||||||
|
for (j, cb) in b_chars.iter().enumerate() {
|
||||||
|
let replace = if ca == *cb { prev[j] } else { prev[j] + 1 };
|
||||||
|
curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(replace);
|
||||||
|
}
|
||||||
|
std::mem::swap(&mut prev, &mut curr);
|
||||||
|
}
|
||||||
|
|
||||||
|
prev[b_chars.len()]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unknown_key_suggestion(key: &str, known_keys: &[&'static str]) -> Option<String> {
|
||||||
|
let normalized = key.to_ascii_lowercase();
|
||||||
|
let mut best: Option<(&str, usize)> = None;
|
||||||
|
for known in known_keys {
|
||||||
|
let distance = levenshtein_distance(&normalized, known);
|
||||||
|
let is_better = match best {
|
||||||
|
Some((_, best_distance)) => distance < best_distance,
|
||||||
|
None => true,
|
||||||
|
};
|
||||||
|
if distance <= 4 && is_better {
|
||||||
|
best = Some((known, distance));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best.map(|(known, _)| known.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_unknown_keys(
|
||||||
|
unknown: &mut Vec<UnknownConfigKey>,
|
||||||
|
known_for_suggestion: &[&'static str],
|
||||||
|
path: &str,
|
||||||
|
table: &toml::Table,
|
||||||
|
allowed: &[&str],
|
||||||
|
) {
|
||||||
|
for key in table.keys() {
|
||||||
|
if !allowed.contains(&key.as_str()) {
|
||||||
|
let full_path = if path.is_empty() {
|
||||||
|
key.clone()
|
||||||
|
} else {
|
||||||
|
format!("{path}.{key}")
|
||||||
|
};
|
||||||
|
unknown.push(UnknownConfigKey {
|
||||||
|
path: full_path,
|
||||||
|
suggestion: unknown_key_suggestion(key, known_for_suggestion),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_known_table(
|
||||||
|
parsed_toml: &toml::Value,
|
||||||
|
unknown: &mut Vec<UnknownConfigKey>,
|
||||||
|
known_for_suggestion: &[&'static str],
|
||||||
|
path: &[&str],
|
||||||
|
allowed: &[&str],
|
||||||
|
) {
|
||||||
|
if let Some(table) = table_at(parsed_toml, path) {
|
||||||
|
push_unknown_keys(
|
||||||
|
unknown,
|
||||||
|
known_for_suggestion,
|
||||||
|
&path.join("."),
|
||||||
|
table,
|
||||||
|
allowed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_nested_table_value(
|
||||||
|
unknown: &mut Vec<UnknownConfigKey>,
|
||||||
|
known_for_suggestion: &[&'static str],
|
||||||
|
path: String,
|
||||||
|
value: &toml::Value,
|
||||||
|
allowed: &[&str],
|
||||||
|
) {
|
||||||
|
if let Some(table) = value.as_table() {
|
||||||
|
push_unknown_keys(unknown, known_for_suggestion, &path, table, allowed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collects unknown keys across every supported nested configuration table.
|
||||||
|
pub(super) fn collect_unknown_config_keys(parsed_toml: &toml::Value) -> Vec<UnknownConfigKey> {
|
||||||
|
let known_for_suggestion = known_config_keys_for_suggestion();
|
||||||
|
let mut unknown = Vec::new();
|
||||||
|
|
||||||
|
if let Some(root) = parsed_toml.as_table() {
|
||||||
|
push_unknown_keys(
|
||||||
|
&mut unknown,
|
||||||
|
&known_for_suggestion,
|
||||||
|
"",
|
||||||
|
root,
|
||||||
|
TOP_LEVEL_CONFIG_KEYS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
check_known_table(
|
||||||
|
parsed_toml,
|
||||||
|
&mut unknown,
|
||||||
|
&known_for_suggestion,
|
||||||
|
&["general"],
|
||||||
|
GENERAL_CONFIG_KEYS,
|
||||||
|
);
|
||||||
|
check_known_table(
|
||||||
|
parsed_toml,
|
||||||
|
&mut unknown,
|
||||||
|
&known_for_suggestion,
|
||||||
|
&["general", "modes"],
|
||||||
|
PROXY_MODES_CONFIG_KEYS,
|
||||||
|
);
|
||||||
|
check_known_table(
|
||||||
|
parsed_toml,
|
||||||
|
&mut unknown,
|
||||||
|
&known_for_suggestion,
|
||||||
|
&["general", "telemetry"],
|
||||||
|
TELEMETRY_CONFIG_KEYS,
|
||||||
|
);
|
||||||
|
check_known_table(
|
||||||
|
parsed_toml,
|
||||||
|
&mut unknown,
|
||||||
|
&known_for_suggestion,
|
||||||
|
&["general", "links"],
|
||||||
|
LINKS_CONFIG_KEYS,
|
||||||
|
);
|
||||||
|
check_known_table(
|
||||||
|
parsed_toml,
|
||||||
|
&mut unknown,
|
||||||
|
&known_for_suggestion,
|
||||||
|
&["logging"],
|
||||||
|
LOGGING_CONFIG_KEYS,
|
||||||
|
);
|
||||||
|
check_known_table(
|
||||||
|
parsed_toml,
|
||||||
|
&mut unknown,
|
||||||
|
&known_for_suggestion,
|
||||||
|
&["network"],
|
||||||
|
NETWORK_CONFIG_KEYS,
|
||||||
|
);
|
||||||
|
check_known_table(
|
||||||
|
parsed_toml,
|
||||||
|
&mut unknown,
|
||||||
|
&known_for_suggestion,
|
||||||
|
&["server"],
|
||||||
|
SERVER_CONFIG_KEYS,
|
||||||
|
);
|
||||||
|
check_known_table(
|
||||||
|
parsed_toml,
|
||||||
|
&mut unknown,
|
||||||
|
&known_for_suggestion,
|
||||||
|
&["server", "api"],
|
||||||
|
API_CONFIG_KEYS,
|
||||||
|
);
|
||||||
|
check_known_table(
|
||||||
|
parsed_toml,
|
||||||
|
&mut unknown,
|
||||||
|
&known_for_suggestion,
|
||||||
|
&["server", "admin_api"],
|
||||||
|
API_CONFIG_KEYS,
|
||||||
|
);
|
||||||
|
check_known_table(
|
||||||
|
parsed_toml,
|
||||||
|
&mut unknown,
|
||||||
|
&known_for_suggestion,
|
||||||
|
&["server", "conntrack_control"],
|
||||||
|
CONNTRACK_CONTROL_CONFIG_KEYS,
|
||||||
|
);
|
||||||
|
check_known_table(
|
||||||
|
parsed_toml,
|
||||||
|
&mut unknown,
|
||||||
|
&known_for_suggestion,
|
||||||
|
&["web"],
|
||||||
|
WEB_CONFIG_KEYS,
|
||||||
|
);
|
||||||
|
check_known_table(
|
||||||
|
parsed_toml,
|
||||||
|
&mut unknown,
|
||||||
|
&known_for_suggestion,
|
||||||
|
&["web", "limits"],
|
||||||
|
WEB_LIMITS_CONFIG_KEYS,
|
||||||
|
);
|
||||||
|
check_known_table(
|
||||||
|
parsed_toml,
|
||||||
|
&mut unknown,
|
||||||
|
&known_for_suggestion,
|
||||||
|
&["web", "timeouts"],
|
||||||
|
WEB_TIMEOUTS_CONFIG_KEYS,
|
||||||
|
);
|
||||||
|
check_known_table(
|
||||||
|
parsed_toml,
|
||||||
|
&mut unknown,
|
||||||
|
&known_for_suggestion,
|
||||||
|
&["timeouts"],
|
||||||
|
TIMEOUTS_CONFIG_KEYS,
|
||||||
|
);
|
||||||
|
check_known_table(
|
||||||
|
parsed_toml,
|
||||||
|
&mut unknown,
|
||||||
|
&known_for_suggestion,
|
||||||
|
&["censorship"],
|
||||||
|
CENSORSHIP_CONFIG_KEYS,
|
||||||
|
);
|
||||||
|
check_known_table(
|
||||||
|
parsed_toml,
|
||||||
|
&mut unknown,
|
||||||
|
&known_for_suggestion,
|
||||||
|
&["censorship", "tls_fetch"],
|
||||||
|
TLS_FETCH_CONFIG_KEYS,
|
||||||
|
);
|
||||||
|
check_known_table(
|
||||||
|
parsed_toml,
|
||||||
|
&mut unknown,
|
||||||
|
&known_for_suggestion,
|
||||||
|
&["access"],
|
||||||
|
ACCESS_CONFIG_KEYS,
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(listeners) = table_at(parsed_toml, &["server"])
|
||||||
|
.and_then(|table| table.get("listeners"))
|
||||||
|
.and_then(toml::Value::as_array)
|
||||||
|
{
|
||||||
|
for (idx, listener) in listeners.iter().enumerate() {
|
||||||
|
check_nested_table_value(
|
||||||
|
&mut unknown,
|
||||||
|
&known_for_suggestion,
|
||||||
|
format!("server.listeners[{idx}]"),
|
||||||
|
listener,
|
||||||
|
LISTENER_CONFIG_KEYS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(vhosts) = table_at(parsed_toml, &["web"])
|
||||||
|
.and_then(|table| table.get("vhosts"))
|
||||||
|
.and_then(toml::Value::as_array)
|
||||||
|
{
|
||||||
|
for (vhost_idx, vhost) in vhosts.iter().enumerate() {
|
||||||
|
check_nested_table_value(
|
||||||
|
&mut unknown,
|
||||||
|
&known_for_suggestion,
|
||||||
|
format!("web.vhosts[{vhost_idx}]"),
|
||||||
|
vhost,
|
||||||
|
WEB_VHOST_CONFIG_KEYS,
|
||||||
|
);
|
||||||
|
if let Some(vhost) = vhost.as_table() {
|
||||||
|
if let Some(decoy) = vhost.get("decoy") {
|
||||||
|
check_nested_table_value(
|
||||||
|
&mut unknown,
|
||||||
|
&known_for_suggestion,
|
||||||
|
format!("web.vhosts[{vhost_idx}].decoy"),
|
||||||
|
decoy,
|
||||||
|
WEB_DECOY_CONFIG_KEYS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(profiles) = vhost.get("profiles").and_then(toml::Value::as_array) {
|
||||||
|
for (profile_idx, profile) in profiles.iter().enumerate() {
|
||||||
|
check_nested_table_value(
|
||||||
|
&mut unknown,
|
||||||
|
&known_for_suggestion,
|
||||||
|
format!("web.vhosts[{vhost_idx}].profiles[{profile_idx}]"),
|
||||||
|
profile,
|
||||||
|
WEB_PROFILE_CONFIG_KEYS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(upstreams) = parsed_toml.get("upstreams").and_then(toml::Value::as_array) {
|
||||||
|
for (idx, upstream) in upstreams.iter().enumerate() {
|
||||||
|
check_nested_table_value(
|
||||||
|
&mut unknown,
|
||||||
|
&known_for_suggestion,
|
||||||
|
format!("upstreams[{idx}]"),
|
||||||
|
upstream,
|
||||||
|
UPSTREAM_CONFIG_KEYS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for access_map in ["user_rate_limits", "cidr_rate_limits"] {
|
||||||
|
if let Some(table) = table_at(parsed_toml, &["access"])
|
||||||
|
.and_then(|access| access.get(access_map))
|
||||||
|
.and_then(toml::Value::as_table)
|
||||||
|
{
|
||||||
|
for (entry_name, value) in table {
|
||||||
|
check_nested_table_value(
|
||||||
|
&mut unknown,
|
||||||
|
&known_for_suggestion,
|
||||||
|
format!("access.{access_map}.{entry_name}"),
|
||||||
|
value,
|
||||||
|
RATE_LIMIT_BPS_CONFIG_KEYS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unknown
|
||||||
|
}
|
||||||
@@ -0,0 +1,545 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const WEB_FRAME_HEADER_BYTES: usize = 8;
|
||||||
|
const WEB_QUEUE_ITEM_COST: usize = 256;
|
||||||
|
const WEB_CONTROL_EXTRA_ITEMS: usize = 16;
|
||||||
|
const WEB_CONTROL_ITEMS_PER_STREAM: usize = 3;
|
||||||
|
const WEB_INITIAL_STREAM_WINDOW: usize = 4 * 1024 * 1024;
|
||||||
|
const MAX_WEB_HEADER_BYTES: usize = 64 * 1024;
|
||||||
|
const MAX_WEB_BODY_BYTES: usize = 16 * 1024 * 1024;
|
||||||
|
const MAX_WEB_FRAME_BYTES: usize = 1024 * 1024;
|
||||||
|
const MAX_WEB_FRAMES_PER_BODY: usize = 4096;
|
||||||
|
const MAX_WEB_TOMBSTONES_PER_SESSION: usize = 4096;
|
||||||
|
const MAX_WEB_MEMORY_ENVELOPE_BYTES: usize = 4 * 1024 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Validates WEB policy and resource bounds before building runtime state.
|
||||||
|
pub(super) fn validate(config: &mut ProxyConfig) -> Result<()> {
|
||||||
|
let web_listener_count = config
|
||||||
|
.server
|
||||||
|
.listeners
|
||||||
|
.iter()
|
||||||
|
.filter(|listener| listener.transport == ListenerTransport::Web)
|
||||||
|
.count();
|
||||||
|
let eligible_web_listener_count = 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))
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
|
||||||
|
for (idx, listener) in config.server.listeners.iter().enumerate() {
|
||||||
|
match listener.transport {
|
||||||
|
ListenerTransport::Mtproxy => {
|
||||||
|
if !listener.web_trusted_proxy_cidrs.is_empty() {
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"server.listeners[{idx}].web_trusted_proxy_cidrs is only valid for transport=web"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ListenerTransport::Web => validate_web_listener(config, idx, listener)?,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.web.enabled && eligible_web_listener_count == 0 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"web.enabled requires at least one network-eligible server.listeners entry with transport=web"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if web_listener_count > 0 && config.web.vhosts.is_empty() {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"WEB listeners require at least one [[web.vhosts]] entry".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_limits(&config.web.limits)?;
|
||||||
|
validate_timeouts(&config.web.timeouts)?;
|
||||||
|
validate_vhosts(config)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_web_listener(
|
||||||
|
config: &ProxyConfig,
|
||||||
|
idx: usize,
|
||||||
|
listener: &ListenerConfig,
|
||||||
|
) -> Result<()> {
|
||||||
|
if listener.web_trusted_proxy_cidrs.is_empty() {
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"server.listeners[{idx}].web_trusted_proxy_cidrs must be non-empty for transport=web"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if listener
|
||||||
|
.web_trusted_proxy_cidrs
|
||||||
|
.iter()
|
||||||
|
.any(|network| network.prefix() == 0)
|
||||||
|
{
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"server.listeners[{idx}].web_trusted_proxy_cidrs must not contain a /0 network"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let proxy_protocol = listener
|
||||||
|
.proxy_protocol
|
||||||
|
.unwrap_or(config.server.proxy_protocol);
|
||||||
|
if proxy_protocol {
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"server.listeners[{idx}].proxy_protocol must be false for transport=web; WEB identity is accepted only from the configured L7 header"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if listener.reuse_allow {
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"server.listeners[{idx}].reuse_allow is not supported for transport=web without external session affinity"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if listener.client_mss.is_some()
|
||||||
|
|| listener.synlimit != SynLimitMode::Off
|
||||||
|
|| listener.announce.is_some()
|
||||||
|
|| listener.announce_ip.is_some()
|
||||||
|
{
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"server.listeners[{idx}] WEB transport does not accept client_mss, synlimit, announce, or announce_ip"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_limits(limits: &WebLimitsConfig) -> Result<()> {
|
||||||
|
if !(8192..=MAX_WEB_HEADER_BYTES).contains(&limits.max_header_bytes) {
|
||||||
|
return config_error("web.limits.max_header_bytes must be within [8192, 65536]");
|
||||||
|
}
|
||||||
|
if !(WEB_FRAME_HEADER_BYTES..=MAX_WEB_BODY_BYTES).contains(&limits.max_body_bytes) {
|
||||||
|
return config_error("web.limits.max_body_bytes must be within [8, 16777216]");
|
||||||
|
}
|
||||||
|
if !(1..=MAX_WEB_FRAME_BYTES).contains(&limits.max_frame_payload_bytes) {
|
||||||
|
return config_error("web.limits.max_frame_payload_bytes must be within [1, 1048576]");
|
||||||
|
}
|
||||||
|
if !(1..=MAX_WEB_FRAMES_PER_BODY).contains(&limits.max_frames_per_body) {
|
||||||
|
return config_error("web.limits.max_frames_per_body must be within [1, 4096]");
|
||||||
|
}
|
||||||
|
if !(1..=MAX_WEB_TOMBSTONES_PER_SESSION).contains(&limits.max_tombstones_per_session) {
|
||||||
|
return config_error("web.limits.max_tombstones_per_session must be within [1, 4096]");
|
||||||
|
}
|
||||||
|
if limits.carrier_batch_bytes > limits.max_body_bytes
|
||||||
|
|| limits.carrier_batch_bytes
|
||||||
|
< limits
|
||||||
|
.max_frame_payload_bytes
|
||||||
|
.saturating_add(WEB_FRAME_HEADER_BYTES)
|
||||||
|
{
|
||||||
|
return config_error(
|
||||||
|
"web.limits.carrier_batch_bytes must fit max_body_bytes and one maximum frame",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if limits.max_frame_payload_bytes > WEB_INITIAL_STREAM_WINDOW {
|
||||||
|
return config_error(
|
||||||
|
"web.limits.max_frame_payload_bytes must not exceed the initial stream window",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let positive = [
|
||||||
|
("max_http_connections", limits.max_http_connections),
|
||||||
|
("max_http_handlers", limits.max_http_handlers),
|
||||||
|
("max_body_readers", limits.max_body_readers),
|
||||||
|
("max_body_bytes_global", limits.max_body_bytes_global),
|
||||||
|
("max_sessions_global", limits.max_sessions_global),
|
||||||
|
("max_sessions_per_ip", limits.max_sessions_per_ip),
|
||||||
|
("max_streams_per_session", limits.max_streams_per_session),
|
||||||
|
("max_streams_global", limits.max_streams_global),
|
||||||
|
("max_stream_handshakes", limits.max_stream_handshakes),
|
||||||
|
("pending_bytes_per_session", limits.pending_bytes_per_session),
|
||||||
|
("pending_bytes_global", limits.pending_bytes_global),
|
||||||
|
("pending_items_per_session", limits.pending_items_per_session),
|
||||||
|
("pending_items_global", limits.pending_items_global),
|
||||||
|
("control_bytes_per_session", limits.control_bytes_per_session),
|
||||||
|
("control_bytes_global", limits.control_bytes_global),
|
||||||
|
("max_bootstraps_global", limits.max_bootstraps_global),
|
||||||
|
("max_bootstraps_per_ip", limits.max_bootstraps_per_ip),
|
||||||
|
("max_vhosts", limits.max_vhosts),
|
||||||
|
("max_profiles", limits.max_profiles),
|
||||||
|
("max_static_files", limits.max_static_files),
|
||||||
|
("max_static_file_bytes", limits.max_static_file_bytes),
|
||||||
|
("max_static_bytes", limits.max_static_bytes),
|
||||||
|
("memory_envelope_bytes", limits.memory_envelope_bytes),
|
||||||
|
];
|
||||||
|
if let Some((field, _)) = positive.into_iter().find(|(_, value)| *value == 0) {
|
||||||
|
return config_error(&format!("web.limits.{field} must be > 0"));
|
||||||
|
}
|
||||||
|
for (field, value) in [
|
||||||
|
("max_http_connections", limits.max_http_connections),
|
||||||
|
("max_http_handlers", limits.max_http_handlers),
|
||||||
|
("max_body_readers", limits.max_body_readers),
|
||||||
|
("max_body_bytes_global", limits.max_body_bytes_global),
|
||||||
|
("max_stream_handshakes", limits.max_stream_handshakes),
|
||||||
|
] {
|
||||||
|
if value > tokio::sync::Semaphore::MAX_PERMITS {
|
||||||
|
return config_error(&format!("web.limits.{field} exceeds Tokio semaphore capacity"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let rates = [
|
||||||
|
("new_bootstraps_per_minute", limits.new_bootstraps_per_minute),
|
||||||
|
("new_bootstraps_burst", limits.new_bootstraps_burst),
|
||||||
|
("new_sessions_per_minute", limits.new_sessions_per_minute),
|
||||||
|
("new_sessions_burst", limits.new_sessions_burst),
|
||||||
|
("new_streams_per_minute", limits.new_streams_per_minute),
|
||||||
|
("new_streams_burst", limits.new_streams_burst),
|
||||||
|
];
|
||||||
|
if let Some((field, _)) = rates.into_iter().find(|(_, value)| *value == 0) {
|
||||||
|
return config_error(&format!("web.limits.{field} must be > 0"));
|
||||||
|
}
|
||||||
|
if limits.max_streams_per_session > u16::MAX as usize {
|
||||||
|
return config_error("web.limits.max_streams_per_session must fit synthetic source ports");
|
||||||
|
}
|
||||||
|
if limits.max_sessions_per_ip > limits.max_sessions_global
|
||||||
|
|| limits.max_streams_per_session > limits.max_streams_global
|
||||||
|
|| limits.max_stream_handshakes > limits.max_streams_global
|
||||||
|
|| limits.max_bootstraps_per_ip > limits.max_bootstraps_global
|
||||||
|
|| limits.max_http_handlers > limits.max_http_connections
|
||||||
|
|| limits.max_body_readers > limits.max_http_handlers
|
||||||
|
|| limits.pending_bytes_per_session > limits.pending_bytes_global
|
||||||
|
|| limits.pending_items_per_session > limits.pending_items_global
|
||||||
|
|| limits.control_bytes_per_session > limits.control_bytes_global
|
||||||
|
|| limits.control_bytes_per_session > limits.pending_bytes_per_session
|
||||||
|
|| limits.control_bytes_global > limits.pending_bytes_global
|
||||||
|
|| limits.max_static_file_bytes > limits.max_static_bytes
|
||||||
|
{
|
||||||
|
return config_error("web.limits per-owner ceilings must not exceed global ceilings");
|
||||||
|
}
|
||||||
|
let control_items_per_session = WEB_CONTROL_EXTRA_ITEMS
|
||||||
|
.checked_add(
|
||||||
|
limits
|
||||||
|
.max_streams_per_session
|
||||||
|
.checked_mul(WEB_CONTROL_ITEMS_PER_STREAM)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ProxyError::Config(
|
||||||
|
"web.limits control item reservation overflowed usize".to_string(),
|
||||||
|
)
|
||||||
|
})?,
|
||||||
|
)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ProxyError::Config("web.limits control item reservation overflowed usize".to_string())
|
||||||
|
})?;
|
||||||
|
let control_items_global = control_items_per_session
|
||||||
|
.checked_mul(limits.max_sessions_global)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ProxyError::Config("web.limits global control reservation overflowed usize".to_string())
|
||||||
|
})?;
|
||||||
|
let control_frame_cost = WEB_FRAME_HEADER_BYTES + 4 + WEB_QUEUE_ITEM_COST;
|
||||||
|
let required_control_bytes_per_session = control_items_per_session
|
||||||
|
.checked_mul(control_frame_cost)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ProxyError::Config("web.limits control byte reservation overflowed usize".to_string())
|
||||||
|
})?;
|
||||||
|
let required_control_bytes_global = control_items_global
|
||||||
|
.checked_mul(control_frame_cost)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ProxyError::Config("web.limits global control byte reservation overflowed usize".to_string())
|
||||||
|
})?;
|
||||||
|
if control_items_per_session >= limits.pending_items_per_session
|
||||||
|
|| control_items_global >= limits.pending_items_global
|
||||||
|
|| required_control_bytes_per_session > limits.control_bytes_per_session
|
||||||
|
|| required_control_bytes_global > limits.control_bytes_global
|
||||||
|
{
|
||||||
|
return config_error(
|
||||||
|
"web.limits control reserves must cover bounded control frames and leave data capacity",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let uplink_bytes = limits
|
||||||
|
.max_frames_per_body
|
||||||
|
.checked_mul(WEB_QUEUE_ITEM_COST)
|
||||||
|
.and_then(|value| value.checked_add(limits.max_body_bytes))
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ProxyError::Config("web.limits uplink reservation overflowed usize".to_string())
|
||||||
|
})?;
|
||||||
|
let minimum_downlink_frame_bytes = WEB_FRAME_HEADER_BYTES + 1 + WEB_QUEUE_ITEM_COST;
|
||||||
|
let session_required_bytes = limits
|
||||||
|
.control_bytes_per_session
|
||||||
|
.checked_add(uplink_bytes)
|
||||||
|
.and_then(|value| value.checked_add(minimum_downlink_frame_bytes))
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ProxyError::Config("web.limits session reservation overflowed usize".to_string())
|
||||||
|
})?;
|
||||||
|
let session_required_items = control_items_per_session
|
||||||
|
.checked_add(limits.max_frames_per_body)
|
||||||
|
.and_then(|value| value.checked_add(1))
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ProxyError::Config("web.limits session item reservation overflowed usize".to_string())
|
||||||
|
})?;
|
||||||
|
let global_required_bytes = limits
|
||||||
|
.control_bytes_global
|
||||||
|
.checked_add(uplink_bytes)
|
||||||
|
.and_then(|value| value.checked_add(minimum_downlink_frame_bytes))
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ProxyError::Config("web.limits global reservation overflowed usize".to_string())
|
||||||
|
})?;
|
||||||
|
let global_required_items = control_items_global
|
||||||
|
.checked_add(limits.max_frames_per_body)
|
||||||
|
.and_then(|value| value.checked_add(1))
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ProxyError::Config("web.limits global item reservation overflowed usize".to_string())
|
||||||
|
})?;
|
||||||
|
if session_required_bytes > limits.pending_bytes_per_session
|
||||||
|
|| session_required_items > limits.pending_items_per_session
|
||||||
|
|| global_required_bytes > limits.pending_bytes_global
|
||||||
|
|| global_required_items > limits.pending_items_global
|
||||||
|
{
|
||||||
|
return config_error(
|
||||||
|
"web.limits pending ceilings must preserve one uplink batch and downlink progress",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let body_reservation = limits
|
||||||
|
.max_body_readers
|
||||||
|
.checked_mul(limits.max_body_bytes)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ProxyError::Config("web.limits body reader reservation overflowed usize".to_string())
|
||||||
|
})?;
|
||||||
|
if body_reservation > limits.max_body_bytes_global
|
||||||
|
|| limits.max_body_bytes_global > u32::MAX as usize
|
||||||
|
{
|
||||||
|
return config_error(
|
||||||
|
"web.limits max_body_readers * max_body_bytes must fit max_body_bytes_global and u32",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let http_header_reservation = limits
|
||||||
|
.max_http_connections
|
||||||
|
.checked_mul(limits.max_header_bytes)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ProxyError::Config("web.limits HTTP header reservations overflow usize".to_string())
|
||||||
|
})?;
|
||||||
|
let reserved = limits
|
||||||
|
.pending_bytes_global
|
||||||
|
.checked_add(limits.max_body_bytes_global)
|
||||||
|
.and_then(|value| value.checked_add(limits.max_static_bytes))
|
||||||
|
.and_then(|value| value.checked_add(http_header_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
|
||||||
|
{
|
||||||
|
return config_error(
|
||||||
|
"web.limits memory reservations must fit memory_envelope_bytes within 4 GiB",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_timeouts(timeouts: &WebTimeoutsConfig) -> Result<()> {
|
||||||
|
let values = [
|
||||||
|
("header_secs", timeouts.header_secs),
|
||||||
|
("body_secs", timeouts.body_secs),
|
||||||
|
("stream_handshake_secs", timeouts.stream_handshake_secs),
|
||||||
|
("long_poll_secs", timeouts.long_poll_secs),
|
||||||
|
("bootstrap_lifetime_secs", timeouts.bootstrap_lifetime_secs),
|
||||||
|
("reconnect_grace_secs", timeouts.reconnect_grace_secs),
|
||||||
|
("http_idle_secs", timeouts.http_idle_secs),
|
||||||
|
("shutdown_secs", timeouts.shutdown_secs),
|
||||||
|
("decoy_header_secs", timeouts.decoy_header_secs),
|
||||||
|
];
|
||||||
|
if let Some((field, _)) = values
|
||||||
|
.into_iter()
|
||||||
|
.find(|(_, value)| !(1..=3600).contains(value))
|
||||||
|
{
|
||||||
|
return config_error(&format!("web.timeouts.{field} must be within [1, 3600]"));
|
||||||
|
}
|
||||||
|
let request_deadline = timeouts
|
||||||
|
.header_secs
|
||||||
|
.max(timeouts.body_secs)
|
||||||
|
.max(timeouts.long_poll_secs)
|
||||||
|
.max(timeouts.decoy_header_secs);
|
||||||
|
if request_deadline >= timeouts.http_idle_secs {
|
||||||
|
return config_error("web.timeouts request deadlines must be lower than http_idle_secs");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_vhosts(config: &mut ProxyConfig) -> Result<()> {
|
||||||
|
let limits = &config.web.limits;
|
||||||
|
if config.web.vhosts.len() > limits.max_vhosts {
|
||||||
|
return config_error("web.vhosts exceeds web.limits.max_vhosts");
|
||||||
|
}
|
||||||
|
let mut hosts = HashSet::with_capacity(config.web.vhosts.len());
|
||||||
|
let mut profile_count = 0usize;
|
||||||
|
for (vhost_idx, vhost) in config.web.vhosts.iter_mut().enumerate() {
|
||||||
|
vhost.host = normalize_web_host(
|
||||||
|
&vhost.host,
|
||||||
|
&format!("web.vhosts[{vhost_idx}].host"),
|
||||||
|
)?;
|
||||||
|
if !hosts.insert(vhost.host.clone()) {
|
||||||
|
return config_error(&format!("duplicate WEB vhost host `{}`", vhost.host));
|
||||||
|
}
|
||||||
|
if vhost.public_addr.port() != 443 || vhost.public_addr.ip().is_unspecified() {
|
||||||
|
return config_error(&format!(
|
||||||
|
"web.vhosts[{vhost_idx}].public_addr must be a concrete socket address on port 443"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if config.web.enabled && vhost.profiles.is_empty() {
|
||||||
|
return config_error(&format!(
|
||||||
|
"web.vhosts[{vhost_idx}].profiles must be non-empty when web.enabled=true"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
validate_decoy(vhost_idx, &vhost.decoy)?;
|
||||||
|
let mut profiles = HashSet::with_capacity(vhost.profiles.len());
|
||||||
|
for (profile_idx, profile) in vhost.profiles.iter().enumerate() {
|
||||||
|
if !config.access.users.contains_key(&profile.user) {
|
||||||
|
return config_error(&format!(
|
||||||
|
"web.vhosts[{vhost_idx}].profiles[{profile_idx}].user references unknown access user `{}`",
|
||||||
|
profile.user
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !profiles.insert((profile.user.as_str(), profile.secret_mode)) {
|
||||||
|
return config_error(&format!(
|
||||||
|
"duplicate WEB profile for user `{}` in vhost `{}`",
|
||||||
|
profile.user, vhost.host
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let max_streams = profile.max_streams.unwrap_or(limits.max_streams_global);
|
||||||
|
let max_streams_per_session = profile
|
||||||
|
.max_streams_per_session
|
||||||
|
.unwrap_or(limits.max_streams_per_session);
|
||||||
|
if profile.max_sessions == Some(0)
|
||||||
|
|| profile.max_sessions.is_some_and(|value| value > limits.max_sessions_global)
|
||||||
|
|| profile.max_streams == Some(0)
|
||||||
|
|| profile
|
||||||
|
.max_streams
|
||||||
|
.is_some_and(|value| value > limits.max_streams_global)
|
||||||
|
|| profile.max_streams_per_session == Some(0)
|
||||||
|
|| profile
|
||||||
|
.max_streams_per_session
|
||||||
|
.is_some_and(|value| value > limits.max_streams_per_session)
|
||||||
|
|| max_streams_per_session > max_streams
|
||||||
|
{
|
||||||
|
return config_error(&format!(
|
||||||
|
"web.vhosts[{vhost_idx}].profiles[{profile_idx}] limits must be non-zero and within global WEB limits"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
profile_count = profile_count.checked_add(1).ok_or_else(|| {
|
||||||
|
ProxyError::Config("WEB profile count overflowed usize".to_string())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if profile_count > limits.max_profiles {
|
||||||
|
return config_error("WEB profiles exceed web.limits.max_profiles");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_web_host(value: &str, field: &str) -> Result<String> {
|
||||||
|
let input = value.trim();
|
||||||
|
if input.is_empty()
|
||||||
|
|| input.ends_with('.')
|
||||||
|
|| input
|
||||||
|
.chars()
|
||||||
|
.any(|character| matches!(character, ':' | '/' | '?' | '#' | '@'))
|
||||||
|
{
|
||||||
|
return config_error(&format!(
|
||||||
|
"{field} must be a hostname without a port, path, credentials, or trailing dot"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let host = normalize_domain_to_ascii(input, field)?;
|
||||||
|
if host.len() > 253
|
||||||
|
|| !host.contains('.')
|
||||||
|
|| host.parse::<IpAddr>().is_ok()
|
||||||
|
|| web_host_last_label_is_numeric(&host)
|
||||||
|
{
|
||||||
|
return config_error(&format!(
|
||||||
|
"{field} must be a non-IP fully-qualified hostname accepted by Telegram Desktop"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for label in host.split('.') {
|
||||||
|
if label.is_empty()
|
||||||
|
|| label.len() > 63
|
||||||
|
|| label.starts_with('-')
|
||||||
|
|| label.ends_with('-')
|
||||||
|
|| !label
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
|
||||||
|
{
|
||||||
|
return config_error(&format!(
|
||||||
|
"{field} contains a hostname label rejected by Telegram Desktop"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(host)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn web_host_last_label_is_numeric(host: &str) -> bool {
|
||||||
|
let label = host.rsplit('.').next().unwrap_or_default();
|
||||||
|
let digits = label
|
||||||
|
.strip_prefix("0x")
|
||||||
|
.or_else(|| label.strip_prefix("0X"));
|
||||||
|
if let Some(digits) = digits {
|
||||||
|
return digits.bytes().all(|byte| byte.is_ascii_hexdigit());
|
||||||
|
}
|
||||||
|
label.bytes().all(|byte| byte.is_ascii_digit())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_decoy(vhost_idx: usize, decoy: &WebDecoyConfig) -> Result<()> {
|
||||||
|
match decoy {
|
||||||
|
WebDecoyConfig::HttpUpstream { upstream } => {
|
||||||
|
let parsed = url::Url::parse(upstream).map_err(|error| {
|
||||||
|
ProxyError::Config(format!(
|
||||||
|
"web.vhosts[{vhost_idx}].decoy.upstream is invalid: {error}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if parsed.scheme() != "http"
|
||||||
|
|| parsed.host_str().is_none()
|
||||||
|
|| !parsed.username().is_empty()
|
||||||
|
|| parsed.password().is_some()
|
||||||
|
|| parsed.query().is_some()
|
||||||
|
|| parsed.fragment().is_some()
|
||||||
|
|| parsed.path() != "/"
|
||||||
|
|| parsed.port() == Some(0)
|
||||||
|
{
|
||||||
|
return config_error(&format!(
|
||||||
|
"web.vhosts[{vhost_idx}].decoy.upstream must be an http origin without credentials, path, query, or fragment"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let ip = match parsed.host() {
|
||||||
|
Some(url::Host::Ipv4(ip)) => IpAddr::V4(ip),
|
||||||
|
Some(url::Host::Ipv6(ip)) => IpAddr::V6(ip),
|
||||||
|
_ => {
|
||||||
|
return config_error(&format!(
|
||||||
|
"web.vhosts[{vhost_idx}].decoy.upstream host must be a loopback or private IP literal"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let private = match ip {
|
||||||
|
IpAddr::V4(ip) => ip.is_loopback() || ip.is_private() || ip.is_link_local(),
|
||||||
|
IpAddr::V6(ip) => {
|
||||||
|
ip.is_loopback() || ip.is_unique_local() || ip.is_unicast_link_local()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !private {
|
||||||
|
return config_error(&format!(
|
||||||
|
"web.vhosts[{vhost_idx}].decoy.upstream must remain inside loopback or a private network"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WebDecoyConfig::StaticDirectory { directory, index } => {
|
||||||
|
if !directory.is_absolute() {
|
||||||
|
return config_error(&format!(
|
||||||
|
"web.vhosts[{vhost_idx}].decoy.directory must be absolute"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if index.is_empty()
|
||||||
|
|| index.contains('\\')
|
||||||
|
|| std::path::Path::new(index).components().count() != 1
|
||||||
|
|| matches!(index.as_str(), "." | "..")
|
||||||
|
{
|
||||||
|
return config_error(&format!(
|
||||||
|
"web.vhosts[{vhost_idx}].decoy.index must be one safe file name"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_error<T>(message: &str) -> Result<T> {
|
||||||
|
Err(ProxyError::Config(message.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn web_host_normalization_matches_client_vectors() {
|
||||||
|
assert_eq!(
|
||||||
|
normalize_web_host(" Proxy.Example.COM ", "host").unwrap(),
|
||||||
|
"proxy.example.com"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_web_host("bücher.example", "host").unwrap(),
|
||||||
|
"xn--bcher-kva.example"
|
||||||
|
);
|
||||||
|
for invalid in [
|
||||||
|
"localhost",
|
||||||
|
"127.0.0.1",
|
||||||
|
"127.1",
|
||||||
|
"0x7f.1",
|
||||||
|
"0177.0.0.1",
|
||||||
|
"1.2.3",
|
||||||
|
"site.example:443",
|
||||||
|
"site..example",
|
||||||
|
"site.example.",
|
||||||
|
] {
|
||||||
|
assert!(normalize_web_host(invalid, "host").is_err(), "{invalid}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,3 +52,5 @@ mod synlimit_mss_tests;
|
|||||||
mod tls_fetch_tests;
|
mod tls_fetch_tests;
|
||||||
#[path = "load_basic_tests/upstream_tests.rs"]
|
#[path = "load_basic_tests/upstream_tests.rs"]
|
||||||
mod upstream_tests;
|
mod upstream_tests;
|
||||||
|
#[path = "load_basic_tests/web_tests.rs"]
|
||||||
|
mod web_tests;
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
const WEB_CONFIG: &str = r#"
|
||||||
|
[access.users]
|
||||||
|
alice = "000102030405060708090a0b0c0d0e0f"
|
||||||
|
|
||||||
|
[[server.listeners]]
|
||||||
|
ip = "127.0.0.1"
|
||||||
|
port = 18080
|
||||||
|
transport = "web"
|
||||||
|
proxy_protocol = false
|
||||||
|
web_client_ip_source = "x_forwarded_for"
|
||||||
|
web_trusted_proxy_cidrs = ["127.0.0.1/32"]
|
||||||
|
|
||||||
|
[web]
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
[[web.vhosts]]
|
||||||
|
host = "Proxy.Example.COM"
|
||||||
|
public_addr = "203.0.113.10:443"
|
||||||
|
|
||||||
|
[web.vhosts.decoy]
|
||||||
|
mode = "http_upstream"
|
||||||
|
upstream = "http://127.0.0.1:18081"
|
||||||
|
|
||||||
|
[[web.vhosts.profiles]]
|
||||||
|
user = "alice"
|
||||||
|
secret_mode = "dd"
|
||||||
|
max_sessions = 4
|
||||||
|
max_streams = 64
|
||||||
|
max_streams_per_session = 16
|
||||||
|
"#;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn web_config_builds_canonical_runtime_snapshot() {
|
||||||
|
let config = load_config_from_temp_toml(WEB_CONFIG);
|
||||||
|
let runtime = config.web.runtime.expect("WEB runtime snapshot");
|
||||||
|
let vhost = runtime
|
||||||
|
.vhosts
|
||||||
|
.get("proxy.example.com")
|
||||||
|
.expect("canonical WEB vhost");
|
||||||
|
assert_eq!(vhost.profiles.len(), 1);
|
||||||
|
assert_eq!(vhost.profiles[0].user, "alice");
|
||||||
|
assert_eq!(vhost.profiles[0].secret_mode, WebSecretMode::Dd);
|
||||||
|
assert_eq!(vhost.profiles[0].max_sessions, 4);
|
||||||
|
assert_eq!(vhost.profiles[0].max_streams, 64);
|
||||||
|
assert_eq!(vhost.profiles[0].max_streams_per_session, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn web_listener_requires_an_explicit_trusted_proxy() {
|
||||||
|
let invalid = WEB_CONFIG.replace(
|
||||||
|
"web_trusted_proxy_cidrs = [\"127.0.0.1/32\"]",
|
||||||
|
"web_trusted_proxy_cidrs = []",
|
||||||
|
);
|
||||||
|
let error = load_config_error_from_temp_toml(&invalid);
|
||||||
|
assert!(error.contains("web_trusted_proxy_cidrs must be non-empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn web_queue_limits_preserve_control_and_uplink_progress() {
|
||||||
|
let invalid = WEB_CONFIG.replace(
|
||||||
|
"[web]\nenabled = true",
|
||||||
|
"[web]\nenabled = true\n\n[web.limits]\ncontrol_bytes_per_session = 1",
|
||||||
|
);
|
||||||
|
let error = load_config_error_from_temp_toml(&invalid);
|
||||||
|
assert!(error.contains("control reserves must cover bounded control frames"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn web_semaphore_limits_are_rejected_before_runtime_construction() {
|
||||||
|
let invalid = WEB_CONFIG.replace(
|
||||||
|
"[web]\nenabled = true",
|
||||||
|
&format!(
|
||||||
|
"[web]\nenabled = true\n\n[web.limits]\nmax_http_connections = {}",
|
||||||
|
tokio::sync::Semaphore::MAX_PERMITS + 1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let error = load_config_error_from_temp_toml(&invalid);
|
||||||
|
assert!(error.contains("exceeds Tokio semaphore capacity"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn web_ipv6_decoy_uses_a_valid_http_authority() {
|
||||||
|
let ipv6 = WEB_CONFIG.replace(
|
||||||
|
"http://127.0.0.1:18081",
|
||||||
|
"http://[::1]:18081",
|
||||||
|
);
|
||||||
|
let config = load_config_from_temp_toml(&ipv6);
|
||||||
|
let runtime = config.web.runtime.expect("WEB runtime snapshot");
|
||||||
|
let vhost = runtime.vhosts.get("proxy.example.com").unwrap();
|
||||||
|
let WebRuntimeDecoy::HttpUpstream { authority, .. } = &vhost.decoy else {
|
||||||
|
panic!("expected HTTP decoy");
|
||||||
|
};
|
||||||
|
assert_eq!(authority, "[::1]:18081");
|
||||||
|
}
|
||||||
+12
-1
@@ -23,6 +23,7 @@ mod logging;
|
|||||||
mod network;
|
mod network;
|
||||||
mod policies;
|
mod policies;
|
||||||
mod server;
|
mod server;
|
||||||
|
mod web;
|
||||||
|
|
||||||
pub use access::{AccessConfig, CidrRateLimitKey, RateLimitBps};
|
pub use access::{AccessConfig, CidrRateLimitKey, RateLimitBps};
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
@@ -43,7 +44,17 @@ pub use policies::{
|
|||||||
pub use server::{
|
pub use server::{
|
||||||
CLIENT_MSS_2IN8, CLIENT_MSS_EXTREME_LOW, CLIENT_MSS_MAX, CLIENT_MSS_MIN, CLIENT_MSS_TSPU,
|
CLIENT_MSS_2IN8, CLIENT_MSS_EXTREME_LOW, CLIENT_MSS_MAX, CLIENT_MSS_MIN, CLIENT_MSS_TSPU,
|
||||||
ConntrackBackend, ConntrackControlConfig, ConntrackMode, ConntrackPressureProfile,
|
ConntrackBackend, ConntrackControlConfig, ConntrackMode, ConntrackPressureProfile,
|
||||||
ListenerConfig, ServerConfig, SynLimitMode, TimeoutsConfig,
|
ListenerConfig, ListenerTransport, ServerConfig, SynLimitMode, TimeoutsConfig,
|
||||||
|
WebClientIpSource,
|
||||||
|
};
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub use web::{
|
||||||
|
WebConfig, WebDecoyConfig, WebLimitsConfig, WebProfileConfig, WebSecretMode,
|
||||||
|
WebTimeoutsConfig, WebVhostConfig,
|
||||||
|
};
|
||||||
|
pub(crate) use web::{
|
||||||
|
WebRuntimeConfig, WebRuntimeDecoy, WebRuntimeProfile, WebRuntimeVhost, WebStaticAsset,
|
||||||
|
WebStaticSite,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn default_quota_state_path() -> PathBuf {
|
fn default_quota_state_path() -> PathBuf {
|
||||||
|
|||||||
@@ -75,6 +75,26 @@ pub enum SynLimitMode {
|
|||||||
Pf,
|
Pf,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Application protocol accepted by one process-owned TCP listener.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum ListenerTransport {
|
||||||
|
/// Existing MTProxy TCP listener behavior.
|
||||||
|
#[default]
|
||||||
|
Mtproxy,
|
||||||
|
/// Plain HTTP WEB gateway behind a trusted TLS terminator.
|
||||||
|
Web,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trusted L7 source used to recover a WEB client's identity address.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum WebClientIpSource {
|
||||||
|
/// Require exactly one canonical IP in `X-Forwarded-For`.
|
||||||
|
#[default]
|
||||||
|
XForwardedFor,
|
||||||
|
}
|
||||||
|
|
||||||
impl Serialize for SynLimitMode {
|
impl Serialize for SynLimitMode {
|
||||||
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
|
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
|
||||||
where
|
where
|
||||||
@@ -380,6 +400,9 @@ impl Default for TimeoutsConfig {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ListenerConfig {
|
pub struct ListenerConfig {
|
||||||
pub ip: IpAddr,
|
pub ip: IpAddr,
|
||||||
|
/// Application protocol accepted by this listener.
|
||||||
|
#[serde(default)]
|
||||||
|
pub transport: ListenerTransport,
|
||||||
/// Per-listener TCP port. If omitted, falls back to legacy `server.port`.
|
/// Per-listener TCP port. If omitted, falls back to legacy `server.port`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub port: Option<u16>,
|
pub port: Option<u16>,
|
||||||
@@ -429,6 +452,12 @@ pub struct ListenerConfig {
|
|||||||
/// Default is false for safety.
|
/// Default is false for safety.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub reuse_allow: bool,
|
pub reuse_allow: bool,
|
||||||
|
/// L7 header policy used only by WEB listeners.
|
||||||
|
#[serde(default)]
|
||||||
|
pub web_client_ip_source: WebClientIpSource,
|
||||||
|
/// Immediate socket peers allowed to provide the WEB client identity header.
|
||||||
|
#[serde(default)]
|
||||||
|
pub web_trusted_proxy_cidrs: Vec<IpNetwork>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Client-facing TCP MSS preset for extreme-low fragmentation profiles.
|
/// Client-facing TCP MSS preset for extreme-low fragmentation profiles.
|
||||||
|
|||||||
@@ -0,0 +1,434 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use bytes::Bytes;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Client-facing secret representation used to derive a WEB capability.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum WebSecretMode {
|
||||||
|
/// Use the existing 16-byte access secret without a prefix.
|
||||||
|
Plain,
|
||||||
|
/// Prefix the existing access secret with `0xdd` for capability derivation.
|
||||||
|
Dd,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One access user explicitly exposed through a WEB virtual host.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct WebProfileConfig {
|
||||||
|
/// Existing `[access.users]` key authenticated by the inner MTProxy handshake.
|
||||||
|
pub user: String,
|
||||||
|
/// Exact client-facing secret representation advertised in WEB links.
|
||||||
|
pub secret_mode: WebSecretMode,
|
||||||
|
/// Optional per-profile live session ceiling.
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_sessions: Option<usize>,
|
||||||
|
/// Optional per-profile live logical-stream ceiling.
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_streams: Option<usize>,
|
||||||
|
/// Optional per-profile stream ceiling for one session.
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_streams_per_session: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public-site fallback used for requests that are not authenticated WEB traffic.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "mode", rename_all = "snake_case")]
|
||||||
|
pub enum WebDecoyConfig {
|
||||||
|
/// Stream requests to one fixed private HTTP origin.
|
||||||
|
HttpUpstream {
|
||||||
|
/// Origin URL without a query or fragment.
|
||||||
|
upstream: String,
|
||||||
|
},
|
||||||
|
/// Serve an immutable, bounded snapshot of a local directory.
|
||||||
|
StaticDirectory {
|
||||||
|
/// Absolute directory containing public files.
|
||||||
|
directory: PathBuf,
|
||||||
|
/// File served for `/` and directory paths.
|
||||||
|
#[serde(default = "default_web_static_index")]
|
||||||
|
index: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One externally visible WEB hostname and its explicit access profiles.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct WebVhostConfig {
|
||||||
|
/// Canonical lowercase ACE hostname used by Telegram Desktop.
|
||||||
|
pub host: String,
|
||||||
|
/// Stable public destination tuple used by inner relay routing and KDF metadata.
|
||||||
|
pub public_addr: SocketAddr,
|
||||||
|
/// Ordinary-site fallback for this hostname.
|
||||||
|
pub decoy: WebDecoyConfig,
|
||||||
|
/// Access users and exact secret modes enabled for this hostname.
|
||||||
|
#[serde(default)]
|
||||||
|
pub profiles: Vec<WebProfileConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hard process and protocol limits for WEB ingress.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct WebLimitsConfig {
|
||||||
|
/// Maximum bytes accepted while parsing one HTTP request head.
|
||||||
|
#[serde(default = "default_web_max_header_bytes")]
|
||||||
|
pub max_header_bytes: usize,
|
||||||
|
/// Maximum collected carrier request body size.
|
||||||
|
#[serde(default = "default_web_max_body_bytes")]
|
||||||
|
pub max_body_bytes: usize,
|
||||||
|
/// Maximum payload carried by one WEB frame.
|
||||||
|
#[serde(default = "default_web_max_frame_payload_bytes")]
|
||||||
|
pub max_frame_payload_bytes: usize,
|
||||||
|
/// Maximum encoded downlink batch returned by one poll.
|
||||||
|
#[serde(default = "default_web_carrier_batch_bytes")]
|
||||||
|
pub carrier_batch_bytes: usize,
|
||||||
|
/// Maximum frame count parsed or emitted in one carrier body.
|
||||||
|
#[serde(default = "default_web_max_frames_per_body")]
|
||||||
|
pub max_frames_per_body: usize,
|
||||||
|
/// Process-wide accepted WEB HTTP connection ceiling.
|
||||||
|
#[serde(default = "default_web_max_http_connections")]
|
||||||
|
pub max_http_connections: usize,
|
||||||
|
/// Process-wide concurrently executing HTTP handler ceiling.
|
||||||
|
#[serde(default = "default_web_max_http_handlers")]
|
||||||
|
pub max_http_handlers: usize,
|
||||||
|
/// Process-wide concurrently collected request body ceiling.
|
||||||
|
#[serde(default = "default_web_max_body_readers")]
|
||||||
|
pub max_body_readers: usize,
|
||||||
|
/// Process-wide byte reservation for collected request bodies.
|
||||||
|
#[serde(default = "default_web_max_body_bytes_global")]
|
||||||
|
pub max_body_bytes_global: usize,
|
||||||
|
/// Process-wide live WEB session ceiling.
|
||||||
|
#[serde(default = "default_web_max_sessions_global")]
|
||||||
|
pub max_sessions_global: usize,
|
||||||
|
/// Live WEB session ceiling for one forwarded client address.
|
||||||
|
#[serde(default = "default_web_max_sessions_per_ip")]
|
||||||
|
pub max_sessions_per_ip: usize,
|
||||||
|
/// Default live logical-stream ceiling for one WEB session.
|
||||||
|
#[serde(default = "default_web_max_streams_per_session")]
|
||||||
|
pub max_streams_per_session: usize,
|
||||||
|
/// Process-wide live logical-stream ceiling.
|
||||||
|
#[serde(default = "default_web_max_streams_global")]
|
||||||
|
pub max_streams_global: usize,
|
||||||
|
/// Process-wide concurrent inner MTProxy handshake ceiling.
|
||||||
|
#[serde(default = "default_web_max_stream_handshakes")]
|
||||||
|
pub max_stream_handshakes: usize,
|
||||||
|
/// Closed stream identifiers retained by one session.
|
||||||
|
#[serde(default = "default_web_max_tombstones")]
|
||||||
|
pub max_tombstones_per_session: usize,
|
||||||
|
/// Total queued data and control bytes allowed for one session.
|
||||||
|
#[serde(default = "default_web_pending_bytes_per_session")]
|
||||||
|
pub pending_bytes_per_session: usize,
|
||||||
|
/// Process-wide queued data and control byte ceiling.
|
||||||
|
#[serde(default = "default_web_pending_bytes_global")]
|
||||||
|
pub pending_bytes_global: usize,
|
||||||
|
/// Total queued data and control item ceiling for one session.
|
||||||
|
#[serde(default = "default_web_pending_items_per_session")]
|
||||||
|
pub pending_items_per_session: usize,
|
||||||
|
/// Process-wide queued data and control item ceiling.
|
||||||
|
#[serde(default = "default_web_pending_items_global")]
|
||||||
|
pub pending_items_global: usize,
|
||||||
|
/// Per-session byte reserve available only to control frames.
|
||||||
|
#[serde(default = "default_web_control_bytes_per_session")]
|
||||||
|
pub control_bytes_per_session: usize,
|
||||||
|
/// Process-wide byte reserve available only to control frames.
|
||||||
|
#[serde(default = "default_web_control_bytes_global")]
|
||||||
|
pub control_bytes_global: usize,
|
||||||
|
/// Process-wide live bootstrap credential ceiling.
|
||||||
|
#[serde(default = "default_web_max_bootstraps_global")]
|
||||||
|
pub max_bootstraps_global: usize,
|
||||||
|
/// Live bootstrap credential ceiling for one forwarded client address.
|
||||||
|
#[serde(default = "default_web_max_bootstraps_per_ip")]
|
||||||
|
pub max_bootstraps_per_ip: usize,
|
||||||
|
/// Maximum configured WEB virtual-host count.
|
||||||
|
#[serde(default = "default_web_max_vhosts")]
|
||||||
|
pub max_vhosts: usize,
|
||||||
|
/// Maximum configured WEB access-profile count across all virtual hosts.
|
||||||
|
#[serde(default = "default_web_max_profiles")]
|
||||||
|
pub max_profiles: usize,
|
||||||
|
/// Maximum static snapshot entry count across all virtual hosts.
|
||||||
|
#[serde(default = "default_web_max_static_files")]
|
||||||
|
pub max_static_files: usize,
|
||||||
|
/// Maximum bytes read from one static snapshot file.
|
||||||
|
#[serde(default = "default_web_max_static_file_bytes")]
|
||||||
|
pub max_static_file_bytes: usize,
|
||||||
|
/// Maximum static snapshot bytes across all virtual hosts.
|
||||||
|
#[serde(default = "default_web_max_static_bytes")]
|
||||||
|
pub max_static_bytes: usize,
|
||||||
|
/// Declared process envelope for HTTP heads, bodies, queues, and static snapshots.
|
||||||
|
#[serde(default = "default_web_memory_envelope_bytes")]
|
||||||
|
pub memory_envelope_bytes: usize,
|
||||||
|
/// Sustained process-wide bootstrap issuance rate.
|
||||||
|
#[serde(default = "default_web_new_bootstraps_per_minute")]
|
||||||
|
pub new_bootstraps_per_minute: u32,
|
||||||
|
/// Process-wide bootstrap issuance burst.
|
||||||
|
#[serde(default = "default_web_new_bootstraps_burst")]
|
||||||
|
pub new_bootstraps_burst: u32,
|
||||||
|
/// Sustained process-wide session creation rate.
|
||||||
|
#[serde(default = "default_web_new_sessions_per_minute")]
|
||||||
|
pub new_sessions_per_minute: u32,
|
||||||
|
/// Process-wide session creation burst.
|
||||||
|
#[serde(default = "default_web_new_sessions_burst")]
|
||||||
|
pub new_sessions_burst: u32,
|
||||||
|
/// Sustained process-wide logical-stream creation rate.
|
||||||
|
#[serde(default = "default_web_new_streams_per_minute")]
|
||||||
|
pub new_streams_per_minute: u32,
|
||||||
|
/// Process-wide logical-stream creation burst.
|
||||||
|
#[serde(default = "default_web_new_streams_burst")]
|
||||||
|
pub new_streams_burst: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for WebLimitsConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
max_header_bytes: default_web_max_header_bytes(),
|
||||||
|
max_body_bytes: default_web_max_body_bytes(),
|
||||||
|
max_frame_payload_bytes: default_web_max_frame_payload_bytes(),
|
||||||
|
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_handlers: default_web_max_http_handlers(),
|
||||||
|
max_body_readers: default_web_max_body_readers(),
|
||||||
|
max_body_bytes_global: default_web_max_body_bytes_global(),
|
||||||
|
max_sessions_global: default_web_max_sessions_global(),
|
||||||
|
max_sessions_per_ip: default_web_max_sessions_per_ip(),
|
||||||
|
max_streams_per_session: default_web_max_streams_per_session(),
|
||||||
|
max_streams_global: default_web_max_streams_global(),
|
||||||
|
max_stream_handshakes: default_web_max_stream_handshakes(),
|
||||||
|
max_tombstones_per_session: default_web_max_tombstones(),
|
||||||
|
pending_bytes_per_session: default_web_pending_bytes_per_session(),
|
||||||
|
pending_bytes_global: default_web_pending_bytes_global(),
|
||||||
|
pending_items_per_session: default_web_pending_items_per_session(),
|
||||||
|
pending_items_global: default_web_pending_items_global(),
|
||||||
|
control_bytes_per_session: default_web_control_bytes_per_session(),
|
||||||
|
control_bytes_global: default_web_control_bytes_global(),
|
||||||
|
max_bootstraps_global: default_web_max_bootstraps_global(),
|
||||||
|
max_bootstraps_per_ip: default_web_max_bootstraps_per_ip(),
|
||||||
|
max_vhosts: default_web_max_vhosts(),
|
||||||
|
max_profiles: default_web_max_profiles(),
|
||||||
|
max_static_files: default_web_max_static_files(),
|
||||||
|
max_static_file_bytes: default_web_max_static_file_bytes(),
|
||||||
|
max_static_bytes: default_web_max_static_bytes(),
|
||||||
|
memory_envelope_bytes: default_web_memory_envelope_bytes(),
|
||||||
|
new_bootstraps_per_minute: default_web_new_bootstraps_per_minute(),
|
||||||
|
new_bootstraps_burst: default_web_new_bootstraps_burst(),
|
||||||
|
new_sessions_per_minute: default_web_new_sessions_per_minute(),
|
||||||
|
new_sessions_burst: default_web_new_sessions_burst(),
|
||||||
|
new_streams_per_minute: default_web_new_streams_per_minute(),
|
||||||
|
new_streams_burst: default_web_new_streams_burst(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deadlines for WEB HTTP, bootstrap, session, and shutdown lifecycle.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct WebTimeoutsConfig {
|
||||||
|
/// Deadline for receiving one complete HTTP request head.
|
||||||
|
#[serde(default = "default_web_header_timeout_secs")]
|
||||||
|
pub header_secs: u64,
|
||||||
|
/// Deadline for collecting one authenticated carrier request body.
|
||||||
|
#[serde(default = "default_web_body_timeout_secs")]
|
||||||
|
pub body_secs: u64,
|
||||||
|
/// Deadline for the inner MTProxy handshake on one logical stream.
|
||||||
|
#[serde(default = "default_web_stream_handshake_timeout_secs")]
|
||||||
|
pub stream_handshake_secs: u64,
|
||||||
|
/// Maximum wait for one empty downlink long poll.
|
||||||
|
#[serde(default = "default_web_long_poll_timeout_secs")]
|
||||||
|
pub long_poll_secs: u64,
|
||||||
|
/// Lifetime of an unused bootstrap credential and closed-token replay marker.
|
||||||
|
#[serde(default = "default_web_bootstrap_lifetime_secs")]
|
||||||
|
pub bootstrap_lifetime_secs: u64,
|
||||||
|
/// Maximum carrier inactivity before a session is closed.
|
||||||
|
#[serde(default = "default_web_reconnect_grace_secs")]
|
||||||
|
pub reconnect_grace_secs: u64,
|
||||||
|
/// Maximum idle lifetime of a WEB HTTP keep-alive connection.
|
||||||
|
#[serde(default = "default_web_http_idle_secs")]
|
||||||
|
pub http_idle_secs: u64,
|
||||||
|
/// Maximum graceful wait for WEB connections and process-owned tasks.
|
||||||
|
#[serde(default = "default_web_shutdown_secs")]
|
||||||
|
pub shutdown_secs: u64,
|
||||||
|
/// Deadline for connecting to and receiving headers from an HTTP decoy.
|
||||||
|
#[serde(default = "default_web_decoy_header_timeout_secs")]
|
||||||
|
pub decoy_header_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for WebTimeoutsConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
header_secs: default_web_header_timeout_secs(),
|
||||||
|
body_secs: default_web_body_timeout_secs(),
|
||||||
|
stream_handshake_secs: default_web_stream_handshake_timeout_secs(),
|
||||||
|
long_poll_secs: default_web_long_poll_timeout_secs(),
|
||||||
|
bootstrap_lifetime_secs: default_web_bootstrap_lifetime_secs(),
|
||||||
|
reconnect_grace_secs: default_web_reconnect_grace_secs(),
|
||||||
|
http_idle_secs: default_web_http_idle_secs(),
|
||||||
|
shutdown_secs: default_web_shutdown_secs(),
|
||||||
|
decoy_header_secs: default_web_decoy_header_timeout_secs(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WEB ingress, carrier, fallback, and lifecycle configuration.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct WebConfig {
|
||||||
|
/// Enables issuance of new WEB bridge and session credentials.
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: bool,
|
||||||
|
/// Hard process and protocol limits.
|
||||||
|
#[serde(default)]
|
||||||
|
pub limits: WebLimitsConfig,
|
||||||
|
/// WEB lifecycle deadlines.
|
||||||
|
#[serde(default)]
|
||||||
|
pub timeouts: WebTimeoutsConfig,
|
||||||
|
/// Public hostnames served by WEB listeners.
|
||||||
|
#[serde(default)]
|
||||||
|
pub vhosts: Vec<WebVhostConfig>,
|
||||||
|
/// Validated immutable runtime snapshot built during configuration loading.
|
||||||
|
#[serde(skip)]
|
||||||
|
pub(crate) runtime: Option<Arc<WebRuntimeConfig>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Precomputed WEB configuration consumed by listener hot paths.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct WebRuntimeConfig {
|
||||||
|
/// Canonical host lookup used by HTTP request routing.
|
||||||
|
pub(crate) vhosts: BTreeMap<String, Arc<WebRuntimeVhost>>,
|
||||||
|
/// Flat profile inventory used by startup link emission.
|
||||||
|
pub(crate) profiles: Vec<Arc<WebRuntimeProfile>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Precomputed immutable virtual-host data.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct WebRuntimeVhost {
|
||||||
|
/// Canonical lowercase ACE hostname.
|
||||||
|
pub(crate) host: String,
|
||||||
|
/// Immutable ordinary-site fallback snapshot.
|
||||||
|
pub(crate) decoy: WebRuntimeDecoy,
|
||||||
|
/// Upstream connect and response-head deadline.
|
||||||
|
pub(crate) decoy_header_secs: u64,
|
||||||
|
/// Exact capability profiles accepted by this host.
|
||||||
|
pub(crate) profiles: Vec<Arc<WebRuntimeProfile>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Precomputed exact-user capability entry.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct WebRuntimeProfile {
|
||||||
|
/// Canonical host that owns this profile.
|
||||||
|
pub(crate) host: String,
|
||||||
|
/// Stable public destination tuple supplied to relay routing.
|
||||||
|
pub(crate) public_addr: SocketAddr,
|
||||||
|
/// Exact access user authenticated by logical streams.
|
||||||
|
pub(crate) user: String,
|
||||||
|
/// Client secret representation and inner protocol policy.
|
||||||
|
pub(crate) secret_mode: WebSecretMode,
|
||||||
|
/// HMAC-derived bridge capability.
|
||||||
|
pub(crate) capability: [u8; 32],
|
||||||
|
/// Per-profile live session ceiling.
|
||||||
|
pub(crate) max_sessions: usize,
|
||||||
|
/// Per-profile live logical-stream ceiling.
|
||||||
|
pub(crate) max_streams: usize,
|
||||||
|
/// Per-session live relay-task ceiling.
|
||||||
|
pub(crate) max_streams_per_session: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runtime-ready ordinary-site fallback.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) enum WebRuntimeDecoy {
|
||||||
|
HttpUpstream {
|
||||||
|
addr: SocketAddr,
|
||||||
|
authority: String,
|
||||||
|
},
|
||||||
|
StaticDirectory(Arc<WebStaticSite>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Immutable bounded static-site snapshot.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct WebStaticSite {
|
||||||
|
/// Canonical URL-path to immutable response asset mapping.
|
||||||
|
pub(crate) assets: BTreeMap<String, WebStaticAsset>,
|
||||||
|
/// Configured root index file name.
|
||||||
|
pub(crate) index: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One immutable static response body and metadata.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct WebStaticAsset {
|
||||||
|
/// Immutable response body retained by the runtime snapshot.
|
||||||
|
pub(crate) body: Bytes,
|
||||||
|
/// Extension-derived static content type.
|
||||||
|
pub(crate) content_type: &'static str,
|
||||||
|
/// Strong SHA-256 entity tag.
|
||||||
|
pub(crate) etag: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_web_static_index() -> String {
|
||||||
|
"index.html".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! usize_default {
|
||||||
|
($name:ident, $value:expr) => {
|
||||||
|
fn $name() -> usize {
|
||||||
|
$value
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! u32_default {
|
||||||
|
($name:ident, $value:expr) => {
|
||||||
|
fn $name() -> u32 {
|
||||||
|
$value
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! u64_default {
|
||||||
|
($name:ident, $value:expr) => {
|
||||||
|
fn $name() -> u64 {
|
||||||
|
$value
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
usize_default!(default_web_max_header_bytes, 16 * 1024);
|
||||||
|
usize_default!(default_web_max_body_bytes, 2 * 1024 * 1024);
|
||||||
|
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_handlers, 512);
|
||||||
|
usize_default!(default_web_max_body_readers, 32);
|
||||||
|
usize_default!(default_web_max_body_bytes_global, 64 * 1024 * 1024);
|
||||||
|
usize_default!(default_web_max_sessions_global, 128);
|
||||||
|
usize_default!(default_web_max_sessions_per_ip, 16);
|
||||||
|
usize_default!(default_web_max_streams_per_session, 128);
|
||||||
|
usize_default!(default_web_max_streams_global, 4096);
|
||||||
|
usize_default!(default_web_max_stream_handshakes, 256);
|
||||||
|
usize_default!(default_web_max_tombstones, 4096);
|
||||||
|
usize_default!(default_web_pending_bytes_per_session, 32 * 1024 * 1024);
|
||||||
|
usize_default!(default_web_pending_bytes_global, 512 * 1024 * 1024);
|
||||||
|
usize_default!(default_web_pending_items_per_session, 16 * 1024);
|
||||||
|
usize_default!(default_web_pending_items_global, 256 * 1024);
|
||||||
|
usize_default!(default_web_control_bytes_per_session, 256 * 1024);
|
||||||
|
usize_default!(default_web_control_bytes_global, 16 * 1024 * 1024);
|
||||||
|
usize_default!(default_web_max_bootstraps_global, 512);
|
||||||
|
usize_default!(default_web_max_bootstraps_per_ip, 64);
|
||||||
|
usize_default!(default_web_max_vhosts, 8);
|
||||||
|
usize_default!(default_web_max_profiles, 32);
|
||||||
|
usize_default!(default_web_max_static_files, 4096);
|
||||||
|
usize_default!(default_web_max_static_file_bytes, 8 * 1024 * 1024);
|
||||||
|
usize_default!(default_web_max_static_bytes, 64 * 1024 * 1024);
|
||||||
|
usize_default!(default_web_memory_envelope_bytes, 768 * 1024 * 1024);
|
||||||
|
u32_default!(default_web_new_bootstraps_per_minute, 1200);
|
||||||
|
u32_default!(default_web_new_bootstraps_burst, 256);
|
||||||
|
u32_default!(default_web_new_sessions_per_minute, 600);
|
||||||
|
u32_default!(default_web_new_sessions_burst, 128);
|
||||||
|
u32_default!(default_web_new_streams_per_minute, 6000);
|
||||||
|
u32_default!(default_web_new_streams_burst, 512);
|
||||||
|
u64_default!(default_web_header_timeout_secs, 10);
|
||||||
|
u64_default!(default_web_body_timeout_secs, 30);
|
||||||
|
u64_default!(default_web_stream_handshake_timeout_secs, 10);
|
||||||
|
u64_default!(default_web_long_poll_timeout_secs, 25);
|
||||||
|
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_shutdown_secs, 15);
|
||||||
|
u64_default!(default_web_decoy_header_timeout_secs, 30);
|
||||||
@@ -14,6 +14,7 @@ use crate::ip_tracker::UserIpTracker;
|
|||||||
use crate::proxy::route_mode::RelayRouteMode;
|
use crate::proxy::route_mode::RelayRouteMode;
|
||||||
use crate::proxy::route_mode::RouteRuntimeController;
|
use crate::proxy::route_mode::RouteRuntimeController;
|
||||||
use crate::proxy::shared_state::ProxySharedState;
|
use crate::proxy::shared_state::ProxySharedState;
|
||||||
|
use crate::proxy::authenticated::ClientRuntimeDeps;
|
||||||
use crate::stats::beobachten::BeobachtenStore;
|
use crate::stats::beobachten::BeobachtenStore;
|
||||||
use crate::stats::{ReplayChecker, Stats};
|
use crate::stats::{ReplayChecker, Stats};
|
||||||
use crate::stream::BufferPool;
|
use crate::stream::BufferPool;
|
||||||
@@ -227,6 +228,22 @@ impl RuntimeGeneration {
|
|||||||
self.me_pool_runtime.read().await.clone()
|
self.me_pool_runtime.read().await.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pins all dependencies required by a client stream without retaining the generation.
|
||||||
|
pub(crate) fn client_runtime_deps(&self) -> ClientRuntimeDeps {
|
||||||
|
ClientRuntimeDeps {
|
||||||
|
config: self.config(),
|
||||||
|
stats: Arc::clone(&self.stats),
|
||||||
|
upstream_manager: Arc::clone(&self.upstream_manager),
|
||||||
|
buffer_pool: Arc::clone(&self.buffer_pool),
|
||||||
|
rng: Arc::clone(&self.rng),
|
||||||
|
me_pool: self.me_pool.clone(),
|
||||||
|
me_pool_runtime: Some(Arc::clone(&self.me_pool_runtime)),
|
||||||
|
route_runtime: Arc::clone(&self.route_runtime),
|
||||||
|
ip_tracker: Arc::clone(&self.ip_tracker),
|
||||||
|
shared: Arc::clone(&self.proxy_shared),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Registers a session only while admission remains open.
|
/// Registers a session only while admission remains open.
|
||||||
pub(crate) fn spawn_session<F>(&self, future: F) -> bool
|
pub(crate) fn spawn_session<F>(&self, future: F) -> bool
|
||||||
where
|
where
|
||||||
@@ -287,7 +304,7 @@ impl RuntimeGeneration {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
/// Builds a lightweight runtime generation without network startup tasks.
|
/// Builds a lightweight runtime generation without network startup tasks.
|
||||||
pub(super) fn test_runtime_generation(id: u64, config: ProxyConfig) -> Arc<RuntimeGeneration> {
|
pub(crate) fn test_runtime_generation(id: u64, config: ProxyConfig) -> Arc<RuntimeGeneration> {
|
||||||
let (config_tx, config_rx) = watch::channel(Arc::new(config.clone()));
|
let (config_tx, config_rx) = watch::channel(Arc::new(config.clone()));
|
||||||
let (_admission_tx, admission_rx) = watch::channel(true);
|
let (_admission_tx, admission_rx) = watch::channel(true);
|
||||||
let stats = Arc::new(Stats::new());
|
let stats = Arc::new(Stats::new());
|
||||||
|
|||||||
@@ -607,6 +607,46 @@ pub(crate) fn print_proxy_links(host: &str, port: u16, config: &ProxyConfig) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Prints WEB links only for profiles selected by the existing link policy.
|
||||||
|
pub(crate) fn print_web_proxy_links(config: &ProxyConfig) {
|
||||||
|
if !config.web.enabled || config.general.links.show.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(runtime) = config.web.runtime.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let shown = config
|
||||||
|
.general
|
||||||
|
.links
|
||||||
|
.show
|
||||||
|
.resolve_users(&config.access.users);
|
||||||
|
let mut heading_printed = false;
|
||||||
|
for profile in &runtime.profiles {
|
||||||
|
if !shown.iter().any(|user| user.as_str() == profile.user) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !heading_printed {
|
||||||
|
print_maestro_line("WEB proxy links");
|
||||||
|
heading_printed = true;
|
||||||
|
}
|
||||||
|
let Some(secret) = config.access.users.get(&profile.user) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let prefix = match profile.secret_mode {
|
||||||
|
crate::config::WebSecretMode::Plain => "",
|
||||||
|
crate::config::WebSecretMode::Dd => "dd",
|
||||||
|
};
|
||||||
|
print_maestro_line(format!(
|
||||||
|
"User: {} ({:?})",
|
||||||
|
profile.user, profile.secret_mode
|
||||||
|
));
|
||||||
|
print_maestro_line(format!(
|
||||||
|
"WEB: tg://webproxy?server={}&secret={prefix}{secret}",
|
||||||
|
profile.host,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn write_beobachten_snapshot(path: &str, payload: &str) -> std::io::Result<()> {
|
pub(crate) async fn write_beobachten_snapshot(path: &str, payload: &str) -> std::io::Result<()> {
|
||||||
if let Some(parent) = std::path::Path::new(path).parent()
|
if let Some(parent) = std::path::Path::new(path).parent()
|
||||||
&& !parent.as_os_str().is_empty()
|
&& !parent.as_os_str().is_empty()
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ use tokio::net::{TcpListener, TcpStream};
|
|||||||
use tokio::sync::OwnedSemaphorePermit;
|
use tokio::sync::OwnedSemaphorePermit;
|
||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
|
use tokio_util::task::TaskTracker;
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
use crate::config::RstOnCloseMode;
|
use crate::config::{ListenerTransport, RstOnCloseMode};
|
||||||
use crate::proxy::ClientHandler;
|
use crate::proxy::ClientHandler;
|
||||||
use crate::transport::socket::set_linger_zero;
|
use crate::transport::socket::set_linger_zero;
|
||||||
|
use crate::web::manager::WebProcessRuntime;
|
||||||
|
|
||||||
use super::bind::BoundTcpListener;
|
use super::bind::BoundTcpListener;
|
||||||
use super::plan::ListenerBindSpec;
|
use super::plan::ListenerBindSpec;
|
||||||
@@ -19,11 +21,15 @@ use crate::maestro::helpers::{
|
|||||||
expected_handshake_close_description, is_expected_handshake_eof, peer_close_description,
|
expected_handshake_close_description, is_expected_handshake_eof, peer_close_description,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// One bound listener and all connection tasks accepted through its lifecycle.
|
||||||
pub(super) struct ListenerSlot {
|
pub(super) struct ListenerSlot {
|
||||||
pub(super) spec: ListenerBindSpec,
|
pub(super) spec: ListenerBindSpec,
|
||||||
listener: Arc<TcpListener>,
|
listener: Arc<TcpListener>,
|
||||||
cancellation: CancellationToken,
|
cancellation: CancellationToken,
|
||||||
task: Option<JoinHandle<()>>,
|
task: Option<JoinHandle<()>>,
|
||||||
|
connections: TaskTracker,
|
||||||
|
web_runtime: Option<Arc<WebProcessRuntime>>,
|
||||||
|
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum PermitWait {
|
enum PermitWait {
|
||||||
@@ -181,6 +187,8 @@ async fn run_accept_loop(
|
|||||||
listener: Arc<TcpListener>,
|
listener: Arc<TcpListener>,
|
||||||
spec: ListenerBindSpec,
|
spec: ListenerBindSpec,
|
||||||
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
|
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
|
||||||
|
web_runtime: Option<Arc<WebProcessRuntime>>,
|
||||||
|
connections: TaskTracker,
|
||||||
cancellation: CancellationToken,
|
cancellation: CancellationToken,
|
||||||
) {
|
) {
|
||||||
loop {
|
loop {
|
||||||
@@ -191,6 +199,26 @@ async fn run_accept_loop(
|
|||||||
};
|
};
|
||||||
match accepted {
|
match accepted {
|
||||||
Ok((stream, peer_addr)) => {
|
Ok((stream, peer_addr)) => {
|
||||||
|
if spec.transport == ListenerTransport::Web {
|
||||||
|
let Some(web_runtime) = web_runtime.as_ref() else {
|
||||||
|
error!(addr = %spec.addr, "WEB listener has no process runtime");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(connection_permit) = web_runtime.try_http_connection() else {
|
||||||
|
drop(stream);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
connections.spawn(crate::web::http::serve_connection(
|
||||||
|
stream,
|
||||||
|
peer_addr,
|
||||||
|
spec.web_client_ip_source,
|
||||||
|
Arc::clone(&spec.web_trusted_proxy_cidrs),
|
||||||
|
Arc::clone(web_runtime),
|
||||||
|
cancellation.clone(),
|
||||||
|
connection_permit,
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let runtime = active_runtime.load_full();
|
let runtime = active_runtime.load_full();
|
||||||
if !*runtime.admission_rx.borrow() {
|
if !*runtime.admission_rx.borrow() {
|
||||||
debug!(peer = %peer_addr, "Admission gate closed, dropping connection");
|
debug!(peer = %peer_addr, "Admission gate closed, dropping connection");
|
||||||
@@ -232,12 +260,16 @@ impl ListenerSlot {
|
|||||||
pub(super) fn start(
|
pub(super) fn start(
|
||||||
bound: BoundTcpListener,
|
bound: BoundTcpListener,
|
||||||
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
|
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
|
||||||
|
web_runtime: Option<Arc<WebProcessRuntime>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let cancellation = CancellationToken::new();
|
let cancellation = CancellationToken::new();
|
||||||
|
let connections = TaskTracker::new();
|
||||||
let task = tokio::spawn(run_accept_loop(
|
let task = tokio::spawn(run_accept_loop(
|
||||||
bound.listener.clone(),
|
bound.listener.clone(),
|
||||||
bound.spec.clone(),
|
bound.spec.clone(),
|
||||||
active_runtime,
|
active_runtime.clone(),
|
||||||
|
web_runtime.clone(),
|
||||||
|
connections.clone(),
|
||||||
cancellation.clone(),
|
cancellation.clone(),
|
||||||
));
|
));
|
||||||
Self {
|
Self {
|
||||||
@@ -245,6 +277,9 @@ impl ListenerSlot {
|
|||||||
listener: bound.listener,
|
listener: bound.listener,
|
||||||
cancellation,
|
cancellation,
|
||||||
task: Some(task),
|
task: Some(task),
|
||||||
|
connections,
|
||||||
|
web_runtime,
|
||||||
|
active_runtime,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,15 +290,36 @@ impl ListenerSlot {
|
|||||||
format!("listener {} task failed: {error_value}", self.spec.addr)
|
format!("listener {} task failed: {error_value}", self.spec.addr)
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
|
self.connections.close();
|
||||||
|
let connection_stop_timeout = Duration::from_secs(
|
||||||
|
self.active_runtime
|
||||||
|
.load()
|
||||||
|
.config()
|
||||||
|
.web
|
||||||
|
.timeouts
|
||||||
|
.shutdown_secs,
|
||||||
|
);
|
||||||
|
tokio::time::timeout(connection_stop_timeout, self.connections.wait())
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
format!(
|
||||||
|
"listener {} connection shutdown timed out",
|
||||||
|
self.spec.addr
|
||||||
|
)
|
||||||
|
})?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn restart(&mut self, active_runtime: Arc<ArcSwap<RuntimeGeneration>>) {
|
pub(super) fn restart(&mut self, active_runtime: Arc<ArcSwap<RuntimeGeneration>>) {
|
||||||
|
self.active_runtime = active_runtime.clone();
|
||||||
self.cancellation = CancellationToken::new();
|
self.cancellation = CancellationToken::new();
|
||||||
|
self.connections = TaskTracker::new();
|
||||||
self.task = Some(tokio::spawn(run_accept_loop(
|
self.task = Some(tokio::spawn(run_accept_loop(
|
||||||
self.listener.clone(),
|
self.listener.clone(),
|
||||||
self.spec.clone(),
|
self.spec.clone(),
|
||||||
active_runtime,
|
active_runtime,
|
||||||
|
self.web_runtime.clone(),
|
||||||
|
self.connections.clone(),
|
||||||
self.cancellation.clone(),
|
self.cancellation.clone(),
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ use tokio::net::TcpListener;
|
|||||||
use tokio::net::UnixListener;
|
use tokio::net::UnixListener;
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
use crate::config::ProxyConfig;
|
use crate::config::{ListenerTransport, ProxyConfig};
|
||||||
use crate::startup::{COMPONENT_LISTENERS_BIND, StartupTracker};
|
use crate::startup::{COMPONENT_LISTENERS_BIND, StartupTracker};
|
||||||
use crate::transport::find_listener_processes;
|
use crate::transport::find_listener_processes;
|
||||||
use crate::transport::socket::{activate_listener_socket, bind_listener_socket};
|
use crate::transport::socket::{activate_listener_socket, bind_listener_socket};
|
||||||
|
|
||||||
use super::plan::{ListenerBindSpec, listener_bind_plan};
|
use super::plan::{ListenerBindSpec, listener_bind_plan};
|
||||||
use crate::maestro::helpers::print_proxy_links;
|
use crate::maestro::helpers::{print_proxy_links, print_web_proxy_links};
|
||||||
|
|
||||||
/// Owns sockets bound before process accept loops start.
|
/// Owns sockets bound before process accept loops start.
|
||||||
pub(crate) struct BoundListeners {
|
pub(crate) struct BoundListeners {
|
||||||
@@ -57,7 +57,8 @@ fn default_link_port(config: &ProxyConfig) -> u16 {
|
|||||||
config
|
config
|
||||||
.server
|
.server
|
||||||
.listeners
|
.listeners
|
||||||
.first()
|
.iter()
|
||||||
|
.find(|listener| listener.transport == ListenerTransport::Mtproxy)
|
||||||
.and_then(|listener| listener.port)
|
.and_then(|listener| listener.port)
|
||||||
.unwrap_or(config.server.port)
|
.unwrap_or(config.server.port)
|
||||||
}
|
}
|
||||||
@@ -110,7 +111,7 @@ impl PreparedTcpListener {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn log_listener_profile(spec: &ListenerBindSpec) {
|
fn log_listener_profile(spec: &ListenerBindSpec) {
|
||||||
info!(addr = %spec.addr, "Listening on TCP endpoint");
|
info!(addr = %spec.addr, transport = ?spec.transport, "Listening on TCP endpoint");
|
||||||
if let Some(client_mss) = spec.options.client_mss {
|
if let Some(client_mss) = spec.options.client_mss {
|
||||||
info!(
|
info!(
|
||||||
addr = %spec.addr,
|
addr = %spec.addr,
|
||||||
@@ -135,7 +136,11 @@ fn print_configured_links(
|
|||||||
detected_ip_v4: Option<IpAddr>,
|
detected_ip_v4: Option<IpAddr>,
|
||||||
detected_ip_v6: Option<IpAddr>,
|
detected_ip_v6: Option<IpAddr>,
|
||||||
) {
|
) {
|
||||||
|
print_web_proxy_links(config);
|
||||||
for listener in &config.server.listeners {
|
for listener in &config.server.listeners {
|
||||||
|
if listener.transport != ListenerTransport::Mtproxy {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let port = listener.port.unwrap_or(config.server.port);
|
let port = listener.port.unwrap_or(config.server.port);
|
||||||
let addr = SocketAddr::new(listener.ip, port);
|
let addr = SocketAddr::new(listener.ip, port);
|
||||||
if !plan.contains_key(&addr) || config.general.links.public_host.is_some() {
|
if !plan.contains_key(&addr) || config.general.links.public_host.is_some() {
|
||||||
@@ -160,7 +165,12 @@ fn print_configured_links(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.general.links.show.is_empty() || config.general.links.public_host.is_none() {
|
if config.general.links.show.is_empty()
|
||||||
|
|| config.general.links.public_host.is_none()
|
||||||
|
|| !plan
|
||||||
|
.values()
|
||||||
|
.any(|spec| spec.transport == ListenerTransport::Mtproxy)
|
||||||
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let host = config
|
let host = config
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ use std::sync::Arc;
|
|||||||
use arc_swap::ArcSwap;
|
use arc_swap::ArcSwap;
|
||||||
|
|
||||||
use crate::config::ProxyConfig;
|
use crate::config::ProxyConfig;
|
||||||
|
use crate::config::ListenerTransport;
|
||||||
use crate::maestro::generation::RuntimeGeneration;
|
use crate::maestro::generation::RuntimeGeneration;
|
||||||
|
|
||||||
use super::accept::ListenerSlot;
|
use super::accept::ListenerSlot;
|
||||||
use super::bind::{BoundListeners, BoundTcpListener, PreparedTcpListener, prepare_listener};
|
use super::bind::{BoundListeners, BoundTcpListener, PreparedTcpListener, prepare_listener};
|
||||||
use super::plan::{ListenerBindSpec, listener_bind_plan};
|
use super::plan::{ListenerBindSpec, listener_bind_plan};
|
||||||
|
use crate::web::manager::WebProcessRuntime;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
use super::unix::UnixAcceptHandle;
|
use super::unix::UnixAcceptHandle;
|
||||||
|
|
||||||
@@ -17,16 +19,19 @@ use super::unix::UnixAcceptHandle;
|
|||||||
pub(crate) struct ListenerManager {
|
pub(crate) struct ListenerManager {
|
||||||
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
|
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
|
||||||
slots: BTreeMap<SocketAddr, ListenerSlot>,
|
slots: BTreeMap<SocketAddr, ListenerSlot>,
|
||||||
|
web_runtime: Option<Arc<WebProcessRuntime>>,
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
unix: Option<UnixAcceptHandle>,
|
unix: Option<UnixAcceptHandle>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Socket changes prepared without activating or stopping accept loops.
|
||||||
pub(crate) struct PreparedListenerTransition {
|
pub(crate) struct PreparedListenerTransition {
|
||||||
target_specs: BTreeMap<SocketAddr, ListenerBindSpec>,
|
target_specs: BTreeMap<SocketAddr, ListenerBindSpec>,
|
||||||
additions: Vec<PreparedTcpListener>,
|
additions: Vec<PreparedTcpListener>,
|
||||||
removals: Vec<SocketAddr>,
|
removals: Vec<SocketAddr>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Activated additions and stopped removals awaiting runtime publication.
|
||||||
pub(crate) struct PendingListenerTransition {
|
pub(crate) struct PendingListenerTransition {
|
||||||
target_specs: BTreeMap<SocketAddr, ListenerBindSpec>,
|
target_specs: BTreeMap<SocketAddr, ListenerBindSpec>,
|
||||||
additions: Vec<BoundTcpListener>,
|
additions: Vec<BoundTcpListener>,
|
||||||
@@ -39,10 +44,22 @@ impl ListenerManager {
|
|||||||
bound: BoundListeners,
|
bound: BoundListeners,
|
||||||
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
|
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
|
let has_web = bound
|
||||||
|
.listeners
|
||||||
|
.iter()
|
||||||
|
.any(|listener| listener.spec.transport == ListenerTransport::Web);
|
||||||
|
let web_runtime = has_web.then(|| WebProcessRuntime::start(active_runtime.clone()));
|
||||||
let mut slots = BTreeMap::new();
|
let mut slots = BTreeMap::new();
|
||||||
for listener in bound.listeners {
|
for listener in bound.listeners {
|
||||||
let addr = listener.spec.addr;
|
let addr = listener.spec.addr;
|
||||||
slots.insert(addr, ListenerSlot::start(listener, active_runtime.clone()));
|
slots.insert(
|
||||||
|
addr,
|
||||||
|
ListenerSlot::start(
|
||||||
|
listener,
|
||||||
|
active_runtime.clone(),
|
||||||
|
web_runtime.clone(),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
let unix = bound
|
let unix = bound
|
||||||
@@ -51,6 +68,7 @@ impl ListenerManager {
|
|||||||
Self {
|
Self {
|
||||||
active_runtime,
|
active_runtime,
|
||||||
slots,
|
slots,
|
||||||
|
web_runtime,
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
unix,
|
unix,
|
||||||
}
|
}
|
||||||
@@ -61,6 +79,7 @@ impl ListenerManager {
|
|||||||
Self {
|
Self {
|
||||||
active_runtime,
|
active_runtime,
|
||||||
slots: BTreeMap::new(),
|
slots: BTreeMap::new(),
|
||||||
|
web_runtime: None,
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
unix: None,
|
unix: None,
|
||||||
}
|
}
|
||||||
@@ -72,6 +91,20 @@ impl ListenerManager {
|
|||||||
desired: &ProxyConfig,
|
desired: &ProxyConfig,
|
||||||
) -> Result<Option<PreparedListenerTransition>, String> {
|
) -> Result<Option<PreparedListenerTransition>, String> {
|
||||||
let target_specs = listener_bind_plan(desired)?;
|
let target_specs = listener_bind_plan(desired)?;
|
||||||
|
let web_inventory_changed = self
|
||||||
|
.slots
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, slot)| slot.spec.transport == ListenerTransport::Web)
|
||||||
|
.map(|(addr, slot)| (*addr, slot.spec.clone()))
|
||||||
|
.collect::<BTreeMap<_, _>>()
|
||||||
|
!= target_specs
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, spec)| spec.transport == ListenerTransport::Web)
|
||||||
|
.map(|(addr, spec)| (*addr, spec.clone()))
|
||||||
|
.collect::<BTreeMap<_, _>>();
|
||||||
|
if web_inventory_changed {
|
||||||
|
return Err("WEB listener inventory is process-owned; process restart required".to_string());
|
||||||
|
}
|
||||||
let current_addresses: BTreeSet<_> = self.slots.keys().copied().collect();
|
let current_addresses: BTreeSet<_> = self.slots.keys().copied().collect();
|
||||||
let target_addresses: BTreeSet<_> = target_specs.keys().copied().collect();
|
let target_addresses: BTreeSet<_> = target_specs.keys().copied().collect();
|
||||||
if current_addresses == target_addresses
|
if current_addresses == target_addresses
|
||||||
@@ -164,7 +197,11 @@ impl ListenerManager {
|
|||||||
let addr = listener.spec.addr;
|
let addr = listener.spec.addr;
|
||||||
self.slots.insert(
|
self.slots.insert(
|
||||||
addr,
|
addr,
|
||||||
ListenerSlot::start(listener, self.active_runtime.clone()),
|
ListenerSlot::start(
|
||||||
|
listener,
|
||||||
|
self.active_runtime.clone(),
|
||||||
|
self.web_runtime.clone(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
debug_assert_eq!(
|
debug_assert_eq!(
|
||||||
@@ -191,6 +228,9 @@ impl ListenerManager {
|
|||||||
errors.push(error_value);
|
errors.push(error_value);
|
||||||
}
|
}
|
||||||
self.slots.clear();
|
self.slots.clear();
|
||||||
|
if let Some(web_runtime) = self.web_runtime.take() {
|
||||||
|
web_runtime.shutdown().await;
|
||||||
|
}
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
self.unix = None;
|
self.unix = None;
|
||||||
@@ -214,6 +254,7 @@ mod tests {
|
|||||||
fn listener_config(addr: SocketAddr) -> ListenerConfig {
|
fn listener_config(addr: SocketAddr) -> ListenerConfig {
|
||||||
ListenerConfig {
|
ListenerConfig {
|
||||||
ip: addr.ip(),
|
ip: addr.ip(),
|
||||||
|
transport: crate::config::ListenerTransport::Mtproxy,
|
||||||
port: Some(addr.port()),
|
port: Some(addr.port()),
|
||||||
client_mss: None,
|
client_mss: None,
|
||||||
synlimit: SynLimitMode::Off,
|
synlimit: SynLimitMode::Off,
|
||||||
@@ -229,6 +270,8 @@ mod tests {
|
|||||||
announce_ip: None,
|
announce_ip: None,
|
||||||
proxy_protocol: None,
|
proxy_protocol: None,
|
||||||
reuse_allow: false,
|
reuse_allow: false,
|
||||||
|
web_client_ip_source: crate::config::WebClientIpSource::XForwardedFor,
|
||||||
|
web_trusted_proxy_cidrs: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,12 +280,15 @@ mod tests {
|
|||||||
let addr = listener.local_addr().unwrap();
|
let addr = listener.local_addr().unwrap();
|
||||||
let spec = ListenerBindSpec {
|
let spec = ListenerBindSpec {
|
||||||
addr,
|
addr,
|
||||||
|
transport: crate::config::ListenerTransport::Mtproxy,
|
||||||
options: ListenOptions {
|
options: ListenOptions {
|
||||||
reuse_port: false,
|
reuse_port: false,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
proxy_protocol: false,
|
proxy_protocol: false,
|
||||||
tls_response_fragment_size: None,
|
tls_response_fragment_size: None,
|
||||||
|
web_client_ip_source: crate::config::WebClientIpSource::XForwardedFor,
|
||||||
|
web_trusted_proxy_cidrs: Arc::from([]),
|
||||||
};
|
};
|
||||||
(
|
(
|
||||||
BoundTcpListener {
|
BoundTcpListener {
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::config::{ProxyConfig, ServerConfig, SynLimitMode};
|
use crate::config::{
|
||||||
|
ListenerTransport, ProxyConfig, ServerConfig, SynLimitMode, WebClientIpSource,
|
||||||
|
};
|
||||||
use crate::transport::ListenOptions;
|
use crate::transport::ListenOptions;
|
||||||
|
|
||||||
use super::tcp_mss_runtime_profile;
|
use super::tcp_mss_runtime_profile;
|
||||||
@@ -10,9 +13,12 @@ use super::tcp_mss_runtime_profile;
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub(super) struct ListenerBindSpec {
|
pub(super) struct ListenerBindSpec {
|
||||||
pub(super) addr: SocketAddr,
|
pub(super) addr: SocketAddr,
|
||||||
|
pub(super) transport: ListenerTransport,
|
||||||
pub(super) options: ListenOptions,
|
pub(super) options: ListenOptions,
|
||||||
pub(super) proxy_protocol: bool,
|
pub(super) proxy_protocol: bool,
|
||||||
pub(super) tls_response_fragment_size: Option<u16>,
|
pub(super) tls_response_fragment_size: Option<u16>,
|
||||||
|
pub(super) web_client_ip_source: WebClientIpSource,
|
||||||
|
pub(super) web_trusted_proxy_cidrs: Arc<[ipnetwork::IpNetwork]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn listener_port_or_legacy(listener: &crate::config::ListenerConfig, server: &ServerConfig) -> u16 {
|
fn listener_port_or_legacy(listener: &crate::config::ListenerConfig, server: &ServerConfig) -> u16 {
|
||||||
@@ -40,16 +46,24 @@ pub(crate) fn listener_bind_plan(
|
|||||||
if addr.is_ipv6() && config.network.ipv6 == Some(false) {
|
if addr.is_ipv6() && config.network.ipv6 == Some(false) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let configured_client_mss = listener
|
let configured_client_mss = if listener.transport == ListenerTransport::Web {
|
||||||
.effective_client_mss(&config.server)
|
None
|
||||||
.map_err(|error| format!("invalid client MSS for listener {addr}: {error}"))?;
|
} else {
|
||||||
|
listener
|
||||||
|
.effective_client_mss(&config.server)
|
||||||
|
.map_err(|error| format!("invalid client MSS for listener {addr}: {error}"))?
|
||||||
|
};
|
||||||
|
let listener_bulk_mss = (listener.transport != ListenerTransport::Web)
|
||||||
|
.then_some(bulk_client_mss)
|
||||||
|
.flatten();
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
let (client_mss, tls_response_fragment_size) =
|
let (client_mss, tls_response_fragment_size) =
|
||||||
tcp_mss_runtime_profile(configured_client_mss, bulk_client_mss);
|
tcp_mss_runtime_profile(configured_client_mss, listener_bulk_mss);
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
let (client_mss, tls_response_fragment_size) = (configured_client_mss, None);
|
let (client_mss, tls_response_fragment_size) = (configured_client_mss, None);
|
||||||
let spec = ListenerBindSpec {
|
let spec = ListenerBindSpec {
|
||||||
addr,
|
addr,
|
||||||
|
transport: listener.transport,
|
||||||
options: ListenOptions {
|
options: ListenOptions {
|
||||||
reuse_port: listener.reuse_allow,
|
reuse_port: listener.reuse_allow,
|
||||||
ipv6_only: listener.ip.is_ipv6(),
|
ipv6_only: listener.ip.is_ipv6(),
|
||||||
@@ -61,6 +75,8 @@ pub(crate) fn listener_bind_plan(
|
|||||||
.proxy_protocol
|
.proxy_protocol
|
||||||
.unwrap_or(config.server.proxy_protocol),
|
.unwrap_or(config.server.proxy_protocol),
|
||||||
tls_response_fragment_size,
|
tls_response_fragment_size,
|
||||||
|
web_client_ip_source: listener.web_client_ip_source,
|
||||||
|
web_trusted_proxy_cidrs: Arc::from(listener.web_trusted_proxy_cidrs.clone()),
|
||||||
};
|
};
|
||||||
if plan.insert(addr, spec).is_some() {
|
if plan.insert(addr, spec).is_some() {
|
||||||
return Err(format!("duplicate effective listener endpoint: {addr}"));
|
return Err(format!("duplicate effective listener endpoint: {addr}"));
|
||||||
@@ -80,6 +96,23 @@ fn any_synlimit_enabled(config: &ProxyConfig) -> bool {
|
|||||||
|
|
||||||
/// Returns whether an endpoint-only change can use coordinated process rebind.
|
/// Returns whether an endpoint-only change can use coordinated process rebind.
|
||||||
pub(crate) fn listener_rebind_supported(old: &ProxyConfig, desired: &ProxyConfig) -> bool {
|
pub(crate) fn listener_rebind_supported(old: &ProxyConfig, desired: &ProxyConfig) -> bool {
|
||||||
|
let Ok(old_plan) = listener_bind_plan(old) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Ok(desired_plan) = listener_bind_plan(desired) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let old_web = old_plan
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, spec)| spec.transport == ListenerTransport::Web)
|
||||||
|
.collect::<BTreeMap<_, _>>();
|
||||||
|
let desired_web = desired_plan
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, spec)| spec.transport == ListenerTransport::Web)
|
||||||
|
.collect::<BTreeMap<_, _>>();
|
||||||
|
if old_web != desired_web {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if any_synlimit_enabled(old) || any_synlimit_enabled(desired) {
|
if any_synlimit_enabled(old) || any_synlimit_enabled(desired) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -107,6 +140,7 @@ mod tests {
|
|||||||
fn listener(ip: &str, port: u16) -> ListenerConfig {
|
fn listener(ip: &str, port: u16) -> ListenerConfig {
|
||||||
ListenerConfig {
|
ListenerConfig {
|
||||||
ip: ip.parse().unwrap(),
|
ip: ip.parse().unwrap(),
|
||||||
|
transport: crate::config::ListenerTransport::Mtproxy,
|
||||||
port: Some(port),
|
port: Some(port),
|
||||||
client_mss: None,
|
client_mss: None,
|
||||||
synlimit: SynLimitMode::Off,
|
synlimit: SynLimitMode::Off,
|
||||||
@@ -122,6 +156,8 @@ mod tests {
|
|||||||
announce_ip: None,
|
announce_ip: None,
|
||||||
proxy_protocol: None,
|
proxy_protocol: None,
|
||||||
reuse_allow: false,
|
reuse_allow: false,
|
||||||
|
web_client_ip_source: crate::config::WebClientIpSource::XForwardedFor,
|
||||||
|
web_trusted_proxy_cidrs: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ mod tests {
|
|||||||
fn listener_with_synlimit(synlimit: SynLimitMode) -> ListenerConfig {
|
fn listener_with_synlimit(synlimit: SynLimitMode) -> ListenerConfig {
|
||||||
ListenerConfig {
|
ListenerConfig {
|
||||||
ip: "127.0.0.1".parse().unwrap(),
|
ip: "127.0.0.1".parse().unwrap(),
|
||||||
|
transport: crate::config::ListenerTransport::Mtproxy,
|
||||||
port: Some(443),
|
port: Some(443),
|
||||||
client_mss: None,
|
client_mss: None,
|
||||||
synlimit,
|
synlimit,
|
||||||
@@ -146,6 +147,8 @@ mod tests {
|
|||||||
announce_ip: None,
|
announce_ip: None,
|
||||||
proxy_protocol: None,
|
proxy_protocol: None,
|
||||||
reuse_allow: false,
|
reuse_allow: false,
|
||||||
|
web_client_ip_source: crate::config::WebClientIpSource::XForwardedFor,
|
||||||
|
web_trusted_proxy_cidrs: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -406,6 +406,16 @@ pub(crate) fn resolve_reload_config(
|
|||||||
fields.push("logging".to_string());
|
fields.push("logging".to_string());
|
||||||
effective.logging = old.logging.clone();
|
effective.logging = old.logging.clone();
|
||||||
}
|
}
|
||||||
|
if serde_json::to_value(&old.web.limits).ok()
|
||||||
|
!= serde_json::to_value(&desired.web.limits).ok()
|
||||||
|
{
|
||||||
|
fields.push("web.limits".to_string());
|
||||||
|
effective.web.limits = old.web.limits.clone();
|
||||||
|
if effective.rebuild_runtime_web().is_err() {
|
||||||
|
fields.push("web".to_string());
|
||||||
|
effective.web = old.web.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
let runtime_changed = !configs_equal(old, &effective);
|
let runtime_changed = !configs_equal(old, &effective);
|
||||||
ResolvedReloadConfig {
|
ResolvedReloadConfig {
|
||||||
effective,
|
effective,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use super::*;
|
|||||||
fn test_listener(port: u16) -> crate::config::ListenerConfig {
|
fn test_listener(port: u16) -> crate::config::ListenerConfig {
|
||||||
crate::config::ListenerConfig {
|
crate::config::ListenerConfig {
|
||||||
ip: "127.0.0.1".parse().unwrap(),
|
ip: "127.0.0.1".parse().unwrap(),
|
||||||
|
transport: crate::config::ListenerTransport::Mtproxy,
|
||||||
port: Some(port),
|
port: Some(port),
|
||||||
client_mss: None,
|
client_mss: None,
|
||||||
synlimit: crate::config::SynLimitMode::Off,
|
synlimit: crate::config::SynLimitMode::Off,
|
||||||
@@ -18,6 +19,8 @@ fn test_listener(port: u16) -> crate::config::ListenerConfig {
|
|||||||
announce_ip: None,
|
announce_ip: None,
|
||||||
proxy_protocol: None,
|
proxy_protocol: None,
|
||||||
reuse_allow: false,
|
reuse_allow: false,
|
||||||
|
web_client_ip_source: crate::config::WebClientIpSource::XForwardedFor,
|
||||||
|
web_trusted_proxy_cidrs: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +83,7 @@ fn listener_announcement_is_runtime_owned_when_bind_identity_is_stable() {
|
|||||||
let mut old = ProxyConfig::default();
|
let mut old = ProxyConfig::default();
|
||||||
old.server.listeners.push(crate::config::ListenerConfig {
|
old.server.listeners.push(crate::config::ListenerConfig {
|
||||||
ip: "0.0.0.0".parse().unwrap(),
|
ip: "0.0.0.0".parse().unwrap(),
|
||||||
|
transport: crate::config::ListenerTransport::Mtproxy,
|
||||||
port: Some(443),
|
port: Some(443),
|
||||||
client_mss: None,
|
client_mss: None,
|
||||||
synlimit: crate::config::SynLimitMode::Off,
|
synlimit: crate::config::SynLimitMode::Off,
|
||||||
@@ -95,6 +99,8 @@ fn listener_announcement_is_runtime_owned_when_bind_identity_is_stable() {
|
|||||||
announce_ip: None,
|
announce_ip: None,
|
||||||
proxy_protocol: None,
|
proxy_protocol: None,
|
||||||
reuse_allow: false,
|
reuse_allow: false,
|
||||||
|
web_client_ip_source: crate::config::WebClientIpSource::XForwardedFor,
|
||||||
|
web_trusted_proxy_cidrs: Vec::new(),
|
||||||
});
|
});
|
||||||
let mut desired = old.clone();
|
let mut desired = old.clone();
|
||||||
desired.server.listeners[0].announce = Some("proxy.example".to_string());
|
desired.server.listeners[0].announce = Some("proxy.example".to_string());
|
||||||
@@ -143,6 +149,27 @@ fn runtime_only_change_does_not_require_process_rebind() {
|
|||||||
assert!(deferred_process_fields(&old, &new).is_empty());
|
assert!(deferred_process_fields(&old, &new).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn web_allocation_limits_are_deferred_until_restart() {
|
||||||
|
let mut old = ProxyConfig::default();
|
||||||
|
old.rebuild_runtime_user_auth().unwrap();
|
||||||
|
old.rebuild_runtime_web().unwrap();
|
||||||
|
let mut desired = old.clone();
|
||||||
|
desired.web.limits.max_sessions_global += 1;
|
||||||
|
|
||||||
|
let resolved = resolve_reload_config(&old, &desired);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
resolved.deferred_process_fields,
|
||||||
|
vec!["web.limits".to_string()]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolved.effective.web.limits.max_sessions_global,
|
||||||
|
old.web.limits.max_sessions_global
|
||||||
|
);
|
||||||
|
assert!(!resolved.runtime_changed);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn strict_middle_proxy_requires_a_prepared_pool() {
|
fn strict_middle_proxy_requires_a_prepared_pool() {
|
||||||
assert!(strict_middle_proxy_unavailable(true, false, false));
|
assert!(strict_middle_proxy_unavailable(true, false, false));
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ mod synlimit_control;
|
|||||||
mod tls_front;
|
mod tls_front;
|
||||||
mod transport;
|
mod transport;
|
||||||
mod util;
|
mod util;
|
||||||
|
mod web;
|
||||||
|
|
||||||
fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
|
fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
|
||||||
// Install rustls crypto provider early
|
// Install rustls crypto provider early
|
||||||
|
|||||||
@@ -0,0 +1,325 @@
|
|||||||
|
use std::net::{IpAddr, SocketAddr};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use tokio::io::{AsyncRead, AsyncWrite};
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
use crate::config::ProxyConfig;
|
||||||
|
use crate::crypto::SecureRandom;
|
||||||
|
use crate::error::{ProxyError, Result};
|
||||||
|
use crate::ip_tracker::UserIpTracker;
|
||||||
|
use crate::proxy::direct_relay::handle_via_direct_with_shared_and_conntrack;
|
||||||
|
use crate::proxy::handshake::HandshakeSuccess;
|
||||||
|
use crate::proxy::middle_relay::{
|
||||||
|
handle_via_middle_proxy, handle_via_middle_proxy_with_conntrack,
|
||||||
|
};
|
||||||
|
use crate::proxy::route_mode::{RelayRouteMode, RouteRuntimeController};
|
||||||
|
use crate::proxy::shared_state::{ConntrackClosePolicy, ProxySharedState};
|
||||||
|
use crate::stats::Stats;
|
||||||
|
use crate::stream::{BufferPool, CryptoReader, CryptoWriter};
|
||||||
|
use crate::transport::UpstreamManager;
|
||||||
|
use crate::transport::middle_proxy::MePool;
|
||||||
|
|
||||||
|
/// Immutable dependency snapshot pinned by one authenticated client stream.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct ClientRuntimeDeps {
|
||||||
|
/// Immutable effective configuration pinned for this stream.
|
||||||
|
pub(crate) config: Arc<ProxyConfig>,
|
||||||
|
/// Process statistics registry.
|
||||||
|
pub(crate) stats: Arc<Stats>,
|
||||||
|
/// Direct Telegram upstream connector.
|
||||||
|
pub(crate) upstream_manager: Arc<UpstreamManager>,
|
||||||
|
/// Shared relay buffer pool.
|
||||||
|
pub(crate) buffer_pool: Arc<BufferPool>,
|
||||||
|
/// Process cryptographic random source.
|
||||||
|
pub(crate) rng: Arc<SecureRandom>,
|
||||||
|
/// Startup Middle-End pool, when immediately available.
|
||||||
|
pub(crate) me_pool: Option<Arc<MePool>>,
|
||||||
|
/// Hot-swappable Middle-End pool holder.
|
||||||
|
pub(crate) me_pool_runtime: Option<Arc<RwLock<Option<Arc<MePool>>>>>,
|
||||||
|
/// Route-mode controller shared by active generations.
|
||||||
|
pub(crate) route_runtime: Arc<RouteRuntimeController>,
|
||||||
|
/// Per-user source-IP admission tracker.
|
||||||
|
pub(crate) ip_tracker: Arc<UserIpTracker>,
|
||||||
|
/// Process-shared admission and relay coordination state.
|
||||||
|
pub(crate) shared: Arc<ProxySharedState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs admission and relay after a successful MTProxy handshake.
|
||||||
|
pub(crate) async fn run_authenticated<R, W>(
|
||||||
|
client_reader: CryptoReader<R>,
|
||||||
|
client_writer: CryptoWriter<W>,
|
||||||
|
success: HandshakeSuccess,
|
||||||
|
deps: ClientRuntimeDeps,
|
||||||
|
local_addr: SocketAddr,
|
||||||
|
peer_addr: SocketAddr,
|
||||||
|
conntrack_close_policy: ConntrackClosePolicy,
|
||||||
|
) -> Result<()>
|
||||||
|
where
|
||||||
|
R: AsyncRead + Unpin + Send + 'static,
|
||||||
|
W: AsyncWrite + Unpin + Send + 'static,
|
||||||
|
{
|
||||||
|
let user = success.user.clone();
|
||||||
|
if !deps.shared.is_user_enabled(&user) {
|
||||||
|
warn!(user = %user, "Disabled user rejected");
|
||||||
|
return Err(ProxyError::UserDisabled { user });
|
||||||
|
}
|
||||||
|
|
||||||
|
let user_reservation = acquire_user_connection_reservation(
|
||||||
|
&user,
|
||||||
|
&deps.config,
|
||||||
|
Arc::clone(&deps.stats),
|
||||||
|
peer_addr,
|
||||||
|
Arc::clone(&deps.ip_tracker),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
warn!(user = %user, error = %error, "User admission check failed");
|
||||||
|
error
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let route_snapshot = deps.route_runtime.snapshot();
|
||||||
|
let session_id = deps.rng.u64();
|
||||||
|
let user_session = deps.shared.register_user_session(&user, session_id);
|
||||||
|
let session_cancel = user_session.token();
|
||||||
|
let selected_me_pool = if deps.config.general.use_middle_proxy
|
||||||
|
&& matches!(route_snapshot.mode, RelayRouteMode::Middle)
|
||||||
|
{
|
||||||
|
if let Some(pool) = &deps.me_pool {
|
||||||
|
Some(Arc::clone(pool))
|
||||||
|
} else if let Some(pool_runtime) = &deps.me_pool_runtime {
|
||||||
|
pool_runtime.read().await.clone()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let relay_result = if deps.config.general.use_middle_proxy
|
||||||
|
&& matches!(route_snapshot.mode, RelayRouteMode::Middle)
|
||||||
|
{
|
||||||
|
if let Some(pool) = selected_me_pool {
|
||||||
|
if conntrack_close_policy == ConntrackClosePolicy::Publish {
|
||||||
|
handle_via_middle_proxy(
|
||||||
|
client_reader,
|
||||||
|
client_writer,
|
||||||
|
success,
|
||||||
|
pool,
|
||||||
|
Arc::clone(&deps.stats),
|
||||||
|
Arc::clone(&deps.config),
|
||||||
|
Arc::clone(&deps.buffer_pool),
|
||||||
|
local_addr,
|
||||||
|
Arc::clone(&deps.rng),
|
||||||
|
deps.route_runtime.subscribe(),
|
||||||
|
route_snapshot,
|
||||||
|
session_id,
|
||||||
|
session_cancel.clone(),
|
||||||
|
Arc::clone(&deps.shared),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
handle_via_middle_proxy_with_conntrack(
|
||||||
|
client_reader,
|
||||||
|
client_writer,
|
||||||
|
success,
|
||||||
|
pool,
|
||||||
|
Arc::clone(&deps.stats),
|
||||||
|
Arc::clone(&deps.config),
|
||||||
|
Arc::clone(&deps.buffer_pool),
|
||||||
|
local_addr,
|
||||||
|
Arc::clone(&deps.rng),
|
||||||
|
deps.route_runtime.subscribe(),
|
||||||
|
route_snapshot,
|
||||||
|
session_id,
|
||||||
|
session_cancel.clone(),
|
||||||
|
Arc::clone(&deps.shared),
|
||||||
|
ConntrackClosePolicy::Suppress,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
warn!("use_middle_proxy=true but MePool not initialized, falling back to direct");
|
||||||
|
run_direct(
|
||||||
|
client_reader,
|
||||||
|
client_writer,
|
||||||
|
success,
|
||||||
|
&deps,
|
||||||
|
route_snapshot,
|
||||||
|
session_id,
|
||||||
|
local_addr,
|
||||||
|
session_cancel.clone(),
|
||||||
|
conntrack_close_policy,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
run_direct(
|
||||||
|
client_reader,
|
||||||
|
client_writer,
|
||||||
|
success,
|
||||||
|
&deps,
|
||||||
|
route_snapshot,
|
||||||
|
session_id,
|
||||||
|
local_addr,
|
||||||
|
session_cancel,
|
||||||
|
conntrack_close_policy,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
};
|
||||||
|
user_reservation.release().await;
|
||||||
|
relay_result
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_direct<R, W>(
|
||||||
|
client_reader: CryptoReader<R>,
|
||||||
|
client_writer: CryptoWriter<W>,
|
||||||
|
success: HandshakeSuccess,
|
||||||
|
deps: &ClientRuntimeDeps,
|
||||||
|
route_snapshot: crate::proxy::route_mode::RouteCutoverState,
|
||||||
|
session_id: u64,
|
||||||
|
local_addr: SocketAddr,
|
||||||
|
session_cancel: tokio_util::sync::CancellationToken,
|
||||||
|
conntrack_close_policy: ConntrackClosePolicy,
|
||||||
|
) -> Result<()>
|
||||||
|
where
|
||||||
|
R: AsyncRead + Unpin + Send + 'static,
|
||||||
|
W: AsyncWrite + Unpin + Send + 'static,
|
||||||
|
{
|
||||||
|
handle_via_direct_with_shared_and_conntrack(
|
||||||
|
client_reader,
|
||||||
|
client_writer,
|
||||||
|
success,
|
||||||
|
Arc::clone(&deps.upstream_manager),
|
||||||
|
Arc::clone(&deps.stats),
|
||||||
|
Arc::clone(&deps.config),
|
||||||
|
Arc::clone(&deps.buffer_pool),
|
||||||
|
Arc::clone(&deps.rng),
|
||||||
|
deps.route_runtime.subscribe(),
|
||||||
|
route_snapshot,
|
||||||
|
session_id,
|
||||||
|
local_addr,
|
||||||
|
session_cancel,
|
||||||
|
Arc::clone(&deps.shared),
|
||||||
|
conntrack_close_policy,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use = "the reservation owns user and IP admission until release or drop"]
|
||||||
|
/// Owns one authenticated user's connection and source-IP admission slots.
|
||||||
|
pub(crate) struct UserConnectionReservation {
|
||||||
|
stats: Arc<Stats>,
|
||||||
|
ip_tracker: Arc<UserIpTracker>,
|
||||||
|
user: String,
|
||||||
|
ip: IpAddr,
|
||||||
|
tracks_ip: bool,
|
||||||
|
active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserConnectionReservation {
|
||||||
|
/// Creates an active reservation after both admission counters were acquired.
|
||||||
|
pub(crate) fn new(
|
||||||
|
stats: Arc<Stats>,
|
||||||
|
ip_tracker: Arc<UserIpTracker>,
|
||||||
|
user: String,
|
||||||
|
ip: IpAddr,
|
||||||
|
tracks_ip: bool,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
stats,
|
||||||
|
ip_tracker,
|
||||||
|
user,
|
||||||
|
ip,
|
||||||
|
tracks_ip,
|
||||||
|
active: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Releases both admission counters through the asynchronous cleanup path.
|
||||||
|
pub(crate) async fn release(mut self) {
|
||||||
|
if !self.active {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.active = false;
|
||||||
|
if self.tracks_ip {
|
||||||
|
self.ip_tracker.remove_ip(&self.user, self.ip).await;
|
||||||
|
}
|
||||||
|
self.stats.decrement_user_curr_connects(&self.user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for UserConnectionReservation {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if !self.active {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.active = false;
|
||||||
|
self.stats.increment_session_drop_fallback_total();
|
||||||
|
self.stats.decrement_user_curr_connects(&self.user);
|
||||||
|
if self.tracks_ip {
|
||||||
|
self.ip_tracker.enqueue_cleanup(self.user.clone(), self.ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies user quota, connection, and source-IP admission atomically.
|
||||||
|
pub(crate) async fn acquire_user_connection_reservation(
|
||||||
|
user: &str,
|
||||||
|
config: &ProxyConfig,
|
||||||
|
stats: Arc<Stats>,
|
||||||
|
peer_addr: SocketAddr,
|
||||||
|
ip_tracker: Arc<UserIpTracker>,
|
||||||
|
) -> Result<UserConnectionReservation> {
|
||||||
|
if let Some(expiration) = config.access.user_expirations.get(user)
|
||||||
|
&& chrono::Utc::now() > *expiration
|
||||||
|
{
|
||||||
|
return Err(ProxyError::UserExpired {
|
||||||
|
user: user.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(quota) = config.access.user_data_quota.get(user)
|
||||||
|
&& stats.get_user_quota_used(user) >= *quota
|
||||||
|
{
|
||||||
|
return Err(ProxyError::DataQuotaExceeded {
|
||||||
|
user: user.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let limit = config
|
||||||
|
.access
|
||||||
|
.user_max_tcp_conns
|
||||||
|
.get(user)
|
||||||
|
.copied()
|
||||||
|
.filter(|limit| *limit > 0)
|
||||||
|
.or((config.access.user_max_tcp_conns_global_each > 0)
|
||||||
|
.then_some(config.access.user_max_tcp_conns_global_each))
|
||||||
|
.map(|value| value as u64);
|
||||||
|
if !stats.try_acquire_user_curr_connects(user, limit) {
|
||||||
|
return Err(ProxyError::ConnectionLimitExceeded {
|
||||||
|
user: user.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(reason) = ip_tracker.check_and_add(user, peer_addr.ip()).await {
|
||||||
|
stats.decrement_user_curr_connects(user);
|
||||||
|
warn!(
|
||||||
|
user = %user,
|
||||||
|
ip = %peer_addr.ip(),
|
||||||
|
reason = %reason,
|
||||||
|
"IP limit exceeded"
|
||||||
|
);
|
||||||
|
return Err(ProxyError::ConnectionLimitExceeded {
|
||||||
|
user: user.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(UserConnectionReservation::new(
|
||||||
|
stats,
|
||||||
|
ip_tracker,
|
||||||
|
user.to_string(),
|
||||||
|
peer_addr.ip(),
|
||||||
|
true,
|
||||||
|
))
|
||||||
|
}
|
||||||
+29
-225
@@ -26,72 +26,6 @@ enum HandshakeOutcome {
|
|||||||
NeedsMasking(PostHandshakeFuture),
|
NeedsMasking(PostHandshakeFuture),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use = "UserConnectionReservation must be kept alive to retain user/IP reservation until release or drop"]
|
|
||||||
struct UserConnectionReservation {
|
|
||||||
stats: Arc<Stats>,
|
|
||||||
ip_tracker: Arc<UserIpTracker>,
|
|
||||||
user: String,
|
|
||||||
ip: IpAddr,
|
|
||||||
tracks_ip: bool,
|
|
||||||
state: SessionReservationState,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
||||||
enum SessionReservationState {
|
|
||||||
Active,
|
|
||||||
Released,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl UserConnectionReservation {
|
|
||||||
fn new(
|
|
||||||
stats: Arc<Stats>,
|
|
||||||
ip_tracker: Arc<UserIpTracker>,
|
|
||||||
user: String,
|
|
||||||
ip: IpAddr,
|
|
||||||
tracks_ip: bool,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
stats,
|
|
||||||
ip_tracker,
|
|
||||||
user,
|
|
||||||
ip,
|
|
||||||
tracks_ip,
|
|
||||||
state: SessionReservationState::Active,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mark_released(&mut self) -> bool {
|
|
||||||
if self.state != SessionReservationState::Active {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
self.state = SessionReservationState::Released;
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn release(mut self) {
|
|
||||||
if !self.mark_released() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if self.tracks_ip {
|
|
||||||
self.ip_tracker.remove_ip(&self.user, self.ip).await;
|
|
||||||
}
|
|
||||||
self.stats.decrement_user_curr_connects(&self.user);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for UserConnectionReservation {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
if !self.mark_released() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.stats.increment_session_drop_fallback_total();
|
|
||||||
self.stats.decrement_user_curr_connects(&self.user);
|
|
||||||
if self.tracks_ip {
|
|
||||||
self.ip_tracker.enqueue_cleanup(self.user.clone(), self.ip);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
use crate::config::ProxyConfig;
|
use crate::config::ProxyConfig;
|
||||||
use crate::crypto::SecureRandom;
|
use crate::crypto::SecureRandom;
|
||||||
use crate::error::{HandshakeResult, ProxyError, Result, StreamError};
|
use crate::error::{HandshakeResult, ProxyError, Result, StreamError};
|
||||||
@@ -107,7 +41,11 @@ use crate::transport::middle_proxy::MePool;
|
|||||||
use crate::transport::socket::normalize_ip;
|
use crate::transport::socket::normalize_ip;
|
||||||
use crate::transport::{UpstreamManager, configure_client_socket, parse_proxy_protocol};
|
use crate::transport::{UpstreamManager, configure_client_socket, parse_proxy_protocol};
|
||||||
|
|
||||||
use crate::proxy::direct_relay::handle_via_direct_with_shared;
|
use crate::proxy::authenticated::{ClientRuntimeDeps, run_authenticated};
|
||||||
|
#[cfg(test)]
|
||||||
|
use crate::proxy::authenticated::{
|
||||||
|
UserConnectionReservation, acquire_user_connection_reservation,
|
||||||
|
};
|
||||||
use crate::proxy::handshake::{
|
use crate::proxy::handshake::{
|
||||||
HandshakeSuccess, TlsResponseWriteOptions, handle_mtproto_handshake_with_shared,
|
HandshakeSuccess, TlsResponseWriteOptions, handle_mtproto_handshake_with_shared,
|
||||||
handle_tls_handshake_with_shared, handle_tls_handshake_with_shared_and_options,
|
handle_tls_handshake_with_shared, handle_tls_handshake_with_shared_and_options,
|
||||||
@@ -115,9 +53,10 @@ use crate::proxy::handshake::{
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use crate::proxy::handshake::{handle_mtproto_handshake, handle_tls_handshake};
|
use crate::proxy::handshake::{handle_mtproto_handshake, handle_tls_handshake};
|
||||||
use crate::proxy::masking::handle_bad_client_with_shared;
|
use crate::proxy::masking::handle_bad_client_with_shared;
|
||||||
use crate::proxy::middle_relay::handle_via_middle_proxy;
|
use crate::proxy::route_mode::RouteRuntimeController;
|
||||||
use crate::proxy::route_mode::{RelayRouteMode, RouteRuntimeController};
|
#[cfg(test)]
|
||||||
use crate::proxy::shared_state::ProxySharedState;
|
use crate::proxy::route_mode::RelayRouteMode;
|
||||||
|
use crate::proxy::shared_state::{ConntrackClosePolicy, ProxySharedState};
|
||||||
|
|
||||||
fn beobachten_ttl(config: &ProxyConfig) -> Duration {
|
fn beobachten_ttl(config: &ProxyConfig) -> Duration {
|
||||||
const BEOBACHTEN_TTL_MAX_MINUTES: u64 = 24 * 60;
|
const BEOBACHTEN_TTL_MAX_MINUTES: u64 = 24 * 60;
|
||||||
@@ -1688,112 +1627,30 @@ impl RunningClientHandler {
|
|||||||
R: AsyncRead + Unpin + Send + 'static,
|
R: AsyncRead + Unpin + Send + 'static,
|
||||||
W: AsyncWrite + Unpin + Send + 'static,
|
W: AsyncWrite + Unpin + Send + 'static,
|
||||||
{
|
{
|
||||||
let user = success.user.clone();
|
run_authenticated(
|
||||||
|
client_reader,
|
||||||
if !shared.is_user_enabled(&user) {
|
client_writer,
|
||||||
warn!(user = %user, "Disabled user rejected");
|
success,
|
||||||
return Err(ProxyError::UserDisabled { user });
|
ClientRuntimeDeps {
|
||||||
}
|
|
||||||
|
|
||||||
let user_limit_reservation = match Self::acquire_user_connection_reservation_static(
|
|
||||||
&user,
|
|
||||||
&config,
|
|
||||||
stats.clone(),
|
|
||||||
peer_addr,
|
|
||||||
ip_tracker,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(reservation) => reservation,
|
|
||||||
Err(e) => {
|
|
||||||
warn!(user = %user, error = %e, "User admission check failed");
|
|
||||||
return Err(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let route_snapshot = route_runtime.snapshot();
|
|
||||||
let session_id = rng.u64();
|
|
||||||
let _user_session = shared.register_user_session(&user, session_id);
|
|
||||||
let session_cancel = _user_session.token();
|
|
||||||
let selected_me_pool = if config.general.use_middle_proxy
|
|
||||||
&& matches!(route_snapshot.mode, RelayRouteMode::Middle)
|
|
||||||
{
|
|
||||||
if let Some(ref pool) = me_pool {
|
|
||||||
Some(pool.clone())
|
|
||||||
} else if let Some(pool_runtime) = me_pool_runtime.as_ref() {
|
|
||||||
pool_runtime.read().await.clone()
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
let relay_result = if config.general.use_middle_proxy
|
|
||||||
&& matches!(route_snapshot.mode, RelayRouteMode::Middle)
|
|
||||||
{
|
|
||||||
if let Some(pool) = selected_me_pool {
|
|
||||||
handle_via_middle_proxy(
|
|
||||||
client_reader,
|
|
||||||
client_writer,
|
|
||||||
success,
|
|
||||||
pool,
|
|
||||||
stats.clone(),
|
|
||||||
config,
|
|
||||||
buffer_pool,
|
|
||||||
local_addr,
|
|
||||||
rng,
|
|
||||||
route_runtime.subscribe(),
|
|
||||||
route_snapshot,
|
|
||||||
session_id,
|
|
||||||
session_cancel.clone(),
|
|
||||||
shared.clone(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
} else {
|
|
||||||
warn!("use_middle_proxy=true but MePool not initialized, falling back to direct");
|
|
||||||
handle_via_direct_with_shared(
|
|
||||||
client_reader,
|
|
||||||
client_writer,
|
|
||||||
success,
|
|
||||||
upstream_manager,
|
|
||||||
stats.clone(),
|
|
||||||
config,
|
|
||||||
buffer_pool,
|
|
||||||
rng,
|
|
||||||
route_runtime.subscribe(),
|
|
||||||
route_snapshot,
|
|
||||||
session_id,
|
|
||||||
local_addr,
|
|
||||||
session_cancel.clone(),
|
|
||||||
shared.clone(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Direct mode (original behavior)
|
|
||||||
handle_via_direct_with_shared(
|
|
||||||
client_reader,
|
|
||||||
client_writer,
|
|
||||||
success,
|
|
||||||
upstream_manager,
|
|
||||||
stats.clone(),
|
|
||||||
config,
|
config,
|
||||||
|
stats,
|
||||||
|
upstream_manager,
|
||||||
buffer_pool,
|
buffer_pool,
|
||||||
rng,
|
rng,
|
||||||
route_runtime.subscribe(),
|
me_pool,
|
||||||
route_snapshot,
|
me_pool_runtime,
|
||||||
session_id,
|
route_runtime,
|
||||||
local_addr,
|
ip_tracker,
|
||||||
session_cancel,
|
shared,
|
||||||
shared.clone(),
|
},
|
||||||
)
|
local_addr,
|
||||||
.await
|
peer_addr,
|
||||||
};
|
ConntrackClosePolicy::Publish,
|
||||||
user_limit_reservation.release().await;
|
)
|
||||||
relay_result
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
async fn acquire_user_connection_reservation_static(
|
async fn acquire_user_connection_reservation_static(
|
||||||
user: &str,
|
user: &str,
|
||||||
config: &ProxyConfig,
|
config: &ProxyConfig,
|
||||||
@@ -1801,60 +1658,7 @@ impl RunningClientHandler {
|
|||||||
peer_addr: SocketAddr,
|
peer_addr: SocketAddr,
|
||||||
ip_tracker: Arc<UserIpTracker>,
|
ip_tracker: Arc<UserIpTracker>,
|
||||||
) -> Result<UserConnectionReservation> {
|
) -> Result<UserConnectionReservation> {
|
||||||
if let Some(expiration) = config.access.user_expirations.get(user)
|
acquire_user_connection_reservation(user, config, stats, peer_addr, ip_tracker).await
|
||||||
&& chrono::Utc::now() > *expiration
|
|
||||||
{
|
|
||||||
return Err(ProxyError::UserExpired {
|
|
||||||
user: user.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(quota) = config.access.user_data_quota.get(user)
|
|
||||||
&& stats.get_user_quota_used(user) >= *quota
|
|
||||||
{
|
|
||||||
return Err(ProxyError::DataQuotaExceeded {
|
|
||||||
user: user.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let limit = config
|
|
||||||
.access
|
|
||||||
.user_max_tcp_conns
|
|
||||||
.get(user)
|
|
||||||
.copied()
|
|
||||||
.filter(|limit| *limit > 0)
|
|
||||||
.or((config.access.user_max_tcp_conns_global_each > 0)
|
|
||||||
.then_some(config.access.user_max_tcp_conns_global_each))
|
|
||||||
.map(|v| v as u64);
|
|
||||||
if !stats.try_acquire_user_curr_connects(user, limit) {
|
|
||||||
return Err(ProxyError::ConnectionLimitExceeded {
|
|
||||||
user: user.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
match ip_tracker.check_and_add(user, peer_addr.ip()).await {
|
|
||||||
Ok(()) => {}
|
|
||||||
Err(reason) => {
|
|
||||||
stats.decrement_user_curr_connects(user);
|
|
||||||
warn!(
|
|
||||||
user = %user,
|
|
||||||
ip = %peer_addr.ip(),
|
|
||||||
reason = %reason,
|
|
||||||
"IP limit exceeded"
|
|
||||||
);
|
|
||||||
return Err(ProxyError::ConnectionLimitExceeded {
|
|
||||||
user: user.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(UserConnectionReservation::new(
|
|
||||||
stats,
|
|
||||||
ip_tracker,
|
|
||||||
user.to_string(),
|
|
||||||
peer_addr.ip(),
|
|
||||||
true,
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
+59
-12
@@ -22,7 +22,8 @@ use crate::proxy::route_mode::{
|
|||||||
RelayRouteMode, RouteCutoverState, affected_cutover_state, cutover_stagger_delay,
|
RelayRouteMode, RouteCutoverState, affected_cutover_state, cutover_stagger_delay,
|
||||||
};
|
};
|
||||||
use crate::proxy::shared_state::{
|
use crate::proxy::shared_state::{
|
||||||
ConntrackCloseEvent, ConntrackClosePublishResult, ConntrackCloseReason, ProxySharedState,
|
ConntrackCloseEvent, ConntrackClosePolicy, ConntrackClosePublishResult, ConntrackCloseReason,
|
||||||
|
ProxySharedState,
|
||||||
};
|
};
|
||||||
use crate::stats::Stats;
|
use crate::stats::Stats;
|
||||||
use crate::stream::{BufferPool, CryptoReader, CryptoWriter};
|
use crate::stream::{BufferPool, CryptoReader, CryptoWriter};
|
||||||
@@ -229,6 +230,7 @@ fn unknown_dc_test_lock() -> &'static Mutex<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
|
/// Runs Direct relay with standalone cancellation and shared-state defaults.
|
||||||
pub(crate) async fn handle_via_direct<R, W>(
|
pub(crate) async fn handle_via_direct<R, W>(
|
||||||
client_reader: CryptoReader<R>,
|
client_reader: CryptoReader<R>,
|
||||||
client_writer: CryptoWriter<W>,
|
client_writer: CryptoWriter<W>,
|
||||||
@@ -265,7 +267,49 @@ where
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Runs Direct relay for a kernel-backed TCP client tuple.
|
||||||
pub(crate) async fn handle_via_direct_with_shared<R, W>(
|
pub(crate) async fn handle_via_direct_with_shared<R, W>(
|
||||||
|
client_reader: CryptoReader<R>,
|
||||||
|
client_writer: CryptoWriter<W>,
|
||||||
|
success: HandshakeSuccess,
|
||||||
|
upstream_manager: Arc<UpstreamManager>,
|
||||||
|
stats: Arc<Stats>,
|
||||||
|
config: Arc<ProxyConfig>,
|
||||||
|
buffer_pool: Arc<BufferPool>,
|
||||||
|
rng: Arc<SecureRandom>,
|
||||||
|
route_rx: watch::Receiver<RouteCutoverState>,
|
||||||
|
route_snapshot: RouteCutoverState,
|
||||||
|
session_id: u64,
|
||||||
|
local_addr: SocketAddr,
|
||||||
|
session_cancel: CancellationToken,
|
||||||
|
shared: Arc<ProxySharedState>,
|
||||||
|
) -> Result<()>
|
||||||
|
where
|
||||||
|
R: AsyncRead + Unpin + Send + 'static,
|
||||||
|
W: AsyncWrite + Unpin + Send + 'static,
|
||||||
|
{
|
||||||
|
handle_via_direct_with_shared_and_conntrack(
|
||||||
|
client_reader,
|
||||||
|
client_writer,
|
||||||
|
success,
|
||||||
|
upstream_manager,
|
||||||
|
stats,
|
||||||
|
config,
|
||||||
|
buffer_pool,
|
||||||
|
rng,
|
||||||
|
route_rx,
|
||||||
|
route_snapshot,
|
||||||
|
session_id,
|
||||||
|
local_addr,
|
||||||
|
session_cancel,
|
||||||
|
shared,
|
||||||
|
ConntrackClosePolicy::Publish,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs Direct relay with explicit kernel-conntrack close publication policy.
|
||||||
|
pub(crate) async fn handle_via_direct_with_shared_and_conntrack<R, W>(
|
||||||
client_reader: CryptoReader<R>,
|
client_reader: CryptoReader<R>,
|
||||||
client_writer: CryptoWriter<W>,
|
client_writer: CryptoWriter<W>,
|
||||||
success: HandshakeSuccess,
|
success: HandshakeSuccess,
|
||||||
@@ -280,6 +324,7 @@ pub(crate) async fn handle_via_direct_with_shared<R, W>(
|
|||||||
local_addr: SocketAddr,
|
local_addr: SocketAddr,
|
||||||
session_cancel: CancellationToken,
|
session_cancel: CancellationToken,
|
||||||
shared: Arc<ProxySharedState>,
|
shared: Arc<ProxySharedState>,
|
||||||
|
conntrack_close_policy: ConntrackClosePolicy,
|
||||||
) -> Result<()>
|
) -> Result<()>
|
||||||
where
|
where
|
||||||
R: AsyncRead + Unpin + Send + 'static,
|
R: AsyncRead + Unpin + Send + 'static,
|
||||||
@@ -407,17 +452,19 @@ where
|
|||||||
pool_snapshot.allocated.saturating_sub(pool_snapshot.pooled),
|
pool_snapshot.allocated.saturating_sub(pool_snapshot.pooled),
|
||||||
);
|
);
|
||||||
|
|
||||||
let close_reason = classify_conntrack_close_reason(&relay_result);
|
if conntrack_close_policy == ConntrackClosePolicy::Publish {
|
||||||
let publish_result = shared.publish_conntrack_close_event(ConntrackCloseEvent {
|
let close_reason = classify_conntrack_close_reason(&relay_result);
|
||||||
src: success.peer,
|
let publish_result = shared.publish_conntrack_close_event(ConntrackCloseEvent {
|
||||||
dst: local_addr,
|
src: success.peer,
|
||||||
reason: close_reason,
|
dst: local_addr,
|
||||||
});
|
reason: close_reason,
|
||||||
if !matches!(
|
});
|
||||||
publish_result,
|
if !matches!(
|
||||||
ConntrackClosePublishResult::Sent | ConntrackClosePublishResult::Disabled
|
publish_result,
|
||||||
) {
|
ConntrackClosePublishResult::Sent | ConntrackClosePublishResult::Disabled
|
||||||
stats.increment_conntrack_close_event_drop_total();
|
) {
|
||||||
|
stats.increment_conntrack_close_event_drop_total();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
relay_result
|
relay_result
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt};
|
|||||||
use tracing::{debug, info, trace, warn};
|
use tracing::{debug, info, trace, warn};
|
||||||
use zeroize::{Zeroize, Zeroizing};
|
use zeroize::{Zeroize, Zeroizing};
|
||||||
|
|
||||||
use crate::config::{ProxyConfig, UnknownSniAction};
|
use crate::config::{ProxyConfig, UnknownSniAction, WebSecretMode};
|
||||||
use crate::crypto::{AesCtr, SecureRandom, sha256};
|
use crate::crypto::{AesCtr, SecureRandom, sha256};
|
||||||
use crate::error::{HandshakeResult, ProxyError};
|
use crate::error::{HandshakeResult, ProxyError};
|
||||||
use crate::protocol::constants::*;
|
use crate::protocol::constants::*;
|
||||||
@@ -58,6 +58,7 @@ pub(crate) use self::auth_probe::{AuthProbeSaturationState, AuthProbeState};
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub use self::mtproto::handle_mtproto_handshake;
|
pub use self::mtproto::handle_mtproto_handshake;
|
||||||
pub use self::mtproto::handle_mtproto_handshake_with_shared;
|
pub use self::mtproto::handle_mtproto_handshake_with_shared;
|
||||||
|
pub(crate) use self::mtproto::handle_mtproto_handshake_for_web_user;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use self::nonce::{encrypt_tg_nonce, encrypt_tg_nonce_with_ciphers, generate_tg_nonce};
|
pub use self::nonce::{encrypt_tg_nonce, encrypt_tg_nonce_with_ciphers, generate_tg_nonce};
|
||||||
pub use self::session::HandshakeSuccess;
|
pub use self::session::HandshakeSuccess;
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ pub(super) struct MtprotoCandidateValidation {
|
|||||||
pub(super) encryptor: AesCtr,
|
pub(super) encryptor: AesCtr,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub(super) enum MtprotoModePolicy {
|
||||||
|
Configured,
|
||||||
|
Web(WebSecretMode),
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn sni_hint_hash(sni: &str) -> u64 {
|
pub(super) fn sni_hint_hash(sni: &str) -> u64 {
|
||||||
let mut hasher = DefaultHasher::new();
|
let mut hasher = DefaultHasher::new();
|
||||||
for byte in sni.bytes() {
|
for byte in sni.bytes() {
|
||||||
@@ -146,6 +152,7 @@ pub(super) fn validate_mtproto_secret_candidate(
|
|||||||
secret: &[u8; ACCESS_SECRET_BYTES],
|
secret: &[u8; ACCESS_SECRET_BYTES],
|
||||||
config: &ProxyConfig,
|
config: &ProxyConfig,
|
||||||
is_tls: bool,
|
is_tls: bool,
|
||||||
|
mode_policy: MtprotoModePolicy,
|
||||||
) -> Option<MtprotoCandidateValidation> {
|
) -> Option<MtprotoCandidateValidation> {
|
||||||
let mut dec_key_input = Zeroizing::new(Vec::with_capacity(PREKEY_LEN + secret.len()));
|
let mut dec_key_input = Zeroizing::new(Vec::with_capacity(PREKEY_LEN + secret.len()));
|
||||||
dec_key_input.extend_from_slice(dec_prekey);
|
dec_key_input.extend_from_slice(dec_prekey);
|
||||||
@@ -163,7 +170,7 @@ pub(super) fn validate_mtproto_secret_candidate(
|
|||||||
decrypted[PROTO_TAG_POS + 3],
|
decrypted[PROTO_TAG_POS + 3],
|
||||||
];
|
];
|
||||||
let proto_tag = ProtoTag::from_bytes(tag_bytes)?;
|
let proto_tag = ProtoTag::from_bytes(tag_bytes)?;
|
||||||
if !mode_enabled_for_proto(config, proto_tag, is_tls) {
|
if !mode_enabled_for_proto_with_policy(config, proto_tag, is_tls, mode_policy) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,6 +274,28 @@ pub(super) fn mode_enabled_for_proto(
|
|||||||
proto_tag: ProtoTag,
|
proto_tag: ProtoTag,
|
||||||
is_tls: bool,
|
is_tls: bool,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
|
mode_enabled_for_proto_with_policy(
|
||||||
|
config,
|
||||||
|
proto_tag,
|
||||||
|
is_tls,
|
||||||
|
MtprotoModePolicy::Configured,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mode_enabled_for_proto_with_policy(
|
||||||
|
config: &ProxyConfig,
|
||||||
|
proto_tag: ProtoTag,
|
||||||
|
is_tls: bool,
|
||||||
|
policy: MtprotoModePolicy,
|
||||||
|
) -> bool {
|
||||||
|
if let MtprotoModePolicy::Web(secret_mode) = policy {
|
||||||
|
return match secret_mode {
|
||||||
|
WebSecretMode::Plain => {
|
||||||
|
matches!(proto_tag, ProtoTag::Intermediate | ProtoTag::Abridged)
|
||||||
|
}
|
||||||
|
WebSecretMode::Dd => matches!(proto_tag, ProtoTag::Secure),
|
||||||
|
};
|
||||||
|
}
|
||||||
match proto_tag {
|
match proto_tag {
|
||||||
ProtoTag::Secure => {
|
ProtoTag::Secure => {
|
||||||
if is_tls {
|
if is_tls {
|
||||||
@@ -279,6 +308,46 @@ pub(super) fn mode_enabled_for_proto(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod web_mode_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn web_secret_mode_isolates_inner_protocol_tags() {
|
||||||
|
let config = ProxyConfig::default();
|
||||||
|
assert!(mode_enabled_for_proto_with_policy(
|
||||||
|
&config,
|
||||||
|
ProtoTag::Abridged,
|
||||||
|
false,
|
||||||
|
MtprotoModePolicy::Web(WebSecretMode::Plain),
|
||||||
|
));
|
||||||
|
assert!(mode_enabled_for_proto_with_policy(
|
||||||
|
&config,
|
||||||
|
ProtoTag::Intermediate,
|
||||||
|
false,
|
||||||
|
MtprotoModePolicy::Web(WebSecretMode::Plain),
|
||||||
|
));
|
||||||
|
assert!(!mode_enabled_for_proto_with_policy(
|
||||||
|
&config,
|
||||||
|
ProtoTag::Secure,
|
||||||
|
false,
|
||||||
|
MtprotoModePolicy::Web(WebSecretMode::Plain),
|
||||||
|
));
|
||||||
|
assert!(mode_enabled_for_proto_with_policy(
|
||||||
|
&config,
|
||||||
|
ProtoTag::Secure,
|
||||||
|
false,
|
||||||
|
MtprotoModePolicy::Web(WebSecretMode::Dd),
|
||||||
|
));
|
||||||
|
assert!(!mode_enabled_for_proto_with_policy(
|
||||||
|
&config,
|
||||||
|
ProtoTag::Intermediate,
|
||||||
|
false,
|
||||||
|
MtprotoModePolicy::Web(WebSecretMode::Dd),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn decode_user_secrets_in(
|
pub(super) fn decode_user_secrets_in(
|
||||||
shared: &ProxySharedState,
|
shared: &ProxySharedState,
|
||||||
config: &ProxyConfig,
|
config: &ProxyConfig,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// Handle MTProto obfuscation handshake
|
/// Handles an MTProto obfuscation handshake with isolated test state.
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub async fn handle_mtproto_handshake<R, W>(
|
pub async fn handle_mtproto_handshake<R, W>(
|
||||||
handshake: &[u8; HANDSHAKE_LEN],
|
handshake: &[u8; HANDSHAKE_LEN],
|
||||||
@@ -26,11 +26,14 @@ where
|
|||||||
replay_checker,
|
replay_checker,
|
||||||
is_tls,
|
is_tls,
|
||||||
preferred_user,
|
preferred_user,
|
||||||
|
None,
|
||||||
|
MtprotoModePolicy::Configured,
|
||||||
shared.as_ref(),
|
shared.as_ref(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handles an MTProto obfuscation handshake with process-shared defenses.
|
||||||
pub async fn handle_mtproto_handshake_with_shared<R, W>(
|
pub async fn handle_mtproto_handshake_with_shared<R, W>(
|
||||||
handshake: &[u8; HANDSHAKE_LEN],
|
handshake: &[u8; HANDSHAKE_LEN],
|
||||||
reader: R,
|
reader: R,
|
||||||
@@ -55,6 +58,40 @@ where
|
|||||||
replay_checker,
|
replay_checker,
|
||||||
is_tls,
|
is_tls,
|
||||||
preferred_user,
|
preferred_user,
|
||||||
|
None,
|
||||||
|
MtprotoModePolicy::Configured,
|
||||||
|
shared,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authenticates one WEB logical stream against exactly one user and secret mode.
|
||||||
|
pub(crate) async fn handle_mtproto_handshake_for_web_user<R, W>(
|
||||||
|
handshake: &[u8; HANDSHAKE_LEN],
|
||||||
|
reader: R,
|
||||||
|
writer: W,
|
||||||
|
peer: SocketAddr,
|
||||||
|
config: &ProxyConfig,
|
||||||
|
replay_checker: &ReplayChecker,
|
||||||
|
exact_user: &str,
|
||||||
|
secret_mode: WebSecretMode,
|
||||||
|
shared: &ProxySharedState,
|
||||||
|
) -> HandshakeResult<(CryptoReader<R>, CryptoWriter<W>, HandshakeSuccess), R, W>
|
||||||
|
where
|
||||||
|
R: AsyncRead + Unpin + Send,
|
||||||
|
W: AsyncWrite + Unpin + Send,
|
||||||
|
{
|
||||||
|
handle_mtproto_handshake_impl(
|
||||||
|
handshake,
|
||||||
|
reader,
|
||||||
|
writer,
|
||||||
|
peer,
|
||||||
|
config,
|
||||||
|
replay_checker,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
Some(exact_user),
|
||||||
|
MtprotoModePolicy::Web(secret_mode),
|
||||||
shared,
|
shared,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -69,6 +106,8 @@ async fn handle_mtproto_handshake_impl<R, W>(
|
|||||||
replay_checker: &ReplayChecker,
|
replay_checker: &ReplayChecker,
|
||||||
is_tls: bool,
|
is_tls: bool,
|
||||||
preferred_user: Option<&str>,
|
preferred_user: Option<&str>,
|
||||||
|
exact_user: Option<&str>,
|
||||||
|
mode_policy: MtprotoModePolicy,
|
||||||
shared: &ProxySharedState,
|
shared: &ProxySharedState,
|
||||||
) -> HandshakeResult<(CryptoReader<R>, CryptoWriter<W>, HandshakeSuccess), R, W>
|
) -> HandshakeResult<(CryptoReader<R>, CryptoWriter<W>, HandshakeSuccess), R, W>
|
||||||
where
|
where
|
||||||
@@ -113,8 +152,11 @@ where
|
|||||||
let sticky_ip_hint = sticky_hint_get_by_ip(shared, peer.ip());
|
let sticky_ip_hint = sticky_hint_get_by_ip(shared, peer.ip());
|
||||||
let sticky_prefix_hint = sticky_hint_get_by_ip_prefix(shared, peer.ip());
|
let sticky_prefix_hint = sticky_hint_get_by_ip_prefix(shared, peer.ip());
|
||||||
let preferred_user_id = preferred_user.and_then(|user| snapshot.user_id_by_name(user));
|
let preferred_user_id = preferred_user.and_then(|user| snapshot.user_id_by_name(user));
|
||||||
let has_hint =
|
let exact_user_id = exact_user.and_then(|user| snapshot.user_id_by_name(user));
|
||||||
sticky_ip_hint.is_some() || sticky_prefix_hint.is_some() || preferred_user_id.is_some();
|
let has_hint = sticky_ip_hint.is_some()
|
||||||
|
|| sticky_prefix_hint.is_some()
|
||||||
|
|| preferred_user_id.is_some()
|
||||||
|
|| exact_user_id.is_some();
|
||||||
let overload = auth_probe_saturation_is_throttled_in(shared, Instant::now());
|
let overload = auth_probe_saturation_is_throttled_in(shared, Instant::now());
|
||||||
let candidate_budget = budget_for_validation(snapshot.entries().len(), overload, has_hint);
|
let candidate_budget = budget_for_validation(snapshot.entries().len(), overload, has_hint);
|
||||||
|
|
||||||
@@ -145,6 +187,7 @@ where
|
|||||||
&entry.secret,
|
&entry.secret,
|
||||||
config,
|
config,
|
||||||
is_tls,
|
is_tls,
|
||||||
|
mode_policy,
|
||||||
) {
|
) {
|
||||||
matched_user = entry.user.clone();
|
matched_user = entry.user.clone();
|
||||||
matched_user_id = Some($user_id);
|
matched_user_id = Some($user_id);
|
||||||
@@ -159,20 +202,20 @@ where
|
|||||||
}};
|
}};
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut matched = false;
|
let mut matched = exact_user_id.is_some_and(|user_id| try_user_id!(user_id));
|
||||||
if let Some(user_id) = sticky_ip_hint {
|
if exact_user.is_none() && let Some(user_id) = sticky_ip_hint {
|
||||||
matched = try_user_id!(user_id);
|
matched = try_user_id!(user_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if !matched && let Some(user_id) = preferred_user_id {
|
if exact_user.is_none() && !matched && let Some(user_id) = preferred_user_id {
|
||||||
matched = try_user_id!(user_id);
|
matched = try_user_id!(user_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if !matched && let Some(user_id) = sticky_prefix_hint {
|
if exact_user.is_none() && !matched && let Some(user_id) = sticky_prefix_hint {
|
||||||
matched = try_user_id!(user_id);
|
matched = try_user_id!(user_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if !matched && !budget_exhausted {
|
if exact_user.is_none() && !matched && !budget_exhausted {
|
||||||
let ring = &shared.handshake.recent_user_ring;
|
let ring = &shared.handshake.recent_user_ring;
|
||||||
if !ring.is_empty() {
|
if !ring.is_empty() {
|
||||||
let next_seq = shared
|
let next_seq = shared
|
||||||
@@ -197,7 +240,7 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !matched && !budget_exhausted {
|
if exact_user.is_none() && !matched && !budget_exhausted {
|
||||||
for idx in 0..snapshot.entries().len() {
|
for idx in 0..snapshot.entries().len() {
|
||||||
let Some(user_id) = u32::try_from(idx).ok() else {
|
let Some(user_id) = u32::try_from(idx).ok() else {
|
||||||
break;
|
break;
|
||||||
@@ -317,7 +360,16 @@ where
|
|||||||
success,
|
success,
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
let decoded_users = decode_user_secrets_in(shared, config, preferred_user);
|
let decoded_users = match exact_user {
|
||||||
|
Some(user) => config
|
||||||
|
.access
|
||||||
|
.users
|
||||||
|
.get(user)
|
||||||
|
.and_then(|secret| decode_user_secret(shared, user, secret))
|
||||||
|
.map(|secret| vec![(user.to_string(), secret)])
|
||||||
|
.unwrap_or_default(),
|
||||||
|
None => decode_user_secrets_in(shared, config, preferred_user),
|
||||||
|
};
|
||||||
let mut validation_checks = 0usize;
|
let mut validation_checks = 0usize;
|
||||||
|
|
||||||
for (user, secret) in decoded_users {
|
for (user, secret) in decoded_users {
|
||||||
@@ -337,6 +389,7 @@ where
|
|||||||
&secret_arr,
|
&secret_arr,
|
||||||
config,
|
config,
|
||||||
is_tls,
|
is_tls,
|
||||||
|
mode_policy,
|
||||||
) else {
|
) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ use crate::proxy::route_mode::{
|
|||||||
RelayRouteMode, RouteCutoverState, affected_cutover_state, cutover_stagger_delay,
|
RelayRouteMode, RouteCutoverState, affected_cutover_state, cutover_stagger_delay,
|
||||||
};
|
};
|
||||||
use crate::proxy::shared_state::{
|
use crate::proxy::shared_state::{
|
||||||
ConntrackCloseEvent, ConntrackClosePublishResult, ConntrackCloseReason, ProxySharedState,
|
ConntrackCloseEvent, ConntrackClosePolicy, ConntrackClosePublishResult, ConntrackCloseReason,
|
||||||
|
ProxySharedState,
|
||||||
};
|
};
|
||||||
use crate::proxy::traffic_limiter::{RateDirection, TrafficLease, next_refill_delay};
|
use crate::proxy::traffic_limiter::{RateDirection, TrafficLease, next_refill_delay};
|
||||||
use crate::stats::{
|
use crate::stats::{
|
||||||
@@ -44,7 +45,7 @@ mod session;
|
|||||||
|
|
||||||
pub(crate) use self::desync::DesyncDedupRotationState;
|
pub(crate) use self::desync::DesyncDedupRotationState;
|
||||||
pub(crate) use self::idle::{RelayIdleCandidateRegistry, note_global_relay_pressure};
|
pub(crate) use self::idle::{RelayIdleCandidateRegistry, note_global_relay_pressure};
|
||||||
pub(crate) use self::session::handle_via_middle_proxy;
|
pub(crate) use self::session::handle_via_middle_proxy_with_conntrack;
|
||||||
|
|
||||||
use self::c2me::{
|
use self::c2me::{
|
||||||
C2MeCommand, acquire_c2me_payload_permit, c2me_queued_permit_budget, enqueue_c2me_command_in,
|
C2MeCommand, acquire_c2me_payload_permit, c2me_queued_permit_budget, enqueue_c2me_command_in,
|
||||||
@@ -91,6 +92,47 @@ pub(crate) use self::idle::{
|
|||||||
set_relay_pressure_state_for_testing,
|
set_relay_pressure_state_for_testing,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Runs Middle-End relay for a kernel-backed TCP client tuple.
|
||||||
|
pub(crate) async fn handle_via_middle_proxy<R, W>(
|
||||||
|
crypto_reader: CryptoReader<R>,
|
||||||
|
crypto_writer: CryptoWriter<W>,
|
||||||
|
success: HandshakeSuccess,
|
||||||
|
me_pool: Arc<MePool>,
|
||||||
|
stats: Arc<Stats>,
|
||||||
|
config: Arc<ProxyConfig>,
|
||||||
|
buffer_pool: Arc<BufferPool>,
|
||||||
|
local_addr: SocketAddr,
|
||||||
|
rng: Arc<SecureRandom>,
|
||||||
|
route_rx: watch::Receiver<RouteCutoverState>,
|
||||||
|
route_snapshot: RouteCutoverState,
|
||||||
|
session_id: u64,
|
||||||
|
session_cancel: CancellationToken,
|
||||||
|
shared: Arc<ProxySharedState>,
|
||||||
|
) -> Result<()>
|
||||||
|
where
|
||||||
|
R: AsyncRead + Unpin + Send + 'static,
|
||||||
|
W: AsyncWrite + Unpin + Send + 'static,
|
||||||
|
{
|
||||||
|
handle_via_middle_proxy_with_conntrack(
|
||||||
|
crypto_reader,
|
||||||
|
crypto_writer,
|
||||||
|
success,
|
||||||
|
me_pool,
|
||||||
|
stats,
|
||||||
|
config,
|
||||||
|
buffer_pool,
|
||||||
|
local_addr,
|
||||||
|
rng,
|
||||||
|
route_rx,
|
||||||
|
route_snapshot,
|
||||||
|
session_id,
|
||||||
|
session_cancel,
|
||||||
|
shared,
|
||||||
|
ConntrackClosePolicy::Publish,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
const DESYNC_DEDUP_WINDOW: Duration = Duration::from_secs(60);
|
const DESYNC_DEDUP_WINDOW: Duration = Duration::from_secs(60);
|
||||||
const DESYNC_DEDUP_MAX_ENTRIES: usize = 65_536;
|
const DESYNC_DEDUP_MAX_ENTRIES: usize = 65_536;
|
||||||
const DESYNC_FULL_CACHE_EMIT_MIN_INTERVAL: Duration = Duration::from_millis(1000);
|
const DESYNC_FULL_CACHE_EMIT_MIN_INTERVAL: Duration = Duration::from_millis(1000);
|
||||||
@@ -98,6 +140,7 @@ const DESYNC_ERROR_CLASS: &str = "frame_too_large_crypto_desync";
|
|||||||
const C2ME_CHANNEL_CAPACITY_FALLBACK: usize = 128;
|
const C2ME_CHANNEL_CAPACITY_FALLBACK: usize = 128;
|
||||||
const C2ME_SOFT_PRESSURE_MIN_FREE_SLOTS: usize = 64;
|
const C2ME_SOFT_PRESSURE_MIN_FREE_SLOTS: usize = 64;
|
||||||
const C2ME_SENDER_FAIRNESS_BUDGET: usize = 32;
|
const C2ME_SENDER_FAIRNESS_BUDGET: usize = 32;
|
||||||
|
|
||||||
const C2ME_QUEUED_BYTE_PERMIT_UNIT: usize = 16 * 1024;
|
const C2ME_QUEUED_BYTE_PERMIT_UNIT: usize = 16 * 1024;
|
||||||
const C2ME_QUEUED_PERMITS_PER_SLOT: usize = 4;
|
const C2ME_QUEUED_PERMITS_PER_SLOT: usize = 4;
|
||||||
const RELAY_IDLE_IO_POLL_MAX: Duration = Duration::from_secs(1);
|
const RELAY_IDLE_IO_POLL_MAX: Duration = Duration::from_secs(1);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
pub(crate) async fn handle_via_middle_proxy<R, W>(
|
/// Runs Middle-End relay with explicit kernel-conntrack close publication policy.
|
||||||
|
pub(crate) async fn handle_via_middle_proxy_with_conntrack<R, W>(
|
||||||
mut crypto_reader: CryptoReader<R>,
|
mut crypto_reader: CryptoReader<R>,
|
||||||
crypto_writer: CryptoWriter<W>,
|
crypto_writer: CryptoWriter<W>,
|
||||||
success: HandshakeSuccess,
|
success: HandshakeSuccess,
|
||||||
@@ -15,6 +16,7 @@ pub(crate) async fn handle_via_middle_proxy<R, W>(
|
|||||||
session_id: u64,
|
session_id: u64,
|
||||||
session_cancel: CancellationToken,
|
session_cancel: CancellationToken,
|
||||||
shared: Arc<ProxySharedState>,
|
shared: Arc<ProxySharedState>,
|
||||||
|
conntrack_close_policy: ConntrackClosePolicy,
|
||||||
) -> Result<()>
|
) -> Result<()>
|
||||||
where
|
where
|
||||||
R: AsyncRead + Unpin + Send + 'static,
|
R: AsyncRead + Unpin + Send + 'static,
|
||||||
@@ -78,7 +80,7 @@ where
|
|||||||
return Err(ProxyError::RouteSwitched);
|
return Err(ProxyError::RouteSwitched);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-user ad_tag from access.user_ad_tags; fallback to general.ad_tag (hot-reloadable)
|
// Prefer the hot-reloadable per-user ad tag over the global fallback.
|
||||||
let user_tag: Option<Vec<u8>> = config
|
let user_tag: Option<Vec<u8>> = config
|
||||||
.access
|
.access
|
||||||
.user_ad_tags
|
.user_ad_tags
|
||||||
@@ -785,7 +787,7 @@ where
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// When client closes, but ME channel stopped as unregistered - it isnt error
|
// A client-initiated close can unregister the ME channel before its writer exits.
|
||||||
if client_closed && matches!(writer_result, Err(ProxyError::MiddleConnectionLost)) {
|
if client_closed && matches!(writer_result, Err(ProxyError::MiddleConnectionLost)) {
|
||||||
writer_result = Ok(());
|
writer_result = Ok(());
|
||||||
}
|
}
|
||||||
@@ -808,17 +810,19 @@ where
|
|||||||
"ME relay cleanup"
|
"ME relay cleanup"
|
||||||
);
|
);
|
||||||
|
|
||||||
let close_reason = classify_conntrack_close_reason(&result);
|
if conntrack_close_policy == ConntrackClosePolicy::Publish {
|
||||||
let publish_result = shared.publish_conntrack_close_event(ConntrackCloseEvent {
|
let close_reason = classify_conntrack_close_reason(&result);
|
||||||
src: peer,
|
let publish_result = shared.publish_conntrack_close_event(ConntrackCloseEvent {
|
||||||
dst: local_addr,
|
src: peer,
|
||||||
reason: close_reason,
|
dst: local_addr,
|
||||||
});
|
reason: close_reason,
|
||||||
if !matches!(
|
});
|
||||||
publish_result,
|
if !matches!(
|
||||||
ConntrackClosePublishResult::Sent | ConntrackClosePublishResult::Disabled
|
publish_result,
|
||||||
) {
|
ConntrackClosePublishResult::Sent | ConntrackClosePublishResult::Disabled
|
||||||
stats.increment_conntrack_close_event_drop_total();
|
) {
|
||||||
|
stats.increment_conntrack_close_event_drop_total();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clear_relay_idle_candidate_in(shared.as_ref(), conn_id);
|
clear_relay_idle_candidate_in(shared.as_ref(), conn_id);
|
||||||
|
|||||||
@@ -59,6 +59,8 @@
|
|||||||
)]
|
)]
|
||||||
|
|
||||||
pub mod adaptive_buffers;
|
pub mod adaptive_buffers;
|
||||||
|
// Shared authenticated admission and relay orchestration for TCP and WEB streams.
|
||||||
|
pub(crate) mod authenticated;
|
||||||
pub mod client;
|
pub mod client;
|
||||||
// Process-wide Direct relay copy-buffer ownership and pressure policy.
|
// Process-wide Direct relay copy-buffer ownership and pressure policy.
|
||||||
pub(crate) mod direct_buffer_budget;
|
pub(crate) mod direct_buffer_budget;
|
||||||
|
|||||||
@@ -41,6 +41,15 @@ pub(crate) enum ConntrackClosePublishResult {
|
|||||||
QueueClosed,
|
QueueClosed,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Controls whether a relay tuple maps to a real kernel conntrack entry.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum ConntrackClosePolicy {
|
||||||
|
/// Publish closure for a tuple backed by an accepted kernel TCP flow.
|
||||||
|
Publish,
|
||||||
|
/// Suppress closure for a virtual transport tuple with no kernel flow.
|
||||||
|
Suppress,
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) struct HandshakeSharedState {
|
pub(crate) struct HandshakeSharedState {
|
||||||
pub(crate) auth_probe: DashMap<IpAddr, AuthProbeState>,
|
pub(crate) auth_probe: DashMap<IpAddr, AuthProbeState>,
|
||||||
pub(crate) auth_probe_saturation: Mutex<Option<AuthProbeSaturationState>>,
|
pub(crate) auth_probe_saturation: Mutex<Option<AuthProbeSaturationState>>,
|
||||||
|
|||||||
@@ -260,6 +260,7 @@ mod tests {
|
|||||||
fn listener(ip: IpAddr, port: Option<u16>, synlimit: SynLimitMode) -> ListenerConfig {
|
fn listener(ip: IpAddr, port: Option<u16>, synlimit: SynLimitMode) -> ListenerConfig {
|
||||||
ListenerConfig {
|
ListenerConfig {
|
||||||
ip,
|
ip,
|
||||||
|
transport: crate::config::ListenerTransport::Mtproxy,
|
||||||
port,
|
port,
|
||||||
client_mss: None,
|
client_mss: None,
|
||||||
synlimit,
|
synlimit,
|
||||||
@@ -275,6 +276,8 @@ mod tests {
|
|||||||
announce_ip: None,
|
announce_ip: None,
|
||||||
proxy_protocol: None,
|
proxy_protocol: None,
|
||||||
reuse_allow: false,
|
reuse_allow: false,
|
||||||
|
web_client_ip_source: crate::config::WebClientIpSource::XForwardedFor,
|
||||||
|
web_trusted_proxy_cidrs: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
use base64::Engine as _;
|
||||||
|
|
||||||
|
use crate::crypto::SecureRandom;
|
||||||
|
|
||||||
|
/// Browser security policy for the transient Telegram Desktop bridge page.
|
||||||
|
pub(crate) const PERMISSIONS_POLICY: &str = "accelerometer=(), autoplay=(), camera=(), clipboard-read=(), clipboard-write=(), display-capture=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), hid=(), idle-detection=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-create=(), publickey-credentials-get=(), screen-wake-lock=(), serial=(), usb=(), web-share=(), xr-spatial-tracking=()";
|
||||||
|
|
||||||
|
/// Fully rendered bridge response and its per-response script policy.
|
||||||
|
pub(crate) struct BridgePage {
|
||||||
|
/// Complete transient HTML document.
|
||||||
|
pub(crate) body: String,
|
||||||
|
/// Nonce-bound policy that authorizes only the embedded bridge script.
|
||||||
|
pub(crate) content_security_policy: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders the HTTPS-only WEB carrier bridge with a fresh CSP nonce.
|
||||||
|
pub(crate) fn render(
|
||||||
|
host: &str,
|
||||||
|
bootstrap: &str,
|
||||||
|
batch_limit: usize,
|
||||||
|
queue_limit: usize,
|
||||||
|
queue_items: usize,
|
||||||
|
rng: &SecureRandom,
|
||||||
|
) -> BridgePage {
|
||||||
|
let mut nonce = [0u8; 18];
|
||||||
|
rng.fill(&mut nonce);
|
||||||
|
let nonce = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(nonce);
|
||||||
|
let body = DOCUMENT
|
||||||
|
.replace("__NONCE__", &nonce)
|
||||||
|
.replace("__HOST__", host)
|
||||||
|
.replace("__BOOTSTRAP__", bootstrap)
|
||||||
|
.replace("__BATCH_LIMIT__", &batch_limit.to_string())
|
||||||
|
.replace("__QUEUE_LIMIT__", &queue_limit.to_string())
|
||||||
|
.replace("__QUEUE_ITEMS__", &queue_items.to_string());
|
||||||
|
BridgePage {
|
||||||
|
body,
|
||||||
|
content_security_policy: format!(
|
||||||
|
"default-src 'none'; base-uri 'none'; child-src 'none'; connect-src 'self' wss://{host}; font-src 'none'; form-action 'none'; frame-ancestors http://127.0.0.1:*; frame-src 'none'; img-src 'none'; manifest-src 'none'; media-src 'none'; object-src 'none'; script-src 'nonce-{nonce}'; style-src 'none'; worker-src 'none'; sandbox allow-same-origin allow-scripts"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOCUMENT: &str = r##"<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Connection</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script nonce="__NONCE__">
|
||||||
|
(()=>{
|
||||||
|
'use strict';
|
||||||
|
const relayOrigin='https://__HOST__',bootstrap='__BOOTSTRAP__';
|
||||||
|
const batchLimit=__BATCH_LIMIT__,queueLimit=__QUEUE_LIMIT__,queueItemLimit=__QUEUE_ITEMS__;
|
||||||
|
const fragment=location.hash,androidNonce=/^#android=([A-Za-z0-9_-]{43})$/.exec(fragment)?.[1]||'';
|
||||||
|
history.replaceState(null,'',location.pathname);
|
||||||
|
let initialized=false,closed=false,port=null,sessionToken='',createStarted=false;
|
||||||
|
let queuedBytes=0,queuedItems=0,upSequence=1,downCursor='0',upRunning=false,pollController=null;
|
||||||
|
const pending=[],upPending=[];
|
||||||
|
const status=state=>{if(port&&!closed)port.postMessage({t:'status',state})};
|
||||||
|
const pause=milliseconds=>new Promise(resolve=>setTimeout(resolve,milliseconds));
|
||||||
|
const options=(method,token,body,headers,signal,keepalive)=>({
|
||||||
|
method,body,signal,keepalive:!!keepalive,mode:'same-origin',credentials:'omit',cache:'no-store',redirect:'error',referrerPolicy:'no-referrer',
|
||||||
|
headers:Object.assign(token?{Authorization:'Bearer '+token}:{},body?{'Content-Type':'application/octet-stream'}:{},headers||{})
|
||||||
|
});
|
||||||
|
function reserve(data){
|
||||||
|
if(!data.byteLength||data.byteLength>queueLimit-queuedBytes||queuedItems>=queueItemLimit)return false;
|
||||||
|
queuedBytes+=data.byteLength;queuedItems++;return true;
|
||||||
|
}
|
||||||
|
function release(bytes,items){queuedBytes-=bytes;queuedItems-=items}
|
||||||
|
function frameBound(value,maxFrames,maxBytes){
|
||||||
|
const view=new DataView(value);let offset=0,frames=0;
|
||||||
|
while(offset<value.byteLength){
|
||||||
|
if(value.byteLength-offset<8)throw new Error('invalid frame batch');
|
||||||
|
const size=view.getUint32(offset+4),end=offset+8+size;
|
||||||
|
if(size>1048576||end>value.byteLength)throw new Error('invalid frame');
|
||||||
|
if(frames>0&&(frames>=maxFrames||end>maxBytes))break;
|
||||||
|
frames++;offset=end;
|
||||||
|
}
|
||||||
|
if(!frames)throw new Error('empty frame batch');
|
||||||
|
return {frames,bytes:offset};
|
||||||
|
}
|
||||||
|
function splitFrames(value){
|
||||||
|
const view=new DataView(value),result=[];let offset=0;
|
||||||
|
while(offset<value.byteLength){
|
||||||
|
if(value.byteLength-offset<8||result.length>=4096)throw new Error('invalid frame batch');
|
||||||
|
const size=view.getUint32(offset+4),end=offset+8+size;
|
||||||
|
if(size>1048576||end>value.byteLength)throw new Error('invalid frame');
|
||||||
|
result.push(offset===0&&end===value.byteLength?value:value.slice(offset,end));offset=end;
|
||||||
|
}
|
||||||
|
if(!result.length)throw new Error('empty frame batch');return result;
|
||||||
|
}
|
||||||
|
function joinPending(values){
|
||||||
|
let total=0,count=0,frames=0;
|
||||||
|
while(count<values.length){
|
||||||
|
const bound=frameBound(values[count],4096,batchLimit),whole=bound.bytes===values[count].byteLength;
|
||||||
|
if(count===0&&!whole){
|
||||||
|
const head=new Uint8Array(values[0],0,bound.bytes).slice();
|
||||||
|
values[0]=values[0].slice(bound.bytes);queuedItems++;
|
||||||
|
return {body:head.buffer,total:bound.bytes,count:1};
|
||||||
|
}
|
||||||
|
if(count&&(total+values[count].byteLength>batchLimit||frames+bound.frames>4096))break;
|
||||||
|
total+=values[count].byteLength;frames+=bound.frames;count++;
|
||||||
|
}
|
||||||
|
const joined=new Uint8Array(total);let offset=0;
|
||||||
|
for(const data of values.splice(0,count)){joined.set(new Uint8Array(data),offset);offset+=data.byteLength}
|
||||||
|
return {body:joined.buffer,total,count};
|
||||||
|
}
|
||||||
|
function retryAfterMs(response){
|
||||||
|
const value=Number(response.headers.get('Retry-After'));
|
||||||
|
return Number.isFinite(value)&&value>=0?Math.min(value*1000,30000):0;
|
||||||
|
}
|
||||||
|
async function request(path,makeOptions){
|
||||||
|
let delay=250,attempt=0;const deadline=Date.now()+90000;
|
||||||
|
while(true){
|
||||||
|
const requestOptions=makeOptions(),controller=new AbortController(),external=requestOptions.signal;
|
||||||
|
const abort=()=>controller.abort();if(external)external.addEventListener('abort',abort,{once:true});
|
||||||
|
requestOptions.signal=controller.signal;const timer=setTimeout(abort,90000);
|
||||||
|
let serviceUnavailable=false,wait=0;
|
||||||
|
try{
|
||||||
|
const response=await fetch(relayOrigin+path,requestOptions);
|
||||||
|
if(response.status!==503)return response;
|
||||||
|
serviceUnavailable=true;wait=retryAfterMs(response);await response.arrayBuffer();
|
||||||
|
}catch(error){
|
||||||
|
if(closed||(external&&external.aborted))throw error;
|
||||||
|
if(++attempt===9)throw new Error('carrier retry limit reached');
|
||||||
|
}finally{clearTimeout(timer);if(external)external.removeEventListener('abort',abort)}
|
||||||
|
if(serviceUnavailable&&Date.now()>=deadline)throw new Error('carrier retry limit reached');
|
||||||
|
status('reconnecting');await pause(wait||(delay+Math.floor(Math.random()*Math.max(1,delay/4))));
|
||||||
|
if(!serviceUnavailable)delay=Math.min(delay*2,5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function fail(){if(closed)return;status('failed');if(port)port.postMessage({t:'close'});close(true)}
|
||||||
|
async function createSession(first){
|
||||||
|
try{
|
||||||
|
status('connecting');
|
||||||
|
const response=await request('/api/v1/session',()=>options('POST',bootstrap,first));
|
||||||
|
if(response.status!==200||response.headers.get('X-Carrier-Mode')!=='https')throw new Error('session rejected');
|
||||||
|
sessionToken=response.headers.get('X-Session-Token')||'';downCursor=response.headers.get('X-Down-Cursor')||'0';
|
||||||
|
if(!/^[A-Za-z0-9_-]{43}$/.test(sessionToken)||downCursor!=='0')throw new Error('invalid session metadata');
|
||||||
|
if(closed){deleteSession();return}
|
||||||
|
const welcome=await response.arrayBuffer();
|
||||||
|
const welcomeBytes=new Uint8Array(welcome);
|
||||||
|
if(welcomeBytes.length!==8||welcomeBytes[0]!==17||welcomeBytes.slice(1).some(value=>value!==0))throw new Error('invalid welcome');
|
||||||
|
port.postMessage(welcome,[welcome]);status('connected');
|
||||||
|
for(const data of pending.splice(0)){release(data.byteLength,1);queueUp(data)}
|
||||||
|
poll();
|
||||||
|
}catch(error){fail()}
|
||||||
|
}
|
||||||
|
function queueUp(data){if(!reserve(data)){fail();return}upPending.push(data);runUp()}
|
||||||
|
async function runUp(){
|
||||||
|
if(upRunning)return;upRunning=true;
|
||||||
|
try{
|
||||||
|
while(!closed&&sessionToken&&upPending.length){
|
||||||
|
const batch=joinPending(upPending),sequence=String(upSequence);
|
||||||
|
const response=await request('/api/v1/up',()=>options('POST',sessionToken,batch.body,{'X-Up-Seq':sequence}));
|
||||||
|
if(response.status!==204||response.headers.get('X-Up-Ack')!==sequence)throw new Error('uplink rejected');
|
||||||
|
release(batch.total,batch.count);port.postMessage({t:'traffic',up:batch.total,down:0});upSequence++;
|
||||||
|
}
|
||||||
|
}catch(error){fail()}
|
||||||
|
finally{upRunning=false;if(!closed&&sessionToken&&upPending.length)runUp()}
|
||||||
|
}
|
||||||
|
async function poll(){
|
||||||
|
while(!closed&&sessionToken){
|
||||||
|
try{
|
||||||
|
pollController=new AbortController();
|
||||||
|
const response=await request('/api/v1/down',()=>options('POST',sessionToken,null,{'X-Down-Cursor':downCursor},pollController.signal));
|
||||||
|
if(response.status===204){status('connected');continue}
|
||||||
|
if(response.status!==200)throw new Error('downlink rejected');
|
||||||
|
const next=response.headers.get('X-Down-Cursor')||'',data=await response.arrayBuffer();
|
||||||
|
if(!next||!data.byteLength)throw new Error('invalid downlink response');
|
||||||
|
if(closed)return;
|
||||||
|
port.postMessage({t:'traffic',up:0,down:data.byteLength});port.postMessage(data,[data]);downCursor=next;status('connected');
|
||||||
|
}catch(error){if(!closed)fail();return}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function deleteSession(){
|
||||||
|
if(sessionToken)fetch(relayOrigin+'/api/v1/session',options('DELETE',sessionToken,null,null,undefined,true)).catch(()=>{});
|
||||||
|
}
|
||||||
|
function close(notifyServer){
|
||||||
|
if(closed)return;closed=true;if(pollController)pollController.abort();if(notifyServer)deleteSession();
|
||||||
|
pending.length=0;upPending.length=0;queuedBytes=0;queuedItems=0;if(port)port.close();
|
||||||
|
}
|
||||||
|
function activatePort(nextPort){
|
||||||
|
initialized=true;port=nextPort;
|
||||||
|
port.onmessage=message=>{
|
||||||
|
if(message.data instanceof ArrayBuffer){
|
||||||
|
if(!createStarted){createStarted=true;createSession(message.data)}
|
||||||
|
else if(!sessionToken){if(!reserve(message.data)){fail();return}pending.push(message.data)}
|
||||||
|
else queueUp(message.data);
|
||||||
|
}else if(message.data&&message.data.t==='close')close(true);
|
||||||
|
};
|
||||||
|
port.start();status('connecting');
|
||||||
|
}
|
||||||
|
addEventListener('message',event=>{
|
||||||
|
if(initialized||event.source!==parent||event.data===null||typeof event.data!=='object')return;
|
||||||
|
const keys=Object.keys(event.data).sort();
|
||||||
|
if(keys.length!==2||keys[0]!=='t'||keys[1]!=='v'||event.data.t!=='tproxy-init'||event.data.v!==1||event.ports.length!==1)return;
|
||||||
|
let source;try{source=new URL(event.origin)}catch(error){return}
|
||||||
|
if(source.protocol!=='http:'||source.hostname!=='127.0.0.1'||!source.port||source.origin!==event.origin)return;
|
||||||
|
activatePort(event.ports[0]);
|
||||||
|
});
|
||||||
|
const androidBridge=globalThis.TelegramWebProxy;
|
||||||
|
if(!initialized&&androidNonce&&androidBridge&&typeof androidBridge.postMessage==='function'){
|
||||||
|
const androidPort={onmessage:null,start(){},close(){androidBridge.onmessage=null},postMessage(value){
|
||||||
|
if(value instanceof ArrayBuffer){for(const item of splitFrames(value))androidBridge.postMessage(item)}else androidBridge.postMessage(JSON.stringify(value));
|
||||||
|
}};
|
||||||
|
androidBridge.onmessage=event=>{let data=event.data;if(typeof data==='string'){try{data=JSON.parse(data)}catch(error){return}}if(androidPort.onmessage)androidPort.onmessage({data})};
|
||||||
|
activatePort(androidPort);androidBridge.postMessage(JSON.stringify({t:'tproxy-android-init',v:1,nonce:androidNonce}));
|
||||||
|
}
|
||||||
|
addEventListener('pagehide',()=>close(true),{once:true});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"##;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rendered_page_contains_no_template_markers_or_capability() {
|
||||||
|
let page = render(
|
||||||
|
"proxy.example.com",
|
||||||
|
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||||
|
2 * 1024 * 1024,
|
||||||
|
32 * 1024 * 1024,
|
||||||
|
16 * 1024,
|
||||||
|
&SecureRandom::new(),
|
||||||
|
);
|
||||||
|
assert!(!page.body.contains("__"));
|
||||||
|
assert!(!page.body.contains("bridge="));
|
||||||
|
assert!(page.body.contains("X-Up-Seq"));
|
||||||
|
assert!(page
|
||||||
|
.content_security_policy
|
||||||
|
.contains("frame-ancestors http://127.0.0.1:*"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
use bytes::{BufMut, Bytes, BytesMut};
|
||||||
|
|
||||||
|
use crate::config::WebLimitsConfig;
|
||||||
|
|
||||||
|
/// Fixed WEB frame header size.
|
||||||
|
pub(crate) const HEADER_BYTES: usize = 8;
|
||||||
|
/// Initial bidirectional stream credit.
|
||||||
|
pub(crate) const INITIAL_STREAM_WINDOW: u32 = 4 * 1024 * 1024;
|
||||||
|
/// Maximum data chunk emitted by the server.
|
||||||
|
pub(crate) const DATA_CHUNK_BYTES: usize = 64 * 1024;
|
||||||
|
|
||||||
|
/// WEB frame type codes shared with Telegram Desktop.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
#[repr(u8)]
|
||||||
|
pub(crate) enum FrameType {
|
||||||
|
/// Opens a logical MTProxy stream.
|
||||||
|
Open = 0x01,
|
||||||
|
/// Carries logical-stream payload bytes.
|
||||||
|
Data = 0x02,
|
||||||
|
/// Closes a logical stream.
|
||||||
|
Close = 0x03,
|
||||||
|
/// Returns consumed flow-control credit.
|
||||||
|
Window = 0x04,
|
||||||
|
/// Requests an application-level liveness response.
|
||||||
|
Ping = 0x05,
|
||||||
|
/// Answers application-level liveness traffic.
|
||||||
|
Pong = 0x06,
|
||||||
|
/// Starts one WEB carrier session.
|
||||||
|
Hello = 0x10,
|
||||||
|
/// Confirms WEB carrier session creation.
|
||||||
|
Welcome = 0x11,
|
||||||
|
/// Terminates a WEB carrier session.
|
||||||
|
Bye = 0x1f,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FrameType {
|
||||||
|
fn parse(value: u8) -> Option<Self> {
|
||||||
|
match value {
|
||||||
|
0x01 => Some(Self::Open),
|
||||||
|
0x02 => Some(Self::Data),
|
||||||
|
0x03 => Some(Self::Close),
|
||||||
|
0x04 => Some(Self::Window),
|
||||||
|
0x05 => Some(Self::Ping),
|
||||||
|
0x06 => Some(Self::Pong),
|
||||||
|
0x10 => Some(Self::Hello),
|
||||||
|
0x11 => Some(Self::Welcome),
|
||||||
|
0x1f => Some(Self::Bye),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One parsed frame borrowing its payload from the HTTP request body.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub(crate) struct Frame<'a> {
|
||||||
|
/// Parsed frame type.
|
||||||
|
pub(crate) frame_type: FrameType,
|
||||||
|
/// Logical 24-bit stream identifier.
|
||||||
|
pub(crate) stream_id: u32,
|
||||||
|
/// Borrowed frame payload.
|
||||||
|
pub(crate) payload: &'a [u8],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Protocol parse or shape failure.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub(crate) enum FrameError {
|
||||||
|
/// A carrier body contained no frame.
|
||||||
|
EmptyBatch,
|
||||||
|
/// A carrier body exceeded the configured frame count.
|
||||||
|
TooManyFrames,
|
||||||
|
/// A frame header or payload was truncated.
|
||||||
|
Incomplete,
|
||||||
|
/// A frame payload exceeded its configured ceiling.
|
||||||
|
PayloadLimit,
|
||||||
|
/// The frame type code is not defined.
|
||||||
|
UnknownType,
|
||||||
|
/// A known frame violated direction-specific grammar.
|
||||||
|
InvalidShape,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses and validates all frame boundaries without copying payloads.
|
||||||
|
pub(crate) fn parse_all<'a>(
|
||||||
|
input: &'a [u8],
|
||||||
|
limits: &WebLimitsConfig,
|
||||||
|
) -> std::result::Result<Vec<Frame<'a>>, FrameError> {
|
||||||
|
if input.is_empty() {
|
||||||
|
return Err(FrameError::EmptyBatch);
|
||||||
|
}
|
||||||
|
let mut remaining = input;
|
||||||
|
let mut frames = Vec::with_capacity(remaining.len().div_ceil(HEADER_BYTES).min(16));
|
||||||
|
while !remaining.is_empty() {
|
||||||
|
if frames.len() >= limits.max_frames_per_body {
|
||||||
|
return Err(FrameError::TooManyFrames);
|
||||||
|
}
|
||||||
|
if remaining.len() < HEADER_BYTES {
|
||||||
|
return Err(FrameError::Incomplete);
|
||||||
|
}
|
||||||
|
let frame_type = FrameType::parse(remaining[0]).ok_or(FrameError::UnknownType)?;
|
||||||
|
let stream_id = u32::from(remaining[1]) << 16
|
||||||
|
| u32::from(remaining[2]) << 8
|
||||||
|
| u32::from(remaining[3]);
|
||||||
|
let payload_len = u32::from_be_bytes([
|
||||||
|
remaining[4],
|
||||||
|
remaining[5],
|
||||||
|
remaining[6],
|
||||||
|
remaining[7],
|
||||||
|
]) as usize;
|
||||||
|
if payload_len > limits.max_frame_payload_bytes {
|
||||||
|
return Err(FrameError::PayloadLimit);
|
||||||
|
}
|
||||||
|
let frame_len = HEADER_BYTES
|
||||||
|
.checked_add(payload_len)
|
||||||
|
.ok_or(FrameError::PayloadLimit)?;
|
||||||
|
if frame_len > remaining.len() {
|
||||||
|
return Err(FrameError::Incomplete);
|
||||||
|
}
|
||||||
|
frames.push(Frame {
|
||||||
|
frame_type,
|
||||||
|
stream_id,
|
||||||
|
payload: &remaining[HEADER_BYTES..frame_len],
|
||||||
|
});
|
||||||
|
remaining = &remaining[frame_len..];
|
||||||
|
}
|
||||||
|
Ok(frames)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enforces the client-to-server frame grammar.
|
||||||
|
pub(crate) fn validate_client_shape(frame: Frame<'_>) -> std::result::Result<(), FrameError> {
|
||||||
|
if frame.stream_id == 0 {
|
||||||
|
return if frame.frame_type == FrameType::Pong && frame.payload.len() <= 64 {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(FrameError::InvalidShape)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
match frame.frame_type {
|
||||||
|
FrameType::Open | FrameType::Close if frame.payload.is_empty() => Ok(()),
|
||||||
|
FrameType::Data if !frame.payload.is_empty() => Ok(()),
|
||||||
|
FrameType::Window => window_amount(frame.payload).map(|_| ()),
|
||||||
|
_ => Err(FrameError::InvalidShape),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates the exact first-session HELLO body.
|
||||||
|
pub(crate) fn validate_hello(input: &[u8], limits: &WebLimitsConfig) -> bool {
|
||||||
|
let Ok(frames) = parse_all(input, limits) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
frames.len() == 1
|
||||||
|
&& frames[0].frame_type == FrameType::Hello
|
||||||
|
&& frames[0].stream_id == 0
|
||||||
|
&& frames[0].payload == [1]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes one complete WEB frame.
|
||||||
|
pub(crate) fn encode(frame_type: FrameType, stream_id: u32, payload: &[u8]) -> Bytes {
|
||||||
|
let mut output = BytesMut::with_capacity(HEADER_BYTES + payload.len());
|
||||||
|
output.put_u8(frame_type as u8);
|
||||||
|
output.put_u8((stream_id >> 16) as u8);
|
||||||
|
output.put_u8((stream_id >> 8) as u8);
|
||||||
|
output.put_u8(stream_id as u8);
|
||||||
|
output.put_u32(payload.len() as u32);
|
||||||
|
output.extend_from_slice(payload);
|
||||||
|
output.freeze()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes a non-zero WINDOW delta.
|
||||||
|
pub(crate) fn window_amount(payload: &[u8]) -> std::result::Result<u32, FrameError> {
|
||||||
|
let bytes: [u8; 4] = payload.try_into().map_err(|_| FrameError::InvalidShape)?;
|
||||||
|
let amount = u32::from_be_bytes(bytes);
|
||||||
|
(amount != 0)
|
||||||
|
.then_some(amount)
|
||||||
|
.ok_or(FrameError::InvalidShape)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes a WINDOW delta payload.
|
||||||
|
pub(crate) fn window_payload(amount: u32) -> [u8; 4] {
|
||||||
|
amount.to_be_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hello_and_welcome_match_reference_bytes() {
|
||||||
|
let limits = WebLimitsConfig::default();
|
||||||
|
let hello = encode(FrameType::Hello, 0, &[1]);
|
||||||
|
assert_eq!(hello.as_ref(), &hex::decode("100000000000000101").unwrap());
|
||||||
|
assert!(validate_hello(&hello, &limits));
|
||||||
|
assert_eq!(
|
||||||
|
encode(FrameType::Welcome, 0, &[]).as_ref(),
|
||||||
|
&hex::decode("1100000000000000").unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parser_rejects_excessive_payload_before_slicing() {
|
||||||
|
let limits = WebLimitsConfig {
|
||||||
|
max_frame_payload_bytes: 4,
|
||||||
|
..WebLimitsConfig::default()
|
||||||
|
};
|
||||||
|
let frame = encode(FrameType::Data, 1, &[0; 5]);
|
||||||
|
assert_eq!(parse_all(&frame, &limits), Err(FrameError::PayloadLimit));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn client_shape_rejects_control_types_on_stream_zero() {
|
||||||
|
let frame = Frame {
|
||||||
|
frame_type: FrameType::Ping,
|
||||||
|
stream_id: 0,
|
||||||
|
payload: &[],
|
||||||
|
};
|
||||||
|
assert_eq!(validate_client_shape(frame), Err(FrameError::InvalidShape));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_frames_match_client_reference_vectors() {
|
||||||
|
assert_eq!(
|
||||||
|
encode(FrameType::Open, 17, &[]).as_ref(),
|
||||||
|
&hex::decode("0100001100000000").unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
encode(FrameType::Data, 17, b"round trip").as_ref(),
|
||||||
|
&hex::decode("020000110000000a726f756e642074726970").unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
encode(FrameType::Window, 17, &10u32.to_be_bytes()).as_ref(),
|
||||||
|
&hex::decode("04000011000000040000000a").unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
encode(FrameType::Open, 0x00ff_ffff, &[]).as_ref(),
|
||||||
|
&hex::decode("01ffffff00000000").unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parser_rejects_empty_truncated_and_excessive_batches() {
|
||||||
|
let mut limits = WebLimitsConfig::default();
|
||||||
|
assert_eq!(parse_all(&[], &limits), Err(FrameError::EmptyBatch));
|
||||||
|
assert_eq!(
|
||||||
|
parse_all(&hex::decode("0200000100000001").unwrap(), &limits),
|
||||||
|
Err(FrameError::Incomplete)
|
||||||
|
);
|
||||||
|
limits.max_frames_per_body = 1;
|
||||||
|
let mut body = encode(FrameType::Pong, 0, &[]).to_vec();
|
||||||
|
body.extend_from_slice(&encode(FrameType::Pong, 0, &[]));
|
||||||
|
assert_eq!(parse_all(&body, &limits), Err(FrameError::TooManyFrames));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn client_shape_rejects_empty_data_and_zero_window() {
|
||||||
|
let empty_data = Frame {
|
||||||
|
frame_type: FrameType::Data,
|
||||||
|
stream_id: 1,
|
||||||
|
payload: &[],
|
||||||
|
};
|
||||||
|
let zero_window = Frame {
|
||||||
|
frame_type: FrameType::Window,
|
||||||
|
stream_id: 1,
|
||||||
|
payload: &[0; 4],
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
validate_client_shape(empty_data),
|
||||||
|
Err(FrameError::InvalidShape)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
validate_client_shape(zero_window),
|
||||||
|
Err(FrameError::InvalidShape)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+511
@@ -0,0 +1,511 @@
|
|||||||
|
use std::convert::Infallible;
|
||||||
|
use std::error::Error;
|
||||||
|
use std::net::{IpAddr, SocketAddr};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use bytes::Bytes;
|
||||||
|
use http_body_util::combinators::UnsyncBoxBody;
|
||||||
|
use http_body_util::{BodyExt, Full};
|
||||||
|
use hyper::body::Incoming;
|
||||||
|
use hyper::header::{self, HeaderName, HeaderValue};
|
||||||
|
use hyper::server::conn::http1;
|
||||||
|
use hyper::service::service_fn;
|
||||||
|
use hyper::{Method, Request, Response, StatusCode};
|
||||||
|
use hyper_util::rt::{TokioIo, TokioTimer};
|
||||||
|
use ipnetwork::IpNetwork;
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
use crate::config::{WebClientIpSource, WebRuntimeVhost};
|
||||||
|
use crate::web::bridge;
|
||||||
|
use crate::web::frame::{self, FrameType};
|
||||||
|
use crate::web::manager::{ManagerError, WebProcessRuntime};
|
||||||
|
|
||||||
|
// Response-body activity keeps connection idle accounting lifecycle-correct.
|
||||||
|
mod activity;
|
||||||
|
// Body collection retains allocation permits through request processing.
|
||||||
|
mod body;
|
||||||
|
// Decoy routing and upstream proxying are isolated from carrier authentication.
|
||||||
|
mod decoy;
|
||||||
|
// Canonical request parsing rejects ambiguous credentials before routing.
|
||||||
|
mod request;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
|
|
||||||
|
use decoy::serve_decoy;
|
||||||
|
use activity::{ActivityBody, RequestActivity};
|
||||||
|
use body::{CollectBodyError, CollectedBody, collect_body};
|
||||||
|
use request::{
|
||||||
|
bearer_token_hash, binary_content_type, bridge_candidate, canonical_request_host,
|
||||||
|
canonical_u64_header, client_ip, match_profile,
|
||||||
|
};
|
||||||
|
|
||||||
|
type BoxError = Box<dyn Error + Send + Sync>;
|
||||||
|
type HttpBody = UnsyncBoxBody<Bytes, BoxError>;
|
||||||
|
type HttpResponse = Response<HttpBody>;
|
||||||
|
|
||||||
|
const CREATE_BODY_LIMIT: usize = 64;
|
||||||
|
const TRANSPORT_PATHS: [&str; 3] = ["/api/v1/session", "/api/v1/up", "/api/v1/down"];
|
||||||
|
|
||||||
|
/// Serves one bounded HTTP/1.1 connection accepted from an external TLS terminator.
|
||||||
|
pub(crate) async fn serve_connection(
|
||||||
|
stream: TcpStream,
|
||||||
|
peer: SocketAddr,
|
||||||
|
client_ip_source: WebClientIpSource,
|
||||||
|
trusted_proxy_cidrs: Arc<[IpNetwork]>,
|
||||||
|
runtime: Arc<WebProcessRuntime>,
|
||||||
|
cancellation: CancellationToken,
|
||||||
|
connection_permit: tokio::sync::OwnedSemaphorePermit,
|
||||||
|
) {
|
||||||
|
let config = runtime.active_generation().config();
|
||||||
|
let max_header_bytes = config.web.limits.max_header_bytes;
|
||||||
|
let header_timeout = Duration::from_secs(config.web.timeouts.header_secs);
|
||||||
|
let idle_timeout = Duration::from_secs(config.web.timeouts.http_idle_secs);
|
||||||
|
let last_activity = Arc::new(Mutex::new(Instant::now()));
|
||||||
|
let service_last_activity = Arc::clone(&last_activity);
|
||||||
|
let service = service_fn(move |request| {
|
||||||
|
let runtime = Arc::clone(&runtime);
|
||||||
|
let trusted_proxy_cidrs = Arc::clone(&trusted_proxy_cidrs);
|
||||||
|
let last_activity = Arc::clone(&service_last_activity);
|
||||||
|
let client_ip_source = client_ip_source;
|
||||||
|
async move {
|
||||||
|
let activity = RequestActivity::begin(last_activity);
|
||||||
|
let response = if let Some(_handler_permit) = runtime.try_http_handler() {
|
||||||
|
handle_request(
|
||||||
|
request,
|
||||||
|
peer,
|
||||||
|
client_ip_source,
|
||||||
|
&trusted_proxy_cidrs,
|
||||||
|
runtime,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
service_unavailable()
|
||||||
|
};
|
||||||
|
let response = response.map(|body| {
|
||||||
|
ActivityBody::new(body, activity)
|
||||||
|
.boxed_unsync()
|
||||||
|
});
|
||||||
|
Ok::<_, Infallible>(response)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let connection = http1::Builder::new()
|
||||||
|
.timer(TokioTimer::new())
|
||||||
|
.header_read_timeout(header_timeout)
|
||||||
|
.max_buf_size(max_header_bytes)
|
||||||
|
.keep_alive(true)
|
||||||
|
.serve_connection(TokioIo::new(stream), service);
|
||||||
|
tokio::pin!(connection);
|
||||||
|
let mut idle_check = tokio::time::interval((idle_timeout / 2).max(Duration::from_secs(1)));
|
||||||
|
idle_check.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
biased;
|
||||||
|
_ = cancellation.cancelled() => break,
|
||||||
|
_ = &mut connection => break,
|
||||||
|
_ = idle_check.tick() => {
|
||||||
|
if Instant::now().saturating_duration_since(*last_activity.lock())
|
||||||
|
>= idle_timeout
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drop(connection_permit);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_request(
|
||||||
|
request: Request<Incoming>,
|
||||||
|
peer: SocketAddr,
|
||||||
|
client_ip_source: WebClientIpSource,
|
||||||
|
trusted_proxy_cidrs: &[IpNetwork],
|
||||||
|
runtime: Arc<WebProcessRuntime>,
|
||||||
|
) -> HttpResponse {
|
||||||
|
let generation = runtime.active_generation();
|
||||||
|
let config = generation.config();
|
||||||
|
let Some(web_runtime) = config.web.runtime.as_ref() else {
|
||||||
|
return generic_not_found();
|
||||||
|
};
|
||||||
|
let Some(host) = canonical_request_host(&request) else {
|
||||||
|
return generic_not_found();
|
||||||
|
};
|
||||||
|
let Some(vhost) = web_runtime.vhosts.get(host).cloned() else {
|
||||||
|
return generic_not_found();
|
||||||
|
};
|
||||||
|
let path = request.uri().path();
|
||||||
|
if TRANSPORT_PATHS.contains(&path) {
|
||||||
|
return handle_api(
|
||||||
|
request,
|
||||||
|
peer,
|
||||||
|
client_ip_source,
|
||||||
|
trusted_proxy_cidrs,
|
||||||
|
runtime,
|
||||||
|
vhost,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
if path == "/" && matches!(*request.method(), Method::GET | Method::HEAD) {
|
||||||
|
return handle_root(
|
||||||
|
request,
|
||||||
|
peer,
|
||||||
|
client_ip_source,
|
||||||
|
trusted_proxy_cidrs,
|
||||||
|
runtime,
|
||||||
|
vhost,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
serve_decoy(request, vhost, false, &runtime).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_root(
|
||||||
|
mut request: Request<Incoming>,
|
||||||
|
peer: SocketAddr,
|
||||||
|
client_ip_source: WebClientIpSource,
|
||||||
|
trusted_proxy_cidrs: &[IpNetwork],
|
||||||
|
runtime: Arc<WebProcessRuntime>,
|
||||||
|
vhost: Arc<WebRuntimeVhost>,
|
||||||
|
) -> HttpResponse {
|
||||||
|
let (candidate, canonical) = bridge_candidate(request.uri().query());
|
||||||
|
let profile = match_profile(&vhost, &candidate);
|
||||||
|
let Some(profile) = profile.filter(|_| canonical && request.method() == Method::GET) else {
|
||||||
|
return serve_decoy(request, vhost, false, &runtime).await;
|
||||||
|
};
|
||||||
|
let Some(client_ip) = client_ip(
|
||||||
|
&request,
|
||||||
|
peer,
|
||||||
|
client_ip_source,
|
||||||
|
trusted_proxy_cidrs,
|
||||||
|
) else {
|
||||||
|
strip_query(&mut request);
|
||||||
|
return serve_decoy(request, vhost, true, &runtime).await;
|
||||||
|
};
|
||||||
|
let Ok(bootstrap) = runtime.issue_bootstrap(profile, client_ip) else {
|
||||||
|
strip_query(&mut request);
|
||||||
|
return serve_decoy(request, vhost, true, &runtime).await;
|
||||||
|
};
|
||||||
|
let generation = runtime.active_generation();
|
||||||
|
let config = generation.config();
|
||||||
|
let page = bridge::render(
|
||||||
|
&vhost.host,
|
||||||
|
&bootstrap,
|
||||||
|
config.web.limits.carrier_batch_bytes,
|
||||||
|
config.web.limits.pending_bytes_per_session,
|
||||||
|
config.web.limits.pending_items_per_session,
|
||||||
|
&generation.rng,
|
||||||
|
);
|
||||||
|
let mut response = full_response(StatusCode::OK, Bytes::from(page.body));
|
||||||
|
response.headers_mut().insert(
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
HeaderValue::from_static("text/html; charset=utf-8"),
|
||||||
|
);
|
||||||
|
insert_header(
|
||||||
|
&mut response,
|
||||||
|
header::CONTENT_SECURITY_POLICY,
|
||||||
|
&page.content_security_policy,
|
||||||
|
);
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||||
|
response.headers_mut().insert(
|
||||||
|
header::REFERRER_POLICY,
|
||||||
|
HeaderValue::from_static("no-referrer"),
|
||||||
|
);
|
||||||
|
response.headers_mut().insert(
|
||||||
|
header::X_CONTENT_TYPE_OPTIONS,
|
||||||
|
HeaderValue::from_static("nosniff"),
|
||||||
|
);
|
||||||
|
response.headers_mut().insert(
|
||||||
|
HeaderName::from_static("x-dns-prefetch-control"),
|
||||||
|
HeaderValue::from_static("off"),
|
||||||
|
);
|
||||||
|
insert_header(
|
||||||
|
&mut response,
|
||||||
|
HeaderName::from_static("permissions-policy"),
|
||||||
|
bridge::PERMISSIONS_POLICY,
|
||||||
|
);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_api(
|
||||||
|
request: Request<Incoming>,
|
||||||
|
peer: SocketAddr,
|
||||||
|
client_ip_source: WebClientIpSource,
|
||||||
|
trusted_proxy_cidrs: &[IpNetwork],
|
||||||
|
runtime: Arc<WebProcessRuntime>,
|
||||||
|
vhost: Arc<WebRuntimeVhost>,
|
||||||
|
) -> HttpResponse {
|
||||||
|
if request.uri().query().is_some()
|
||||||
|
|| request.headers().contains_key(header::COOKIE)
|
||||||
|
|| request.headers().contains_key("x-lane-id")
|
||||||
|
{
|
||||||
|
return serve_decoy(request, vhost, true, &runtime).await;
|
||||||
|
}
|
||||||
|
let Some(client_ip) = client_ip(
|
||||||
|
&request,
|
||||||
|
peer,
|
||||||
|
client_ip_source,
|
||||||
|
trusted_proxy_cidrs,
|
||||||
|
) else {
|
||||||
|
return serve_decoy(request, vhost, true, &runtime).await;
|
||||||
|
};
|
||||||
|
let Some(token_hash) = bearer_token_hash(&request) else {
|
||||||
|
return serve_decoy(request, vhost, true, &runtime).await;
|
||||||
|
};
|
||||||
|
match request.uri().path() {
|
||||||
|
"/api/v1/session" => {
|
||||||
|
handle_session(request, runtime, vhost, token_hash, client_ip).await
|
||||||
|
}
|
||||||
|
"/api/v1/up" => handle_up(request, runtime, vhost, token_hash).await,
|
||||||
|
"/api/v1/down" => handle_down(request, runtime, vhost, token_hash).await,
|
||||||
|
_ => serve_decoy(request, vhost, true, &runtime).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_session(
|
||||||
|
request: Request<Incoming>,
|
||||||
|
runtime: Arc<WebProcessRuntime>,
|
||||||
|
vhost: Arc<WebRuntimeVhost>,
|
||||||
|
token_hash: crate::web::manager::TokenHash,
|
||||||
|
client_ip: IpAddr,
|
||||||
|
) -> HttpResponse {
|
||||||
|
if request.method() == Method::DELETE {
|
||||||
|
if request.headers().contains_key(header::CONTENT_TYPE) {
|
||||||
|
return serve_decoy(request, vhost, true, &runtime).await;
|
||||||
|
}
|
||||||
|
let CollectedBody {
|
||||||
|
request,
|
||||||
|
body,
|
||||||
|
_body_budget,
|
||||||
|
} = match collect_body(request, &runtime, 1, true).await {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(CollectBodyError::Limit) => return service_unavailable(),
|
||||||
|
Err(CollectBodyError::Invalid(request)) => {
|
||||||
|
return serve_decoy(request, vhost, true, &runtime).await;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !body.is_empty() || runtime.close_token(token_hash, &vhost.host).is_err() {
|
||||||
|
return serve_decoy(request, vhost, true, &runtime).await;
|
||||||
|
}
|
||||||
|
return carrier_empty(StatusCode::NO_CONTENT);
|
||||||
|
}
|
||||||
|
if request.method() != Method::POST || !binary_content_type(&request) {
|
||||||
|
return serve_decoy(request, vhost, true, &runtime).await;
|
||||||
|
}
|
||||||
|
if !runtime.has_bootstrap(token_hash, &vhost.host) {
|
||||||
|
return serve_decoy(request, vhost, true, &runtime).await;
|
||||||
|
}
|
||||||
|
let CollectedBody {
|
||||||
|
request,
|
||||||
|
body,
|
||||||
|
_body_budget,
|
||||||
|
} = match collect_body(request, &runtime, CREATE_BODY_LIMIT, false).await {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(CollectBodyError::Limit) => return service_unavailable(),
|
||||||
|
Err(CollectBodyError::Invalid(request)) => {
|
||||||
|
return serve_decoy(request, vhost, true, &runtime).await;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match runtime.create_session(token_hash, &vhost.host, client_ip, &body) {
|
||||||
|
Ok(result) => {
|
||||||
|
let welcome = frame::encode(FrameType::Welcome, 0, &[]);
|
||||||
|
let mut response = full_response(StatusCode::OK, welcome);
|
||||||
|
carrier_headers(&mut response);
|
||||||
|
insert_header(
|
||||||
|
&mut response,
|
||||||
|
HeaderName::from_static("x-session-token"),
|
||||||
|
&result.token,
|
||||||
|
);
|
||||||
|
response.headers_mut().insert(
|
||||||
|
HeaderName::from_static("x-carrier-mode"),
|
||||||
|
HeaderValue::from_static("https"),
|
||||||
|
);
|
||||||
|
response.headers_mut().insert(
|
||||||
|
HeaderName::from_static("x-down-cursor"),
|
||||||
|
HeaderValue::from_static("0"),
|
||||||
|
);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
Err(ManagerError::Limit | ManagerError::Backpressure | ManagerError::Concurrent) => {
|
||||||
|
service_unavailable()
|
||||||
|
}
|
||||||
|
Err(_) => serve_decoy(request, vhost, true, &runtime).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_up(
|
||||||
|
request: Request<Incoming>,
|
||||||
|
runtime: Arc<WebProcessRuntime>,
|
||||||
|
vhost: Arc<WebRuntimeVhost>,
|
||||||
|
token_hash: crate::web::manager::TokenHash,
|
||||||
|
) -> HttpResponse {
|
||||||
|
if request.method() != Method::POST || !binary_content_type(&request) {
|
||||||
|
return serve_decoy(request, vhost, true, &runtime).await;
|
||||||
|
}
|
||||||
|
let Some(sequence) = canonical_u64_header(&request, "x-up-seq").filter(|value| *value != 0)
|
||||||
|
else {
|
||||||
|
return serve_decoy(request, vhost, true, &runtime).await;
|
||||||
|
};
|
||||||
|
let Ok(session) = runtime.get_session(token_hash, &vhost.host) else {
|
||||||
|
return serve_decoy(request, vhost, true, &runtime).await;
|
||||||
|
};
|
||||||
|
let limit = runtime.active_generation().config().web.limits.max_body_bytes;
|
||||||
|
let CollectedBody {
|
||||||
|
request,
|
||||||
|
body,
|
||||||
|
_body_budget,
|
||||||
|
} = match collect_body(request, &runtime, limit, false).await {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(CollectBodyError::Limit) => return service_unavailable(),
|
||||||
|
Err(CollectBodyError::Invalid(request)) => {
|
||||||
|
return serve_decoy(request, vhost, true, &runtime).await;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match session.process_up(sequence, &body) {
|
||||||
|
Ok(ack) => {
|
||||||
|
let mut response = carrier_empty(StatusCode::NO_CONTENT);
|
||||||
|
insert_header(
|
||||||
|
&mut response,
|
||||||
|
HeaderName::from_static("x-up-ack"),
|
||||||
|
&ack.to_string(),
|
||||||
|
);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
Err(ManagerError::Backpressure | ManagerError::Concurrent | ManagerError::Limit) => {
|
||||||
|
service_unavailable()
|
||||||
|
}
|
||||||
|
Err(_) => serve_decoy(request, vhost, true, &runtime).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_down(
|
||||||
|
request: Request<Incoming>,
|
||||||
|
runtime: Arc<WebProcessRuntime>,
|
||||||
|
vhost: Arc<WebRuntimeVhost>,
|
||||||
|
token_hash: crate::web::manager::TokenHash,
|
||||||
|
) -> HttpResponse {
|
||||||
|
if request.method() != Method::POST || request.headers().contains_key(header::CONTENT_TYPE) {
|
||||||
|
return serve_decoy(request, vhost, true, &runtime).await;
|
||||||
|
}
|
||||||
|
let Some(cursor) = canonical_u64_header(&request, "x-down-cursor") else {
|
||||||
|
return serve_decoy(request, vhost, true, &runtime).await;
|
||||||
|
};
|
||||||
|
let Ok(session) = runtime.get_session(token_hash, &vhost.host) else {
|
||||||
|
return serve_decoy(request, vhost, true, &runtime).await;
|
||||||
|
};
|
||||||
|
let CollectedBody {
|
||||||
|
request,
|
||||||
|
body,
|
||||||
|
_body_budget,
|
||||||
|
} = match collect_body(request, &runtime, 1, true).await {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(CollectBodyError::Limit) => return service_unavailable(),
|
||||||
|
Err(CollectBodyError::Invalid(request)) => {
|
||||||
|
return serve_decoy(request, vhost, true, &runtime).await;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !body.is_empty() {
|
||||||
|
return serve_decoy(request, vhost, true, &runtime).await;
|
||||||
|
}
|
||||||
|
match session.poll_down(cursor).await {
|
||||||
|
Ok(result) if result.body.is_empty() => {
|
||||||
|
let mut response = carrier_empty(StatusCode::NO_CONTENT);
|
||||||
|
insert_header(
|
||||||
|
&mut response,
|
||||||
|
HeaderName::from_static("x-down-cursor"),
|
||||||
|
&result.next_cursor.to_string(),
|
||||||
|
);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
Ok(result) => {
|
||||||
|
let mut response = full_response(StatusCode::OK, result.body);
|
||||||
|
carrier_headers(&mut response);
|
||||||
|
insert_header(
|
||||||
|
&mut response,
|
||||||
|
HeaderName::from_static("x-down-cursor"),
|
||||||
|
&result.next_cursor.to_string(),
|
||||||
|
);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
Err(ManagerError::Concurrent | ManagerError::Backpressure | ManagerError::Limit) => {
|
||||||
|
service_unavailable()
|
||||||
|
}
|
||||||
|
Err(_) => serve_decoy(request, vhost, true, &runtime).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn carrier_headers(response: &mut HttpResponse) {
|
||||||
|
response.headers_mut().insert(
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
HeaderValue::from_static("application/octet-stream"),
|
||||||
|
);
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn carrier_empty(status: StatusCode) -> HttpResponse {
|
||||||
|
let mut response = empty_response(status);
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
fn service_unavailable() -> HttpResponse {
|
||||||
|
let mut response = carrier_empty(StatusCode::SERVICE_UNAVAILABLE);
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert(header::RETRY_AFTER, HeaderValue::from_static("1"));
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bad_gateway() -> HttpResponse {
|
||||||
|
full_response(
|
||||||
|
StatusCode::BAD_GATEWAY,
|
||||||
|
Bytes::from_static(b"site unavailable\n"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generic_not_found() -> HttpResponse {
|
||||||
|
full_response(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Bytes::from_static(b"not found\n"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn full_response(status: StatusCode, body: Bytes) -> HttpResponse {
|
||||||
|
let length = body.len();
|
||||||
|
let body = Full::new(body)
|
||||||
|
.map_err(|never| -> BoxError { match never {} })
|
||||||
|
.boxed_unsync();
|
||||||
|
let mut response = Response::new(body);
|
||||||
|
*response.status_mut() = status;
|
||||||
|
insert_header(
|
||||||
|
&mut response,
|
||||||
|
header::CONTENT_LENGTH,
|
||||||
|
&length.to_string(),
|
||||||
|
);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
fn empty_response(status: StatusCode) -> HttpResponse {
|
||||||
|
full_response(status, Bytes::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_header(response: &mut HttpResponse, name: HeaderName, value: &str) {
|
||||||
|
if let Ok(value) = HeaderValue::from_str(value) {
|
||||||
|
response.headers_mut().insert(name, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_query<B>(request: &mut Request<B>) {
|
||||||
|
if request.uri().query().is_some()
|
||||||
|
&& let Ok(uri) = request.uri().path().parse()
|
||||||
|
{
|
||||||
|
*request.uri_mut() = uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
use std::pin::Pin;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::task::{Context, Poll};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use bytes::Bytes;
|
||||||
|
use hyper::body::{Body, Frame, SizeHint};
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
|
||||||
|
use super::{BoxError, HttpBody};
|
||||||
|
|
||||||
|
/// Request lifecycle guard that refreshes HTTP connection activity on completion.
|
||||||
|
pub(super) struct RequestActivity {
|
||||||
|
last_activity: Arc<Mutex<Instant>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RequestActivity {
|
||||||
|
/// Starts activity accounting for one HTTP request.
|
||||||
|
pub(super) fn begin(last_activity: Arc<Mutex<Instant>>) -> Self {
|
||||||
|
*last_activity.lock() = Instant::now();
|
||||||
|
Self { last_activity }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for RequestActivity {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
*self.last_activity.lock() = Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response body wrapper that refreshes activity while downstream data progresses.
|
||||||
|
pub(super) struct ActivityBody {
|
||||||
|
inner: HttpBody,
|
||||||
|
activity: RequestActivity,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActivityBody {
|
||||||
|
/// Binds one response body to its request activity guard.
|
||||||
|
pub(super) fn new(inner: HttpBody, activity: RequestActivity) -> Self {
|
||||||
|
Self {
|
||||||
|
inner,
|
||||||
|
activity,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Body for ActivityBody {
|
||||||
|
type Data = Bytes;
|
||||||
|
type Error = BoxError;
|
||||||
|
|
||||||
|
fn poll_frame(
|
||||||
|
mut self: Pin<&mut Self>,
|
||||||
|
context: &mut Context<'_>,
|
||||||
|
) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
|
||||||
|
let result = Pin::new(&mut self.inner).poll_frame(context);
|
||||||
|
if result.is_ready() {
|
||||||
|
*self.activity.last_activity.lock() = Instant::now();
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_end_stream(&self) -> bool {
|
||||||
|
self.inner.is_end_stream()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn size_hint(&self) -> SizeHint {
|
||||||
|
self.inner.size_hint()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use bytes::Bytes;
|
||||||
|
use http_body_util::{BodyExt, Empty, Limited};
|
||||||
|
use hyper::body::{Body as _, Incoming};
|
||||||
|
use hyper::Request;
|
||||||
|
|
||||||
|
use crate::web::manager::WebProcessRuntime;
|
||||||
|
|
||||||
|
/// Collected carrier request retaining its process-wide body reservation.
|
||||||
|
pub(super) struct CollectedBody {
|
||||||
|
/// Request head reconstructed without the consumed network body.
|
||||||
|
pub(super) request: Request<Empty<Bytes>>,
|
||||||
|
/// Fully collected bounded carrier payload.
|
||||||
|
pub(super) body: Bytes,
|
||||||
|
/// Byte-budget reservation held through request processing.
|
||||||
|
pub(super) _body_budget: tokio::sync::OwnedSemaphorePermit,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep rejected requests inline to avoid attacker-controlled allocations on invalid bodies.
|
||||||
|
#[allow(clippy::large_enum_variant)]
|
||||||
|
/// Body collection failure with sanitized request context when decoy routing is safe.
|
||||||
|
pub(super) enum CollectBodyError {
|
||||||
|
/// The body shape, size, or deadline failed after retaining the request head.
|
||||||
|
Invalid(Request<Empty<Bytes>>),
|
||||||
|
/// Process-wide body reader or byte capacity is temporarily exhausted.
|
||||||
|
Limit,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collects one bounded carrier body under reader, byte, and deadline ownership.
|
||||||
|
pub(super) async fn collect_body(
|
||||||
|
request: Request<Incoming>,
|
||||||
|
runtime: &WebProcessRuntime,
|
||||||
|
limit: usize,
|
||||||
|
allow_empty: bool,
|
||||||
|
) -> Result<CollectedBody, CollectBodyError> {
|
||||||
|
let exceeds_limit = request.body().size_hint().lower() > limit as u64
|
||||||
|
|| request
|
||||||
|
.body()
|
||||||
|
.size_hint()
|
||||||
|
.upper()
|
||||||
|
.is_some_and(|upper| upper > limit as u64);
|
||||||
|
let (parts, body) = request.into_parts();
|
||||||
|
if exceeds_limit {
|
||||||
|
return Err(CollectBodyError::Invalid(Request::from_parts(
|
||||||
|
parts,
|
||||||
|
Empty::new(),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let Some((reader_budget, body_budget)) = runtime.try_body_budget(limit) else {
|
||||||
|
return Err(CollectBodyError::Limit);
|
||||||
|
};
|
||||||
|
let body_timeout = Duration::from_secs(
|
||||||
|
runtime
|
||||||
|
.active_generation()
|
||||||
|
.config()
|
||||||
|
.web
|
||||||
|
.timeouts
|
||||||
|
.body_secs,
|
||||||
|
);
|
||||||
|
let body = match tokio::time::timeout(body_timeout, Limited::new(body, limit).collect()).await {
|
||||||
|
Ok(Ok(body)) => body.to_bytes(),
|
||||||
|
_ => {
|
||||||
|
return Err(CollectBodyError::Invalid(Request::from_parts(
|
||||||
|
parts,
|
||||||
|
Empty::new(),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
drop(reader_budget);
|
||||||
|
let request = Request::from_parts(parts, Empty::new());
|
||||||
|
if !allow_empty && body.is_empty() {
|
||||||
|
return Err(CollectBodyError::Invalid(request));
|
||||||
|
}
|
||||||
|
Ok(CollectedBody {
|
||||||
|
request,
|
||||||
|
body,
|
||||||
|
_body_budget: body_budget,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
use std::error::Error;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use bytes::Bytes;
|
||||||
|
use http_body_util::{BodyExt, Empty};
|
||||||
|
use hyper::header::{self, HeaderName, HeaderValue};
|
||||||
|
use hyper::{Method, Request, StatusCode, Uri};
|
||||||
|
use hyper_util::rt::TokioIo;
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
BoxError, HttpBody, HttpResponse, bad_gateway, full_response, generic_not_found,
|
||||||
|
insert_header,
|
||||||
|
};
|
||||||
|
use crate::config::{WebRuntimeDecoy, WebRuntimeVhost};
|
||||||
|
use crate::web::manager::WebProcessRuntime;
|
||||||
|
|
||||||
|
/// Serves the configured ordinary site after optionally removing carrier material.
|
||||||
|
pub(super) async fn serve_decoy<B>(
|
||||||
|
mut request: Request<B>,
|
||||||
|
vhost: Arc<WebRuntimeVhost>,
|
||||||
|
sanitize_transport: bool,
|
||||||
|
runtime: &WebProcessRuntime,
|
||||||
|
) -> HttpResponse
|
||||||
|
where
|
||||||
|
B: hyper::body::Body<Data = Bytes> + Send + 'static,
|
||||||
|
B::Error: Error + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
if sanitize_transport {
|
||||||
|
sanitize_transport_request(&mut request);
|
||||||
|
}
|
||||||
|
let (parts, body) = request.into_parts();
|
||||||
|
let body = if sanitize_transport {
|
||||||
|
Empty::<Bytes>::new()
|
||||||
|
.map_err(|never| -> BoxError { match never {} })
|
||||||
|
.boxed_unsync()
|
||||||
|
} else {
|
||||||
|
body.map_err(|error| -> BoxError { Box::new(error) })
|
||||||
|
.boxed_unsync()
|
||||||
|
};
|
||||||
|
let request = Request::from_parts(parts, body);
|
||||||
|
match &vhost.decoy {
|
||||||
|
WebRuntimeDecoy::StaticDirectory(site) => serve_static(request, site),
|
||||||
|
WebRuntimeDecoy::HttpUpstream { addr, authority } => {
|
||||||
|
proxy_to_upstream(
|
||||||
|
request,
|
||||||
|
*addr,
|
||||||
|
authority,
|
||||||
|
Duration::from_secs(vhost.decoy_header_secs),
|
||||||
|
runtime,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serve_static<B>(request: Request<B>, site: &crate::config::WebStaticSite) -> HttpResponse {
|
||||||
|
if !matches!(*request.method(), Method::GET | Method::HEAD) {
|
||||||
|
return static_entry(request, site, None, StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
let path = request.uri().path();
|
||||||
|
let resolved = resolve_static_path(path, site);
|
||||||
|
let status = if resolved.is_some() {
|
||||||
|
StatusCode::OK
|
||||||
|
} else {
|
||||||
|
StatusCode::NOT_FOUND
|
||||||
|
};
|
||||||
|
static_entry(request, site, resolved, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn static_entry<B>(
|
||||||
|
request: Request<B>,
|
||||||
|
site: &crate::config::WebStaticSite,
|
||||||
|
route: Option<&str>,
|
||||||
|
status: StatusCode,
|
||||||
|
) -> HttpResponse {
|
||||||
|
let fallback = format!("/{}", site.index);
|
||||||
|
let not_found = site.assets.contains_key("/404.html").then_some("/404.html");
|
||||||
|
let route = route.or(not_found).unwrap_or(&fallback);
|
||||||
|
let Some(asset) = site.assets.get(route) else {
|
||||||
|
return generic_not_found();
|
||||||
|
};
|
||||||
|
let not_modified = status == StatusCode::OK
|
||||||
|
&& request
|
||||||
|
.headers()
|
||||||
|
.get(header::IF_NONE_MATCH)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
== Some(asset.etag.as_str());
|
||||||
|
let response_body = if request.method() == Method::HEAD || not_modified {
|
||||||
|
Bytes::new()
|
||||||
|
} else {
|
||||||
|
asset.body.clone()
|
||||||
|
};
|
||||||
|
let mut response = full_response(
|
||||||
|
if not_modified {
|
||||||
|
StatusCode::NOT_MODIFIED
|
||||||
|
} else {
|
||||||
|
status
|
||||||
|
},
|
||||||
|
response_body,
|
||||||
|
);
|
||||||
|
insert_header(&mut response, header::CONTENT_TYPE, asset.content_type);
|
||||||
|
insert_header(&mut response, header::ETAG, &asset.etag);
|
||||||
|
insert_header(
|
||||||
|
&mut response,
|
||||||
|
header::CONTENT_LENGTH,
|
||||||
|
&asset.body.len().to_string(),
|
||||||
|
);
|
||||||
|
response.headers_mut().insert(
|
||||||
|
header::CACHE_CONTROL,
|
||||||
|
if status.is_client_error() || request.uri().query().is_some() {
|
||||||
|
HeaderValue::from_static("no-store")
|
||||||
|
} else {
|
||||||
|
HeaderValue::from_static("public, max-age=300")
|
||||||
|
},
|
||||||
|
);
|
||||||
|
response.headers_mut().insert(
|
||||||
|
header::CONTENT_SECURITY_POLICY,
|
||||||
|
HeaderValue::from_static("default-src 'self'; style-src 'self'; img-src 'self'; worker-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'"),
|
||||||
|
);
|
||||||
|
response.headers_mut().insert(
|
||||||
|
header::REFERRER_POLICY,
|
||||||
|
HeaderValue::from_static("strict-origin-when-cross-origin"),
|
||||||
|
);
|
||||||
|
response.headers_mut().insert(
|
||||||
|
header::X_CONTENT_TYPE_OPTIONS,
|
||||||
|
HeaderValue::from_static("nosniff"),
|
||||||
|
);
|
||||||
|
response.headers_mut().insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_static_path<'a>(
|
||||||
|
path: &str,
|
||||||
|
site: &'a crate::config::WebStaticSite,
|
||||||
|
) -> Option<&'a str> {
|
||||||
|
if !path.starts_with('/')
|
||||||
|
|| path.contains('\\')
|
||||||
|
|| path.contains("//")
|
||||||
|
|| path.split('/').any(|part| matches!(part, "." | ".."))
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let root;
|
||||||
|
let route = if path == "/" {
|
||||||
|
root = format!("/{}", site.index);
|
||||||
|
root.as_str()
|
||||||
|
} else {
|
||||||
|
path
|
||||||
|
};
|
||||||
|
if site.assets.contains_key(route) {
|
||||||
|
return site.assets.get_key_value(route).map(|(key, _)| key.as_str());
|
||||||
|
}
|
||||||
|
if route == "/favicon.ico" && site.assets.contains_key("/favicon.svg") {
|
||||||
|
return Some("/favicon.svg");
|
||||||
|
}
|
||||||
|
if !route.rsplit('/').next().unwrap_or_default().contains('.') {
|
||||||
|
let html = format!("{route}.html");
|
||||||
|
return site
|
||||||
|
.assets
|
||||||
|
.get_key_value(&html)
|
||||||
|
.map(|(key, _)| key.as_str());
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn proxy_to_upstream(
|
||||||
|
mut request: Request<HttpBody>,
|
||||||
|
addr: SocketAddr,
|
||||||
|
authority: &str,
|
||||||
|
header_timeout: Duration,
|
||||||
|
runtime: &WebProcessRuntime,
|
||||||
|
) -> HttpResponse {
|
||||||
|
remove_hop_by_hop(request.headers_mut());
|
||||||
|
if let Ok(host) = HeaderValue::from_str(authority) {
|
||||||
|
request.headers_mut().insert(header::HOST, host);
|
||||||
|
}
|
||||||
|
let path_and_query = request
|
||||||
|
.uri()
|
||||||
|
.path_and_query()
|
||||||
|
.map(|value| value.as_str())
|
||||||
|
.unwrap_or("/");
|
||||||
|
let Ok(uri) = path_and_query.parse::<Uri>() else {
|
||||||
|
return bad_gateway();
|
||||||
|
};
|
||||||
|
*request.uri_mut() = uri;
|
||||||
|
let stream = match tokio::time::timeout(header_timeout, TcpStream::connect(addr)).await {
|
||||||
|
Ok(Ok(stream)) => stream,
|
||||||
|
_ => return bad_gateway(),
|
||||||
|
};
|
||||||
|
let max_header_bytes = runtime
|
||||||
|
.active_generation()
|
||||||
|
.config()
|
||||||
|
.web
|
||||||
|
.limits
|
||||||
|
.max_header_bytes;
|
||||||
|
let mut builder = hyper::client::conn::http1::Builder::new();
|
||||||
|
builder.max_buf_size(max_header_bytes);
|
||||||
|
let (mut sender, connection) = match tokio::time::timeout(
|
||||||
|
header_timeout,
|
||||||
|
builder.handshake(TokioIo::new(stream)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(parts)) => parts,
|
||||||
|
_ => return bad_gateway(),
|
||||||
|
};
|
||||||
|
runtime.spawn_auxiliary(async move {
|
||||||
|
let _ = connection.await;
|
||||||
|
});
|
||||||
|
let mut response = match tokio::time::timeout(header_timeout, sender.send_request(request)).await
|
||||||
|
{
|
||||||
|
Ok(Ok(response)) => response,
|
||||||
|
_ => return bad_gateway(),
|
||||||
|
};
|
||||||
|
remove_hop_by_hop(response.headers_mut());
|
||||||
|
response.map(|body| {
|
||||||
|
body.map_err(|error| -> BoxError { Box::new(error) })
|
||||||
|
.boxed_unsync()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_transport_request<B>(request: &mut Request<B>) {
|
||||||
|
for name in [
|
||||||
|
header::AUTHORIZATION,
|
||||||
|
header::CONTENT_LENGTH,
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
header::UPGRADE,
|
||||||
|
HeaderName::from_static("sec-websocket-key"),
|
||||||
|
HeaderName::from_static("sec-websocket-protocol"),
|
||||||
|
HeaderName::from_static("sec-websocket-version"),
|
||||||
|
HeaderName::from_static("x-down-cursor"),
|
||||||
|
HeaderName::from_static("x-lane-id"),
|
||||||
|
HeaderName::from_static("x-up-seq"),
|
||||||
|
] {
|
||||||
|
request.headers_mut().remove(name);
|
||||||
|
}
|
||||||
|
request
|
||||||
|
.headers_mut()
|
||||||
|
.insert(header::CONNECTION, HeaderValue::from_static("close"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_hop_by_hop(headers: &mut hyper::HeaderMap) {
|
||||||
|
let nominated = headers
|
||||||
|
.get_all(header::CONNECTION)
|
||||||
|
.iter()
|
||||||
|
.filter_map(|value| value.to_str().ok())
|
||||||
|
.flat_map(|value| value.split(','))
|
||||||
|
.filter_map(|value| HeaderName::from_bytes(value.trim().as_bytes()).ok())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
for name in nominated {
|
||||||
|
headers.remove(name);
|
||||||
|
}
|
||||||
|
for name in [
|
||||||
|
header::CONNECTION,
|
||||||
|
header::PROXY_AUTHENTICATE,
|
||||||
|
header::PROXY_AUTHORIZATION,
|
||||||
|
header::TE,
|
||||||
|
header::TRAILER,
|
||||||
|
header::TRANSFER_ENCODING,
|
||||||
|
header::UPGRADE,
|
||||||
|
HeaderName::from_static("keep-alive"),
|
||||||
|
HeaderName::from_static("proxy-connection"),
|
||||||
|
] {
|
||||||
|
headers.remove(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn static_resolver_rejects_rewritten_paths() {
|
||||||
|
let site = crate::config::WebStaticSite {
|
||||||
|
assets: std::collections::BTreeMap::new(),
|
||||||
|
index: "index.html".to_string(),
|
||||||
|
};
|
||||||
|
assert!(resolve_static_path("/../index.html", &site).is_none());
|
||||||
|
assert!(resolve_static_path("//index.html", &site).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
use std::net::{IpAddr, SocketAddr};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use base64::Engine as _;
|
||||||
|
use hyper::header;
|
||||||
|
use hyper::Request;
|
||||||
|
use ipnetwork::IpNetwork;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use subtle::ConstantTimeEq;
|
||||||
|
|
||||||
|
use crate::config::{
|
||||||
|
WebClientIpSource, WebRuntimeProfile, WebRuntimeVhost,
|
||||||
|
};
|
||||||
|
use crate::web::manager::TokenHash;
|
||||||
|
|
||||||
|
/// Parses one lowercase canonical Host value restricted to the public HTTPS port.
|
||||||
|
pub(super) fn canonical_request_host<B>(request: &Request<B>) -> Option<&str> {
|
||||||
|
let values = request.headers().get_all(header::HOST);
|
||||||
|
let mut values = values.iter();
|
||||||
|
let value = values.next()?.to_str().ok()?;
|
||||||
|
if values.next().is_some() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let authority = value.parse::<hyper::http::uri::Authority>().ok()?;
|
||||||
|
if authority.port_u16().is_some_and(|port| port != 443) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let host = value.strip_suffix(":443").unwrap_or(value);
|
||||||
|
if authority.host() != host || host.bytes().any(|byte| byte.is_ascii_uppercase())
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(host)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accepts one canonical forwarded client address from an explicitly trusted peer.
|
||||||
|
pub(super) fn client_ip<B>(
|
||||||
|
request: &Request<B>,
|
||||||
|
peer: SocketAddr,
|
||||||
|
source: WebClientIpSource,
|
||||||
|
trusted_proxy_cidrs: &[IpNetwork],
|
||||||
|
) -> Option<IpAddr> {
|
||||||
|
if !trusted_proxy_cidrs
|
||||||
|
.iter()
|
||||||
|
.any(|network| network.contains(peer.ip()))
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let header_name = match source {
|
||||||
|
WebClientIpSource::XForwardedFor => "x-forwarded-for",
|
||||||
|
};
|
||||||
|
let values = request.headers().get_all(header_name);
|
||||||
|
let mut values = values.iter();
|
||||||
|
let value = values.next()?.to_str().ok()?;
|
||||||
|
if values.next().is_some()
|
||||||
|
|| value.is_empty()
|
||||||
|
|| value.trim() != value
|
||||||
|
|| value.contains(',')
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let ip = value.parse::<IpAddr>().ok()?;
|
||||||
|
(ip.to_string() == value).then_some(ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes an exact canonical bridge query without allocating credential strings.
|
||||||
|
pub(super) fn bridge_candidate(query: Option<&str>) -> ([u8; 32], bool) {
|
||||||
|
let mut candidate = [0u8; 32];
|
||||||
|
let Some(value) = query.and_then(|query| query.strip_prefix("bridge=")) else {
|
||||||
|
return (candidate, false);
|
||||||
|
};
|
||||||
|
if value.len() != 43 {
|
||||||
|
return (candidate, false);
|
||||||
|
}
|
||||||
|
let mut decoded = [0u8; 32];
|
||||||
|
let Ok(decoded_len) = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||||
|
.decode_slice(value, &mut decoded)
|
||||||
|
else {
|
||||||
|
return (candidate, false);
|
||||||
|
};
|
||||||
|
let mut canonical = [0u8; 43];
|
||||||
|
let Ok(encoded_len) = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||||
|
.encode_slice(decoded, &mut canonical)
|
||||||
|
else {
|
||||||
|
return (candidate, false);
|
||||||
|
};
|
||||||
|
if decoded_len != decoded.len()
|
||||||
|
|| encoded_len != canonical.len()
|
||||||
|
|| !bool::from(canonical.ct_eq(value.as_bytes()))
|
||||||
|
{
|
||||||
|
return (candidate, false);
|
||||||
|
}
|
||||||
|
candidate = decoded;
|
||||||
|
(candidate, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Matches a capability in constant time across every profile of one virtual host.
|
||||||
|
pub(super) fn match_profile(
|
||||||
|
vhost: &WebRuntimeVhost,
|
||||||
|
candidate: &[u8; 32],
|
||||||
|
) -> Option<Arc<WebRuntimeProfile>> {
|
||||||
|
let mut matched = None;
|
||||||
|
for profile in &vhost.profiles {
|
||||||
|
if bool::from(profile.capability.ct_eq(candidate)) {
|
||||||
|
matched = Some(Arc::clone(profile));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
matched
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates and hashes one canonical bearer credential for map lookup.
|
||||||
|
pub(super) fn bearer_token_hash<B>(request: &Request<B>) -> Option<TokenHash> {
|
||||||
|
let values = request.headers().get_all(header::AUTHORIZATION);
|
||||||
|
let mut values = values.iter();
|
||||||
|
let value = values.next()?.to_str().ok()?;
|
||||||
|
if values.next().is_some() || !value.starts_with("Bearer ") || value.matches(' ').count() != 1
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let token = value.strip_prefix("Bearer ")?;
|
||||||
|
if token.len() != 43 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut decoded = [0u8; 32];
|
||||||
|
let decoded_len = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||||
|
.decode_slice(token, &mut decoded)
|
||||||
|
.ok()?;
|
||||||
|
let mut canonical = [0u8; 43];
|
||||||
|
let encoded_len = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||||
|
.encode_slice(decoded, &mut canonical)
|
||||||
|
.ok()?;
|
||||||
|
(decoded_len == decoded.len()
|
||||||
|
&& encoded_len == canonical.len()
|
||||||
|
&& bool::from(canonical.ct_eq(token.as_bytes())))
|
||||||
|
.then(|| Sha256::digest(decoded).into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks the exact carrier media type without accepting duplicate headers.
|
||||||
|
pub(super) fn binary_content_type<B>(request: &Request<B>) -> bool {
|
||||||
|
let values = request.headers().get_all(header::CONTENT_TYPE);
|
||||||
|
let mut values = values.iter();
|
||||||
|
let value = values.next().and_then(|value| value.to_str().ok());
|
||||||
|
values.next().is_none()
|
||||||
|
&& value.is_some_and(|value| value.eq_ignore_ascii_case("application/octet-stream"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses one canonical unsigned decimal carrier sequence header.
|
||||||
|
pub(super) fn canonical_u64_header<B>(
|
||||||
|
request: &Request<B>,
|
||||||
|
name: &'static str,
|
||||||
|
) -> Option<u64> {
|
||||||
|
let values = request.headers().get_all(name);
|
||||||
|
let mut values = values.iter();
|
||||||
|
let value = values.next()?.to_str().ok()?;
|
||||||
|
if values.next().is_some()
|
||||||
|
|| value.is_empty()
|
||||||
|
|| value.starts_with('+')
|
||||||
|
|| (value.len() > 1 && value.starts_with('0'))
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let parsed = value.parse::<u64>().ok()?;
|
||||||
|
(parsed.to_string() == value).then_some(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn canonical_bridge_query_rejects_aliases() {
|
||||||
|
let token = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([7u8; 32]);
|
||||||
|
assert!(bridge_candidate(Some(&format!("bridge={token}"))).1);
|
||||||
|
assert!(!bridge_candidate(Some(&format!("x=1&bridge={token}"))).1);
|
||||||
|
assert!(!bridge_candidate(Some(&format!("bridge={token}="))).1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn host_and_forwarded_identity_require_canonical_single_values() {
|
||||||
|
let request = Request::builder()
|
||||||
|
.header(header::HOST, "proxy.example.com:443")
|
||||||
|
.header("x-forwarded-for", "192.0.2.10")
|
||||||
|
.body(())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
canonical_request_host(&request),
|
||||||
|
Some("proxy.example.com")
|
||||||
|
);
|
||||||
|
let trusted: [IpNetwork; 1] = ["127.0.0.1/32".parse().unwrap()];
|
||||||
|
assert_eq!(
|
||||||
|
client_ip(
|
||||||
|
&request,
|
||||||
|
"127.0.0.1:40000".parse().unwrap(),
|
||||||
|
WebClientIpSource::XForwardedFor,
|
||||||
|
&trusted,
|
||||||
|
),
|
||||||
|
Some("192.0.2.10".parse().unwrap())
|
||||||
|
);
|
||||||
|
|
||||||
|
let uppercase = Request::builder()
|
||||||
|
.header(header::HOST, "Proxy.Example.com")
|
||||||
|
.body(())
|
||||||
|
.unwrap();
|
||||||
|
assert!(canonical_request_host(&uppercase).is_none());
|
||||||
|
let appended = Request::builder()
|
||||||
|
.header("x-forwarded-for", "192.0.2.10, 198.51.100.4")
|
||||||
|
.body(())
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
client_ip(
|
||||||
|
&appended,
|
||||||
|
"127.0.0.1:40000".parse().unwrap(),
|
||||||
|
WebClientIpSource::XForwardedFor,
|
||||||
|
&trusted,
|
||||||
|
)
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bearer_and_sequence_headers_reject_noncanonical_aliases() {
|
||||||
|
let token = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([1u8; 32]);
|
||||||
|
let request = Request::builder()
|
||||||
|
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||||
|
.header("x-up-seq", "17")
|
||||||
|
.body(())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
bearer_token_hash(&request),
|
||||||
|
Some(Sha256::digest([1u8; 32]).into())
|
||||||
|
);
|
||||||
|
assert_eq!(canonical_u64_header(&request, "x-up-seq"), Some(17));
|
||||||
|
|
||||||
|
let leading_zero = Request::builder()
|
||||||
|
.header("x-up-seq", "017")
|
||||||
|
.body(())
|
||||||
|
.unwrap();
|
||||||
|
assert!(canonical_u64_header(&leading_zero, "x-up-seq").is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use arc_swap::ArcSwap;
|
||||||
|
use base64::Engine as _;
|
||||||
|
use bytes::Bytes;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
use super::serve_connection;
|
||||||
|
use crate::config::{
|
||||||
|
ProxyConfig, WebClientIpSource, WebRuntimeConfig, WebRuntimeDecoy,
|
||||||
|
WebRuntimeProfile, WebRuntimeVhost, WebSecretMode, WebStaticAsset, WebStaticSite,
|
||||||
|
};
|
||||||
|
use crate::maestro::generation::test_runtime_generation;
|
||||||
|
use crate::web::frame::{self, FrameType};
|
||||||
|
use crate::web::manager::WebProcessRuntime;
|
||||||
|
|
||||||
|
fn runtime_config(capability: [u8; 32]) -> ProxyConfig {
|
||||||
|
let profile = Arc::new(WebRuntimeProfile {
|
||||||
|
host: "proxy.example.com".to_string(),
|
||||||
|
public_addr: "203.0.113.10:443".parse().unwrap(),
|
||||||
|
user: "alice".to_string(),
|
||||||
|
secret_mode: WebSecretMode::Plain,
|
||||||
|
capability,
|
||||||
|
max_sessions: 4,
|
||||||
|
max_streams: 16,
|
||||||
|
max_streams_per_session: 4,
|
||||||
|
});
|
||||||
|
let mut assets = BTreeMap::new();
|
||||||
|
assets.insert(
|
||||||
|
"/index.html".to_string(),
|
||||||
|
WebStaticAsset {
|
||||||
|
body: Bytes::from_static(b"<!doctype html><title>decoy</title>"),
|
||||||
|
content_type: "text/html; charset=utf-8",
|
||||||
|
etag: "\"test\"".to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let site = Arc::new(WebStaticSite {
|
||||||
|
assets,
|
||||||
|
index: "index.html".to_string(),
|
||||||
|
});
|
||||||
|
let vhost = Arc::new(WebRuntimeVhost {
|
||||||
|
host: "proxy.example.com".to_string(),
|
||||||
|
decoy: WebRuntimeDecoy::StaticDirectory(Arc::clone(&site)),
|
||||||
|
decoy_header_secs: 1,
|
||||||
|
profiles: vec![Arc::clone(&profile)],
|
||||||
|
});
|
||||||
|
let mut vhosts = BTreeMap::new();
|
||||||
|
vhosts.insert("proxy.example.com".to_string(), vhost);
|
||||||
|
vhosts.insert(
|
||||||
|
"other.example.com".to_string(),
|
||||||
|
Arc::new(WebRuntimeVhost {
|
||||||
|
host: "other.example.com".to_string(),
|
||||||
|
decoy: WebRuntimeDecoy::StaticDirectory(site),
|
||||||
|
decoy_header_secs: 1,
|
||||||
|
profiles: Vec::new(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let mut config = ProxyConfig::default();
|
||||||
|
config.web.enabled = true;
|
||||||
|
config.web.limits.max_bootstraps_per_ip = 1;
|
||||||
|
config.web.timeouts.shutdown_secs = 1;
|
||||||
|
config.web.runtime = Some(Arc::new(WebRuntimeConfig {
|
||||||
|
vhosts,
|
||||||
|
profiles: vec![profile],
|
||||||
|
}));
|
||||||
|
config
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn request(
|
||||||
|
listener: &TcpListener,
|
||||||
|
runtime: &Arc<WebProcessRuntime>,
|
||||||
|
request: Vec<u8>,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let (accepted, client) = tokio::join!(listener.accept(), TcpStream::connect(addr));
|
||||||
|
let (server, peer) = accepted.unwrap();
|
||||||
|
let mut client = client.unwrap();
|
||||||
|
let permit = runtime.try_http_connection().unwrap();
|
||||||
|
let task = tokio::spawn(serve_connection(
|
||||||
|
server,
|
||||||
|
peer,
|
||||||
|
WebClientIpSource::XForwardedFor,
|
||||||
|
Arc::from(["127.0.0.1/32".parse().unwrap()]),
|
||||||
|
Arc::clone(runtime),
|
||||||
|
CancellationToken::new(),
|
||||||
|
permit,
|
||||||
|
));
|
||||||
|
client.write_all(&request).await.unwrap();
|
||||||
|
let mut response = Vec::new();
|
||||||
|
client.read_to_end(&mut response).await.unwrap();
|
||||||
|
task.await.unwrap();
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split_response(response: &[u8]) -> (&[u8], &[u8]) {
|
||||||
|
let separator = response
|
||||||
|
.windows(4)
|
||||||
|
.position(|window| window == b"\r\n\r\n")
|
||||||
|
.unwrap();
|
||||||
|
(&response[..separator], &response[separator + 4..])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn response_header<'a>(headers: &'a [u8], name: &str) -> &'a str {
|
||||||
|
std::str::from_utf8(headers)
|
||||||
|
.unwrap()
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| line.split_once(':'))
|
||||||
|
.find_map(|(header, value)| header.eq_ignore_ascii_case(name).then_some(value.trim()))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn https_carrier_bootstraps_and_closes_one_session() {
|
||||||
|
let capability = [7u8; 32];
|
||||||
|
let generation = test_runtime_generation(1, runtime_config(capability));
|
||||||
|
let active_runtime = Arc::new(ArcSwap::from(Arc::clone(&generation)));
|
||||||
|
let runtime = WebProcessRuntime::start(Arc::clone(&active_runtime));
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(capability);
|
||||||
|
let wrong_family = format!(
|
||||||
|
"GET /?bridge={encoded} HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 2001:db8::10\r\nConnection: close\r\n\r\n"
|
||||||
|
)
|
||||||
|
.into_bytes();
|
||||||
|
let wrong_family_response = request(&listener, &runtime, wrong_family).await;
|
||||||
|
let (_, wrong_family_body) = split_response(&wrong_family_response);
|
||||||
|
assert!(!wrong_family_body
|
||||||
|
.windows(11)
|
||||||
|
.any(|value| value == b"bootstrap='"));
|
||||||
|
let root = format!(
|
||||||
|
"GET /?bridge={encoded} HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nConnection: close\r\n\r\n"
|
||||||
|
)
|
||||||
|
.into_bytes();
|
||||||
|
let root_response = request(&listener, &runtime, root).await;
|
||||||
|
let (root_headers, root_body) = split_response(&root_response);
|
||||||
|
assert!(root_headers.starts_with(b"HTTP/1.1 200"));
|
||||||
|
let root_body = std::str::from_utf8(root_body).unwrap();
|
||||||
|
let bootstrap = root_body
|
||||||
|
.split_once("bootstrap='")
|
||||||
|
.and_then(|(_, suffix)| suffix.split_once('\''))
|
||||||
|
.map(|(token, _)| token)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(bootstrap.len(), 43);
|
||||||
|
|
||||||
|
let hello = frame::encode(FrameType::Hello, 0, &[1]);
|
||||||
|
let mut wrong_host = format!(
|
||||||
|
"POST /api/v1/session HTTP/1.1\r\nHost: other.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {bootstrap}\r\nContent-Type: application/octet-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||||
|
hello.len()
|
||||||
|
)
|
||||||
|
.into_bytes();
|
||||||
|
wrong_host.extend_from_slice(&hello);
|
||||||
|
let wrong_host_response = request(&listener, &runtime, wrong_host).await;
|
||||||
|
assert!(wrong_host_response.starts_with(b"HTTP/1.1 404"));
|
||||||
|
|
||||||
|
let mut create = format!(
|
||||||
|
"POST /api/v1/session HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {bootstrap}\r\nContent-Type: application/octet-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||||
|
hello.len()
|
||||||
|
)
|
||||||
|
.into_bytes();
|
||||||
|
let create_retry = create.clone();
|
||||||
|
create.extend_from_slice(&hello);
|
||||||
|
let mut create_retry = create_retry;
|
||||||
|
create_retry.extend_from_slice(&hello);
|
||||||
|
let create_response = request(&listener, &runtime, create).await;
|
||||||
|
let (create_headers, create_body) = split_response(&create_response);
|
||||||
|
assert!(create_headers.starts_with(b"HTTP/1.1 200"));
|
||||||
|
assert_eq!(response_header(create_headers, "x-carrier-mode"), "https");
|
||||||
|
assert_eq!(create_body, frame::encode(FrameType::Welcome, 0, &[]));
|
||||||
|
let session = response_header(create_headers, "x-session-token");
|
||||||
|
assert_eq!(session.len(), 43);
|
||||||
|
|
||||||
|
let replacement = test_runtime_generation(2, runtime_config(capability));
|
||||||
|
active_runtime.store(Arc::clone(&replacement));
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
|
||||||
|
let retry_response = request(&listener, &runtime, create_retry).await;
|
||||||
|
let (retry_headers, retry_body) = split_response(&retry_response);
|
||||||
|
assert!(retry_headers.starts_with(b"HTTP/1.1 200"));
|
||||||
|
assert_eq!(response_header(retry_headers, "x-session-token"), session);
|
||||||
|
assert_eq!(retry_body, frame::encode(FrameType::Welcome, 0, &[]));
|
||||||
|
|
||||||
|
let next_root = format!(
|
||||||
|
"GET /?bridge={encoded} HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nConnection: close\r\n\r\n"
|
||||||
|
)
|
||||||
|
.into_bytes();
|
||||||
|
let next_root_response = request(&listener, &runtime, next_root).await;
|
||||||
|
let (_, next_root_body) = split_response(&next_root_response);
|
||||||
|
assert!(next_root_body.windows(11).any(|value| value == b"bootstrap='"));
|
||||||
|
|
||||||
|
let close = format!(
|
||||||
|
"DELETE /api/v1/session HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {session}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||||
|
)
|
||||||
|
.into_bytes();
|
||||||
|
let close_retry = close.clone();
|
||||||
|
let close_response = request(&listener, &runtime, close).await;
|
||||||
|
assert!(close_response.starts_with(b"HTTP/1.1 204"));
|
||||||
|
let close_retry_response = request(&listener, &runtime, close_retry).await;
|
||||||
|
assert!(close_retry_response.starts_with(b"HTTP/1.1 204"));
|
||||||
|
|
||||||
|
runtime.shutdown().await;
|
||||||
|
generation.stop_sessions().await;
|
||||||
|
generation.stop_background_tasks().await;
|
||||||
|
replacement.stop_sessions().await;
|
||||||
|
replacement.stop_background_tasks().await;
|
||||||
|
}
|
||||||
@@ -0,0 +1,532 @@
|
|||||||
|
use std::future::Future;
|
||||||
|
use std::net::IpAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use arc_swap::ArcSwap;
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use subtle::ConstantTimeEq;
|
||||||
|
use tokio::sync::{Notify, OwnedSemaphorePermit, Semaphore};
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
use tokio_util::task::TaskTracker;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use crate::config::{WebLimitsConfig, WebRuntimeProfile};
|
||||||
|
use crate::maestro::generation::RuntimeGeneration;
|
||||||
|
use crate::web::frame;
|
||||||
|
use crate::web::session::WebSession;
|
||||||
|
|
||||||
|
// Credential maps, quotas, and token-bucket helpers remain private to the manager.
|
||||||
|
mod state;
|
||||||
|
// Stream admission and synthetic tuple ownership are process-scoped.
|
||||||
|
mod admission;
|
||||||
|
// Shutdown and expiry work remain outside request-path coordination.
|
||||||
|
mod lifecycle;
|
||||||
|
use state::{
|
||||||
|
Bootstrap, ManagerState, allow_rate, control_item_reserve, decrement_map,
|
||||||
|
evict_oldest_unused_bootstrap, matching_profile, new_unique_token, profile_key,
|
||||||
|
remove_expired_locked,
|
||||||
|
};
|
||||||
|
|
||||||
|
const TOKEN_BYTES: usize = 32;
|
||||||
|
const CLEANUP_INTERVAL: Duration = Duration::from_secs(1);
|
||||||
|
|
||||||
|
/// Stable hash key used for bootstrap and session credentials.
|
||||||
|
pub(crate) type TokenHash = [u8; TOKEN_BYTES];
|
||||||
|
/// Stable non-allocating key used for per-profile quotas.
|
||||||
|
pub(crate) type ProfileKey = [u8; TOKEN_BYTES];
|
||||||
|
|
||||||
|
/// WEB manager operation failure category.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub(crate) enum ManagerError {
|
||||||
|
/// Credential, hostname, or ownership validation failed.
|
||||||
|
Authentication,
|
||||||
|
/// Bounded queue capacity is temporarily unavailable.
|
||||||
|
Backpressure,
|
||||||
|
/// A configured admission or rate ceiling was reached.
|
||||||
|
Limit,
|
||||||
|
/// Carrier framing or sequencing violated the protocol.
|
||||||
|
Protocol,
|
||||||
|
/// The operation conflicts with another in-flight operation.
|
||||||
|
Concurrent,
|
||||||
|
/// The process or session has stopped accepting work.
|
||||||
|
Closed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Successful idempotent session creation result.
|
||||||
|
pub(crate) struct CreateResult {
|
||||||
|
/// Opaque bearer token for the created or replayed session.
|
||||||
|
pub(crate) token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process-owned bounded WEB credential, session, and memory coordinator.
|
||||||
|
pub(crate) struct WebProcessRuntime {
|
||||||
|
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
|
||||||
|
limits: WebLimitsConfig,
|
||||||
|
state: Mutex<ManagerState>,
|
||||||
|
http_connections: Arc<Semaphore>,
|
||||||
|
http_handlers: Arc<Semaphore>,
|
||||||
|
body_readers: Arc<Semaphore>,
|
||||||
|
body_bytes: Arc<Semaphore>,
|
||||||
|
stream_handshakes: Arc<Semaphore>,
|
||||||
|
budget_notify: Arc<Notify>,
|
||||||
|
budget_saturated: AtomicBool,
|
||||||
|
shutdown: CancellationToken,
|
||||||
|
tasks: TaskTracker,
|
||||||
|
sessions_created: AtomicU64,
|
||||||
|
sessions_closed: AtomicU64,
|
||||||
|
streams_opened: AtomicU64,
|
||||||
|
streams_rejected: AtomicU64,
|
||||||
|
bytes_up: AtomicU64,
|
||||||
|
bytes_down: AtomicU64,
|
||||||
|
limit_hits: AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WebProcessRuntime {
|
||||||
|
/// Starts one process-scoped manager using immutable allocation ceilings.
|
||||||
|
pub(crate) fn start(
|
||||||
|
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
|
||||||
|
) -> Arc<Self> {
|
||||||
|
let limits = active_runtime.load().config().web.limits.clone();
|
||||||
|
let runtime = Arc::new(Self {
|
||||||
|
active_runtime,
|
||||||
|
http_connections: Arc::new(Semaphore::new(limits.max_http_connections)),
|
||||||
|
http_handlers: Arc::new(Semaphore::new(limits.max_http_handlers)),
|
||||||
|
body_readers: Arc::new(Semaphore::new(limits.max_body_readers)),
|
||||||
|
body_bytes: Arc::new(Semaphore::new(limits.max_body_bytes_global)),
|
||||||
|
stream_handshakes: Arc::new(Semaphore::new(limits.max_stream_handshakes)),
|
||||||
|
limits,
|
||||||
|
state: Mutex::new(ManagerState::default()),
|
||||||
|
budget_notify: Arc::new(Notify::new()),
|
||||||
|
budget_saturated: AtomicBool::new(false),
|
||||||
|
shutdown: CancellationToken::new(),
|
||||||
|
tasks: TaskTracker::new(),
|
||||||
|
sessions_created: AtomicU64::new(0),
|
||||||
|
sessions_closed: AtomicU64::new(0),
|
||||||
|
streams_opened: AtomicU64::new(0),
|
||||||
|
streams_rejected: AtomicU64::new(0),
|
||||||
|
bytes_up: AtomicU64::new(0),
|
||||||
|
bytes_down: AtomicU64::new(0),
|
||||||
|
limit_hits: AtomicU64::new(0),
|
||||||
|
});
|
||||||
|
let weak = Arc::downgrade(&runtime);
|
||||||
|
let shutdown = runtime.shutdown.clone();
|
||||||
|
runtime.tasks.spawn(async move {
|
||||||
|
let mut interval = tokio::time::interval(CLEANUP_INTERVAL);
|
||||||
|
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = shutdown.cancelled() => break,
|
||||||
|
_ = interval.tick() => {
|
||||||
|
let Some(runtime) = weak.upgrade() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
runtime.cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
runtime
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads the currently active generation without retaining older generations.
|
||||||
|
pub(crate) fn active_generation(&self) -> Arc<RuntimeGeneration> {
|
||||||
|
self.active_runtime.load_full()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reserves one accepted HTTP connection.
|
||||||
|
pub(crate) fn try_http_connection(&self) -> Option<OwnedSemaphorePermit> {
|
||||||
|
let permit = Arc::clone(&self.http_connections).try_acquire_owned().ok();
|
||||||
|
if permit.is_none() {
|
||||||
|
self.record_limit_hit();
|
||||||
|
}
|
||||||
|
permit
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reserves one concurrently executing HTTP request handler.
|
||||||
|
pub(crate) fn try_http_handler(&self) -> Option<OwnedSemaphorePermit> {
|
||||||
|
let permit = Arc::clone(&self.http_handlers).try_acquire_owned().ok();
|
||||||
|
if permit.is_none() {
|
||||||
|
self.record_limit_hit();
|
||||||
|
}
|
||||||
|
permit
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reserves one logical stream in the inner MTProxy handshake phase.
|
||||||
|
pub(crate) fn try_stream_handshake(&self) -> Option<OwnedSemaphorePermit> {
|
||||||
|
let permit = Arc::clone(&self.stream_handshakes)
|
||||||
|
.try_acquire_owned()
|
||||||
|
.ok();
|
||||||
|
if permit.is_none() {
|
||||||
|
self.record_stream_rejected();
|
||||||
|
}
|
||||||
|
permit
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns one process-owned auxiliary task with shutdown cancellation.
|
||||||
|
pub(crate) fn spawn_auxiliary<F>(&self, future: F)
|
||||||
|
where
|
||||||
|
F: Future<Output = ()> + Send + 'static,
|
||||||
|
{
|
||||||
|
let shutdown = self.shutdown.clone();
|
||||||
|
self.tasks.spawn(async move {
|
||||||
|
tokio::select! {
|
||||||
|
_ = shutdown.cancelled() => {}
|
||||||
|
_ = future => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reserves one body reader and its declared bounded body allocation.
|
||||||
|
pub(crate) fn try_body_budget(
|
||||||
|
&self,
|
||||||
|
bytes: usize,
|
||||||
|
) -> Option<(OwnedSemaphorePermit, OwnedSemaphorePermit)> {
|
||||||
|
let Some(bytes) = u32::try_from(bytes).ok() else {
|
||||||
|
self.record_limit_hit();
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let Some(reader) = Arc::clone(&self.body_readers).try_acquire_owned().ok() else {
|
||||||
|
self.record_limit_hit();
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let Some(body) = Arc::clone(&self.body_bytes)
|
||||||
|
.try_acquire_many_owned(bytes)
|
||||||
|
.ok()
|
||||||
|
else {
|
||||||
|
self.record_limit_hit();
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
Some((reader, body))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Issues a one-use bootstrap credential for the active generation.
|
||||||
|
pub(crate) fn issue_bootstrap(
|
||||||
|
&self,
|
||||||
|
profile: Arc<WebRuntimeProfile>,
|
||||||
|
client_ip: IpAddr,
|
||||||
|
) -> std::result::Result<String, ManagerError> {
|
||||||
|
let generation = self.active_generation();
|
||||||
|
let config = generation.config();
|
||||||
|
let profile = config
|
||||||
|
.web
|
||||||
|
.runtime
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|runtime| matching_profile(runtime, &profile))
|
||||||
|
.ok_or(ManagerError::Authentication)?;
|
||||||
|
if !config.web.enabled
|
||||||
|
|| profile.public_addr.is_ipv4() != client_ip.is_ipv4()
|
||||||
|
|| !generation.proxy_shared.is_user_enabled(&profile.user)
|
||||||
|
{
|
||||||
|
return Err(ManagerError::Closed);
|
||||||
|
}
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut state = self.state.lock();
|
||||||
|
remove_expired_locked(&mut state, now);
|
||||||
|
if state.closed
|
||||||
|
|| state.bootstraps_per_ip.get(&client_ip).copied().unwrap_or(0)
|
||||||
|
>= self.limits.max_bootstraps_per_ip
|
||||||
|
|| !allow_rate(
|
||||||
|
&mut state.bootstrap_rate,
|
||||||
|
now,
|
||||||
|
self.limits.new_bootstraps_per_minute,
|
||||||
|
self.limits.new_bootstraps_burst,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
self.limit_hits.fetch_add(1, Ordering::Relaxed);
|
||||||
|
return Err(ManagerError::Limit);
|
||||||
|
}
|
||||||
|
if state.bootstraps.len() >= self.limits.max_bootstraps_global
|
||||||
|
&& !evict_oldest_unused_bootstrap(&mut state)
|
||||||
|
{
|
||||||
|
self.limit_hits.fetch_add(1, Ordering::Relaxed);
|
||||||
|
return Err(ManagerError::Limit);
|
||||||
|
}
|
||||||
|
let Some((token, hash)) = new_unique_token(&generation, &state) else {
|
||||||
|
self.limit_hits.fetch_add(1, Ordering::Relaxed);
|
||||||
|
return Err(ManagerError::Limit);
|
||||||
|
};
|
||||||
|
state.bootstraps.insert(
|
||||||
|
hash,
|
||||||
|
Bootstrap {
|
||||||
|
generation_id: generation.id,
|
||||||
|
expires_at: now + Duration::from_secs(config.web.timeouts.bootstrap_lifetime_secs),
|
||||||
|
issued_at: now,
|
||||||
|
issuance_ip: client_ip,
|
||||||
|
profile,
|
||||||
|
body_digest: [0; TOKEN_BYTES],
|
||||||
|
session_token: Zeroizing::new(String::new()),
|
||||||
|
session: None,
|
||||||
|
used: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
*state.bootstraps_per_ip.entry(client_ip).or_insert(0) += 1;
|
||||||
|
Ok(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks whether a bootstrap token is live before reading a request body.
|
||||||
|
pub(crate) fn has_bootstrap(&self, hash: TokenHash, host: &str) -> bool {
|
||||||
|
let generation_id = self.active_runtime.load().id;
|
||||||
|
let now = Instant::now();
|
||||||
|
let state = self.state.lock();
|
||||||
|
state.bootstraps.get(&hash).is_some_and(|entry| {
|
||||||
|
entry.profile.host == host
|
||||||
|
&& now <= entry.expires_at
|
||||||
|
&& (entry.generation_id == generation_id
|
||||||
|
|| entry.used && entry.session.is_some())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a session exactly once or replays the original successful result.
|
||||||
|
pub(crate) fn create_session(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
bootstrap_hash: TokenHash,
|
||||||
|
host: &str,
|
||||||
|
client_ip: IpAddr,
|
||||||
|
body: &[u8],
|
||||||
|
) -> std::result::Result<CreateResult, ManagerError> {
|
||||||
|
if !frame::validate_hello(body, &self.limits) {
|
||||||
|
return Err(ManagerError::Protocol);
|
||||||
|
}
|
||||||
|
let body_digest: TokenHash = Sha256::digest(body).into();
|
||||||
|
let generation = self.active_generation();
|
||||||
|
let config = generation.config();
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut state = self.state.lock();
|
||||||
|
remove_expired_locked(&mut state, now);
|
||||||
|
let Some(entry) = state.bootstraps.get(&bootstrap_hash) else {
|
||||||
|
return Err(ManagerError::Authentication);
|
||||||
|
};
|
||||||
|
if entry.profile.host != host || now > entry.expires_at {
|
||||||
|
return Err(ManagerError::Authentication);
|
||||||
|
}
|
||||||
|
if entry.used {
|
||||||
|
let digest_matches = bool::from(entry.body_digest.ct_eq(&body_digest));
|
||||||
|
if !digest_matches {
|
||||||
|
return Err(ManagerError::Authentication);
|
||||||
|
}
|
||||||
|
if entry.session.is_none() {
|
||||||
|
return Err(ManagerError::Authentication);
|
||||||
|
}
|
||||||
|
return Ok(CreateResult {
|
||||||
|
token: entry.session_token.as_str().to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if entry.generation_id != generation.id {
|
||||||
|
return Err(ManagerError::Authentication);
|
||||||
|
}
|
||||||
|
if state.closed || !config.web.enabled {
|
||||||
|
return Err(ManagerError::Closed);
|
||||||
|
}
|
||||||
|
let profile = config
|
||||||
|
.web
|
||||||
|
.runtime
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|runtime| matching_profile(runtime, &entry.profile))
|
||||||
|
.filter(|profile| {
|
||||||
|
profile.public_addr.is_ipv4() == client_ip.is_ipv4()
|
||||||
|
&& generation.proxy_shared.is_user_enabled(&profile.user)
|
||||||
|
})
|
||||||
|
.ok_or(ManagerError::Authentication)?;
|
||||||
|
let profile_key = profile_key(&profile);
|
||||||
|
if state.sessions.len() >= self.limits.max_sessions_global
|
||||||
|
|| state.sessions_per_ip.get(&client_ip).copied().unwrap_or(0)
|
||||||
|
>= self.limits.max_sessions_per_ip
|
||||||
|
|| state
|
||||||
|
.sessions_per_profile
|
||||||
|
.get(&profile_key)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(0)
|
||||||
|
>= profile.max_sessions
|
||||||
|
|| !allow_rate(
|
||||||
|
&mut state.session_rate,
|
||||||
|
now,
|
||||||
|
self.limits.new_sessions_per_minute,
|
||||||
|
self.limits.new_sessions_burst,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
self.limit_hits.fetch_add(1, Ordering::Relaxed);
|
||||||
|
return Err(ManagerError::Limit);
|
||||||
|
}
|
||||||
|
let Some((session_token, session_hash)) = new_unique_token(&generation, &state) else {
|
||||||
|
self.limit_hits.fetch_add(1, Ordering::Relaxed);
|
||||||
|
return Err(ManagerError::Limit);
|
||||||
|
};
|
||||||
|
let session = WebSession::new(
|
||||||
|
Arc::downgrade(self),
|
||||||
|
session_hash,
|
||||||
|
client_ip,
|
||||||
|
profile,
|
||||||
|
profile_key,
|
||||||
|
self.limits.clone(),
|
||||||
|
config.web.timeouts.clone(),
|
||||||
|
);
|
||||||
|
state.sessions.insert(session_hash, Arc::clone(&session));
|
||||||
|
*state.sessions_per_ip.entry(client_ip).or_insert(0) += 1;
|
||||||
|
*state.sessions_per_profile.entry(profile_key).or_insert(0) += 1;
|
||||||
|
let entry = state
|
||||||
|
.bootstraps
|
||||||
|
.get_mut(&bootstrap_hash)
|
||||||
|
.ok_or(ManagerError::Authentication)?;
|
||||||
|
entry.used = true;
|
||||||
|
entry.body_digest = body_digest;
|
||||||
|
entry.session_token = Zeroizing::new(session_token.clone());
|
||||||
|
entry.session = Some(Arc::clone(&session));
|
||||||
|
let issuance_ip = entry.issuance_ip;
|
||||||
|
decrement_map(&mut state.bootstraps_per_ip, &issuance_ip);
|
||||||
|
self.sessions_created.fetch_add(1, Ordering::Relaxed);
|
||||||
|
Ok(CreateResult {
|
||||||
|
token: session_token,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves an authenticated session token.
|
||||||
|
pub(crate) fn get_session(
|
||||||
|
&self,
|
||||||
|
hash: TokenHash,
|
||||||
|
host: &str,
|
||||||
|
) -> std::result::Result<Arc<WebSession>, ManagerError> {
|
||||||
|
self.state
|
||||||
|
.lock()
|
||||||
|
.sessions
|
||||||
|
.get(&hash)
|
||||||
|
.cloned()
|
||||||
|
.filter(|session| session.matches_host(host))
|
||||||
|
.ok_or(ManagerError::Authentication)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closes a live token and accepts bounded tombstone retries.
|
||||||
|
pub(crate) fn close_token(
|
||||||
|
&self,
|
||||||
|
hash: TokenHash,
|
||||||
|
host: &str,
|
||||||
|
) -> std::result::Result<(), ManagerError> {
|
||||||
|
let state = self.state.lock();
|
||||||
|
let session = state
|
||||||
|
.sessions
|
||||||
|
.get(&hash)
|
||||||
|
.filter(|session| session.matches_host(host))
|
||||||
|
.cloned();
|
||||||
|
let closed = state
|
||||||
|
.closed_tokens
|
||||||
|
.get(&hash)
|
||||||
|
.is_some_and(|closed| closed.host == host);
|
||||||
|
drop(state);
|
||||||
|
if let Some(session) = session {
|
||||||
|
session.close();
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
closed.then_some(()).ok_or(ManagerError::Authentication)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reserves bounded process-wide queue capacity for data or control traffic.
|
||||||
|
pub(crate) fn try_reserve_pending(
|
||||||
|
&self,
|
||||||
|
bytes: usize,
|
||||||
|
items: usize,
|
||||||
|
control: bool,
|
||||||
|
downlink: bool,
|
||||||
|
) -> bool {
|
||||||
|
let mut state = self.state.lock();
|
||||||
|
let data_byte_limit = self
|
||||||
|
.limits
|
||||||
|
.pending_bytes_global
|
||||||
|
.saturating_sub(self.limits.control_bytes_global);
|
||||||
|
let control_item_reserve = control_item_reserve(&self.limits);
|
||||||
|
let data_item_limit = self
|
||||||
|
.limits
|
||||||
|
.pending_items_global
|
||||||
|
.saturating_sub(control_item_reserve);
|
||||||
|
if state.closed {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let fits = if control {
|
||||||
|
bytes <= self.limits.control_bytes_global
|
||||||
|
&& items <= control_item_reserve
|
||||||
|
&& state.pending_bytes
|
||||||
|
<= self.limits.pending_bytes_global.saturating_sub(bytes)
|
||||||
|
&& state.pending_items
|
||||||
|
<= self.limits.pending_items_global.saturating_sub(items)
|
||||||
|
&& state.pending_control_bytes
|
||||||
|
<= self.limits.control_bytes_global.saturating_sub(bytes)
|
||||||
|
&& state.pending_control_items
|
||||||
|
<= control_item_reserve.saturating_sub(items)
|
||||||
|
} else {
|
||||||
|
let data_bytes = state
|
||||||
|
.pending_bytes
|
||||||
|
.saturating_sub(state.pending_control_bytes);
|
||||||
|
let data_items = state
|
||||||
|
.pending_items
|
||||||
|
.saturating_sub(state.pending_control_items);
|
||||||
|
let (byte_limit, item_limit) = if downlink {
|
||||||
|
let uplink_bytes = self
|
||||||
|
.limits
|
||||||
|
.max_body_bytes
|
||||||
|
.saturating_add(
|
||||||
|
self.limits
|
||||||
|
.max_frames_per_body
|
||||||
|
.saturating_mul(crate::web::session::QUEUE_ITEM_COST),
|
||||||
|
);
|
||||||
|
(
|
||||||
|
data_byte_limit.saturating_sub(uplink_bytes),
|
||||||
|
data_item_limit.saturating_sub(self.limits.max_frames_per_body),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(data_byte_limit, data_item_limit)
|
||||||
|
};
|
||||||
|
bytes <= byte_limit
|
||||||
|
&& items <= item_limit
|
||||||
|
&& data_bytes <= byte_limit - bytes
|
||||||
|
&& data_items <= item_limit - items
|
||||||
|
};
|
||||||
|
if !fits {
|
||||||
|
self.budget_saturated.store(true, Ordering::Release);
|
||||||
|
self.record_limit_hit();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
state.pending_bytes += bytes;
|
||||||
|
state.pending_items += items;
|
||||||
|
if control {
|
||||||
|
state.pending_control_bytes += bytes;
|
||||||
|
state.pending_control_items += items;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Releases process-wide queue capacity and wakes blocked relay writers.
|
||||||
|
pub(crate) fn release_pending(&self, bytes: usize, items: usize, control: bool) {
|
||||||
|
let mut state = self.state.lock();
|
||||||
|
state.pending_bytes = state.pending_bytes.saturating_sub(bytes);
|
||||||
|
state.pending_items = state.pending_items.saturating_sub(items);
|
||||||
|
if control {
|
||||||
|
state.pending_control_bytes = state.pending_control_bytes.saturating_sub(bytes);
|
||||||
|
state.pending_control_items = state.pending_control_items.saturating_sub(items);
|
||||||
|
}
|
||||||
|
drop(state);
|
||||||
|
if self.budget_saturated.swap(false, Ordering::AcqRel) {
|
||||||
|
self.budget_notify.notify_waiters();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the shared notification source for global queue capacity changes.
|
||||||
|
pub(crate) fn budget_notify(&self) -> Arc<Notify> {
|
||||||
|
Arc::clone(&self.budget_notify)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accounts one successfully committed carrier uplink body.
|
||||||
|
pub(crate) fn record_up(&self, bytes: usize) {
|
||||||
|
self.bytes_up.fetch_add(bytes as u64, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accounts one emitted carrier downlink body.
|
||||||
|
pub(crate) fn record_down(&self, bytes: usize) {
|
||||||
|
self.bytes_down.fetch_add(bytes as u64, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_limit_hit(&self) {
|
||||||
|
self.limit_hits.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
use std::net::{IpAddr, SocketAddr};
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use super::state::{
|
||||||
|
allocate_stream_port, allow_rate, decrement_map, release_stream_port,
|
||||||
|
};
|
||||||
|
use super::{ProfileKey, WebProcessRuntime};
|
||||||
|
|
||||||
|
impl WebProcessRuntime {
|
||||||
|
/// Reserves one process-wide and per-profile live logical-stream slot.
|
||||||
|
pub(crate) fn try_acquire_stream(
|
||||||
|
&self,
|
||||||
|
profile_key: ProfileKey,
|
||||||
|
max_streams: usize,
|
||||||
|
client_ip: IpAddr,
|
||||||
|
public_addr: SocketAddr,
|
||||||
|
) -> Option<u16> {
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut state = self.state.lock();
|
||||||
|
if state.closed
|
||||||
|
|| state.streams_live >= self.limits.max_streams_global
|
||||||
|
|| state
|
||||||
|
.streams_per_profile
|
||||||
|
.get(&profile_key)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(0)
|
||||||
|
>= max_streams
|
||||||
|
|| !allow_rate(
|
||||||
|
&mut state.stream_rate,
|
||||||
|
now,
|
||||||
|
self.limits.new_streams_per_minute,
|
||||||
|
self.limits.new_streams_burst,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
self.streams_rejected.fetch_add(1, Ordering::Relaxed);
|
||||||
|
self.limit_hits.fetch_add(1, Ordering::Relaxed);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let Some(peer_port) = allocate_stream_port(&mut state, client_ip, public_addr) else {
|
||||||
|
self.streams_rejected.fetch_add(1, Ordering::Relaxed);
|
||||||
|
self.limit_hits.fetch_add(1, Ordering::Relaxed);
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
state.streams_live += 1;
|
||||||
|
*state
|
||||||
|
.streams_per_profile
|
||||||
|
.entry(profile_key)
|
||||||
|
.or_insert(0) += 1;
|
||||||
|
self.streams_opened.fetch_add(1, Ordering::Relaxed);
|
||||||
|
Some(peer_port)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Releases one live logical-stream slot after its relay task exits.
|
||||||
|
pub(crate) fn release_stream(
|
||||||
|
&self,
|
||||||
|
profile_key: ProfileKey,
|
||||||
|
client_ip: IpAddr,
|
||||||
|
public_addr: SocketAddr,
|
||||||
|
peer_port: u16,
|
||||||
|
) {
|
||||||
|
let mut state = self.state.lock();
|
||||||
|
if !release_stream_port(&mut state, client_ip, public_addr, peer_port) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.streams_live = state.streams_live.saturating_sub(1);
|
||||||
|
decrement_map(&mut state.streams_per_profile, &profile_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records a logical stream rejected outside manager quota acquisition.
|
||||||
|
pub(crate) fn record_stream_rejected(&self) {
|
||||||
|
self.streams_rejected.fetch_add(1, Ordering::Relaxed);
|
||||||
|
self.record_limit_hit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use arc_swap::ArcSwap;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::config::ProxyConfig;
|
||||||
|
use crate::maestro::generation::test_runtime_generation;
|
||||||
|
use crate::web::session::QUEUE_ITEM_COST;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn global_downlink_budget_preserves_one_maximum_uplink_batch() {
|
||||||
|
let generation = test_runtime_generation(1, ProxyConfig::default());
|
||||||
|
let runtime = WebProcessRuntime::start(Arc::new(ArcSwap::from(generation)));
|
||||||
|
let control_items = super::super::state::control_item_reserve(&runtime.limits);
|
||||||
|
let data_bytes = runtime
|
||||||
|
.limits
|
||||||
|
.pending_bytes_global
|
||||||
|
.saturating_sub(runtime.limits.control_bytes_global);
|
||||||
|
let data_items = runtime
|
||||||
|
.limits
|
||||||
|
.pending_items_global
|
||||||
|
.saturating_sub(control_items);
|
||||||
|
let uplink_bytes = runtime
|
||||||
|
.limits
|
||||||
|
.max_body_bytes
|
||||||
|
.saturating_add(runtime.limits.max_frames_per_body * QUEUE_ITEM_COST);
|
||||||
|
let downlink_bytes = data_bytes - uplink_bytes;
|
||||||
|
let downlink_items = data_items - runtime.limits.max_frames_per_body;
|
||||||
|
|
||||||
|
assert!(runtime.try_reserve_pending(
|
||||||
|
downlink_bytes,
|
||||||
|
downlink_items,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
));
|
||||||
|
assert!(runtime.try_reserve_pending(
|
||||||
|
uplink_bytes,
|
||||||
|
runtime.limits.max_frames_per_body,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
assert!(!runtime.try_reserve_pending(1, 1, false, true));
|
||||||
|
|
||||||
|
runtime.release_pending(downlink_bytes, downlink_items, false);
|
||||||
|
runtime.release_pending(
|
||||||
|
uplink_bytes,
|
||||||
|
runtime.limits.max_frames_per_body,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
runtime.shutdown().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
use std::net::IpAddr;
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
use super::{ProfileKey, TokenHash, WebProcessRuntime};
|
||||||
|
use super::state::{
|
||||||
|
ClosedToken, decrement_map, remove_bootstrap_locked, remove_expired_locked,
|
||||||
|
};
|
||||||
|
|
||||||
|
impl WebProcessRuntime {
|
||||||
|
/// Removes one closed session and retains a bounded host-bound replay marker.
|
||||||
|
pub(crate) fn session_finished(
|
||||||
|
&self,
|
||||||
|
hash: TokenHash,
|
||||||
|
client_ip: IpAddr,
|
||||||
|
profile_key: ProfileKey,
|
||||||
|
profile_host: &str,
|
||||||
|
) {
|
||||||
|
let mut state = self.state.lock();
|
||||||
|
if state.sessions.remove(&hash).is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
decrement_map(&mut state.sessions_per_ip, &client_ip);
|
||||||
|
decrement_map(&mut state.sessions_per_profile, &profile_key);
|
||||||
|
let expiry = Instant::now()
|
||||||
|
+ Duration::from_secs(
|
||||||
|
self.active_runtime
|
||||||
|
.load()
|
||||||
|
.config()
|
||||||
|
.web
|
||||||
|
.timeouts
|
||||||
|
.bootstrap_lifetime_secs,
|
||||||
|
);
|
||||||
|
state.closed_tokens.insert(
|
||||||
|
hash,
|
||||||
|
ClosedToken {
|
||||||
|
expires_at: expiry,
|
||||||
|
host: profile_host.to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
while state.closed_tokens.len() > self.limits.max_sessions_global.saturating_mul(16) {
|
||||||
|
let Some(oldest) = state
|
||||||
|
.closed_tokens
|
||||||
|
.iter()
|
||||||
|
.min_by_key(|(_, closed)| closed.expires_at)
|
||||||
|
.map(|(hash, _)| *hash)
|
||||||
|
else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
state.closed_tokens.remove(&oldest);
|
||||||
|
}
|
||||||
|
let bootstrap_hashes = state
|
||||||
|
.bootstraps
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(bootstrap_hash, bootstrap)| {
|
||||||
|
bootstrap
|
||||||
|
.session
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|session| session.token_hash() == hash)
|
||||||
|
.then_some(*bootstrap_hash)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
for bootstrap_hash in bootstrap_hashes {
|
||||||
|
remove_bootstrap_locked(&mut state, bootstrap_hash);
|
||||||
|
}
|
||||||
|
self.sessions_closed.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops issuance, closes all sessions, and joins bounded child work.
|
||||||
|
pub(crate) async fn shutdown(&self) {
|
||||||
|
self.shutdown.cancel();
|
||||||
|
let sessions = {
|
||||||
|
let mut state = self.state.lock();
|
||||||
|
state.closed = true;
|
||||||
|
state.bootstraps.clear();
|
||||||
|
state.bootstraps_per_ip.clear();
|
||||||
|
state.sessions.values().cloned().collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
for session in &sessions {
|
||||||
|
session.close();
|
||||||
|
}
|
||||||
|
let timeout_secs = self
|
||||||
|
.active_runtime
|
||||||
|
.load()
|
||||||
|
.config()
|
||||||
|
.web
|
||||||
|
.timeouts
|
||||||
|
.shutdown_secs;
|
||||||
|
let waits = async {
|
||||||
|
for session in sessions {
|
||||||
|
session.wait().await;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = tokio::time::timeout(Duration::from_secs(timeout_secs), waits).await;
|
||||||
|
self.tasks.close();
|
||||||
|
let _ = tokio::time::timeout(Duration::from_secs(timeout_secs), self.tasks.wait()).await;
|
||||||
|
let (sessions_live, streams_live, pending_bytes, pending_items) = {
|
||||||
|
let state = self.state.lock();
|
||||||
|
(
|
||||||
|
state.sessions.len(),
|
||||||
|
state.streams_live,
|
||||||
|
state.pending_bytes,
|
||||||
|
state.pending_items,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
info!(
|
||||||
|
target: "telemt::web",
|
||||||
|
sessions_created = self.sessions_created.load(Ordering::Relaxed),
|
||||||
|
sessions_closed = self.sessions_closed.load(Ordering::Relaxed),
|
||||||
|
sessions_live,
|
||||||
|
streams_opened = self.streams_opened.load(Ordering::Relaxed),
|
||||||
|
streams_rejected = self.streams_rejected.load(Ordering::Relaxed),
|
||||||
|
streams_live,
|
||||||
|
pending_bytes,
|
||||||
|
pending_items,
|
||||||
|
bytes_up = self.bytes_up.load(Ordering::Relaxed),
|
||||||
|
bytes_down = self.bytes_down.load(Ordering::Relaxed),
|
||||||
|
limit_hits = self.limit_hits.load(Ordering::Relaxed),
|
||||||
|
"WEB runtime stopped"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expires credentials and closes idle sessions without holding locks across callbacks.
|
||||||
|
pub(super) fn cleanup(&self) {
|
||||||
|
let generation_id = self.active_runtime.load().id;
|
||||||
|
let now = Instant::now();
|
||||||
|
let sessions = {
|
||||||
|
let mut state = self.state.lock();
|
||||||
|
remove_expired_locked(&mut state, now);
|
||||||
|
let stale_bootstraps = state
|
||||||
|
.bootstraps
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(hash, bootstrap)| {
|
||||||
|
(bootstrap.generation_id != generation_id && !bootstrap.used)
|
||||||
|
.then_some(*hash)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
for hash in stale_bootstraps {
|
||||||
|
remove_bootstrap_locked(&mut state, hash);
|
||||||
|
}
|
||||||
|
state.sessions.values().cloned().collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
for session in sessions.into_iter().filter(|session| session.is_idle(now)) {
|
||||||
|
session.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::net::{IpAddr, SocketAddr};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use base64::Engine as _;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use super::{ProfileKey, TOKEN_BYTES, TokenHash};
|
||||||
|
use crate::config::{WebLimitsConfig, WebRuntimeConfig, WebRuntimeProfile};
|
||||||
|
use crate::maestro::generation::RuntimeGeneration;
|
||||||
|
use crate::web::session::WebSession;
|
||||||
|
|
||||||
|
/// One issued bootstrap and optional idempotent session-creation replay state.
|
||||||
|
pub(super) struct Bootstrap {
|
||||||
|
/// Generation that issued the bootstrap.
|
||||||
|
pub(super) generation_id: u64,
|
||||||
|
/// Credential and replay-state expiry deadline.
|
||||||
|
pub(super) expires_at: Instant,
|
||||||
|
/// Stable ordering point used for bounded eviction.
|
||||||
|
pub(super) issued_at: Instant,
|
||||||
|
/// Forwarded client address that owns this credential.
|
||||||
|
pub(super) issuance_ip: IpAddr,
|
||||||
|
/// Immutable profile selected during capability validation.
|
||||||
|
pub(super) profile: Arc<WebRuntimeProfile>,
|
||||||
|
/// Digest of the accepted HELLO body for idempotent retry matching.
|
||||||
|
pub(super) body_digest: TokenHash,
|
||||||
|
/// Zeroizing copy returned only for an exact session-creation retry.
|
||||||
|
pub(super) session_token: Zeroizing<String>,
|
||||||
|
/// Created session retained while retry replay remains valid.
|
||||||
|
pub(super) session: Option<Arc<WebSession>>,
|
||||||
|
/// Distinguishes unused issuance quota from completed creation replay state.
|
||||||
|
pub(super) used: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounded replay marker for one explicitly or naturally closed session token.
|
||||||
|
pub(super) struct ClosedToken {
|
||||||
|
/// Deadline after which the token hash may be forgotten.
|
||||||
|
pub(super) expires_at: Instant,
|
||||||
|
/// Canonical host that owned the session.
|
||||||
|
pub(super) host: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Token-bucket state for one process-wide creation class.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(super) struct RateState {
|
||||||
|
tokens: f64,
|
||||||
|
last: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StreamPortState {
|
||||||
|
active: HashSet<u16>,
|
||||||
|
next: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process-wide WEB registries and quota accounting protected by one short lock.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(super) struct ManagerState {
|
||||||
|
/// Bootstrap credentials indexed by their SHA-256 token hash.
|
||||||
|
pub(super) bootstraps: HashMap<TokenHash, Bootstrap>,
|
||||||
|
/// Unused bootstrap ownership counts by forwarded client address.
|
||||||
|
pub(super) bootstraps_per_ip: HashMap<IpAddr, usize>,
|
||||||
|
/// Live sessions indexed by bearer-token hash.
|
||||||
|
pub(super) sessions: HashMap<TokenHash, Arc<WebSession>>,
|
||||||
|
/// Recently closed token hashes retained for idempotent DELETE semantics.
|
||||||
|
pub(super) closed_tokens: HashMap<TokenHash, ClosedToken>,
|
||||||
|
/// Live session counts by forwarded client address.
|
||||||
|
pub(super) sessions_per_ip: HashMap<IpAddr, usize>,
|
||||||
|
/// Live session counts by stable profile key.
|
||||||
|
pub(super) sessions_per_profile: HashMap<ProfileKey, usize>,
|
||||||
|
/// Live relay-task counts by stable profile key.
|
||||||
|
pub(super) streams_per_profile: HashMap<ProfileKey, usize>,
|
||||||
|
/// Process-wide live relay-task count.
|
||||||
|
pub(super) streams_live: usize,
|
||||||
|
stream_ports: HashMap<(IpAddr, SocketAddr), StreamPortState>,
|
||||||
|
/// Total process-wide queued byte reservation.
|
||||||
|
pub(super) pending_bytes: usize,
|
||||||
|
/// Total process-wide queued item reservation.
|
||||||
|
pub(super) pending_items: usize,
|
||||||
|
/// Portion of queued bytes charged to the control reserve.
|
||||||
|
pub(super) pending_control_bytes: usize,
|
||||||
|
/// Portion of queued items charged to the control reserve.
|
||||||
|
pub(super) pending_control_items: usize,
|
||||||
|
/// Bootstrap issuance rate limiter.
|
||||||
|
pub(super) bootstrap_rate: RateState,
|
||||||
|
/// Session creation rate limiter.
|
||||||
|
pub(super) session_rate: RateState,
|
||||||
|
/// Logical-stream creation rate limiter.
|
||||||
|
pub(super) stream_rate: RateState,
|
||||||
|
/// Process shutdown admission latch.
|
||||||
|
pub(super) closed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generates one collision-checked credential and its stable hash key.
|
||||||
|
pub(super) fn new_unique_token(
|
||||||
|
generation: &RuntimeGeneration,
|
||||||
|
state: &ManagerState,
|
||||||
|
) -> Option<(String, TokenHash)> {
|
||||||
|
for _ in 0..8 {
|
||||||
|
let mut raw = [0u8; TOKEN_BYTES];
|
||||||
|
generation.rng.fill(&mut raw);
|
||||||
|
let token = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw);
|
||||||
|
let hash = Sha256::digest(raw).into();
|
||||||
|
if !state.bootstraps.contains_key(&hash)
|
||||||
|
&& !state.sessions.contains_key(&hash)
|
||||||
|
&& !state.closed_tokens.contains_key(&hash)
|
||||||
|
{
|
||||||
|
return Some((token, hash));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the precomputed capability as the stable process profile key.
|
||||||
|
pub(super) fn profile_key(profile: &WebRuntimeProfile) -> ProfileKey {
|
||||||
|
profile.capability
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-resolves an issued profile against the active generation without weakening identity.
|
||||||
|
pub(super) fn matching_profile(
|
||||||
|
runtime: &WebRuntimeConfig,
|
||||||
|
expected: &WebRuntimeProfile,
|
||||||
|
) -> Option<Arc<WebRuntimeProfile>> {
|
||||||
|
runtime
|
||||||
|
.profiles
|
||||||
|
.iter()
|
||||||
|
.find(|profile| {
|
||||||
|
profile.host == expected.host
|
||||||
|
&& profile.public_addr == expected.public_addr
|
||||||
|
&& profile.user == expected.user
|
||||||
|
&& profile.secret_mode == expected.secret_mode
|
||||||
|
&& profile.capability == expected.capability
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies one token-bucket admission decision at a caller-supplied monotonic time.
|
||||||
|
pub(super) fn allow_rate(
|
||||||
|
state: &mut RateState,
|
||||||
|
now: Instant,
|
||||||
|
per_minute: u32,
|
||||||
|
burst: u32,
|
||||||
|
) -> bool {
|
||||||
|
let burst = f64::from(burst);
|
||||||
|
if let Some(last) = state.last {
|
||||||
|
let elapsed = now.saturating_duration_since(last).as_secs_f64();
|
||||||
|
state.tokens =
|
||||||
|
(state.tokens + elapsed * f64::from(per_minute) / 60.0).min(burst);
|
||||||
|
} else {
|
||||||
|
state.tokens = burst;
|
||||||
|
}
|
||||||
|
state.last = Some(now);
|
||||||
|
if state.tokens < 1.0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
state.tokens -= 1.0;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evicts the oldest unused bootstrap while preserving used retry state.
|
||||||
|
pub(super) fn evict_oldest_unused_bootstrap(state: &mut ManagerState) -> bool {
|
||||||
|
let Some(hash) = state
|
||||||
|
.bootstraps
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, bootstrap)| !bootstrap.used)
|
||||||
|
.min_by_key(|(_, bootstrap)| bootstrap.issued_at)
|
||||||
|
.map(|(hash, _)| *hash)
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
remove_bootstrap_locked(state, hash);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes expired bootstrap and closed-token entries while the manager lock is held.
|
||||||
|
pub(super) fn remove_expired_locked(state: &mut ManagerState, now: Instant) {
|
||||||
|
let expired = state
|
||||||
|
.bootstraps
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(hash, bootstrap)| (now > bootstrap.expires_at).then_some(*hash))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
for hash in expired {
|
||||||
|
remove_bootstrap_locked(state, hash);
|
||||||
|
}
|
||||||
|
state
|
||||||
|
.closed_tokens
|
||||||
|
.retain(|_, closed| now <= closed.expires_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes one bootstrap and releases its per-address issuance quota when unused.
|
||||||
|
pub(super) fn remove_bootstrap_locked(state: &mut ManagerState, hash: TokenHash) {
|
||||||
|
let Some(bootstrap) = state.bootstraps.remove(&hash) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !bootstrap.used {
|
||||||
|
decrement_map(&mut state.bootstraps_per_ip, &bootstrap.issuance_ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrements one counted owner and removes its map entry at zero.
|
||||||
|
pub(super) fn decrement_map<K, Q>(values: &mut HashMap<K, usize>, key: &Q)
|
||||||
|
where
|
||||||
|
K: std::borrow::Borrow<Q> + std::hash::Hash + Eq,
|
||||||
|
Q: std::hash::Hash + Eq + ?Sized,
|
||||||
|
{
|
||||||
|
let remove = if let Some(value) = values.get_mut(key) {
|
||||||
|
*value = value.saturating_sub(1);
|
||||||
|
*value == 0
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
if remove {
|
||||||
|
values.remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes the process-wide item reserve required for session control progress.
|
||||||
|
pub(super) fn control_item_reserve(limits: &WebLimitsConfig) -> usize {
|
||||||
|
limits.max_sessions_global.saturating_mul(
|
||||||
|
16usize.saturating_add(limits.max_streams_per_session.saturating_mul(3)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Allocates a non-zero source port unique among live streams for one KDF route.
|
||||||
|
pub(super) fn allocate_stream_port(
|
||||||
|
state: &mut ManagerState,
|
||||||
|
client_ip: IpAddr,
|
||||||
|
public_addr: SocketAddr,
|
||||||
|
) -> Option<u16> {
|
||||||
|
let ports = state
|
||||||
|
.stream_ports
|
||||||
|
.entry((client_ip, public_addr))
|
||||||
|
.or_insert_with(|| StreamPortState {
|
||||||
|
active: HashSet::new(),
|
||||||
|
next: 1,
|
||||||
|
});
|
||||||
|
for _ in 0..u16::MAX {
|
||||||
|
let candidate = ports.next;
|
||||||
|
ports.next = ports.next.checked_add(1).unwrap_or(1);
|
||||||
|
if ports.active.insert(candidate) {
|
||||||
|
return Some(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Releases one source port and reclaims empty per-route allocator state.
|
||||||
|
pub(super) fn release_stream_port(
|
||||||
|
state: &mut ManagerState,
|
||||||
|
client_ip: IpAddr,
|
||||||
|
public_addr: SocketAddr,
|
||||||
|
peer_port: u16,
|
||||||
|
) -> bool {
|
||||||
|
let key = (client_ip, public_addr);
|
||||||
|
let Some(ports) = state.stream_ports.get_mut(&key) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let removed = ports.active.remove(&peer_port);
|
||||||
|
if ports.active.is_empty() {
|
||||||
|
state.stream_ports.remove(&key);
|
||||||
|
}
|
||||||
|
removed
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn synthetic_ports_are_unique_per_live_route_and_state_is_reclaimed() {
|
||||||
|
let mut state = ManagerState::default();
|
||||||
|
let client_ip = "192.0.2.10".parse().unwrap();
|
||||||
|
let public_addr = "203.0.113.10:443".parse().unwrap();
|
||||||
|
let first = allocate_stream_port(&mut state, client_ip, public_addr).unwrap();
|
||||||
|
let second = allocate_stream_port(&mut state, client_ip, public_addr).unwrap();
|
||||||
|
|
||||||
|
assert_ne!(first, second);
|
||||||
|
assert!(release_stream_port(
|
||||||
|
&mut state,
|
||||||
|
client_ip,
|
||||||
|
public_addr,
|
||||||
|
first,
|
||||||
|
));
|
||||||
|
assert!(release_stream_port(
|
||||||
|
&mut state,
|
||||||
|
client_ip,
|
||||||
|
public_addr,
|
||||||
|
second,
|
||||||
|
));
|
||||||
|
assert!(state.stream_ports.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
//! Bounded WEB carrier ingress behind a trusted external TLS terminator.
|
||||||
|
|
||||||
|
/// Browser bridge generation for the serialized HTTPS carrier.
|
||||||
|
pub(crate) mod bridge;
|
||||||
|
/// Shared binary frame codec and protocol constants.
|
||||||
|
pub(crate) mod frame;
|
||||||
|
/// Plain HTTP ingress and decoy routing behind external TLS termination.
|
||||||
|
pub(crate) mod http;
|
||||||
|
/// Process-wide credentials, quotas, memory budgets, and shutdown ownership.
|
||||||
|
pub(crate) mod manager;
|
||||||
|
/// Resumable carrier sessions and logical-stream state machines.
|
||||||
|
pub(crate) mod session;
|
||||||
|
/// AsyncRead and AsyncWrite adapter for one logical MTProxy stream.
|
||||||
|
pub(crate) mod stream;
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
use std::collections::{HashMap, HashSet, VecDeque};
|
||||||
|
use std::io;
|
||||||
|
use std::net::IpAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||||
|
use std::task::{Context, Poll, Waker};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use bytes::{Bytes, BytesMut};
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use tokio::io::ReadBuf;
|
||||||
|
use tokio::sync::Notify;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
use crate::config::{WebLimitsConfig, WebRuntimeProfile, WebTimeoutsConfig};
|
||||||
|
use crate::web::frame::{self, FrameType};
|
||||||
|
use crate::web::manager::{ProfileKey, TokenHash, WebProcessRuntime};
|
||||||
|
|
||||||
|
// Backend tasks own generation admission and authenticated MTProxy relay lifetimes.
|
||||||
|
mod backend;
|
||||||
|
// Downlink queues own cursor replay, flow control, and memory reservations.
|
||||||
|
mod downlink;
|
||||||
|
// Uplink batches own exactly-once sequencing and client-frame validation.
|
||||||
|
mod uplink;
|
||||||
|
|
||||||
|
/// Conservative allocator and container overhead charged to every queued item.
|
||||||
|
pub(crate) const QUEUE_ITEM_COST: usize = 256;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum PendingClass {
|
||||||
|
Uplink,
|
||||||
|
Downlink,
|
||||||
|
Control,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct InboundChunk {
|
||||||
|
bytes: Bytes,
|
||||||
|
offset: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StreamState {
|
||||||
|
inbound: VecDeque<InboundChunk>,
|
||||||
|
receive_window: u32,
|
||||||
|
send_credit: u64,
|
||||||
|
read_waker: Option<Waker>,
|
||||||
|
write_waker: Option<Waker>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct QueuedFrame {
|
||||||
|
encoded: BytesMut,
|
||||||
|
frame_type: FrameType,
|
||||||
|
stream_id: u32,
|
||||||
|
control: bool,
|
||||||
|
cost: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DownBatch {
|
||||||
|
body: Bytes,
|
||||||
|
base_cursor: u64,
|
||||||
|
next_cursor: u64,
|
||||||
|
data_bytes: usize,
|
||||||
|
data_items: usize,
|
||||||
|
control_bytes: usize,
|
||||||
|
control_items: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SessionState {
|
||||||
|
streams: HashMap<u32, StreamState>,
|
||||||
|
active_peer_ports: HashSet<u16>,
|
||||||
|
closed_streams: HashSet<u32>,
|
||||||
|
closed_order: VecDeque<u32>,
|
||||||
|
pending_frames: VecDeque<QueuedFrame>,
|
||||||
|
pending_windows: HashMap<u32, usize>,
|
||||||
|
unacked: Option<DownBatch>,
|
||||||
|
down_cursor: u64,
|
||||||
|
down_epoch: u64,
|
||||||
|
last_up_sequence: u64,
|
||||||
|
last_up_digest: TokenHash,
|
||||||
|
pending_bytes: usize,
|
||||||
|
pending_items: usize,
|
||||||
|
pending_control_bytes: usize,
|
||||||
|
pending_control_items: usize,
|
||||||
|
last_activity: Instant,
|
||||||
|
closed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One bounded WEB carrier session containing logical MTProxy streams.
|
||||||
|
pub(crate) struct WebSession {
|
||||||
|
manager: std::sync::Weak<WebProcessRuntime>,
|
||||||
|
token_hash: TokenHash,
|
||||||
|
client_ip: IpAddr,
|
||||||
|
profile: Arc<WebRuntimeProfile>,
|
||||||
|
profile_key: ProfileKey,
|
||||||
|
limits: WebLimitsConfig,
|
||||||
|
timeouts: WebTimeoutsConfig,
|
||||||
|
state: Mutex<SessionState>,
|
||||||
|
down_notify: Arc<Notify>,
|
||||||
|
cancel: CancellationToken,
|
||||||
|
tasks_live: AtomicUsize,
|
||||||
|
tasks_done: Arc<Notify>,
|
||||||
|
finished: AtomicBool,
|
||||||
|
up_active: AtomicBool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One successful downlink poll result.
|
||||||
|
pub(crate) struct PollResult {
|
||||||
|
/// Encoded downlink frame batch, or an empty long-poll result.
|
||||||
|
pub(crate) body: Bytes,
|
||||||
|
/// Cursor the client must present on its next downlink request.
|
||||||
|
pub(crate) next_cursor: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WebSession {
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
/// Creates one carrier session with immutable ownership and allocation policy.
|
||||||
|
pub(crate) fn new(
|
||||||
|
manager: std::sync::Weak<WebProcessRuntime>,
|
||||||
|
token_hash: TokenHash,
|
||||||
|
client_ip: IpAddr,
|
||||||
|
profile: Arc<WebRuntimeProfile>,
|
||||||
|
profile_key: ProfileKey,
|
||||||
|
limits: WebLimitsConfig,
|
||||||
|
timeouts: WebTimeoutsConfig,
|
||||||
|
) -> Arc<Self> {
|
||||||
|
Arc::new(Self {
|
||||||
|
manager,
|
||||||
|
token_hash,
|
||||||
|
client_ip,
|
||||||
|
profile,
|
||||||
|
profile_key,
|
||||||
|
limits,
|
||||||
|
timeouts,
|
||||||
|
state: Mutex::new(SessionState {
|
||||||
|
streams: HashMap::new(),
|
||||||
|
active_peer_ports: HashSet::new(),
|
||||||
|
closed_streams: HashSet::new(),
|
||||||
|
closed_order: VecDeque::new(),
|
||||||
|
pending_frames: VecDeque::new(),
|
||||||
|
pending_windows: HashMap::new(),
|
||||||
|
unacked: None,
|
||||||
|
down_cursor: 0,
|
||||||
|
down_epoch: 0,
|
||||||
|
last_up_sequence: 0,
|
||||||
|
last_up_digest: [0; 32],
|
||||||
|
pending_bytes: 0,
|
||||||
|
pending_items: 0,
|
||||||
|
pending_control_bytes: 0,
|
||||||
|
pending_control_items: 0,
|
||||||
|
last_activity: Instant::now(),
|
||||||
|
closed: false,
|
||||||
|
}),
|
||||||
|
down_notify: Arc::new(Notify::new()),
|
||||||
|
cancel: CancellationToken::new(),
|
||||||
|
tasks_live: AtomicUsize::new(0),
|
||||||
|
tasks_done: Arc::new(Notify::new()),
|
||||||
|
finished: AtomicBool::new(false),
|
||||||
|
up_active: AtomicBool::new(false),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the stable hashed token identity without exposing the credential.
|
||||||
|
pub(crate) fn token_hash(&self) -> TokenHash {
|
||||||
|
self.token_hash
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks the canonical virtual host that owns this bearer session.
|
||||||
|
pub(crate) fn matches_host(&self, host: &str) -> bool {
|
||||||
|
self.profile.host == host
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closes carrier state while relay tasks retain their admission until exit.
|
||||||
|
pub(crate) fn close(&self) {
|
||||||
|
let (data_bytes, data_items, control_bytes, control_items) = {
|
||||||
|
let mut state = self.state.lock();
|
||||||
|
if state.closed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.closed = true;
|
||||||
|
for stream in state.streams.values_mut() {
|
||||||
|
if let Some(waker) = stream.read_waker.take() {
|
||||||
|
waker.wake();
|
||||||
|
}
|
||||||
|
if let Some(waker) = stream.write_waker.take() {
|
||||||
|
waker.wake();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.streams.clear();
|
||||||
|
state.pending_frames.clear();
|
||||||
|
state.pending_windows.clear();
|
||||||
|
state.unacked = None;
|
||||||
|
let control_bytes = state.pending_control_bytes;
|
||||||
|
let control_items = state.pending_control_items;
|
||||||
|
let data_bytes = state.pending_bytes.saturating_sub(control_bytes);
|
||||||
|
let data_items = state.pending_items.saturating_sub(control_items);
|
||||||
|
state.pending_bytes = 0;
|
||||||
|
state.pending_items = 0;
|
||||||
|
state.pending_control_bytes = 0;
|
||||||
|
state.pending_control_items = 0;
|
||||||
|
(data_bytes, data_items, control_bytes, control_items)
|
||||||
|
};
|
||||||
|
self.cancel.cancel();
|
||||||
|
self.down_notify.notify_waiters();
|
||||||
|
if let Some(manager) = self.manager.upgrade() {
|
||||||
|
manager.release_pending(data_bytes, data_items, false);
|
||||||
|
manager.release_pending(control_bytes, control_items, true);
|
||||||
|
if !self.finished.swap(true, Ordering::AcqRel) {
|
||||||
|
manager.session_finished(
|
||||||
|
self.token_hash,
|
||||||
|
self.client_ip,
|
||||||
|
self.profile_key,
|
||||||
|
&self.profile.host,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Waits for all logical-stream tasks after admission has closed.
|
||||||
|
pub(crate) async fn wait(&self) {
|
||||||
|
loop {
|
||||||
|
let notified = self.tasks_done.notified();
|
||||||
|
if self.tasks_live.load(Ordering::Acquire) == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notified.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether reconnect grace elapsed without activity.
|
||||||
|
pub(crate) fn is_idle(&self, now: Instant) -> bool {
|
||||||
|
let state = self.state.lock();
|
||||||
|
!state.closed
|
||||||
|
&& now.saturating_duration_since(state.last_activity)
|
||||||
|
>= Duration::from_secs(self.timeouts.reconnect_grace_secs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Polls client-to-server bytes and returns consumed flow-control credit.
|
||||||
|
pub(super) fn poll_read(
|
||||||
|
&self,
|
||||||
|
stream_id: u32,
|
||||||
|
cx: &mut Context<'_>,
|
||||||
|
output: &mut ReadBuf<'_>,
|
||||||
|
) -> Poll<io::Result<()>> {
|
||||||
|
let mut state = self.state.lock();
|
||||||
|
let (count, finished) = {
|
||||||
|
let Some(stream) = state.streams.get_mut(&stream_id) else {
|
||||||
|
return Poll::Ready(Ok(()));
|
||||||
|
};
|
||||||
|
let Some(chunk) = stream.inbound.front_mut() else {
|
||||||
|
stream.read_waker = Some(cx.waker().clone());
|
||||||
|
return Poll::Pending;
|
||||||
|
};
|
||||||
|
let available = &chunk.bytes[chunk.offset..];
|
||||||
|
let count = available.len().min(output.remaining());
|
||||||
|
output.put_slice(&available[..count]);
|
||||||
|
chunk.offset += count;
|
||||||
|
let finished = chunk.offset == chunk.bytes.len();
|
||||||
|
if finished {
|
||||||
|
stream.inbound.pop_front();
|
||||||
|
}
|
||||||
|
stream.receive_window = stream.receive_window.saturating_add(count as u32);
|
||||||
|
(count, finished)
|
||||||
|
};
|
||||||
|
let overhead = if finished { QUEUE_ITEM_COST } else { 0 };
|
||||||
|
self.release_locked(&mut state, count + overhead, usize::from(finished), false);
|
||||||
|
if !self.queue_window_locked(&mut state, stream_id, count as u32) {
|
||||||
|
drop(state);
|
||||||
|
self.close();
|
||||||
|
return Poll::Ready(Err(io::Error::other("WEB session control budget exhausted")));
|
||||||
|
}
|
||||||
|
Poll::Ready(Ok(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Polls server-to-client writes against stream credit and bounded queues.
|
||||||
|
pub(super) fn poll_write(
|
||||||
|
&self,
|
||||||
|
stream_id: u32,
|
||||||
|
cx: &mut Context<'_>,
|
||||||
|
input: &[u8],
|
||||||
|
) -> Poll<io::Result<usize>> {
|
||||||
|
if input.is_empty() {
|
||||||
|
return Poll::Ready(Ok(0));
|
||||||
|
}
|
||||||
|
let mut state = self.state.lock();
|
||||||
|
let Some(stream) = state.streams.get_mut(&stream_id) else {
|
||||||
|
return Poll::Ready(Err(io::Error::new(
|
||||||
|
io::ErrorKind::BrokenPipe,
|
||||||
|
"WEB logical stream is closed",
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
let count = input
|
||||||
|
.len()
|
||||||
|
.min(frame::DATA_CHUNK_BYTES)
|
||||||
|
.min(self.limits.max_frame_payload_bytes)
|
||||||
|
.min(stream.send_credit as usize);
|
||||||
|
if count == 0 {
|
||||||
|
stream.write_waker = Some(cx.waker().clone());
|
||||||
|
return Poll::Pending;
|
||||||
|
}
|
||||||
|
if !self.queue_data_locked(&mut state, stream_id, &input[..count]) {
|
||||||
|
if let Some(stream) = state.streams.get_mut(&stream_id) {
|
||||||
|
stream.write_waker = Some(cx.waker().clone());
|
||||||
|
}
|
||||||
|
return Poll::Pending;
|
||||||
|
}
|
||||||
|
let Some(stream) = state.streams.get_mut(&stream_id) else {
|
||||||
|
return Poll::Ready(Err(io::Error::new(
|
||||||
|
io::ErrorKind::BrokenPipe,
|
||||||
|
"WEB logical stream is closed",
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
stream.send_credit -= count as u64;
|
||||||
|
state.last_activity = Instant::now();
|
||||||
|
drop(state);
|
||||||
|
self.down_notify.notify_waiters();
|
||||||
|
Poll::Ready(Ok(count))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the process queue-capacity notification source while the manager lives.
|
||||||
|
pub(super) fn budget_notify(&self) -> Option<Arc<Notify>> {
|
||||||
|
self.manager.upgrade().map(|manager| manager.budget_notify())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn release_stream_reservation(&self, peer_port: u16) {
|
||||||
|
let removed = self.state.lock().active_peer_ports.remove(&peer_port);
|
||||||
|
if removed
|
||||||
|
&& let Some(manager) = self.manager.upgrade()
|
||||||
|
{
|
||||||
|
manager.release_stream(
|
||||||
|
self.profile_key,
|
||||||
|
self.client_ip,
|
||||||
|
self.profile.public_addr,
|
||||||
|
peer_port,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inbound_queue_cost(queue: &VecDeque<InboundChunk>) -> (usize, usize) {
|
||||||
|
let bytes = queue.iter().fold(0usize, |total, chunk| {
|
||||||
|
total.saturating_add(chunk.bytes.len().saturating_sub(chunk.offset) + QUEUE_ITEM_COST)
|
||||||
|
});
|
||||||
|
(bytes, queue.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remember_closed(state: &mut SessionState, stream_id: u32, limit: usize) {
|
||||||
|
if !state.closed_streams.insert(stream_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.closed_order.push_back(stream_id);
|
||||||
|
while state.closed_order.len() > limit {
|
||||||
|
if let Some(oldest) = state.closed_order.pop_front() {
|
||||||
|
state.closed_streams.remove(&oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
use std::io;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::web::frame::FrameType;
|
||||||
|
use crate::web::stream::WebLogicalStream;
|
||||||
|
use crate::proxy::shared_state::ConntrackClosePolicy;
|
||||||
|
|
||||||
|
use super::{WebSession, inbound_queue_cost, remember_closed};
|
||||||
|
|
||||||
|
impl WebSession {
|
||||||
|
/// Starts one owned inner handshake and relay task for an admitted stream.
|
||||||
|
pub(super) fn spawn_stream(self: &Arc<Self>, stream_id: u32, peer_port: u16) {
|
||||||
|
let Some(manager) = self.manager.upgrade() else {
|
||||||
|
self.stream_finished(stream_id, peer_port);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let generation = manager.active_generation();
|
||||||
|
let Ok(connection_permit) = generation.max_connections.clone().try_acquire_owned() else {
|
||||||
|
manager.record_stream_rejected();
|
||||||
|
self.stream_finished(stream_id, peer_port);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(handshake_permit) = manager.try_stream_handshake() else {
|
||||||
|
self.stream_finished(stream_id, peer_port);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let deps = generation.client_runtime_deps();
|
||||||
|
let replay_checker = Arc::clone(&generation.replay_checker);
|
||||||
|
let session = Arc::clone(self);
|
||||||
|
let cancel = self.cancel.clone();
|
||||||
|
self.tasks_live.fetch_add(1, Ordering::AcqRel);
|
||||||
|
let spawned = generation.spawn_session(async move {
|
||||||
|
let _connection_permit = connection_permit;
|
||||||
|
let _completion = StreamCompletion {
|
||||||
|
session: Arc::clone(&session),
|
||||||
|
stream_id,
|
||||||
|
peer_port,
|
||||||
|
};
|
||||||
|
let stream = WebLogicalStream::new(Arc::clone(&session), stream_id);
|
||||||
|
tokio::select! {
|
||||||
|
_ = cancel.cancelled() => {}
|
||||||
|
_ = run_stream(
|
||||||
|
Arc::clone(&session),
|
||||||
|
stream,
|
||||||
|
deps,
|
||||||
|
replay_checker,
|
||||||
|
handshake_permit,
|
||||||
|
peer_port,
|
||||||
|
) => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if !spawned {
|
||||||
|
self.tasks_live.fetch_sub(1, Ordering::AcqRel);
|
||||||
|
self.stream_finished(stream_id, peer_port);
|
||||||
|
self.tasks_done.notify_waiters();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stream_finished(&self, stream_id: u32, peer_port: u16) {
|
||||||
|
let (queued, reserved) = {
|
||||||
|
let mut state = self.state.lock();
|
||||||
|
let reserved = state.active_peer_ports.remove(&peer_port);
|
||||||
|
let queued = state.streams.remove(&stream_id).map(|stream| {
|
||||||
|
let (bytes, items) = inbound_queue_cost(&stream.inbound);
|
||||||
|
self.release_locked(&mut state, bytes, items, false);
|
||||||
|
remember_closed(
|
||||||
|
&mut state,
|
||||||
|
stream_id,
|
||||||
|
self.limits.max_tombstones_per_session,
|
||||||
|
);
|
||||||
|
self.queue_control_locked(&mut state, FrameType::Close, stream_id, &[])
|
||||||
|
});
|
||||||
|
(queued, reserved)
|
||||||
|
};
|
||||||
|
if reserved
|
||||||
|
&& let Some(manager) = self.manager.upgrade()
|
||||||
|
{
|
||||||
|
manager.release_stream(
|
||||||
|
self.profile_key,
|
||||||
|
self.client_ip,
|
||||||
|
self.profile.public_addr,
|
||||||
|
peer_port,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(queued) = queued {
|
||||||
|
if !queued {
|
||||||
|
self.close();
|
||||||
|
}
|
||||||
|
self.down_notify.notify_waiters();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StreamCompletion {
|
||||||
|
session: Arc<WebSession>,
|
||||||
|
stream_id: u32,
|
||||||
|
peer_port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for StreamCompletion {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.session.stream_finished(self.stream_id, self.peer_port);
|
||||||
|
if self.session.tasks_live.fetch_sub(1, Ordering::AcqRel) == 1 {
|
||||||
|
self.session.tasks_done.notify_waiters();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_stream(
|
||||||
|
session: Arc<WebSession>,
|
||||||
|
stream: WebLogicalStream,
|
||||||
|
deps: crate::proxy::authenticated::ClientRuntimeDeps,
|
||||||
|
replay_checker: Arc<crate::stats::ReplayChecker>,
|
||||||
|
handshake_permit: tokio::sync::OwnedSemaphorePermit,
|
||||||
|
peer_port: u16,
|
||||||
|
) {
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
|
||||||
|
use crate::protocol::constants::HANDSHAKE_LEN;
|
||||||
|
use crate::proxy::authenticated::run_authenticated;
|
||||||
|
use crate::proxy::handshake::handle_mtproto_handshake_for_web_user;
|
||||||
|
|
||||||
|
let (mut reader, writer) = tokio::io::split(stream);
|
||||||
|
let mut handshake = [0u8; HANDSHAKE_LEN];
|
||||||
|
let peer = std::net::SocketAddr::new(session.client_ip, peer_port);
|
||||||
|
deps.stats.increment_connects_all();
|
||||||
|
let handshake_result = tokio::time::timeout(
|
||||||
|
Duration::from_secs(session.timeouts.stream_handshake_secs),
|
||||||
|
async {
|
||||||
|
reader.read_exact(&mut handshake).await?;
|
||||||
|
Ok::<_, io::Error>(
|
||||||
|
handle_mtproto_handshake_for_web_user(
|
||||||
|
&handshake,
|
||||||
|
reader,
|
||||||
|
writer,
|
||||||
|
peer,
|
||||||
|
&deps.config,
|
||||||
|
&replay_checker,
|
||||||
|
&session.profile.user,
|
||||||
|
session.profile.secret_mode,
|
||||||
|
&deps.shared,
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
drop(handshake_permit);
|
||||||
|
let Ok(Ok(crate::error::HandshakeResult::Success((reader, writer, success)))) =
|
||||||
|
handshake_result
|
||||||
|
else {
|
||||||
|
deps.stats
|
||||||
|
.increment_connects_bad_with_class("web_mtproto_bad_client");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let _ = run_authenticated(
|
||||||
|
reader,
|
||||||
|
writer,
|
||||||
|
success,
|
||||||
|
deps,
|
||||||
|
session.profile.public_addr,
|
||||||
|
peer,
|
||||||
|
ConntrackClosePolicy::Suppress,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
@@ -0,0 +1,494 @@
|
|||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use bytes::{BufMut, Bytes, BytesMut};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
DownBatch, PendingClass, PollResult, QUEUE_ITEM_COST, QueuedFrame, SessionState,
|
||||||
|
WebSession,
|
||||||
|
};
|
||||||
|
use crate::web::frame::{self, FrameType};
|
||||||
|
use crate::web::manager::ManagerError;
|
||||||
|
|
||||||
|
impl WebSession {
|
||||||
|
/// Polls pending downlink frames with cursor replay and newest-poll-wins semantics.
|
||||||
|
pub(crate) async fn poll_down(&self, cursor: u64) -> Result<PollResult, ManagerError> {
|
||||||
|
let epoch = {
|
||||||
|
let mut state = self.state.lock();
|
||||||
|
if state.closed {
|
||||||
|
return Err(ManagerError::Closed);
|
||||||
|
}
|
||||||
|
state.last_activity = Instant::now();
|
||||||
|
if let Some(unacked) = &state.unacked {
|
||||||
|
if cursor == unacked.base_cursor {
|
||||||
|
return Ok(PollResult {
|
||||||
|
body: unacked.body.clone(),
|
||||||
|
next_cursor: unacked.next_cursor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if cursor != unacked.next_cursor {
|
||||||
|
drop(state);
|
||||||
|
self.close();
|
||||||
|
return Err(ManagerError::Protocol);
|
||||||
|
}
|
||||||
|
self.release_unacked_locked(&mut state);
|
||||||
|
} else if cursor != state.down_cursor {
|
||||||
|
drop(state);
|
||||||
|
self.close();
|
||||||
|
return Err(ManagerError::Protocol);
|
||||||
|
}
|
||||||
|
state.down_epoch = state.down_epoch.wrapping_add(1).max(1);
|
||||||
|
state.down_epoch
|
||||||
|
};
|
||||||
|
self.down_notify.notify_waiters();
|
||||||
|
|
||||||
|
let deadline = Duration::from_secs(self.timeouts.long_poll_secs);
|
||||||
|
let poll = async {
|
||||||
|
loop {
|
||||||
|
let notified = self.down_notify.notified();
|
||||||
|
{
|
||||||
|
let mut state = self.state.lock();
|
||||||
|
if state.down_epoch != epoch {
|
||||||
|
return Ok(PollResult {
|
||||||
|
body: Bytes::new(),
|
||||||
|
next_cursor: cursor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !state.pending_frames.is_empty() {
|
||||||
|
let batch = match self.take_down_batch_locked(&mut state, cursor) {
|
||||||
|
Ok(batch) => batch,
|
||||||
|
Err(error) => {
|
||||||
|
drop(state);
|
||||||
|
self.close();
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let result = PollResult {
|
||||||
|
body: batch.body.clone(),
|
||||||
|
next_cursor: batch.next_cursor,
|
||||||
|
};
|
||||||
|
if let Some(manager) = self.manager.upgrade() {
|
||||||
|
manager.record_down(result.body.len());
|
||||||
|
}
|
||||||
|
state.unacked = Some(batch);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
if state.closed {
|
||||||
|
return Err(ManagerError::Closed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
notified.await;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match tokio::time::timeout(deadline, poll).await {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(_) => {
|
||||||
|
let mut state = self.state.lock();
|
||||||
|
if state.down_epoch == epoch {
|
||||||
|
state.last_activity = Instant::now();
|
||||||
|
}
|
||||||
|
Ok(PollResult {
|
||||||
|
body: Bytes::new(),
|
||||||
|
next_cursor: cursor,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reserves session and process queue capacity while the session lock is held.
|
||||||
|
pub(super) fn reserve_locked(
|
||||||
|
&self,
|
||||||
|
state: &mut SessionState,
|
||||||
|
bytes: usize,
|
||||||
|
items: usize,
|
||||||
|
class: PendingClass,
|
||||||
|
) -> bool {
|
||||||
|
if bytes == 0 && items == 0 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let data_byte_limit = self
|
||||||
|
.limits
|
||||||
|
.pending_bytes_per_session
|
||||||
|
.saturating_sub(self.limits.control_bytes_per_session);
|
||||||
|
let item_reserve = 16usize.saturating_add(
|
||||||
|
self.limits.max_streams_per_session.saturating_mul(3),
|
||||||
|
);
|
||||||
|
let data_item_limit = self
|
||||||
|
.limits
|
||||||
|
.pending_items_per_session
|
||||||
|
.saturating_sub(item_reserve);
|
||||||
|
if state.closed {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let control = class == PendingClass::Control;
|
||||||
|
let fits = if control {
|
||||||
|
bytes <= self.limits.control_bytes_per_session
|
||||||
|
&& items <= item_reserve
|
||||||
|
&& state.pending_bytes
|
||||||
|
<= self.limits.pending_bytes_per_session.saturating_sub(bytes)
|
||||||
|
&& state.pending_items
|
||||||
|
<= self.limits.pending_items_per_session.saturating_sub(items)
|
||||||
|
&& state.pending_control_bytes
|
||||||
|
<= self.limits.control_bytes_per_session.saturating_sub(bytes)
|
||||||
|
&& state.pending_control_items <= item_reserve.saturating_sub(items)
|
||||||
|
} else {
|
||||||
|
let data_bytes = state
|
||||||
|
.pending_bytes
|
||||||
|
.saturating_sub(state.pending_control_bytes);
|
||||||
|
let data_items = state
|
||||||
|
.pending_items
|
||||||
|
.saturating_sub(state.pending_control_items);
|
||||||
|
let (byte_limit, item_limit) = if class == PendingClass::Downlink {
|
||||||
|
let uplink_bytes = self
|
||||||
|
.limits
|
||||||
|
.max_body_bytes
|
||||||
|
.saturating_add(
|
||||||
|
self.limits
|
||||||
|
.max_frames_per_body
|
||||||
|
.saturating_mul(QUEUE_ITEM_COST),
|
||||||
|
);
|
||||||
|
(
|
||||||
|
data_byte_limit.saturating_sub(uplink_bytes),
|
||||||
|
data_item_limit.saturating_sub(self.limits.max_frames_per_body),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(data_byte_limit, data_item_limit)
|
||||||
|
};
|
||||||
|
bytes <= byte_limit
|
||||||
|
&& items <= item_limit
|
||||||
|
&& data_bytes <= byte_limit - bytes
|
||||||
|
&& data_items <= item_limit - items
|
||||||
|
};
|
||||||
|
if !fits {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Some(manager) = self.manager.upgrade() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if !manager.try_reserve_pending(
|
||||||
|
bytes,
|
||||||
|
items,
|
||||||
|
control,
|
||||||
|
class == PendingClass::Downlink,
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
state.pending_bytes += bytes;
|
||||||
|
state.pending_items += items;
|
||||||
|
if control {
|
||||||
|
state.pending_control_bytes += bytes;
|
||||||
|
state.pending_control_items += items;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Releases session and process queue capacity while the session lock is held.
|
||||||
|
pub(super) fn release_locked(
|
||||||
|
&self,
|
||||||
|
state: &mut SessionState,
|
||||||
|
bytes: usize,
|
||||||
|
items: usize,
|
||||||
|
control: bool,
|
||||||
|
) {
|
||||||
|
state.pending_bytes = state.pending_bytes.saturating_sub(bytes);
|
||||||
|
state.pending_items = state.pending_items.saturating_sub(items);
|
||||||
|
if control {
|
||||||
|
state.pending_control_bytes = state.pending_control_bytes.saturating_sub(bytes);
|
||||||
|
state.pending_control_items = state.pending_control_items.saturating_sub(items);
|
||||||
|
}
|
||||||
|
if let Some(manager) = self.manager.upgrade() {
|
||||||
|
manager.release_pending(bytes, items, control);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Coalesces one flow-control update into the bounded control queue.
|
||||||
|
pub(super) fn queue_window_locked(
|
||||||
|
&self,
|
||||||
|
state: &mut SessionState,
|
||||||
|
stream_id: u32,
|
||||||
|
amount: u32,
|
||||||
|
) -> bool {
|
||||||
|
if amount == 0 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if let Some(index) = state.pending_windows.get(&stream_id).copied()
|
||||||
|
&& let Some(queued) = state.pending_frames.get_mut(index)
|
||||||
|
{
|
||||||
|
let previous = u32::from_be_bytes(
|
||||||
|
queued.encoded[frame::HEADER_BYTES..frame::HEADER_BYTES + 4]
|
||||||
|
.try_into()
|
||||||
|
.unwrap_or([0; 4]),
|
||||||
|
);
|
||||||
|
if let Some(total) = previous.checked_add(amount) {
|
||||||
|
queued.encoded[frame::HEADER_BYTES..frame::HEADER_BYTES + 4]
|
||||||
|
.copy_from_slice(&total.to_be_bytes());
|
||||||
|
self.down_notify.notify_waiters();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.queue_control_locked(
|
||||||
|
state,
|
||||||
|
FrameType::Window,
|
||||||
|
stream_id,
|
||||||
|
&frame::window_payload(amount),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appends one control frame under both reserved queue budgets.
|
||||||
|
pub(super) fn queue_control_locked(
|
||||||
|
&self,
|
||||||
|
state: &mut SessionState,
|
||||||
|
frame_type: FrameType,
|
||||||
|
stream_id: u32,
|
||||||
|
payload: &[u8],
|
||||||
|
) -> bool {
|
||||||
|
self.queue_frame_locked(state, frame_type, stream_id, payload, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appends one server-to-client DATA frame under downlink data budgets.
|
||||||
|
pub(super) fn queue_data_locked(
|
||||||
|
&self,
|
||||||
|
state: &mut SessionState,
|
||||||
|
stream_id: u32,
|
||||||
|
payload: &[u8],
|
||||||
|
) -> bool {
|
||||||
|
let can_coalesce = state.pending_frames.back().is_some_and(|last| {
|
||||||
|
last.frame_type == FrameType::Data
|
||||||
|
&& last.stream_id == stream_id
|
||||||
|
&& last.encoded.len() - frame::HEADER_BYTES + payload.len()
|
||||||
|
<= self.limits.max_frame_payload_bytes
|
||||||
|
});
|
||||||
|
if can_coalesce {
|
||||||
|
if !self.reserve_locked(state, payload.len(), 0, PendingClass::Downlink) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Some(last) = state.pending_frames.back_mut() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
last.encoded.extend_from_slice(payload);
|
||||||
|
last.cost += payload.len();
|
||||||
|
let payload_len = (last.encoded.len() - frame::HEADER_BYTES) as u32;
|
||||||
|
last.encoded[4..8].copy_from_slice(&payload_len.to_be_bytes());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
self.queue_frame_locked(state, FrameType::Data, stream_id, payload, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn queue_frame_locked(
|
||||||
|
&self,
|
||||||
|
state: &mut SessionState,
|
||||||
|
frame_type: FrameType,
|
||||||
|
stream_id: u32,
|
||||||
|
payload: &[u8],
|
||||||
|
control: bool,
|
||||||
|
) -> bool {
|
||||||
|
let cost = frame::HEADER_BYTES + payload.len() + QUEUE_ITEM_COST;
|
||||||
|
let class = if control {
|
||||||
|
PendingClass::Control
|
||||||
|
} else {
|
||||||
|
PendingClass::Downlink
|
||||||
|
};
|
||||||
|
if !self.reserve_locked(state, cost, 1, class) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut encoded = BytesMut::with_capacity(frame::HEADER_BYTES + payload.len());
|
||||||
|
encoded.put_u8(frame_type as u8);
|
||||||
|
encoded.put_u8((stream_id >> 16) as u8);
|
||||||
|
encoded.put_u8((stream_id >> 8) as u8);
|
||||||
|
encoded.put_u8(stream_id as u8);
|
||||||
|
encoded.put_u32(payload.len() as u32);
|
||||||
|
encoded.extend_from_slice(payload);
|
||||||
|
let index = state.pending_frames.len();
|
||||||
|
state.pending_frames.push_back(QueuedFrame {
|
||||||
|
encoded,
|
||||||
|
frame_type,
|
||||||
|
stream_id,
|
||||||
|
control,
|
||||||
|
cost,
|
||||||
|
});
|
||||||
|
if frame_type == FrameType::Window {
|
||||||
|
state.pending_windows.insert(stream_id, index);
|
||||||
|
}
|
||||||
|
self.down_notify.notify_waiters();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take_down_batch_locked(
|
||||||
|
&self,
|
||||||
|
state: &mut SessionState,
|
||||||
|
cursor: u64,
|
||||||
|
) -> Result<DownBatch, ManagerError> {
|
||||||
|
let next_cursor = state
|
||||||
|
.down_cursor
|
||||||
|
.checked_add(1)
|
||||||
|
.ok_or(ManagerError::Protocol)?;
|
||||||
|
let mut count = 0usize;
|
||||||
|
let mut body_len = 0usize;
|
||||||
|
for queued in &state.pending_frames {
|
||||||
|
if count >= self.limits.max_frames_per_body
|
||||||
|
|| (count != 0
|
||||||
|
&& body_len.saturating_add(queued.encoded.len())
|
||||||
|
> self.limits.carrier_batch_bytes)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
body_len += queued.encoded.len();
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
let mut body = BytesMut::with_capacity(body_len);
|
||||||
|
let mut data_bytes = 0usize;
|
||||||
|
let mut data_items = 0usize;
|
||||||
|
let mut control_bytes = 0usize;
|
||||||
|
let mut control_items = 0usize;
|
||||||
|
for index in 0..count {
|
||||||
|
let Some(queued) = state.pending_frames.get(index) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
if queued.frame_type == FrameType::Window
|
||||||
|
&& state.pending_windows.get(&queued.stream_id) == Some(&index)
|
||||||
|
{
|
||||||
|
state.pending_windows.remove(&queued.stream_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _ in 0..count {
|
||||||
|
let Some(queued) = state.pending_frames.pop_front() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
body.extend_from_slice(&queued.encoded);
|
||||||
|
if queued.control {
|
||||||
|
control_bytes += queued.cost;
|
||||||
|
control_items += 1;
|
||||||
|
} else {
|
||||||
|
data_bytes += queued.cost;
|
||||||
|
data_items += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for index in state.pending_windows.values_mut() {
|
||||||
|
*index = index.saturating_sub(count);
|
||||||
|
}
|
||||||
|
state.down_cursor = next_cursor;
|
||||||
|
Ok(DownBatch {
|
||||||
|
body: body.freeze(),
|
||||||
|
base_cursor: cursor,
|
||||||
|
next_cursor,
|
||||||
|
data_bytes,
|
||||||
|
data_items,
|
||||||
|
control_bytes,
|
||||||
|
control_items,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn release_unacked_locked(&self, state: &mut SessionState) {
|
||||||
|
let Some(batch) = state.unacked.take() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
self.release_locked(state, batch.data_bytes, batch.data_items, false);
|
||||||
|
self.release_locked(state, batch.control_bytes, batch.control_items, true);
|
||||||
|
for stream in state.streams.values_mut() {
|
||||||
|
if let Some(waker) = stream.write_waker.take() {
|
||||||
|
waker.wake();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::config::{
|
||||||
|
WebLimitsConfig, WebRuntimeProfile, WebSecretMode, WebTimeoutsConfig,
|
||||||
|
};
|
||||||
|
use crate::web::manager::WebProcessRuntime;
|
||||||
|
|
||||||
|
fn session() -> Arc<WebSession> {
|
||||||
|
let profile = Arc::new(WebRuntimeProfile {
|
||||||
|
host: "proxy.example.com".to_string(),
|
||||||
|
public_addr: SocketAddr::from(([203, 0, 113, 10], 443)),
|
||||||
|
user: "alice".to_string(),
|
||||||
|
secret_mode: WebSecretMode::Plain,
|
||||||
|
capability: [0; 32],
|
||||||
|
max_sessions: 1,
|
||||||
|
max_streams: 1,
|
||||||
|
max_streams_per_session: 1,
|
||||||
|
});
|
||||||
|
WebSession::new(
|
||||||
|
std::sync::Weak::<WebProcessRuntime>::new(),
|
||||||
|
[1; 32],
|
||||||
|
"192.0.2.10".parse().unwrap(),
|
||||||
|
profile,
|
||||||
|
[2; 32],
|
||||||
|
WebLimitsConfig::default(),
|
||||||
|
WebTimeoutsConfig::default(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn queue_close(session: &WebSession) {
|
||||||
|
let encoded = frame::encode(FrameType::Close, 1, &[]);
|
||||||
|
session.state.lock().pending_frames.push_back(QueuedFrame {
|
||||||
|
encoded: BytesMut::from(encoded.as_ref()),
|
||||||
|
frame_type: FrameType::Close,
|
||||||
|
stream_id: 1,
|
||||||
|
control: true,
|
||||||
|
cost: frame::HEADER_BYTES + QUEUE_ITEM_COST,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn downlink_replays_unacknowledged_batch_byte_for_byte() {
|
||||||
|
let session = session();
|
||||||
|
queue_close(&session);
|
||||||
|
let first = session.poll_down(0).await.unwrap();
|
||||||
|
let replay = session.poll_down(0).await.unwrap();
|
||||||
|
assert_eq!(first.next_cursor, 1);
|
||||||
|
assert_eq!(replay.next_cursor, 1);
|
||||||
|
assert_eq!(first.body, replay.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn invalid_or_overflowing_cursor_closes_session() {
|
||||||
|
let invalid = session();
|
||||||
|
assert!(matches!(
|
||||||
|
invalid.poll_down(1).await,
|
||||||
|
Err(ManagerError::Protocol)
|
||||||
|
));
|
||||||
|
assert!(invalid.state.lock().closed);
|
||||||
|
|
||||||
|
let overflow = session();
|
||||||
|
{
|
||||||
|
let mut state = overflow.state.lock();
|
||||||
|
state.down_cursor = u64::MAX;
|
||||||
|
}
|
||||||
|
queue_close(&overflow);
|
||||||
|
assert!(matches!(
|
||||||
|
overflow.poll_down(u64::MAX).await,
|
||||||
|
Err(ManagerError::Protocol)
|
||||||
|
));
|
||||||
|
assert!(overflow.state.lock().closed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn newer_poll_supersedes_older_poll_without_closing_session() {
|
||||||
|
let session = session();
|
||||||
|
let first_session = Arc::clone(&session);
|
||||||
|
let first = tokio::spawn(async move { first_session.poll_down(0).await });
|
||||||
|
while session.state.lock().down_epoch < 1 {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
let second_session = Arc::clone(&session);
|
||||||
|
let second = tokio::spawn(async move { second_session.poll_down(0).await });
|
||||||
|
while session.state.lock().down_epoch < 2 {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
let superseded = tokio::time::timeout(Duration::from_secs(1), first)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert!(superseded.body.is_empty());
|
||||||
|
assert_eq!(superseded.next_cursor, 0);
|
||||||
|
assert!(!session.state.lock().closed);
|
||||||
|
second.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
use std::collections::{HashMap, HashSet, VecDeque};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use bytes::Bytes;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use subtle::ConstantTimeEq;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
InboundChunk, PendingClass, QUEUE_ITEM_COST, SessionState, StreamState, WebSession,
|
||||||
|
inbound_queue_cost, remember_closed,
|
||||||
|
};
|
||||||
|
use crate::web::frame::{self, Frame, FrameType};
|
||||||
|
use crate::web::manager::{ManagerError, TokenHash};
|
||||||
|
|
||||||
|
impl WebSession {
|
||||||
|
/// Applies one exactly-once uplink batch.
|
||||||
|
pub(crate) fn process_up(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
sequence: u64,
|
||||||
|
body: &[u8],
|
||||||
|
) -> Result<u64, ManagerError> {
|
||||||
|
if self
|
||||||
|
.up_active
|
||||||
|
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return Err(ManagerError::Concurrent);
|
||||||
|
}
|
||||||
|
let _uplink = UplinkGuard(&self.up_active);
|
||||||
|
let frames = match frame::parse_all(body, &self.limits) {
|
||||||
|
Ok(frames) => frames,
|
||||||
|
Err(_) => {
|
||||||
|
self.close();
|
||||||
|
return Err(ManagerError::Protocol);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if frames
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.any(|value| frame::validate_client_shape(value).is_err())
|
||||||
|
{
|
||||||
|
self.close();
|
||||||
|
return Err(ManagerError::Protocol);
|
||||||
|
}
|
||||||
|
let digest: TokenHash = Sha256::digest(body).into();
|
||||||
|
let mut opened = Vec::new();
|
||||||
|
let result = {
|
||||||
|
let mut state = self.state.lock();
|
||||||
|
if state.closed {
|
||||||
|
return Err(ManagerError::Closed);
|
||||||
|
}
|
||||||
|
state.last_activity = Instant::now();
|
||||||
|
if sequence == state.last_up_sequence && sequence != 0 {
|
||||||
|
return if bool::from(state.last_up_digest.ct_eq(&digest)) {
|
||||||
|
Ok(sequence)
|
||||||
|
} else {
|
||||||
|
drop(state);
|
||||||
|
self.close();
|
||||||
|
Err(ManagerError::Protocol)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if sequence == 0 || sequence != state.last_up_sequence.saturating_add(1) {
|
||||||
|
drop(state);
|
||||||
|
self.close();
|
||||||
|
return Err(ManagerError::Protocol);
|
||||||
|
}
|
||||||
|
if !validate_batch(&state, &frames) {
|
||||||
|
drop(state);
|
||||||
|
self.close();
|
||||||
|
return Err(ManagerError::Protocol);
|
||||||
|
}
|
||||||
|
let (reserve_bytes, reserve_items) = inbound_reservation(&state, &frames);
|
||||||
|
if !self.reserve_locked(
|
||||||
|
&mut state,
|
||||||
|
reserve_bytes,
|
||||||
|
reserve_items,
|
||||||
|
PendingClass::Uplink,
|
||||||
|
) {
|
||||||
|
return Err(ManagerError::Backpressure);
|
||||||
|
}
|
||||||
|
let mut unused_bytes = reserve_bytes;
|
||||||
|
let mut unused_items = reserve_items;
|
||||||
|
let applied = self.apply_batch_locked(
|
||||||
|
&mut state,
|
||||||
|
&frames,
|
||||||
|
&mut opened,
|
||||||
|
&mut unused_bytes,
|
||||||
|
&mut unused_items,
|
||||||
|
);
|
||||||
|
self.release_locked(&mut state, unused_bytes, unused_items, false);
|
||||||
|
if !applied {
|
||||||
|
Err(ManagerError::Closed)
|
||||||
|
} else {
|
||||||
|
state.last_up_sequence = sequence;
|
||||||
|
state.last_up_digest = digest;
|
||||||
|
Ok(sequence)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if matches!(result, Err(ManagerError::Backpressure)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if result.is_err() {
|
||||||
|
self.close();
|
||||||
|
for (_, peer_port) in opened {
|
||||||
|
self.release_stream_reservation(peer_port);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
for (stream_id, peer_port) in opened {
|
||||||
|
self.spawn_stream(stream_id, peer_port);
|
||||||
|
}
|
||||||
|
if let Some(manager) = self.manager.upgrade() {
|
||||||
|
manager.record_up(body.len());
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_batch_locked(
|
||||||
|
&self,
|
||||||
|
state: &mut SessionState,
|
||||||
|
frames: &[Frame<'_>],
|
||||||
|
opened: &mut Vec<(u32, u16)>,
|
||||||
|
unused_bytes: &mut usize,
|
||||||
|
unused_items: &mut usize,
|
||||||
|
) -> bool {
|
||||||
|
for value in frames {
|
||||||
|
if value.stream_id == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let was_closed = state.closed_streams.contains(&value.stream_id);
|
||||||
|
match value.frame_type {
|
||||||
|
FrameType::Open => {
|
||||||
|
let Some(peer_port) = self.reserve_stream_locked(state) else {
|
||||||
|
remember_closed(
|
||||||
|
state,
|
||||||
|
value.stream_id,
|
||||||
|
self.limits.max_tombstones_per_session,
|
||||||
|
);
|
||||||
|
if !self.queue_control_locked(
|
||||||
|
state,
|
||||||
|
FrameType::Close,
|
||||||
|
value.stream_id,
|
||||||
|
&[],
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
state.streams.insert(
|
||||||
|
value.stream_id,
|
||||||
|
StreamState {
|
||||||
|
inbound: VecDeque::new(),
|
||||||
|
receive_window: frame::INITIAL_STREAM_WINDOW,
|
||||||
|
send_credit: u64::from(frame::INITIAL_STREAM_WINDOW),
|
||||||
|
read_waker: None,
|
||||||
|
write_waker: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
opened.push((value.stream_id, peer_port));
|
||||||
|
}
|
||||||
|
FrameType::Data if !was_closed => {
|
||||||
|
let Some(stream) = state.streams.get_mut(&value.stream_id) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
stream.receive_window -= value.payload.len() as u32;
|
||||||
|
stream.inbound.push_back(InboundChunk {
|
||||||
|
bytes: Bytes::copy_from_slice(value.payload),
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
*unused_bytes = unused_bytes
|
||||||
|
.saturating_sub(value.payload.len() + QUEUE_ITEM_COST);
|
||||||
|
*unused_items = unused_items.saturating_sub(1);
|
||||||
|
if let Some(waker) = stream.read_waker.take() {
|
||||||
|
waker.wake();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FrameType::Window if !was_closed => {
|
||||||
|
let Some(stream) = state.streams.get_mut(&value.stream_id) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let amount = frame::window_amount(value.payload).unwrap_or(0);
|
||||||
|
stream.send_credit = stream
|
||||||
|
.send_credit
|
||||||
|
.saturating_add(u64::from(amount))
|
||||||
|
.min(u64::from(u32::MAX));
|
||||||
|
if let Some(waker) = stream.write_waker.take() {
|
||||||
|
waker.wake();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FrameType::Close if !was_closed => {
|
||||||
|
let Some(stream) = state.streams.remove(&value.stream_id) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let (bytes, items) = inbound_queue_cost(&stream.inbound);
|
||||||
|
self.release_locked(state, bytes, items, false);
|
||||||
|
remember_closed(
|
||||||
|
state,
|
||||||
|
value.stream_id,
|
||||||
|
self.limits.max_tombstones_per_session,
|
||||||
|
);
|
||||||
|
if let Some(waker) = stream.read_waker {
|
||||||
|
waker.wake();
|
||||||
|
}
|
||||||
|
if let Some(waker) = stream.write_waker {
|
||||||
|
waker.wake();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FrameType::Data | FrameType::Window | FrameType::Close => {}
|
||||||
|
_ => return false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reserve_stream_locked(&self, state: &mut SessionState) -> Option<u16> {
|
||||||
|
if state.active_peer_ports.len() >= self.profile.max_streams_per_session {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let manager = self.manager.upgrade()?;
|
||||||
|
let peer_port = manager.try_acquire_stream(
|
||||||
|
self.profile_key,
|
||||||
|
self.profile.max_streams,
|
||||||
|
self.client_ip,
|
||||||
|
self.profile.public_addr,
|
||||||
|
)?;
|
||||||
|
if state.active_peer_ports.insert(peer_port) {
|
||||||
|
return Some(peer_port);
|
||||||
|
}
|
||||||
|
manager.release_stream(
|
||||||
|
self.profile_key,
|
||||||
|
self.client_ip,
|
||||||
|
self.profile.public_addr,
|
||||||
|
peer_port,
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UplinkGuard<'a>(&'a AtomicBool);
|
||||||
|
|
||||||
|
impl Drop for UplinkGuard<'_> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.0.store(false, Ordering::Release);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_batch(state: &SessionState, frames: &[Frame<'_>]) -> bool {
|
||||||
|
let mut live = state
|
||||||
|
.streams
|
||||||
|
.iter()
|
||||||
|
.map(|(id, stream)| (*id, (stream.receive_window, stream.send_credit)))
|
||||||
|
.collect::<HashMap<_, _>>();
|
||||||
|
let mut closed = HashSet::new();
|
||||||
|
for value in frames {
|
||||||
|
if value.stream_id == 0 {
|
||||||
|
if value.frame_type != FrameType::Pong {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let was_closed = state.closed_streams.contains(&value.stream_id)
|
||||||
|
|| closed.contains(&value.stream_id);
|
||||||
|
match value.frame_type {
|
||||||
|
FrameType::Open => {
|
||||||
|
if live.contains_key(&value.stream_id) || was_closed {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
live.insert(
|
||||||
|
value.stream_id,
|
||||||
|
(
|
||||||
|
frame::INITIAL_STREAM_WINDOW,
|
||||||
|
u64::from(frame::INITIAL_STREAM_WINDOW),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
FrameType::Data if !was_closed => {
|
||||||
|
let Some((receive_window, send_credit)) = live.get_mut(&value.stream_id) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Ok(payload_len) = u32::try_from(value.payload.len()) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if payload_len > *receive_window {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
*receive_window -= payload_len;
|
||||||
|
let _ = send_credit;
|
||||||
|
}
|
||||||
|
FrameType::Window if !was_closed => {
|
||||||
|
let Some((_, send_credit)) = live.get_mut(&value.stream_id) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Ok(amount) = frame::window_amount(value.payload) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
*send_credit = send_credit
|
||||||
|
.saturating_add(u64::from(amount))
|
||||||
|
.min(u64::from(u32::MAX));
|
||||||
|
}
|
||||||
|
FrameType::Close if !was_closed => {
|
||||||
|
if live.remove(&value.stream_id).is_none() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
closed.insert(value.stream_id);
|
||||||
|
}
|
||||||
|
FrameType::Data | FrameType::Window | FrameType::Close => {}
|
||||||
|
_ => return false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inbound_reservation(state: &SessionState, frames: &[Frame<'_>]) -> (usize, usize) {
|
||||||
|
let mut live = state.streams.keys().copied().collect::<HashSet<_>>();
|
||||||
|
let mut bytes = 0usize;
|
||||||
|
let mut items = 0usize;
|
||||||
|
for value in frames {
|
||||||
|
match value.frame_type {
|
||||||
|
FrameType::Open => {
|
||||||
|
live.insert(value.stream_id);
|
||||||
|
}
|
||||||
|
FrameType::Data if live.contains(&value.stream_id) => {
|
||||||
|
bytes = bytes.saturating_add(value.payload.len() + QUEUE_ITEM_COST);
|
||||||
|
items = items.saturating_add(1);
|
||||||
|
}
|
||||||
|
FrameType::Close => {
|
||||||
|
live.remove(&value.stream_id);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(bytes, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
|
use crate::config::{
|
||||||
|
WebLimitsConfig, WebRuntimeProfile, WebSecretMode, WebTimeoutsConfig,
|
||||||
|
};
|
||||||
|
use crate::web::manager::WebProcessRuntime;
|
||||||
|
|
||||||
|
fn session() -> Arc<WebSession> {
|
||||||
|
let profile = Arc::new(WebRuntimeProfile {
|
||||||
|
host: "proxy.example.com".to_string(),
|
||||||
|
public_addr: SocketAddr::from(([203, 0, 113, 10], 443)),
|
||||||
|
user: "alice".to_string(),
|
||||||
|
secret_mode: WebSecretMode::Plain,
|
||||||
|
capability: [0; 32],
|
||||||
|
max_sessions: 1,
|
||||||
|
max_streams: 1,
|
||||||
|
max_streams_per_session: 1,
|
||||||
|
});
|
||||||
|
WebSession::new(
|
||||||
|
std::sync::Weak::<WebProcessRuntime>::new(),
|
||||||
|
[1; 32],
|
||||||
|
"192.0.2.10".parse().unwrap(),
|
||||||
|
profile,
|
||||||
|
[2; 32],
|
||||||
|
WebLimitsConfig::default(),
|
||||||
|
WebTimeoutsConfig::default(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uplink_retry_commits_only_one_exact_body() {
|
||||||
|
let session = session();
|
||||||
|
let first = frame::encode(FrameType::Pong, 0, &[1, 2, 3]);
|
||||||
|
assert_eq!(session.process_up(1, &first), Ok(1));
|
||||||
|
assert_eq!(session.process_up(1, &first), Ok(1));
|
||||||
|
|
||||||
|
let changed = frame::encode(FrameType::Pong, 0, &[1, 2, 4]);
|
||||||
|
assert_eq!(session.process_up(1, &changed), Err(ManagerError::Protocol));
|
||||||
|
assert!(session.state.lock().closed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn concurrent_uplink_does_not_commit_sequence() {
|
||||||
|
let session = session();
|
||||||
|
let body = frame::encode(FrameType::Pong, 0, &[]);
|
||||||
|
session.up_active.store(true, Ordering::Release);
|
||||||
|
assert_eq!(
|
||||||
|
session.process_up(1, &body),
|
||||||
|
Err(ManagerError::Concurrent)
|
||||||
|
);
|
||||||
|
assert_eq!(session.state.lock().last_up_sequence, 0);
|
||||||
|
session.up_active.store(false, Ordering::Release);
|
||||||
|
assert_eq!(session.process_up(1, &body), Ok(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backpressured_uplink_does_not_commit_or_close() {
|
||||||
|
let session = session();
|
||||||
|
{
|
||||||
|
let mut state = session.state.lock();
|
||||||
|
state.streams.insert(
|
||||||
|
1,
|
||||||
|
StreamState {
|
||||||
|
inbound: VecDeque::new(),
|
||||||
|
receive_window: frame::INITIAL_STREAM_WINDOW,
|
||||||
|
send_credit: u64::from(frame::INITIAL_STREAM_WINDOW),
|
||||||
|
read_waker: None,
|
||||||
|
write_waker: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
state.pending_bytes = session.limits.pending_bytes_per_session;
|
||||||
|
}
|
||||||
|
let body = frame::encode(FrameType::Data, 1, &[1]);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
session.process_up(1, &body),
|
||||||
|
Err(ManagerError::Backpressure)
|
||||||
|
);
|
||||||
|
let state = session.state.lock();
|
||||||
|
assert!(!state.closed);
|
||||||
|
assert_eq!(state.last_up_sequence, 0);
|
||||||
|
assert!(state.streams.get(&1).unwrap().inbound.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uplink_gap_is_fatal() {
|
||||||
|
let session = session();
|
||||||
|
let body = frame::encode(FrameType::Pong, 0, &[]);
|
||||||
|
assert_eq!(session.process_up(2, &body), Err(ManagerError::Protocol));
|
||||||
|
assert!(session.state.lock().closed);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
use std::future::Future;
|
||||||
|
use std::io;
|
||||||
|
use std::pin::Pin;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::task::{Context, Poll};
|
||||||
|
|
||||||
|
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
|
||||||
|
use tokio::sync::futures::OwnedNotified;
|
||||||
|
|
||||||
|
use crate::web::session::WebSession;
|
||||||
|
|
||||||
|
/// Async byte stream that maps one WEB stream identifier onto carrier frames.
|
||||||
|
pub(crate) struct WebLogicalStream {
|
||||||
|
session: Arc<WebSession>,
|
||||||
|
stream_id: u32,
|
||||||
|
budget_wait: Option<Pin<Box<OwnedNotified>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WebLogicalStream {
|
||||||
|
/// Binds a virtual byte stream to one live carrier stream identifier.
|
||||||
|
pub(crate) fn new(session: Arc<WebSession>, stream_id: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
session,
|
||||||
|
stream_id,
|
||||||
|
budget_wait: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsyncRead for WebLogicalStream {
|
||||||
|
fn poll_read(
|
||||||
|
self: Pin<&mut Self>,
|
||||||
|
cx: &mut Context<'_>,
|
||||||
|
output: &mut ReadBuf<'_>,
|
||||||
|
) -> Poll<io::Result<()>> {
|
||||||
|
self.session.poll_read(self.stream_id, cx, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsyncWrite for WebLogicalStream {
|
||||||
|
fn poll_write(
|
||||||
|
mut self: Pin<&mut Self>,
|
||||||
|
cx: &mut Context<'_>,
|
||||||
|
input: &[u8],
|
||||||
|
) -> Poll<io::Result<usize>> {
|
||||||
|
let result = self.session.poll_write(self.stream_id, cx, input);
|
||||||
|
if !result.is_pending() {
|
||||||
|
self.budget_wait = None;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register before retrying so a concurrent global-capacity release cannot be lost.
|
||||||
|
loop {
|
||||||
|
if self.budget_wait.is_none()
|
||||||
|
&& let Some(notify) = self.session.budget_notify()
|
||||||
|
{
|
||||||
|
self.budget_wait = Some(Box::pin(notify.notified_owned()));
|
||||||
|
}
|
||||||
|
let Some(wait) = self.budget_wait.as_mut() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
if wait.as_mut().poll(cx).is_pending() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
self.budget_wait = None;
|
||||||
|
}
|
||||||
|
match self.session.poll_write(self.stream_id, cx, input) {
|
||||||
|
Poll::Ready(result) => {
|
||||||
|
self.budget_wait = None;
|
||||||
|
Poll::Ready(result)
|
||||||
|
}
|
||||||
|
Poll::Pending => Poll::Pending,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||||
|
Poll::Ready(Ok(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||||
|
Poll::Ready(Ok(()))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user