From 1628a7d8221e69dd84588dc7e988d65c965f295e Mon Sep 17 00:00:00 2001 From: Mirotin Artem Date: Tue, 9 Jun 2026 11:43:39 +0300 Subject: [PATCH 1/5] feat(api): generic config section writer + array-table bounds --- src/api/config_store.rs | 123 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 120 insertions(+), 3 deletions(-) diff --git a/src/api/config_store.rs b/src/api/config_store.rs index 6be4040..233fb17 100644 --- a/src/api/config_store.rs +++ b/src/api/config_store.rs @@ -97,6 +97,81 @@ pub(super) async fn save_config_to_disk( Ok(compute_revision(&serialized)) } +/// Top-level config tables that may be edited via the config API. +/// +/// Intentionally excluded (defense-in-depth, enforces the spec's per-node +/// identity invariant at the Telemt layer too): +/// +/// - `access` : owned by the users API. +/// - `server` : carries per-node identity (`port`, `api`/`api_bind`, listeners). +/// - `network` : carries per-node identity (`ipv4`/`ipv6`). +/// +/// A future field-level allowlist can re-admit specific safe fields +/// (e.g. `network.dns_overrides`) without opening the whole section. +pub(super) const EDITABLE_SECTIONS: &[&str] = &[ + "general", + "timeouts", + "censorship", + "upstreams", + "show_link", + "dc_overrides", +]; + +/// 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); + } + + let body = toml::to_string(table) + .map_err(|e| ApiFailure::internal(format!("failed to serialize {}: {}", section, e)))?; + let mut out = format!("[{}]\n", section); + out.push_str(&body); + if !out.ends_with('\n') { + out.push('\n'); + } + Ok(out) +} + pub(super) async fn save_access_sections_to_disk( config_path: &Path, cfg: &ProxyConfig, @@ -273,17 +348,20 @@ fn upsert_toml_table(source: &str, table_name: &str, replacement: &str) -> Strin } fn find_toml_table_bounds(source: &str, table_name: &str) -> Option<(usize, usize)> { - let target = format!("[{}]", table_name); + let single = format!("[{}]", table_name); + let array = format!("[[{}]]", table_name); let mut offset = 0usize; let mut start = None; for line in source.split_inclusive('\n') { let trimmed = line.trim(); if let Some(start_offset) = start { - if trimmed.starts_with('[') { + let is_same_array = trimmed == array; + let is_new_header = trimmed.starts_with('['); + if is_new_header && !is_same_array { return Some((start_offset, offset)); } - } else if trimmed == target { + } else if trimmed == single || trimmed == array { start = Some(offset); } offset = offset.saturating_add(line.len()); @@ -336,6 +414,45 @@ fn write_atomic_sync(path: &Path, contents: &str) -> std::io::Result<()> { 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 render_user_rate_limits_section() { let mut cfg = ProxyConfig::default(); From e39aaeb5c5da716329b8b7fc4d28df2ca2cfcbc1 Mon Sep 17 00:00:00 2001 From: Mirotin Artem Date: Tue, 9 Jun 2026 11:43:53 +0300 Subject: [PATCH 2/5] feat(config): classify_config_changes (hot vs restart) via overlay_hot_fields --- src/config/hot_reload.rs | 77 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/src/config/hot_reload.rs b/src/config/hot_reload.rs index c869e74..c4a4f44 100644 --- a/src/config/hot_reload.rs +++ b/src/config/hot_reload.rs @@ -1489,6 +1489,48 @@ pub fn spawn_config_watcher( (config_rx, log_rx) } +// ── Change classification ───────────────────────────────────────────────────── + +/// Which top-level config sections changed and whether any require a restart. +#[derive(Debug, Default, Clone, serde::Serialize)] +pub struct ChangeClassification { + pub changed: Vec, + pub restart_required: bool, +} + +/// Classify old->new using Telemt's OWN reload rule: overlay the hot fields and +/// see if anything non-hot remains different. This guarantees `restart_required` +/// matches actual runtime behavior and never drifts as new fields are added. +pub fn classify_config_changes(old: &ProxyConfig, new: &ProxyConfig) -> ChangeClassification { + let applied = overlay_hot_fields(old, new); + let restart_required = !config_equal(&applied, new); + ChangeClassification { + changed: changed_sections(old, new), + restart_required, + } +} + +/// Top-level config sections whose canonical serialized form differs between +/// old and new. Uses the same serialize+canonicalize path as `config_equal`. +fn changed_sections(old: &ProxyConfig, new: &ProxyConfig) -> Vec { + let mut lhs = serde_json::to_value(old).unwrap_or(serde_json::Value::Null); + let mut rhs = serde_json::to_value(new).unwrap_or(serde_json::Value::Null); + canonicalize_json(&mut lhs); + canonicalize_json(&mut rhs); + + let mut out = Vec::new(); + if let (Some(lo), Some(ro)) = (lhs.as_object(), rhs.as_object()) { + let mut keys: std::collections::BTreeSet<&String> = lo.keys().collect(); + keys.extend(ro.keys()); + for key in keys { + if lo.get(key) != ro.get(key) { + out.push(key.clone()); + } + } + } + out +} + #[cfg(test)] mod tests { use super::*; @@ -1661,6 +1703,41 @@ mod tests { let _ = std::fs::remove_file(path); } + #[test] + fn classify_sni_change_requires_restart() { + // censorship.* is not in overlay_hot_fields -> restart. + let old = ProxyConfig::default(); + let mut new = ProxyConfig::default(); + new.censorship.tls_domain = "front.example".to_string(); + + let class = classify_config_changes(&old, &new); + assert!(class.restart_required); + assert!(class.changed.iter().any(|c| c == "censorship")); + } + + #[test] + fn classify_dns_overrides_change_is_hot() { + // network.dns_overrides IS in overlay_hot_fields -> no restart. + let old = ProxyConfig::default(); + let mut new = ProxyConfig::default(); + new.network.dns_overrides.push("1.1.1.1".to_string()); + + let class = classify_config_changes(&old, &new); + assert!(!class.restart_required); + assert!(class.changed.iter().any(|c| c == "network")); + } + + #[test] + fn classify_timeouts_change_requires_restart() { + // timeouts.* is NOT in overlay_hot_fields -> restart. + let old = ProxyConfig::default(); + let mut new = ProxyConfig::default(); + new.timeouts.client_handshake = old.timeouts.client_handshake + 1; + + let class = classify_config_changes(&old, &new); + assert!(class.restart_required); + } + #[test] fn reload_recovers_after_parse_error_on_next_attempt() { let initial_tag = "cccccccccccccccccccccccccccccccc"; From d7e16f5b265e61f5d5d547ef73c8eb580518f4aa Mon Sep 17 00:00:00 2001 From: Mirotin Artem Date: Tue, 9 Jun 2026 11:44:07 +0300 Subject: [PATCH 3/5] feat(api): config-edit endpoints PATCH/GET /v1/config --- Cargo.lock | 1 + Cargo.toml | 1 + src/api/config_edit.rs | 308 +++++++++++++++++++++++++++++++++++++++++ src/api/mod.rs | 34 +++++ 4 files changed, 344 insertions(+) create mode 100644 src/api/config_edit.rs diff --git a/Cargo.lock b/Cargo.lock index aad4bbc..a7e52c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2834,6 +2834,7 @@ dependencies = [ "socket2", "static_assertions", "subtle", + "tempfile", "thiserror", "tokio", "tokio-rustls", diff --git a/Cargo.toml b/Cargo.toml index ed158f4..d33815d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -90,6 +90,7 @@ tokio-test = "0.4" criterion = "0.8" proptest = "1.4" futures = "0.3" +tempfile = "3.27.0" [[bench]] name = "crypto_bench" diff --git a/src/api/config_edit.rs b/src/api/config_edit.rs new file mode 100644 index 0000000..5fe1f57 --- /dev/null +++ b/src/api/config_edit.rs @@ -0,0 +1,308 @@ +//! Config-editing API: read managed sections and apply sparse field patches. +//! `access.*` is intentionally not editable here (owned by the users API). + +use serde_json::Value as Json; +use toml::Value as Toml; + +use super::ApiShared; +use super::config_store::{ + EDITABLE_SECTIONS, compute_revision, current_revision, save_sections_to_disk, +}; +use super::model::ApiFailure; +use crate::config::ProxyConfig; +use crate::config::hot_reload::classify_config_changes; +use serde::Serialize; +use std::path::Path; + +#[derive(Debug, Serialize)] +pub(super) struct PatchConfigResponse { + pub revision: String, + pub restart_required: bool, + pub changed: Vec, +} + +/// 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. +pub(super) async fn patch_config( + patch_json: Json, + expected_revision: Option, + shared: &ApiShared, +) -> Result { + let _guard = shared.mutation_lock.lock().await; + let resp = apply_patch_to_path(&shared.config_path, &patch_json, expected_revision).await?; + drop(_guard); + shared + .runtime_events + .record("api.config.patch.ok", format!("changed={:?}", resp.changed)); + Ok(resp) +} + +/// 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. +pub(super) async fn apply_patch_to_path( + config_path: &Path, + patch_json: &Json, + expected_revision: Option, +) -> Result { + // 1. optimistic concurrency + let current = current_revision(config_path).await?; + if expected_revision.is_some_and(|expected| expected != current) { + return Err(ApiFailure::new( + hyper::StatusCode::CONFLICT, + "revision_conflict", + "Config revision mismatch", + )); + } + + // 2. convert + reject access / unknown sections + let patch_toml = json_to_toml(patch_json) + .map_err(|e| ApiFailure::bad_request(format!("invalid patch: {}", e)))?; + let patch_table = patch_toml + .as_table() + .ok_or_else(|| ApiFailure::bad_request("patch must be a JSON object"))?; + if patch_table.contains_key("access") { + return Err(ApiFailure::new( + hyper::StatusCode::BAD_REQUEST, + "access_not_editable", + "access.* is managed via the users API, not editable here", + )); + } + for key in patch_table.keys() { + if !EDITABLE_SECTIONS.contains(&key.as_str()) { + return Err(ApiFailure::new( + hyper::StatusCode::BAD_REQUEST, + "section_not_editable", + format!("section not editable: {}", key), + )); + } + } + let touched: Vec<&str> = patch_table + .keys() + .map(|k| k.as_str()) + .filter(|k| EDITABLE_SECTIONS.contains(k)) + .collect(); + if touched.is_empty() { + 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; + deep_merge(&mut merged, &patch_toml); + + let new_cfg: ProxyConfig = merged + .clone() + .try_into() + .map_err(|e| ApiFailure::bad_request(format!("config does not deserialize: {}", e)))?; + new_cfg + .validate() + .map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?; + + // 4. classify changes (Telemt's own hot/restart rule) + let class = classify_config_changes(&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, + changed: class.changed, + }) +} + +/// Return the editable config sections (no `access.*`) + 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 mut table = parsed + .as_table() + .cloned() + .unwrap_or_else(toml::value::Table::new); + table.remove("access"); // never expose users/secrets via this endpoint + + let revision = compute_revision(&original); + Ok((Toml::Table(table), revision)) +} + +/// Convert a serde_json value to a toml value. `null` is dropped from objects +/// (a patch never sets a key to TOML-null). Numbers become integers when exact, +/// otherwise floats. +fn json_to_toml(j: &Json) -> Result { + Ok(match j { + Json::Null => return Err("null is not representable in TOML".into()), + Json::Bool(b) => Toml::Boolean(*b), + Json::Number(n) => { + if let Some(i) = n.as_i64() { + Toml::Integer(i) + } else if let Some(f) = n.as_f64() { + Toml::Float(f) + } else { + return Err(format!("unrepresentable number: {}", n)); + } + } + Json::String(s) => Toml::String(s.clone()), + Json::Array(items) => { + let mut out = Vec::with_capacity(items.len()); + for item in items { + out.push(json_to_toml(item)?); + } + Toml::Array(out) + } + Json::Object(map) => { + 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 + } + table.insert(k.clone(), json_to_toml(v)?); + } + Toml::Table(table) + } + }) +} + +/// Recursively overlay `patch` onto `base`. Tables merge key-by-key; every +/// other value type (scalars, arrays) replaces wholesale. +fn deep_merge(base: &mut Toml, patch: &Toml) { + match (base, patch) { + (Toml::Table(b), Toml::Table(p)) => { + for (k, pv) in p { + match b.get_mut(k) { + Some(bv) => deep_merge(bv, pv), + None => { + b.insert(k.clone(), pv.clone()); + } + } + } + } + (b, p) => *b = p.clone(), + } +} + +#[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.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 patch_rejects_server_section() { + 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, "section_not_editable"); + } + + #[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.changed.iter().any(|c| c == "general")); + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 4239b59..8724416 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -28,6 +28,7 @@ use crate::stats::Stats; use crate::transport::UpstreamManager; use crate::transport::middle_proxy::MePool; +mod config_edit; mod config_store; mod events; mod http_utils; @@ -84,6 +85,7 @@ const ALLOW_GET: &str = "GET"; const ALLOW_POST: &str = "POST"; const ALLOW_GET_POST: &str = "GET, POST"; const ALLOW_GET_PATCH_DELETE: &str = "GET, PATCH, DELETE"; +const ALLOW_GET_PATCH: &str = "GET, PATCH"; pub(super) struct ApiRuntimeState { pub(super) process_started_at_epoch_secs: u64, @@ -174,6 +176,7 @@ fn allowed_methods_for_path(path: &str) -> Option<&'static str> { | "/v1/stats/users/quota" | "/v1/stats/users" => Some(ALLOW_GET), "/v1/users" => Some(ALLOW_GET_POST), + "/v1/config" => Some(ALLOW_GET_PATCH), _ if user_action_route_matches(path, "/reset-quota") => Some(ALLOW_POST), _ if user_action_route_matches(path, "/rotate-secret") => Some(ALLOW_POST), _ if user_action_route_matches(path, "/enable") => Some(ALLOW_POST), @@ -643,6 +646,37 @@ async fn handle( }; Ok(success_response(status, data, revision)) } + ("GET", "/v1/config") => { + let (value, revision) = + config_edit::read_managed_config(&shared.config_path).await?; + Ok(success_response(StatusCode::OK, value, revision)) + } + ("PATCH", "/v1/config") => { + if api_cfg.read_only { + return Ok(error_response( + request_id, + ApiFailure::new( + StatusCode::FORBIDDEN, + "read_only", + "API runs in read-only mode", + ), + )); + } + let expected_revision = parse_if_match(req.headers()); + let body = read_json::(req.into_body(), body_limit).await?; + match config_edit::patch_config(body, expected_revision, &shared).await { + Ok(resp) => { + let revision = resp.revision.clone(); + Ok(success_response(StatusCode::OK, resp, revision)) + } + Err(error) => { + shared + .runtime_events + .record("api.config.patch.failed", error.code); + Err(error) + } + } + } _ => { if method == Method::POST && let Some(base_user) = normalized_path From 27ee634f4aef9c60a81fec4f7a2543722e075bf5 Mon Sep 17 00:00:00 2001 From: Mirotin Artem Date: Tue, 9 Jun 2026 11:44:32 +0300 Subject: [PATCH 4/5] docs(api): document PATCH/GET /v1/config --- docs/Architecture/API/API.md | 138 +++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) diff --git a/docs/Architecture/API/API.md b/docs/Architecture/API/API.md index 9266aad..3fa4cca 100644 --- a/docs/Architecture/API/API.md +++ b/docs/Architecture/API/API.md @@ -106,6 +106,8 @@ Notes: | `GET` | `/v1/runtime/tls-fingerprints` | optional `limit=1..1000` | `200` | `RuntimeEdgeTlsFingerprintsData` | | `GET` | `/v1/stats/users/active-ips` | none | `200` | `UserActiveIps[]` | | `GET` | `/v1/stats/users` | none | `200` | `UserInfo[]` | +| `GET` | `/v1/config` | none | `200` | `ConfigData` | +| `PATCH` | `/v1/config` | sparse JSON object | `200` | `PatchConfigResponse` | | `GET` | `/v1/users` | none | `200` | `UserInfo[]` | | `POST` | `/v1/users` | `CreateUserRequest` | `201` or `202` | `CreateUserResponse` | | `GET` | `/v1/users/{username}` | none | `200` | `UserInfo` | @@ -143,6 +145,8 @@ Notes: | `GET /v1/runtime/events/recent` | Returns recent API/runtime event records with optional `limit` query. | | `GET /v1/stats/users/active-ips` | Returns users that currently have non-empty active source-IP lists. | | `GET /v1/stats/users` | Alias of `GET /v1/users`; returns disk-first user views with runtime lag flag. | +| `GET /v1/config` | Returns the current editable config sections as JSON (no `access.*`) plus the revision. | +| `PATCH /v1/config` | Applies a sparse patch to editable config sections; validates, writes, and reports restart impact. | | `GET /v1/users` | Returns disk-first user views sorted by username. | | `POST /v1/users` | Creates a user and returns the effective user view plus secret. | | `GET /v1/users/{username}` | Returns one disk-first user view or `404` when absent. | @@ -158,6 +162,8 @@ Notes: | HTTP | `error.code` | Trigger | | --- | --- | --- | | `400` | `bad_request` | Invalid JSON, validation failures, malformed request body. | +| `400` | `access_not_editable` | `PATCH /v1/config` body contains an `access` key (managed via users API). | +| `400` | `section_not_editable` | `PATCH /v1/config` body contains `server`, `network`, or an unknown top-level key. | | `401` | `unauthorized` | Missing/invalid `Authorization` when `auth_header` is configured. | | `403` | `forbidden` | Source IP is not allowed by whitelist. | | `403` | `read_only` | Mutating endpoint called while `read_only=true`. | @@ -177,6 +183,7 @@ Notes: | Path matching | Exact match on `req.uri().path()`. Query string does not affect route matching. | | Trailing slash | Trimmed for route matching when path length is greater than 1. Example: `/v1/users/` matches `/v1/users`. | | Username route with extra slash | `/v1/users/{username}/...` is not treated as user route and returns `404`. | +| `DELETE /v1/config` (or any method not in `GET`, `PATCH`) | `405 method_not_allowed` with `Allow: GET, PATCH`. | | `PUT /v1/users/{username}` | `405 method_not_allowed`. | | `POST /v1/users/{username}` | `404 not_found`. | | `POST /v1/users/{username}/rotate-secret/` | Trailing slash is trimmed and the route matches `rotate-secret`. | @@ -245,6 +252,20 @@ alice = ["203.0.113.0/24", "2001:db8:abcd::/48"] bob = ["198.51.100.42/32"] ``` +### `PatchConfigRequest` + +A sparse JSON object containing only the top-level config sections to modify. Each key must be one of the editable sections (`general`, `timeouts`, `censorship`, `upstreams`, `show_link`, `dc_overrides`). Tables within a section are deep-merged field-by-field into the existing config; arrays and scalar values replace the existing value wholesale. Untouched sections and file comments are preserved. + +**Rejected keys:** +- `access` → `400 access_not_editable` (users/secrets are managed via `POST/PATCH /v1/users`). +- `server`, `network`, or any unknown top-level key → `400 section_not_editable`. +- An object with no editable keys → `400 bad_request` (empty patch). + +Example — patch only the SNI domain: +```json +{"censorship": {"tls_domain": "front.example.com"}} +``` + ### `RotateSecretRequest` | Field | Type | Required | Description | | --- | --- | --- | --- | @@ -254,6 +275,32 @@ An empty request body is accepted and generates a new secret automatically. ## Response Data Contracts +### `ConfigData` + +Returned by `GET /v1/config`. The top-level fields mirror the editable TOML sections; `access.*` is intentionally omitted. + +| Field | Type | Description | +| --- | --- | --- | +| `revision` | `string` | SHA-256 hex of the current on-disk config content (same value as `config_hash` in `SystemInfoData` and the envelope `revision`). | +| `general` | `object?` | `[general]` section, if present in config. | +| `timeouts` | `object?` | `[timeouts]` section, if present. | +| `censorship` | `object?` | `[censorship]` section, if present. | +| `upstreams` | `object?` | `[upstreams]` section, if present. | +| `show_link` | `object?` | `[show_link]` section, if present. | +| `dc_overrides` | `object?` | `[dc_overrides]` section, if present. | + +Sections absent from the config file are absent from the response (not `null`). The `access` section is always stripped — users and secrets are never exposed here. + +### `PatchConfigResponse` + +Returned by `PATCH /v1/config` on success (`200`). + +| Field | Type | Description | +| --- | --- | --- | +| `revision` | `string` | SHA-256 hex of the config file after the patch was written. | +| `restart_required` | `bool` | `true` when one or more changed fields require a process restart to take effect. Hot-reloadable fields (e.g. `general.log_level`) are applied automatically by the config file watcher; restart-required fields (e.g. any `censorship.*`, `timeouts.*`, `upstreams`, or `general.modes` change) are written to disk but only take effect after the Telemt process is restarted. The caller is responsible for triggering a restart when this flag is `true`. | +| `changed` | `string[]` | Top-level section names that differed between the old and new config (e.g. `["censorship"]`). | + ### `HealthData` | Field | Type | Description | | --- | --- | --- | @@ -1279,10 +1326,101 @@ Link generation uses active config and enabled modes: | `used_bytes` | `u64` | Current used bytes after reset; always `0` on success. | | `last_reset_epoch_secs` | `u64` | Unix timestamp of the reset operation. | +## Config Endpoints + +### `GET /v1/config` + +Returns the current editable config sections as TOML-shaped JSON, plus the current revision. The `access` section (users and secrets) is always stripped and never appears in the response. + +**Auth:** requires `Authorization` header when `auth_header` is configured (same as all other endpoints). + +**Success `200` response body** (`data` field of the standard envelope): +```json +{ + "revision": "", + "censorship": {"tls_domain": "front.example.com"}, + "general": {"log_level": "normal"} +} +``` + +Top-level sections absent from the config file are absent from the response. Only `GET` and `PATCH` are accepted; any other method returns `405 Method Not Allowed` with `Allow: GET, PATCH`. + +--- + +### `PATCH /v1/config` + +Applies a sparse patch to the editable config sections. The merged config is fully validated before writing; if validation fails the file is not modified. + +**Auth:** requires `Authorization` header when `auth_header` is configured. + +**Headers:** + +| Header | Required | Description | +| --- | --- | --- | +| `Authorization` | when configured | Same token as all other endpoints. | +| `Content-Type: application/json` | recommended | Not enforced, but body must be valid JSON. | +| `If-Match: ` | no | Optimistic concurrency. `` is the `revision` value from `GET /v1/config` or `config_hash` from `GET /v1/system/info`. If supplied and it does not match the current on-disk revision, returns `409 revision_conflict`. If omitted, the patch applies unconditionally. | + +**Editable sections:** `general`, `timeouts`, `censorship`, `upstreams`, `show_link`, `dc_overrides`. + +**Rejected keys and their error codes:** + +| Key | HTTP | `error.code` | +| --- | --- | --- | +| `access` | `400` | `access_not_editable` | +| `server`, `network`, or any unknown key | `400` | `section_not_editable` | +| Object with no editable key | `400` | `bad_request` | + +**Merge semantics:** tables are deep-merged field-by-field; arrays and scalar values replace the existing value wholesale. File comments and untouched sections are preserved. + +**Validation:** the merged config is deserialized into the full `ProxyConfig` type and validated before writing. Failures return `400` with a descriptive message; the file is not modified. + +**Read-only mode:** returns `403 read_only` when the API runs with `read_only = true`. + +**Success `200` response body** (`data` field of the standard envelope): +```json +{ + "revision": "", + "restart_required": true, + "changed": ["censorship"] +} +``` + +- `revision` — SHA-256 hex of the config file after the write. +- `restart_required` — `true` when the change affects a field that Telemt cannot hot-reload (e.g. `censorship.*`, `timeouts.*`, `upstreams`, `general.modes`). Hot-reloadable fields (e.g. `general.log_level`) are applied automatically by the config file watcher. Restart-required fields are written to disk but only take effect after the Telemt process is restarted; the caller is responsible for triggering the restart. +- `changed` — list of top-level section names that differed. + +**Status codes:** + +| HTTP | `error.code` | Condition | +| --- | --- | --- | +| `200` | — | Patch applied successfully. | +| `400` | `bad_request` | Invalid JSON, empty patch, or config validation/deserialization failure. | +| `400` | `access_not_editable` | Patch contains an `access` key. | +| `400` | `section_not_editable` | Patch contains `server`, `network`, or an unknown top-level key. | +| `401` | `unauthorized` | Missing or invalid `Authorization` header. | +| `403` | `read_only` | API is in read-only mode. | +| `405` | `method_not_allowed` | Method other than `GET` or `PATCH` used on `/v1/config`. | +| `409` | `revision_conflict` | `If-Match` header supplied but does not match current revision. | +| `500` | `internal_error` | I/O or serialization failure. | + +**curl example:** +```bash +# get current revision +curl -s -H "Authorization: " http://127.0.0.1:/v1/system/info | jq -r .config_hash + +# patch the SNI domain with optimistic concurrency +curl -s -X PATCH -H "Authorization: " -H "If-Match: " \ + -H "Content-Type: application/json" \ + -d '{"censorship":{"tls_domain":"front.example.com"}}' \ + http://127.0.0.1:/v1/config +``` + ## Mutation Semantics | Endpoint | Notes | | --- | --- | +| `PATCH /v1/config` | Deep-merges the patch into editable config sections (tables merged per-field; arrays/scalars replaced wholesale). Validates the merged result before writing. Writes only the touched sections via atomic `tmp + rename`. Returns the new revision and which sections changed. | | `POST /v1/users` | Creates user, validates config, then atomically updates only affected `access.*` TOML tables (`access.users` always, plus optional per-user tables present in request). | | `PATCH /v1/users/{username}` | Partial update of provided fields only. Missing fields remain unchanged; explicit `null` removes optional per-user entries. The write path updates only affected `access.*` TOML tables. | | `POST /v1/users/{username}/rotate-secret` | Replaces the user's secret with a provided valid 32-hex value or a generated value, then returns the effective secret in `CreateUserResponse`. | From ff7a12d5f8a46f3d80911ff1a36f3ab5b16ca031 Mon Sep 17 00:00:00 2001 From: Mirotin Artem Date: Tue, 9 Jun 2026 12:13:32 +0300 Subject: [PATCH 5/5] fix(api): GET /v1/config returns only editable sections; tolerate commented TOML headers; doc fixes --- docs/Architecture/API/API.md | 5 ++--- src/api/config_edit.rs | 32 +++++++++++++++++++++++++++++--- src/api/config_store.rs | 22 ++++++++++++++++++---- 3 files changed, 49 insertions(+), 10 deletions(-) diff --git a/docs/Architecture/API/API.md b/docs/Architecture/API/API.md index 3fa4cca..2c2de51 100644 --- a/docs/Architecture/API/API.md +++ b/docs/Architecture/API/API.md @@ -277,11 +277,10 @@ An empty request body is accepted and generates a new secret automatically. ### `ConfigData` -Returned by `GET /v1/config`. The top-level fields mirror the editable TOML sections; `access.*` is intentionally omitted. +Returned by `GET /v1/config` as the envelope `data`. The fields are exactly the editable TOML sections. The current revision is returned in the envelope `revision` field (same value as `config_hash` in `SystemInfoData`), **not** inside `data`. | Field | Type | Description | | --- | --- | --- | -| `revision` | `string` | SHA-256 hex of the current on-disk config content (same value as `config_hash` in `SystemInfoData` and the envelope `revision`). | | `general` | `object?` | `[general]` section, if present in config. | | `timeouts` | `object?` | `[timeouts]` section, if present. | | `censorship` | `object?` | `[censorship]` section, if present. | @@ -289,7 +288,7 @@ Returned by `GET /v1/config`. The top-level fields mirror the editable TOML sect | `show_link` | `object?` | `[show_link]` section, if present. | | `dc_overrides` | `object?` | `[dc_overrides]` section, if present. | -Sections absent from the config file are absent from the response (not `null`). The `access` section is always stripped — users and secrets are never exposed here. +Sections absent from the config file are absent from the response (not `null`). Only the editable sections above are returned; `access` (users/secrets), `server` (carries the API `auth_header` and per-node identity), and `network` (per-node addresses) are always excluded. ### `PatchConfigResponse` diff --git a/src/api/config_edit.rs b/src/api/config_edit.rs index 5fe1f57..1de100b 100644 --- a/src/api/config_edit.rs +++ b/src/api/config_edit.rs @@ -125,7 +125,7 @@ pub(super) async fn apply_patch_to_path( }) } -/// Return the editable config sections (no `access.*`) + current revision. +/// 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 @@ -133,11 +133,19 @@ pub(super) async fn read_managed_config(config_path: &Path) -> Result<(Toml, Str let parsed: Toml = toml::from_str(&original) .map_err(|e| ApiFailure::internal(format!("failed to parse config: {}", e)))?; - let mut table = parsed + let parsed_table = parsed .as_table() .cloned() .unwrap_or_else(toml::value::Table::new); - table.remove("access"); // never expose users/secrets via this endpoint + // Whitelist: return ONLY the editable sections. A blacklist (just removing + // `access`) would leak `server` (carries the API `auth_header` + per-node + // identity) and `network` (per-node addresses). Mirror the PATCH contract. + let mut table = toml::value::Table::new(); + for section in EDITABLE_SECTIONS { + if let Some(value) = parsed_table.get(*section) { + table.insert((*section).to_string(), value.clone()); + } + } let revision = compute_revision(&original); Ok((Toml::Table(table), revision)) @@ -279,6 +287,24 @@ mod tests { assert_eq!(revision, current_revision(&path).await.unwrap()); } + #[tokio::test] + async fn read_managed_config_returns_only_editable_sections() { + // server carries the API auth_header + per-node identity; network carries + // per-node addresses. Neither must be exposed by GET /v1/config. + 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 API auth_header / identity leak + assert!(!table.contains_key("network")); // no per-node identity leak + assert!(!table.contains_key("access")); // no users/secrets + } + #[tokio::test] async fn patch_rejects_server_section() { let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n"); diff --git a/src/api/config_store.rs b/src/api/config_store.rs index 233fb17..e0ca68d 100644 --- a/src/api/config_store.rs +++ b/src/api/config_store.rs @@ -354,14 +354,16 @@ fn find_toml_table_bounds(source: &str, table_name: &str) -> Option<(usize, usiz let mut start = None; for line in source.split_inclusive('\n') { - let trimmed = line.trim(); + // 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(); if let Some(start_offset) = start { - let is_same_array = trimmed == array; - let is_new_header = trimmed.starts_with('['); + let is_same_array = header == array; + let is_new_header = header.starts_with('['); if is_new_header && !is_same_array { return Some((start_offset, offset)); } - } else if trimmed == single || trimmed == array { + } else if header == single || header == array { start = Some(offset); } offset = offset.saturating_add(line.len()); @@ -453,6 +455,18 @@ mod tests { 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 + } + #[test] fn render_user_rate_limits_section() { let mut cfg = ProxyConfig::default();