Configurable HTTP Methods for Bridge Carriers

Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
This commit is contained in:
Alexey
2026-10-01 23:40:08 +03:00
parent 94f4f7d540
commit fca6506449
17 changed files with 763 additions and 17 deletions
+5
View File
@@ -17,6 +17,7 @@
//! | `network` | `dns_overrides` | Applied immediately |
//! | `access` | All user/quota fields | Effective immediately |
//! | `web` | Carrier, timing, and debug policy | Applied to newly issued sessions |
//! | `web` | `carrier_method` | Applied to newly rendered bridge pages |
//! Fields that require re-binding sockets (`server.listeners`, legacy
//! `server.port`, `censorship.*`, `network.*`, `use_middle_proxy`) are **not**
//! applied; a warning is emitted. SYN limiter rules are process-owned and are
@@ -67,3 +68,7 @@ use watcher::{ReloadState, reload_config};
mod base_path_tests;
#[cfg(test)]
mod tests;
// Carrier method reloads preserve page-owned requests and process-owned limits.
#[cfg(test)]
#[path = "hot_reload/carrier_method_tests.rs"]
mod carrier_method_tests;
@@ -0,0 +1,50 @@
use super::*;
use crate::config::WebCarrierMethod;
#[test]
fn carrier_method_reload_is_hot_and_preserves_process_limits() {
let mut active = ProxyConfig::default();
for method in [WebCarrierMethod::Put, WebCarrierMethod::Post] {
let mut desired = active.clone();
desired.web.carrier_method = method;
assert_eq!(classify_config_changes(&active, &desired).changed, ["web"]);
assert!(!classify_config_changes(&active, &desired).restart_required);
desired.web.limits.max_http_connections += 1;
let applied = overlay_hot_fields(&active, &desired);
assert_eq!(applied.web.carrier_method, method);
assert_eq!(
applied.web.limits.max_http_connections,
active.web.limits.max_http_connections
);
active = applied;
}
}
#[test]
fn carrier_method_reload_publishes_both_directions_and_keeps_last_good_value() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.toml");
let source = |method| format!("[web]\ncarrier_method = \"{method}\"\n");
std::fs::write(&path, source("post")).unwrap();
let initial = Arc::new(ProxyConfig::load(&path).unwrap());
let initial_hash = ProxyConfig::load_with_metadata(&path)
.unwrap()
.rendered_hash;
let (config_tx, _config_rx) = watch::channel(Arc::clone(&initial));
let (log_tx, _log_rx) = watch::channel(initial.general.log_level.clone());
let mut reload_state = ReloadState::new(Some(initial_hash));
for (token, method) in [
("put", WebCarrierMethod::Put),
("post", WebCarrierMethod::Post),
] {
std::fs::write(&path, source(token)).unwrap();
reload_config(&path, &config_tx, &log_tx, None, None, &mut reload_state).unwrap();
let applied = config_tx.borrow().clone();
assert_eq!(applied.web.carrier_method, method);
std::fs::write(&path, source("PATCH")).unwrap();
reload_config(&path, &config_tx, &log_tx, None, None, &mut reload_state);
let unchanged = config_tx.borrow().clone();
assert!(Arc::ptr_eq(&unchanged, &applied));
}
}
+1
View File
@@ -263,6 +263,7 @@ const LISTENER_CONFIG_KEYS: &[&str] = &[
const WEB_CONFIG_KEYS: &[&str] = &[
"enabled",
"carrier",
"carrier_method",
"carriers",
"carrier_learning",
"carrier_negotiation_aggressiveness",
@@ -3,6 +3,10 @@ use super::*;
#[path = "web_tests/base_path_tests.rs"]
mod base_path_tests;
// Method selection must not alter capability identity or accepted config literals.
#[path = "web_tests/carrier_method_tests.rs"]
mod carrier_method_tests;
const WEB_CONFIG: &str = r#"
[access.users]
alice = "000102030405060708090a0b0c0d0e0f"
@@ -0,0 +1,48 @@
use super::*;
#[test]
fn carrier_method_defaults_and_roundtrips_without_changing_capabilities() {
let default = load_config_from_temp_toml(WEB_CONFIG);
assert_eq!(default.web.carrier_method, WebCarrierMethod::Post);
let capabilities = default.web.runtime.as_ref().unwrap().capabilities.clone();
for (token, method) in [
("post", WebCarrierMethod::Post),
("put", WebCarrierMethod::Put),
] {
let configured = WEB_CONFIG.replace(
"carrier = \"https-lanes\"",
&format!("carrier = \"https-lanes\"\ncarrier_method = \"{token}\""),
);
let source = format!("[general]\nconfig_strict = true\n{configured}");
let config = load_config_from_temp_toml(&source);
assert_eq!(config.web.carrier_method, method);
assert_eq!(
config.web.runtime.as_ref().unwrap().capabilities,
capabilities
);
let json = serde_json::to_value(&config.web).unwrap();
assert_eq!(json["carrier_method"], token);
let decoded: WebConfig = serde_json::from_value(json).unwrap();
assert_eq!(decoded.carrier_method, method);
let serialized = toml::to_string(&config.web).unwrap();
let decoded: WebConfig = toml::from_str(&serialized).unwrap();
assert_eq!(decoded.carrier_method, method);
}
}
#[test]
fn carrier_method_rejects_unknown_tokens_types_and_aliases() {
for value in ["\"POST\"", "\"PUT\"", "\"patch\"", "true", "42", "[]"] {
let source = WEB_CONFIG.replace(
"carrier = \"https-lanes\"",
&format!("carrier = \"https-lanes\"\ncarrier_method = {value}"),
);
assert!(load_config_error_from_temp_toml(&source).contains("carrier_method"));
}
let configured = WEB_CONFIG.replace(
"carrier = \"https-lanes\"",
"carrier = \"https-lanes\"\nhttp_method = \"put\"",
);
let source = format!("[general]\nconfig_strict = true\n{configured}");
assert!(load_config_error_from_temp_toml(&source).contains("http_method"));
}
+1 -1
View File
@@ -63,7 +63,7 @@ pub(crate) use web::{
};
pub(crate) use web_carrier::WEB_CARRIER_LEARNING_MIN_ENTRIES;
#[allow(unused_imports)]
pub use web_carrier::{WebCarrier, WebCarriers};
pub use web_carrier::{WebCarrier, WebCarrierMethod, WebCarriers};
pub(crate) use web_debug::web_debug_fits_limits;
pub use web_debug::{WebDebugBodyCapture, WebDebugConfig};
+5 -1
View File
@@ -6,7 +6,7 @@ use std::sync::Arc;
use bytes::Bytes;
use serde::{Deserialize, Serialize};
use super::web_carrier::{WebCarrier, WebCarriers};
use super::web_carrier::{WebCarrier, WebCarrierMethod, WebCarriers};
use super::web_debug::WebDebugConfig;
// Serialized WEB defaults remain separate from the runtime data model.
@@ -413,6 +413,9 @@ pub struct WebConfig {
/// Sole carrier when negotiation is disabled and final fallback when enabled.
#[serde(default)]
pub carrier: WebCarrier,
/// Page-owned HTTP method for HTTPS uplink and downlink, including retries.
#[serde(default)]
pub carrier_method: WebCarrierMethod,
/// Ordered carriers considered by server-side negotiation before the fallback carrier.
#[serde(default)]
pub carriers: WebCarriers,
@@ -471,6 +474,7 @@ impl Default for WebConfig {
Self {
enabled: false,
carrier: WebCarrier::default(),
carrier_method: WebCarrierMethod::default(),
carriers: WebCarriers::default(),
carrier_learning: default_web_carrier_learning(),
carrier_negotiation_aggressiveness: WebCarrierNegotiationAggressiveness::default(),
+21
View File
@@ -3,6 +3,27 @@ use serde::{Deserialize, Serialize};
/// Minimum restart-owned entries required for one complete learning sample.
pub(crate) const WEB_CARRIER_LEARNING_MIN_ENTRIES: usize = 3;
/// HTTP method emitted by newly rendered HTTPS carrier bridges.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum WebCarrierMethod {
/// Preserve the original bridge request method.
#[default]
Post,
/// Allow idempotent-method connection reuse in Linux WebKitGTK.
Put,
}
impl WebCarrierMethod {
/// Returns the HTTP token embedded in the page-owned carrier policy.
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Post => "POST",
Self::Put => "PUT",
}
}
}
/// Carrier selected for one newly issued WEB relay session.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]