diff --git a/src/api/config_edit.rs b/src/api/config_edit.rs index de42b95..984bb4d 100644 --- a/src/api/config_edit.rs +++ b/src/api/config_edit.rs @@ -7,16 +7,17 @@ use toml::Value as Toml; use super::ApiShared; use super::config_store::{ - EDITABLE_SECTIONS, EDITABLE_SERVER_FIELDS, compute_revision, current_revision, - is_editable_section, load_config_from_disk, save_sections_to_disk, + EDITABLE_SECTIONS, EDITABLE_SERVER_FIELDS, compute_snapshot_revision, is_editable_section, + 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 crate::config::ProxyConfig; use crate::config::hot_reload::classify_config_changes; 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 std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Arc; #[derive(Debug, Serialize)] @@ -31,9 +32,15 @@ pub(super) struct PatchConfigResponse { pub reload: Option, } -/// Shared-state wrapper around [`apply_patch_to_path`]: serializes config -/// mutations behind `mutation_lock`, then records a runtime event. The route -/// handler calls this; the core logic stays decoupled for unit tests. +struct PreparedConfigPatch { + owner_path: PathBuf, + owner_contents: String, + desired_config: Arc, + 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( patch_json: Json, expected_revision: Option, @@ -41,36 +48,29 @@ pub(super) async fn patch_config( shared: &ApiShared, ) -> Result { let _guard = shared.mutation_lock.lock().await; - if reload_request.is_some() - && let Some(reload_id) = shared.reload_control.in_progress().await - { - return Err(ApiFailure::new( - hyper::StatusCode::CONFLICT, - "reload_in_progress", - format!("Reload {} is already in progress", reload_id), - )); - } - let mut resp = apply_patch_to_path(&shared.config_path, &patch_json, expected_revision).await?; - if let Some(request) = reload_request { - let config = Arc::new(load_config_from_disk(&shared.config_path).await?); - let accepted = shared + let active_config = shared.active_runtime.load_full().config(); + let mut prepared = + prepare_patch_to_path(&shared.config_path, &patch_json, expected_revision).await?; + let resolved = resolve_reload_config(&active_config, &prepared.desired_config); + prepared.response.runtime_reload_required = resolved.runtime_changed; + prepared.response.process_restart_required = !resolved.deferred_process_fields.is_empty(); + prepared.response.deferred_process_fields = resolved.deferred_process_fields; + let reservation = if let Some(request) = reload_request.filter(|_| resolved.runtime_changed) { + Some( + shared .reload_control - .submit(config, resp.revision.clone(), request) + .reserve(prepared.response.revision.clone(), request) .await - .map_err(|error| 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", - ), - })?; - resp.reload = Some(accepted); + .map_err(reload_submit_failure)?, + ) + } else { + None + }; + write_atomic(prepared.owner_path, prepared.owner_contents).await?; + if let Some(reservation) = reservation { + prepared.response.reload = Some(reservation.enqueue(prepared.desired_config)); } + let resp = prepared.response; drop(_guard); shared .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 /// against a temp file. The route handler holds `mutation_lock` while calling this. +#[cfg(test)] pub(super) async fn apply_patch_to_path( config_path: &Path, patch_json: &Json, expected_revision: Option, ) -> Result { + 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, +) -> Result { // 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) { return Err(ApiFailure::new( 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")); } - // 3. Parse old + merged from the SAME deserialize path so the classifier - // sees only the delta this patch introduces. `ProxyConfig::load` applies - // include-expansion / legacy-compat / normalization that a bare - // `try_into` does not; mixing the two paths would make unrelated fields - // compare unequal and spuriously force `restart_required`. - let original = tokio::fs::read_to_string(config_path) - .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; + // 3. Merge against the fully expanded and normalized desired config. The + // source owner is resolved separately so included sections stay in their + // original file and unrelated source files remain byte-identical. + let old_cfg = loaded.config.clone(); + let mut merged = Toml::try_from(&old_cfg) + .map_err(|e| ApiFailure::internal(format!("failed to serialize config: {}", e)))?; deep_merge(&mut merged, &patch_toml); - let new_cfg: ProxyConfig = merged + let requested_cfg: ProxyConfig = merged .clone() .try_into() .map_err(|e| ApiFailure::bad_request(format!("config does not deserialize: {}", e)))?; - new_cfg + requested_cfg .validate() .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 deferred_process_fields = deferred_process_fields(&old_cfg, &new_cfg); - // 5. write only the touched top-level sections - let revision = save_sections_to_disk(config_path, &new_cfg, &touched).await?; - - Ok(PatchConfigResponse { - revision, - restart_required: class.restart_required, - runtime_reload_required: class.restart_required, - process_restart_required: !deferred_process_fields.is_empty(), - deferred_process_fields, - changed: class.changed, - reload: None, + Ok(PreparedConfigPatch { + owner_path, + owner_contents, + desired_config: Arc::new(new_cfg), + response: PatchConfigResponse { + revision, + restart_required: class.restart_required, + runtime_reload_required: class.restart_required, + process_restart_required: !deferred_process_fields.is_empty(), + deferred_process_fields, + 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. pub(super) async fn read_managed_config(config_path: &Path) -> Result<(Toml, String), ApiFailure> { - let original = tokio::fs::read_to_string(config_path) - .await - .map_err(|e| ApiFailure::internal(format!("failed to read config: {}", e)))?; - let parsed: Toml = toml::from_str(&original) - .map_err(|e| ApiFailure::internal(format!("failed to parse config: {}", e)))?; + let loaded = load_config_snapshot(config_path, false).await?; + let revision = compute_snapshot_revision(&loaded); + let parsed = Toml::try_from(&loaded.config) + .map_err(|error| ApiFailure::internal(format!("failed to serialize config: {error}")))?; let parsed_table = parsed .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)) } @@ -291,7 +355,8 @@ fn json_to_toml(j: &Json) -> Result { let mut table = toml::value::Table::new(); for (k, v) in map { 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)?); } @@ -319,271 +384,5 @@ fn deep_merge(base: &mut Toml, patch: &Toml) { } #[cfg(test)] -mod tests { - 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")); // 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")); - } -} +#[path = "config_edit/tests.rs"] +mod tests; diff --git a/src/api/config_edit/tests.rs b/src/api/config_edit/tests.rs new file mode 100644 index 0000000..a5e9f52 --- /dev/null +++ b/src/api/config_edit/tests.rs @@ -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")); +} diff --git a/src/api/config_store.rs b/src/api/config_store.rs index adbe56b..ff40032 100644 --- a/src/api/config_store.rs +++ b/src/api/config_store.rs @@ -1,16 +1,25 @@ -use std::collections::BTreeMap; -use std::io::Write; +use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; -use chrono::{DateTime, Utc}; use hyper::header::IF_MATCH; -use serde::Serialize; use sha2::{Digest, Sha256}; -use crate::config::{ProxyConfig, RateLimitBps}; +use crate::config::{ConfigSourceGraph, LoadedConfig, ProxyConfig}; 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)] pub(super) enum AccessSection { Users, @@ -66,17 +75,21 @@ pub(super) async fn ensure_expected_revision( } pub(super) async fn current_revision(config_path: &Path) -> Result { - 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 - .map_err(|e| ApiFailure::internal(format!("failed to read config: {}", e)))?; - Ok(compute_revision(&content)) + .map_err(|error| ApiFailure::internal(format!("failed to join config reader: {error}")))? + .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 { - 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 - .map_err(|error| format!("failed to read config: {}", error))?; - Ok(compute_revision(&content)) + .map_err(|error| format!("failed to join config reader: {error}"))? + .map(|graph| compute_source_revision(&graph)) + .map_err(|error| error.to_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()) } +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 { + 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 { + 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, + owner_path: PathBuf, + owner_contents: String, +) -> Result { + 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 { let config_path = config_path.to_path_buf(); 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" } -/// 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 { - 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 { - 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 { - 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 { - let body = match section { - AccessSection::Users => { - let rows: BTreeMap = cfg - .access - .users - .iter() - .map(|(key, value)| (key.clone(), value.clone())) - .collect(); - serialize_table_body(&rows)? - } - AccessSection::UserEnabled => { - let rows: BTreeMap = cfg - .access - .user_enabled - .iter() - .map(|(key, value)| (key.clone(), *value)) - .collect(); - serialize_table_body(&rows)? - } - AccessSection::UserAdTags => { - let rows: BTreeMap = cfg - .access - .user_ad_tags - .iter() - .map(|(key, value)| (key.clone(), value.clone())) - .collect(); - serialize_table_body(&rows)? - } - AccessSection::UserMaxTcpConns => { - let rows: BTreeMap = cfg - .access - .user_max_tcp_conns - .iter() - .map(|(key, value)| (key.clone(), *value)) - .collect(); - serialize_table_body(&rows)? - } - AccessSection::UserExpirations => { - let rows: BTreeMap> = cfg - .access - .user_expirations - .iter() - .map(|(key, value)| (key.clone(), *value)) - .collect(); - serialize_table_body(&rows)? - } - AccessSection::UserDataQuota => { - let rows: BTreeMap = cfg - .access - .user_data_quota - .iter() - .map(|(key, value)| (key.clone(), *value)) - .collect(); - serialize_table_body(&rows)? - } - AccessSection::UserRateLimits => { - let rows: BTreeMap = cfg - .access - .user_rate_limits - .iter() - .map(|(key, value)| (key.clone(), *value)) - .collect(); - serialize_rate_limit_body(&rows)? - } - AccessSection::UserMaxUniqueIps => { - let rows: BTreeMap = 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(value: &T) -> Result { - toml::to_string(value) - .map_err(|e| ApiFailure::internal(format!("failed to serialize access section: {}", e))) -} - -fn serialize_rate_limit_body(rows: &BTreeMap) -> Result { - 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 { - 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 = 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::() - ); - 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)] -mod tests { - use super::*; - - #[tokio::test] - async fn save_sections_preserves_other_tables_and_comments() { - let dir = std::env::temp_dir().join(format!("cfgtest-{}", rand::random::())); - 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::(&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::(&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::(&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 }")); - } -} +#[path = "config_store/tests.rs"] +mod tests; diff --git a/src/api/config_store/persistence.rs b/src/api/config_store/persistence.rs new file mode 100644 index 0000000..f7d14a8 --- /dev/null +++ b/src/api/config_store/persistence.rs @@ -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 { + 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 { + 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 { + 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 { + 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::(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::>(); + 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 { + let body = match section { + AccessSection::Users => { + let rows: BTreeMap = cfg + .access + .users + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect(); + serialize_table_body(&rows)? + } + AccessSection::UserEnabled => { + let rows: BTreeMap = cfg + .access + .user_enabled + .iter() + .map(|(key, value)| (key.clone(), *value)) + .collect(); + serialize_table_body(&rows)? + } + AccessSection::UserAdTags => { + let rows: BTreeMap = cfg + .access + .user_ad_tags + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect(); + serialize_table_body(&rows)? + } + AccessSection::UserMaxTcpConns => { + let rows: BTreeMap = cfg + .access + .user_max_tcp_conns + .iter() + .map(|(key, value)| (key.clone(), *value)) + .collect(); + serialize_table_body(&rows)? + } + AccessSection::UserExpirations => { + let rows: BTreeMap> = cfg + .access + .user_expirations + .iter() + .map(|(key, value)| (key.clone(), *value)) + .collect(); + serialize_table_body(&rows)? + } + AccessSection::UserDataQuota => { + let rows: BTreeMap = cfg + .access + .user_data_quota + .iter() + .map(|(key, value)| (key.clone(), *value)) + .collect(); + serialize_table_body(&rows)? + } + AccessSection::UserRateLimits => { + let rows: BTreeMap = cfg + .access + .user_rate_limits + .iter() + .map(|(key, value)| (key.clone(), *value)) + .collect(); + serialize_rate_limit_body(&rows)? + } + AccessSection::UserMaxUniqueIps => { + let rows: BTreeMap = 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(value: &T) -> Result { + toml::to_string(value) + .map_err(|e| ApiFailure::internal(format!("failed to serialize access section: {}", e))) +} + +fn serialize_rate_limit_body(rows: &BTreeMap) -> Result { + 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 { + 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 = 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::() + ); + 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 +} diff --git a/src/api/config_store/tests.rs b/src/api/config_store/tests.rs new file mode 100644 index 0000000..85f53cb --- /dev/null +++ b/src/api/config_store/tests.rs @@ -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::())); + 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::(&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::(&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::(&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 }")); +} diff --git a/src/api/reload_tests.rs b/src/api/reload_tests.rs index d4857ef..0e62141 100644 --- a/src/api/reload_tests.rs +++ b/src/api/reload_tests.rs @@ -8,7 +8,7 @@ async fn config_file() -> (tempfile::TempDir, PathBuf, String) { config.server.max_connections = 4_242; let body = toml::to_string_pretty(&config).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) } diff --git a/src/api/users.rs b/src/api/users.rs index 48acc25..39820f6 100644 --- a/src/api/users.rs +++ b/src/api/users.rs @@ -418,18 +418,8 @@ pub(super) async fn rotate_secret( cfg.access.users.insert(user.to_string(), secret.clone()); cfg.validate() .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 = - 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); 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.user_enabled.remove(user); - cfg.access.user_ad_tags.remove(user); - cfg.access.user_max_tcp_conns.remove(user); - cfg.access.user_expirations.remove(user); - cfg.access.user_data_quota.remove(user); - cfg.access.user_rate_limits.remove(user); - cfg.access.user_max_unique_ips.remove(user); + if cfg.access.user_enabled.remove(user).is_some() { + touched_sections.push(AccessSection::UserEnabled); + } + if cfg.access.user_ad_tags.remove(user).is_some() { + touched_sections.push(AccessSection::UserAdTags); + } + 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() .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 = save_access_sections_to_disk(&shared.config_path, &cfg, &touched_sections).await?; drop(_guard); diff --git a/src/config/hot_reload.rs b/src/config/hot_reload.rs index 1a4ab2c..cdd6975 100644 --- a/src/config/hot_reload.rs +++ b/src/config/hot_reload.rs @@ -16,12 +16,10 @@ //! | `general` | `telemetry` / `me_*_policy` | Applied immediately | //! | `network` | `dns_overrides` | Applied 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 //! `server.port`, `censorship.*`, `network.*`, `use_middle_proxy`) are **not** -//! applied, except for SYN limiter fields on unchanged listener endpoints; a -//! warning is emitted. +//! applied; a warning is emitted. SYN limiter rules are process-owned and are +//! reconciled only during privileged startup. //! Non-hot changes are never mixed into the runtime config snapshot. use std::collections::BTreeSet; @@ -36,9 +34,11 @@ use tracing::{error, info, warn}; use super::load::{LoadedConfig, ProxyConfig}; use crate::config::{ - CidrRateLimitKey, ListenerConfig, LogLevel, MeBindStaleMode, MeFloorMode, MeSocksKdfPolicy, - MeTelemetryLevel, MeWriterPickMode, SynLimitMode, + CidrRateLimitKey, LogLevel, MeBindStaleMode, MeFloorMode, MeSocksKdfPolicy, MeTelemetryLevel, + MeWriterPickMode, }; +#[cfg(test)] +use crate::config::{ListenerConfig, SynLimitMode}; 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_mode: crate::config::UserMaxUniqueIpsMode, pub user_max_unique_ips_window_secs: u64, - pub listener_synlimit: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ListenerSynLimitHotFields { - pub ip: IpAddr, - pub port: Option, - 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 { @@ -278,30 +262,6 @@ impl HotFields { 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_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], rhs: &[crate::config::ListenerConfig], ) -> bool { - if lhs.len() != rhs.len() { - 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 - }) + serde_json::to_value(lhs).ok() == serde_json::to_value(rhs).ok() } 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_mode = new.access.user_max_unique_ips_mode; 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() { cfg.runtime_user_auth = None; } @@ -617,26 +564,6 @@ fn overlay_hot_fields(old: &ProxyConfig, new: &ProxyConfig) -> ProxyConfig { 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). fn warn_non_hot_changes(old: &ProxyConfig, new: &ProxyConfig, non_hot_changed: bool) { 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 { info!( "config reload: desync_all_full: {} → {}", @@ -1338,6 +1258,7 @@ fn reload_config( let LoadedConfig { config: new_cfg, source_files, + source_contents: _, rendered_hash, } = loaded; let next_manifest = WatchManifest::from_source_files(&source_files); @@ -1731,7 +1652,7 @@ mod tests { } #[test] - fn listener_synlimit_extended_fields_are_hot() { + fn listener_synlimit_fields_are_process_owned() { let mut old = sample_config(); old.server.listeners.push(ListenerConfig { ip: "0.0.0.0".parse().unwrap(), @@ -1765,14 +1686,17 @@ mod tests { let applied = overlay_hot_fields(&old, &new); let listener = &applied.server.listeners[0]; assert_eq!(applied.server.port, old.server.port); - assert_eq!(listener.synlimit_seconds, 120); - assert_eq!(listener.synlimit_hitcount, 96); - assert_eq!(listener.synlimit_burst, 2); - assert_eq!(listener.synlimit_ios_seconds, 2); - assert_eq!(listener.synlimit_ios_hitcount, 18); - assert_eq!(listener.synlimit_ios_burst, 36); - assert_eq!(listener.synlimit_hashlimit_expire_ms, 90_000); - assert_eq!(listener.synlimit_hashlimit_size, 65_536); + assert_eq!(listener.synlimit_seconds, old.server.listeners[0].synlimit_seconds); + assert_eq!( + listener.synlimit_hitcount, + old.server.listeners[0].synlimit_hitcount + ); + assert_eq!(listener.synlimit_burst, old.server.listeners[0].synlimit_burst); + assert_eq!( + listener.synlimit_hashlimit_size, + old.server.listeners[0].synlimit_hashlimit_size + ); + assert!(classify_config_changes(&old, &new).restart_required); } #[test] diff --git a/src/config/load.rs b/src/config/load.rs index aa97b58..71dd39e 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -1,6 +1,6 @@ #![allow(deprecated)] -use std::collections::{BTreeSet, HashMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::net::{IpAddr, SocketAddr}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -34,8 +34,8 @@ use self::normalize::{ pub(crate) use self::runtime_auth::UserAuthSnapshot; use self::strict_keys::handle_unknown_config_keys; use self::validation::{ - normalize_upstream_family_policy, validate_logging_config, validate_network_cfg, - validate_upstreams, + normalize_upstream_family_policy, validate_listener_runtime_profiles, + validate_logging_config, validate_network_cfg, validate_upstreams, }; 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; #[derive(Debug, Clone)] +/// Validated config plus the exact recursive source snapshot used to build it. pub(crate) struct LoadedConfig { + /// Validated and normalized effective configuration. pub(crate) config: ProxyConfig, + /// Canonical paths participating in the recursive include graph. pub(crate) source_files: Vec, + /// Raw source bytes keyed by canonical source path. + pub(crate) source_contents: BTreeMap, + /// Legacy hash of the include-expanded rendered snapshot. 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, + /// Include-expanded TOML used for typed deserialization. + pub(crate) rendered: String, +} + /// Main runtime configuration loaded from TOML. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ProxyConfig { @@ -120,14 +135,64 @@ impl ProxyConfig { 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>(path: P) -> Result { + 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>( + path: P, + source_overrides: &BTreeMap, + ) -> Result { + 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>(path: P) -> Result { + 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>( + path: P, + source_overrides: &BTreeMap, + ) -> Result { let path = path.as_ref(); - let content = - std::fs::read_to_string(path).map_err(|e| ProxyError::Config(e.to_string()))?; + let normalized_path = normalize_config_path(path); + 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 mut source_files = BTreeSet::new(); - source_files.insert(normalize_config_path(path)); - let processed = preprocess_includes(&content, base_dir, 0, &mut source_files)?; + source_files.insert(normalized_path.clone()); + 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 { + let ConfigSourceGraph { + source_contents, + rendered: processed, + } = graph; + let source_files: BTreeSet = source_contents.keys().cloned().collect(); let parsed_toml: toml::Value = toml::from_str(&processed).map_err(|e| ProxyError::Config(e.to_string()))?; @@ -1349,6 +1414,7 @@ impl ProxyConfig { listener.announce = Some(ip.to_string()); } } + validate_listener_runtime_profiles(&config)?; // Migration: show_link (top-level) → general.links.show. if !config.show_link.is_empty() && config.general.links.show.is_empty() { @@ -1387,6 +1453,7 @@ impl ProxyConfig { Ok(LoadedConfig { config, source_files: source_files.into_iter().collect(), + source_contents, rendered_hash: hash_rendered_snapshot(&processed), }) } @@ -1458,6 +1525,7 @@ impl ProxyConfig { } crate::network::dns_overrides::validate_entries(&self.network.dns_overrides)?; + validate_listener_runtime_profiles(self)?; Ok(()) } diff --git a/src/config/load/includes.rs b/src/config/load/includes.rs index 222887b..89780b2 100644 --- a/src/config/load/includes.rs +++ b/src/config/load/includes.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::hash::{DefaultHasher, Hash, Hasher}; use std::path::{Path, PathBuf}; @@ -27,6 +27,8 @@ pub(super) fn preprocess_includes( base_dir: &Path, depth: u8, source_files: &mut BTreeSet, + source_contents: &mut BTreeMap, + source_overrides: &BTreeMap, ) -> Result { if depth > 10 { return Err(ProxyError::Config("Include depth > 10".into())); @@ -39,15 +41,23 @@ pub(super) fn preprocess_includes( if let Some(rest) = rest.strip_prefix('=') { let path_str = rest.trim().trim_matches('"'); let resolved = base_dir.join(path_str); - source_files.insert(normalize_config_path(&resolved)); - let included = std::fs::read_to_string(&resolved) + let normalized = normalize_config_path(&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()))?; + source_contents.insert(normalized, included.clone()); let included_dir = resolved.parent().unwrap_or(base_dir); output.push_str(&preprocess_includes( &included, included_dir, depth + 1, source_files, + source_contents, + source_overrides, )?); output.push('\n'); continue; diff --git a/src/config/load/validation.rs b/src/config/load/validation.rs index b95f73b..581d82c 100644 --- a/src/config/load/validation.rs +++ b/src/config/load/validation.rs @@ -3,7 +3,9 @@ use tracing::warn; 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; pub(super) fn validate_network_cfg(net: &mut NetworkConfig) -> Result<()> { @@ -90,6 +92,77 @@ pub(super) fn validate_upstreams(config: &ProxyConfig) -> Result<()> { 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) { for (idx, upstream) in config.upstreams.iter_mut().enumerate() { if matches!(upstream.ipv4, Some(false)) && upstream.prefer == Some(4) { diff --git a/src/config/mod.rs b/src/config/mod.rs index dcb3bec..b50cbaa 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -5,5 +5,6 @@ pub mod hot_reload; mod load; mod types; +pub(crate) use load::{ConfigSourceGraph, LoadedConfig}; pub use load::ProxyConfig; pub use types::*; diff --git a/src/config/tests/load_basic_tests.rs b/src/config/tests/load_basic_tests.rs index 6810895..b99d71d 100644 --- a/src/config/tests/load_basic_tests.rs +++ b/src/config/tests/load_basic_tests.rs @@ -62,6 +62,7 @@ fn synlimit_synfix_defaults_are_loaded_for_listener() { assert_eq!(listener.synlimit_hashlimit_size, 32_768); } +#[cfg(target_os = "freebsd")] #[test] fn synlimit_pf_mode_is_loaded_for_listener() { 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); } +#[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] fn synlimit_synfix_zero_values_are_rejected() { for (field, expected) in [ @@ -1742,11 +1767,12 @@ fn client_mss_presets_and_listener_override_are_resolved() { let _ = std::fs::remove_file(path); } +#[cfg(target_os = "linux")] #[test] fn client_mss_custom_value_is_accepted() { let toml = r#" [server] - client_mss = "4096" + client_mss = "92" client_mss_bulk = "1400" [censorship] @@ -1760,11 +1786,83 @@ fn client_mss_custom_value_is_accepted() { std::fs::write(&path, toml).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))); 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] fn client_mss_out_of_range_is_rejected() { for value in ["87", "4097"] { diff --git a/src/config/types.rs b/src/config/types.rs index d72ce03..8594853 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -1623,11 +1623,11 @@ pub struct ServerConfig { #[serde(default)] pub client_mss: Option, - /// Client-facing TCP MSS for bulk traffic when initial-response fragmentation - /// is enabled on Linux. The listener uses this MSS from connection setup; - /// `client_mss` becomes the fragment size for the authenticated FakeTLS - /// response. Empty/omitted keeps `client_mss` connection-wide. Uses the same - /// preset/integer grammar as `client_mss`. + /// Experimental Linux-only bulk MSS used with best-effort userspace + /// chunking of the authenticated FakeTLS response. TCP offloads, loss, and + /// retransmission may coalesce write boundaries. Empty or omitted keeps + /// `client_mss` connection-wide. Uses the same preset/integer grammar as + /// `client_mss`. #[serde(default)] pub client_mss_bulk: Option, diff --git a/src/maestro/listeners.rs b/src/maestro/listeners.rs index 7649f26..3540ce5 100644 --- a/src/maestro/listeners.rs +++ b/src/maestro/listeners.rs @@ -152,8 +152,7 @@ pub(crate) async fn bind_listeners( %addr, fragment_size, bulk_mss = client_mss, - segment_multiplier = mss_segment_multiplier(fragment_size), - "Initial FakeTLS response fragmentation configured" + "Initial FakeTLS response best-effort chunking configured" ); } let listener_proxy_protocol = listener_conf @@ -518,7 +517,7 @@ mod tests { } #[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!( tcp_mss_runtime_profile(Some(92), Some(1400)), (Some(1400), Some(92)) diff --git a/src/maestro/mod.rs b/src/maestro/mod.rs index e9cc100..e47fc90 100644 --- a/src/maestro/mod.rs +++ b/src/maestro/mod.rs @@ -33,7 +33,7 @@ use tracing::{error, info, warn}; use tracing_subscriber::{EnvFilter, fmt, prelude::*, reload as tracing_reload}; use crate::api; -use crate::config::{LogLevel, ProxyConfig}; +use crate::config::{LogLevel, ProxyConfig, SynLimitMode}; use crate::conntrack_control; use crate::crypto::SecureRandom; use crate::ip_tracker::UserIpTracker; @@ -96,6 +96,7 @@ pub async fn run() -> std::result::Result<(), Box> { // 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. async fn run_telemt_core( + privilege_drop_requested: bool, drop_after_bind: impl FnOnce(), ) -> std::result::Result<(), Box> { let process_started_at = Instant::now(); @@ -279,6 +280,7 @@ async fn run_telemt_core( eprintln!("[telemt] Invalid config: {}", e); std::process::exit(1); } + validate_synlimit_privilege_drop(&config, privilege_drop_requested)?; if let Some(p) = data_path { config.general.data_path = Some(p); @@ -987,13 +989,13 @@ async fn run_telemt_core( 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. 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()) .await; @@ -1012,7 +1014,6 @@ async fn run_telemt_core( process_started_at, active_runtime, quota_state_path, - synlimit_controller, reload_supervisor, ) .await; @@ -1020,6 +1021,24 @@ async fn run_telemt_core( 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)] async fn run_inner( daemon_opts: DaemonOptions, @@ -1040,7 +1059,7 @@ async fn run_inner( let user = daemon_opts.user.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 let Err(e) = drop_privileges(user.as_deref(), group.as_deref(), _pid_file.as_ref()) { error!(error = %e, "Failed to drop privileges"); @@ -1053,5 +1072,46 @@ async fn run_inner( #[cfg(not(unix))] async fn run_inner() -> std::result::Result<(), Box> { - 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()); + } } diff --git a/src/maestro/reload.rs b/src/maestro/reload.rs index 375bcb5..e0e6a60 100644 --- a/src/maestro/reload.rs +++ b/src/maestro/reload.rs @@ -4,7 +4,8 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; -use tokio::sync::{Mutex, mpsc}; +use parking_lot::Mutex; +use tokio::sync::mpsc; use crate::config::ProxyConfig; @@ -188,6 +189,14 @@ pub(crate) struct ReloadCommandReceiver { command_rx: mpsc::Receiver, } +/// Capacity and status reservation held while a config mutation is committed. +pub(crate) struct ReloadReservation { + permit: Option>, + status: ReloadStatus, + request: ReloadRequest, + status_store: Arc, +} + struct ReloadStatusState { next_reload_id: u64, active_reload_id: Option, @@ -232,95 +241,121 @@ impl ReloadControl { config_revision: String, request: ReloadRequest, ) -> Result { + 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 { + let permit = self + .command_tx + .clone() + .try_reserve_owned() + .map_err(|_| ReloadSubmitError::MaestroUnavailable)?; let target_generation = self .active_generation .load(Ordering::Acquire) .saturating_add(1); let status = self .status_store - .reserve(target_generation, config_revision, request.clone()) - .await?; - let command = ReloadCommand { - reload_id: status.reload_id, - target_generation, - config, - config_revision: status.config_revision.clone(), + .reserve(target_generation, config_revision, request.clone())?; + Ok(ReloadReservation { + permit: Some(permit), + status, request, - }; - 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, + status_store: self.status_store.clone(), }) } /// Returns a retained reload status by identifier. pub(crate) async fn status(&self, reload_id: u64) -> Option { - self.status_store.get(reload_id).await + self.status_store.get(reload_id) } /// Returns the identifier of the currently active reload. pub(crate) async fn in_progress(&self) -> Option { - 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. 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. 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. pub(crate) async fn set_deferred_fields(&self, reload_id: u64, fields: Vec) { self.status_store - .update(reload_id, |status| status.deferred_fields = fields) - .await; + .update(reload_id, |status| status.deferred_fields = fields); } /// Commits the active generation and completes the matching reload. pub(crate) async fn succeed(&self, reload_id: u64, generation: u64) { self.status_store - .finish_success(reload_id, generation, &self.active_generation) - .await; + .finish_success(reload_id, generation, &self.active_generation); } /// Marks the matching reload as failed. pub(crate) async fn fail(&self, reload_id: u64, error: impl Into) { self.status_store - .finish(reload_id, ReloadPhase::Failed, Some(error.into())) - .await; + .finish(reload_id, ReloadPhase::Failed, Some(error.into())); } /// Marks the matching reload as rolled back. pub(crate) async fn rolled_back(&self, reload_id: u64, error: impl Into) { self.status_store - .finish(reload_id, ReloadPhase::RolledBack, Some(error.into())) - .await; + .finish(reload_id, ReloadPhase::RolledBack, Some(error.into())); } /// Appends a non-fatal warning to the matching reload status. pub(crate) async fn add_warning(&self, reload_id: u64, warning: impl Into) { let warning = warning.into(); self.status_store - .update(reload_id, |status| status.warnings.push(warning)) - .await; + .update(reload_id, |status| status.warnings.push(warning)); + } +} + +impl ReloadReservation { + /// Enqueues the already reserved command without another fallible step. + pub(crate) fn enqueue(mut self, config: Arc) -> 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 { - async fn reserve( + fn reserve( &self, target_generation: u64, config_revision: String, request: ReloadRequest, ) -> Result { - let mut state = self.state.lock().await; + let mut state = self.state.lock(); if !state.accepting_commands { return Err(ReloadSubmitError::MaestroUnavailable); } @@ -369,29 +404,27 @@ impl ReloadStatusStore { Ok(status) } - async fn get(&self, reload_id: u64) -> Option { + fn get(&self, reload_id: u64) -> Option { self.state .lock() - .await .statuses .iter() .find(|status| status.reload_id == reload_id) .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| { status.state = phase; if status.started_at_epoch_secs.is_none() && phase != ReloadPhase::Accepted { status.started_at_epoch_secs = Some(now_epoch_secs()); } - }) - .await; + }); } - async fn finish(&self, reload_id: u64, phase: ReloadPhase, error: Option) { + fn finish(&self, reload_id: u64, phase: ReloadPhase, error: Option) { debug_assert!(phase.is_terminal()); - let mut state = self.state.lock().await; + let mut state = self.state.lock(); if let Some(status) = state .statuses .iter_mut() @@ -406,8 +439,8 @@ impl ReloadStatusStore { } } - async fn finish_success(&self, reload_id: u64, generation: u64, active_generation: &AtomicU64) { - let mut state = self.state.lock().await; + fn finish_success(&self, reload_id: u64, generation: u64, active_generation: &AtomicU64) { + let mut state = self.state.lock(); if state.active_reload_id != Some(reload_id) { return; } @@ -425,8 +458,8 @@ impl ReloadStatusStore { state.active_reload_id = None; } - async fn update(&self, reload_id: u64, update: impl FnOnce(&mut ReloadStatus)) { - let mut state = self.state.lock().await; + fn update(&self, reload_id: u64, update: impl FnOnce(&mut ReloadStatus)) { + let mut state = self.state.lock(); if let Some(status) = state .statuses .iter_mut() @@ -435,6 +468,20 @@ impl ReloadStatusStore { 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 { diff --git a/src/maestro/reload_supervisor.rs b/src/maestro/reload_supervisor.rs index 8344218..8db02eb 100644 --- a/src/maestro/reload_supervisor.rs +++ b/src/maestro/reload_supervisor.rs @@ -14,7 +14,7 @@ use super::reload::{ ReloadCommand, ReloadCommandReceiver, ReloadControl, ReloadFailurePolicy, ReloadMode, 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; pub(crate) struct ReloadSupervisor { @@ -147,14 +147,17 @@ impl ReloadSupervisor { .mark_phase(command.reload_id, ReloadPhase::Preparing) .await; 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 - .set_deferred_fields(command.reload_id, deferred) + .set_deferred_fields( + command.reload_id, + resolved.deferred_process_fields.clone(), + ) .await; let prepared = match prepare_runtime( command.target_generation, - command.config.as_ref().clone(), + resolved.effective, &self.config_path, self.quota_store.clone(), self.runtime_log_filter.clone(), diff --git a/src/maestro/reload_tests.rs b/src/maestro/reload_tests.rs index f302214..53cb11c 100644 --- a/src/maestro/reload_tests.rs +++ b/src/maestro/reload_tests.rs @@ -220,12 +220,51 @@ async fn closed_command_channel_marks_reload_failed_and_releases_slot() { assert_eq!(result, Err(ReloadSubmitError::MaestroUnavailable)); assert_eq!(control.in_progress().await, None); - let status = control.status(1).await.unwrap(); - assert_eq!(status.state, ReloadPhase::Failed); - assert_eq!( - status.error.as_deref(), - Some("maestro command channel is closed") - ); + assert!(control.status(1).await.is_none()); +} + +#[tokio::test] +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] diff --git a/src/maestro/runtime_build.rs b/src/maestro/runtime_build.rs index 1cf7e5b..637c107 100644 --- a/src/maestro/runtime_build.rs +++ b/src/maestro/runtime_build.rs @@ -5,7 +5,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tokio::sync::{RwLock, Semaphore, watch}; -use crate::config::ProxyConfig; +use crate::config::{ProxyConfig, ServerConfig}; use crate::crypto::SecureRandom; use crate::ip_tracker::UserIpTracker; 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 } -pub(crate) fn deferred_process_fields(old: &ProxyConfig, new: &ProxyConfig) -> Vec { +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, + /// 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(); - if old.server.port != new.server.port - || old.server.proxy_protocol != new.server.proxy_protocol - || old.server.listen_backlog != new.server.listen_backlog - || serde_json::to_value(&old.server.listeners).ok() - != serde_json::to_value(&new.server.listeners).ok() + let listener_identity_matches = listeners_have_same_bind_identity(&old.server, &desired.server); + let listener_process_fields_changed = !listener_identity_matches + || !listener_process_fields_equal(&old.server, &desired.server); + if old.server.port != desired.server.port + || 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()); + 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 - || old.server.listen_unix_sock_perm != new.server.listen_unix_sock_perm + if old.server.listen_unix_sock != desired.server.listen_unix_sock + || old.server.listen_unix_sock_perm != desired.server.listen_unix_sock_perm { 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 - || old.server.api.enabled != new.server.api.enabled + if old.server.api.listen != desired.server.api.listen + || old.server.api.enabled != desired.server.api.enabled { 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 - || old.server.metrics_port != new.server.metrics_port + if old.server.api.runtime_edge_events_capacity + != 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()); + 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()); + 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()); + 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()); + 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()); + 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 { + 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)] -mod tests { - 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 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)); - } -} +#[path = "runtime_build_tests.rs"] +mod tests; diff --git a/src/maestro/runtime_build_tests.rs b/src/maestro/runtime_build_tests.rs new file mode 100644 index 0000000..142d864 --- /dev/null +++ b/src/maestro/runtime_build_tests.rs @@ -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)); +} diff --git a/src/maestro/shutdown.rs b/src/maestro/shutdown.rs index 1688bf4..33d94d3 100644 --- a/src/maestro/shutdown.rs +++ b/src/maestro/shutdown.rs @@ -51,7 +51,6 @@ pub(crate) async fn wait_for_shutdown( process_started_at: Instant, active_runtime: Arc>, quota_state_path: PathBuf, - synlimit_controller: synlimit_control::SynlimitController, reload_supervisor: ReloadSupervisorHandle, ) { let signal = wait_for_shutdown_signal().await; @@ -60,7 +59,6 @@ pub(crate) async fn wait_for_shutdown( process_started_at, active_runtime, quota_state_path, - synlimit_controller, reload_supervisor, ) .await; @@ -92,7 +90,6 @@ async fn perform_shutdown( process_started_at: Instant, active_runtime: Arc>, quota_state_path: PathBuf, - synlimit_controller: synlimit_control::SynlimitController, reload_supervisor: ReloadSupervisorHandle, ) { 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 { warn!(error = %error, "Failed to clear SYN limiter rules during shutdown"); } diff --git a/src/proxy/handshake.rs b/src/proxy/handshake.rs index 134cb80..1debee4 100644 --- a/src/proxy/handshake.rs +++ b/src/proxy/handshake.rs @@ -69,7 +69,7 @@ pub(crate) struct 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")] pub(crate) fn tcp(fd: std::os::unix::io::RawFd, fragment_size: Option) -> Self { Self { @@ -980,7 +980,7 @@ where .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( handshake: &[u8], reader: R, @@ -2023,7 +2023,8 @@ pub fn generate_tg_nonce( 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_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); } @@ -2064,7 +2065,8 @@ pub fn encrypt_tg_nonce_with_ciphers(nonce: &[u8; HANDSHAKE_LEN]) -> (Vec, A let dec_iv = u128::from_be_bytes(dec_iv_arr); 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(); result.extend_from_slice(&encrypted_full[PROTO_TAG_POS..]); diff --git a/src/synlimit_control/mod.rs b/src/synlimit_control/mod.rs index 91fa5d6..554f14c 100644 --- a/src/synlimit_control/mod.rs +++ b/src/synlimit_control/mod.rs @@ -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 crate::config::ProxyConfig; -use crate::maestro::generation::RuntimeWatchState; mod command; mod iptables; @@ -18,228 +15,91 @@ use self::model::{SynLimitNamespace, synlimit_namespace, synlimit_targets}; static ACTIVE_SYNLIMIT_NAMESPACE: Mutex> = Mutex::new(None); -/// Process-owned lifecycle handle for the SYN limiter reconciler. -pub(crate) struct SynlimitController { - 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>, -) -> 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( - mut runtime_watch_rx: watch::Receiver>, - shutdown: CancellationToken, - mut on_config: F, -) where - F: FnMut(u64, Arc) -> Fut, - Fut: std::future::Future, -{ - 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>, - previous_generation_id: u64, - shutdown: &CancellationToken, -) -> Option { - 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) { +/// Installs the complete startup SYN-limiter ruleset before accept loops start. +pub(crate) async fn reconcile_synlimit_rules(cfg: &ProxyConfig) -> Result<(), String> { 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() { - return; + return Ok(()); } - let Some(namespace) = namespace else { - return; - }; if !has_firewall_privileges() { - warn!("SYN limiter configured but firewall privileges are not available; rules not applied"); - return; + return Err( + "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 { - Ok(true) => { - warn!("Removed stale SYN limiter rules left by a previous run before reconcile"); + let apply_result = async { + if targets.has_iptables_targets() { + iptables::apply_synlimit_rules(&targets, &namespace).await?; } - Ok(false) => {} - Err(error) => { - warn!(error = %error, "Failed to clear stale SYN limiter rules before reconcile"); + if targets.has_nft_targets() { + nftables::apply_synlimit_rules(&targets, &namespace).await?; } + 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) = iptables::apply_synlimit_rules(&targets, &namespace).await { - warn!(error = %error, "Failed to apply iptables SYN limiter rules"); - } - } - 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"); - } + if let Err(error) = set_active_synlimit_namespace(namespace.clone()) { + return match clear_synlimit_rules_for_namespace(&namespace).await { + Ok(_) => Err(error), + Err(cleanup_error) => Err(format!( + "{error}; candidate cleanup failed: {cleanup_error}" + )), + }; } + Ok(()) } +/// Removes the ruleset installed by the current process, if any. pub(crate) async fn clear_synlimit_rules_all_backends() -> Result { - let Some(namespace) = take_active_synlimit_namespace() else { + let Some(namespace) = active_synlimit_namespace()? else { 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 { 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 removed = false; match nftables::clear_rules_all_families(namespace).await { - Ok(value) => { - removed |= value; - } - Err(error) => { - errors.push(error); - } + Ok(value) => removed |= value, + Err(error) => errors.push(error), } match iptables::clear_rules_for_binary("iptables", namespace).await { - Ok(value) => { - removed |= value; - } - Err(error) => { - errors.push(error); - } + Ok(value) => removed |= value, + Err(error) => errors.push(error), } match iptables::clear_rules_for_binary("ip6tables", namespace).await { - Ok(value) => { - removed |= value; - } - Err(error) => { - errors.push(error); - } + Ok(value) => removed |= value, + Err(error) => errors.push(error), } match pf::clear_rules(namespace).await { - Ok(value) => { - removed |= value; - } - Err(error) => { - errors.push(error); - } + Ok(value) => removed |= value, + Err(error) => errors.push(error), } 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) -> Option { +fn set_active_synlimit_namespace(next: SynLimitNamespace) -> Result<(), String> { match ACTIVE_SYNLIMIT_NAMESPACE.lock() { Ok(mut active) => { - if *active == next { - None - } else { - std::mem::replace(&mut *active, next) + if active.is_some() { + return Err("SYN limiter namespace is already active".to_string()); } + *active = Some(next); + Ok(()) } - Err(error) => { - warn!(error = %error, "Failed to update active SYN limiter namespace"); - None - } + Err(error) => Err(format!( + "failed to update active SYN limiter namespace: {error}" + )), } } -fn take_active_synlimit_namespace() -> Option { +fn active_synlimit_namespace() -> Result, String> { match ACTIVE_SYNLIMIT_NAMESPACE.lock() { - Ok(mut active) => active.take(), - Err(error) => { - warn!(error = %error, "Failed to read active SYN limiter namespace"); - None - } + Ok(active) => Ok(active.clone()), + Err(error) => Err(format!( + "failed to read active SYN limiter namespace: {error}" + )), } } -#[cfg(test)] -mod tests { - use super::*; - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::time::Duration; - use tokio::sync::{Notify, mpsc}; - - fn runtime_state( - generation_id: u64, - max_connections: u32, - ) -> ( - RuntimeWatchState, - watch::Sender>, - watch::Sender, - ) { - 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); +fn clear_active_synlimit_namespace(expected: &SynLimitNamespace) -> Result<(), String> { + match ACTIVE_SYNLIMIT_NAMESPACE.lock() { + Ok(mut active) => { + if active.as_ref() == Some(expected) { + *active = None; } - }) - .await; - - assert_eq!(calls.load(Ordering::Acquire), 0); + Ok(()) + } + Err(error) => Err(format!( + "failed to update active SYN limiter namespace: {error}" + )), } } diff --git a/src/synlimit_control/pf.rs b/src/synlimit_control/pf.rs index cfcc4d7..fc43216 100644 --- a/src/synlimit_control/pf.rs +++ b/src/synlimit_control/pf.rs @@ -5,6 +5,21 @@ use super::model::{SynLimitNamespace, SynLimitRule, SynLimitTargets}; 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( targets: &SynLimitTargets, namespace: &SynLimitNamespace, @@ -31,26 +46,23 @@ fn is_pf_anchor_hook_line(line: &str) -> bool { fn pf_synlimit_script(targets: &SynLimitTargets) -> String { let mut script = String::new(); 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 { - push_pf_rules(&mut script, target); + push_pf_rules(&mut script, PfFamily::Inet6, target); } 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); 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, rate = target.generic_hitcount, 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) -> String { @@ -84,24 +96,15 @@ mod tests { use crate::synlimit_control::model::test_rule; #[test] - fn pf_script_uses_rate_limited_pass_before_reject() { + fn pf_script_uses_native_rate_limited_pass() { let mut targets = SynLimitTargets::default(); targets.pf_v4 = vec![test_rule(Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7))), 443)]; let script = pf_synlimit_script(&targets); 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( - "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); + assert!(!script.contains("return-rst")); } #[test] @@ -111,8 +114,8 @@ mod tests { targets.pf_v6 = vec![test_rule(Some(IpAddr::V6(Ipv6Addr::LOCALHOST)), 8443)]; let script = pf_synlimit_script(&targets); - assert!(script.contains("to any port 443")); - assert!(script.contains("to ::1 port 8443")); + assert!(script.contains("pass in quick inet proto tcp from any to any port 443")); + assert!(script.contains("pass in quick inet6 proto tcp from any to ::1 port 8443")); } #[test] diff --git a/src/transport/socket.rs b/src/transport/socket.rs index 839fcf9..ce084ef 100644 --- a/src/transport/socket.rs +++ b/src/transport/socket.rs @@ -11,6 +11,11 @@ use std::time::Duration; use tokio::net::TcpStream; 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; /// 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(()) } -#[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::() 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::(), - 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 #[allow(dead_code)] pub fn create_outgoing_socket(addr: SocketAddr) -> Result { @@ -565,277 +477,5 @@ fn listening_inodes_for_port(addr: SocketAddr) -> HashSet { } #[cfg(test)] -mod tests { - 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 = (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)); - } -} +#[path = "socket/tests.rs"] +mod tests; diff --git a/src/transport/socket/fragmented_send.rs b/src/transport/socket/fragmented_send.rs new file mode 100644 index 0000000..623efd0 --- /dev/null +++ b/src/transport/socket/fragmented_send.rs @@ -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::() 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::(), + 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(()) +} diff --git a/src/transport/socket/tests.rs b/src/transport/socket/tests.rs new file mode 100644 index 0000000..843712b --- /dev/null +++ b/src/transport/socket/tests.rs @@ -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 = (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); +}