mirror of
https://github.com/telemt/telemt.git
synced 2026-11-01 13:21:58 +03:00
Hardened listener reload + config persistence + SYN Limit startup safety
This commit is contained in:
+139
-340
@@ -7,16 +7,17 @@ use toml::Value as Toml;
|
|||||||
|
|
||||||
use super::ApiShared;
|
use super::ApiShared;
|
||||||
use super::config_store::{
|
use super::config_store::{
|
||||||
EDITABLE_SECTIONS, EDITABLE_SERVER_FIELDS, compute_revision, current_revision,
|
EDITABLE_SECTIONS, EDITABLE_SERVER_FIELDS, compute_snapshot_revision, is_editable_section,
|
||||||
is_editable_section, load_config_from_disk, save_sections_to_disk,
|
load_candidate_snapshot, load_config_snapshot, render_server_listeners,
|
||||||
|
render_top_level_section, resolve_single_source_owner, upsert_toml_table, write_atomic,
|
||||||
};
|
};
|
||||||
use super::model::ApiFailure;
|
use super::model::ApiFailure;
|
||||||
use crate::config::ProxyConfig;
|
use crate::config::ProxyConfig;
|
||||||
use crate::config::hot_reload::classify_config_changes;
|
use crate::config::hot_reload::classify_config_changes;
|
||||||
use crate::maestro::reload::{ReloadAccepted, ReloadRequest, ReloadSubmitError};
|
use crate::maestro::reload::{ReloadAccepted, ReloadRequest, ReloadSubmitError};
|
||||||
use crate::maestro::runtime_build::deferred_process_fields;
|
use crate::maestro::runtime_build::{deferred_process_fields, resolve_reload_config};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::path::Path;
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -31,9 +32,15 @@ pub(super) struct PatchConfigResponse {
|
|||||||
pub reload: Option<ReloadAccepted>,
|
pub reload: Option<ReloadAccepted>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shared-state wrapper around [`apply_patch_to_path`]: serializes config
|
struct PreparedConfigPatch {
|
||||||
/// mutations behind `mutation_lock`, then records a runtime event. The route
|
owner_path: PathBuf,
|
||||||
/// handler calls this; the core logic stays decoupled for unit tests.
|
owner_contents: String,
|
||||||
|
desired_config: Arc<ProxyConfig>,
|
||||||
|
response: PatchConfigResponse,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serializes config mutations behind `mutation_lock`, commits them, and records
|
||||||
|
/// a runtime event. The route handler calls this shared-state wrapper.
|
||||||
pub(super) async fn patch_config(
|
pub(super) async fn patch_config(
|
||||||
patch_json: Json,
|
patch_json: Json,
|
||||||
expected_revision: Option<String>,
|
expected_revision: Option<String>,
|
||||||
@@ -41,36 +48,29 @@ pub(super) async fn patch_config(
|
|||||||
shared: &ApiShared,
|
shared: &ApiShared,
|
||||||
) -> Result<PatchConfigResponse, ApiFailure> {
|
) -> Result<PatchConfigResponse, ApiFailure> {
|
||||||
let _guard = shared.mutation_lock.lock().await;
|
let _guard = shared.mutation_lock.lock().await;
|
||||||
if reload_request.is_some()
|
let active_config = shared.active_runtime.load_full().config();
|
||||||
&& let Some(reload_id) = shared.reload_control.in_progress().await
|
let mut prepared =
|
||||||
{
|
prepare_patch_to_path(&shared.config_path, &patch_json, expected_revision).await?;
|
||||||
return Err(ApiFailure::new(
|
let resolved = resolve_reload_config(&active_config, &prepared.desired_config);
|
||||||
hyper::StatusCode::CONFLICT,
|
prepared.response.runtime_reload_required = resolved.runtime_changed;
|
||||||
"reload_in_progress",
|
prepared.response.process_restart_required = !resolved.deferred_process_fields.is_empty();
|
||||||
format!("Reload {} is already in progress", reload_id),
|
prepared.response.deferred_process_fields = resolved.deferred_process_fields;
|
||||||
));
|
let reservation = if let Some(request) = reload_request.filter(|_| resolved.runtime_changed) {
|
||||||
}
|
Some(
|
||||||
let mut resp = apply_patch_to_path(&shared.config_path, &patch_json, expected_revision).await?;
|
shared
|
||||||
if let Some(request) = reload_request {
|
|
||||||
let config = Arc::new(load_config_from_disk(&shared.config_path).await?);
|
|
||||||
let accepted = shared
|
|
||||||
.reload_control
|
.reload_control
|
||||||
.submit(config, resp.revision.clone(), request)
|
.reserve(prepared.response.revision.clone(), request)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| match error {
|
.map_err(reload_submit_failure)?,
|
||||||
ReloadSubmitError::InProgress(reload_id) => ApiFailure::new(
|
)
|
||||||
hyper::StatusCode::CONFLICT,
|
} else {
|
||||||
"reload_in_progress",
|
None
|
||||||
format!("Reload {} is already in progress", reload_id),
|
};
|
||||||
),
|
write_atomic(prepared.owner_path, prepared.owner_contents).await?;
|
||||||
ReloadSubmitError::MaestroUnavailable => ApiFailure::new(
|
if let Some(reservation) = reservation {
|
||||||
hyper::StatusCode::SERVICE_UNAVAILABLE,
|
prepared.response.reload = Some(reservation.enqueue(prepared.desired_config));
|
||||||
"maestro_unavailable",
|
|
||||||
"Maestro reload coordinator is unavailable",
|
|
||||||
),
|
|
||||||
})?;
|
|
||||||
resp.reload = Some(accepted);
|
|
||||||
}
|
}
|
||||||
|
let resp = prepared.response;
|
||||||
drop(_guard);
|
drop(_guard);
|
||||||
shared
|
shared
|
||||||
.runtime_events
|
.runtime_events
|
||||||
@@ -80,13 +80,25 @@ pub(super) async fn patch_config(
|
|||||||
|
|
||||||
/// Core patch logic, decoupled from hyper/shared-state so it is unit-testable
|
/// Core patch logic, decoupled from hyper/shared-state so it is unit-testable
|
||||||
/// against a temp file. The route handler holds `mutation_lock` while calling this.
|
/// against a temp file. The route handler holds `mutation_lock` while calling this.
|
||||||
|
#[cfg(test)]
|
||||||
pub(super) async fn apply_patch_to_path(
|
pub(super) async fn apply_patch_to_path(
|
||||||
config_path: &Path,
|
config_path: &Path,
|
||||||
patch_json: &Json,
|
patch_json: &Json,
|
||||||
expected_revision: Option<String>,
|
expected_revision: Option<String>,
|
||||||
) -> Result<PatchConfigResponse, ApiFailure> {
|
) -> Result<PatchConfigResponse, ApiFailure> {
|
||||||
|
let prepared = prepare_patch_to_path(config_path, patch_json, expected_revision).await?;
|
||||||
|
write_atomic(prepared.owner_path, prepared.owner_contents).await?;
|
||||||
|
Ok(prepared.response)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn prepare_patch_to_path(
|
||||||
|
config_path: &Path,
|
||||||
|
patch_json: &Json,
|
||||||
|
expected_revision: Option<String>,
|
||||||
|
) -> Result<PreparedConfigPatch, ApiFailure> {
|
||||||
// 1. optimistic concurrency
|
// 1. optimistic concurrency
|
||||||
let current = current_revision(config_path).await?;
|
let loaded = load_config_snapshot(config_path, false).await?;
|
||||||
|
let current = compute_snapshot_revision(&loaded);
|
||||||
if expected_revision.is_some_and(|expected| expected != current) {
|
if expected_revision.is_some_and(|expected| expected != current) {
|
||||||
return Err(ApiFailure::new(
|
return Err(ApiFailure::new(
|
||||||
hyper::StatusCode::CONFLICT,
|
hyper::StatusCode::CONFLICT,
|
||||||
@@ -129,57 +141,110 @@ pub(super) async fn apply_patch_to_path(
|
|||||||
return Err(ApiFailure::bad_request("empty patch: no editable sections"));
|
return Err(ApiFailure::bad_request("empty patch: no editable sections"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Parse old + merged from the SAME deserialize path so the classifier
|
// 3. Merge against the fully expanded and normalized desired config. The
|
||||||
// sees only the delta this patch introduces. `ProxyConfig::load` applies
|
// source owner is resolved separately so included sections stay in their
|
||||||
// include-expansion / legacy-compat / normalization that a bare
|
// original file and unrelated source files remain byte-identical.
|
||||||
// `try_into` does not; mixing the two paths would make unrelated fields
|
let old_cfg = loaded.config.clone();
|
||||||
// compare unequal and spuriously force `restart_required`.
|
let mut merged = Toml::try_from(&old_cfg)
|
||||||
let original = tokio::fs::read_to_string(config_path)
|
.map_err(|e| ApiFailure::internal(format!("failed to serialize config: {}", e)))?;
|
||||||
.await
|
|
||||||
.map_err(|e| ApiFailure::internal(format!("failed to read config: {}", e)))?;
|
|
||||||
let original_toml: Toml = toml::from_str(&original)
|
|
||||||
.map_err(|e| ApiFailure::internal(format!("failed to parse config: {}", e)))?;
|
|
||||||
let old_cfg: ProxyConfig = original_toml
|
|
||||||
.clone()
|
|
||||||
.try_into()
|
|
||||||
.map_err(|e| ApiFailure::internal(format!("config does not deserialize: {}", e)))?;
|
|
||||||
|
|
||||||
let mut merged = original_toml;
|
|
||||||
deep_merge(&mut merged, &patch_toml);
|
deep_merge(&mut merged, &patch_toml);
|
||||||
|
|
||||||
let new_cfg: ProxyConfig = merged
|
let requested_cfg: ProxyConfig = merged
|
||||||
.clone()
|
.clone()
|
||||||
.try_into()
|
.try_into()
|
||||||
.map_err(|e| ApiFailure::bad_request(format!("config does not deserialize: {}", e)))?;
|
.map_err(|e| ApiFailure::bad_request(format!("config does not deserialize: {}", e)))?;
|
||||||
new_cfg
|
requested_cfg
|
||||||
.validate()
|
.validate()
|
||||||
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
|
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
|
||||||
|
|
||||||
// 4. classify changes (Telemt's own hot/restart rule)
|
let ownership_targets: Vec<&str> = touched
|
||||||
|
.iter()
|
||||||
|
.map(|section| {
|
||||||
|
if *section == "server" {
|
||||||
|
"server.listeners"
|
||||||
|
} else {
|
||||||
|
*section
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let owner_path = resolve_single_source_owner(&loaded, config_path, &ownership_targets)?;
|
||||||
|
let mut owner_contents = loaded
|
||||||
|
.source_contents
|
||||||
|
.get(&owner_path)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| ApiFailure::internal("config source owner is missing from snapshot"))?;
|
||||||
|
for section in &touched {
|
||||||
|
if *section == "server" {
|
||||||
|
let rendered = render_server_listeners(&requested_cfg)?;
|
||||||
|
owner_contents =
|
||||||
|
upsert_toml_table(&owner_contents, "server.listeners", &rendered);
|
||||||
|
} else {
|
||||||
|
let rendered = render_top_level_section(&requested_cfg, section)?;
|
||||||
|
owner_contents = upsert_toml_table(&owner_contents, section, &rendered);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidate = load_candidate_snapshot(
|
||||||
|
config_path,
|
||||||
|
&loaded.source_contents,
|
||||||
|
owner_path.clone(),
|
||||||
|
owner_contents.clone(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if touched.contains(&"server")
|
||||||
|
&& serde_json::to_value(&candidate.config.server.listeners).ok()
|
||||||
|
!= serde_json::to_value(&requested_cfg.server.listeners).ok()
|
||||||
|
{
|
||||||
|
return Err(ApiFailure::new(
|
||||||
|
hyper::StatusCode::BAD_REQUEST,
|
||||||
|
"ambiguous_listeners",
|
||||||
|
"server.listeners normalizes to a different effective listener set",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. classify the validated, normalized candidate.
|
||||||
|
let revision = compute_snapshot_revision(&candidate);
|
||||||
|
let new_cfg = candidate.config;
|
||||||
let class = classify_config_changes(&old_cfg, &new_cfg);
|
let class = classify_config_changes(&old_cfg, &new_cfg);
|
||||||
let deferred_process_fields = deferred_process_fields(&old_cfg, &new_cfg);
|
let deferred_process_fields = deferred_process_fields(&old_cfg, &new_cfg);
|
||||||
|
|
||||||
// 5. write only the touched top-level sections
|
Ok(PreparedConfigPatch {
|
||||||
let revision = save_sections_to_disk(config_path, &new_cfg, &touched).await?;
|
owner_path,
|
||||||
|
owner_contents,
|
||||||
Ok(PatchConfigResponse {
|
desired_config: Arc::new(new_cfg),
|
||||||
revision,
|
response: PatchConfigResponse {
|
||||||
restart_required: class.restart_required,
|
revision,
|
||||||
runtime_reload_required: class.restart_required,
|
restart_required: class.restart_required,
|
||||||
process_restart_required: !deferred_process_fields.is_empty(),
|
runtime_reload_required: class.restart_required,
|
||||||
deferred_process_fields,
|
process_restart_required: !deferred_process_fields.is_empty(),
|
||||||
changed: class.changed,
|
deferred_process_fields,
|
||||||
reload: None,
|
changed: class.changed,
|
||||||
|
reload: None,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn reload_submit_failure(error: ReloadSubmitError) -> ApiFailure {
|
||||||
|
match error {
|
||||||
|
ReloadSubmitError::InProgress(reload_id) => ApiFailure::new(
|
||||||
|
hyper::StatusCode::CONFLICT,
|
||||||
|
"reload_in_progress",
|
||||||
|
format!("Reload {} is already in progress", reload_id),
|
||||||
|
),
|
||||||
|
ReloadSubmitError::MaestroUnavailable => ApiFailure::new(
|
||||||
|
hyper::StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"maestro_unavailable",
|
||||||
|
"Maestro reload coordinator is unavailable",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Return only the editable config sections + current revision.
|
/// Return only the editable config sections + current revision.
|
||||||
pub(super) async fn read_managed_config(config_path: &Path) -> Result<(Toml, String), ApiFailure> {
|
pub(super) async fn read_managed_config(config_path: &Path) -> Result<(Toml, String), ApiFailure> {
|
||||||
let original = tokio::fs::read_to_string(config_path)
|
let loaded = load_config_snapshot(config_path, false).await?;
|
||||||
.await
|
let revision = compute_snapshot_revision(&loaded);
|
||||||
.map_err(|e| ApiFailure::internal(format!("failed to read config: {}", e)))?;
|
let parsed = Toml::try_from(&loaded.config)
|
||||||
let parsed: Toml = toml::from_str(&original)
|
.map_err(|error| ApiFailure::internal(format!("failed to serialize config: {error}")))?;
|
||||||
.map_err(|e| ApiFailure::internal(format!("failed to parse config: {}", e)))?;
|
|
||||||
|
|
||||||
let parsed_table = parsed
|
let parsed_table = parsed
|
||||||
.as_table()
|
.as_table()
|
||||||
@@ -201,7 +266,6 @@ pub(super) async fn read_managed_config(config_path: &Path) -> Result<(Toml, Str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let revision = compute_revision(&original);
|
|
||||||
Ok((Toml::Table(table), revision))
|
Ok((Toml::Table(table), revision))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,7 +355,8 @@ fn json_to_toml(j: &Json) -> Result<Toml, String> {
|
|||||||
let mut table = toml::value::Table::new();
|
let mut table = toml::value::Table::new();
|
||||||
for (k, v) in map {
|
for (k, v) in map {
|
||||||
if v.is_null() {
|
if v.is_null() {
|
||||||
continue; // skip nulls instead of erroring at object level
|
// TOML has no null value, so sparse object nulls are omitted.
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
table.insert(k.clone(), json_to_toml(v)?);
|
table.insert(k.clone(), json_to_toml(v)?);
|
||||||
}
|
}
|
||||||
@@ -319,271 +384,5 @@ fn deep_merge(base: &mut Toml, patch: &Toml) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "config_edit/tests.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn json_object_converts_to_toml_table() {
|
|
||||||
let j: Json = serde_json::json!({"censorship": {"tls_domain": "a.com"}, "default_dc": 2});
|
|
||||||
let t = json_to_toml(&j).expect("convertible");
|
|
||||||
let table = t.as_table().unwrap();
|
|
||||||
assert_eq!(table["censorship"]["tls_domain"].as_str(), Some("a.com"));
|
|
||||||
assert_eq!(table["default_dc"].as_integer(), Some(2));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn deep_merge_overlays_tables_and_replaces_scalars() {
|
|
||||||
let mut base: Toml =
|
|
||||||
toml::from_str("[censorship]\ntls_domain = \"old\"\nfake_cert_len = 100\n").unwrap();
|
|
||||||
let patch: Toml = toml::from_str("[censorship]\ntls_domain = \"new\"\n").unwrap();
|
|
||||||
|
|
||||||
deep_merge(&mut base, &patch);
|
|
||||||
|
|
||||||
let cens = base["censorship"].as_table().unwrap();
|
|
||||||
assert_eq!(cens["tls_domain"].as_str(), Some("new")); // overlaid
|
|
||||||
assert_eq!(cens["fake_cert_len"].as_integer(), Some(100)); // preserved
|
|
||||||
}
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
fn temp_config(body: &str) -> (PathBuf, tempfile::TempDir) {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let path = dir.path().join("config.toml");
|
|
||||||
std::fs::write(&path, body).unwrap();
|
|
||||||
(path, dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn patch_rejects_access_section() {
|
|
||||||
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
|
|
||||||
let patch: Json = serde_json::json!({"access": {"users": {"x": "y"}}});
|
|
||||||
let err = apply_patch_to_path(&path, &patch, None).await.unwrap_err();
|
|
||||||
assert_eq!(err.code, "access_not_editable");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn patch_revision_conflict() {
|
|
||||||
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
|
|
||||||
let patch: Json = serde_json::json!({"censorship": {"tls_domain": "b"}});
|
|
||||||
let err = apply_patch_to_path(&path, &patch, Some("deadbeef".into()))
|
|
||||||
.await
|
|
||||||
.unwrap_err();
|
|
||||||
assert_eq!(err.code, "revision_conflict");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn patch_sni_reports_restart_required() {
|
|
||||||
let (path, _d) =
|
|
||||||
temp_config("[censorship]\ntls_domain = \"a.com\"\n[server]\nport = 443\n");
|
|
||||||
let patch: Json = serde_json::json!({"censorship": {"tls_domain": "b.com"}});
|
|
||||||
let resp = apply_patch_to_path(&path, &patch, None).await.unwrap();
|
|
||||||
assert!(resp.restart_required);
|
|
||||||
assert!(resp.runtime_reload_required);
|
|
||||||
assert!(!resp.process_restart_required);
|
|
||||||
assert!(resp.deferred_process_fields.is_empty());
|
|
||||||
assert!(resp.changed.iter().any(|c| c == "censorship"));
|
|
||||||
let written = std::fs::read_to_string(&path).unwrap();
|
|
||||||
assert!(written.contains("tls_domain = \"b.com\""));
|
|
||||||
assert_eq!(
|
|
||||||
resp.revision,
|
|
||||||
crate::api::config_store::compute_revision(&written)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn read_managed_config_strips_access() {
|
|
||||||
let (path, _d) = temp_config(
|
|
||||||
"[censorship]\ntls_domain = \"a.com\"\n[access.users]\nbob = \"deadbeef\"\n",
|
|
||||||
);
|
|
||||||
let (value, revision) = read_managed_config(&path).await.unwrap();
|
|
||||||
let table = value.as_table().unwrap();
|
|
||||||
assert!(table.contains_key("censorship"));
|
|
||||||
assert!(!table.contains_key("access")); // secrets never leave the box here
|
|
||||||
assert_eq!(revision, current_revision(&path).await.unwrap());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn read_managed_config_returns_only_editable_sections() {
|
|
||||||
// Full server (api/port) and network must not leak. Listeners-only server
|
|
||||||
// is returned via the nested allowlist (covered in a dedicated test).
|
|
||||||
let (path, _d) = temp_config(concat!(
|
|
||||||
"[censorship]\ntls_domain = \"a\"\n",
|
|
||||||
"[server]\nport = 443\n[server.api]\nauth_header = \"SECRET\"\n",
|
|
||||||
"[network]\nipv4 = \"1.2.3.4\"\n",
|
|
||||||
"[access.users]\nbob = \"deadbeef\"\n",
|
|
||||||
));
|
|
||||||
let (value, _rev) = read_managed_config(&path).await.unwrap();
|
|
||||||
let table = value.as_table().unwrap();
|
|
||||||
assert!(table.contains_key("censorship"));
|
|
||||||
assert!(!table.contains_key("server")); // no listeners → omit whole server
|
|
||||||
assert!(!table.contains_key("network")); // no per-node identity leak
|
|
||||||
assert!(!table.contains_key("access")); // no users/secrets
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn read_managed_config_returns_server_listeners_only() {
|
|
||||||
let (path, _d) = temp_config(concat!(
|
|
||||||
"[censorship]\ntls_domain = \"a\"\n",
|
|
||||||
"[server]\nport = 443\n",
|
|
||||||
"[server.api]\nauth_header = \"SECRET\"\n",
|
|
||||||
"[[server.listeners]]\nip = \"0.0.0.0\"\nport = 443\n",
|
|
||||||
));
|
|
||||||
let (value, _rev) = read_managed_config(&path).await.unwrap();
|
|
||||||
let table = value.as_table().unwrap();
|
|
||||||
let server = table
|
|
||||||
.get("server")
|
|
||||||
.expect("server.listeners present")
|
|
||||||
.as_table()
|
|
||||||
.unwrap();
|
|
||||||
assert!(server.contains_key("listeners"));
|
|
||||||
assert!(!server.contains_key("api"));
|
|
||||||
assert!(!server.contains_key("port"));
|
|
||||||
let listeners = server["listeners"].as_array().unwrap();
|
|
||||||
assert_eq!(listeners.len(), 1);
|
|
||||||
assert_eq!(listeners[0]["port"].as_integer(), Some(443));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn patch_rejects_forbidden_server_fields() {
|
|
||||||
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
|
|
||||||
let patch: Json = serde_json::json!({"server": {"port": 1}});
|
|
||||||
let err = apply_patch_to_path(&path, &patch, None).await.unwrap_err();
|
|
||||||
assert_eq!(err.code, "field_not_editable");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn patch_rejects_server_api_field() {
|
|
||||||
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
|
|
||||||
let patch: Json = serde_json::json!({"server": {"api": {"enabled": false}}});
|
|
||||||
let err = apply_patch_to_path(&path, &patch, None).await.unwrap_err();
|
|
||||||
assert_eq!(err.code, "field_not_editable");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn patch_server_listeners_preserves_api() {
|
|
||||||
let (path, _d) = temp_config(concat!(
|
|
||||||
"[censorship]\ntls_domain = \"a\"\n",
|
|
||||||
"[server]\nport = 443\n",
|
|
||||||
"[server.api]\nenabled = true\nauth_header = \"SECRET\"\n",
|
|
||||||
"[[server.listeners]]\nip = \"0.0.0.0\"\nport = 443\n",
|
|
||||||
));
|
|
||||||
let patch: Json = serde_json::json!({
|
|
||||||
"server": {
|
|
||||||
"listeners": [
|
|
||||||
{"ip": "0.0.0.0", "port": 8443, "client_mss": "92"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let resp = apply_patch_to_path(&path, &patch, None).await.unwrap();
|
|
||||||
assert!(resp.changed.iter().any(|c| c == "server"));
|
|
||||||
let written = tokio::fs::read_to_string(&path).await.unwrap();
|
|
||||||
let parsed: toml::Value = toml::from_str(&written).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
parsed["server"]["api"]["auth_header"].as_str(),
|
|
||||||
Some("SECRET"),
|
|
||||||
"{written}"
|
|
||||||
);
|
|
||||||
let listeners = parsed["server"]["listeners"].as_array().unwrap();
|
|
||||||
assert_eq!(listeners.len(), 1, "{written}");
|
|
||||||
assert_eq!(listeners[0]["port"].as_integer(), Some(8443), "{written}");
|
|
||||||
assert_eq!(listeners[0]["client_mss"].as_str(), Some("92"), "{written}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn patch_rejects_show_link_section() {
|
|
||||||
// show_link is a legacy top-level scalar/array (not a [table]); it cannot
|
|
||||||
// be upserted safely and is superseded by the editable general.links.show.
|
|
||||||
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
|
|
||||||
let patch: Json = serde_json::json!({"show_link": "*"});
|
|
||||||
let err = apply_patch_to_path(&path, &patch, None).await.unwrap_err();
|
|
||||||
assert_eq!(err.code, "section_not_editable");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn patch_general_links_show_is_editable() {
|
|
||||||
// The supported replacement path: edit show via the general.links sub-table.
|
|
||||||
let (path, _d) = temp_config(
|
|
||||||
"[general]\nprefer_ipv6 = false\n[general.links]\nshow = \"*\"\n\
|
|
||||||
[censorship]\ntls_domain = \"a\"\n",
|
|
||||||
);
|
|
||||||
let patch: Json = serde_json::json!({"general": {"links": {"show": ["alice"]}}});
|
|
||||||
let resp = apply_patch_to_path(&path, &patch, None).await.unwrap();
|
|
||||||
assert!(resp.changed.iter().any(|c| c == "general"));
|
|
||||||
let written = tokio::fs::read_to_string(&path).await.unwrap();
|
|
||||||
let parsed: toml::Value = toml::from_str(&written).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
parsed["general"]["links"]["show"][0].as_str(),
|
|
||||||
Some("alice"),
|
|
||||||
"{written}"
|
|
||||||
);
|
|
||||||
// No leaked top-level [links]/[modes] and no duplicate sub-tables.
|
|
||||||
assert_eq!(written.matches("[general.links]").count(), 1, "{written}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn patch_links_public_port_written_as_integer_not_float_or_string() {
|
|
||||||
// A JSON integer must land on disk as a bare TOML integer (443), never
|
|
||||||
// 443.0 nor "443". The write re-renders from the typed config, so the
|
|
||||||
// u16 field dictates the output format regardless of JSON quirks.
|
|
||||||
let (path, _d) = temp_config("[general]\nprefer_ipv6 = false\n");
|
|
||||||
let patch: Json = serde_json::json!({"general": {"links": {"public_port": 443}}});
|
|
||||||
apply_patch_to_path(&path, &patch, None).await.unwrap();
|
|
||||||
|
|
||||||
let written = tokio::fs::read_to_string(&path).await.unwrap();
|
|
||||||
assert!(written.contains("public_port = 443"), "{written}");
|
|
||||||
assert!(
|
|
||||||
!written.contains("443.0"),
|
|
||||||
"must not be a float:\n{written}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!written.contains("\"443\""),
|
|
||||||
"must not be a string:\n{written}"
|
|
||||||
);
|
|
||||||
|
|
||||||
let parsed: toml::Value = toml::from_str(&written).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
parsed["general"]["links"]["public_port"].as_integer(),
|
|
||||||
Some(443),
|
|
||||||
"{written}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn patch_links_public_port_rejects_float() {
|
|
||||||
// 443.0 cannot deserialize into u16 -> rejected, not silently coerced.
|
|
||||||
let (path, _d) = temp_config("[general]\nprefer_ipv6 = false\n");
|
|
||||||
let patch: Json = serde_json::json!({"general": {"links": {"public_port": 443.0}}});
|
|
||||||
let err = apply_patch_to_path(&path, &patch, None).await.unwrap_err();
|
|
||||||
assert_eq!(err.status, hyper::StatusCode::BAD_REQUEST, "{:?}", err);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn patch_links_public_port_rejects_string() {
|
|
||||||
// "443" is a string, not a u16 -> rejected.
|
|
||||||
let (path, _d) = temp_config("[general]\nprefer_ipv6 = false\n");
|
|
||||||
let patch: Json = serde_json::json!({"general": {"links": {"public_port": "443"}}});
|
|
||||||
let err = apply_patch_to_path(&path, &patch, None).await.unwrap_err();
|
|
||||||
assert_eq!(err.status, hyper::StatusCode::BAD_REQUEST, "{:?}", err);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn patch_empty_is_rejected() {
|
|
||||||
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
|
|
||||||
let patch: Json = serde_json::json!({});
|
|
||||||
assert!(apply_patch_to_path(&path, &patch, None).await.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn patch_log_level_is_hot() {
|
|
||||||
// general.log_level is hot-reloadable -> a patch changing only it must
|
|
||||||
// report restart_required = false (exercises the full apply path, not
|
|
||||||
// just the classifier). Default LogLevel is Normal; patch to "debug".
|
|
||||||
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
|
|
||||||
let patch: Json = serde_json::json!({"general": {"log_level": "debug"}});
|
|
||||||
let resp = apply_patch_to_path(&path, &patch, None).await.unwrap();
|
|
||||||
assert!(!resp.restart_required);
|
|
||||||
assert!(!resp.runtime_reload_required);
|
|
||||||
assert!(!resp.process_restart_required);
|
|
||||||
assert!(resp.changed.iter().any(|c| c == "general"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,374 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn json_object_converts_to_toml_table() {
|
||||||
|
let j: Json = serde_json::json!({"censorship": {"tls_domain": "a.com"}, "default_dc": 2});
|
||||||
|
let t = json_to_toml(&j).expect("convertible");
|
||||||
|
let table = t.as_table().unwrap();
|
||||||
|
assert_eq!(table["censorship"]["tls_domain"].as_str(), Some("a.com"));
|
||||||
|
assert_eq!(table["default_dc"].as_integer(), Some(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deep_merge_overlays_tables_and_replaces_scalars() {
|
||||||
|
let mut base: Toml =
|
||||||
|
toml::from_str("[censorship]\ntls_domain = \"old\"\nfake_cert_len = 100\n").unwrap();
|
||||||
|
let patch: Toml = toml::from_str("[censorship]\ntls_domain = \"new\"\n").unwrap();
|
||||||
|
|
||||||
|
deep_merge(&mut base, &patch);
|
||||||
|
|
||||||
|
let cens = base["censorship"].as_table().unwrap();
|
||||||
|
assert_eq!(cens["tls_domain"].as_str(), Some("new"));
|
||||||
|
assert_eq!(cens["fake_cert_len"].as_integer(), Some(100));
|
||||||
|
}
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn temp_config(body: &str) -> (PathBuf, tempfile::TempDir) {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("config.toml");
|
||||||
|
std::fs::write(&path, body).unwrap();
|
||||||
|
(path, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn patch_rejects_access_section() {
|
||||||
|
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
|
||||||
|
let patch: Json = serde_json::json!({"access": {"users": {"x": "y"}}});
|
||||||
|
let err = apply_patch_to_path(&path, &patch, None).await.unwrap_err();
|
||||||
|
assert_eq!(err.code, "access_not_editable");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn patch_revision_conflict() {
|
||||||
|
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
|
||||||
|
let patch: Json = serde_json::json!({"censorship": {"tls_domain": "b"}});
|
||||||
|
let err = apply_patch_to_path(&path, &patch, Some("deadbeef".into()))
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(err.code, "revision_conflict");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn patch_sni_reports_restart_required() {
|
||||||
|
let (path, _d) =
|
||||||
|
temp_config("[censorship]\ntls_domain = \"a.com\"\n[server]\nport = 443\n");
|
||||||
|
let patch: Json = serde_json::json!({"censorship": {"tls_domain": "b.com"}});
|
||||||
|
let resp = apply_patch_to_path(&path, &patch, None).await.unwrap();
|
||||||
|
assert!(resp.restart_required);
|
||||||
|
assert!(resp.runtime_reload_required);
|
||||||
|
assert!(!resp.process_restart_required);
|
||||||
|
assert!(resp.deferred_process_fields.is_empty());
|
||||||
|
assert!(resp.changed.iter().any(|c| c == "censorship"));
|
||||||
|
let written = std::fs::read_to_string(&path).unwrap();
|
||||||
|
assert!(written.contains("tls_domain = \"b.com\""));
|
||||||
|
assert_eq!(
|
||||||
|
resp.revision,
|
||||||
|
crate::api::config_store::current_revision(&path)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn read_managed_config_strips_access() {
|
||||||
|
let (path, _d) = temp_config(
|
||||||
|
"[censorship]\ntls_domain = \"a.com\"\n[access.users]\nbob = \"00000000000000000000000000000000\"\n",
|
||||||
|
);
|
||||||
|
let (value, revision) = read_managed_config(&path).await.unwrap();
|
||||||
|
let table = value.as_table().unwrap();
|
||||||
|
assert!(table.contains_key("censorship"));
|
||||||
|
// Secrets never leave the box through this endpoint.
|
||||||
|
assert!(!table.contains_key("access"));
|
||||||
|
assert_eq!(
|
||||||
|
revision,
|
||||||
|
crate::api::config_store::current_revision(&path)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn read_managed_config_returns_only_editable_sections() {
|
||||||
|
// Full server (api/port) and network must not leak. Listeners-only server
|
||||||
|
// is returned via the nested allowlist (covered in a dedicated test).
|
||||||
|
let (path, _d) = temp_config(concat!(
|
||||||
|
"[censorship]\ntls_domain = \"a\"\n",
|
||||||
|
"[server]\nport = 443\n[server.api]\nauth_header = \"SECRET\"\n",
|
||||||
|
"[network]\nipv4 = true\n",
|
||||||
|
"[access.users]\nbob = \"00000000000000000000000000000000\"\n",
|
||||||
|
));
|
||||||
|
let (value, _rev) = read_managed_config(&path).await.unwrap();
|
||||||
|
let table = value.as_table().unwrap();
|
||||||
|
assert!(table.contains_key("censorship"));
|
||||||
|
let server = table["server"].as_table().unwrap();
|
||||||
|
assert!(server.contains_key("listeners"));
|
||||||
|
assert!(!server.contains_key("api"));
|
||||||
|
assert!(!server.contains_key("port"));
|
||||||
|
assert!(!table.contains_key("network"));
|
||||||
|
assert!(!table.contains_key("access"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn read_managed_config_returns_server_listeners_only() {
|
||||||
|
let (path, _d) = temp_config(concat!(
|
||||||
|
"[censorship]\ntls_domain = \"a\"\n",
|
||||||
|
"[server]\nport = 443\n",
|
||||||
|
"[server.api]\nauth_header = \"SECRET\"\n",
|
||||||
|
"[[server.listeners]]\nip = \"0.0.0.0\"\nport = 443\n",
|
||||||
|
));
|
||||||
|
let (value, _rev) = read_managed_config(&path).await.unwrap();
|
||||||
|
let table = value.as_table().unwrap();
|
||||||
|
let server = table
|
||||||
|
.get("server")
|
||||||
|
.expect("server.listeners present")
|
||||||
|
.as_table()
|
||||||
|
.unwrap();
|
||||||
|
assert!(server.contains_key("listeners"));
|
||||||
|
assert!(!server.contains_key("api"));
|
||||||
|
assert!(!server.contains_key("port"));
|
||||||
|
let listeners = server["listeners"].as_array().unwrap();
|
||||||
|
assert_eq!(listeners.len(), 1);
|
||||||
|
assert_eq!(listeners[0]["port"].as_integer(), Some(443));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn patch_rejects_forbidden_server_fields() {
|
||||||
|
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
|
||||||
|
let patch: Json = serde_json::json!({"server": {"port": 1}});
|
||||||
|
let err = apply_patch_to_path(&path, &patch, None).await.unwrap_err();
|
||||||
|
assert_eq!(err.code, "field_not_editable");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn patch_rejects_server_api_field() {
|
||||||
|
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
|
||||||
|
let patch: Json = serde_json::json!({"server": {"api": {"enabled": false}}});
|
||||||
|
let err = apply_patch_to_path(&path, &patch, None).await.unwrap_err();
|
||||||
|
assert_eq!(err.code, "field_not_editable");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn patch_server_listeners_preserves_api() {
|
||||||
|
let (path, _d) = temp_config(concat!(
|
||||||
|
"[censorship]\ntls_domain = \"a\"\n",
|
||||||
|
"[server]\nport = 443\n",
|
||||||
|
"[server.api]\nenabled = true\nauth_header = \"SECRET\"\n",
|
||||||
|
"[[server.listeners]]\nip = \"0.0.0.0\"\nport = 443\n",
|
||||||
|
));
|
||||||
|
let patch: Json = serde_json::json!({
|
||||||
|
"server": {
|
||||||
|
"listeners": [
|
||||||
|
{"ip": "0.0.0.0", "port": 8443, "client_mss": "92"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let resp = apply_patch_to_path(&path, &patch, None).await.unwrap();
|
||||||
|
assert!(resp.changed.iter().any(|c| c == "server"));
|
||||||
|
let written = tokio::fs::read_to_string(&path).await.unwrap();
|
||||||
|
let parsed: toml::Value = toml::from_str(&written).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
parsed["server"]["api"]["auth_header"].as_str(),
|
||||||
|
Some("SECRET"),
|
||||||
|
"{written}"
|
||||||
|
);
|
||||||
|
let listeners = parsed["server"]["listeners"].as_array().unwrap();
|
||||||
|
assert_eq!(listeners.len(), 1, "{written}");
|
||||||
|
assert_eq!(listeners[0]["port"].as_integer(), Some(8443), "{written}");
|
||||||
|
assert_eq!(listeners[0]["client_mss"].as_str(), Some("92"), "{written}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn patch_rejects_show_link_section() {
|
||||||
|
// show_link is a legacy top-level scalar/array (not a [table]); it cannot
|
||||||
|
// be upserted safely and is superseded by the editable general.links.show.
|
||||||
|
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
|
||||||
|
let patch: Json = serde_json::json!({"show_link": "*"});
|
||||||
|
let err = apply_patch_to_path(&path, &patch, None).await.unwrap_err();
|
||||||
|
assert_eq!(err.code, "section_not_editable");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn patch_general_links_show_is_editable() {
|
||||||
|
// The supported replacement path: edit show via the general.links sub-table.
|
||||||
|
let (path, _d) = temp_config(
|
||||||
|
"[general]\nprefer_ipv6 = false\n[general.links]\nshow = \"*\"\n\
|
||||||
|
[censorship]\ntls_domain = \"a\"\n",
|
||||||
|
);
|
||||||
|
let patch: Json = serde_json::json!({"general": {"links": {"show": ["alice"]}}});
|
||||||
|
let resp = apply_patch_to_path(&path, &patch, None).await.unwrap();
|
||||||
|
assert!(resp.changed.iter().any(|c| c == "general"));
|
||||||
|
let written = tokio::fs::read_to_string(&path).await.unwrap();
|
||||||
|
let parsed: toml::Value = toml::from_str(&written).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
parsed["general"]["links"]["show"][0].as_str(),
|
||||||
|
Some("alice"),
|
||||||
|
"{written}"
|
||||||
|
);
|
||||||
|
// No leaked top-level [links]/[modes] and no duplicate sub-tables.
|
||||||
|
assert_eq!(written.matches("[general.links]").count(), 1, "{written}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn patch_writes_the_included_section_owner_only() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let root = dir.path().join("config.toml");
|
||||||
|
let included = dir.path().join("censorship.toml");
|
||||||
|
let root_body = "include = \"censorship.toml\"\n[server]\nport = 443\n";
|
||||||
|
let included_body = "[censorship]\ntls_domain = \"old.example\"\n";
|
||||||
|
tokio::fs::write(&root, root_body).await.unwrap();
|
||||||
|
tokio::fs::write(&included, included_body).await.unwrap();
|
||||||
|
let patch: Json = serde_json::json!({
|
||||||
|
"censorship": {"tls_domain": "new.example"}
|
||||||
|
});
|
||||||
|
|
||||||
|
let response = apply_patch_to_path(&root, &patch, None).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(tokio::fs::read_to_string(&root).await.unwrap(), root_body);
|
||||||
|
let written = tokio::fs::read_to_string(&included).await.unwrap();
|
||||||
|
assert!(written.contains("tls_domain = \"new.example\""));
|
||||||
|
assert_eq!(
|
||||||
|
response.revision,
|
||||||
|
crate::api::config_store::current_revision(&root)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn patch_rejects_multiple_source_owners_without_writing() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let root = dir.path().join("config.toml");
|
||||||
|
let included = dir.path().join("censorship.toml");
|
||||||
|
let root_body = concat!(
|
||||||
|
"include = \"censorship.toml\"\n",
|
||||||
|
"[general]\nprefer_ipv6 = false\n"
|
||||||
|
);
|
||||||
|
let included_body = "[censorship]\ntls_domain = \"old.example\"\n";
|
||||||
|
tokio::fs::write(&root, root_body).await.unwrap();
|
||||||
|
tokio::fs::write(&included, included_body).await.unwrap();
|
||||||
|
let patch: Json = serde_json::json!({
|
||||||
|
"general": {"prefer_ipv6": true},
|
||||||
|
"censorship": {"tls_domain": "new.example"}
|
||||||
|
});
|
||||||
|
|
||||||
|
let error = apply_patch_to_path(&root, &patch, None)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(error.code, "config_patch_not_atomic");
|
||||||
|
assert_eq!(tokio::fs::read_to_string(&root).await.unwrap(), root_body);
|
||||||
|
assert_eq!(
|
||||||
|
tokio::fs::read_to_string(&included).await.unwrap(),
|
||||||
|
included_body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unavailable_reload_coordinator_is_detected_before_config_write() {
|
||||||
|
let (path, _dir) = temp_config("[censorship]\ntls_domain = \"old.example\"\n");
|
||||||
|
let original = tokio::fs::read_to_string(&path).await.unwrap();
|
||||||
|
let patch: Json = serde_json::json!({
|
||||||
|
"censorship": {"tls_domain": "new.example"}
|
||||||
|
});
|
||||||
|
let prepared = prepare_patch_to_path(&path, &patch, None).await.unwrap();
|
||||||
|
let (control, receiver) = crate::maestro::reload::ReloadControl::channel(1);
|
||||||
|
drop(receiver);
|
||||||
|
|
||||||
|
let error = match control
|
||||||
|
.reserve(
|
||||||
|
prepared.response.revision.clone(),
|
||||||
|
ReloadRequest::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => panic!("closed reload coordinator must reject the reservation"),
|
||||||
|
Err(error) => error,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(error, ReloadSubmitError::MaestroUnavailable);
|
||||||
|
assert_eq!(tokio::fs::read_to_string(&path).await.unwrap(), original);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn failed_config_write_releases_reload_reservation() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let (control, _receiver) = crate::maestro::reload::ReloadControl::channel(1);
|
||||||
|
let reservation = control
|
||||||
|
.reserve("candidate-revision".to_string(), ReloadRequest::default())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result = write_atomic(dir.path().to_path_buf(), "invalid target".to_string()).await;
|
||||||
|
drop(reservation);
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert_eq!(control.in_progress().await, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn patch_links_public_port_written_as_integer_not_float_or_string() {
|
||||||
|
// A JSON integer must land on disk as a bare TOML integer (443), never
|
||||||
|
// 443.0 nor "443". The write re-renders from the typed config, so the
|
||||||
|
// u16 field dictates the output format regardless of JSON quirks.
|
||||||
|
let (path, _d) = temp_config("[general]\nprefer_ipv6 = false\n");
|
||||||
|
let patch: Json = serde_json::json!({"general": {"links": {"public_port": 443}}});
|
||||||
|
apply_patch_to_path(&path, &patch, None).await.unwrap();
|
||||||
|
|
||||||
|
let written = tokio::fs::read_to_string(&path).await.unwrap();
|
||||||
|
assert!(written.contains("public_port = 443"), "{written}");
|
||||||
|
assert!(
|
||||||
|
!written.contains("443.0"),
|
||||||
|
"must not be a float:\n{written}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!written.contains("\"443\""),
|
||||||
|
"must not be a string:\n{written}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let parsed: toml::Value = toml::from_str(&written).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
parsed["general"]["links"]["public_port"].as_integer(),
|
||||||
|
Some(443),
|
||||||
|
"{written}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn patch_links_public_port_rejects_float() {
|
||||||
|
// 443.0 cannot deserialize into u16 -> rejected, not silently coerced.
|
||||||
|
let (path, _d) = temp_config("[general]\nprefer_ipv6 = false\n");
|
||||||
|
let patch: Json = serde_json::json!({"general": {"links": {"public_port": 443.0}}});
|
||||||
|
let err = apply_patch_to_path(&path, &patch, None).await.unwrap_err();
|
||||||
|
assert_eq!(err.status, hyper::StatusCode::BAD_REQUEST, "{:?}", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn patch_links_public_port_rejects_string() {
|
||||||
|
// "443" is a string, not a u16 -> rejected.
|
||||||
|
let (path, _d) = temp_config("[general]\nprefer_ipv6 = false\n");
|
||||||
|
let patch: Json = serde_json::json!({"general": {"links": {"public_port": "443"}}});
|
||||||
|
let err = apply_patch_to_path(&path, &patch, None).await.unwrap_err();
|
||||||
|
assert_eq!(err.status, hyper::StatusCode::BAD_REQUEST, "{:?}", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn patch_empty_is_rejected() {
|
||||||
|
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
|
||||||
|
let patch: Json = serde_json::json!({});
|
||||||
|
assert!(apply_patch_to_path(&path, &patch, None).await.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn patch_log_level_is_hot() {
|
||||||
|
// general.log_level is hot-reloadable -> a patch changing only it must
|
||||||
|
// report restart_required = false (exercises the full apply path, not
|
||||||
|
// just the classifier). Default LogLevel is Normal; patch to "debug".
|
||||||
|
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
|
||||||
|
let patch: Json = serde_json::json!({"general": {"log_level": "debug"}});
|
||||||
|
let resp = apply_patch_to_path(&path, &patch, None).await.unwrap();
|
||||||
|
assert!(!resp.restart_required);
|
||||||
|
assert!(!resp.runtime_reload_required);
|
||||||
|
assert!(!resp.process_restart_required);
|
||||||
|
assert!(resp.changed.iter().any(|c| c == "general"));
|
||||||
|
}
|
||||||
+184
-559
@@ -1,16 +1,25 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
use std::io::Write;
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use hyper::header::IF_MATCH;
|
use hyper::header::IF_MATCH;
|
||||||
use serde::Serialize;
|
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
use crate::config::{ProxyConfig, RateLimitBps};
|
use crate::config::{ConfigSourceGraph, LoadedConfig, ProxyConfig};
|
||||||
|
|
||||||
use super::model::ApiFailure;
|
use super::model::ApiFailure;
|
||||||
|
|
||||||
|
// Source-preserving TOML rendering and atomic persistence helpers.
|
||||||
|
mod persistence;
|
||||||
|
|
||||||
|
pub(in crate::api) use persistence::{
|
||||||
|
render_server_listeners, render_top_level_section, save_access_sections_to_disk,
|
||||||
|
upsert_toml_table, write_atomic,
|
||||||
|
};
|
||||||
|
#[cfg(test)]
|
||||||
|
use persistence::{
|
||||||
|
find_toml_table_bounds, render_access_section, save_sections_to_disk,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub(super) enum AccessSection {
|
pub(super) enum AccessSection {
|
||||||
Users,
|
Users,
|
||||||
@@ -66,17 +75,21 @@ pub(super) async fn ensure_expected_revision(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn current_revision(config_path: &Path) -> Result<String, ApiFailure> {
|
pub(super) async fn current_revision(config_path: &Path) -> Result<String, ApiFailure> {
|
||||||
let content = tokio::fs::read_to_string(config_path)
|
let config_path = config_path.to_path_buf();
|
||||||
|
let graph = tokio::task::spawn_blocking(move || ProxyConfig::read_source_graph(config_path))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiFailure::internal(format!("failed to read config: {}", e)))?;
|
.map_err(|error| ApiFailure::internal(format!("failed to join config reader: {error}")))?
|
||||||
Ok(compute_revision(&content))
|
.map_err(|error| ApiFailure::internal(format!("failed to read config graph: {error}")))?;
|
||||||
|
Ok(compute_source_revision(&graph))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn current_revision_for_maestro(config_path: &Path) -> Result<String, String> {
|
pub(crate) async fn current_revision_for_maestro(config_path: &Path) -> Result<String, String> {
|
||||||
let content = tokio::fs::read_to_string(config_path)
|
let config_path = config_path.to_path_buf();
|
||||||
|
tokio::task::spawn_blocking(move || ProxyConfig::read_source_graph(config_path))
|
||||||
.await
|
.await
|
||||||
.map_err(|error| format!("failed to read config: {}", error))?;
|
.map_err(|error| format!("failed to join config reader: {error}"))?
|
||||||
Ok(compute_revision(&content))
|
.map(|graph| compute_source_revision(&graph))
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn compute_revision(content: &str) -> String {
|
pub(super) fn compute_revision(content: &str) -> String {
|
||||||
@@ -85,6 +98,164 @@ pub(super) fn compute_revision(content: &str) -> String {
|
|||||||
hex::encode(hasher.finalize())
|
hex::encode(hasher.finalize())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) fn compute_snapshot_revision(loaded: &LoadedConfig) -> String {
|
||||||
|
compute_source_revision(&ConfigSourceGraph {
|
||||||
|
source_contents: loaded.source_contents.clone(),
|
||||||
|
rendered: String::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn compute_source_revision(graph: &ConfigSourceGraph) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(b"telemt-config-manifest-v1\0");
|
||||||
|
for (path, content) in &graph.source_contents {
|
||||||
|
let path = path.as_os_str().as_encoded_bytes();
|
||||||
|
hasher.update((path.len() as u64).to_le_bytes());
|
||||||
|
hasher.update(path);
|
||||||
|
hasher.update((content.len() as u64).to_le_bytes());
|
||||||
|
hasher.update(content.as_bytes());
|
||||||
|
}
|
||||||
|
hex::encode(hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn load_config_snapshot(
|
||||||
|
config_path: &Path,
|
||||||
|
invalid_is_bad_request: bool,
|
||||||
|
) -> Result<LoadedConfig, ApiFailure> {
|
||||||
|
let config_path = config_path.to_path_buf();
|
||||||
|
tokio::task::spawn_blocking(move || ProxyConfig::load_with_metadata(config_path))
|
||||||
|
.await
|
||||||
|
.map_err(|error| ApiFailure::internal(format!("failed to join config loader: {error}")))?
|
||||||
|
.map_err(|error| {
|
||||||
|
if invalid_is_bad_request {
|
||||||
|
ApiFailure::bad_request(format!("invalid runtime config: {error}"))
|
||||||
|
} else {
|
||||||
|
ApiFailure::internal(format!("failed to load config: {error}"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn resolve_single_source_owner(
|
||||||
|
loaded: &LoadedConfig,
|
||||||
|
config_path: &Path,
|
||||||
|
targets: &[&str],
|
||||||
|
) -> Result<PathBuf, ApiFailure> {
|
||||||
|
let root = normalize_source_path(config_path);
|
||||||
|
let mut mutation_owners = BTreeSet::new();
|
||||||
|
|
||||||
|
if loaded
|
||||||
|
.source_contents
|
||||||
|
.values()
|
||||||
|
.any(|content| has_include_inside_table(content))
|
||||||
|
{
|
||||||
|
return Err(ApiFailure::new(
|
||||||
|
hyper::StatusCode::CONFLICT,
|
||||||
|
"config_patch_not_atomic",
|
||||||
|
"config includes nested inside a TOML table cannot be mutated atomically",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
for target in targets {
|
||||||
|
let mut owners = BTreeSet::new();
|
||||||
|
for (path, content) in &loaded.source_contents {
|
||||||
|
let parsed: toml::Value = toml::from_str(content).map_err(|error| {
|
||||||
|
ApiFailure::new(
|
||||||
|
hyper::StatusCode::CONFLICT,
|
||||||
|
"config_patch_not_atomic",
|
||||||
|
format!(
|
||||||
|
"config source {} is not independently writable: {error}",
|
||||||
|
path.display()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if toml_path_exists(&parsed, target) {
|
||||||
|
owners.insert(path.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match owners.len() {
|
||||||
|
0 => {
|
||||||
|
mutation_owners.insert(root.clone());
|
||||||
|
}
|
||||||
|
1 => {
|
||||||
|
mutation_owners.extend(owners);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(ApiFailure::new(
|
||||||
|
hyper::StatusCode::CONFLICT,
|
||||||
|
"config_patch_not_atomic",
|
||||||
|
format!("config section {target} is owned by multiple source files"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if mutation_owners.len() != 1 {
|
||||||
|
return Err(ApiFailure::new(
|
||||||
|
hyper::StatusCode::CONFLICT,
|
||||||
|
"config_patch_not_atomic",
|
||||||
|
"one mutation may update only one config source file",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
mutation_owners
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| ApiFailure::bad_request("empty mutation: no owned config sections"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toml_path_exists(value: &toml::Value, target: &str) -> bool {
|
||||||
|
target
|
||||||
|
.split('.')
|
||||||
|
.try_fold(value, |current, part| current.get(part))
|
||||||
|
.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_include_inside_table(content: &str) -> bool {
|
||||||
|
let mut inside_table = false;
|
||||||
|
for line in content.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.starts_with('[') {
|
||||||
|
inside_table = true;
|
||||||
|
}
|
||||||
|
if inside_table
|
||||||
|
&& trimmed
|
||||||
|
.strip_prefix("include")
|
||||||
|
.is_some_and(|rest| rest.trim_start().starts_with('='))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn load_candidate_snapshot(
|
||||||
|
config_path: &Path,
|
||||||
|
base_sources: &BTreeMap<PathBuf, String>,
|
||||||
|
owner_path: PathBuf,
|
||||||
|
owner_contents: String,
|
||||||
|
) -> Result<LoadedConfig, ApiFailure> {
|
||||||
|
let config_path = config_path.to_path_buf();
|
||||||
|
let mut overrides = base_sources.clone();
|
||||||
|
overrides.insert(owner_path, owner_contents);
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
ProxyConfig::load_with_source_overrides(config_path, &overrides)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| ApiFailure::internal(format!("failed to join config loader: {error}")))?
|
||||||
|
.map_err(|error| ApiFailure::bad_request(format!("invalid patched config: {error}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_source_path(path: &Path) -> PathBuf {
|
||||||
|
path.canonicalize().unwrap_or_else(|_| {
|
||||||
|
if path.is_absolute() {
|
||||||
|
path.to_path_buf()
|
||||||
|
} else {
|
||||||
|
std::env::current_dir()
|
||||||
|
.map(|cwd| cwd.join(path))
|
||||||
|
.unwrap_or_else(|_| path.to_path_buf())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) async fn load_config_from_disk(config_path: &Path) -> Result<ProxyConfig, ApiFailure> {
|
pub(super) async fn load_config_from_disk(config_path: &Path) -> Result<ProxyConfig, ApiFailure> {
|
||||||
let config_path = config_path.to_path_buf();
|
let config_path = config_path.to_path_buf();
|
||||||
tokio::task::spawn_blocking(move || ProxyConfig::load(config_path))
|
tokio::task::spawn_blocking(move || ProxyConfig::load(config_path))
|
||||||
@@ -154,552 +325,6 @@ pub(super) fn is_editable_section(key: &str) -> bool {
|
|||||||
EDITABLE_SECTIONS.contains(&key) || key == "server"
|
EDITABLE_SECTIONS.contains(&key) || key == "server"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-render the given top-level tables from `cfg` and upsert each into the
|
|
||||||
/// on-disk file, preserving every untouched section (and its comments).
|
|
||||||
pub(super) async fn save_sections_to_disk(
|
|
||||||
config_path: &Path,
|
|
||||||
cfg: &ProxyConfig,
|
|
||||||
sections: &[&str],
|
|
||||||
) -> Result<String, ApiFailure> {
|
|
||||||
let mut content = tokio::fs::read_to_string(config_path)
|
|
||||||
.await
|
|
||||||
.map_err(|e| ApiFailure::internal(format!("failed to read config: {}", e)))?;
|
|
||||||
|
|
||||||
for section in sections {
|
|
||||||
let rendered = render_top_level_section(cfg, section)?;
|
|
||||||
content = upsert_toml_table(&content, section, &rendered);
|
|
||||||
}
|
|
||||||
|
|
||||||
write_atomic(config_path.to_path_buf(), content.clone()).await?;
|
|
||||||
Ok(compute_revision(&content))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render one top-level table as `[section]\n...\n` (or `[[upstreams]]` array
|
|
||||||
/// of tables) from the typed `cfg`. Serializes via the `toml` crate so the
|
|
||||||
/// output matches the canonical format Telemt parses.
|
|
||||||
fn render_top_level_section(cfg: &ProxyConfig, section: &str) -> Result<String, ApiFailure> {
|
|
||||||
let value = toml::Value::try_from(cfg)
|
|
||||||
.map_err(|e| ApiFailure::internal(format!("failed to serialize config: {}", e)))?;
|
|
||||||
let table = value
|
|
||||||
.get(section)
|
|
||||||
.ok_or_else(|| ApiFailure::internal(format!("unknown section: {}", section)))?;
|
|
||||||
|
|
||||||
// upstreams is an array-of-tables -> render as [[upstreams]] blocks.
|
|
||||||
if let toml::Value::Array(items) = table {
|
|
||||||
let mut out = String::new();
|
|
||||||
for item in items {
|
|
||||||
out.push_str(&format!("[[{}]]\n", section));
|
|
||||||
out.push_str(&toml::to_string(item).map_err(|e| {
|
|
||||||
ApiFailure::internal(format!("failed to serialize {}: {}", section, e))
|
|
||||||
})?);
|
|
||||||
if !out.ends_with('\n') {
|
|
||||||
out.push('\n');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Ok(out);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Serialize the table *inside a wrapper keyed by `section`* so the `toml`
|
|
||||||
// crate emits correctly dotted headers for nested sub-tables, e.g.
|
|
||||||
// `[general]` + `[general.modes]` + `[general.links]`. Serializing the
|
|
||||||
// inner table alone would render bare `[modes]`/`[links]` headers, which
|
|
||||||
// would leak as duplicate top-level tables and break config load.
|
|
||||||
let mut wrapper = toml::value::Table::new();
|
|
||||||
wrapper.insert(section.to_string(), table.clone());
|
|
||||||
let mut out = toml::to_string(&toml::Value::Table(wrapper))
|
|
||||||
.map_err(|e| ApiFailure::internal(format!("failed to serialize {}: {}", section, e)))?;
|
|
||||||
if !out.ends_with('\n') {
|
|
||||||
out.push('\n');
|
|
||||||
}
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn save_access_sections_to_disk(
|
|
||||||
config_path: &Path,
|
|
||||||
cfg: &ProxyConfig,
|
|
||||||
sections: &[AccessSection],
|
|
||||||
) -> Result<String, ApiFailure> {
|
|
||||||
let mut content = tokio::fs::read_to_string(config_path)
|
|
||||||
.await
|
|
||||||
.map_err(|e| ApiFailure::internal(format!("failed to read config: {}", e)))?;
|
|
||||||
|
|
||||||
let mut applied = Vec::new();
|
|
||||||
for section in sections {
|
|
||||||
if applied.contains(section) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if find_toml_table_bounds(&content, section.table_name()).is_none()
|
|
||||||
&& access_section_is_empty(cfg, *section)
|
|
||||||
{
|
|
||||||
applied.push(*section);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let rendered = render_access_section(cfg, *section)?;
|
|
||||||
content = upsert_toml_table(&content, section.table_name(), &rendered);
|
|
||||||
applied.push(*section);
|
|
||||||
}
|
|
||||||
|
|
||||||
write_atomic(config_path.to_path_buf(), content.clone()).await?;
|
|
||||||
Ok(compute_revision(&content))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_access_section(cfg: &ProxyConfig, section: AccessSection) -> Result<String, ApiFailure> {
|
|
||||||
let body = match section {
|
|
||||||
AccessSection::Users => {
|
|
||||||
let rows: BTreeMap<String, String> = cfg
|
|
||||||
.access
|
|
||||||
.users
|
|
||||||
.iter()
|
|
||||||
.map(|(key, value)| (key.clone(), value.clone()))
|
|
||||||
.collect();
|
|
||||||
serialize_table_body(&rows)?
|
|
||||||
}
|
|
||||||
AccessSection::UserEnabled => {
|
|
||||||
let rows: BTreeMap<String, bool> = cfg
|
|
||||||
.access
|
|
||||||
.user_enabled
|
|
||||||
.iter()
|
|
||||||
.map(|(key, value)| (key.clone(), *value))
|
|
||||||
.collect();
|
|
||||||
serialize_table_body(&rows)?
|
|
||||||
}
|
|
||||||
AccessSection::UserAdTags => {
|
|
||||||
let rows: BTreeMap<String, String> = cfg
|
|
||||||
.access
|
|
||||||
.user_ad_tags
|
|
||||||
.iter()
|
|
||||||
.map(|(key, value)| (key.clone(), value.clone()))
|
|
||||||
.collect();
|
|
||||||
serialize_table_body(&rows)?
|
|
||||||
}
|
|
||||||
AccessSection::UserMaxTcpConns => {
|
|
||||||
let rows: BTreeMap<String, usize> = cfg
|
|
||||||
.access
|
|
||||||
.user_max_tcp_conns
|
|
||||||
.iter()
|
|
||||||
.map(|(key, value)| (key.clone(), *value))
|
|
||||||
.collect();
|
|
||||||
serialize_table_body(&rows)?
|
|
||||||
}
|
|
||||||
AccessSection::UserExpirations => {
|
|
||||||
let rows: BTreeMap<String, DateTime<Utc>> = cfg
|
|
||||||
.access
|
|
||||||
.user_expirations
|
|
||||||
.iter()
|
|
||||||
.map(|(key, value)| (key.clone(), *value))
|
|
||||||
.collect();
|
|
||||||
serialize_table_body(&rows)?
|
|
||||||
}
|
|
||||||
AccessSection::UserDataQuota => {
|
|
||||||
let rows: BTreeMap<String, u64> = cfg
|
|
||||||
.access
|
|
||||||
.user_data_quota
|
|
||||||
.iter()
|
|
||||||
.map(|(key, value)| (key.clone(), *value))
|
|
||||||
.collect();
|
|
||||||
serialize_table_body(&rows)?
|
|
||||||
}
|
|
||||||
AccessSection::UserRateLimits => {
|
|
||||||
let rows: BTreeMap<String, RateLimitBps> = cfg
|
|
||||||
.access
|
|
||||||
.user_rate_limits
|
|
||||||
.iter()
|
|
||||||
.map(|(key, value)| (key.clone(), *value))
|
|
||||||
.collect();
|
|
||||||
serialize_rate_limit_body(&rows)?
|
|
||||||
}
|
|
||||||
AccessSection::UserMaxUniqueIps => {
|
|
||||||
let rows: BTreeMap<String, usize> = cfg
|
|
||||||
.access
|
|
||||||
.user_max_unique_ips
|
|
||||||
.iter()
|
|
||||||
.map(|(key, value)| (key.clone(), *value))
|
|
||||||
.collect();
|
|
||||||
serialize_table_body(&rows)?
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut out = format!("[{}]\n", section.table_name());
|
|
||||||
if !body.is_empty() {
|
|
||||||
out.push_str(&body);
|
|
||||||
}
|
|
||||||
if !out.ends_with('\n') {
|
|
||||||
out.push('\n');
|
|
||||||
}
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn access_section_is_empty(cfg: &ProxyConfig, section: AccessSection) -> bool {
|
|
||||||
match section {
|
|
||||||
AccessSection::Users => cfg.access.users.is_empty(),
|
|
||||||
AccessSection::UserEnabled => cfg.access.user_enabled.is_empty(),
|
|
||||||
AccessSection::UserAdTags => cfg.access.user_ad_tags.is_empty(),
|
|
||||||
AccessSection::UserMaxTcpConns => cfg.access.user_max_tcp_conns.is_empty(),
|
|
||||||
AccessSection::UserExpirations => cfg.access.user_expirations.is_empty(),
|
|
||||||
AccessSection::UserDataQuota => cfg.access.user_data_quota.is_empty(),
|
|
||||||
AccessSection::UserRateLimits => cfg.access.user_rate_limits.is_empty(),
|
|
||||||
AccessSection::UserMaxUniqueIps => cfg.access.user_max_unique_ips.is_empty(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn serialize_table_body<T: Serialize>(value: &T) -> Result<String, ApiFailure> {
|
|
||||||
toml::to_string(value)
|
|
||||||
.map_err(|e| ApiFailure::internal(format!("failed to serialize access section: {}", e)))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn serialize_rate_limit_body(rows: &BTreeMap<String, RateLimitBps>) -> Result<String, ApiFailure> {
|
|
||||||
let mut out = String::new();
|
|
||||||
for (key, value) in rows {
|
|
||||||
let key = serialize_toml_key(key)?;
|
|
||||||
out.push_str(&format!(
|
|
||||||
"{key} = {{ up_bps = {}, down_bps = {} }}\n",
|
|
||||||
value.up_bps, value.down_bps
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn serialize_toml_key(key: &str) -> Result<String, ApiFailure> {
|
|
||||||
let mut row = BTreeMap::new();
|
|
||||||
row.insert(key.to_string(), 0_u8);
|
|
||||||
let rendered = serialize_table_body(&row)?;
|
|
||||||
rendered
|
|
||||||
.split_once(" = ")
|
|
||||||
.map(|(key, _)| key.to_string())
|
|
||||||
.ok_or_else(|| ApiFailure::internal("failed to serialize TOML key"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn upsert_toml_table(source: &str, table_name: &str, replacement: &str) -> String {
|
|
||||||
let blocks = find_all_table_blocks(source, table_name);
|
|
||||||
if let Some(&(first_start, first_end)) = blocks.first() {
|
|
||||||
// Replace the first block in place and delete any further blocks that
|
|
||||||
// also belong to this table. Telemt writes a section's sub-tables
|
|
||||||
// contiguously, but a hand-edited config may scatter them; dropping the
|
|
||||||
// extras here prevents the duplicate-table corruption that would
|
|
||||||
// otherwise break config load.
|
|
||||||
let mut out = String::with_capacity(source.len() + replacement.len());
|
|
||||||
out.push_str(&source[..first_start]);
|
|
||||||
out.push_str(replacement);
|
|
||||||
let mut cursor = first_end;
|
|
||||||
for &(start, end) in &blocks[1..] {
|
|
||||||
out.push_str(&source[cursor..start]);
|
|
||||||
cursor = end;
|
|
||||||
}
|
|
||||||
out.push_str(&source[cursor..]);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut out = source.to_string();
|
|
||||||
if !out.is_empty() && !out.ends_with('\n') {
|
|
||||||
out.push('\n');
|
|
||||||
}
|
|
||||||
if !out.is_empty() {
|
|
||||||
out.push('\n');
|
|
||||||
}
|
|
||||||
out.push_str(replacement);
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether a (comment-stripped, trimmed) TOML header line belongs to
|
|
||||||
/// `table_name`: the table itself (`[X]` / `[[X]]`) or any of its nested
|
|
||||||
/// sub-tables (`[X.…]` / `[[X.…]]`). The trailing dot guards against sibling
|
|
||||||
/// prefixes — `access.users` must not match `access.user_enabled`.
|
|
||||||
fn header_belongs_to(header: &str, table_name: &str) -> bool {
|
|
||||||
let body = match header.strip_prefix("[[").and_then(|h| h.strip_suffix("]]")) {
|
|
||||||
Some(body) => body,
|
|
||||||
None => match header.strip_prefix('[').and_then(|h| h.strip_suffix(']')) {
|
|
||||||
Some(body) => body,
|
|
||||||
None => return false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
let body = body.trim();
|
|
||||||
body == table_name
|
|
||||||
|| body
|
|
||||||
.strip_prefix(table_name)
|
|
||||||
.is_some_and(|rest| rest.starts_with('.'))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Locate the first contiguous byte range covering `table_name` and the nested
|
|
||||||
/// sub-tables immediately following it. Used for existence checks; see
|
|
||||||
/// [`find_all_table_blocks`] for the full set of (possibly scattered) blocks.
|
|
||||||
fn find_toml_table_bounds(source: &str, table_name: &str) -> Option<(usize, usize)> {
|
|
||||||
find_all_table_blocks(source, table_name).into_iter().next()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Locate every byte range that belongs to `table_name`: the table header and
|
|
||||||
/// its nested sub-tables. Returns one range per contiguous run, so a config
|
|
||||||
/// where a section's sub-tables are scattered (e.g. hand-edited) yields several
|
|
||||||
/// ranges — letting the caller collapse them into a single rendered block.
|
|
||||||
fn find_all_table_blocks(source: &str, table_name: &str) -> Vec<(usize, usize)> {
|
|
||||||
let mut blocks = Vec::new();
|
|
||||||
let mut offset = 0usize;
|
|
||||||
let mut start: Option<usize> = None;
|
|
||||||
|
|
||||||
for line in source.split_inclusive('\n') {
|
|
||||||
// Drop any inline comment so a hand-edited header like
|
|
||||||
// `[censorship] # note` still matches. Section names never contain `#`.
|
|
||||||
let header = line.trim().split('#').next().unwrap_or("").trim();
|
|
||||||
let is_header = header.starts_with('[');
|
|
||||||
if let Some(start_offset) = start {
|
|
||||||
if is_header && !header_belongs_to(header, table_name) {
|
|
||||||
blocks.push((start_offset, offset));
|
|
||||||
start = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if start.is_none() && header_belongs_to(header, table_name) {
|
|
||||||
start = Some(offset);
|
|
||||||
}
|
|
||||||
offset = offset.saturating_add(line.len());
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(start_offset) = start {
|
|
||||||
blocks.push((start_offset, source.len()));
|
|
||||||
}
|
|
||||||
blocks
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn write_atomic(path: PathBuf, contents: String) -> Result<(), ApiFailure> {
|
|
||||||
tokio::task::spawn_blocking(move || write_atomic_sync(&path, &contents))
|
|
||||||
.await
|
|
||||||
.map_err(|e| ApiFailure::internal(format!("failed to join writer: {}", e)))?
|
|
||||||
.map_err(|e| ApiFailure::internal(format!("failed to write config: {}", e)))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_atomic_sync(path: &Path, contents: &str) -> std::io::Result<()> {
|
|
||||||
let parent = path.parent().unwrap_or_else(|| Path::new("."));
|
|
||||||
std::fs::create_dir_all(parent)?;
|
|
||||||
|
|
||||||
let tmp_name = format!(
|
|
||||||
".{}.tmp-{}",
|
|
||||||
path.file_name()
|
|
||||||
.and_then(|s| s.to_str())
|
|
||||||
.unwrap_or("config.toml"),
|
|
||||||
rand::random::<u64>()
|
|
||||||
);
|
|
||||||
let tmp_path = parent.join(tmp_name);
|
|
||||||
|
|
||||||
let write_result = (|| {
|
|
||||||
let mut file = std::fs::OpenOptions::new()
|
|
||||||
.create_new(true)
|
|
||||||
.write(true)
|
|
||||||
.open(&tmp_path)?;
|
|
||||||
file.write_all(contents.as_bytes())?;
|
|
||||||
file.sync_all()?;
|
|
||||||
std::fs::rename(&tmp_path, path)?;
|
|
||||||
if let Ok(dir) = std::fs::File::open(parent) {
|
|
||||||
let _ = dir.sync_all();
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
})();
|
|
||||||
|
|
||||||
if write_result.is_err() {
|
|
||||||
let _ = std::fs::remove_file(&tmp_path);
|
|
||||||
}
|
|
||||||
write_result
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "config_store/tests.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn save_sections_preserves_other_tables_and_comments() {
|
|
||||||
let dir = std::env::temp_dir().join(format!("cfgtest-{}", rand::random::<u64>()));
|
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
|
||||||
let path = dir.join("config.toml");
|
|
||||||
std::fs::write(
|
|
||||||
&path,
|
|
||||||
"# top comment\n[censorship]\ntls_domain = \"old.example\"\n\n[server]\nport = 443\n",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let mut cfg = ProxyConfig::default();
|
|
||||||
cfg.censorship.tls_domain = "new.example".to_string();
|
|
||||||
cfg.server.port = 443;
|
|
||||||
|
|
||||||
let rev = save_sections_to_disk(&path, &cfg, &["censorship"])
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let written = std::fs::read_to_string(&path).unwrap();
|
|
||||||
assert!(written.contains("tls_domain = \"new.example\""));
|
|
||||||
assert!(written.contains("# top comment")); // untouched comment kept
|
|
||||||
assert!(written.contains("[server]\nport = 443")); // untouched table kept
|
|
||||||
assert_eq!(rev, compute_revision(&written));
|
|
||||||
std::fs::remove_dir_all(&dir).ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn find_bounds_matches_array_of_tables() {
|
|
||||||
let src =
|
|
||||||
"[server]\nport = 1\n\n[[upstreams]]\nkind = \"a\"\n\n[[upstreams]]\nkind = \"b\"\n";
|
|
||||||
let bounds = find_toml_table_bounds(src, "upstreams");
|
|
||||||
assert!(bounds.is_some(), "should locate [[upstreams]] block start");
|
|
||||||
let (start, end) = bounds.unwrap();
|
|
||||||
let slice = &src[start..end];
|
|
||||||
assert!(slice.starts_with("[[upstreams]]"));
|
|
||||||
assert!(slice.contains("kind = \"b\"")); // spans through the last upstream block
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn find_bounds_matches_header_with_inline_comment() {
|
|
||||||
let src = "[censorship] # notes\ntls_domain = \"a\"\n\n[server]\nport = 1\n";
|
|
||||||
let bounds = find_toml_table_bounds(src, "censorship");
|
|
||||||
assert!(bounds.is_some(), "commented header must still match");
|
|
||||||
let (start, end) = bounds.unwrap();
|
|
||||||
let slice = &src[start..end];
|
|
||||||
assert!(slice.starts_with("[censorship] # notes"));
|
|
||||||
assert!(slice.contains("tls_domain"));
|
|
||||||
assert!(!slice.contains("[server]")); // terminates at the next header
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn save_general_section_keeps_subtables_dotted_without_duplicates() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let path = dir.path().join("config.toml");
|
|
||||||
tokio::fs::write(
|
|
||||||
&path,
|
|
||||||
"[general]\nprefer_ipv6 = false\n\n[general.modes]\ntls = true\n\n\
|
|
||||||
[general.links]\npublic_host = \"old.example\"\n\n[server]\nport = 443\n",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let mut cfg = ProxyConfig::default();
|
|
||||||
cfg.general.prefer_ipv6 = true;
|
|
||||||
|
|
||||||
save_sections_to_disk(&path, &cfg, &["general"])
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let written = tokio::fs::read_to_string(&path).await.unwrap();
|
|
||||||
|
|
||||||
// No bare top-level [modes] / [links] headers leaked.
|
|
||||||
for line in written.lines() {
|
|
||||||
let header = line.trim();
|
|
||||||
assert_ne!(header, "[modes]", "leaked top-level [modes]:\n{written}");
|
|
||||||
assert_ne!(header, "[links]", "leaked top-level [links]:\n{written}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sub-tables kept their dotted prefix exactly once each.
|
|
||||||
assert_eq!(
|
|
||||||
written.matches("[general.modes]").count(),
|
|
||||||
1,
|
|
||||||
"[general.modes] must appear exactly once:\n{written}"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
written.matches("[general.links]").count(),
|
|
||||||
1,
|
|
||||||
"[general.links] must appear exactly once:\n{written}"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Result parses (duplicate tables would error here).
|
|
||||||
toml::from_str::<toml::Value>(&written)
|
|
||||||
.unwrap_or_else(|e| panic!("written config must parse: {e}\n{written}"));
|
|
||||||
|
|
||||||
assert!(written.contains("[server]\nport = 443")); // untouched table kept
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn save_general_section_is_idempotent_across_repeated_saves() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let path = dir.path().join("config.toml");
|
|
||||||
tokio::fs::write(
|
|
||||||
&path,
|
|
||||||
"[general]\nprefer_ipv6 = false\n\n[general.modes]\ntls = true\n\n\
|
|
||||||
[general.links]\npublic_host = \"old.example\"\n",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let mut cfg = ProxyConfig::default();
|
|
||||||
cfg.general.prefer_ipv6 = true;
|
|
||||||
|
|
||||||
save_sections_to_disk(&path, &cfg, &["general"])
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
save_sections_to_disk(&path, &cfg, &["general"])
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let written = tokio::fs::read_to_string(&path).await.unwrap();
|
|
||||||
assert_eq!(written.matches("[general.modes]").count(), 1, "{written}");
|
|
||||||
assert_eq!(written.matches("[general.links]").count(), 1, "{written}");
|
|
||||||
assert_eq!(written.matches("[general]").count(), 1, "{written}");
|
|
||||||
toml::from_str::<toml::Value>(&written)
|
|
||||||
.unwrap_or_else(|e| panic!("written config must parse: {e}\n{written}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn find_bounds_spans_dotted_subtables() {
|
|
||||||
let src = "[general]\nprefer_ipv6 = false\n\n[general.modes]\ntls = true\n\n\
|
|
||||||
[general.links]\npublic_host = \"a\"\n\n[server]\nport = 1\n";
|
|
||||||
let bounds = find_toml_table_bounds(src, "general");
|
|
||||||
assert!(bounds.is_some(), "should locate [general] block");
|
|
||||||
let (start, end) = bounds.unwrap();
|
|
||||||
let slice = &src[start..end];
|
|
||||||
assert!(slice.starts_with("[general]"));
|
|
||||||
assert!(slice.contains("[general.modes]")); // spans nested sub-tables
|
|
||||||
assert!(slice.contains("[general.links]"));
|
|
||||||
assert!(!slice.contains("[server]")); // terminates at the next unrelated header
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn find_bounds_does_not_overrun_sibling_prefix() {
|
|
||||||
// access.users must not swallow access.user_enabled (dot guards the prefix).
|
|
||||||
let src = "[access.users]\nalice = \"x\"\n\n[access.user_enabled]\nalice = true\n";
|
|
||||||
let bounds = find_toml_table_bounds(src, "access.users").unwrap();
|
|
||||||
let slice = &src[bounds.0..bounds.1];
|
|
||||||
assert!(slice.starts_with("[access.users]"));
|
|
||||||
assert!(!slice.contains("[access.user_enabled]"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn save_general_handles_non_contiguous_subtables() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let path = dir.path().join("config.toml");
|
|
||||||
// Hand-edited layout: [general.modes] sits AFTER an unrelated [server].
|
|
||||||
tokio::fs::write(
|
|
||||||
&path,
|
|
||||||
"[general]\nprefer_ipv6 = false\n\n[server]\nport = 443\n\n\
|
|
||||||
[general.modes]\ntls = true\n",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let mut cfg = ProxyConfig::default();
|
|
||||||
cfg.general.prefer_ipv6 = true;
|
|
||||||
|
|
||||||
save_sections_to_disk(&path, &cfg, &["general"])
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let written = tokio::fs::read_to_string(&path).await.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
written.matches("[general.modes]").count(),
|
|
||||||
1,
|
|
||||||
"non-contiguous [general.modes] must not duplicate:\n{written}"
|
|
||||||
);
|
|
||||||
toml::from_str::<toml::Value>(&written)
|
|
||||||
.unwrap_or_else(|e| panic!("written config must parse: {e}\n{written}"));
|
|
||||||
assert!(written.contains("[server]")); // unrelated section preserved
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn render_user_rate_limits_section() {
|
|
||||||
let mut cfg = ProxyConfig::default();
|
|
||||||
cfg.access.user_rate_limits.insert(
|
|
||||||
"alice".to_string(),
|
|
||||||
RateLimitBps {
|
|
||||||
up_bps: 1024,
|
|
||||||
down_bps: 2048,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let rendered = render_access_section(&cfg, AccessSection::UserRateLimits)
|
|
||||||
.expect("section must render");
|
|
||||||
|
|
||||||
assert!(rendered.starts_with("[access.user_rate_limits]\n"));
|
|
||||||
assert!(rendered.contains("alice = { up_bps = 1024, down_bps = 2048 }"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,423 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::config::{ProxyConfig, RateLimitBps};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
AccessSection, compute_snapshot_revision, load_candidate_snapshot, load_config_snapshot,
|
||||||
|
resolve_single_source_owner, toml_path_exists,
|
||||||
|
};
|
||||||
|
use crate::api::model::ApiFailure;
|
||||||
|
#[cfg(test)]
|
||||||
|
use super::compute_revision;
|
||||||
|
|
||||||
|
/// Re-render the given top-level tables from `cfg` and upsert each into the
|
||||||
|
/// on-disk file, preserving every untouched section (and its comments).
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(super) async fn save_sections_to_disk(
|
||||||
|
config_path: &Path,
|
||||||
|
cfg: &ProxyConfig,
|
||||||
|
sections: &[&str],
|
||||||
|
) -> Result<String, ApiFailure> {
|
||||||
|
let mut content = tokio::fs::read_to_string(config_path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiFailure::internal(format!("failed to read config: {}", e)))?;
|
||||||
|
|
||||||
|
for section in sections {
|
||||||
|
let rendered = render_top_level_section(cfg, section)?;
|
||||||
|
content = upsert_toml_table(&content, section, &rendered);
|
||||||
|
}
|
||||||
|
|
||||||
|
write_atomic(config_path.to_path_buf(), content.clone()).await?;
|
||||||
|
Ok(compute_revision(&content))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render one top-level table as `[section]\n...\n` (or `[[upstreams]]` array
|
||||||
|
/// of tables) from the typed `cfg`. Serializes via the `toml` crate so the
|
||||||
|
/// output matches the canonical format Telemt parses.
|
||||||
|
pub(in crate::api) fn render_top_level_section(
|
||||||
|
cfg: &ProxyConfig,
|
||||||
|
section: &str,
|
||||||
|
) -> Result<String, ApiFailure> {
|
||||||
|
let value = toml::Value::try_from(cfg)
|
||||||
|
.map_err(|e| ApiFailure::internal(format!("failed to serialize config: {}", e)))?;
|
||||||
|
let table = value
|
||||||
|
.get(section)
|
||||||
|
.ok_or_else(|| ApiFailure::internal(format!("unknown section: {}", section)))?;
|
||||||
|
|
||||||
|
// upstreams is an array-of-tables -> render as [[upstreams]] blocks.
|
||||||
|
if let toml::Value::Array(items) = table {
|
||||||
|
let mut out = String::new();
|
||||||
|
for item in items {
|
||||||
|
out.push_str(&format!("[[{}]]\n", section));
|
||||||
|
out.push_str(&toml::to_string(item).map_err(|e| {
|
||||||
|
ApiFailure::internal(format!("failed to serialize {}: {}", section, e))
|
||||||
|
})?);
|
||||||
|
if !out.ends_with('\n') {
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize the table *inside a wrapper keyed by `section`* so the `toml`
|
||||||
|
// crate emits correctly dotted headers for nested sub-tables, e.g.
|
||||||
|
// `[general]` + `[general.modes]` + `[general.links]`. Serializing the
|
||||||
|
// inner table alone would render bare `[modes]`/`[links]` headers, which
|
||||||
|
// would leak as duplicate top-level tables and break config load.
|
||||||
|
let mut wrapper = toml::value::Table::new();
|
||||||
|
wrapper.insert(section.to_string(), table.clone());
|
||||||
|
let mut out = toml::to_string(&toml::Value::Table(wrapper))
|
||||||
|
.map_err(|e| ApiFailure::internal(format!("failed to serialize {}: {}", section, e)))?;
|
||||||
|
if !out.ends_with('\n') {
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders normalized listener entries as nested array-of-table blocks.
|
||||||
|
pub(in crate::api) fn render_server_listeners(
|
||||||
|
cfg: &ProxyConfig,
|
||||||
|
) -> Result<String, ApiFailure> {
|
||||||
|
let mut out = String::new();
|
||||||
|
for listener in &cfg.server.listeners {
|
||||||
|
out.push_str("[[server.listeners]]\n");
|
||||||
|
out.push_str(&toml::to_string(listener).map_err(|error| {
|
||||||
|
ApiFailure::internal(format!("failed to serialize server.listeners: {error}"))
|
||||||
|
})?);
|
||||||
|
if !out.ends_with('\n') {
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates and atomically writes access tables to their single source owner.
|
||||||
|
pub(in crate::api) async fn save_access_sections_to_disk(
|
||||||
|
config_path: &Path,
|
||||||
|
cfg: &ProxyConfig,
|
||||||
|
sections: &[AccessSection],
|
||||||
|
) -> Result<String, ApiFailure> {
|
||||||
|
let loaded = load_config_snapshot(config_path, false).await?;
|
||||||
|
let mut applied = Vec::new();
|
||||||
|
for section in sections {
|
||||||
|
if applied.contains(section) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
applied.push(*section);
|
||||||
|
}
|
||||||
|
applied.retain(|section| {
|
||||||
|
!access_section_is_empty(cfg, *section)
|
||||||
|
|| loaded.source_contents.values().any(|contents| {
|
||||||
|
toml::from_str::<toml::Value>(contents)
|
||||||
|
.ok()
|
||||||
|
.is_some_and(|value| toml_path_exists(&value, section.table_name()))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if applied.is_empty() {
|
||||||
|
return Ok(compute_snapshot_revision(&loaded));
|
||||||
|
}
|
||||||
|
|
||||||
|
let targets = applied
|
||||||
|
.iter()
|
||||||
|
.map(|section| section.table_name())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let owner_path = resolve_single_source_owner(&loaded, config_path, &targets)?;
|
||||||
|
let mut owner_contents = loaded
|
||||||
|
.source_contents
|
||||||
|
.get(&owner_path)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| ApiFailure::internal("config source owner is missing from snapshot"))?;
|
||||||
|
for section in applied {
|
||||||
|
let rendered = render_access_section(cfg, section)?;
|
||||||
|
owner_contents = upsert_toml_table(&owner_contents, section.table_name(), &rendered);
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidate = load_candidate_snapshot(
|
||||||
|
config_path,
|
||||||
|
&loaded.source_contents,
|
||||||
|
owner_path.clone(),
|
||||||
|
owner_contents.clone(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let revision = compute_snapshot_revision(&candidate);
|
||||||
|
write_atomic(owner_path, owner_contents).await?;
|
||||||
|
Ok(revision)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders one access-control table for persistence tests and user mutations.
|
||||||
|
pub(super) fn render_access_section(
|
||||||
|
cfg: &ProxyConfig,
|
||||||
|
section: AccessSection,
|
||||||
|
) -> Result<String, ApiFailure> {
|
||||||
|
let body = match section {
|
||||||
|
AccessSection::Users => {
|
||||||
|
let rows: BTreeMap<String, String> = cfg
|
||||||
|
.access
|
||||||
|
.users
|
||||||
|
.iter()
|
||||||
|
.map(|(key, value)| (key.clone(), value.clone()))
|
||||||
|
.collect();
|
||||||
|
serialize_table_body(&rows)?
|
||||||
|
}
|
||||||
|
AccessSection::UserEnabled => {
|
||||||
|
let rows: BTreeMap<String, bool> = cfg
|
||||||
|
.access
|
||||||
|
.user_enabled
|
||||||
|
.iter()
|
||||||
|
.map(|(key, value)| (key.clone(), *value))
|
||||||
|
.collect();
|
||||||
|
serialize_table_body(&rows)?
|
||||||
|
}
|
||||||
|
AccessSection::UserAdTags => {
|
||||||
|
let rows: BTreeMap<String, String> = cfg
|
||||||
|
.access
|
||||||
|
.user_ad_tags
|
||||||
|
.iter()
|
||||||
|
.map(|(key, value)| (key.clone(), value.clone()))
|
||||||
|
.collect();
|
||||||
|
serialize_table_body(&rows)?
|
||||||
|
}
|
||||||
|
AccessSection::UserMaxTcpConns => {
|
||||||
|
let rows: BTreeMap<String, usize> = cfg
|
||||||
|
.access
|
||||||
|
.user_max_tcp_conns
|
||||||
|
.iter()
|
||||||
|
.map(|(key, value)| (key.clone(), *value))
|
||||||
|
.collect();
|
||||||
|
serialize_table_body(&rows)?
|
||||||
|
}
|
||||||
|
AccessSection::UserExpirations => {
|
||||||
|
let rows: BTreeMap<String, DateTime<Utc>> = cfg
|
||||||
|
.access
|
||||||
|
.user_expirations
|
||||||
|
.iter()
|
||||||
|
.map(|(key, value)| (key.clone(), *value))
|
||||||
|
.collect();
|
||||||
|
serialize_table_body(&rows)?
|
||||||
|
}
|
||||||
|
AccessSection::UserDataQuota => {
|
||||||
|
let rows: BTreeMap<String, u64> = cfg
|
||||||
|
.access
|
||||||
|
.user_data_quota
|
||||||
|
.iter()
|
||||||
|
.map(|(key, value)| (key.clone(), *value))
|
||||||
|
.collect();
|
||||||
|
serialize_table_body(&rows)?
|
||||||
|
}
|
||||||
|
AccessSection::UserRateLimits => {
|
||||||
|
let rows: BTreeMap<String, RateLimitBps> = cfg
|
||||||
|
.access
|
||||||
|
.user_rate_limits
|
||||||
|
.iter()
|
||||||
|
.map(|(key, value)| (key.clone(), *value))
|
||||||
|
.collect();
|
||||||
|
serialize_rate_limit_body(&rows)?
|
||||||
|
}
|
||||||
|
AccessSection::UserMaxUniqueIps => {
|
||||||
|
let rows: BTreeMap<String, usize> = cfg
|
||||||
|
.access
|
||||||
|
.user_max_unique_ips
|
||||||
|
.iter()
|
||||||
|
.map(|(key, value)| (key.clone(), *value))
|
||||||
|
.collect();
|
||||||
|
serialize_table_body(&rows)?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut out = format!("[{}]\n", section.table_name());
|
||||||
|
if !body.is_empty() {
|
||||||
|
out.push_str(&body);
|
||||||
|
}
|
||||||
|
if !out.ends_with('\n') {
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn access_section_is_empty(cfg: &ProxyConfig, section: AccessSection) -> bool {
|
||||||
|
match section {
|
||||||
|
AccessSection::Users => cfg.access.users.is_empty(),
|
||||||
|
AccessSection::UserEnabled => cfg.access.user_enabled.is_empty(),
|
||||||
|
AccessSection::UserAdTags => cfg.access.user_ad_tags.is_empty(),
|
||||||
|
AccessSection::UserMaxTcpConns => cfg.access.user_max_tcp_conns.is_empty(),
|
||||||
|
AccessSection::UserExpirations => cfg.access.user_expirations.is_empty(),
|
||||||
|
AccessSection::UserDataQuota => cfg.access.user_data_quota.is_empty(),
|
||||||
|
AccessSection::UserRateLimits => cfg.access.user_rate_limits.is_empty(),
|
||||||
|
AccessSection::UserMaxUniqueIps => cfg.access.user_max_unique_ips.is_empty(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serialize_table_body<T: Serialize>(value: &T) -> Result<String, ApiFailure> {
|
||||||
|
toml::to_string(value)
|
||||||
|
.map_err(|e| ApiFailure::internal(format!("failed to serialize access section: {}", e)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serialize_rate_limit_body(rows: &BTreeMap<String, RateLimitBps>) -> Result<String, ApiFailure> {
|
||||||
|
let mut out = String::new();
|
||||||
|
for (key, value) in rows {
|
||||||
|
let key = serialize_toml_key(key)?;
|
||||||
|
out.push_str(&format!(
|
||||||
|
"{key} = {{ up_bps = {}, down_bps = {} }}\n",
|
||||||
|
value.up_bps, value.down_bps
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serialize_toml_key(key: &str) -> Result<String, ApiFailure> {
|
||||||
|
let mut row = BTreeMap::new();
|
||||||
|
row.insert(key.to_string(), 0_u8);
|
||||||
|
let rendered = serialize_table_body(&row)?;
|
||||||
|
rendered
|
||||||
|
.split_once(" = ")
|
||||||
|
.map(|(key, _)| key.to_string())
|
||||||
|
.ok_or_else(|| ApiFailure::internal("failed to serialize TOML key"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces all blocks owned by one semantic TOML table with one rendering.
|
||||||
|
pub(in crate::api) fn upsert_toml_table(
|
||||||
|
source: &str,
|
||||||
|
table_name: &str,
|
||||||
|
replacement: &str,
|
||||||
|
) -> String {
|
||||||
|
let blocks = find_all_table_blocks(source, table_name);
|
||||||
|
if let Some(&(first_start, first_end)) = blocks.first() {
|
||||||
|
// Replace the first block in place and delete any further blocks that
|
||||||
|
// also belong to this table. Telemt writes a section's sub-tables
|
||||||
|
// contiguously, but a hand-edited config may scatter them; dropping the
|
||||||
|
// extras here prevents the duplicate-table corruption that would
|
||||||
|
// otherwise break config load.
|
||||||
|
let mut out = String::with_capacity(source.len() + replacement.len());
|
||||||
|
out.push_str(&source[..first_start]);
|
||||||
|
out.push_str(replacement);
|
||||||
|
let mut cursor = first_end;
|
||||||
|
for &(start, end) in &blocks[1..] {
|
||||||
|
out.push_str(&source[cursor..start]);
|
||||||
|
cursor = end;
|
||||||
|
}
|
||||||
|
out.push_str(&source[cursor..]);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = source.to_string();
|
||||||
|
if !out.is_empty() && !out.ends_with('\n') {
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
if !out.is_empty() {
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
out.push_str(replacement);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a (comment-stripped, trimmed) TOML header line belongs to
|
||||||
|
/// `table_name`: the table itself (`[X]` / `[[X]]`) or any of its nested
|
||||||
|
/// sub-tables (`[X.…]` / `[[X.…]]`). The trailing dot guards against sibling
|
||||||
|
/// prefixes — `access.users` must not match `access.user_enabled`.
|
||||||
|
fn header_belongs_to(header: &str, table_name: &str) -> bool {
|
||||||
|
let body = match header.strip_prefix("[[").and_then(|h| h.strip_suffix("]]")) {
|
||||||
|
Some(body) => body,
|
||||||
|
None => match header.strip_prefix('[').and_then(|h| h.strip_suffix(']')) {
|
||||||
|
Some(body) => body,
|
||||||
|
None => return false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let body = body.trim();
|
||||||
|
body == table_name
|
||||||
|
|| body
|
||||||
|
.strip_prefix(table_name)
|
||||||
|
.is_some_and(|rest| rest.starts_with('.'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Locate the first contiguous byte range covering `table_name` and the nested
|
||||||
|
/// sub-tables immediately following it. Used for existence checks; see
|
||||||
|
/// [`find_all_table_blocks`] for the full set of (possibly scattered) blocks.
|
||||||
|
/// Locates one complete TOML table block in source text.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(super) fn find_toml_table_bounds(
|
||||||
|
source: &str,
|
||||||
|
table_name: &str,
|
||||||
|
) -> Option<(usize, usize)> {
|
||||||
|
find_all_table_blocks(source, table_name).into_iter().next()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Locate every byte range that belongs to `table_name`: the table header and
|
||||||
|
/// its nested sub-tables. Returns one range per contiguous run, so a config
|
||||||
|
/// where a section's sub-tables are scattered (e.g. hand-edited) yields several
|
||||||
|
/// ranges — letting the caller collapse them into a single rendered block.
|
||||||
|
fn find_all_table_blocks(source: &str, table_name: &str) -> Vec<(usize, usize)> {
|
||||||
|
let mut blocks = Vec::new();
|
||||||
|
let mut offset = 0usize;
|
||||||
|
let mut start: Option<usize> = None;
|
||||||
|
|
||||||
|
for line in source.split_inclusive('\n') {
|
||||||
|
// Drop any inline comment so a hand-edited header like
|
||||||
|
// `[censorship] # note` still matches. Section names never contain `#`.
|
||||||
|
let header = line.trim().split('#').next().unwrap_or("").trim();
|
||||||
|
let is_header = header.starts_with('[');
|
||||||
|
if let Some(start_offset) = start {
|
||||||
|
if is_header && !header_belongs_to(header, table_name) {
|
||||||
|
blocks.push((start_offset, offset));
|
||||||
|
start = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if start.is_none() && header_belongs_to(header, table_name) {
|
||||||
|
start = Some(offset);
|
||||||
|
}
|
||||||
|
offset = offset.saturating_add(line.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(start_offset) = start {
|
||||||
|
blocks.push((start_offset, source.len()));
|
||||||
|
}
|
||||||
|
blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces one config source through a durable same-directory rename.
|
||||||
|
pub(in crate::api) async fn write_atomic(
|
||||||
|
path: PathBuf,
|
||||||
|
contents: String,
|
||||||
|
) -> Result<(), ApiFailure> {
|
||||||
|
tokio::task::spawn_blocking(move || write_atomic_sync(&path, &contents))
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiFailure::internal(format!("failed to join writer: {}", e)))?
|
||||||
|
.map_err(|e| ApiFailure::internal(format!("failed to write config: {}", e)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_atomic_sync(path: &Path, contents: &str) -> std::io::Result<()> {
|
||||||
|
let parent = path.parent().unwrap_or_else(|| Path::new("."));
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
|
||||||
|
let tmp_name = format!(
|
||||||
|
".{}.tmp-{}",
|
||||||
|
path.file_name()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or("config.toml"),
|
||||||
|
rand::random::<u64>()
|
||||||
|
);
|
||||||
|
let tmp_path = parent.join(tmp_name);
|
||||||
|
|
||||||
|
let write_result = (|| {
|
||||||
|
let mut file = std::fs::OpenOptions::new()
|
||||||
|
.create_new(true)
|
||||||
|
.write(true)
|
||||||
|
.open(&tmp_path)?;
|
||||||
|
file.write_all(contents.as_bytes())?;
|
||||||
|
file.sync_all()?;
|
||||||
|
std::fs::rename(&tmp_path, path)?;
|
||||||
|
if let Ok(dir) = std::fs::File::open(parent) {
|
||||||
|
let _ = dir.sync_all();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
|
||||||
|
if write_result.is_err() {
|
||||||
|
let _ = std::fs::remove_file(&tmp_path);
|
||||||
|
}
|
||||||
|
write_result
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
use super::*;
|
||||||
|
use crate::config::RateLimitBps;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn save_sections_preserves_other_tables_and_comments() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("cfgtest-{}", rand::random::<u64>()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let path = dir.join("config.toml");
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
"# top comment\n[censorship]\ntls_domain = \"old.example\"\n\n[server]\nport = 443\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut cfg = ProxyConfig::default();
|
||||||
|
cfg.censorship.tls_domain = "new.example".to_string();
|
||||||
|
cfg.server.port = 443;
|
||||||
|
|
||||||
|
let rev = save_sections_to_disk(&path, &cfg, &["censorship"])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let written = std::fs::read_to_string(&path).unwrap();
|
||||||
|
assert!(written.contains("tls_domain = \"new.example\""));
|
||||||
|
// Untouched comments and tables remain byte content.
|
||||||
|
assert!(written.contains("# top comment"));
|
||||||
|
assert!(written.contains("[server]\nport = 443"));
|
||||||
|
assert_eq!(rev, compute_revision(&written));
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_bounds_matches_array_of_tables() {
|
||||||
|
let src =
|
||||||
|
"[server]\nport = 1\n\n[[upstreams]]\nkind = \"a\"\n\n[[upstreams]]\nkind = \"b\"\n";
|
||||||
|
let bounds = find_toml_table_bounds(src, "upstreams");
|
||||||
|
assert!(bounds.is_some(), "should locate [[upstreams]] block start");
|
||||||
|
let (start, end) = bounds.unwrap();
|
||||||
|
let slice = &src[start..end];
|
||||||
|
assert!(slice.starts_with("[[upstreams]]"));
|
||||||
|
// The bound spans through the last upstream block.
|
||||||
|
assert!(slice.contains("kind = \"b\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_bounds_matches_header_with_inline_comment() {
|
||||||
|
let src = "[censorship] # notes\ntls_domain = \"a\"\n\n[server]\nport = 1\n";
|
||||||
|
let bounds = find_toml_table_bounds(src, "censorship");
|
||||||
|
assert!(bounds.is_some(), "commented header must still match");
|
||||||
|
let (start, end) = bounds.unwrap();
|
||||||
|
let slice = &src[start..end];
|
||||||
|
assert!(slice.starts_with("[censorship] # notes"));
|
||||||
|
assert!(slice.contains("tls_domain"));
|
||||||
|
// The bound terminates at the next header.
|
||||||
|
assert!(!slice.contains("[server]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn save_general_section_keeps_subtables_dotted_without_duplicates() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("config.toml");
|
||||||
|
tokio::fs::write(
|
||||||
|
&path,
|
||||||
|
"[general]\nprefer_ipv6 = false\n\n[general.modes]\ntls = true\n\n\
|
||||||
|
[general.links]\npublic_host = \"old.example\"\n\n[server]\nport = 443\n",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut cfg = ProxyConfig::default();
|
||||||
|
cfg.general.prefer_ipv6 = true;
|
||||||
|
|
||||||
|
save_sections_to_disk(&path, &cfg, &["general"])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let written = tokio::fs::read_to_string(&path).await.unwrap();
|
||||||
|
|
||||||
|
// No bare top-level [modes] / [links] headers leaked.
|
||||||
|
for line in written.lines() {
|
||||||
|
let header = line.trim();
|
||||||
|
assert_ne!(header, "[modes]", "leaked top-level [modes]:\n{written}");
|
||||||
|
assert_ne!(header, "[links]", "leaked top-level [links]:\n{written}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sub-tables kept their dotted prefix exactly once each.
|
||||||
|
assert_eq!(
|
||||||
|
written.matches("[general.modes]").count(),
|
||||||
|
1,
|
||||||
|
"[general.modes] must appear exactly once:\n{written}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
written.matches("[general.links]").count(),
|
||||||
|
1,
|
||||||
|
"[general.links] must appear exactly once:\n{written}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Result parses (duplicate tables would error here).
|
||||||
|
toml::from_str::<toml::Value>(&written)
|
||||||
|
.unwrap_or_else(|e| panic!("written config must parse: {e}\n{written}"));
|
||||||
|
|
||||||
|
// The unrelated table remains untouched.
|
||||||
|
assert!(written.contains("[server]\nport = 443"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn save_general_section_is_idempotent_across_repeated_saves() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("config.toml");
|
||||||
|
tokio::fs::write(
|
||||||
|
&path,
|
||||||
|
"[general]\nprefer_ipv6 = false\n\n[general.modes]\ntls = true\n\n\
|
||||||
|
[general.links]\npublic_host = \"old.example\"\n",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut cfg = ProxyConfig::default();
|
||||||
|
cfg.general.prefer_ipv6 = true;
|
||||||
|
|
||||||
|
save_sections_to_disk(&path, &cfg, &["general"])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
save_sections_to_disk(&path, &cfg, &["general"])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let written = tokio::fs::read_to_string(&path).await.unwrap();
|
||||||
|
assert_eq!(written.matches("[general.modes]").count(), 1, "{written}");
|
||||||
|
assert_eq!(written.matches("[general.links]").count(), 1, "{written}");
|
||||||
|
assert_eq!(written.matches("[general]").count(), 1, "{written}");
|
||||||
|
toml::from_str::<toml::Value>(&written)
|
||||||
|
.unwrap_or_else(|e| panic!("written config must parse: {e}\n{written}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_bounds_spans_dotted_subtables() {
|
||||||
|
let src = "[general]\nprefer_ipv6 = false\n\n[general.modes]\ntls = true\n\n\
|
||||||
|
[general.links]\npublic_host = \"a\"\n\n[server]\nport = 1\n";
|
||||||
|
let bounds = find_toml_table_bounds(src, "general");
|
||||||
|
assert!(bounds.is_some(), "should locate [general] block");
|
||||||
|
let (start, end) = bounds.unwrap();
|
||||||
|
let slice = &src[start..end];
|
||||||
|
assert!(slice.starts_with("[general]"));
|
||||||
|
// Nested sub-tables belong to the parent table bound.
|
||||||
|
assert!(slice.contains("[general.modes]"));
|
||||||
|
assert!(slice.contains("[general.links]"));
|
||||||
|
// The bound terminates before an unrelated header.
|
||||||
|
assert!(!slice.contains("[server]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_bounds_does_not_overrun_sibling_prefix() {
|
||||||
|
// access.users must not swallow access.user_enabled (dot guards the prefix).
|
||||||
|
let src = "[access.users]\nalice = \"x\"\n\n[access.user_enabled]\nalice = true\n";
|
||||||
|
let bounds = find_toml_table_bounds(src, "access.users").unwrap();
|
||||||
|
let slice = &src[bounds.0..bounds.1];
|
||||||
|
assert!(slice.starts_with("[access.users]"));
|
||||||
|
assert!(!slice.contains("[access.user_enabled]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nested_include_detection_does_not_reject_similar_access_keys() {
|
||||||
|
assert!(has_include_inside_table(
|
||||||
|
"[access.users]\ninclude = \"users.toml\"\n"
|
||||||
|
));
|
||||||
|
assert!(!has_include_inside_table(
|
||||||
|
"[access.users]\ninclude_user = \"00000000000000000000000000000000\"\n"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn save_general_handles_non_contiguous_subtables() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("config.toml");
|
||||||
|
// Hand-edited layout: [general.modes] sits AFTER an unrelated [server].
|
||||||
|
tokio::fs::write(
|
||||||
|
&path,
|
||||||
|
"[general]\nprefer_ipv6 = false\n\n[server]\nport = 443\n\n\
|
||||||
|
[general.modes]\ntls = true\n",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut cfg = ProxyConfig::default();
|
||||||
|
cfg.general.prefer_ipv6 = true;
|
||||||
|
|
||||||
|
save_sections_to_disk(&path, &cfg, &["general"])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let written = tokio::fs::read_to_string(&path).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
written.matches("[general.modes]").count(),
|
||||||
|
1,
|
||||||
|
"non-contiguous [general.modes] must not duplicate:\n{written}"
|
||||||
|
);
|
||||||
|
toml::from_str::<toml::Value>(&written)
|
||||||
|
.unwrap_or_else(|e| panic!("written config must parse: {e}\n{written}"));
|
||||||
|
// The unrelated section remains present.
|
||||||
|
assert!(written.contains("[server]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn manifest_revision_changes_when_an_included_source_changes() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let root = dir.path().join("config.toml");
|
||||||
|
let included = dir.path().join("included.toml");
|
||||||
|
tokio::fs::write(&root, "include = \"included.toml\"\n")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
tokio::fs::write(&included, "[censorship]\ntls_domain = \"one.example\"\n")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let first = current_revision(&root).await.unwrap();
|
||||||
|
|
||||||
|
tokio::fs::write(&included, "[censorship]\ntls_domain = \"two.example\"\n")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let second = current_revision(&root).await.unwrap();
|
||||||
|
|
||||||
|
assert_ne!(first, second);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn manifest_revision_does_not_require_typed_config_validation() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let root = dir.path().join("config.toml");
|
||||||
|
tokio::fs::write(&root, "[server]\nport = \"invalid\"\n")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let revision = current_revision(&root).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(revision.len(), 64);
|
||||||
|
assert!(load_config_snapshot(&root, false).await.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn access_mutation_writes_only_the_single_included_owner() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let root = dir.path().join("config.toml");
|
||||||
|
let included = dir.path().join("users.toml");
|
||||||
|
let root_body = "include = \"users.toml\"\n[censorship]\ntls_domain = \"example.com\"\n";
|
||||||
|
let included_body =
|
||||||
|
"[access.users]\nalice = \"00000000000000000000000000000000\"\n";
|
||||||
|
tokio::fs::write(&root, root_body).await.unwrap();
|
||||||
|
tokio::fs::write(&included, included_body).await.unwrap();
|
||||||
|
let mut cfg = load_config_from_disk(&root).await.unwrap();
|
||||||
|
cfg.access.users.insert(
|
||||||
|
"bob".to_string(),
|
||||||
|
"11111111111111111111111111111111".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let revision = save_access_sections_to_disk(&root, &cfg, &[AccessSection::Users])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(tokio::fs::read_to_string(&root).await.unwrap(), root_body);
|
||||||
|
let written = tokio::fs::read_to_string(&included).await.unwrap();
|
||||||
|
assert!(written.contains("bob = \"11111111111111111111111111111111\""));
|
||||||
|
assert_eq!(revision, current_revision(&root).await.unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn access_mutation_rejects_sections_with_different_source_owners() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let root = dir.path().join("config.toml");
|
||||||
|
let included = dir.path().join("enabled.toml");
|
||||||
|
let root_body = concat!(
|
||||||
|
"include = \"enabled.toml\"\n",
|
||||||
|
"[access.users]\nalice = \"00000000000000000000000000000000\"\n"
|
||||||
|
);
|
||||||
|
let included_body = "[access.user_enabled]\nalice = false\n";
|
||||||
|
tokio::fs::write(&root, root_body).await.unwrap();
|
||||||
|
tokio::fs::write(&included, included_body).await.unwrap();
|
||||||
|
let cfg = load_config_from_disk(&root).await.unwrap();
|
||||||
|
|
||||||
|
let error = save_access_sections_to_disk(
|
||||||
|
&root,
|
||||||
|
&cfg,
|
||||||
|
&[AccessSection::Users, AccessSection::UserEnabled],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(error.code, "config_patch_not_atomic");
|
||||||
|
assert_eq!(tokio::fs::read_to_string(&root).await.unwrap(), root_body);
|
||||||
|
assert_eq!(
|
||||||
|
tokio::fs::read_to_string(&included).await.unwrap(),
|
||||||
|
included_body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_user_rate_limits_section() {
|
||||||
|
let mut cfg = ProxyConfig::default();
|
||||||
|
cfg.access.user_rate_limits.insert(
|
||||||
|
"alice".to_string(),
|
||||||
|
RateLimitBps {
|
||||||
|
up_bps: 1024,
|
||||||
|
down_bps: 2048,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let rendered = render_access_section(&cfg, AccessSection::UserRateLimits)
|
||||||
|
.expect("section must render");
|
||||||
|
|
||||||
|
assert!(rendered.starts_with("[access.user_rate_limits]\n"));
|
||||||
|
assert!(rendered.contains("alice = { up_bps = 1024, down_bps = 2048 }"));
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ async fn config_file() -> (tempfile::TempDir, PathBuf, String) {
|
|||||||
config.server.max_connections = 4_242;
|
config.server.max_connections = 4_242;
|
||||||
let body = toml::to_string_pretty(&config).unwrap();
|
let body = toml::to_string_pretty(&config).unwrap();
|
||||||
tokio::fs::write(&path, &body).await.unwrap();
|
tokio::fs::write(&path, &body).await.unwrap();
|
||||||
let revision = config_store::compute_revision(&body);
|
let revision = config_store::current_revision(&path).await.unwrap();
|
||||||
(directory, path, revision)
|
(directory, path, revision)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+23
-28
@@ -418,18 +418,8 @@ pub(super) async fn rotate_secret(
|
|||||||
cfg.access.users.insert(user.to_string(), secret.clone());
|
cfg.access.users.insert(user.to_string(), secret.clone());
|
||||||
cfg.validate()
|
cfg.validate()
|
||||||
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
|
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
|
||||||
let touched_sections = [
|
|
||||||
AccessSection::Users,
|
|
||||||
AccessSection::UserEnabled,
|
|
||||||
AccessSection::UserAdTags,
|
|
||||||
AccessSection::UserMaxTcpConns,
|
|
||||||
AccessSection::UserExpirations,
|
|
||||||
AccessSection::UserDataQuota,
|
|
||||||
AccessSection::UserRateLimits,
|
|
||||||
AccessSection::UserMaxUniqueIps,
|
|
||||||
];
|
|
||||||
let revision =
|
let revision =
|
||||||
save_access_sections_to_disk(&shared.config_path, &cfg, &touched_sections).await?;
|
save_access_sections_to_disk(&shared.config_path, &cfg, &[AccessSection::Users]).await?;
|
||||||
drop(_guard);
|
drop(_guard);
|
||||||
|
|
||||||
let (detected_ip_v4, detected_ip_v6) = shared.detected_link_ips();
|
let (detected_ip_v4, detected_ip_v6) = shared.detected_link_ips();
|
||||||
@@ -529,27 +519,32 @@ pub(super) async fn delete_user(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut touched_sections = vec![AccessSection::Users];
|
||||||
cfg.access.users.remove(user);
|
cfg.access.users.remove(user);
|
||||||
cfg.access.user_enabled.remove(user);
|
if cfg.access.user_enabled.remove(user).is_some() {
|
||||||
cfg.access.user_ad_tags.remove(user);
|
touched_sections.push(AccessSection::UserEnabled);
|
||||||
cfg.access.user_max_tcp_conns.remove(user);
|
}
|
||||||
cfg.access.user_expirations.remove(user);
|
if cfg.access.user_ad_tags.remove(user).is_some() {
|
||||||
cfg.access.user_data_quota.remove(user);
|
touched_sections.push(AccessSection::UserAdTags);
|
||||||
cfg.access.user_rate_limits.remove(user);
|
}
|
||||||
cfg.access.user_max_unique_ips.remove(user);
|
if cfg.access.user_max_tcp_conns.remove(user).is_some() {
|
||||||
|
touched_sections.push(AccessSection::UserMaxTcpConns);
|
||||||
|
}
|
||||||
|
if cfg.access.user_expirations.remove(user).is_some() {
|
||||||
|
touched_sections.push(AccessSection::UserExpirations);
|
||||||
|
}
|
||||||
|
if cfg.access.user_data_quota.remove(user).is_some() {
|
||||||
|
touched_sections.push(AccessSection::UserDataQuota);
|
||||||
|
}
|
||||||
|
if cfg.access.user_rate_limits.remove(user).is_some() {
|
||||||
|
touched_sections.push(AccessSection::UserRateLimits);
|
||||||
|
}
|
||||||
|
if cfg.access.user_max_unique_ips.remove(user).is_some() {
|
||||||
|
touched_sections.push(AccessSection::UserMaxUniqueIps);
|
||||||
|
}
|
||||||
|
|
||||||
cfg.validate()
|
cfg.validate()
|
||||||
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
|
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
|
||||||
let touched_sections = [
|
|
||||||
AccessSection::Users,
|
|
||||||
AccessSection::UserEnabled,
|
|
||||||
AccessSection::UserAdTags,
|
|
||||||
AccessSection::UserMaxTcpConns,
|
|
||||||
AccessSection::UserExpirations,
|
|
||||||
AccessSection::UserDataQuota,
|
|
||||||
AccessSection::UserRateLimits,
|
|
||||||
AccessSection::UserMaxUniqueIps,
|
|
||||||
];
|
|
||||||
let revision =
|
let revision =
|
||||||
save_access_sections_to_disk(&shared.config_path, &cfg, &touched_sections).await?;
|
save_access_sections_to_disk(&shared.config_path, &cfg, &touched_sections).await?;
|
||||||
drop(_guard);
|
drop(_guard);
|
||||||
|
|||||||
+20
-96
@@ -16,12 +16,10 @@
|
|||||||
//! | `general` | `telemetry` / `me_*_policy` | Applied immediately |
|
//! | `general` | `telemetry` / `me_*_policy` | Applied immediately |
|
||||||
//! | `network` | `dns_overrides` | Applied immediately |
|
//! | `network` | `dns_overrides` | Applied immediately |
|
||||||
//! | `access` | All user/quota fields | Effective immediately |
|
//! | `access` | All user/quota fields | Effective immediately |
|
||||||
//! | `server.listeners` | `synlimit*` for existing endpoints | Netfilter rules reconciled immediately |
|
|
||||||
//!
|
|
||||||
//! Fields that require re-binding sockets (`server.listeners`, legacy
|
//! Fields that require re-binding sockets (`server.listeners`, legacy
|
||||||
//! `server.port`, `censorship.*`, `network.*`, `use_middle_proxy`) are **not**
|
//! `server.port`, `censorship.*`, `network.*`, `use_middle_proxy`) are **not**
|
||||||
//! applied, except for SYN limiter fields on unchanged listener endpoints; a
|
//! applied; a warning is emitted. SYN limiter rules are process-owned and are
|
||||||
//! warning is emitted.
|
//! reconciled only during privileged startup.
|
||||||
//! Non-hot changes are never mixed into the runtime config snapshot.
|
//! Non-hot changes are never mixed into the runtime config snapshot.
|
||||||
|
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
@@ -36,9 +34,11 @@ use tracing::{error, info, warn};
|
|||||||
|
|
||||||
use super::load::{LoadedConfig, ProxyConfig};
|
use super::load::{LoadedConfig, ProxyConfig};
|
||||||
use crate::config::{
|
use crate::config::{
|
||||||
CidrRateLimitKey, ListenerConfig, LogLevel, MeBindStaleMode, MeFloorMode, MeSocksKdfPolicy,
|
CidrRateLimitKey, LogLevel, MeBindStaleMode, MeFloorMode, MeSocksKdfPolicy, MeTelemetryLevel,
|
||||||
MeTelemetryLevel, MeWriterPickMode, SynLimitMode,
|
MeWriterPickMode,
|
||||||
};
|
};
|
||||||
|
#[cfg(test)]
|
||||||
|
use crate::config::{ListenerConfig, SynLimitMode};
|
||||||
|
|
||||||
const HOT_RELOAD_DEBOUNCE: Duration = Duration::from_millis(50);
|
const HOT_RELOAD_DEBOUNCE: Duration = Duration::from_millis(50);
|
||||||
|
|
||||||
@@ -133,22 +133,6 @@ pub struct HotFields {
|
|||||||
pub user_max_unique_ips_global_each: usize,
|
pub user_max_unique_ips_global_each: usize,
|
||||||
pub user_max_unique_ips_mode: crate::config::UserMaxUniqueIpsMode,
|
pub user_max_unique_ips_mode: crate::config::UserMaxUniqueIpsMode,
|
||||||
pub user_max_unique_ips_window_secs: u64,
|
pub user_max_unique_ips_window_secs: u64,
|
||||||
pub listener_synlimit: Vec<ListenerSynLimitHotFields>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct ListenerSynLimitHotFields {
|
|
||||||
pub ip: IpAddr,
|
|
||||||
pub port: Option<u16>,
|
|
||||||
pub synlimit: SynLimitMode,
|
|
||||||
pub synlimit_seconds: u32,
|
|
||||||
pub synlimit_hitcount: u32,
|
|
||||||
pub synlimit_burst: u32,
|
|
||||||
pub synlimit_ios_seconds: u32,
|
|
||||||
pub synlimit_ios_hitcount: u32,
|
|
||||||
pub synlimit_ios_burst: u32,
|
|
||||||
pub synlimit_hashlimit_expire_ms: u32,
|
|
||||||
pub synlimit_hashlimit_size: u32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HotFields {
|
impl HotFields {
|
||||||
@@ -278,30 +262,6 @@ impl HotFields {
|
|||||||
user_max_unique_ips_global_each: cfg.access.user_max_unique_ips_global_each,
|
user_max_unique_ips_global_each: cfg.access.user_max_unique_ips_global_each,
|
||||||
user_max_unique_ips_mode: cfg.access.user_max_unique_ips_mode,
|
user_max_unique_ips_mode: cfg.access.user_max_unique_ips_mode,
|
||||||
user_max_unique_ips_window_secs: cfg.access.user_max_unique_ips_window_secs,
|
user_max_unique_ips_window_secs: cfg.access.user_max_unique_ips_window_secs,
|
||||||
listener_synlimit: cfg
|
|
||||||
.server
|
|
||||||
.listeners
|
|
||||||
.iter()
|
|
||||||
.map(ListenerSynLimitHotFields::from_listener)
|
|
||||||
.collect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ListenerSynLimitHotFields {
|
|
||||||
fn from_listener(listener: &ListenerConfig) -> Self {
|
|
||||||
Self {
|
|
||||||
ip: listener.ip,
|
|
||||||
port: listener.port,
|
|
||||||
synlimit: listener.synlimit,
|
|
||||||
synlimit_seconds: listener.synlimit_seconds,
|
|
||||||
synlimit_hitcount: listener.synlimit_hitcount,
|
|
||||||
synlimit_burst: listener.synlimit_burst,
|
|
||||||
synlimit_ios_seconds: listener.synlimit_ios_seconds,
|
|
||||||
synlimit_ios_hitcount: listener.synlimit_ios_hitcount,
|
|
||||||
synlimit_ios_burst: listener.synlimit_ios_burst,
|
|
||||||
synlimit_hashlimit_expire_ms: listener.synlimit_hashlimit_expire_ms,
|
|
||||||
synlimit_hashlimit_size: listener.synlimit_hashlimit_size,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -348,18 +308,7 @@ fn listeners_equal(
|
|||||||
lhs: &[crate::config::ListenerConfig],
|
lhs: &[crate::config::ListenerConfig],
|
||||||
rhs: &[crate::config::ListenerConfig],
|
rhs: &[crate::config::ListenerConfig],
|
||||||
) -> bool {
|
) -> bool {
|
||||||
if lhs.len() != rhs.len() {
|
serde_json::to_value(lhs).ok() == serde_json::to_value(rhs).ok()
|
||||||
return false;
|
|
||||||
}
|
|
||||||
lhs.iter().zip(rhs.iter()).all(|(a, b)| {
|
|
||||||
a.ip == b.ip
|
|
||||||
&& a.port == b.port
|
|
||||||
&& a.client_mss == b.client_mss
|
|
||||||
&& a.announce == b.announce
|
|
||||||
&& a.announce_ip == b.announce_ip
|
|
||||||
&& a.proxy_protocol == b.proxy_protocol
|
|
||||||
&& a.reuse_allow == b.reuse_allow
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_default_link_port(cfg: &ProxyConfig) -> u16 {
|
fn resolve_default_link_port(cfg: &ProxyConfig) -> u16 {
|
||||||
@@ -608,8 +557,6 @@ fn overlay_hot_fields(old: &ProxyConfig, new: &ProxyConfig) -> ProxyConfig {
|
|||||||
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;
|
||||||
overlay_listener_synlimit_fields(&mut cfg.server.listeners, &new.server.listeners);
|
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
@@ -617,26 +564,6 @@ fn overlay_hot_fields(old: &ProxyConfig, new: &ProxyConfig) -> ProxyConfig {
|
|||||||
cfg
|
cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
fn overlay_listener_synlimit_fields(old: &mut [ListenerConfig], new: &[ListenerConfig]) {
|
|
||||||
if old.len() != new.len() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (old_listener, new_listener) in old.iter_mut().zip(new.iter()) {
|
|
||||||
if old_listener.ip != new_listener.ip || old_listener.port != new_listener.port {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
old_listener.synlimit = new_listener.synlimit;
|
|
||||||
old_listener.synlimit_seconds = new_listener.synlimit_seconds;
|
|
||||||
old_listener.synlimit_hitcount = new_listener.synlimit_hitcount;
|
|
||||||
old_listener.synlimit_burst = new_listener.synlimit_burst;
|
|
||||||
old_listener.synlimit_ios_seconds = new_listener.synlimit_ios_seconds;
|
|
||||||
old_listener.synlimit_ios_hitcount = new_listener.synlimit_ios_hitcount;
|
|
||||||
old_listener.synlimit_ios_burst = new_listener.synlimit_ios_burst;
|
|
||||||
old_listener.synlimit_hashlimit_expire_ms = new_listener.synlimit_hashlimit_expire_ms;
|
|
||||||
old_listener.synlimit_hashlimit_size = new_listener.synlimit_hashlimit_size;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Warn if any non-hot fields changed (require restart).
|
/// Warn if any non-hot fields changed (require restart).
|
||||||
fn warn_non_hot_changes(old: &ProxyConfig, new: &ProxyConfig, non_hot_changed: bool) {
|
fn warn_non_hot_changes(old: &ProxyConfig, new: &ProxyConfig, non_hot_changed: bool) {
|
||||||
let mut warned = false;
|
let mut warned = false;
|
||||||
@@ -913,13 +840,6 @@ fn log_changes(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if old_hot.listener_synlimit != new_hot.listener_synlimit {
|
|
||||||
info!(
|
|
||||||
"config reload: server.listeners SYN limiter updated ({} listeners)",
|
|
||||||
new_hot.listener_synlimit.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if old_hot.desync_all_full != new_hot.desync_all_full {
|
if old_hot.desync_all_full != new_hot.desync_all_full {
|
||||||
info!(
|
info!(
|
||||||
"config reload: desync_all_full: {} → {}",
|
"config reload: desync_all_full: {} → {}",
|
||||||
@@ -1338,6 +1258,7 @@ fn reload_config(
|
|||||||
let LoadedConfig {
|
let LoadedConfig {
|
||||||
config: new_cfg,
|
config: new_cfg,
|
||||||
source_files,
|
source_files,
|
||||||
|
source_contents: _,
|
||||||
rendered_hash,
|
rendered_hash,
|
||||||
} = loaded;
|
} = loaded;
|
||||||
let next_manifest = WatchManifest::from_source_files(&source_files);
|
let next_manifest = WatchManifest::from_source_files(&source_files);
|
||||||
@@ -1731,7 +1652,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn listener_synlimit_extended_fields_are_hot() {
|
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(),
|
||||||
@@ -1765,14 +1686,17 @@ mod tests {
|
|||||||
let applied = overlay_hot_fields(&old, &new);
|
let applied = overlay_hot_fields(&old, &new);
|
||||||
let listener = &applied.server.listeners[0];
|
let listener = &applied.server.listeners[0];
|
||||||
assert_eq!(applied.server.port, old.server.port);
|
assert_eq!(applied.server.port, old.server.port);
|
||||||
assert_eq!(listener.synlimit_seconds, 120);
|
assert_eq!(listener.synlimit_seconds, old.server.listeners[0].synlimit_seconds);
|
||||||
assert_eq!(listener.synlimit_hitcount, 96);
|
assert_eq!(
|
||||||
assert_eq!(listener.synlimit_burst, 2);
|
listener.synlimit_hitcount,
|
||||||
assert_eq!(listener.synlimit_ios_seconds, 2);
|
old.server.listeners[0].synlimit_hitcount
|
||||||
assert_eq!(listener.synlimit_ios_hitcount, 18);
|
);
|
||||||
assert_eq!(listener.synlimit_ios_burst, 36);
|
assert_eq!(listener.synlimit_burst, old.server.listeners[0].synlimit_burst);
|
||||||
assert_eq!(listener.synlimit_hashlimit_expire_ms, 90_000);
|
assert_eq!(
|
||||||
assert_eq!(listener.synlimit_hashlimit_size, 65_536);
|
listener.synlimit_hashlimit_size,
|
||||||
|
old.server.listeners[0].synlimit_hashlimit_size
|
||||||
|
);
|
||||||
|
assert!(classify_config_changes(&old, &new).restart_required);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+75
-7
@@ -1,6 +1,6 @@
|
|||||||
#![allow(deprecated)]
|
#![allow(deprecated)]
|
||||||
|
|
||||||
use std::collections::{BTreeSet, HashMap, HashSet};
|
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
||||||
use std::net::{IpAddr, SocketAddr};
|
use std::net::{IpAddr, SocketAddr};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -34,8 +34,8 @@ use self::normalize::{
|
|||||||
pub(crate) use self::runtime_auth::UserAuthSnapshot;
|
pub(crate) use self::runtime_auth::UserAuthSnapshot;
|
||||||
use self::strict_keys::handle_unknown_config_keys;
|
use self::strict_keys::handle_unknown_config_keys;
|
||||||
use self::validation::{
|
use self::validation::{
|
||||||
normalize_upstream_family_policy, validate_logging_config, validate_network_cfg,
|
normalize_upstream_family_policy, validate_listener_runtime_profiles,
|
||||||
validate_upstreams,
|
validate_logging_config, validate_network_cfg, validate_upstreams,
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAX_ME_WRITER_CMD_CHANNEL_CAPACITY: usize = 16_384;
|
const MAX_ME_WRITER_CMD_CHANNEL_CAPACITY: usize = 16_384;
|
||||||
@@ -49,12 +49,27 @@ const MAX_MAX_CLIENT_FRAME_BYTES: usize = 16 * 1024 * 1024;
|
|||||||
const MAX_API_REQUEST_BODY_LIMIT_BYTES: usize = 1024 * 1024;
|
const MAX_API_REQUEST_BODY_LIMIT_BYTES: usize = 1024 * 1024;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
/// Validated config plus the exact recursive source snapshot used to build it.
|
||||||
pub(crate) struct LoadedConfig {
|
pub(crate) struct LoadedConfig {
|
||||||
|
/// Validated and normalized effective configuration.
|
||||||
pub(crate) config: ProxyConfig,
|
pub(crate) config: ProxyConfig,
|
||||||
|
/// Canonical paths participating in the recursive include graph.
|
||||||
pub(crate) source_files: Vec<PathBuf>,
|
pub(crate) source_files: Vec<PathBuf>,
|
||||||
|
/// Raw source bytes keyed by canonical source path.
|
||||||
|
pub(crate) source_contents: BTreeMap<PathBuf, String>,
|
||||||
|
/// Legacy hash of the include-expanded rendered snapshot.
|
||||||
pub(crate) rendered_hash: u64,
|
pub(crate) rendered_hash: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Raw recursive source graph captured before typed deserialization.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct ConfigSourceGraph {
|
||||||
|
/// Raw source bytes keyed by canonical source path.
|
||||||
|
pub(crate) source_contents: BTreeMap<PathBuf, String>,
|
||||||
|
/// Include-expanded TOML used for typed deserialization.
|
||||||
|
pub(crate) rendered: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// Main runtime configuration loaded from TOML.
|
/// Main runtime configuration loaded from TOML.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct ProxyConfig {
|
pub struct ProxyConfig {
|
||||||
@@ -120,14 +135,64 @@ impl ProxyConfig {
|
|||||||
Self::load_with_metadata(path).map(|loaded| loaded.config)
|
Self::load_with_metadata(path).map(|loaded| loaded.config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Loads typed configuration together with its source and rendered metadata.
|
||||||
pub(crate) fn load_with_metadata<P: AsRef<Path>>(path: P) -> Result<LoadedConfig> {
|
pub(crate) fn load_with_metadata<P: AsRef<Path>>(path: P) -> Result<LoadedConfig> {
|
||||||
|
Self::load_with_source_overrides(path, &BTreeMap::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads a typed snapshot while replacing selected captured source documents.
|
||||||
|
pub(crate) fn load_with_source_overrides<P: AsRef<Path>>(
|
||||||
|
path: P,
|
||||||
|
source_overrides: &BTreeMap<PathBuf, String>,
|
||||||
|
) -> Result<LoadedConfig> {
|
||||||
|
let graph = Self::read_source_graph_with_overrides(path, source_overrides)?;
|
||||||
|
Self::load_source_graph(graph)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Captures the raw include graph without requiring typed config validity.
|
||||||
|
pub(crate) fn read_source_graph<P: AsRef<Path>>(path: P) -> Result<ConfigSourceGraph> {
|
||||||
|
Self::read_source_graph_with_overrides(path, &BTreeMap::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Captures a raw include graph with in-memory source replacements.
|
||||||
|
pub(crate) fn read_source_graph_with_overrides<P: AsRef<Path>>(
|
||||||
|
path: P,
|
||||||
|
source_overrides: &BTreeMap<PathBuf, String>,
|
||||||
|
) -> Result<ConfigSourceGraph> {
|
||||||
let path = path.as_ref();
|
let path = path.as_ref();
|
||||||
let content =
|
let normalized_path = normalize_config_path(path);
|
||||||
std::fs::read_to_string(path).map_err(|e| ProxyError::Config(e.to_string()))?;
|
let content = source_overrides
|
||||||
|
.get(&normalized_path)
|
||||||
|
.cloned()
|
||||||
|
.map(Ok)
|
||||||
|
.unwrap_or_else(|| std::fs::read_to_string(path))
|
||||||
|
.map_err(|e| ProxyError::Config(e.to_string()))?;
|
||||||
let base_dir = path.parent().unwrap_or(Path::new("."));
|
let base_dir = path.parent().unwrap_or(Path::new("."));
|
||||||
let mut source_files = BTreeSet::new();
|
let mut source_files = BTreeSet::new();
|
||||||
source_files.insert(normalize_config_path(path));
|
source_files.insert(normalized_path.clone());
|
||||||
let processed = preprocess_includes(&content, base_dir, 0, &mut source_files)?;
|
let mut source_contents = BTreeMap::new();
|
||||||
|
source_contents.insert(normalized_path, content.clone());
|
||||||
|
let processed = preprocess_includes(
|
||||||
|
&content,
|
||||||
|
base_dir,
|
||||||
|
0,
|
||||||
|
&mut source_files,
|
||||||
|
&mut source_contents,
|
||||||
|
source_overrides,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(ConfigSourceGraph {
|
||||||
|
source_contents,
|
||||||
|
rendered: processed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_source_graph(graph: ConfigSourceGraph) -> Result<LoadedConfig> {
|
||||||
|
let ConfigSourceGraph {
|
||||||
|
source_contents,
|
||||||
|
rendered: processed,
|
||||||
|
} = graph;
|
||||||
|
let source_files: BTreeSet<PathBuf> = source_contents.keys().cloned().collect();
|
||||||
|
|
||||||
let parsed_toml: toml::Value =
|
let parsed_toml: toml::Value =
|
||||||
toml::from_str(&processed).map_err(|e| ProxyError::Config(e.to_string()))?;
|
toml::from_str(&processed).map_err(|e| ProxyError::Config(e.to_string()))?;
|
||||||
@@ -1349,6 +1414,7 @@ impl ProxyConfig {
|
|||||||
listener.announce = Some(ip.to_string());
|
listener.announce = Some(ip.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
validate_listener_runtime_profiles(&config)?;
|
||||||
|
|
||||||
// Migration: show_link (top-level) → general.links.show.
|
// Migration: show_link (top-level) → general.links.show.
|
||||||
if !config.show_link.is_empty() && config.general.links.show.is_empty() {
|
if !config.show_link.is_empty() && config.general.links.show.is_empty() {
|
||||||
@@ -1387,6 +1453,7 @@ impl ProxyConfig {
|
|||||||
Ok(LoadedConfig {
|
Ok(LoadedConfig {
|
||||||
config,
|
config,
|
||||||
source_files: source_files.into_iter().collect(),
|
source_files: source_files.into_iter().collect(),
|
||||||
|
source_contents,
|
||||||
rendered_hash: hash_rendered_snapshot(&processed),
|
rendered_hash: hash_rendered_snapshot(&processed),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1458,6 +1525,7 @@ impl ProxyConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
crate::network::dns_overrides::validate_entries(&self.network.dns_overrides)?;
|
crate::network::dns_overrides::validate_entries(&self.network.dns_overrides)?;
|
||||||
|
validate_listener_runtime_profiles(self)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::collections::BTreeSet;
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
@@ -27,6 +27,8 @@ pub(super) fn preprocess_includes(
|
|||||||
base_dir: &Path,
|
base_dir: &Path,
|
||||||
depth: u8,
|
depth: u8,
|
||||||
source_files: &mut BTreeSet<PathBuf>,
|
source_files: &mut BTreeSet<PathBuf>,
|
||||||
|
source_contents: &mut BTreeMap<PathBuf, String>,
|
||||||
|
source_overrides: &BTreeMap<PathBuf, String>,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
if depth > 10 {
|
if depth > 10 {
|
||||||
return Err(ProxyError::Config("Include depth > 10".into()));
|
return Err(ProxyError::Config("Include depth > 10".into()));
|
||||||
@@ -39,15 +41,23 @@ pub(super) fn preprocess_includes(
|
|||||||
if let Some(rest) = rest.strip_prefix('=') {
|
if let Some(rest) = rest.strip_prefix('=') {
|
||||||
let path_str = rest.trim().trim_matches('"');
|
let path_str = rest.trim().trim_matches('"');
|
||||||
let resolved = base_dir.join(path_str);
|
let resolved = base_dir.join(path_str);
|
||||||
source_files.insert(normalize_config_path(&resolved));
|
let normalized = normalize_config_path(&resolved);
|
||||||
let included = std::fs::read_to_string(&resolved)
|
source_files.insert(normalized.clone());
|
||||||
|
let included = source_overrides
|
||||||
|
.get(&normalized)
|
||||||
|
.cloned()
|
||||||
|
.map(Ok)
|
||||||
|
.unwrap_or_else(|| std::fs::read_to_string(&resolved))
|
||||||
.map_err(|e| ProxyError::Config(e.to_string()))?;
|
.map_err(|e| ProxyError::Config(e.to_string()))?;
|
||||||
|
source_contents.insert(normalized, included.clone());
|
||||||
let included_dir = resolved.parent().unwrap_or(base_dir);
|
let included_dir = resolved.parent().unwrap_or(base_dir);
|
||||||
output.push_str(&preprocess_includes(
|
output.push_str(&preprocess_includes(
|
||||||
&included,
|
&included,
|
||||||
included_dir,
|
included_dir,
|
||||||
depth + 1,
|
depth + 1,
|
||||||
source_files,
|
source_files,
|
||||||
|
source_contents,
|
||||||
|
source_overrides,
|
||||||
)?);
|
)?);
|
||||||
output.push('\n');
|
output.push('\n');
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ use tracing::warn;
|
|||||||
|
|
||||||
use crate::error::{ProxyError, Result};
|
use crate::error::{ProxyError, Result};
|
||||||
|
|
||||||
use super::super::types::{LoggingConfig, LoggingDestination, NetworkConfig, UpstreamType};
|
use super::super::types::{
|
||||||
|
LoggingConfig, LoggingDestination, NetworkConfig, SynLimitMode, UpstreamType,
|
||||||
|
};
|
||||||
use super::ProxyConfig;
|
use super::ProxyConfig;
|
||||||
|
|
||||||
pub(super) fn validate_network_cfg(net: &mut NetworkConfig) -> Result<()> {
|
pub(super) fn validate_network_cfg(net: &mut NetworkConfig) -> Result<()> {
|
||||||
@@ -90,6 +92,77 @@ pub(super) fn validate_upstreams(config: &ProxyConfig) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) fn validate_listener_runtime_profiles(config: &ProxyConfig) -> Result<()> {
|
||||||
|
for (index, listener) in config.server.listeners.iter().enumerate() {
|
||||||
|
let supported = if cfg!(target_os = "linux") {
|
||||||
|
matches!(
|
||||||
|
listener.synlimit,
|
||||||
|
SynLimitMode::Off | SynLimitMode::Iptables | SynLimitMode::Nftables
|
||||||
|
)
|
||||||
|
} else if cfg!(target_os = "freebsd") {
|
||||||
|
matches!(listener.synlimit, SynLimitMode::Off | SynLimitMode::Pf)
|
||||||
|
} else {
|
||||||
|
listener.synlimit == SynLimitMode::Off
|
||||||
|
};
|
||||||
|
if !supported {
|
||||||
|
let backend = match listener.synlimit {
|
||||||
|
SynLimitMode::Off => "off",
|
||||||
|
SynLimitMode::Iptables => "iptables",
|
||||||
|
SynLimitMode::Nftables => "nftables",
|
||||||
|
SynLimitMode::Pf => "pf",
|
||||||
|
};
|
||||||
|
let supported = if cfg!(target_os = "linux") {
|
||||||
|
"off, iptables, nftables"
|
||||||
|
} else if cfg!(target_os = "freebsd") {
|
||||||
|
"off, pf"
|
||||||
|
} else {
|
||||||
|
"off"
|
||||||
|
};
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"server.listeners[{index}].synlimit backend {backend} is unsupported on this platform; supported values: {supported}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(bulk_mss) = config
|
||||||
|
.server
|
||||||
|
.client_mss_bulk_value()
|
||||||
|
.map_err(|error| ProxyError::Config(format!("server.client_mss_bulk {error}")))?
|
||||||
|
else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
if !cfg!(target_os = "linux") {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"server.client_mss_bulk is supported only on Linux".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut participants = 0usize;
|
||||||
|
for (index, listener) in config.server.listeners.iter().enumerate() {
|
||||||
|
let handshake_mss = listener
|
||||||
|
.effective_client_mss(&config.server)
|
||||||
|
.map_err(|error| {
|
||||||
|
ProxyError::Config(format!("server.listeners[{index}].client_mss {error}"))
|
||||||
|
})?;
|
||||||
|
let Some(handshake_mss) = handshake_mss else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
participants = participants.saturating_add(1);
|
||||||
|
if bulk_mss <= handshake_mss {
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"server.client_mss_bulk ({bulk_mss}) must be greater than the effective handshake MSS ({handshake_mss}) for server.listeners[{index}]"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if participants == 0 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"server.client_mss_bulk requires an effective client_mss on at least one listener"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn normalize_upstream_family_policy(config: &mut ProxyConfig) {
|
pub(super) fn normalize_upstream_family_policy(config: &mut ProxyConfig) {
|
||||||
for (idx, upstream) in config.upstreams.iter_mut().enumerate() {
|
for (idx, upstream) in config.upstreams.iter_mut().enumerate() {
|
||||||
if matches!(upstream.ipv4, Some(false)) && upstream.prefer == Some(4) {
|
if matches!(upstream.ipv4, Some(false)) && upstream.prefer == Some(4) {
|
||||||
|
|||||||
@@ -5,5 +5,6 @@ pub mod hot_reload;
|
|||||||
mod load;
|
mod load;
|
||||||
mod types;
|
mod types;
|
||||||
|
|
||||||
|
pub(crate) use load::{ConfigSourceGraph, LoadedConfig};
|
||||||
pub use load::ProxyConfig;
|
pub use load::ProxyConfig;
|
||||||
pub use types::*;
|
pub use types::*;
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ fn synlimit_synfix_defaults_are_loaded_for_listener() {
|
|||||||
assert_eq!(listener.synlimit_hashlimit_size, 32_768);
|
assert_eq!(listener.synlimit_hashlimit_size, 32_768);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "freebsd")]
|
||||||
#[test]
|
#[test]
|
||||||
fn synlimit_pf_mode_is_loaded_for_listener() {
|
fn synlimit_pf_mode_is_loaded_for_listener() {
|
||||||
let cfg = load_config_from_temp_toml(
|
let cfg = load_config_from_temp_toml(
|
||||||
@@ -82,6 +83,30 @@ fn synlimit_pf_mode_is_loaded_for_listener() {
|
|||||||
assert_eq!(cfg.server.listeners[0].synlimit, SynLimitMode::Pf);
|
assert_eq!(cfg.server.listeners[0].synlimit, SynLimitMode::Pf);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "freebsd"))]
|
||||||
|
#[test]
|
||||||
|
fn synlimit_pf_mode_is_rejected_off_freebsd() {
|
||||||
|
let toml = r#"
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
|
||||||
|
[[server.listeners]]
|
||||||
|
ip = "0.0.0.0"
|
||||||
|
port = 443
|
||||||
|
synlimit = "pf"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_synlimit_pf_unsupported_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||||
|
|
||||||
|
assert!(err.contains("backend pf is unsupported on this platform"));
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn synlimit_synfix_zero_values_are_rejected() {
|
fn synlimit_synfix_zero_values_are_rejected() {
|
||||||
for (field, expected) in [
|
for (field, expected) in [
|
||||||
@@ -1742,11 +1767,12 @@ fn client_mss_presets_and_listener_override_are_resolved() {
|
|||||||
let _ = std::fs::remove_file(path);
|
let _ = std::fs::remove_file(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
#[test]
|
#[test]
|
||||||
fn client_mss_custom_value_is_accepted() {
|
fn client_mss_custom_value_is_accepted() {
|
||||||
let toml = r#"
|
let toml = r#"
|
||||||
[server]
|
[server]
|
||||||
client_mss = "4096"
|
client_mss = "92"
|
||||||
client_mss_bulk = "1400"
|
client_mss_bulk = "1400"
|
||||||
|
|
||||||
[censorship]
|
[censorship]
|
||||||
@@ -1760,11 +1786,83 @@ fn client_mss_custom_value_is_accepted() {
|
|||||||
std::fs::write(&path, toml).unwrap();
|
std::fs::write(&path, toml).unwrap();
|
||||||
let cfg = ProxyConfig::load(&path).unwrap();
|
let cfg = ProxyConfig::load(&path).unwrap();
|
||||||
|
|
||||||
assert_eq!(cfg.server.client_mss_value(), Ok(Some(4096)));
|
assert_eq!(cfg.server.client_mss_value(), Ok(Some(92)));
|
||||||
assert_eq!(cfg.server.client_mss_bulk_value(), Ok(Some(1400)));
|
assert_eq!(cfg.server.client_mss_bulk_value(), Ok(Some(1400)));
|
||||||
let _ = std::fs::remove_file(path);
|
let _ = std::fs::remove_file(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[test]
|
||||||
|
fn client_mss_bulk_requires_a_larger_bulk_profile_and_handshake_participant() {
|
||||||
|
for (name, server, expected) in [
|
||||||
|
(
|
||||||
|
"without_handshake",
|
||||||
|
"client_mss_bulk = \"1400\"",
|
||||||
|
"requires an effective client_mss",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"equal",
|
||||||
|
"client_mss = \"1400\"\nclient_mss_bulk = \"1400\"",
|
||||||
|
"must be greater than the effective handshake MSS",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"inverted",
|
||||||
|
"client_mss = \"1500\"\nclient_mss_bulk = \"1400\"",
|
||||||
|
"must be greater than the effective handshake MSS",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let toml = format!(
|
||||||
|
"[server]\n{server}\n\n[censorship]\ntls_domain = \"example.com\"\n\n[access.users]\nuser = \"00000000000000000000000000000000\"\n"
|
||||||
|
);
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join(format!("telemt_client_mss_bulk_{name}_test.toml"));
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||||
|
|
||||||
|
assert!(err.contains(expected), "unexpected error: {err}");
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[test]
|
||||||
|
fn client_mss_bulk_allows_explicit_listener_opt_out() {
|
||||||
|
let toml = r#"
|
||||||
|
[server]
|
||||||
|
client_mss = "92"
|
||||||
|
client_mss_bulk = "1400"
|
||||||
|
|
||||||
|
[[server.listeners]]
|
||||||
|
ip = "0.0.0.0"
|
||||||
|
port = 443
|
||||||
|
|
||||||
|
[[server.listeners]]
|
||||||
|
ip = "::"
|
||||||
|
port = 443
|
||||||
|
client_mss = ""
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_client_mss_bulk_listener_opt_out_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let cfg = ProxyConfig::load(&path).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
cfg.server.listeners[0].effective_client_mss(&cfg.server),
|
||||||
|
Ok(Some(92))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.server.listeners[1].effective_client_mss(&cfg.server),
|
||||||
|
Ok(None)
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn client_mss_out_of_range_is_rejected() {
|
fn client_mss_out_of_range_is_rejected() {
|
||||||
for value in ["87", "4097"] {
|
for value in ["87", "4097"] {
|
||||||
|
|||||||
+5
-5
@@ -1623,11 +1623,11 @@ pub struct ServerConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub client_mss: Option<String>,
|
pub client_mss: Option<String>,
|
||||||
|
|
||||||
/// Client-facing TCP MSS for bulk traffic when initial-response fragmentation
|
/// Experimental Linux-only bulk MSS used with best-effort userspace
|
||||||
/// is enabled on Linux. The listener uses this MSS from connection setup;
|
/// chunking of the authenticated FakeTLS response. TCP offloads, loss, and
|
||||||
/// `client_mss` becomes the fragment size for the authenticated FakeTLS
|
/// retransmission may coalesce write boundaries. Empty or omitted keeps
|
||||||
/// response. Empty/omitted keeps `client_mss` connection-wide. Uses the same
|
/// `client_mss` connection-wide. Uses the same preset/integer grammar as
|
||||||
/// preset/integer grammar as `client_mss`.
|
/// `client_mss`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub client_mss_bulk: Option<String>,
|
pub client_mss_bulk: Option<String>,
|
||||||
|
|
||||||
|
|||||||
@@ -152,8 +152,7 @@ pub(crate) async fn bind_listeners(
|
|||||||
%addr,
|
%addr,
|
||||||
fragment_size,
|
fragment_size,
|
||||||
bulk_mss = client_mss,
|
bulk_mss = client_mss,
|
||||||
segment_multiplier = mss_segment_multiplier(fragment_size),
|
"Initial FakeTLS response best-effort chunking configured"
|
||||||
"Initial FakeTLS response fragmentation configured"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let listener_proxy_protocol = listener_conf
|
let listener_proxy_protocol = listener_conf
|
||||||
@@ -518,7 +517,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn client_mss_with_bulk_uses_bulk_listener_and_fragments_initial_response() {
|
fn client_mss_with_bulk_uses_bulk_listener_and_chunks_initial_response() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tcp_mss_runtime_profile(Some(92), Some(1400)),
|
tcp_mss_runtime_profile(Some(92), Some(1400)),
|
||||||
(Some(1400), Some(92))
|
(Some(1400), Some(92))
|
||||||
|
|||||||
+67
-7
@@ -33,7 +33,7 @@ use tracing::{error, info, warn};
|
|||||||
use tracing_subscriber::{EnvFilter, fmt, prelude::*, reload as tracing_reload};
|
use tracing_subscriber::{EnvFilter, fmt, prelude::*, reload as tracing_reload};
|
||||||
|
|
||||||
use crate::api;
|
use crate::api;
|
||||||
use crate::config::{LogLevel, ProxyConfig};
|
use crate::config::{LogLevel, ProxyConfig, SynLimitMode};
|
||||||
use crate::conntrack_control;
|
use crate::conntrack_control;
|
||||||
use crate::crypto::SecureRandom;
|
use crate::crypto::SecureRandom;
|
||||||
use crate::ip_tracker::UserIpTracker;
|
use crate::ip_tracker::UserIpTracker;
|
||||||
@@ -96,6 +96,7 @@ pub async fn run() -> std::result::Result<(), Box<dyn std::error::Error>> {
|
|||||||
// Shared maestro startup and main loop. `drop_after_bind` runs on Unix after listeners are bound
|
// Shared maestro startup and main loop. `drop_after_bind` runs on Unix after listeners are bound
|
||||||
// (for privilege drop); it is a no-op on other platforms.
|
// (for privilege drop); it is a no-op on other platforms.
|
||||||
async fn run_telemt_core(
|
async fn run_telemt_core(
|
||||||
|
privilege_drop_requested: bool,
|
||||||
drop_after_bind: impl FnOnce(),
|
drop_after_bind: impl FnOnce(),
|
||||||
) -> std::result::Result<(), Box<dyn std::error::Error>> {
|
) -> std::result::Result<(), Box<dyn std::error::Error>> {
|
||||||
let process_started_at = Instant::now();
|
let process_started_at = Instant::now();
|
||||||
@@ -279,6 +280,7 @@ async fn run_telemt_core(
|
|||||||
eprintln!("[telemt] Invalid config: {}", e);
|
eprintln!("[telemt] Invalid config: {}", e);
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
validate_synlimit_privilege_drop(&config, privilege_drop_requested)?;
|
||||||
|
|
||||||
if let Some(p) = data_path {
|
if let Some(p) = data_path {
|
||||||
config.general.data_path = Some(p);
|
config.general.data_path = Some(p);
|
||||||
@@ -987,13 +989,13 @@ async fn run_telemt_core(
|
|||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
synlimit_control::reconcile_synlimit_rules(&config).await;
|
synlimit_control::reconcile_synlimit_rules(&config)
|
||||||
|
.await
|
||||||
|
.map_err(std::io::Error::other)?;
|
||||||
|
|
||||||
// On Unix, caller supplies privilege drop after bind and privileged firewall setup.
|
// On Unix, caller supplies privilege drop after bind and privileged firewall setup.
|
||||||
drop_after_bind();
|
drop_after_bind();
|
||||||
|
|
||||||
let synlimit_controller = synlimit_control::spawn_synlimit_controller(runtime_watch_rx);
|
|
||||||
|
|
||||||
runtime_tasks::spawn_metrics_if_configured(&config, &startup_tracker, active_runtime.clone())
|
runtime_tasks::spawn_metrics_if_configured(&config, &startup_tracker, active_runtime.clone())
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -1012,7 +1014,6 @@ async fn run_telemt_core(
|
|||||||
process_started_at,
|
process_started_at,
|
||||||
active_runtime,
|
active_runtime,
|
||||||
quota_state_path,
|
quota_state_path,
|
||||||
synlimit_controller,
|
|
||||||
reload_supervisor,
|
reload_supervisor,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -1020,6 +1021,24 @@ async fn run_telemt_core(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_synlimit_privilege_drop(
|
||||||
|
config: &ProxyConfig,
|
||||||
|
privilege_drop_requested: bool,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
if privilege_drop_requested
|
||||||
|
&& config
|
||||||
|
.server
|
||||||
|
.listeners
|
||||||
|
.iter()
|
||||||
|
.any(|listener| listener.synlimit != SynLimitMode::Off)
|
||||||
|
{
|
||||||
|
return Err(std::io::Error::other(
|
||||||
|
"SYN limiter cannot be combined with --run-as-user or --run-as-group without a privileged firewall helper",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
async fn run_inner(
|
async fn run_inner(
|
||||||
daemon_opts: DaemonOptions,
|
daemon_opts: DaemonOptions,
|
||||||
@@ -1040,7 +1059,7 @@ async fn run_inner(
|
|||||||
let user = daemon_opts.user.clone();
|
let user = daemon_opts.user.clone();
|
||||||
let group = daemon_opts.group.clone();
|
let group = daemon_opts.group.clone();
|
||||||
|
|
||||||
run_telemt_core(|| {
|
run_telemt_core(user.is_some() || group.is_some(), || {
|
||||||
if user.is_some() || group.is_some() {
|
if user.is_some() || group.is_some() {
|
||||||
if let Err(e) = drop_privileges(user.as_deref(), group.as_deref(), _pid_file.as_ref()) {
|
if let Err(e) = drop_privileges(user.as_deref(), group.as_deref(), _pid_file.as_ref()) {
|
||||||
error!(error = %e, "Failed to drop privileges");
|
error!(error = %e, "Failed to drop privileges");
|
||||||
@@ -1053,5 +1072,46 @@ async fn run_inner(
|
|||||||
|
|
||||||
#[cfg(not(unix))]
|
#[cfg(not(unix))]
|
||||||
async fn run_inner() -> std::result::Result<(), Box<dyn std::error::Error>> {
|
async fn run_inner() -> std::result::Result<(), Box<dyn std::error::Error>> {
|
||||||
run_telemt_core(|| {}).await
|
run_telemt_core(false, || {}).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::config::ListenerConfig;
|
||||||
|
|
||||||
|
fn listener_with_synlimit(synlimit: SynLimitMode) -> ListenerConfig {
|
||||||
|
ListenerConfig {
|
||||||
|
ip: "127.0.0.1".parse().unwrap(),
|
||||||
|
port: Some(443),
|
||||||
|
client_mss: None,
|
||||||
|
synlimit,
|
||||||
|
synlimit_seconds: 60,
|
||||||
|
synlimit_hitcount: 48,
|
||||||
|
synlimit_burst: 24,
|
||||||
|
synlimit_ios_seconds: 1,
|
||||||
|
synlimit_ios_hitcount: 12,
|
||||||
|
synlimit_ios_burst: 24,
|
||||||
|
synlimit_hashlimit_expire_ms: 60_000,
|
||||||
|
synlimit_hashlimit_size: 32_768,
|
||||||
|
announce: None,
|
||||||
|
announce_ip: None,
|
||||||
|
proxy_protocol: None,
|
||||||
|
reuse_allow: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn privilege_drop_rejects_enabled_synlimit_only() {
|
||||||
|
let mut config = ProxyConfig::default();
|
||||||
|
config
|
||||||
|
.server
|
||||||
|
.listeners
|
||||||
|
.push(listener_with_synlimit(SynLimitMode::Iptables));
|
||||||
|
|
||||||
|
assert!(validate_synlimit_privilege_drop(&config, true).is_err());
|
||||||
|
assert!(validate_synlimit_privilege_drop(&config, false).is_ok());
|
||||||
|
config.server.listeners[0].synlimit = SynLimitMode::Off;
|
||||||
|
assert!(validate_synlimit_privilege_drop(&config, true).is_ok());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+100
-53
@@ -4,7 +4,8 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
|||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::sync::{Mutex, mpsc};
|
use parking_lot::Mutex;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
use crate::config::ProxyConfig;
|
use crate::config::ProxyConfig;
|
||||||
|
|
||||||
@@ -188,6 +189,14 @@ pub(crate) struct ReloadCommandReceiver {
|
|||||||
command_rx: mpsc::Receiver<ReloadCommand>,
|
command_rx: mpsc::Receiver<ReloadCommand>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Capacity and status reservation held while a config mutation is committed.
|
||||||
|
pub(crate) struct ReloadReservation {
|
||||||
|
permit: Option<mpsc::OwnedPermit<ReloadCommand>>,
|
||||||
|
status: ReloadStatus,
|
||||||
|
request: ReloadRequest,
|
||||||
|
status_store: Arc<ReloadStatusStore>,
|
||||||
|
}
|
||||||
|
|
||||||
struct ReloadStatusState {
|
struct ReloadStatusState {
|
||||||
next_reload_id: u64,
|
next_reload_id: u64,
|
||||||
active_reload_id: Option<u64>,
|
active_reload_id: Option<u64>,
|
||||||
@@ -232,95 +241,121 @@ impl ReloadControl {
|
|||||||
config_revision: String,
|
config_revision: String,
|
||||||
request: ReloadRequest,
|
request: ReloadRequest,
|
||||||
) -> Result<ReloadAccepted, ReloadSubmitError> {
|
) -> Result<ReloadAccepted, ReloadSubmitError> {
|
||||||
|
let reservation = self.reserve(config_revision, request).await?;
|
||||||
|
Ok(reservation.enqueue(config))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reserves coordinator capacity before an external config mutation commits.
|
||||||
|
pub(crate) async fn reserve(
|
||||||
|
&self,
|
||||||
|
config_revision: String,
|
||||||
|
request: ReloadRequest,
|
||||||
|
) -> Result<ReloadReservation, ReloadSubmitError> {
|
||||||
|
let permit = self
|
||||||
|
.command_tx
|
||||||
|
.clone()
|
||||||
|
.try_reserve_owned()
|
||||||
|
.map_err(|_| ReloadSubmitError::MaestroUnavailable)?;
|
||||||
let target_generation = self
|
let target_generation = self
|
||||||
.active_generation
|
.active_generation
|
||||||
.load(Ordering::Acquire)
|
.load(Ordering::Acquire)
|
||||||
.saturating_add(1);
|
.saturating_add(1);
|
||||||
let status = self
|
let status = self
|
||||||
.status_store
|
.status_store
|
||||||
.reserve(target_generation, config_revision, request.clone())
|
.reserve(target_generation, config_revision, request.clone())?;
|
||||||
.await?;
|
Ok(ReloadReservation {
|
||||||
let command = ReloadCommand {
|
permit: Some(permit),
|
||||||
reload_id: status.reload_id,
|
status,
|
||||||
target_generation,
|
|
||||||
config,
|
|
||||||
config_revision: status.config_revision.clone(),
|
|
||||||
request,
|
request,
|
||||||
};
|
status_store: self.status_store.clone(),
|
||||||
if self.command_tx.try_send(command).is_err() {
|
|
||||||
self.status_store
|
|
||||||
.finish(
|
|
||||||
status.reload_id,
|
|
||||||
ReloadPhase::Failed,
|
|
||||||
Some("maestro command channel is closed".to_string()),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
return Err(ReloadSubmitError::MaestroUnavailable);
|
|
||||||
}
|
|
||||||
Ok(ReloadAccepted {
|
|
||||||
reload_id: status.reload_id,
|
|
||||||
target_generation,
|
|
||||||
config_revision: status.config_revision,
|
|
||||||
state: ReloadPhase::Accepted,
|
|
||||||
mode: status.mode,
|
|
||||||
failure_policy: status.failure_policy,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a retained reload status by identifier.
|
/// Returns a retained reload status by identifier.
|
||||||
pub(crate) async fn status(&self, reload_id: u64) -> Option<ReloadStatus> {
|
pub(crate) async fn status(&self, reload_id: u64) -> Option<ReloadStatus> {
|
||||||
self.status_store.get(reload_id).await
|
self.status_store.get(reload_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the identifier of the currently active reload.
|
/// Returns the identifier of the currently active reload.
|
||||||
pub(crate) async fn in_progress(&self) -> Option<u64> {
|
pub(crate) async fn in_progress(&self) -> Option<u64> {
|
||||||
self.status_store.state.lock().await.active_reload_id
|
self.status_store.state.lock().active_reload_id
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rejects new commands while preserving an already accepted operation.
|
/// Rejects new commands while preserving an already accepted operation.
|
||||||
pub(crate) async fn begin_shutdown(&self) {
|
pub(crate) async fn begin_shutdown(&self) {
|
||||||
self.status_store.state.lock().await.accepting_commands = false;
|
self.status_store.state.lock().accepting_commands = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Records a non-terminal lifecycle phase.
|
/// Records a non-terminal lifecycle phase.
|
||||||
pub(crate) async fn mark_phase(&self, reload_id: u64, phase: ReloadPhase) {
|
pub(crate) async fn mark_phase(&self, reload_id: u64, phase: ReloadPhase) {
|
||||||
self.status_store.mark_phase(reload_id, phase).await;
|
self.status_store.mark_phase(reload_id, phase);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Records process-owned fields deferred until the next process restart.
|
/// Records process-owned fields deferred until the next process restart.
|
||||||
pub(crate) async fn set_deferred_fields(&self, reload_id: u64, fields: Vec<String>) {
|
pub(crate) async fn set_deferred_fields(&self, reload_id: u64, fields: Vec<String>) {
|
||||||
self.status_store
|
self.status_store
|
||||||
.update(reload_id, |status| status.deferred_fields = fields)
|
.update(reload_id, |status| status.deferred_fields = fields);
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Commits the active generation and completes the matching reload.
|
/// Commits the active generation and completes the matching reload.
|
||||||
pub(crate) async fn succeed(&self, reload_id: u64, generation: u64) {
|
pub(crate) async fn succeed(&self, reload_id: u64, generation: u64) {
|
||||||
self.status_store
|
self.status_store
|
||||||
.finish_success(reload_id, generation, &self.active_generation)
|
.finish_success(reload_id, generation, &self.active_generation);
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Marks the matching reload as failed.
|
/// Marks the matching reload as failed.
|
||||||
pub(crate) async fn fail(&self, reload_id: u64, error: impl Into<String>) {
|
pub(crate) async fn fail(&self, reload_id: u64, error: impl Into<String>) {
|
||||||
self.status_store
|
self.status_store
|
||||||
.finish(reload_id, ReloadPhase::Failed, Some(error.into()))
|
.finish(reload_id, ReloadPhase::Failed, Some(error.into()));
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Marks the matching reload as rolled back.
|
/// Marks the matching reload as rolled back.
|
||||||
pub(crate) async fn rolled_back(&self, reload_id: u64, error: impl Into<String>) {
|
pub(crate) async fn rolled_back(&self, reload_id: u64, error: impl Into<String>) {
|
||||||
self.status_store
|
self.status_store
|
||||||
.finish(reload_id, ReloadPhase::RolledBack, Some(error.into()))
|
.finish(reload_id, ReloadPhase::RolledBack, Some(error.into()));
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Appends a non-fatal warning to the matching reload status.
|
/// Appends a non-fatal warning to the matching reload status.
|
||||||
pub(crate) async fn add_warning(&self, reload_id: u64, warning: impl Into<String>) {
|
pub(crate) async fn add_warning(&self, reload_id: u64, warning: impl Into<String>) {
|
||||||
let warning = warning.into();
|
let warning = warning.into();
|
||||||
self.status_store
|
self.status_store
|
||||||
.update(reload_id, |status| status.warnings.push(warning))
|
.update(reload_id, |status| status.warnings.push(warning));
|
||||||
.await;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReloadReservation {
|
||||||
|
/// Enqueues the already reserved command without another fallible step.
|
||||||
|
pub(crate) fn enqueue(mut self, config: Arc<ProxyConfig>) -> ReloadAccepted {
|
||||||
|
let target_generation = self.status.target_generation;
|
||||||
|
let command = ReloadCommand {
|
||||||
|
reload_id: self.status.reload_id,
|
||||||
|
target_generation,
|
||||||
|
config,
|
||||||
|
config_revision: self.status.config_revision.clone(),
|
||||||
|
request: self.request.clone(),
|
||||||
|
};
|
||||||
|
// This consuming transition is the only path that removes the permit.
|
||||||
|
let permit = self
|
||||||
|
.permit
|
||||||
|
.take()
|
||||||
|
.expect("reload reservation always owns one channel permit");
|
||||||
|
permit.send(command);
|
||||||
|
ReloadAccepted {
|
||||||
|
reload_id: self.status.reload_id,
|
||||||
|
target_generation,
|
||||||
|
config_revision: self.status.config_revision.clone(),
|
||||||
|
state: ReloadPhase::Accepted,
|
||||||
|
mode: self.status.mode,
|
||||||
|
failure_policy: self.status.failure_policy,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for ReloadReservation {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if self.permit.is_some() {
|
||||||
|
self.status_store.cancel_reservation(self.status.reload_id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,13 +367,13 @@ impl ReloadCommandReceiver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ReloadStatusStore {
|
impl ReloadStatusStore {
|
||||||
async fn reserve(
|
fn reserve(
|
||||||
&self,
|
&self,
|
||||||
target_generation: u64,
|
target_generation: u64,
|
||||||
config_revision: String,
|
config_revision: String,
|
||||||
request: ReloadRequest,
|
request: ReloadRequest,
|
||||||
) -> Result<ReloadStatus, ReloadSubmitError> {
|
) -> Result<ReloadStatus, ReloadSubmitError> {
|
||||||
let mut state = self.state.lock().await;
|
let mut state = self.state.lock();
|
||||||
if !state.accepting_commands {
|
if !state.accepting_commands {
|
||||||
return Err(ReloadSubmitError::MaestroUnavailable);
|
return Err(ReloadSubmitError::MaestroUnavailable);
|
||||||
}
|
}
|
||||||
@@ -369,29 +404,27 @@ impl ReloadStatusStore {
|
|||||||
Ok(status)
|
Ok(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get(&self, reload_id: u64) -> Option<ReloadStatus> {
|
fn get(&self, reload_id: u64) -> Option<ReloadStatus> {
|
||||||
self.state
|
self.state
|
||||||
.lock()
|
.lock()
|
||||||
.await
|
|
||||||
.statuses
|
.statuses
|
||||||
.iter()
|
.iter()
|
||||||
.find(|status| status.reload_id == reload_id)
|
.find(|status| status.reload_id == reload_id)
|
||||||
.cloned()
|
.cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn mark_phase(&self, reload_id: u64, phase: ReloadPhase) {
|
fn mark_phase(&self, reload_id: u64, phase: ReloadPhase) {
|
||||||
self.update(reload_id, |status| {
|
self.update(reload_id, |status| {
|
||||||
status.state = phase;
|
status.state = phase;
|
||||||
if status.started_at_epoch_secs.is_none() && phase != ReloadPhase::Accepted {
|
if status.started_at_epoch_secs.is_none() && phase != ReloadPhase::Accepted {
|
||||||
status.started_at_epoch_secs = Some(now_epoch_secs());
|
status.started_at_epoch_secs = Some(now_epoch_secs());
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn finish(&self, reload_id: u64, phase: ReloadPhase, error: Option<String>) {
|
fn finish(&self, reload_id: u64, phase: ReloadPhase, error: Option<String>) {
|
||||||
debug_assert!(phase.is_terminal());
|
debug_assert!(phase.is_terminal());
|
||||||
let mut state = self.state.lock().await;
|
let mut state = self.state.lock();
|
||||||
if let Some(status) = state
|
if let Some(status) = state
|
||||||
.statuses
|
.statuses
|
||||||
.iter_mut()
|
.iter_mut()
|
||||||
@@ -406,8 +439,8 @@ impl ReloadStatusStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn finish_success(&self, reload_id: u64, generation: u64, active_generation: &AtomicU64) {
|
fn finish_success(&self, reload_id: u64, generation: u64, active_generation: &AtomicU64) {
|
||||||
let mut state = self.state.lock().await;
|
let mut state = self.state.lock();
|
||||||
if state.active_reload_id != Some(reload_id) {
|
if state.active_reload_id != Some(reload_id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -425,8 +458,8 @@ impl ReloadStatusStore {
|
|||||||
state.active_reload_id = None;
|
state.active_reload_id = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update(&self, reload_id: u64, update: impl FnOnce(&mut ReloadStatus)) {
|
fn update(&self, reload_id: u64, update: impl FnOnce(&mut ReloadStatus)) {
|
||||||
let mut state = self.state.lock().await;
|
let mut state = self.state.lock();
|
||||||
if let Some(status) = state
|
if let Some(status) = state
|
||||||
.statuses
|
.statuses
|
||||||
.iter_mut()
|
.iter_mut()
|
||||||
@@ -435,6 +468,20 @@ impl ReloadStatusStore {
|
|||||||
update(status);
|
update(status);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cancel_reservation(&self, reload_id: u64) {
|
||||||
|
let mut state = self.state.lock();
|
||||||
|
if state.active_reload_id == Some(reload_id) {
|
||||||
|
state.active_reload_id = None;
|
||||||
|
}
|
||||||
|
if let Some(index) = state
|
||||||
|
.statuses
|
||||||
|
.iter()
|
||||||
|
.position(|status| status.reload_id == reload_id)
|
||||||
|
{
|
||||||
|
state.statuses.remove(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn now_epoch_secs() -> u64 {
|
fn now_epoch_secs() -> u64 {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use super::reload::{
|
|||||||
ReloadCommand, ReloadCommandReceiver, ReloadControl, ReloadFailurePolicy, ReloadMode,
|
ReloadCommand, ReloadCommandReceiver, ReloadControl, ReloadFailurePolicy, ReloadMode,
|
||||||
ReloadPhase,
|
ReloadPhase,
|
||||||
};
|
};
|
||||||
use super::runtime_build::{PreparedRuntime, deferred_process_fields, prepare_runtime};
|
use super::runtime_build::{PreparedRuntime, prepare_runtime, resolve_reload_config};
|
||||||
use super::runtime_tasks::RuntimeLogFilter;
|
use super::runtime_tasks::RuntimeLogFilter;
|
||||||
|
|
||||||
pub(crate) struct ReloadSupervisor {
|
pub(crate) struct ReloadSupervisor {
|
||||||
@@ -147,14 +147,17 @@ impl ReloadSupervisor {
|
|||||||
.mark_phase(command.reload_id, ReloadPhase::Preparing)
|
.mark_phase(command.reload_id, ReloadPhase::Preparing)
|
||||||
.await;
|
.await;
|
||||||
let old_runtime = self.active_runtime.load_full();
|
let old_runtime = self.active_runtime.load_full();
|
||||||
let deferred = deferred_process_fields(&old_runtime.config(), &command.config);
|
let resolved = resolve_reload_config(&old_runtime.config(), &command.config);
|
||||||
self.control
|
self.control
|
||||||
.set_deferred_fields(command.reload_id, deferred)
|
.set_deferred_fields(
|
||||||
|
command.reload_id,
|
||||||
|
resolved.deferred_process_fields.clone(),
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let prepared = match prepare_runtime(
|
let prepared = match prepare_runtime(
|
||||||
command.target_generation,
|
command.target_generation,
|
||||||
command.config.as_ref().clone(),
|
resolved.effective,
|
||||||
&self.config_path,
|
&self.config_path,
|
||||||
self.quota_store.clone(),
|
self.quota_store.clone(),
|
||||||
self.runtime_log_filter.clone(),
|
self.runtime_log_filter.clone(),
|
||||||
|
|||||||
@@ -220,12 +220,51 @@ async fn closed_command_channel_marks_reload_failed_and_releases_slot() {
|
|||||||
|
|
||||||
assert_eq!(result, Err(ReloadSubmitError::MaestroUnavailable));
|
assert_eq!(result, Err(ReloadSubmitError::MaestroUnavailable));
|
||||||
assert_eq!(control.in_progress().await, None);
|
assert_eq!(control.in_progress().await, None);
|
||||||
let status = control.status(1).await.unwrap();
|
assert!(control.status(1).await.is_none());
|
||||||
assert_eq!(status.state, ReloadPhase::Failed);
|
}
|
||||||
assert_eq!(
|
|
||||||
status.error.as_deref(),
|
#[tokio::test]
|
||||||
Some("maestro command channel is closed")
|
async fn dropped_reservation_releases_status_and_channel_capacity() {
|
||||||
);
|
let (control, mut receiver) = ReloadControl::channel(1);
|
||||||
|
let reservation = control
|
||||||
|
.reserve("rev-reserved".to_string(), ReloadRequest::default())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let reload_id = reservation.status.reload_id;
|
||||||
|
|
||||||
|
drop(reservation);
|
||||||
|
|
||||||
|
assert_eq!(control.in_progress().await, None);
|
||||||
|
assert!(control.status(reload_id).await.is_none());
|
||||||
|
let accepted = control
|
||||||
|
.submit(
|
||||||
|
Arc::new(ProxyConfig::default()),
|
||||||
|
"rev-next".to_string(),
|
||||||
|
ReloadRequest::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(receiver.recv().await.is_some());
|
||||||
|
assert!(accepted.reload_id > reload_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reservation_preserves_the_complete_reload_request() {
|
||||||
|
let (control, mut receiver) = ReloadControl::channel(1);
|
||||||
|
let request = ReloadRequest {
|
||||||
|
mode: ReloadMode::Drain,
|
||||||
|
timeout_secs: Some(30),
|
||||||
|
failure_policy: ReloadFailurePolicy::Rollback,
|
||||||
|
};
|
||||||
|
let reservation = control
|
||||||
|
.reserve("rev-drain".to_string(), request.clone())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
reservation.enqueue(Arc::new(ProxyConfig::default()));
|
||||||
|
let command = receiver.recv().await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(command.request, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
+121
-49
@@ -5,7 +5,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|||||||
|
|
||||||
use tokio::sync::{RwLock, Semaphore, watch};
|
use tokio::sync::{RwLock, Semaphore, watch};
|
||||||
|
|
||||||
use crate::config::ProxyConfig;
|
use crate::config::{ProxyConfig, ServerConfig};
|
||||||
use crate::crypto::SecureRandom;
|
use crate::crypto::SecureRandom;
|
||||||
use crate::ip_tracker::UserIpTracker;
|
use crate::ip_tracker::UserIpTracker;
|
||||||
use crate::network::probe::{decide_network_capabilities, run_probe};
|
use crate::network::probe::{decide_network_capabilities, run_probe};
|
||||||
@@ -309,75 +309,147 @@ fn strict_middle_proxy_unavailable(
|
|||||||
use_middle_proxy && !direct_first_startup && !pool_available
|
use_middle_proxy && !direct_first_startup && !pool_available
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn deferred_process_fields(old: &ProxyConfig, new: &ProxyConfig) -> Vec<String> {
|
pub(crate) struct ResolvedReloadConfig {
|
||||||
|
/// Runtime-safe candidate with process-owned values retained from active state.
|
||||||
|
pub(crate) effective: ProxyConfig,
|
||||||
|
/// Stable public labels for desired fields deferred until process restart.
|
||||||
|
pub(crate) deferred_process_fields: Vec<String>,
|
||||||
|
/// Whether activating the effective candidate changes runtime-owned state.
|
||||||
|
pub(crate) runtime_changed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves desired configuration into effective runtime and deferred process state.
|
||||||
|
pub(crate) fn resolve_reload_config(
|
||||||
|
old: &ProxyConfig,
|
||||||
|
desired: &ProxyConfig,
|
||||||
|
) -> ResolvedReloadConfig {
|
||||||
|
let mut effective = desired.clone();
|
||||||
let mut fields = Vec::new();
|
let mut fields = Vec::new();
|
||||||
if old.server.port != new.server.port
|
let listener_identity_matches = listeners_have_same_bind_identity(&old.server, &desired.server);
|
||||||
|| old.server.proxy_protocol != new.server.proxy_protocol
|
let listener_process_fields_changed = !listener_identity_matches
|
||||||
|| old.server.listen_backlog != new.server.listen_backlog
|
|| !listener_process_fields_equal(&old.server, &desired.server);
|
||||||
|| serde_json::to_value(&old.server.listeners).ok()
|
if old.server.port != desired.server.port
|
||||||
!= serde_json::to_value(&new.server.listeners).ok()
|
|| old.server.listen_addr_ipv4 != desired.server.listen_addr_ipv4
|
||||||
|
|| old.server.listen_addr_ipv6 != desired.server.listen_addr_ipv6
|
||||||
|
|| old.server.listen_tcp != desired.server.listen_tcp
|
||||||
|
|| old.server.client_mss != desired.server.client_mss
|
||||||
|
|| old.server.client_mss_bulk != desired.server.client_mss_bulk
|
||||||
|
|| old.server.proxy_protocol != desired.server.proxy_protocol
|
||||||
|
|| old.server.listen_backlog != desired.server.listen_backlog
|
||||||
|
|| listener_process_fields_changed
|
||||||
{
|
{
|
||||||
fields.push("server.listeners".to_string());
|
fields.push("server.listeners".to_string());
|
||||||
|
effective.server.port = old.server.port;
|
||||||
|
effective.server.listen_addr_ipv4 = old.server.listen_addr_ipv4.clone();
|
||||||
|
effective.server.listen_addr_ipv6 = old.server.listen_addr_ipv6.clone();
|
||||||
|
effective.server.listen_tcp = old.server.listen_tcp;
|
||||||
|
effective.server.client_mss = old.server.client_mss.clone();
|
||||||
|
effective.server.client_mss_bulk = old.server.client_mss_bulk.clone();
|
||||||
|
effective.server.proxy_protocol = old.server.proxy_protocol;
|
||||||
|
effective.server.listen_backlog = old.server.listen_backlog;
|
||||||
|
effective.server.listeners = old.server.listeners.clone();
|
||||||
|
if listener_identity_matches {
|
||||||
|
for (effective_listener, desired_listener) in effective
|
||||||
|
.server
|
||||||
|
.listeners
|
||||||
|
.iter_mut()
|
||||||
|
.zip(&desired.server.listeners)
|
||||||
|
{
|
||||||
|
effective_listener.announce = desired_listener.announce.clone();
|
||||||
|
effective_listener.announce_ip = desired_listener.announce_ip;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if old.server.listen_unix_sock != new.server.listen_unix_sock
|
if old.server.listen_unix_sock != desired.server.listen_unix_sock
|
||||||
|| old.server.listen_unix_sock_perm != new.server.listen_unix_sock_perm
|
|| old.server.listen_unix_sock_perm != desired.server.listen_unix_sock_perm
|
||||||
{
|
{
|
||||||
fields.push("server.listen_unix_sock".to_string());
|
fields.push("server.listen_unix_sock".to_string());
|
||||||
|
effective.server.listen_unix_sock = old.server.listen_unix_sock.clone();
|
||||||
|
effective.server.listen_unix_sock_perm = old.server.listen_unix_sock_perm.clone();
|
||||||
}
|
}
|
||||||
if old.server.api.listen != new.server.api.listen
|
if old.server.api.listen != desired.server.api.listen
|
||||||
|| old.server.api.enabled != new.server.api.enabled
|
|| old.server.api.enabled != desired.server.api.enabled
|
||||||
{
|
{
|
||||||
fields.push("server.api.listen".to_string());
|
fields.push("server.api.listen".to_string());
|
||||||
|
effective.server.api.listen = old.server.api.listen.clone();
|
||||||
|
effective.server.api.enabled = old.server.api.enabled;
|
||||||
}
|
}
|
||||||
if old.server.metrics_listen != new.server.metrics_listen
|
if old.server.api.runtime_edge_events_capacity
|
||||||
|| old.server.metrics_port != new.server.metrics_port
|
!= desired.server.api.runtime_edge_events_capacity
|
||||||
|
{
|
||||||
|
fields.push("server.api.runtime_edge_events_capacity".to_string());
|
||||||
|
effective.server.api.runtime_edge_events_capacity =
|
||||||
|
old.server.api.runtime_edge_events_capacity;
|
||||||
|
}
|
||||||
|
if old.server.metrics_listen != desired.server.metrics_listen
|
||||||
|
|| old.server.metrics_port != desired.server.metrics_port
|
||||||
{
|
{
|
||||||
fields.push("server.metrics_listen".to_string());
|
fields.push("server.metrics_listen".to_string());
|
||||||
|
effective.server.metrics_listen = old.server.metrics_listen.clone();
|
||||||
|
effective.server.metrics_port = old.server.metrics_port;
|
||||||
}
|
}
|
||||||
if old.general.quota_state_path != new.general.quota_state_path {
|
if old.general.quota_state_path != desired.general.quota_state_path {
|
||||||
fields.push("general.quota_state_path".to_string());
|
fields.push("general.quota_state_path".to_string());
|
||||||
|
effective.general.quota_state_path = old.general.quota_state_path.clone();
|
||||||
}
|
}
|
||||||
if old.general.disable_colors != new.general.disable_colors {
|
if old.general.disable_colors != desired.general.disable_colors {
|
||||||
fields.push("general.disable_colors".to_string());
|
fields.push("general.disable_colors".to_string());
|
||||||
|
effective.general.disable_colors = old.general.disable_colors;
|
||||||
}
|
}
|
||||||
if old.general.data_path != new.general.data_path {
|
if old.general.data_path != desired.general.data_path {
|
||||||
fields.push("general.data_path".to_string());
|
fields.push("general.data_path".to_string());
|
||||||
|
effective.general.data_path = old.general.data_path.clone();
|
||||||
}
|
}
|
||||||
if serde_json::to_value(&old.logging).ok() != serde_json::to_value(&new.logging).ok() {
|
if serde_json::to_value(&old.logging).ok()
|
||||||
|
!= serde_json::to_value(&desired.logging).ok()
|
||||||
|
{
|
||||||
fields.push("logging".to_string());
|
fields.push("logging".to_string());
|
||||||
|
effective.logging = old.logging.clone();
|
||||||
}
|
}
|
||||||
fields
|
let runtime_changed = !configs_equal(old, &effective);
|
||||||
|
ResolvedReloadConfig {
|
||||||
|
effective,
|
||||||
|
deferred_process_fields: fields,
|
||||||
|
runtime_changed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn listeners_have_same_bind_identity(old: &ServerConfig, desired: &ServerConfig) -> bool {
|
||||||
|
old.listeners.len() == desired.listeners.len()
|
||||||
|
&& old
|
||||||
|
.listeners
|
||||||
|
.iter()
|
||||||
|
.zip(&desired.listeners)
|
||||||
|
.all(|(old_listener, desired_listener)| {
|
||||||
|
old_listener.ip == desired_listener.ip
|
||||||
|
&& old_listener.port.unwrap_or(old.port)
|
||||||
|
== desired_listener.port.unwrap_or(desired.port)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn listener_process_fields_equal(old: &ServerConfig, desired: &ServerConfig) -> bool {
|
||||||
|
let mut old_listeners = old.listeners.clone();
|
||||||
|
let mut desired_listeners = desired.listeners.clone();
|
||||||
|
for listener in &mut old_listeners {
|
||||||
|
listener.announce = None;
|
||||||
|
listener.announce_ip = None;
|
||||||
|
}
|
||||||
|
for listener in &mut desired_listeners {
|
||||||
|
listener.announce = None;
|
||||||
|
listener.announce_ip = None;
|
||||||
|
}
|
||||||
|
serde_json::to_value(old_listeners).ok() == serde_json::to_value(desired_listeners).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns process-owned fields that cannot change in the current generation.
|
||||||
|
pub(crate) fn deferred_process_fields(old: &ProxyConfig, new: &ProxyConfig) -> Vec<String> {
|
||||||
|
resolve_reload_config(old, new).deferred_process_fields
|
||||||
|
}
|
||||||
|
|
||||||
|
fn configs_equal(old: &ProxyConfig, new: &ProxyConfig) -> bool {
|
||||||
|
serde_json::to_value(old).ok() == serde_json::to_value(new).ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "runtime_build_tests.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn process_socket_and_logging_changes_are_deferred() {
|
|
||||||
let old = ProxyConfig::default();
|
|
||||||
let mut new = old.clone();
|
|
||||||
new.server.listen_backlog = new.server.listen_backlog.saturating_add(1);
|
|
||||||
new.general.disable_colors = !new.general.disable_colors;
|
|
||||||
|
|
||||||
let fields = deferred_process_fields(&old, &new);
|
|
||||||
assert!(fields.contains(&"server.listeners".to_string()));
|
|
||||||
assert!(fields.contains(&"general.disable_colors".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn runtime_only_change_does_not_require_process_rebind() {
|
|
||||||
let old = ProxyConfig::default();
|
|
||||||
let mut new = old.clone();
|
|
||||||
new.censorship.tls_domain = "reload.example".to_string();
|
|
||||||
assert!(deferred_process_fields(&old, &new).is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn strict_middle_proxy_requires_a_prepared_pool() {
|
|
||||||
assert!(strict_middle_proxy_unavailable(true, false, false));
|
|
||||||
assert!(!strict_middle_proxy_unavailable(true, false, true));
|
|
||||||
assert!(!strict_middle_proxy_unavailable(true, true, false));
|
|
||||||
assert!(!strict_middle_proxy_unavailable(false, false, false));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn process_socket_and_logging_changes_are_deferred() {
|
||||||
|
let old = ProxyConfig::default();
|
||||||
|
let mut new = old.clone();
|
||||||
|
new.server.listen_backlog = new.server.listen_backlog.saturating_add(1);
|
||||||
|
new.general.disable_colors = !new.general.disable_colors;
|
||||||
|
|
||||||
|
let fields = deferred_process_fields(&old, &new);
|
||||||
|
assert!(fields.contains(&"server.listeners".to_string()));
|
||||||
|
assert!(fields.contains(&"general.disable_colors".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn global_mss_profiles_are_deferred_with_the_listener_socket_group() {
|
||||||
|
let old = ProxyConfig::default();
|
||||||
|
let mut desired = old.clone();
|
||||||
|
desired.server.client_mss = Some("92".to_string());
|
||||||
|
desired.server.client_mss_bulk = Some("1400".to_string());
|
||||||
|
|
||||||
|
let resolved = resolve_reload_config(&old, &desired);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
resolved.deferred_process_fields,
|
||||||
|
vec!["server.listeners".to_string()]
|
||||||
|
);
|
||||||
|
assert_eq!(resolved.effective.server.client_mss, old.server.client_mss);
|
||||||
|
assert_eq!(
|
||||||
|
resolved.effective.server.client_mss_bulk,
|
||||||
|
old.server.client_mss_bulk
|
||||||
|
);
|
||||||
|
assert!(!resolved.runtime_changed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mixed_reload_retains_process_state_and_applies_runtime_state() {
|
||||||
|
let old = ProxyConfig::default();
|
||||||
|
let mut desired = old.clone();
|
||||||
|
desired.server.client_mss = Some("92".to_string());
|
||||||
|
desired.censorship.tls_domain = "reload.example".to_string();
|
||||||
|
|
||||||
|
let resolved = resolve_reload_config(&old, &desired);
|
||||||
|
|
||||||
|
assert_eq!(resolved.effective.server.client_mss, old.server.client_mss);
|
||||||
|
assert_eq!(
|
||||||
|
resolved.effective.censorship.tls_domain,
|
||||||
|
desired.censorship.tls_domain
|
||||||
|
);
|
||||||
|
assert!(resolved.runtime_changed);
|
||||||
|
assert_eq!(
|
||||||
|
resolved.deferred_process_fields,
|
||||||
|
vec!["server.listeners".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
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(),
|
||||||
|
port: Some(443),
|
||||||
|
client_mss: None,
|
||||||
|
synlimit: crate::config::SynLimitMode::Off,
|
||||||
|
synlimit_seconds: 60,
|
||||||
|
synlimit_hitcount: 48,
|
||||||
|
synlimit_burst: 24,
|
||||||
|
synlimit_ios_seconds: 1,
|
||||||
|
synlimit_ios_hitcount: 12,
|
||||||
|
synlimit_ios_burst: 24,
|
||||||
|
synlimit_hashlimit_expire_ms: 60_000,
|
||||||
|
synlimit_hashlimit_size: 32_768,
|
||||||
|
announce: None,
|
||||||
|
announce_ip: None,
|
||||||
|
proxy_protocol: None,
|
||||||
|
reuse_allow: false,
|
||||||
|
});
|
||||||
|
let mut desired = old.clone();
|
||||||
|
desired.server.listeners[0].announce = Some("proxy.example".to_string());
|
||||||
|
|
||||||
|
let resolved = resolve_reload_config(&old, &desired);
|
||||||
|
|
||||||
|
assert!(resolved.deferred_process_fields.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
resolved.effective.server.listeners[0].announce.as_deref(),
|
||||||
|
Some("proxy.example")
|
||||||
|
);
|
||||||
|
assert!(resolved.runtime_changed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn process_field_labels_are_stable_ordered_and_unique() {
|
||||||
|
let old = ProxyConfig::default();
|
||||||
|
let mut desired = old.clone();
|
||||||
|
desired.server.listen_backlog = desired.server.listen_backlog.saturating_add(1);
|
||||||
|
desired.server.api.enabled = !desired.server.api.enabled;
|
||||||
|
desired.server.api.runtime_edge_events_capacity = desired
|
||||||
|
.server
|
||||||
|
.api
|
||||||
|
.runtime_edge_events_capacity
|
||||||
|
.saturating_add(1);
|
||||||
|
desired.general.disable_colors = !desired.general.disable_colors;
|
||||||
|
|
||||||
|
let resolved = resolve_reload_config(&old, &desired);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
resolved.deferred_process_fields,
|
||||||
|
vec![
|
||||||
|
"server.listeners".to_string(),
|
||||||
|
"server.api.listen".to_string(),
|
||||||
|
"server.api.runtime_edge_events_capacity".to_string(),
|
||||||
|
"general.disable_colors".to_string(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_only_change_does_not_require_process_rebind() {
|
||||||
|
let old = ProxyConfig::default();
|
||||||
|
let mut new = old.clone();
|
||||||
|
new.censorship.tls_domain = "reload.example".to_string();
|
||||||
|
assert!(deferred_process_fields(&old, &new).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strict_middle_proxy_requires_a_prepared_pool() {
|
||||||
|
assert!(strict_middle_proxy_unavailable(true, false, false));
|
||||||
|
assert!(!strict_middle_proxy_unavailable(true, false, true));
|
||||||
|
assert!(!strict_middle_proxy_unavailable(true, true, false));
|
||||||
|
assert!(!strict_middle_proxy_unavailable(false, false, false));
|
||||||
|
}
|
||||||
@@ -51,7 +51,6 @@ pub(crate) async fn wait_for_shutdown(
|
|||||||
process_started_at: Instant,
|
process_started_at: Instant,
|
||||||
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
|
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
|
||||||
quota_state_path: PathBuf,
|
quota_state_path: PathBuf,
|
||||||
synlimit_controller: synlimit_control::SynlimitController,
|
|
||||||
reload_supervisor: ReloadSupervisorHandle,
|
reload_supervisor: ReloadSupervisorHandle,
|
||||||
) {
|
) {
|
||||||
let signal = wait_for_shutdown_signal().await;
|
let signal = wait_for_shutdown_signal().await;
|
||||||
@@ -60,7 +59,6 @@ pub(crate) async fn wait_for_shutdown(
|
|||||||
process_started_at,
|
process_started_at,
|
||||||
active_runtime,
|
active_runtime,
|
||||||
quota_state_path,
|
quota_state_path,
|
||||||
synlimit_controller,
|
|
||||||
reload_supervisor,
|
reload_supervisor,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -92,7 +90,6 @@ async fn perform_shutdown(
|
|||||||
process_started_at: Instant,
|
process_started_at: Instant,
|
||||||
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
|
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
|
||||||
quota_state_path: PathBuf,
|
quota_state_path: PathBuf,
|
||||||
synlimit_controller: synlimit_control::SynlimitController,
|
|
||||||
reload_supervisor: ReloadSupervisorHandle,
|
reload_supervisor: ReloadSupervisorHandle,
|
||||||
) {
|
) {
|
||||||
let shutdown_started_at = Instant::now();
|
let shutdown_started_at = Instant::now();
|
||||||
@@ -130,7 +127,6 @@ async fn perform_shutdown(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
synlimit_controller.shutdown().await;
|
|
||||||
if let Err(error) = synlimit_control::clear_synlimit_rules_all_backends().await {
|
if let Err(error) = synlimit_control::clear_synlimit_rules_all_backends().await {
|
||||||
warn!(error = %error, "Failed to clear SYN limiter rules during shutdown");
|
warn!(error = %error, "Failed to clear SYN limiter rules during shutdown");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ pub(crate) struct TlsResponseWriteOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl TlsResponseWriteOptions {
|
impl TlsResponseWriteOptions {
|
||||||
/// Creates Linux TCP response fragmentation options for an accepted socket.
|
/// Creates Linux best-effort response chunking options for an accepted socket.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub(crate) fn tcp(fd: std::os::unix::io::RawFd, fragment_size: Option<u16>) -> Self {
|
pub(crate) fn tcp(fd: std::os::unix::io::RawFd, fragment_size: Option<u16>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -980,7 +980,7 @@ where
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles FakeTLS with optional initial-response fragmentation on a TCP socket.
|
/// Handles FakeTLS with optional best-effort initial-response chunking.
|
||||||
pub(crate) async fn handle_tls_handshake_with_shared_and_options<R, W>(
|
pub(crate) async fn handle_tls_handshake_with_shared_and_options<R, W>(
|
||||||
handshake: &[u8],
|
handshake: &[u8],
|
||||||
reader: R,
|
reader: R,
|
||||||
@@ -2023,7 +2023,8 @@ pub fn generate_tg_nonce(
|
|||||||
let mut key_iv = Zeroizing::new(Vec::with_capacity(KEY_LEN + IV_LEN));
|
let mut key_iv = Zeroizing::new(Vec::with_capacity(KEY_LEN + IV_LEN));
|
||||||
key_iv.extend_from_slice(client_enc_key);
|
key_iv.extend_from_slice(client_enc_key);
|
||||||
key_iv.extend_from_slice(&client_enc_iv.to_be_bytes());
|
key_iv.extend_from_slice(&client_enc_iv.to_be_bytes());
|
||||||
key_iv.reverse(); // Python/C behavior: reversed enc_key+enc_iv in nonce
|
// Python/C compatibility requires reversed enc_key+enc_iv nonce bytes.
|
||||||
|
key_iv.reverse();
|
||||||
nonce[SKIP_LEN..SKIP_LEN + KEY_LEN + IV_LEN].copy_from_slice(&key_iv);
|
nonce[SKIP_LEN..SKIP_LEN + KEY_LEN + IV_LEN].copy_from_slice(&key_iv);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2064,7 +2065,8 @@ pub fn encrypt_tg_nonce_with_ciphers(nonce: &[u8; HANDSHAKE_LEN]) -> (Vec<u8>, A
|
|||||||
let dec_iv = u128::from_be_bytes(dec_iv_arr);
|
let dec_iv = u128::from_be_bytes(dec_iv_arr);
|
||||||
|
|
||||||
let mut encryptor = AesCtr::new(&enc_key, enc_iv);
|
let mut encryptor = AesCtr::new(&enc_key, enc_iv);
|
||||||
let encrypted_full = encryptor.encrypt(nonce); // counter: 0 → 4
|
// Encryption advances the nonce counter from zero to four.
|
||||||
|
let encrypted_full = encryptor.encrypt(nonce);
|
||||||
|
|
||||||
let mut result = nonce[..PROTO_TAG_POS].to_vec();
|
let mut result = nonce[..PROTO_TAG_POS].to_vec();
|
||||||
result.extend_from_slice(&encrypted_full[PROTO_TAG_POS..]);
|
result.extend_from_slice(&encrypted_full[PROTO_TAG_POS..]);
|
||||||
|
|||||||
+78
-339
@@ -1,11 +1,8 @@
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::Mutex;
|
||||||
|
|
||||||
use tokio::sync::watch;
|
|
||||||
use tokio_util::sync::CancellationToken;
|
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
use crate::config::ProxyConfig;
|
use crate::config::ProxyConfig;
|
||||||
use crate::maestro::generation::RuntimeWatchState;
|
|
||||||
|
|
||||||
mod command;
|
mod command;
|
||||||
mod iptables;
|
mod iptables;
|
||||||
@@ -18,228 +15,91 @@ use self::model::{SynLimitNamespace, synlimit_namespace, synlimit_targets};
|
|||||||
|
|
||||||
static ACTIVE_SYNLIMIT_NAMESPACE: Mutex<Option<SynLimitNamespace>> = Mutex::new(None);
|
static ACTIVE_SYNLIMIT_NAMESPACE: Mutex<Option<SynLimitNamespace>> = Mutex::new(None);
|
||||||
|
|
||||||
/// Process-owned lifecycle handle for the SYN limiter reconciler.
|
/// Installs the complete startup SYN-limiter ruleset before accept loops start.
|
||||||
pub(crate) struct SynlimitController {
|
pub(crate) async fn reconcile_synlimit_rules(cfg: &ProxyConfig) -> Result<(), String> {
|
||||||
shutdown: CancellationToken,
|
|
||||||
join: tokio::task::JoinHandle<()>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SynlimitController {
|
|
||||||
/// Stops config observation after any in-flight reconcile completes.
|
|
||||||
pub(crate) async fn shutdown(self) {
|
|
||||||
self.shutdown.cancel();
|
|
||||||
let _ = self.join.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Spawns the process-scoped SYN limiter reconciler for active generations.
|
|
||||||
pub(crate) fn spawn_synlimit_controller(
|
|
||||||
runtime_watch_rx: watch::Receiver<Option<RuntimeWatchState>>,
|
|
||||||
) -> SynlimitController {
|
|
||||||
let shutdown = CancellationToken::new();
|
|
||||||
let join = tokio::spawn(watch_active_runtime_configs(
|
|
||||||
runtime_watch_rx,
|
|
||||||
shutdown.clone(),
|
|
||||||
|_generation_id, cfg| async move {
|
|
||||||
reconcile_synlimit_rules(&cfg).await;
|
|
||||||
},
|
|
||||||
));
|
|
||||||
SynlimitController { shutdown, join }
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn watch_active_runtime_configs<F, Fut>(
|
|
||||||
mut runtime_watch_rx: watch::Receiver<Option<RuntimeWatchState>>,
|
|
||||||
shutdown: CancellationToken,
|
|
||||||
mut on_config: F,
|
|
||||||
) where
|
|
||||||
F: FnMut(u64, Arc<ProxyConfig>) -> Fut,
|
|
||||||
Fut: std::future::Future<Output = ()>,
|
|
||||||
{
|
|
||||||
let mut current = loop {
|
|
||||||
if let Some(state) = runtime_watch_rx.borrow().clone() {
|
|
||||||
break state;
|
|
||||||
}
|
|
||||||
tokio::select! {
|
|
||||||
biased;
|
|
||||||
_ = shutdown.cancelled() => return,
|
|
||||||
changed = runtime_watch_rx.changed() => {
|
|
||||||
if changed.is_err() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if shutdown.is_cancelled() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let initial_config = current.config_rx.borrow().clone();
|
|
||||||
on_config(current.generation_id, initial_config).await;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
tokio::select! {
|
|
||||||
biased;
|
|
||||||
_ = shutdown.cancelled() => break,
|
|
||||||
changed = runtime_watch_rx.changed() => {
|
|
||||||
if changed.is_err() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let Some(next) = runtime_watch_rx.borrow().clone() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if next.generation_id != current.generation_id {
|
|
||||||
current = next;
|
|
||||||
let config = current.config_rx.borrow().clone();
|
|
||||||
on_config(current.generation_id, config).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
changed = current.config_rx.changed() => {
|
|
||||||
if changed.is_err() {
|
|
||||||
let Some(next) = wait_for_new_runtime(
|
|
||||||
&mut runtime_watch_rx,
|
|
||||||
current.generation_id,
|
|
||||||
&shutdown,
|
|
||||||
).await else {
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
current = next;
|
|
||||||
let config = current.config_rx.borrow().clone();
|
|
||||||
on_config(current.generation_id, config).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let active_generation_id = runtime_watch_rx
|
|
||||||
.borrow()
|
|
||||||
.as_ref()
|
|
||||||
.map(|state| state.generation_id);
|
|
||||||
if active_generation_id == Some(current.generation_id) {
|
|
||||||
let cfg = current.config_rx.borrow_and_update().clone();
|
|
||||||
on_config(current.generation_id, cfg).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn wait_for_new_runtime(
|
|
||||||
runtime_watch_rx: &mut watch::Receiver<Option<RuntimeWatchState>>,
|
|
||||||
previous_generation_id: u64,
|
|
||||||
shutdown: &CancellationToken,
|
|
||||||
) -> Option<RuntimeWatchState> {
|
|
||||||
loop {
|
|
||||||
if let Some(state) = runtime_watch_rx.borrow().clone()
|
|
||||||
&& state.generation_id != previous_generation_id
|
|
||||||
{
|
|
||||||
return Some(state);
|
|
||||||
}
|
|
||||||
tokio::select! {
|
|
||||||
biased;
|
|
||||||
_ = shutdown.cancelled() => return None,
|
|
||||||
changed = runtime_watch_rx.changed() => {
|
|
||||||
if changed.is_err() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn reconcile_synlimit_rules(cfg: &ProxyConfig) {
|
|
||||||
let targets = synlimit_targets(cfg);
|
let targets = synlimit_targets(cfg);
|
||||||
let namespace = synlimit_namespace(&targets);
|
|
||||||
if let Some(previous_namespace) = set_active_synlimit_namespace(namespace.clone()) {
|
|
||||||
match clear_synlimit_rules_for_namespace(&previous_namespace).await {
|
|
||||||
Ok(true) => {
|
|
||||||
warn!("Removed previous SYN limiter namespace before reconcile");
|
|
||||||
}
|
|
||||||
Ok(false) => {}
|
|
||||||
Err(error) => {
|
|
||||||
warn!(error = %error, "Failed to clear previous SYN limiter namespace before reconcile");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if targets.is_empty() {
|
if targets.is_empty() {
|
||||||
return;
|
return Ok(());
|
||||||
}
|
}
|
||||||
let Some(namespace) = namespace else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if !has_firewall_privileges() {
|
if !has_firewall_privileges() {
|
||||||
warn!("SYN limiter configured but firewall privileges are not available; rules not applied");
|
return Err(
|
||||||
return;
|
"SYN limiter requires root or CAP_NET_ADMIN for startup and shutdown".to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let namespace = synlimit_namespace(&targets)
|
||||||
|
.ok_or_else(|| "SYN limiter namespace could not be derived".to_string())?;
|
||||||
|
|
||||||
|
if clear_synlimit_rules_for_namespace(&namespace).await? {
|
||||||
|
warn!("Removed stale SYN limiter rules left by a previous run before startup");
|
||||||
}
|
}
|
||||||
|
|
||||||
match clear_synlimit_rules_for_namespace(&namespace).await {
|
let apply_result = async {
|
||||||
Ok(true) => {
|
if targets.has_iptables_targets() {
|
||||||
warn!("Removed stale SYN limiter rules left by a previous run before reconcile");
|
iptables::apply_synlimit_rules(&targets, &namespace).await?;
|
||||||
}
|
}
|
||||||
Ok(false) => {}
|
if targets.has_nft_targets() {
|
||||||
Err(error) => {
|
nftables::apply_synlimit_rules(&targets, &namespace).await?;
|
||||||
warn!(error = %error, "Failed to clear stale SYN limiter rules before reconcile");
|
|
||||||
}
|
}
|
||||||
|
if targets.has_pf_targets() {
|
||||||
|
pf::apply_synlimit_rules(&targets, &namespace).await?;
|
||||||
|
}
|
||||||
|
Ok::<(), String>(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
if let Err(apply_error) = apply_result {
|
||||||
|
return match clear_synlimit_rules_for_namespace(&namespace).await {
|
||||||
|
Ok(_) => Err(apply_error),
|
||||||
|
Err(cleanup_error) => Err(format!(
|
||||||
|
"{apply_error}; candidate cleanup failed: {cleanup_error}"
|
||||||
|
)),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if targets.has_iptables_targets() {
|
if let Err(error) = set_active_synlimit_namespace(namespace.clone()) {
|
||||||
if let Err(error) = iptables::apply_synlimit_rules(&targets, &namespace).await {
|
return match clear_synlimit_rules_for_namespace(&namespace).await {
|
||||||
warn!(error = %error, "Failed to apply iptables SYN limiter rules");
|
Ok(_) => Err(error),
|
||||||
}
|
Err(cleanup_error) => Err(format!(
|
||||||
}
|
"{error}; candidate cleanup failed: {cleanup_error}"
|
||||||
if targets.has_nft_targets() {
|
)),
|
||||||
if let Err(error) = nftables::apply_synlimit_rules(&targets, &namespace).await {
|
};
|
||||||
warn!(error = %error, "Failed to apply nftables SYN limiter rules");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if targets.has_pf_targets() {
|
|
||||||
if let Err(error) = pf::apply_synlimit_rules(&targets, &namespace).await {
|
|
||||||
warn!(error = %error, "Failed to apply PF SYN limiter rules");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Removes the ruleset installed by the current process, if any.
|
||||||
pub(crate) async fn clear_synlimit_rules_all_backends() -> Result<bool, String> {
|
pub(crate) async fn clear_synlimit_rules_all_backends() -> Result<bool, String> {
|
||||||
let Some(namespace) = take_active_synlimit_namespace() else {
|
let Some(namespace) = active_synlimit_namespace()? else {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
clear_synlimit_rules_for_namespace(&namespace).await
|
let removed = clear_synlimit_rules_for_namespace(&namespace).await?;
|
||||||
|
clear_active_synlimit_namespace(&namespace)?;
|
||||||
|
Ok(removed)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn clear_synlimit_rules_for_namespace(namespace: &SynLimitNamespace) -> Result<bool, String> {
|
async fn clear_synlimit_rules_for_namespace(namespace: &SynLimitNamespace) -> Result<bool, String> {
|
||||||
if !has_firewall_privileges() {
|
if !has_firewall_privileges() {
|
||||||
return Ok(false);
|
return Err(
|
||||||
|
"SYN limiter cleanup requires root or CAP_NET_ADMIN privileges".to_string(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut errors = Vec::new();
|
let mut errors = Vec::new();
|
||||||
let mut removed = false;
|
let mut removed = false;
|
||||||
match nftables::clear_rules_all_families(namespace).await {
|
match nftables::clear_rules_all_families(namespace).await {
|
||||||
Ok(value) => {
|
Ok(value) => removed |= value,
|
||||||
removed |= value;
|
Err(error) => errors.push(error),
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
errors.push(error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
match iptables::clear_rules_for_binary("iptables", namespace).await {
|
match iptables::clear_rules_for_binary("iptables", namespace).await {
|
||||||
Ok(value) => {
|
Ok(value) => removed |= value,
|
||||||
removed |= value;
|
Err(error) => errors.push(error),
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
errors.push(error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
match iptables::clear_rules_for_binary("ip6tables", namespace).await {
|
match iptables::clear_rules_for_binary("ip6tables", namespace).await {
|
||||||
Ok(value) => {
|
Ok(value) => removed |= value,
|
||||||
removed |= value;
|
Err(error) => errors.push(error),
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
errors.push(error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
match pf::clear_rules(namespace).await {
|
match pf::clear_rules(namespace).await {
|
||||||
Ok(value) => {
|
Ok(value) => removed |= value,
|
||||||
removed |= value;
|
Err(error) => errors.push(error),
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
errors.push(error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if errors.is_empty() {
|
if errors.is_empty() {
|
||||||
@@ -249,161 +109,40 @@ async fn clear_synlimit_rules_for_namespace(namespace: &SynLimitNamespace) -> Re
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_active_synlimit_namespace(next: Option<SynLimitNamespace>) -> Option<SynLimitNamespace> {
|
fn set_active_synlimit_namespace(next: SynLimitNamespace) -> Result<(), String> {
|
||||||
match ACTIVE_SYNLIMIT_NAMESPACE.lock() {
|
match ACTIVE_SYNLIMIT_NAMESPACE.lock() {
|
||||||
Ok(mut active) => {
|
Ok(mut active) => {
|
||||||
if *active == next {
|
if active.is_some() {
|
||||||
None
|
return Err("SYN limiter namespace is already active".to_string());
|
||||||
} else {
|
|
||||||
std::mem::replace(&mut *active, next)
|
|
||||||
}
|
}
|
||||||
|
*active = Some(next);
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => Err(format!(
|
||||||
warn!(error = %error, "Failed to update active SYN limiter namespace");
|
"failed to update active SYN limiter namespace: {error}"
|
||||||
None
|
)),
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn take_active_synlimit_namespace() -> Option<SynLimitNamespace> {
|
fn active_synlimit_namespace() -> Result<Option<SynLimitNamespace>, String> {
|
||||||
match ACTIVE_SYNLIMIT_NAMESPACE.lock() {
|
match ACTIVE_SYNLIMIT_NAMESPACE.lock() {
|
||||||
Ok(mut active) => active.take(),
|
Ok(active) => Ok(active.clone()),
|
||||||
Err(error) => {
|
Err(error) => Err(format!(
|
||||||
warn!(error = %error, "Failed to read active SYN limiter namespace");
|
"failed to read active SYN limiter namespace: {error}"
|
||||||
None
|
)),
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
fn clear_active_synlimit_namespace(expected: &SynLimitNamespace) -> Result<(), String> {
|
||||||
mod tests {
|
match ACTIVE_SYNLIMIT_NAMESPACE.lock() {
|
||||||
use super::*;
|
Ok(mut active) => {
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
if active.as_ref() == Some(expected) {
|
||||||
use std::time::Duration;
|
*active = None;
|
||||||
use tokio::sync::{Notify, mpsc};
|
|
||||||
|
|
||||||
fn runtime_state(
|
|
||||||
generation_id: u64,
|
|
||||||
max_connections: u32,
|
|
||||||
) -> (
|
|
||||||
RuntimeWatchState,
|
|
||||||
watch::Sender<Arc<ProxyConfig>>,
|
|
||||||
watch::Sender<bool>,
|
|
||||||
) {
|
|
||||||
let mut config = ProxyConfig::default();
|
|
||||||
config.server.max_connections = max_connections;
|
|
||||||
let (config_tx, config_rx) = watch::channel(Arc::new(config));
|
|
||||||
let (admission_tx, admission_rx) = watch::channel(true);
|
|
||||||
(
|
|
||||||
RuntimeWatchState {
|
|
||||||
generation_id,
|
|
||||||
config_rx,
|
|
||||||
admission_rx,
|
|
||||||
},
|
|
||||||
config_tx,
|
|
||||||
admission_tx,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn config_watcher_ignores_retired_generation_updates() {
|
|
||||||
let (initial, initial_config_tx, _initial_admission_tx) = runtime_state(1, 10);
|
|
||||||
let (runtime_tx, runtime_rx) = watch::channel(Some(initial));
|
|
||||||
let (observed_tx, mut observed_rx) = mpsc::unbounded_channel();
|
|
||||||
let watcher = tokio::spawn(watch_active_runtime_configs(
|
|
||||||
runtime_rx,
|
|
||||||
CancellationToken::new(),
|
|
||||||
move |generation_id, cfg| {
|
|
||||||
let observed_tx = observed_tx.clone();
|
|
||||||
async move {
|
|
||||||
let _ = observed_tx.send((generation_id, cfg.server.max_connections));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
));
|
|
||||||
|
|
||||||
assert_eq!(observed_rx.recv().await, Some((1, 10)));
|
|
||||||
let (next, next_config_tx, _next_admission_tx) = runtime_state(2, 20);
|
|
||||||
runtime_tx.send_replace(Some(next));
|
|
||||||
assert_eq!(observed_rx.recv().await, Some((2, 20)));
|
|
||||||
|
|
||||||
let mut stale = ProxyConfig::default();
|
|
||||||
stale.server.max_connections = 30;
|
|
||||||
initial_config_tx.send_replace(Arc::new(stale));
|
|
||||||
assert!(
|
|
||||||
tokio::time::timeout(Duration::from_millis(50), observed_rx.recv())
|
|
||||||
.await
|
|
||||||
.is_err()
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut active = ProxyConfig::default();
|
|
||||||
active.server.max_connections = 40;
|
|
||||||
next_config_tx.send_replace(Arc::new(active));
|
|
||||||
assert_eq!(observed_rx.recv().await, Some((2, 40)));
|
|
||||||
|
|
||||||
watcher.abort();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn shutdown_waits_for_inflight_reconcile_and_stops_future_updates() {
|
|
||||||
let (initial, config_tx, _admission_tx) = runtime_state(1, 10);
|
|
||||||
let (_runtime_tx, runtime_rx) = watch::channel(Some(initial));
|
|
||||||
let shutdown = CancellationToken::new();
|
|
||||||
let started = Arc::new(Notify::new());
|
|
||||||
let release = Arc::new(Notify::new());
|
|
||||||
let calls = Arc::new(AtomicUsize::new(0));
|
|
||||||
let started_callback = started.clone();
|
|
||||||
let release_callback = release.clone();
|
|
||||||
let calls_callback = calls.clone();
|
|
||||||
let watcher_shutdown = shutdown.clone();
|
|
||||||
let watcher = tokio::spawn(watch_active_runtime_configs(
|
|
||||||
runtime_rx,
|
|
||||||
watcher_shutdown,
|
|
||||||
move |_generation_id, _cfg| {
|
|
||||||
let started = started_callback.clone();
|
|
||||||
let release = release_callback.clone();
|
|
||||||
let calls = calls_callback.clone();
|
|
||||||
async move {
|
|
||||||
calls.fetch_add(1, Ordering::AcqRel);
|
|
||||||
started.notify_one();
|
|
||||||
release.notified().await;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
));
|
|
||||||
started.notified().await;
|
|
||||||
|
|
||||||
shutdown.cancel();
|
|
||||||
tokio::task::yield_now().await;
|
|
||||||
assert!(!watcher.is_finished());
|
|
||||||
release.notify_one();
|
|
||||||
tokio::time::timeout(Duration::from_secs(1), watcher)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
drop(_runtime_tx);
|
|
||||||
let mut updated = ProxyConfig::default();
|
|
||||||
updated.server.max_connections = 20;
|
|
||||||
assert!(config_tx.send(Arc::new(updated)).is_err());
|
|
||||||
assert_eq!(calls.load(Ordering::Acquire), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn shutdown_before_start_skips_initial_reconcile() {
|
|
||||||
let (initial, _config_tx, _admission_tx) = runtime_state(1, 10);
|
|
||||||
let (_runtime_tx, runtime_rx) = watch::channel(Some(initial));
|
|
||||||
let shutdown = CancellationToken::new();
|
|
||||||
shutdown.cancel();
|
|
||||||
let calls = Arc::new(AtomicUsize::new(0));
|
|
||||||
let calls_callback = calls.clone();
|
|
||||||
|
|
||||||
watch_active_runtime_configs(runtime_rx, shutdown, move |_generation_id, _cfg| {
|
|
||||||
let calls = calls_callback.clone();
|
|
||||||
async move {
|
|
||||||
calls.fetch_add(1, Ordering::AcqRel);
|
|
||||||
}
|
}
|
||||||
})
|
Ok(())
|
||||||
.await;
|
}
|
||||||
|
Err(error) => Err(format!(
|
||||||
assert_eq!(calls.load(Ordering::Acquire), 0);
|
"failed to update active SYN limiter namespace: {error}"
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-22
@@ -5,6 +5,21 @@ use super::model::{SynLimitNamespace, SynLimitRule, SynLimitTargets};
|
|||||||
|
|
||||||
const PF_ANCHOR_ROOT: &str = "telemt_synlimit";
|
const PF_ANCHOR_ROOT: &str = "telemt_synlimit";
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum PfFamily {
|
||||||
|
Inet,
|
||||||
|
Inet6,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PfFamily {
|
||||||
|
fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Inet => "inet",
|
||||||
|
Self::Inet6 => "inet6",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) async fn apply_synlimit_rules(
|
pub(super) async fn apply_synlimit_rules(
|
||||||
targets: &SynLimitTargets,
|
targets: &SynLimitTargets,
|
||||||
namespace: &SynLimitNamespace,
|
namespace: &SynLimitNamespace,
|
||||||
@@ -31,26 +46,23 @@ fn is_pf_anchor_hook_line(line: &str) -> bool {
|
|||||||
fn pf_synlimit_script(targets: &SynLimitTargets) -> String {
|
fn pf_synlimit_script(targets: &SynLimitTargets) -> String {
|
||||||
let mut script = String::new();
|
let mut script = String::new();
|
||||||
for target in &targets.pf_v4 {
|
for target in &targets.pf_v4 {
|
||||||
push_pf_rules(&mut script, target);
|
push_pf_rules(&mut script, PfFamily::Inet, target);
|
||||||
}
|
}
|
||||||
for target in &targets.pf_v6 {
|
for target in &targets.pf_v6 {
|
||||||
push_pf_rules(&mut script, target);
|
push_pf_rules(&mut script, PfFamily::Inet6, target);
|
||||||
}
|
}
|
||||||
script
|
script
|
||||||
}
|
}
|
||||||
|
|
||||||
fn push_pf_rules(script: &mut String, target: &SynLimitRule) {
|
fn push_pf_rules(script: &mut String, family: PfFamily, target: &SynLimitRule) {
|
||||||
let destination = pf_destination(target.ip);
|
let destination = pf_destination(target.ip);
|
||||||
script.push_str(&format!(
|
script.push_str(&format!(
|
||||||
"pass in quick proto tcp from any to {destination} port {port} flags S/SA keep state (max-src-conn-rate {rate}/{seconds})\n",
|
"pass in quick {family} proto tcp from any to {destination} port {port} flags S/SA keep state (max-src-conn-rate {rate}/{seconds})\n",
|
||||||
|
family = family.as_str(),
|
||||||
port = target.port,
|
port = target.port,
|
||||||
rate = target.generic_hitcount,
|
rate = target.generic_hitcount,
|
||||||
seconds = target.generic_seconds,
|
seconds = target.generic_seconds,
|
||||||
));
|
));
|
||||||
script.push_str(&format!(
|
|
||||||
"block return-rst in quick proto tcp from any to {destination} port {port}\n",
|
|
||||||
port = target.port,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pf_destination(ip: Option<IpAddr>) -> String {
|
fn pf_destination(ip: Option<IpAddr>) -> String {
|
||||||
@@ -84,24 +96,15 @@ mod tests {
|
|||||||
use crate::synlimit_control::model::test_rule;
|
use crate::synlimit_control::model::test_rule;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pf_script_uses_rate_limited_pass_before_reject() {
|
fn pf_script_uses_native_rate_limited_pass() {
|
||||||
let mut targets = SynLimitTargets::default();
|
let mut targets = SynLimitTargets::default();
|
||||||
targets.pf_v4 = vec![test_rule(Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7))), 443)];
|
targets.pf_v4 = vec![test_rule(Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7))), 443)];
|
||||||
let script = pf_synlimit_script(&targets);
|
let script = pf_synlimit_script(&targets);
|
||||||
|
|
||||||
assert!(script.contains(
|
assert!(script.contains(
|
||||||
"pass in quick proto tcp from any to 203.0.113.7 port 443 flags S/SA keep state (max-src-conn-rate 48/60)"
|
"pass in quick inet proto tcp from any to 203.0.113.7 port 443 flags S/SA keep state (max-src-conn-rate 48/60)"
|
||||||
));
|
));
|
||||||
assert!(script.contains(
|
assert!(!script.contains("return-rst"));
|
||||||
"block return-rst in quick proto tcp from any to 203.0.113.7 port 443"
|
|
||||||
));
|
|
||||||
let pass_idx = script
|
|
||||||
.find("pass in quick proto tcp from any to 203.0.113.7 port 443")
|
|
||||||
.expect("rate-limited pass rule must be rendered");
|
|
||||||
let block_idx = script
|
|
||||||
.find("block return-rst in quick proto tcp from any to 203.0.113.7 port 443")
|
|
||||||
.expect("reject fallback rule must be rendered");
|
|
||||||
assert!(pass_idx < block_idx);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -111,8 +114,8 @@ mod tests {
|
|||||||
targets.pf_v6 = vec![test_rule(Some(IpAddr::V6(Ipv6Addr::LOCALHOST)), 8443)];
|
targets.pf_v6 = vec![test_rule(Some(IpAddr::V6(Ipv6Addr::LOCALHOST)), 8443)];
|
||||||
let script = pf_synlimit_script(&targets);
|
let script = pf_synlimit_script(&targets);
|
||||||
|
|
||||||
assert!(script.contains("to any port 443"));
|
assert!(script.contains("pass in quick inet proto tcp from any to any port 443"));
|
||||||
assert!(script.contains("to ::1 port 8443"));
|
assert!(script.contains("pass in quick inet6 proto tcp from any to ::1 port 8443"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+7
-367
@@ -11,6 +11,11 @@ use std::time::Duration;
|
|||||||
use tokio::net::TcpStream;
|
use tokio::net::TcpStream;
|
||||||
use tracing::{debug, warn};
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod fragmented_send;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub(crate) use fragmented_send::send_tcp_fragmented_fd;
|
||||||
|
|
||||||
const DEFAULT_SOCKET_BUFFER_BYTES: usize = 256 * 1024;
|
const DEFAULT_SOCKET_BUFFER_BYTES: usize = 256 * 1024;
|
||||||
|
|
||||||
/// Configure TCP socket with recommended settings for proxy use
|
/// Configure TCP socket with recommended settings for proxy use
|
||||||
@@ -125,99 +130,6 @@ pub fn clear_linger_fd(fd: std::os::unix::io::RawFd) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
fn force_tcp_push(fd: std::os::unix::io::RawFd) -> Result<()> {
|
|
||||||
let enabled: libc::c_int = 1;
|
|
||||||
let rc = unsafe {
|
|
||||||
libc::setsockopt(
|
|
||||||
fd,
|
|
||||||
libc::IPPROTO_TCP,
|
|
||||||
libc::TCP_NODELAY,
|
|
||||||
&enabled as *const libc::c_int as *const libc::c_void,
|
|
||||||
std::mem::size_of::<libc::c_int>() as libc::socklen_t,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
if rc != 0 {
|
|
||||||
return Err(std::io::Error::last_os_error());
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sends an initial TCP response in collapse-resistant segments on Linux.
|
|
||||||
///
|
|
||||||
/// `fd` must refer to a connected, nonblocking TCP socket and remain valid for
|
|
||||||
/// the duration of this call. The caller retains ownership of the original fd.
|
|
||||||
/// Each successful `send(2)` is marked with `MSG_EOR`; Linux preserves that mark
|
|
||||||
/// when deciding whether adjacent SKBs may be collapsed, including retransmits.
|
|
||||||
/// Re-applying `TCP_NODELAY` forces each marked SKB out despite auto-corking.
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
pub(crate) async fn send_tcp_fragmented_fd(
|
|
||||||
fd: std::os::unix::io::RawFd,
|
|
||||||
data: &[u8],
|
|
||||||
fragment_size: usize,
|
|
||||||
) -> Result<()> {
|
|
||||||
use std::io::{Error, ErrorKind};
|
|
||||||
use std::os::fd::{AsRawFd, BorrowedFd};
|
|
||||||
use tokio::io::Interest;
|
|
||||||
use tokio::io::unix::AsyncFd;
|
|
||||||
|
|
||||||
if fragment_size == 0 {
|
|
||||||
return Err(Error::new(
|
|
||||||
ErrorKind::InvalidInput,
|
|
||||||
"TCP fragment size must be greater than zero",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if data.is_empty() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
if fd < 0 {
|
|
||||||
return Err(Error::from_raw_os_error(libc::EBADF));
|
|
||||||
}
|
|
||||||
|
|
||||||
// SAFETY: the caller guarantees that fd remains open for this call.
|
|
||||||
let borrowed_fd = unsafe { BorrowedFd::borrow_raw(fd) };
|
|
||||||
let duplicated_fd = borrowed_fd.try_clone_to_owned()?;
|
|
||||||
let async_fd = AsyncFd::with_interest(duplicated_fd, Interest::WRITABLE)?;
|
|
||||||
|
|
||||||
for fragment in data.chunks(fragment_size) {
|
|
||||||
let mut offset = 0;
|
|
||||||
while offset < fragment.len() {
|
|
||||||
let mut writable = async_fd.writable().await?;
|
|
||||||
let sent = match writable.try_io(|inner| {
|
|
||||||
let remaining = &fragment[offset..];
|
|
||||||
let sent = unsafe {
|
|
||||||
libc::send(
|
|
||||||
inner.get_ref().as_raw_fd(),
|
|
||||||
remaining.as_ptr().cast::<libc::c_void>(),
|
|
||||||
remaining.len(),
|
|
||||||
libc::MSG_DONTWAIT | libc::MSG_EOR | libc::MSG_NOSIGNAL,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
if sent < 0 {
|
|
||||||
Err(Error::last_os_error())
|
|
||||||
} else if sent == 0 {
|
|
||||||
Err(Error::new(
|
|
||||||
ErrorKind::WriteZero,
|
|
||||||
"fragmented TCP send returned zero",
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
Ok(sent as usize)
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
Ok(Ok(sent)) => sent,
|
|
||||||
Ok(Err(error)) if error.kind() == ErrorKind::Interrupted => continue,
|
|
||||||
Ok(Err(error)) => return Err(error),
|
|
||||||
Err(_) => continue,
|
|
||||||
};
|
|
||||||
|
|
||||||
offset += sent;
|
|
||||||
force_tcp_push(async_fd.get_ref().as_raw_fd())?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new TCP socket for outgoing connections
|
/// Create a new TCP socket for outgoing connections
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn create_outgoing_socket(addr: SocketAddr) -> Result<Socket> {
|
pub fn create_outgoing_socket(addr: SocketAddr) -> Result<Socket> {
|
||||||
@@ -565,277 +477,5 @@ fn listening_inodes_for_port(addr: SocketAddr) -> HashSet<u64> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "socket/tests.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
use std::io::ErrorKind;
|
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
||||||
use tokio::net::TcpListener;
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_configure_socket() {
|
|
||||||
let listener = match TcpListener::bind("127.0.0.1:0").await {
|
|
||||||
Ok(l) => l,
|
|
||||||
Err(e) if e.kind() == ErrorKind::PermissionDenied => return,
|
|
||||||
Err(e) => panic!("bind failed: {e}"),
|
|
||||||
};
|
|
||||||
let addr = listener.local_addr().unwrap();
|
|
||||||
|
|
||||||
let stream = match TcpStream::connect(addr).await {
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(e) if e.kind() == ErrorKind::PermissionDenied => return,
|
|
||||||
Err(e) => panic!("connect failed: {e}"),
|
|
||||||
};
|
|
||||||
if let Err(e) = configure_tcp_socket(&stream, true, Duration::from_secs(30)) {
|
|
||||||
if e.kind() == ErrorKind::PermissionDenied {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
panic!("configure_tcp_socket failed: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_configure_client_socket() {
|
|
||||||
let listener = match TcpListener::bind("127.0.0.1:0").await {
|
|
||||||
Ok(l) => l,
|
|
||||||
Err(e) if e.kind() == ErrorKind::PermissionDenied => return,
|
|
||||||
Err(e) => panic!("bind failed: {e}"),
|
|
||||||
};
|
|
||||||
let addr = match listener.local_addr() {
|
|
||||||
Ok(addr) => addr,
|
|
||||||
Err(e) => panic!("local_addr failed: {e}"),
|
|
||||||
};
|
|
||||||
|
|
||||||
let stream = match TcpStream::connect(addr).await {
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(e) if e.kind() == ErrorKind::PermissionDenied => return,
|
|
||||||
Err(e) => panic!("connect failed: {e}"),
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(e) = configure_client_socket(&stream, 30, 30) {
|
|
||||||
if e.kind() == ErrorKind::PermissionDenied {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
panic!("configure_client_socket failed: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_configure_client_socket_zero_ack_timeout() {
|
|
||||||
let listener = match TcpListener::bind("127.0.0.1:0").await {
|
|
||||||
Ok(l) => l,
|
|
||||||
Err(e) if e.kind() == ErrorKind::PermissionDenied => return,
|
|
||||||
Err(e) => panic!("bind failed: {e}"),
|
|
||||||
};
|
|
||||||
let addr = match listener.local_addr() {
|
|
||||||
Ok(addr) => addr,
|
|
||||||
Err(e) => panic!("local_addr failed: {e}"),
|
|
||||||
};
|
|
||||||
|
|
||||||
let stream = match TcpStream::connect(addr).await {
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(e) if e.kind() == ErrorKind::PermissionDenied => return,
|
|
||||||
Err(e) => panic!("connect failed: {e}"),
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(e) = configure_client_socket(&stream, 30, 0) {
|
|
||||||
if e.kind() == ErrorKind::PermissionDenied {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
panic!("configure_client_socket with zero ack timeout failed: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_configure_client_socket_roundtrip_io() {
|
|
||||||
let listener = match TcpListener::bind("127.0.0.1:0").await {
|
|
||||||
Ok(l) => l,
|
|
||||||
Err(e) if e.kind() == ErrorKind::PermissionDenied => return,
|
|
||||||
Err(e) => panic!("bind failed: {e}"),
|
|
||||||
};
|
|
||||||
let addr = match listener.local_addr() {
|
|
||||||
Ok(addr) => addr,
|
|
||||||
Err(e) => panic!("local_addr failed: {e}"),
|
|
||||||
};
|
|
||||||
|
|
||||||
let server_task = tokio::spawn(async move {
|
|
||||||
let (mut accepted, _) = match listener.accept().await {
|
|
||||||
Ok(v) => v,
|
|
||||||
Err(e) => panic!("accept failed: {e}"),
|
|
||||||
};
|
|
||||||
let mut payload = [0u8; 4];
|
|
||||||
if let Err(e) = accepted.read_exact(&mut payload).await {
|
|
||||||
panic!("server read_exact failed: {e}");
|
|
||||||
}
|
|
||||||
if let Err(e) = accepted.write_all(b"pong").await {
|
|
||||||
panic!("server write_all failed: {e}");
|
|
||||||
}
|
|
||||||
payload
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut stream = match TcpStream::connect(addr).await {
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(e) if e.kind() == ErrorKind::PermissionDenied => return,
|
|
||||||
Err(e) => panic!("connect failed: {e}"),
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(e) = configure_client_socket(&stream, 30, 30) {
|
|
||||||
if e.kind() == ErrorKind::PermissionDenied {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
panic!("configure_client_socket failed: {e}");
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(e) = stream.write_all(b"ping").await {
|
|
||||||
panic!("client write_all failed: {e}");
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut reply = [0u8; 4];
|
|
||||||
if let Err(e) = stream.read_exact(&mut reply).await {
|
|
||||||
panic!("client read_exact failed: {e}");
|
|
||||||
}
|
|
||||||
assert_eq!(&reply, b"pong");
|
|
||||||
|
|
||||||
let server_seen = match server_task.await {
|
|
||||||
Ok(value) => value,
|
|
||||||
Err(e) => panic!("server task join failed: {e}"),
|
|
||||||
};
|
|
||||||
assert_eq!(&server_seen, b"ping");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_configure_client_socket_ack_timeout_overflow_rejected() {
|
|
||||||
let listener = match TcpListener::bind("127.0.0.1:0").await {
|
|
||||||
Ok(l) => l,
|
|
||||||
Err(e) if e.kind() == ErrorKind::PermissionDenied => return,
|
|
||||||
Err(e) => panic!("bind failed: {e}"),
|
|
||||||
};
|
|
||||||
let addr = match listener.local_addr() {
|
|
||||||
Ok(addr) => addr,
|
|
||||||
Err(e) => panic!("local_addr failed: {e}"),
|
|
||||||
};
|
|
||||||
|
|
||||||
let stream = match TcpStream::connect(addr).await {
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(e) if e.kind() == ErrorKind::PermissionDenied => return,
|
|
||||||
Err(e) => panic!("connect failed: {e}"),
|
|
||||||
};
|
|
||||||
|
|
||||||
let too_large_secs = (i32::MAX as u64 / 1000) + 1;
|
|
||||||
let err = match configure_client_socket(&stream, 30, too_large_secs) {
|
|
||||||
Ok(()) => panic!("expected overflow validation error"),
|
|
||||||
Err(e) => e,
|
|
||||||
};
|
|
||||||
assert_eq!(err.kind(), ErrorKind::InvalidInput);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_normalize_ip() {
|
|
||||||
// IPv4 stays IPv4
|
|
||||||
let v4: SocketAddr = "192.168.1.1:8080".parse().unwrap();
|
|
||||||
assert_eq!(normalize_ip(v4), v4);
|
|
||||||
|
|
||||||
// Pure IPv6 stays IPv6
|
|
||||||
let v6: SocketAddr = "[::1]:8080".parse().unwrap();
|
|
||||||
assert_eq!(normalize_ip(v6), v6);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_listen_options_default() {
|
|
||||||
let opts = ListenOptions::default();
|
|
||||||
assert!(opts.reuse_addr);
|
|
||||||
assert!(opts.reuse_port);
|
|
||||||
assert_eq!(opts.backlog, 1024);
|
|
||||||
assert_eq!(opts.client_mss, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
#[test]
|
|
||||||
fn test_create_listener_applies_client_mss() {
|
|
||||||
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
|
|
||||||
let options = ListenOptions {
|
|
||||||
reuse_port: false,
|
|
||||||
client_mss: Some(256),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let socket = match create_listener(addr, &options) {
|
|
||||||
Ok(socket) => socket,
|
|
||||||
Err(e) if e.kind() == ErrorKind::PermissionDenied => return,
|
|
||||||
Err(e) => panic!("create_listener failed: {e}"),
|
|
||||||
};
|
|
||||||
let mss = match socket.tcp_mss() {
|
|
||||||
Ok(mss) => mss,
|
|
||||||
Err(e) if e.kind() == ErrorKind::PermissionDenied => return,
|
|
||||||
Err(e) => panic!("tcp_mss failed: {e}"),
|
|
||||||
};
|
|
||||||
assert_eq!(mss, 256);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_chunked_send_preserves_stream_and_configured_mss() {
|
|
||||||
use std::os::fd::AsRawFd;
|
|
||||||
|
|
||||||
let options = ListenOptions {
|
|
||||||
reuse_port: false,
|
|
||||||
client_mss: Some(1400),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let socket = match create_listener("127.0.0.1:0".parse().unwrap(), &options) {
|
|
||||||
Ok(socket) => socket,
|
|
||||||
Err(e) if e.kind() == ErrorKind::PermissionDenied => return,
|
|
||||||
Err(e) => panic!("create_listener failed: {e}"),
|
|
||||||
};
|
|
||||||
let listener = TcpListener::from_std(socket.into()).unwrap();
|
|
||||||
let addr = listener.local_addr().unwrap();
|
|
||||||
let client = TcpStream::connect(addr).await.unwrap();
|
|
||||||
let (mut server, _) = listener.accept().await.unwrap();
|
|
||||||
|
|
||||||
let initial_response: Vec<u8> = (0..4096).map(|value| (value % 251) as u8).collect();
|
|
||||||
let bulk_payload = vec![0xA5; 8192];
|
|
||||||
let expected_len = initial_response.len() + bulk_payload.len();
|
|
||||||
let reader = tokio::spawn(async move {
|
|
||||||
let mut client = client;
|
|
||||||
let mut received = vec![0; expected_len];
|
|
||||||
client.read_exact(&mut received).await.unwrap();
|
|
||||||
received
|
|
||||||
});
|
|
||||||
|
|
||||||
let mss_before = socket2::SockRef::from(&server).tcp_mss().unwrap();
|
|
||||||
send_tcp_fragmented_fd(server.as_raw_fd(), &initial_response, 92)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
server.write_all(&bulk_payload).await.unwrap();
|
|
||||||
let mss_after = socket2::SockRef::from(&server).tcp_mss().unwrap();
|
|
||||||
|
|
||||||
let mut expected = initial_response;
|
|
||||||
expected.extend_from_slice(&bulk_payload);
|
|
||||||
assert_eq!(
|
|
||||||
reader.await.unwrap(),
|
|
||||||
expected,
|
|
||||||
"chunked send must preserve the byte stream"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
mss_after, mss_before,
|
|
||||||
"chunked send must not change the configured socket MSS"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_chunked_send_rejects_zero_fragment_size() {
|
|
||||||
let error = send_tcp_fragmented_fd(-1, b"response", 0)
|
|
||||||
.await
|
|
||||||
.unwrap_err();
|
|
||||||
assert_eq!(error.kind(), ErrorKind::InvalidInput);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_chunked_send_rejects_invalid_fd() {
|
|
||||||
let error = send_tcp_fragmented_fd(-1, b"response", 92)
|
|
||||||
.await
|
|
||||||
.unwrap_err();
|
|
||||||
assert_eq!(error.raw_os_error(), Some(libc::EBADF));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
use std::io::{Error, ErrorKind, Result};
|
||||||
|
use std::os::fd::{AsRawFd, BorrowedFd, RawFd};
|
||||||
|
|
||||||
|
use tokio::io::Interest;
|
||||||
|
use tokio::io::unix::AsyncFd;
|
||||||
|
|
||||||
|
fn force_tcp_push(fd: RawFd) -> Result<()> {
|
||||||
|
let enabled: libc::c_int = 1;
|
||||||
|
let rc = unsafe {
|
||||||
|
libc::setsockopt(
|
||||||
|
fd,
|
||||||
|
libc::IPPROTO_TCP,
|
||||||
|
libc::TCP_NODELAY,
|
||||||
|
&enabled as *const libc::c_int as *const libc::c_void,
|
||||||
|
std::mem::size_of::<libc::c_int>() as libc::socklen_t,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if rc != 0 {
|
||||||
|
return Err(Error::last_os_error());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends an initial TCP response using best-effort userspace write chunking.
|
||||||
|
///
|
||||||
|
/// `fd` must refer to a connected, nonblocking TCP socket and remain valid for
|
||||||
|
/// the duration of this call. The caller retains ownership of the original fd.
|
||||||
|
/// `MSG_EOR` is only a best-effort Linux hint for TCP: offloads, loss, and
|
||||||
|
/// retransmission may coalesce these write boundaries on the wire.
|
||||||
|
pub(crate) async fn send_tcp_fragmented_fd(
|
||||||
|
fd: RawFd,
|
||||||
|
data: &[u8],
|
||||||
|
fragment_size: usize,
|
||||||
|
) -> Result<()> {
|
||||||
|
if fragment_size == 0 {
|
||||||
|
return Err(Error::new(
|
||||||
|
ErrorKind::InvalidInput,
|
||||||
|
"TCP fragment size must be greater than zero",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if data.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if fd < 0 {
|
||||||
|
return Err(Error::from_raw_os_error(libc::EBADF));
|
||||||
|
}
|
||||||
|
|
||||||
|
// SAFETY: the caller guarantees that fd remains open for this call.
|
||||||
|
let borrowed_fd = unsafe { BorrowedFd::borrow_raw(fd) };
|
||||||
|
let duplicated_fd = borrowed_fd.try_clone_to_owned()?;
|
||||||
|
let async_fd = AsyncFd::with_interest(duplicated_fd, Interest::WRITABLE)?;
|
||||||
|
|
||||||
|
for fragment in data.chunks(fragment_size) {
|
||||||
|
let mut offset = 0;
|
||||||
|
while offset < fragment.len() {
|
||||||
|
let mut writable = async_fd.writable().await?;
|
||||||
|
let sent = match writable.try_io(|inner| {
|
||||||
|
let remaining = &fragment[offset..];
|
||||||
|
let sent = unsafe {
|
||||||
|
libc::send(
|
||||||
|
inner.get_ref().as_raw_fd(),
|
||||||
|
remaining.as_ptr().cast::<libc::c_void>(),
|
||||||
|
remaining.len(),
|
||||||
|
libc::MSG_DONTWAIT | libc::MSG_EOR | libc::MSG_NOSIGNAL,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if sent < 0 {
|
||||||
|
Err(Error::last_os_error())
|
||||||
|
} else if sent == 0 {
|
||||||
|
Err(Error::new(
|
||||||
|
ErrorKind::WriteZero,
|
||||||
|
"fragmented TCP send returned zero",
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Ok(sent as usize)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Ok(Ok(sent)) => sent,
|
||||||
|
Ok(Err(error)) if error.kind() == ErrorKind::Interrupted => continue,
|
||||||
|
Ok(Err(error)) => return Err(error),
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
offset += sent;
|
||||||
|
force_tcp_push(async_fd.get_ref().as_raw_fd())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
use super::*;
|
||||||
|
use std::io::ErrorKind;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_configure_socket() {
|
||||||
|
let listener = match TcpListener::bind("127.0.0.1:0").await {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(e) if e.kind() == ErrorKind::PermissionDenied => return,
|
||||||
|
Err(e) => panic!("bind failed: {e}"),
|
||||||
|
};
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
|
||||||
|
let stream = match TcpStream::connect(addr).await {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) if e.kind() == ErrorKind::PermissionDenied => return,
|
||||||
|
Err(e) => panic!("connect failed: {e}"),
|
||||||
|
};
|
||||||
|
if let Err(e) = configure_tcp_socket(&stream, true, Duration::from_secs(30)) {
|
||||||
|
if e.kind() == ErrorKind::PermissionDenied {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
panic!("configure_tcp_socket failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_configure_client_socket() {
|
||||||
|
let listener = match TcpListener::bind("127.0.0.1:0").await {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(e) if e.kind() == ErrorKind::PermissionDenied => return,
|
||||||
|
Err(e) => panic!("bind failed: {e}"),
|
||||||
|
};
|
||||||
|
let addr = match listener.local_addr() {
|
||||||
|
Ok(addr) => addr,
|
||||||
|
Err(e) => panic!("local_addr failed: {e}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let stream = match TcpStream::connect(addr).await {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) if e.kind() == ErrorKind::PermissionDenied => return,
|
||||||
|
Err(e) => panic!("connect failed: {e}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = configure_client_socket(&stream, 30, 30) {
|
||||||
|
if e.kind() == ErrorKind::PermissionDenied {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
panic!("configure_client_socket failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_configure_client_socket_zero_ack_timeout() {
|
||||||
|
let listener = match TcpListener::bind("127.0.0.1:0").await {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(e) if e.kind() == ErrorKind::PermissionDenied => return,
|
||||||
|
Err(e) => panic!("bind failed: {e}"),
|
||||||
|
};
|
||||||
|
let addr = match listener.local_addr() {
|
||||||
|
Ok(addr) => addr,
|
||||||
|
Err(e) => panic!("local_addr failed: {e}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let stream = match TcpStream::connect(addr).await {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) if e.kind() == ErrorKind::PermissionDenied => return,
|
||||||
|
Err(e) => panic!("connect failed: {e}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = configure_client_socket(&stream, 30, 0) {
|
||||||
|
if e.kind() == ErrorKind::PermissionDenied {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
panic!("configure_client_socket with zero ack timeout failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_configure_client_socket_roundtrip_io() {
|
||||||
|
let listener = match TcpListener::bind("127.0.0.1:0").await {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(e) if e.kind() == ErrorKind::PermissionDenied => return,
|
||||||
|
Err(e) => panic!("bind failed: {e}"),
|
||||||
|
};
|
||||||
|
let addr = match listener.local_addr() {
|
||||||
|
Ok(addr) => addr,
|
||||||
|
Err(e) => panic!("local_addr failed: {e}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let server_task = tokio::spawn(async move {
|
||||||
|
let (mut accepted, _) = match listener.accept().await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => panic!("accept failed: {e}"),
|
||||||
|
};
|
||||||
|
let mut payload = [0u8; 4];
|
||||||
|
if let Err(e) = accepted.read_exact(&mut payload).await {
|
||||||
|
panic!("server read_exact failed: {e}");
|
||||||
|
}
|
||||||
|
if let Err(e) = accepted.write_all(b"pong").await {
|
||||||
|
panic!("server write_all failed: {e}");
|
||||||
|
}
|
||||||
|
payload
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut stream = match TcpStream::connect(addr).await {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) if e.kind() == ErrorKind::PermissionDenied => return,
|
||||||
|
Err(e) => panic!("connect failed: {e}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = configure_client_socket(&stream, 30, 30) {
|
||||||
|
if e.kind() == ErrorKind::PermissionDenied {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
panic!("configure_client_socket failed: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = stream.write_all(b"ping").await {
|
||||||
|
panic!("client write_all failed: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut reply = [0u8; 4];
|
||||||
|
if let Err(e) = stream.read_exact(&mut reply).await {
|
||||||
|
panic!("client read_exact failed: {e}");
|
||||||
|
}
|
||||||
|
assert_eq!(&reply, b"pong");
|
||||||
|
|
||||||
|
let server_seen = match server_task.await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(e) => panic!("server task join failed: {e}"),
|
||||||
|
};
|
||||||
|
assert_eq!(&server_seen, b"ping");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_configure_client_socket_ack_timeout_overflow_rejected() {
|
||||||
|
let listener = match TcpListener::bind("127.0.0.1:0").await {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(e) if e.kind() == ErrorKind::PermissionDenied => return,
|
||||||
|
Err(e) => panic!("bind failed: {e}"),
|
||||||
|
};
|
||||||
|
let addr = match listener.local_addr() {
|
||||||
|
Ok(addr) => addr,
|
||||||
|
Err(e) => panic!("local_addr failed: {e}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let stream = match TcpStream::connect(addr).await {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) if e.kind() == ErrorKind::PermissionDenied => return,
|
||||||
|
Err(e) => panic!("connect failed: {e}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let too_large_secs = (i32::MAX as u64 / 1000) + 1;
|
||||||
|
let err = match configure_client_socket(&stream, 30, too_large_secs) {
|
||||||
|
Ok(()) => panic!("expected overflow validation error"),
|
||||||
|
Err(e) => e,
|
||||||
|
};
|
||||||
|
assert_eq!(err.kind(), ErrorKind::InvalidInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_ip() {
|
||||||
|
// IPv4 stays IPv4
|
||||||
|
let v4: SocketAddr = "192.168.1.1:8080".parse().unwrap();
|
||||||
|
assert_eq!(normalize_ip(v4), v4);
|
||||||
|
|
||||||
|
// Pure IPv6 stays IPv6
|
||||||
|
let v6: SocketAddr = "[::1]:8080".parse().unwrap();
|
||||||
|
assert_eq!(normalize_ip(v6), v6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_listen_options_default() {
|
||||||
|
let opts = ListenOptions::default();
|
||||||
|
assert!(opts.reuse_addr);
|
||||||
|
assert!(opts.reuse_port);
|
||||||
|
assert_eq!(opts.backlog, 1024);
|
||||||
|
assert_eq!(opts.client_mss, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[test]
|
||||||
|
fn test_create_listener_applies_client_mss() {
|
||||||
|
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
|
||||||
|
let options = ListenOptions {
|
||||||
|
reuse_port: false,
|
||||||
|
client_mss: Some(256),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let socket = match create_listener(addr, &options) {
|
||||||
|
Ok(socket) => socket,
|
||||||
|
Err(e) if e.kind() == ErrorKind::PermissionDenied => return,
|
||||||
|
Err(e) => panic!("create_listener failed: {e}"),
|
||||||
|
};
|
||||||
|
let mss = match socket.tcp_mss() {
|
||||||
|
Ok(mss) => mss,
|
||||||
|
Err(e) if e.kind() == ErrorKind::PermissionDenied => return,
|
||||||
|
Err(e) => panic!("tcp_mss failed: {e}"),
|
||||||
|
};
|
||||||
|
assert_eq!(mss, 256);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_chunked_send_preserves_stream_and_configured_mss() {
|
||||||
|
use std::os::fd::AsRawFd;
|
||||||
|
|
||||||
|
let options = ListenOptions {
|
||||||
|
reuse_port: false,
|
||||||
|
client_mss: Some(1400),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let socket = match create_listener("127.0.0.1:0".parse().unwrap(), &options) {
|
||||||
|
Ok(socket) => socket,
|
||||||
|
Err(e) if e.kind() == ErrorKind::PermissionDenied => return,
|
||||||
|
Err(e) => panic!("create_listener failed: {e}"),
|
||||||
|
};
|
||||||
|
let listener = TcpListener::from_std(socket.into()).unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let client = TcpStream::connect(addr).await.unwrap();
|
||||||
|
let (mut server, _) = listener.accept().await.unwrap();
|
||||||
|
|
||||||
|
let initial_response: Vec<u8> = (0..4096).map(|value| (value % 251) as u8).collect();
|
||||||
|
let bulk_payload = vec![0xA5; 8192];
|
||||||
|
let expected_len = initial_response.len() + bulk_payload.len();
|
||||||
|
let reader = tokio::spawn(async move {
|
||||||
|
let mut client = client;
|
||||||
|
let mut received = vec![0; expected_len];
|
||||||
|
client.read_exact(&mut received).await.unwrap();
|
||||||
|
received
|
||||||
|
});
|
||||||
|
|
||||||
|
let mss_before = socket2::SockRef::from(&server).tcp_mss().unwrap();
|
||||||
|
send_tcp_fragmented_fd(server.as_raw_fd(), &initial_response, 92)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
server.write_all(&bulk_payload).await.unwrap();
|
||||||
|
let mss_after = socket2::SockRef::from(&server).tcp_mss().unwrap();
|
||||||
|
|
||||||
|
let mut expected = initial_response;
|
||||||
|
expected.extend_from_slice(&bulk_payload);
|
||||||
|
assert_eq!(
|
||||||
|
reader.await.unwrap(),
|
||||||
|
expected,
|
||||||
|
"chunked send must preserve the byte stream"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
mss_after, mss_before,
|
||||||
|
"chunked send must not change the configured socket MSS"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_chunked_send_rejects_zero_fragment_size() {
|
||||||
|
let error = send_tcp_fragmented_fd(-1, b"response", 0)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(error.kind(), ErrorKind::InvalidInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_chunked_send_rejects_invalid_fd() {
|
||||||
|
let error = send_tcp_fragmented_fd(-1, b"response", 92)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(error.raw_os_error(), Some(libc::EBADF));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "manual descriptor-pressure gate"]
|
||||||
|
async fn test_chunked_send_has_no_fd_growth_after_success_and_cancellation_stress() {
|
||||||
|
use std::os::fd::AsRawFd;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
let baseline_fds = std::fs::read_dir("/proc/self/fd").unwrap().count();
|
||||||
|
|
||||||
|
let blocked_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let blocked_addr = blocked_listener.local_addr().unwrap();
|
||||||
|
let _blocked_client = TcpStream::connect(blocked_addr).await.unwrap();
|
||||||
|
let (blocked_server, _) = blocked_listener.accept().await.unwrap();
|
||||||
|
socket2::SockRef::from(&blocked_server)
|
||||||
|
.set_send_buffer_size(4 * 1024)
|
||||||
|
.unwrap();
|
||||||
|
let blocked_fd = blocked_server.as_raw_fd();
|
||||||
|
let payload = Arc::new(vec![0xA5; 1024 * 1024]);
|
||||||
|
for _ in 0..5_000 {
|
||||||
|
let payload = payload.clone();
|
||||||
|
let sender = tokio::spawn(async move {
|
||||||
|
send_tcp_fragmented_fd(blocked_fd, payload.as_slice(), 92).await
|
||||||
|
});
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
sender.abort();
|
||||||
|
let _ = sender.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let success_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let success_addr = success_listener.local_addr().unwrap();
|
||||||
|
let mut success_client = TcpStream::connect(success_addr).await.unwrap();
|
||||||
|
let (success_server, _) = success_listener.accept().await.unwrap();
|
||||||
|
let success_fd = success_server.as_raw_fd();
|
||||||
|
let reader = tokio::spawn(async move {
|
||||||
|
let mut received = vec![0_u8; 5_000];
|
||||||
|
success_client.read_exact(&mut received).await.unwrap();
|
||||||
|
received
|
||||||
|
});
|
||||||
|
for _ in 0..5_000 {
|
||||||
|
send_tcp_fragmented_fd(success_fd, &[0x5A], 92)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
assert!(reader.await.unwrap().iter().all(|byte| *byte == 0x5A));
|
||||||
|
|
||||||
|
drop(success_server);
|
||||||
|
drop(success_listener);
|
||||||
|
drop(blocked_server);
|
||||||
|
drop(_blocked_client);
|
||||||
|
drop(blocked_listener);
|
||||||
|
|
||||||
|
let final_fds = std::fs::read_dir("/proc/self/fd").unwrap().count();
|
||||||
|
assert_eq!(final_fds, baseline_fds);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user