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:
Alexey
2026-08-23 03:12:11 +03:00
parent 8dbd24b11b
commit 1029703c2c
58 changed files with 7460 additions and 2646 deletions
+18 -1
View File
@@ -14,6 +14,7 @@ use crate::ip_tracker::UserIpTracker;
use crate::proxy::route_mode::RelayRouteMode;
use crate::proxy::route_mode::RouteRuntimeController;
use crate::proxy::shared_state::ProxySharedState;
use crate::proxy::authenticated::ClientRuntimeDeps;
use crate::stats::beobachten::BeobachtenStore;
use crate::stats::{ReplayChecker, Stats};
use crate::stream::BufferPool;
@@ -227,6 +228,22 @@ impl RuntimeGeneration {
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.
pub(crate) fn spawn_session<F>(&self, future: F) -> bool
where
@@ -287,7 +304,7 @@ impl RuntimeGeneration {
#[cfg(test)]
/// 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 (_admission_tx, admission_rx) = watch::channel(true);
let stats = Arc::new(Stats::new());
+40
View File
@@ -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<()> {
if let Some(parent) = std::path::Path::new(path).parent()
&& !parent.as_os_str().is_empty()
+58 -2
View File
@@ -6,11 +6,13 @@ use tokio::net::{TcpListener, TcpStream};
use tokio::sync::OwnedSemaphorePermit;
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use tokio_util::task::TaskTracker;
use tracing::{debug, error, info, warn};
use crate::config::RstOnCloseMode;
use crate::config::{ListenerTransport, RstOnCloseMode};
use crate::proxy::ClientHandler;
use crate::transport::socket::set_linger_zero;
use crate::web::manager::WebProcessRuntime;
use super::bind::BoundTcpListener;
use super::plan::ListenerBindSpec;
@@ -19,11 +21,15 @@ use crate::maestro::helpers::{
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) spec: ListenerBindSpec,
listener: Arc<TcpListener>,
cancellation: CancellationToken,
task: Option<JoinHandle<()>>,
connections: TaskTracker,
web_runtime: Option<Arc<WebProcessRuntime>>,
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
}
enum PermitWait {
@@ -181,6 +187,8 @@ async fn run_accept_loop(
listener: Arc<TcpListener>,
spec: ListenerBindSpec,
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
web_runtime: Option<Arc<WebProcessRuntime>>,
connections: TaskTracker,
cancellation: CancellationToken,
) {
loop {
@@ -191,6 +199,26 @@ async fn run_accept_loop(
};
match accepted {
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();
if !*runtime.admission_rx.borrow() {
debug!(peer = %peer_addr, "Admission gate closed, dropping connection");
@@ -232,12 +260,16 @@ impl ListenerSlot {
pub(super) fn start(
bound: BoundTcpListener,
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
web_runtime: Option<Arc<WebProcessRuntime>>,
) -> Self {
let cancellation = CancellationToken::new();
let connections = TaskTracker::new();
let task = tokio::spawn(run_accept_loop(
bound.listener.clone(),
bound.spec.clone(),
active_runtime,
active_runtime.clone(),
web_runtime.clone(),
connections.clone(),
cancellation.clone(),
));
Self {
@@ -245,6 +277,9 @@ impl ListenerSlot {
listener: bound.listener,
cancellation,
task: Some(task),
connections,
web_runtime,
active_runtime,
}
}
@@ -255,15 +290,36 @@ impl ListenerSlot {
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(())
}
pub(super) fn restart(&mut self, active_runtime: Arc<ArcSwap<RuntimeGeneration>>) {
self.active_runtime = active_runtime.clone();
self.cancellation = CancellationToken::new();
self.connections = TaskTracker::new();
self.task = Some(tokio::spawn(run_accept_loop(
self.listener.clone(),
self.spec.clone(),
active_runtime,
self.web_runtime.clone(),
self.connections.clone(),
self.cancellation.clone(),
)));
}
+15 -5
View File
@@ -8,13 +8,13 @@ use tokio::net::TcpListener;
use tokio::net::UnixListener;
use tracing::{error, info, warn};
use crate::config::ProxyConfig;
use crate::config::{ListenerTransport, ProxyConfig};
use crate::startup::{COMPONENT_LISTENERS_BIND, StartupTracker};
use crate::transport::find_listener_processes;
use crate::transport::socket::{activate_listener_socket, bind_listener_socket};
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.
pub(crate) struct BoundListeners {
@@ -57,7 +57,8 @@ fn default_link_port(config: &ProxyConfig) -> u16 {
config
.server
.listeners
.first()
.iter()
.find(|listener| listener.transport == ListenerTransport::Mtproxy)
.and_then(|listener| listener.port)
.unwrap_or(config.server.port)
}
@@ -110,7 +111,7 @@ impl PreparedTcpListener {
}
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 {
info!(
addr = %spec.addr,
@@ -135,7 +136,11 @@ fn print_configured_links(
detected_ip_v4: Option<IpAddr>,
detected_ip_v6: Option<IpAddr>,
) {
print_web_proxy_links(config);
for listener in &config.server.listeners {
if listener.transport != ListenerTransport::Mtproxy {
continue;
}
let port = listener.port.unwrap_or(config.server.port);
let addr = SocketAddr::new(listener.ip, port);
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;
}
let host = config
+48 -2
View File
@@ -5,11 +5,13 @@ use std::sync::Arc;
use arc_swap::ArcSwap;
use crate::config::ProxyConfig;
use crate::config::ListenerTransport;
use crate::maestro::generation::RuntimeGeneration;
use super::accept::ListenerSlot;
use super::bind::{BoundListeners, BoundTcpListener, PreparedTcpListener, prepare_listener};
use super::plan::{ListenerBindSpec, listener_bind_plan};
use crate::web::manager::WebProcessRuntime;
#[cfg(unix)]
use super::unix::UnixAcceptHandle;
@@ -17,16 +19,19 @@ use super::unix::UnixAcceptHandle;
pub(crate) struct ListenerManager {
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
slots: BTreeMap<SocketAddr, ListenerSlot>,
web_runtime: Option<Arc<WebProcessRuntime>>,
#[cfg(unix)]
unix: Option<UnixAcceptHandle>,
}
/// Socket changes prepared without activating or stopping accept loops.
pub(crate) struct PreparedListenerTransition {
target_specs: BTreeMap<SocketAddr, ListenerBindSpec>,
additions: Vec<PreparedTcpListener>,
removals: Vec<SocketAddr>,
}
/// Activated additions and stopped removals awaiting runtime publication.
pub(crate) struct PendingListenerTransition {
target_specs: BTreeMap<SocketAddr, ListenerBindSpec>,
additions: Vec<BoundTcpListener>,
@@ -39,10 +44,22 @@ impl ListenerManager {
bound: BoundListeners,
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
) -> 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();
for listener in bound.listeners {
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)]
let unix = bound
@@ -51,6 +68,7 @@ impl ListenerManager {
Self {
active_runtime,
slots,
web_runtime,
#[cfg(unix)]
unix,
}
@@ -61,6 +79,7 @@ impl ListenerManager {
Self {
active_runtime,
slots: BTreeMap::new(),
web_runtime: None,
#[cfg(unix)]
unix: None,
}
@@ -72,6 +91,20 @@ impl ListenerManager {
desired: &ProxyConfig,
) -> Result<Option<PreparedListenerTransition>, String> {
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 target_addresses: BTreeSet<_> = target_specs.keys().copied().collect();
if current_addresses == target_addresses
@@ -164,7 +197,11 @@ impl ListenerManager {
let addr = listener.spec.addr;
self.slots.insert(
addr,
ListenerSlot::start(listener, self.active_runtime.clone()),
ListenerSlot::start(
listener,
self.active_runtime.clone(),
self.web_runtime.clone(),
),
);
}
debug_assert_eq!(
@@ -191,6 +228,9 @@ impl ListenerManager {
errors.push(error_value);
}
self.slots.clear();
if let Some(web_runtime) = self.web_runtime.take() {
web_runtime.shutdown().await;
}
#[cfg(unix)]
{
self.unix = None;
@@ -214,6 +254,7 @@ mod tests {
fn listener_config(addr: SocketAddr) -> ListenerConfig {
ListenerConfig {
ip: addr.ip(),
transport: crate::config::ListenerTransport::Mtproxy,
port: Some(addr.port()),
client_mss: None,
synlimit: SynLimitMode::Off,
@@ -229,6 +270,8 @@ mod tests {
announce_ip: None,
proxy_protocol: None,
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 spec = ListenerBindSpec {
addr,
transport: crate::config::ListenerTransport::Mtproxy,
options: ListenOptions {
reuse_port: false,
..Default::default()
},
proxy_protocol: false,
tls_response_fragment_size: None,
web_client_ip_source: crate::config::WebClientIpSource::XForwardedFor,
web_trusted_proxy_cidrs: Arc::from([]),
};
(
BoundTcpListener {
+41 -5
View File
@@ -1,7 +1,10 @@
use std::collections::{BTreeMap, BTreeSet};
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 super::tcp_mss_runtime_profile;
@@ -10,9 +13,12 @@ use super::tcp_mss_runtime_profile;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct ListenerBindSpec {
pub(super) addr: SocketAddr,
pub(super) transport: ListenerTransport,
pub(super) options: ListenOptions,
pub(super) proxy_protocol: bool,
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 {
@@ -40,16 +46,24 @@ pub(crate) fn listener_bind_plan(
if addr.is_ipv6() && config.network.ipv6 == Some(false) {
continue;
}
let configured_client_mss = listener
.effective_client_mss(&config.server)
.map_err(|error| format!("invalid client MSS for listener {addr}: {error}"))?;
let configured_client_mss = if listener.transport == ListenerTransport::Web {
None
} 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")]
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"))]
let (client_mss, tls_response_fragment_size) = (configured_client_mss, None);
let spec = ListenerBindSpec {
addr,
transport: listener.transport,
options: ListenOptions {
reuse_port: listener.reuse_allow,
ipv6_only: listener.ip.is_ipv6(),
@@ -61,6 +75,8 @@ pub(crate) fn listener_bind_plan(
.proxy_protocol
.unwrap_or(config.server.proxy_protocol),
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() {
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.
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) {
return false;
}
@@ -107,6 +140,7 @@ mod tests {
fn listener(ip: &str, port: u16) -> ListenerConfig {
ListenerConfig {
ip: ip.parse().unwrap(),
transport: crate::config::ListenerTransport::Mtproxy,
port: Some(port),
client_mss: None,
synlimit: SynLimitMode::Off,
@@ -122,6 +156,8 @@ mod tests {
announce_ip: None,
proxy_protocol: None,
reuse_allow: false,
web_client_ip_source: crate::config::WebClientIpSource::XForwardedFor,
web_trusted_proxy_cidrs: Vec::new(),
}
}
+3
View File
@@ -131,6 +131,7 @@ mod tests {
fn listener_with_synlimit(synlimit: SynLimitMode) -> ListenerConfig {
ListenerConfig {
ip: "127.0.0.1".parse().unwrap(),
transport: crate::config::ListenerTransport::Mtproxy,
port: Some(443),
client_mss: None,
synlimit,
@@ -146,6 +147,8 @@ mod tests {
announce_ip: None,
proxy_protocol: None,
reuse_allow: false,
web_client_ip_source: crate::config::WebClientIpSource::XForwardedFor,
web_trusted_proxy_cidrs: Vec::new(),
}
}
+10
View File
@@ -406,6 +406,16 @@ pub(crate) fn resolve_reload_config(
fields.push("logging".to_string());
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);
ResolvedReloadConfig {
effective,
+27
View File
@@ -3,6 +3,7 @@ use super::*;
fn test_listener(port: u16) -> crate::config::ListenerConfig {
crate::config::ListenerConfig {
ip: "127.0.0.1".parse().unwrap(),
transport: crate::config::ListenerTransport::Mtproxy,
port: Some(port),
client_mss: None,
synlimit: crate::config::SynLimitMode::Off,
@@ -18,6 +19,8 @@ fn test_listener(port: u16) -> crate::config::ListenerConfig {
announce_ip: None,
proxy_protocol: None,
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();
old.server.listeners.push(crate::config::ListenerConfig {
ip: "0.0.0.0".parse().unwrap(),
transport: crate::config::ListenerTransport::Mtproxy,
port: Some(443),
client_mss: None,
synlimit: crate::config::SynLimitMode::Off,
@@ -95,6 +99,8 @@ fn listener_announcement_is_runtime_owned_when_bind_identity_is_stable() {
announce_ip: None,
proxy_protocol: None,
reuse_allow: false,
web_client_ip_source: crate::config::WebClientIpSource::XForwardedFor,
web_trusted_proxy_cidrs: Vec::new(),
});
let mut desired = old.clone();
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());
}
#[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]
fn strict_middle_proxy_requires_a_prepared_pool() {
assert!(strict_middle_proxy_unavailable(true, false, false));