//! 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")); } }