mirror of
https://github.com/telemt/telemt.git
synced 2026-09-13 22:14:08 +03:00
Hardened listener reload + config persistence + SYN Limit startup safety
This commit is contained in:
+139
-340
@@ -7,16 +7,17 @@ use toml::Value as Toml;
|
||||
|
||||
use super::ApiShared;
|
||||
use super::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<ReloadAccepted>,
|
||||
}
|
||||
|
||||
/// 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<ProxyConfig>,
|
||||
response: PatchConfigResponse,
|
||||
}
|
||||
|
||||
/// Serializes config mutations behind `mutation_lock`, commits them, and records
|
||||
/// a runtime event. The route handler calls this shared-state wrapper.
|
||||
pub(super) async fn patch_config(
|
||||
patch_json: Json,
|
||||
expected_revision: Option<String>,
|
||||
@@ -41,36 +48,29 @@ pub(super) async fn patch_config(
|
||||
shared: &ApiShared,
|
||||
) -> Result<PatchConfigResponse, ApiFailure> {
|
||||
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<String>,
|
||||
) -> Result<PatchConfigResponse, ApiFailure> {
|
||||
let prepared = prepare_patch_to_path(config_path, patch_json, expected_revision).await?;
|
||||
write_atomic(prepared.owner_path, prepared.owner_contents).await?;
|
||||
Ok(prepared.response)
|
||||
}
|
||||
|
||||
async fn prepare_patch_to_path(
|
||||
config_path: &Path,
|
||||
patch_json: &Json,
|
||||
expected_revision: Option<String>,
|
||||
) -> Result<PreparedConfigPatch, ApiFailure> {
|
||||
// 1. optimistic concurrency
|
||||
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<Toml, String> {
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn json_object_converts_to_toml_table() {
|
||||
let j: Json = serde_json::json!({"censorship": {"tls_domain": "a.com"}, "default_dc": 2});
|
||||
let t = json_to_toml(&j).expect("convertible");
|
||||
let table = t.as_table().unwrap();
|
||||
assert_eq!(table["censorship"]["tls_domain"].as_str(), Some("a.com"));
|
||||
assert_eq!(table["default_dc"].as_integer(), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_merge_overlays_tables_and_replaces_scalars() {
|
||||
let mut base: Toml =
|
||||
toml::from_str("[censorship]\ntls_domain = \"old\"\nfake_cert_len = 100\n").unwrap();
|
||||
let patch: Toml = toml::from_str("[censorship]\ntls_domain = \"new\"\n").unwrap();
|
||||
|
||||
deep_merge(&mut base, &patch);
|
||||
|
||||
let cens = base["censorship"].as_table().unwrap();
|
||||
assert_eq!(cens["tls_domain"].as_str(), Some("new"));
|
||||
assert_eq!(cens["fake_cert_len"].as_integer(), Some(100));
|
||||
}
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn temp_config(body: &str) -> (PathBuf, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
std::fs::write(&path, body).unwrap();
|
||||
(path, dir)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn patch_rejects_access_section() {
|
||||
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
|
||||
let patch: Json = serde_json::json!({"access": {"users": {"x": "y"}}});
|
||||
let err = apply_patch_to_path(&path, &patch, None).await.unwrap_err();
|
||||
assert_eq!(err.code, "access_not_editable");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn patch_revision_conflict() {
|
||||
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
|
||||
let patch: Json = serde_json::json!({"censorship": {"tls_domain": "b"}});
|
||||
let err = apply_patch_to_path(&path, &patch, Some("deadbeef".into()))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code, "revision_conflict");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn patch_sni_reports_restart_required() {
|
||||
let (path, _d) =
|
||||
temp_config("[censorship]\ntls_domain = \"a.com\"\n[server]\nport = 443\n");
|
||||
let patch: Json = serde_json::json!({"censorship": {"tls_domain": "b.com"}});
|
||||
let resp = apply_patch_to_path(&path, &patch, None).await.unwrap();
|
||||
assert!(resp.restart_required);
|
||||
assert!(resp.runtime_reload_required);
|
||||
assert!(!resp.process_restart_required);
|
||||
assert!(resp.deferred_process_fields.is_empty());
|
||||
assert!(resp.changed.iter().any(|c| c == "censorship"));
|
||||
let written = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(written.contains("tls_domain = \"b.com\""));
|
||||
assert_eq!(
|
||||
resp.revision,
|
||||
crate::api::config_store::current_revision(&path)
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_managed_config_strips_access() {
|
||||
let (path, _d) = temp_config(
|
||||
"[censorship]\ntls_domain = \"a.com\"\n[access.users]\nbob = \"00000000000000000000000000000000\"\n",
|
||||
);
|
||||
let (value, revision) = read_managed_config(&path).await.unwrap();
|
||||
let table = value.as_table().unwrap();
|
||||
assert!(table.contains_key("censorship"));
|
||||
// Secrets never leave the box through this endpoint.
|
||||
assert!(!table.contains_key("access"));
|
||||
assert_eq!(
|
||||
revision,
|
||||
crate::api::config_store::current_revision(&path)
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_managed_config_returns_only_editable_sections() {
|
||||
// Full server (api/port) and network must not leak. Listeners-only server
|
||||
// is returned via the nested allowlist (covered in a dedicated test).
|
||||
let (path, _d) = temp_config(concat!(
|
||||
"[censorship]\ntls_domain = \"a\"\n",
|
||||
"[server]\nport = 443\n[server.api]\nauth_header = \"SECRET\"\n",
|
||||
"[network]\nipv4 = true\n",
|
||||
"[access.users]\nbob = \"00000000000000000000000000000000\"\n",
|
||||
));
|
||||
let (value, _rev) = read_managed_config(&path).await.unwrap();
|
||||
let table = value.as_table().unwrap();
|
||||
assert!(table.contains_key("censorship"));
|
||||
let server = table["server"].as_table().unwrap();
|
||||
assert!(server.contains_key("listeners"));
|
||||
assert!(!server.contains_key("api"));
|
||||
assert!(!server.contains_key("port"));
|
||||
assert!(!table.contains_key("network"));
|
||||
assert!(!table.contains_key("access"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_managed_config_returns_server_listeners_only() {
|
||||
let (path, _d) = temp_config(concat!(
|
||||
"[censorship]\ntls_domain = \"a\"\n",
|
||||
"[server]\nport = 443\n",
|
||||
"[server.api]\nauth_header = \"SECRET\"\n",
|
||||
"[[server.listeners]]\nip = \"0.0.0.0\"\nport = 443\n",
|
||||
));
|
||||
let (value, _rev) = read_managed_config(&path).await.unwrap();
|
||||
let table = value.as_table().unwrap();
|
||||
let server = table
|
||||
.get("server")
|
||||
.expect("server.listeners present")
|
||||
.as_table()
|
||||
.unwrap();
|
||||
assert!(server.contains_key("listeners"));
|
||||
assert!(!server.contains_key("api"));
|
||||
assert!(!server.contains_key("port"));
|
||||
let listeners = server["listeners"].as_array().unwrap();
|
||||
assert_eq!(listeners.len(), 1);
|
||||
assert_eq!(listeners[0]["port"].as_integer(), Some(443));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn patch_rejects_forbidden_server_fields() {
|
||||
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
|
||||
let patch: Json = serde_json::json!({"server": {"port": 1}});
|
||||
let err = apply_patch_to_path(&path, &patch, None).await.unwrap_err();
|
||||
assert_eq!(err.code, "field_not_editable");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn patch_rejects_server_api_field() {
|
||||
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
|
||||
let patch: Json = serde_json::json!({"server": {"api": {"enabled": false}}});
|
||||
let err = apply_patch_to_path(&path, &patch, None).await.unwrap_err();
|
||||
assert_eq!(err.code, "field_not_editable");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn patch_server_listeners_preserves_api() {
|
||||
let (path, _d) = temp_config(concat!(
|
||||
"[censorship]\ntls_domain = \"a\"\n",
|
||||
"[server]\nport = 443\n",
|
||||
"[server.api]\nenabled = true\nauth_header = \"SECRET\"\n",
|
||||
"[[server.listeners]]\nip = \"0.0.0.0\"\nport = 443\n",
|
||||
));
|
||||
let patch: Json = serde_json::json!({
|
||||
"server": {
|
||||
"listeners": [
|
||||
{"ip": "0.0.0.0", "port": 8443, "client_mss": "92"}
|
||||
]
|
||||
}
|
||||
});
|
||||
let resp = apply_patch_to_path(&path, &patch, None).await.unwrap();
|
||||
assert!(resp.changed.iter().any(|c| c == "server"));
|
||||
let written = tokio::fs::read_to_string(&path).await.unwrap();
|
||||
let parsed: toml::Value = toml::from_str(&written).unwrap();
|
||||
assert_eq!(
|
||||
parsed["server"]["api"]["auth_header"].as_str(),
|
||||
Some("SECRET"),
|
||||
"{written}"
|
||||
);
|
||||
let listeners = parsed["server"]["listeners"].as_array().unwrap();
|
||||
assert_eq!(listeners.len(), 1, "{written}");
|
||||
assert_eq!(listeners[0]["port"].as_integer(), Some(8443), "{written}");
|
||||
assert_eq!(listeners[0]["client_mss"].as_str(), Some("92"), "{written}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn patch_rejects_show_link_section() {
|
||||
// show_link is a legacy top-level scalar/array (not a [table]); it cannot
|
||||
// be upserted safely and is superseded by the editable general.links.show.
|
||||
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
|
||||
let patch: Json = serde_json::json!({"show_link": "*"});
|
||||
let err = apply_patch_to_path(&path, &patch, None).await.unwrap_err();
|
||||
assert_eq!(err.code, "section_not_editable");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn patch_general_links_show_is_editable() {
|
||||
// The supported replacement path: edit show via the general.links sub-table.
|
||||
let (path, _d) = temp_config(
|
||||
"[general]\nprefer_ipv6 = false\n[general.links]\nshow = \"*\"\n\
|
||||
[censorship]\ntls_domain = \"a\"\n",
|
||||
);
|
||||
let patch: Json = serde_json::json!({"general": {"links": {"show": ["alice"]}}});
|
||||
let resp = apply_patch_to_path(&path, &patch, None).await.unwrap();
|
||||
assert!(resp.changed.iter().any(|c| c == "general"));
|
||||
let written = tokio::fs::read_to_string(&path).await.unwrap();
|
||||
let parsed: toml::Value = toml::from_str(&written).unwrap();
|
||||
assert_eq!(
|
||||
parsed["general"]["links"]["show"][0].as_str(),
|
||||
Some("alice"),
|
||||
"{written}"
|
||||
);
|
||||
// No leaked top-level [links]/[modes] and no duplicate sub-tables.
|
||||
assert_eq!(written.matches("[general.links]").count(), 1, "{written}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn patch_writes_the_included_section_owner_only() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().join("config.toml");
|
||||
let included = dir.path().join("censorship.toml");
|
||||
let root_body = "include = \"censorship.toml\"\n[server]\nport = 443\n";
|
||||
let included_body = "[censorship]\ntls_domain = \"old.example\"\n";
|
||||
tokio::fs::write(&root, root_body).await.unwrap();
|
||||
tokio::fs::write(&included, included_body).await.unwrap();
|
||||
let patch: Json = serde_json::json!({
|
||||
"censorship": {"tls_domain": "new.example"}
|
||||
});
|
||||
|
||||
let response = apply_patch_to_path(&root, &patch, None).await.unwrap();
|
||||
|
||||
assert_eq!(tokio::fs::read_to_string(&root).await.unwrap(), root_body);
|
||||
let written = tokio::fs::read_to_string(&included).await.unwrap();
|
||||
assert!(written.contains("tls_domain = \"new.example\""));
|
||||
assert_eq!(
|
||||
response.revision,
|
||||
crate::api::config_store::current_revision(&root)
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn patch_rejects_multiple_source_owners_without_writing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().join("config.toml");
|
||||
let included = dir.path().join("censorship.toml");
|
||||
let root_body = concat!(
|
||||
"include = \"censorship.toml\"\n",
|
||||
"[general]\nprefer_ipv6 = false\n"
|
||||
);
|
||||
let included_body = "[censorship]\ntls_domain = \"old.example\"\n";
|
||||
tokio::fs::write(&root, root_body).await.unwrap();
|
||||
tokio::fs::write(&included, included_body).await.unwrap();
|
||||
let patch: Json = serde_json::json!({
|
||||
"general": {"prefer_ipv6": true},
|
||||
"censorship": {"tls_domain": "new.example"}
|
||||
});
|
||||
|
||||
let error = apply_patch_to_path(&root, &patch, None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(error.code, "config_patch_not_atomic");
|
||||
assert_eq!(tokio::fs::read_to_string(&root).await.unwrap(), root_body);
|
||||
assert_eq!(
|
||||
tokio::fs::read_to_string(&included).await.unwrap(),
|
||||
included_body
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unavailable_reload_coordinator_is_detected_before_config_write() {
|
||||
let (path, _dir) = temp_config("[censorship]\ntls_domain = \"old.example\"\n");
|
||||
let original = tokio::fs::read_to_string(&path).await.unwrap();
|
||||
let patch: Json = serde_json::json!({
|
||||
"censorship": {"tls_domain": "new.example"}
|
||||
});
|
||||
let prepared = prepare_patch_to_path(&path, &patch, None).await.unwrap();
|
||||
let (control, receiver) = crate::maestro::reload::ReloadControl::channel(1);
|
||||
drop(receiver);
|
||||
|
||||
let error = match control
|
||||
.reserve(
|
||||
prepared.response.revision.clone(),
|
||||
ReloadRequest::default(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => panic!("closed reload coordinator must reject the reservation"),
|
||||
Err(error) => error,
|
||||
};
|
||||
|
||||
assert_eq!(error, ReloadSubmitError::MaestroUnavailable);
|
||||
assert_eq!(tokio::fs::read_to_string(&path).await.unwrap(), original);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_config_write_releases_reload_reservation() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (control, _receiver) = crate::maestro::reload::ReloadControl::channel(1);
|
||||
let reservation = control
|
||||
.reserve("candidate-revision".to_string(), ReloadRequest::default())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = write_atomic(dir.path().to_path_buf(), "invalid target".to_string()).await;
|
||||
drop(reservation);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(control.in_progress().await, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn patch_links_public_port_written_as_integer_not_float_or_string() {
|
||||
// A JSON integer must land on disk as a bare TOML integer (443), never
|
||||
// 443.0 nor "443". The write re-renders from the typed config, so the
|
||||
// u16 field dictates the output format regardless of JSON quirks.
|
||||
let (path, _d) = temp_config("[general]\nprefer_ipv6 = false\n");
|
||||
let patch: Json = serde_json::json!({"general": {"links": {"public_port": 443}}});
|
||||
apply_patch_to_path(&path, &patch, None).await.unwrap();
|
||||
|
||||
let written = tokio::fs::read_to_string(&path).await.unwrap();
|
||||
assert!(written.contains("public_port = 443"), "{written}");
|
||||
assert!(
|
||||
!written.contains("443.0"),
|
||||
"must not be a float:\n{written}"
|
||||
);
|
||||
assert!(
|
||||
!written.contains("\"443\""),
|
||||
"must not be a string:\n{written}"
|
||||
);
|
||||
|
||||
let parsed: toml::Value = toml::from_str(&written).unwrap();
|
||||
assert_eq!(
|
||||
parsed["general"]["links"]["public_port"].as_integer(),
|
||||
Some(443),
|
||||
"{written}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn patch_links_public_port_rejects_float() {
|
||||
// 443.0 cannot deserialize into u16 -> rejected, not silently coerced.
|
||||
let (path, _d) = temp_config("[general]\nprefer_ipv6 = false\n");
|
||||
let patch: Json = serde_json::json!({"general": {"links": {"public_port": 443.0}}});
|
||||
let err = apply_patch_to_path(&path, &patch, None).await.unwrap_err();
|
||||
assert_eq!(err.status, hyper::StatusCode::BAD_REQUEST, "{:?}", err);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn patch_links_public_port_rejects_string() {
|
||||
// "443" is a string, not a u16 -> rejected.
|
||||
let (path, _d) = temp_config("[general]\nprefer_ipv6 = false\n");
|
||||
let patch: Json = serde_json::json!({"general": {"links": {"public_port": "443"}}});
|
||||
let err = apply_patch_to_path(&path, &patch, None).await.unwrap_err();
|
||||
assert_eq!(err.status, hyper::StatusCode::BAD_REQUEST, "{:?}", err);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn patch_empty_is_rejected() {
|
||||
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
|
||||
let patch: Json = serde_json::json!({});
|
||||
assert!(apply_patch_to_path(&path, &patch, None).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn patch_log_level_is_hot() {
|
||||
// general.log_level is hot-reloadable -> a patch changing only it must
|
||||
// report restart_required = false (exercises the full apply path, not
|
||||
// just the classifier). Default LogLevel is Normal; patch to "debug".
|
||||
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
|
||||
let patch: Json = serde_json::json!({"general": {"log_level": "debug"}});
|
||||
let resp = apply_patch_to_path(&path, &patch, None).await.unwrap();
|
||||
assert!(!resp.restart_required);
|
||||
assert!(!resp.runtime_reload_required);
|
||||
assert!(!resp.process_restart_required);
|
||||
assert!(resp.changed.iter().any(|c| c == "general"));
|
||||
}
|
||||
+184
-559
@@ -1,16 +1,25 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::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<String, ApiFailure> {
|
||||
let content = tokio::fs::read_to_string(config_path)
|
||||
let config_path = config_path.to_path_buf();
|
||||
let graph = tokio::task::spawn_blocking(move || ProxyConfig::read_source_graph(config_path))
|
||||
.await
|
||||
.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<String, String> {
|
||||
let content = tokio::fs::read_to_string(config_path)
|
||||
let config_path = config_path.to_path_buf();
|
||||
tokio::task::spawn_blocking(move || ProxyConfig::read_source_graph(config_path))
|
||||
.await
|
||||
.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<LoadedConfig, ApiFailure> {
|
||||
let config_path = config_path.to_path_buf();
|
||||
tokio::task::spawn_blocking(move || ProxyConfig::load_with_metadata(config_path))
|
||||
.await
|
||||
.map_err(|error| ApiFailure::internal(format!("failed to join config loader: {error}")))?
|
||||
.map_err(|error| {
|
||||
if invalid_is_bad_request {
|
||||
ApiFailure::bad_request(format!("invalid runtime config: {error}"))
|
||||
} else {
|
||||
ApiFailure::internal(format!("failed to load config: {error}"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn resolve_single_source_owner(
|
||||
loaded: &LoadedConfig,
|
||||
config_path: &Path,
|
||||
targets: &[&str],
|
||||
) -> Result<PathBuf, ApiFailure> {
|
||||
let root = normalize_source_path(config_path);
|
||||
let mut mutation_owners = BTreeSet::new();
|
||||
|
||||
if loaded
|
||||
.source_contents
|
||||
.values()
|
||||
.any(|content| has_include_inside_table(content))
|
||||
{
|
||||
return Err(ApiFailure::new(
|
||||
hyper::StatusCode::CONFLICT,
|
||||
"config_patch_not_atomic",
|
||||
"config includes nested inside a TOML table cannot be mutated atomically",
|
||||
));
|
||||
}
|
||||
|
||||
for target in targets {
|
||||
let mut owners = BTreeSet::new();
|
||||
for (path, content) in &loaded.source_contents {
|
||||
let parsed: toml::Value = toml::from_str(content).map_err(|error| {
|
||||
ApiFailure::new(
|
||||
hyper::StatusCode::CONFLICT,
|
||||
"config_patch_not_atomic",
|
||||
format!(
|
||||
"config source {} is not independently writable: {error}",
|
||||
path.display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
if toml_path_exists(&parsed, target) {
|
||||
owners.insert(path.clone());
|
||||
}
|
||||
}
|
||||
match owners.len() {
|
||||
0 => {
|
||||
mutation_owners.insert(root.clone());
|
||||
}
|
||||
1 => {
|
||||
mutation_owners.extend(owners);
|
||||
}
|
||||
_ => {
|
||||
return Err(ApiFailure::new(
|
||||
hyper::StatusCode::CONFLICT,
|
||||
"config_patch_not_atomic",
|
||||
format!("config section {target} is owned by multiple source files"),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if mutation_owners.len() != 1 {
|
||||
return Err(ApiFailure::new(
|
||||
hyper::StatusCode::CONFLICT,
|
||||
"config_patch_not_atomic",
|
||||
"one mutation may update only one config source file",
|
||||
));
|
||||
}
|
||||
mutation_owners
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| ApiFailure::bad_request("empty mutation: no owned config sections"))
|
||||
}
|
||||
|
||||
fn toml_path_exists(value: &toml::Value, target: &str) -> bool {
|
||||
target
|
||||
.split('.')
|
||||
.try_fold(value, |current, part| current.get(part))
|
||||
.is_some()
|
||||
}
|
||||
|
||||
fn has_include_inside_table(content: &str) -> bool {
|
||||
let mut inside_table = false;
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with('[') {
|
||||
inside_table = true;
|
||||
}
|
||||
if inside_table
|
||||
&& trimmed
|
||||
.strip_prefix("include")
|
||||
.is_some_and(|rest| rest.trim_start().starts_with('='))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(super) async fn load_candidate_snapshot(
|
||||
config_path: &Path,
|
||||
base_sources: &BTreeMap<PathBuf, String>,
|
||||
owner_path: PathBuf,
|
||||
owner_contents: String,
|
||||
) -> Result<LoadedConfig, ApiFailure> {
|
||||
let config_path = config_path.to_path_buf();
|
||||
let mut overrides = base_sources.clone();
|
||||
overrides.insert(owner_path, owner_contents);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
ProxyConfig::load_with_source_overrides(config_path, &overrides)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| ApiFailure::internal(format!("failed to join config loader: {error}")))?
|
||||
.map_err(|error| ApiFailure::bad_request(format!("invalid patched config: {error}")))
|
||||
}
|
||||
|
||||
fn normalize_source_path(path: &Path) -> PathBuf {
|
||||
path.canonicalize().unwrap_or_else(|_| {
|
||||
if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
std::env::current_dir()
|
||||
.map(|cwd| cwd.join(path))
|
||||
.unwrap_or_else(|_| path.to_path_buf())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn load_config_from_disk(config_path: &Path) -> Result<ProxyConfig, ApiFailure> {
|
||||
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<String, ApiFailure> {
|
||||
let mut content = tokio::fs::read_to_string(config_path)
|
||||
.await
|
||||
.map_err(|e| ApiFailure::internal(format!("failed to read config: {}", e)))?;
|
||||
|
||||
for section in sections {
|
||||
let rendered = render_top_level_section(cfg, section)?;
|
||||
content = upsert_toml_table(&content, section, &rendered);
|
||||
}
|
||||
|
||||
write_atomic(config_path.to_path_buf(), content.clone()).await?;
|
||||
Ok(compute_revision(&content))
|
||||
}
|
||||
|
||||
/// Render one top-level table as `[section]\n...\n` (or `[[upstreams]]` array
|
||||
/// of tables) from the typed `cfg`. Serializes via the `toml` crate so the
|
||||
/// output matches the canonical format Telemt parses.
|
||||
fn render_top_level_section(cfg: &ProxyConfig, section: &str) -> Result<String, ApiFailure> {
|
||||
let value = toml::Value::try_from(cfg)
|
||||
.map_err(|e| ApiFailure::internal(format!("failed to serialize config: {}", e)))?;
|
||||
let table = value
|
||||
.get(section)
|
||||
.ok_or_else(|| ApiFailure::internal(format!("unknown section: {}", section)))?;
|
||||
|
||||
// upstreams is an array-of-tables -> render as [[upstreams]] blocks.
|
||||
if let toml::Value::Array(items) = table {
|
||||
let mut out = String::new();
|
||||
for item in items {
|
||||
out.push_str(&format!("[[{}]]\n", section));
|
||||
out.push_str(&toml::to_string(item).map_err(|e| {
|
||||
ApiFailure::internal(format!("failed to serialize {}: {}", section, e))
|
||||
})?);
|
||||
if !out.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
|
||||
// Serialize the table *inside a wrapper keyed by `section`* so the `toml`
|
||||
// crate emits correctly dotted headers for nested sub-tables, e.g.
|
||||
// `[general]` + `[general.modes]` + `[general.links]`. Serializing the
|
||||
// inner table alone would render bare `[modes]`/`[links]` headers, which
|
||||
// would leak as duplicate top-level tables and break config load.
|
||||
let mut wrapper = toml::value::Table::new();
|
||||
wrapper.insert(section.to_string(), table.clone());
|
||||
let mut out = toml::to_string(&toml::Value::Table(wrapper))
|
||||
.map_err(|e| ApiFailure::internal(format!("failed to serialize {}: {}", section, e)))?;
|
||||
if !out.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(super) async fn save_access_sections_to_disk(
|
||||
config_path: &Path,
|
||||
cfg: &ProxyConfig,
|
||||
sections: &[AccessSection],
|
||||
) -> Result<String, ApiFailure> {
|
||||
let mut content = tokio::fs::read_to_string(config_path)
|
||||
.await
|
||||
.map_err(|e| ApiFailure::internal(format!("failed to read config: {}", e)))?;
|
||||
|
||||
let mut applied = Vec::new();
|
||||
for section in sections {
|
||||
if applied.contains(section) {
|
||||
continue;
|
||||
}
|
||||
if find_toml_table_bounds(&content, section.table_name()).is_none()
|
||||
&& access_section_is_empty(cfg, *section)
|
||||
{
|
||||
applied.push(*section);
|
||||
continue;
|
||||
}
|
||||
let rendered = render_access_section(cfg, *section)?;
|
||||
content = upsert_toml_table(&content, section.table_name(), &rendered);
|
||||
applied.push(*section);
|
||||
}
|
||||
|
||||
write_atomic(config_path.to_path_buf(), content.clone()).await?;
|
||||
Ok(compute_revision(&content))
|
||||
}
|
||||
|
||||
fn render_access_section(cfg: &ProxyConfig, section: AccessSection) -> Result<String, ApiFailure> {
|
||||
let body = match section {
|
||||
AccessSection::Users => {
|
||||
let rows: BTreeMap<String, String> = cfg
|
||||
.access
|
||||
.users
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), value.clone()))
|
||||
.collect();
|
||||
serialize_table_body(&rows)?
|
||||
}
|
||||
AccessSection::UserEnabled => {
|
||||
let rows: BTreeMap<String, bool> = cfg
|
||||
.access
|
||||
.user_enabled
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), *value))
|
||||
.collect();
|
||||
serialize_table_body(&rows)?
|
||||
}
|
||||
AccessSection::UserAdTags => {
|
||||
let rows: BTreeMap<String, String> = cfg
|
||||
.access
|
||||
.user_ad_tags
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), value.clone()))
|
||||
.collect();
|
||||
serialize_table_body(&rows)?
|
||||
}
|
||||
AccessSection::UserMaxTcpConns => {
|
||||
let rows: BTreeMap<String, usize> = cfg
|
||||
.access
|
||||
.user_max_tcp_conns
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), *value))
|
||||
.collect();
|
||||
serialize_table_body(&rows)?
|
||||
}
|
||||
AccessSection::UserExpirations => {
|
||||
let rows: BTreeMap<String, DateTime<Utc>> = cfg
|
||||
.access
|
||||
.user_expirations
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), *value))
|
||||
.collect();
|
||||
serialize_table_body(&rows)?
|
||||
}
|
||||
AccessSection::UserDataQuota => {
|
||||
let rows: BTreeMap<String, u64> = cfg
|
||||
.access
|
||||
.user_data_quota
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), *value))
|
||||
.collect();
|
||||
serialize_table_body(&rows)?
|
||||
}
|
||||
AccessSection::UserRateLimits => {
|
||||
let rows: BTreeMap<String, RateLimitBps> = cfg
|
||||
.access
|
||||
.user_rate_limits
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), *value))
|
||||
.collect();
|
||||
serialize_rate_limit_body(&rows)?
|
||||
}
|
||||
AccessSection::UserMaxUniqueIps => {
|
||||
let rows: BTreeMap<String, usize> = cfg
|
||||
.access
|
||||
.user_max_unique_ips
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), *value))
|
||||
.collect();
|
||||
serialize_table_body(&rows)?
|
||||
}
|
||||
};
|
||||
|
||||
let mut out = format!("[{}]\n", section.table_name());
|
||||
if !body.is_empty() {
|
||||
out.push_str(&body);
|
||||
}
|
||||
if !out.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn access_section_is_empty(cfg: &ProxyConfig, section: AccessSection) -> bool {
|
||||
match section {
|
||||
AccessSection::Users => cfg.access.users.is_empty(),
|
||||
AccessSection::UserEnabled => cfg.access.user_enabled.is_empty(),
|
||||
AccessSection::UserAdTags => cfg.access.user_ad_tags.is_empty(),
|
||||
AccessSection::UserMaxTcpConns => cfg.access.user_max_tcp_conns.is_empty(),
|
||||
AccessSection::UserExpirations => cfg.access.user_expirations.is_empty(),
|
||||
AccessSection::UserDataQuota => cfg.access.user_data_quota.is_empty(),
|
||||
AccessSection::UserRateLimits => cfg.access.user_rate_limits.is_empty(),
|
||||
AccessSection::UserMaxUniqueIps => cfg.access.user_max_unique_ips.is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
fn serialize_table_body<T: Serialize>(value: &T) -> Result<String, ApiFailure> {
|
||||
toml::to_string(value)
|
||||
.map_err(|e| ApiFailure::internal(format!("failed to serialize access section: {}", e)))
|
||||
}
|
||||
|
||||
fn serialize_rate_limit_body(rows: &BTreeMap<String, RateLimitBps>) -> Result<String, ApiFailure> {
|
||||
let mut out = String::new();
|
||||
for (key, value) in rows {
|
||||
let key = serialize_toml_key(key)?;
|
||||
out.push_str(&format!(
|
||||
"{key} = {{ up_bps = {}, down_bps = {} }}\n",
|
||||
value.up_bps, value.down_bps
|
||||
));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn serialize_toml_key(key: &str) -> Result<String, ApiFailure> {
|
||||
let mut row = BTreeMap::new();
|
||||
row.insert(key.to_string(), 0_u8);
|
||||
let rendered = serialize_table_body(&row)?;
|
||||
rendered
|
||||
.split_once(" = ")
|
||||
.map(|(key, _)| key.to_string())
|
||||
.ok_or_else(|| ApiFailure::internal("failed to serialize TOML key"))
|
||||
}
|
||||
|
||||
fn upsert_toml_table(source: &str, table_name: &str, replacement: &str) -> String {
|
||||
let blocks = find_all_table_blocks(source, table_name);
|
||||
if let Some(&(first_start, first_end)) = blocks.first() {
|
||||
// Replace the first block in place and delete any further blocks that
|
||||
// also belong to this table. Telemt writes a section's sub-tables
|
||||
// contiguously, but a hand-edited config may scatter them; dropping the
|
||||
// extras here prevents the duplicate-table corruption that would
|
||||
// otherwise break config load.
|
||||
let mut out = String::with_capacity(source.len() + replacement.len());
|
||||
out.push_str(&source[..first_start]);
|
||||
out.push_str(replacement);
|
||||
let mut cursor = first_end;
|
||||
for &(start, end) in &blocks[1..] {
|
||||
out.push_str(&source[cursor..start]);
|
||||
cursor = end;
|
||||
}
|
||||
out.push_str(&source[cursor..]);
|
||||
return out;
|
||||
}
|
||||
|
||||
let mut out = source.to_string();
|
||||
if !out.is_empty() && !out.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
if !out.is_empty() {
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str(replacement);
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether a (comment-stripped, trimmed) TOML header line belongs to
|
||||
/// `table_name`: the table itself (`[X]` / `[[X]]`) or any of its nested
|
||||
/// sub-tables (`[X.…]` / `[[X.…]]`). The trailing dot guards against sibling
|
||||
/// prefixes — `access.users` must not match `access.user_enabled`.
|
||||
fn header_belongs_to(header: &str, table_name: &str) -> bool {
|
||||
let body = match header.strip_prefix("[[").and_then(|h| h.strip_suffix("]]")) {
|
||||
Some(body) => body,
|
||||
None => match header.strip_prefix('[').and_then(|h| h.strip_suffix(']')) {
|
||||
Some(body) => body,
|
||||
None => return false,
|
||||
},
|
||||
};
|
||||
let body = body.trim();
|
||||
body == table_name
|
||||
|| body
|
||||
.strip_prefix(table_name)
|
||||
.is_some_and(|rest| rest.starts_with('.'))
|
||||
}
|
||||
|
||||
/// Locate the first contiguous byte range covering `table_name` and the nested
|
||||
/// sub-tables immediately following it. Used for existence checks; see
|
||||
/// [`find_all_table_blocks`] for the full set of (possibly scattered) blocks.
|
||||
fn find_toml_table_bounds(source: &str, table_name: &str) -> Option<(usize, usize)> {
|
||||
find_all_table_blocks(source, table_name).into_iter().next()
|
||||
}
|
||||
|
||||
/// Locate every byte range that belongs to `table_name`: the table header and
|
||||
/// its nested sub-tables. Returns one range per contiguous run, so a config
|
||||
/// where a section's sub-tables are scattered (e.g. hand-edited) yields several
|
||||
/// ranges — letting the caller collapse them into a single rendered block.
|
||||
fn find_all_table_blocks(source: &str, table_name: &str) -> Vec<(usize, usize)> {
|
||||
let mut blocks = Vec::new();
|
||||
let mut offset = 0usize;
|
||||
let mut start: Option<usize> = None;
|
||||
|
||||
for line in source.split_inclusive('\n') {
|
||||
// Drop any inline comment so a hand-edited header like
|
||||
// `[censorship] # note` still matches. Section names never contain `#`.
|
||||
let header = line.trim().split('#').next().unwrap_or("").trim();
|
||||
let is_header = header.starts_with('[');
|
||||
if let Some(start_offset) = start {
|
||||
if is_header && !header_belongs_to(header, table_name) {
|
||||
blocks.push((start_offset, offset));
|
||||
start = None;
|
||||
}
|
||||
}
|
||||
if start.is_none() && header_belongs_to(header, table_name) {
|
||||
start = Some(offset);
|
||||
}
|
||||
offset = offset.saturating_add(line.len());
|
||||
}
|
||||
|
||||
if let Some(start_offset) = start {
|
||||
blocks.push((start_offset, source.len()));
|
||||
}
|
||||
blocks
|
||||
}
|
||||
|
||||
async fn write_atomic(path: PathBuf, contents: String) -> Result<(), ApiFailure> {
|
||||
tokio::task::spawn_blocking(move || write_atomic_sync(&path, &contents))
|
||||
.await
|
||||
.map_err(|e| ApiFailure::internal(format!("failed to join writer: {}", e)))?
|
||||
.map_err(|e| ApiFailure::internal(format!("failed to write config: {}", e)))
|
||||
}
|
||||
|
||||
fn write_atomic_sync(path: &Path, contents: &str) -> std::io::Result<()> {
|
||||
let parent = path.parent().unwrap_or_else(|| Path::new("."));
|
||||
std::fs::create_dir_all(parent)?;
|
||||
|
||||
let tmp_name = format!(
|
||||
".{}.tmp-{}",
|
||||
path.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("config.toml"),
|
||||
rand::random::<u64>()
|
||||
);
|
||||
let tmp_path = parent.join(tmp_name);
|
||||
|
||||
let write_result = (|| {
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(&tmp_path)?;
|
||||
file.write_all(contents.as_bytes())?;
|
||||
file.sync_all()?;
|
||||
std::fs::rename(&tmp_path, path)?;
|
||||
if let Ok(dir) = std::fs::File::open(parent) {
|
||||
let _ = dir.sync_all();
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
if write_result.is_err() {
|
||||
let _ = std::fs::remove_file(&tmp_path);
|
||||
}
|
||||
write_result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
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::<u64>()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("config.toml");
|
||||
std::fs::write(
|
||||
&path,
|
||||
"# top comment\n[censorship]\ntls_domain = \"old.example\"\n\n[server]\nport = 443\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut cfg = ProxyConfig::default();
|
||||
cfg.censorship.tls_domain = "new.example".to_string();
|
||||
cfg.server.port = 443;
|
||||
|
||||
let rev = save_sections_to_disk(&path, &cfg, &["censorship"])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let written = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(written.contains("tls_domain = \"new.example\""));
|
||||
assert!(written.contains("# top comment")); // untouched comment kept
|
||||
assert!(written.contains("[server]\nport = 443")); // untouched table kept
|
||||
assert_eq!(rev, compute_revision(&written));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_bounds_matches_array_of_tables() {
|
||||
let src =
|
||||
"[server]\nport = 1\n\n[[upstreams]]\nkind = \"a\"\n\n[[upstreams]]\nkind = \"b\"\n";
|
||||
let bounds = find_toml_table_bounds(src, "upstreams");
|
||||
assert!(bounds.is_some(), "should locate [[upstreams]] block start");
|
||||
let (start, end) = bounds.unwrap();
|
||||
let slice = &src[start..end];
|
||||
assert!(slice.starts_with("[[upstreams]]"));
|
||||
assert!(slice.contains("kind = \"b\"")); // spans through the last upstream block
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_bounds_matches_header_with_inline_comment() {
|
||||
let src = "[censorship] # notes\ntls_domain = \"a\"\n\n[server]\nport = 1\n";
|
||||
let bounds = find_toml_table_bounds(src, "censorship");
|
||||
assert!(bounds.is_some(), "commented header must still match");
|
||||
let (start, end) = bounds.unwrap();
|
||||
let slice = &src[start..end];
|
||||
assert!(slice.starts_with("[censorship] # notes"));
|
||||
assert!(slice.contains("tls_domain"));
|
||||
assert!(!slice.contains("[server]")); // terminates at the next header
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_general_section_keeps_subtables_dotted_without_duplicates() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
tokio::fs::write(
|
||||
&path,
|
||||
"[general]\nprefer_ipv6 = false\n\n[general.modes]\ntls = true\n\n\
|
||||
[general.links]\npublic_host = \"old.example\"\n\n[server]\nport = 443\n",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut cfg = ProxyConfig::default();
|
||||
cfg.general.prefer_ipv6 = true;
|
||||
|
||||
save_sections_to_disk(&path, &cfg, &["general"])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let written = tokio::fs::read_to_string(&path).await.unwrap();
|
||||
|
||||
// No bare top-level [modes] / [links] headers leaked.
|
||||
for line in written.lines() {
|
||||
let header = line.trim();
|
||||
assert_ne!(header, "[modes]", "leaked top-level [modes]:\n{written}");
|
||||
assert_ne!(header, "[links]", "leaked top-level [links]:\n{written}");
|
||||
}
|
||||
|
||||
// Sub-tables kept their dotted prefix exactly once each.
|
||||
assert_eq!(
|
||||
written.matches("[general.modes]").count(),
|
||||
1,
|
||||
"[general.modes] must appear exactly once:\n{written}"
|
||||
);
|
||||
assert_eq!(
|
||||
written.matches("[general.links]").count(),
|
||||
1,
|
||||
"[general.links] must appear exactly once:\n{written}"
|
||||
);
|
||||
|
||||
// Result parses (duplicate tables would error here).
|
||||
toml::from_str::<toml::Value>(&written)
|
||||
.unwrap_or_else(|e| panic!("written config must parse: {e}\n{written}"));
|
||||
|
||||
assert!(written.contains("[server]\nport = 443")); // untouched table kept
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_general_section_is_idempotent_across_repeated_saves() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
tokio::fs::write(
|
||||
&path,
|
||||
"[general]\nprefer_ipv6 = false\n\n[general.modes]\ntls = true\n\n\
|
||||
[general.links]\npublic_host = \"old.example\"\n",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut cfg = ProxyConfig::default();
|
||||
cfg.general.prefer_ipv6 = true;
|
||||
|
||||
save_sections_to_disk(&path, &cfg, &["general"])
|
||||
.await
|
||||
.unwrap();
|
||||
save_sections_to_disk(&path, &cfg, &["general"])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let written = tokio::fs::read_to_string(&path).await.unwrap();
|
||||
assert_eq!(written.matches("[general.modes]").count(), 1, "{written}");
|
||||
assert_eq!(written.matches("[general.links]").count(), 1, "{written}");
|
||||
assert_eq!(written.matches("[general]").count(), 1, "{written}");
|
||||
toml::from_str::<toml::Value>(&written)
|
||||
.unwrap_or_else(|e| panic!("written config must parse: {e}\n{written}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_bounds_spans_dotted_subtables() {
|
||||
let src = "[general]\nprefer_ipv6 = false\n\n[general.modes]\ntls = true\n\n\
|
||||
[general.links]\npublic_host = \"a\"\n\n[server]\nport = 1\n";
|
||||
let bounds = find_toml_table_bounds(src, "general");
|
||||
assert!(bounds.is_some(), "should locate [general] block");
|
||||
let (start, end) = bounds.unwrap();
|
||||
let slice = &src[start..end];
|
||||
assert!(slice.starts_with("[general]"));
|
||||
assert!(slice.contains("[general.modes]")); // spans nested sub-tables
|
||||
assert!(slice.contains("[general.links]"));
|
||||
assert!(!slice.contains("[server]")); // terminates at the next unrelated header
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_bounds_does_not_overrun_sibling_prefix() {
|
||||
// access.users must not swallow access.user_enabled (dot guards the prefix).
|
||||
let src = "[access.users]\nalice = \"x\"\n\n[access.user_enabled]\nalice = true\n";
|
||||
let bounds = find_toml_table_bounds(src, "access.users").unwrap();
|
||||
let slice = &src[bounds.0..bounds.1];
|
||||
assert!(slice.starts_with("[access.users]"));
|
||||
assert!(!slice.contains("[access.user_enabled]"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_general_handles_non_contiguous_subtables() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
// Hand-edited layout: [general.modes] sits AFTER an unrelated [server].
|
||||
tokio::fs::write(
|
||||
&path,
|
||||
"[general]\nprefer_ipv6 = false\n\n[server]\nport = 443\n\n\
|
||||
[general.modes]\ntls = true\n",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut cfg = ProxyConfig::default();
|
||||
cfg.general.prefer_ipv6 = true;
|
||||
|
||||
save_sections_to_disk(&path, &cfg, &["general"])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let written = tokio::fs::read_to_string(&path).await.unwrap();
|
||||
assert_eq!(
|
||||
written.matches("[general.modes]").count(),
|
||||
1,
|
||||
"non-contiguous [general.modes] must not duplicate:\n{written}"
|
||||
);
|
||||
toml::from_str::<toml::Value>(&written)
|
||||
.unwrap_or_else(|e| panic!("written config must parse: {e}\n{written}"));
|
||||
assert!(written.contains("[server]")); // unrelated section preserved
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_user_rate_limits_section() {
|
||||
let mut cfg = ProxyConfig::default();
|
||||
cfg.access.user_rate_limits.insert(
|
||||
"alice".to_string(),
|
||||
RateLimitBps {
|
||||
up_bps: 1024,
|
||||
down_bps: 2048,
|
||||
},
|
||||
);
|
||||
|
||||
let rendered = render_access_section(&cfg, AccessSection::UserRateLimits)
|
||||
.expect("section must render");
|
||||
|
||||
assert!(rendered.starts_with("[access.user_rate_limits]\n"));
|
||||
assert!(rendered.contains("alice = { up_bps = 1024, down_bps = 2048 }"));
|
||||
}
|
||||
}
|
||||
#[path = "config_store/tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::config::{ProxyConfig, RateLimitBps};
|
||||
|
||||
use super::{
|
||||
AccessSection, compute_snapshot_revision, load_candidate_snapshot, load_config_snapshot,
|
||||
resolve_single_source_owner, toml_path_exists,
|
||||
};
|
||||
use crate::api::model::ApiFailure;
|
||||
#[cfg(test)]
|
||||
use super::compute_revision;
|
||||
|
||||
/// Re-render the given top-level tables from `cfg` and upsert each into the
|
||||
/// on-disk file, preserving every untouched section (and its comments).
|
||||
#[cfg(test)]
|
||||
pub(super) async fn save_sections_to_disk(
|
||||
config_path: &Path,
|
||||
cfg: &ProxyConfig,
|
||||
sections: &[&str],
|
||||
) -> Result<String, ApiFailure> {
|
||||
let mut content = tokio::fs::read_to_string(config_path)
|
||||
.await
|
||||
.map_err(|e| ApiFailure::internal(format!("failed to read config: {}", e)))?;
|
||||
|
||||
for section in sections {
|
||||
let rendered = render_top_level_section(cfg, section)?;
|
||||
content = upsert_toml_table(&content, section, &rendered);
|
||||
}
|
||||
|
||||
write_atomic(config_path.to_path_buf(), content.clone()).await?;
|
||||
Ok(compute_revision(&content))
|
||||
}
|
||||
|
||||
/// Render one top-level table as `[section]\n...\n` (or `[[upstreams]]` array
|
||||
/// of tables) from the typed `cfg`. Serializes via the `toml` crate so the
|
||||
/// output matches the canonical format Telemt parses.
|
||||
pub(in crate::api) fn render_top_level_section(
|
||||
cfg: &ProxyConfig,
|
||||
section: &str,
|
||||
) -> Result<String, ApiFailure> {
|
||||
let value = toml::Value::try_from(cfg)
|
||||
.map_err(|e| ApiFailure::internal(format!("failed to serialize config: {}", e)))?;
|
||||
let table = value
|
||||
.get(section)
|
||||
.ok_or_else(|| ApiFailure::internal(format!("unknown section: {}", section)))?;
|
||||
|
||||
// upstreams is an array-of-tables -> render as [[upstreams]] blocks.
|
||||
if let toml::Value::Array(items) = table {
|
||||
let mut out = String::new();
|
||||
for item in items {
|
||||
out.push_str(&format!("[[{}]]\n", section));
|
||||
out.push_str(&toml::to_string(item).map_err(|e| {
|
||||
ApiFailure::internal(format!("failed to serialize {}: {}", section, e))
|
||||
})?);
|
||||
if !out.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
|
||||
// Serialize the table *inside a wrapper keyed by `section`* so the `toml`
|
||||
// crate emits correctly dotted headers for nested sub-tables, e.g.
|
||||
// `[general]` + `[general.modes]` + `[general.links]`. Serializing the
|
||||
// inner table alone would render bare `[modes]`/`[links]` headers, which
|
||||
// would leak as duplicate top-level tables and break config load.
|
||||
let mut wrapper = toml::value::Table::new();
|
||||
wrapper.insert(section.to_string(), table.clone());
|
||||
let mut out = toml::to_string(&toml::Value::Table(wrapper))
|
||||
.map_err(|e| ApiFailure::internal(format!("failed to serialize {}: {}", section, e)))?;
|
||||
if !out.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Renders normalized listener entries as nested array-of-table blocks.
|
||||
pub(in crate::api) fn render_server_listeners(
|
||||
cfg: &ProxyConfig,
|
||||
) -> Result<String, ApiFailure> {
|
||||
let mut out = String::new();
|
||||
for listener in &cfg.server.listeners {
|
||||
out.push_str("[[server.listeners]]\n");
|
||||
out.push_str(&toml::to_string(listener).map_err(|error| {
|
||||
ApiFailure::internal(format!("failed to serialize server.listeners: {error}"))
|
||||
})?);
|
||||
if !out.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Validates and atomically writes access tables to their single source owner.
|
||||
pub(in crate::api) async fn save_access_sections_to_disk(
|
||||
config_path: &Path,
|
||||
cfg: &ProxyConfig,
|
||||
sections: &[AccessSection],
|
||||
) -> Result<String, ApiFailure> {
|
||||
let loaded = load_config_snapshot(config_path, false).await?;
|
||||
let mut applied = Vec::new();
|
||||
for section in sections {
|
||||
if applied.contains(section) {
|
||||
continue;
|
||||
}
|
||||
applied.push(*section);
|
||||
}
|
||||
applied.retain(|section| {
|
||||
!access_section_is_empty(cfg, *section)
|
||||
|| loaded.source_contents.values().any(|contents| {
|
||||
toml::from_str::<toml::Value>(contents)
|
||||
.ok()
|
||||
.is_some_and(|value| toml_path_exists(&value, section.table_name()))
|
||||
})
|
||||
});
|
||||
if applied.is_empty() {
|
||||
return Ok(compute_snapshot_revision(&loaded));
|
||||
}
|
||||
|
||||
let targets = applied
|
||||
.iter()
|
||||
.map(|section| section.table_name())
|
||||
.collect::<Vec<_>>();
|
||||
let owner_path = resolve_single_source_owner(&loaded, config_path, &targets)?;
|
||||
let mut owner_contents = loaded
|
||||
.source_contents
|
||||
.get(&owner_path)
|
||||
.cloned()
|
||||
.ok_or_else(|| ApiFailure::internal("config source owner is missing from snapshot"))?;
|
||||
for section in applied {
|
||||
let rendered = render_access_section(cfg, section)?;
|
||||
owner_contents = upsert_toml_table(&owner_contents, section.table_name(), &rendered);
|
||||
}
|
||||
|
||||
let candidate = load_candidate_snapshot(
|
||||
config_path,
|
||||
&loaded.source_contents,
|
||||
owner_path.clone(),
|
||||
owner_contents.clone(),
|
||||
)
|
||||
.await?;
|
||||
let revision = compute_snapshot_revision(&candidate);
|
||||
write_atomic(owner_path, owner_contents).await?;
|
||||
Ok(revision)
|
||||
}
|
||||
|
||||
/// Renders one access-control table for persistence tests and user mutations.
|
||||
pub(super) fn render_access_section(
|
||||
cfg: &ProxyConfig,
|
||||
section: AccessSection,
|
||||
) -> Result<String, ApiFailure> {
|
||||
let body = match section {
|
||||
AccessSection::Users => {
|
||||
let rows: BTreeMap<String, String> = cfg
|
||||
.access
|
||||
.users
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), value.clone()))
|
||||
.collect();
|
||||
serialize_table_body(&rows)?
|
||||
}
|
||||
AccessSection::UserEnabled => {
|
||||
let rows: BTreeMap<String, bool> = cfg
|
||||
.access
|
||||
.user_enabled
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), *value))
|
||||
.collect();
|
||||
serialize_table_body(&rows)?
|
||||
}
|
||||
AccessSection::UserAdTags => {
|
||||
let rows: BTreeMap<String, String> = cfg
|
||||
.access
|
||||
.user_ad_tags
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), value.clone()))
|
||||
.collect();
|
||||
serialize_table_body(&rows)?
|
||||
}
|
||||
AccessSection::UserMaxTcpConns => {
|
||||
let rows: BTreeMap<String, usize> = cfg
|
||||
.access
|
||||
.user_max_tcp_conns
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), *value))
|
||||
.collect();
|
||||
serialize_table_body(&rows)?
|
||||
}
|
||||
AccessSection::UserExpirations => {
|
||||
let rows: BTreeMap<String, DateTime<Utc>> = cfg
|
||||
.access
|
||||
.user_expirations
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), *value))
|
||||
.collect();
|
||||
serialize_table_body(&rows)?
|
||||
}
|
||||
AccessSection::UserDataQuota => {
|
||||
let rows: BTreeMap<String, u64> = cfg
|
||||
.access
|
||||
.user_data_quota
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), *value))
|
||||
.collect();
|
||||
serialize_table_body(&rows)?
|
||||
}
|
||||
AccessSection::UserRateLimits => {
|
||||
let rows: BTreeMap<String, RateLimitBps> = cfg
|
||||
.access
|
||||
.user_rate_limits
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), *value))
|
||||
.collect();
|
||||
serialize_rate_limit_body(&rows)?
|
||||
}
|
||||
AccessSection::UserMaxUniqueIps => {
|
||||
let rows: BTreeMap<String, usize> = cfg
|
||||
.access
|
||||
.user_max_unique_ips
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), *value))
|
||||
.collect();
|
||||
serialize_table_body(&rows)?
|
||||
}
|
||||
};
|
||||
|
||||
let mut out = format!("[{}]\n", section.table_name());
|
||||
if !body.is_empty() {
|
||||
out.push_str(&body);
|
||||
}
|
||||
if !out.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn access_section_is_empty(cfg: &ProxyConfig, section: AccessSection) -> bool {
|
||||
match section {
|
||||
AccessSection::Users => cfg.access.users.is_empty(),
|
||||
AccessSection::UserEnabled => cfg.access.user_enabled.is_empty(),
|
||||
AccessSection::UserAdTags => cfg.access.user_ad_tags.is_empty(),
|
||||
AccessSection::UserMaxTcpConns => cfg.access.user_max_tcp_conns.is_empty(),
|
||||
AccessSection::UserExpirations => cfg.access.user_expirations.is_empty(),
|
||||
AccessSection::UserDataQuota => cfg.access.user_data_quota.is_empty(),
|
||||
AccessSection::UserRateLimits => cfg.access.user_rate_limits.is_empty(),
|
||||
AccessSection::UserMaxUniqueIps => cfg.access.user_max_unique_ips.is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
fn serialize_table_body<T: Serialize>(value: &T) -> Result<String, ApiFailure> {
|
||||
toml::to_string(value)
|
||||
.map_err(|e| ApiFailure::internal(format!("failed to serialize access section: {}", e)))
|
||||
}
|
||||
|
||||
fn serialize_rate_limit_body(rows: &BTreeMap<String, RateLimitBps>) -> Result<String, ApiFailure> {
|
||||
let mut out = String::new();
|
||||
for (key, value) in rows {
|
||||
let key = serialize_toml_key(key)?;
|
||||
out.push_str(&format!(
|
||||
"{key} = {{ up_bps = {}, down_bps = {} }}\n",
|
||||
value.up_bps, value.down_bps
|
||||
));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn serialize_toml_key(key: &str) -> Result<String, ApiFailure> {
|
||||
let mut row = BTreeMap::new();
|
||||
row.insert(key.to_string(), 0_u8);
|
||||
let rendered = serialize_table_body(&row)?;
|
||||
rendered
|
||||
.split_once(" = ")
|
||||
.map(|(key, _)| key.to_string())
|
||||
.ok_or_else(|| ApiFailure::internal("failed to serialize TOML key"))
|
||||
}
|
||||
|
||||
/// Replaces all blocks owned by one semantic TOML table with one rendering.
|
||||
pub(in crate::api) fn upsert_toml_table(
|
||||
source: &str,
|
||||
table_name: &str,
|
||||
replacement: &str,
|
||||
) -> String {
|
||||
let blocks = find_all_table_blocks(source, table_name);
|
||||
if let Some(&(first_start, first_end)) = blocks.first() {
|
||||
// Replace the first block in place and delete any further blocks that
|
||||
// also belong to this table. Telemt writes a section's sub-tables
|
||||
// contiguously, but a hand-edited config may scatter them; dropping the
|
||||
// extras here prevents the duplicate-table corruption that would
|
||||
// otherwise break config load.
|
||||
let mut out = String::with_capacity(source.len() + replacement.len());
|
||||
out.push_str(&source[..first_start]);
|
||||
out.push_str(replacement);
|
||||
let mut cursor = first_end;
|
||||
for &(start, end) in &blocks[1..] {
|
||||
out.push_str(&source[cursor..start]);
|
||||
cursor = end;
|
||||
}
|
||||
out.push_str(&source[cursor..]);
|
||||
return out;
|
||||
}
|
||||
|
||||
let mut out = source.to_string();
|
||||
if !out.is_empty() && !out.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
if !out.is_empty() {
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str(replacement);
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether a (comment-stripped, trimmed) TOML header line belongs to
|
||||
/// `table_name`: the table itself (`[X]` / `[[X]]`) or any of its nested
|
||||
/// sub-tables (`[X.…]` / `[[X.…]]`). The trailing dot guards against sibling
|
||||
/// prefixes — `access.users` must not match `access.user_enabled`.
|
||||
fn header_belongs_to(header: &str, table_name: &str) -> bool {
|
||||
let body = match header.strip_prefix("[[").and_then(|h| h.strip_suffix("]]")) {
|
||||
Some(body) => body,
|
||||
None => match header.strip_prefix('[').and_then(|h| h.strip_suffix(']')) {
|
||||
Some(body) => body,
|
||||
None => return false,
|
||||
},
|
||||
};
|
||||
let body = body.trim();
|
||||
body == table_name
|
||||
|| body
|
||||
.strip_prefix(table_name)
|
||||
.is_some_and(|rest| rest.starts_with('.'))
|
||||
}
|
||||
|
||||
/// Locate the first contiguous byte range covering `table_name` and the nested
|
||||
/// sub-tables immediately following it. Used for existence checks; see
|
||||
/// [`find_all_table_blocks`] for the full set of (possibly scattered) blocks.
|
||||
/// Locates one complete TOML table block in source text.
|
||||
#[cfg(test)]
|
||||
pub(super) fn find_toml_table_bounds(
|
||||
source: &str,
|
||||
table_name: &str,
|
||||
) -> Option<(usize, usize)> {
|
||||
find_all_table_blocks(source, table_name).into_iter().next()
|
||||
}
|
||||
|
||||
/// Locate every byte range that belongs to `table_name`: the table header and
|
||||
/// its nested sub-tables. Returns one range per contiguous run, so a config
|
||||
/// where a section's sub-tables are scattered (e.g. hand-edited) yields several
|
||||
/// ranges — letting the caller collapse them into a single rendered block.
|
||||
fn find_all_table_blocks(source: &str, table_name: &str) -> Vec<(usize, usize)> {
|
||||
let mut blocks = Vec::new();
|
||||
let mut offset = 0usize;
|
||||
let mut start: Option<usize> = None;
|
||||
|
||||
for line in source.split_inclusive('\n') {
|
||||
// Drop any inline comment so a hand-edited header like
|
||||
// `[censorship] # note` still matches. Section names never contain `#`.
|
||||
let header = line.trim().split('#').next().unwrap_or("").trim();
|
||||
let is_header = header.starts_with('[');
|
||||
if let Some(start_offset) = start {
|
||||
if is_header && !header_belongs_to(header, table_name) {
|
||||
blocks.push((start_offset, offset));
|
||||
start = None;
|
||||
}
|
||||
}
|
||||
if start.is_none() && header_belongs_to(header, table_name) {
|
||||
start = Some(offset);
|
||||
}
|
||||
offset = offset.saturating_add(line.len());
|
||||
}
|
||||
|
||||
if let Some(start_offset) = start {
|
||||
blocks.push((start_offset, source.len()));
|
||||
}
|
||||
blocks
|
||||
}
|
||||
|
||||
/// Replaces one config source through a durable same-directory rename.
|
||||
pub(in crate::api) async fn write_atomic(
|
||||
path: PathBuf,
|
||||
contents: String,
|
||||
) -> Result<(), ApiFailure> {
|
||||
tokio::task::spawn_blocking(move || write_atomic_sync(&path, &contents))
|
||||
.await
|
||||
.map_err(|e| ApiFailure::internal(format!("failed to join writer: {}", e)))?
|
||||
.map_err(|e| ApiFailure::internal(format!("failed to write config: {}", e)))
|
||||
}
|
||||
|
||||
fn write_atomic_sync(path: &Path, contents: &str) -> std::io::Result<()> {
|
||||
let parent = path.parent().unwrap_or_else(|| Path::new("."));
|
||||
std::fs::create_dir_all(parent)?;
|
||||
|
||||
let tmp_name = format!(
|
||||
".{}.tmp-{}",
|
||||
path.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("config.toml"),
|
||||
rand::random::<u64>()
|
||||
);
|
||||
let tmp_path = parent.join(tmp_name);
|
||||
|
||||
let write_result = (|| {
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(&tmp_path)?;
|
||||
file.write_all(contents.as_bytes())?;
|
||||
file.sync_all()?;
|
||||
std::fs::rename(&tmp_path, path)?;
|
||||
if let Ok(dir) = std::fs::File::open(parent) {
|
||||
let _ = dir.sync_all();
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
if write_result.is_err() {
|
||||
let _ = std::fs::remove_file(&tmp_path);
|
||||
}
|
||||
write_result
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
use super::*;
|
||||
use crate::config::RateLimitBps;
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_sections_preserves_other_tables_and_comments() {
|
||||
let dir = std::env::temp_dir().join(format!("cfgtest-{}", rand::random::<u64>()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("config.toml");
|
||||
std::fs::write(
|
||||
&path,
|
||||
"# top comment\n[censorship]\ntls_domain = \"old.example\"\n\n[server]\nport = 443\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut cfg = ProxyConfig::default();
|
||||
cfg.censorship.tls_domain = "new.example".to_string();
|
||||
cfg.server.port = 443;
|
||||
|
||||
let rev = save_sections_to_disk(&path, &cfg, &["censorship"])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let written = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(written.contains("tls_domain = \"new.example\""));
|
||||
// Untouched comments and tables remain byte content.
|
||||
assert!(written.contains("# top comment"));
|
||||
assert!(written.contains("[server]\nport = 443"));
|
||||
assert_eq!(rev, compute_revision(&written));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_bounds_matches_array_of_tables() {
|
||||
let src =
|
||||
"[server]\nport = 1\n\n[[upstreams]]\nkind = \"a\"\n\n[[upstreams]]\nkind = \"b\"\n";
|
||||
let bounds = find_toml_table_bounds(src, "upstreams");
|
||||
assert!(bounds.is_some(), "should locate [[upstreams]] block start");
|
||||
let (start, end) = bounds.unwrap();
|
||||
let slice = &src[start..end];
|
||||
assert!(slice.starts_with("[[upstreams]]"));
|
||||
// The bound spans through the last upstream block.
|
||||
assert!(slice.contains("kind = \"b\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_bounds_matches_header_with_inline_comment() {
|
||||
let src = "[censorship] # notes\ntls_domain = \"a\"\n\n[server]\nport = 1\n";
|
||||
let bounds = find_toml_table_bounds(src, "censorship");
|
||||
assert!(bounds.is_some(), "commented header must still match");
|
||||
let (start, end) = bounds.unwrap();
|
||||
let slice = &src[start..end];
|
||||
assert!(slice.starts_with("[censorship] # notes"));
|
||||
assert!(slice.contains("tls_domain"));
|
||||
// The bound terminates at the next header.
|
||||
assert!(!slice.contains("[server]"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_general_section_keeps_subtables_dotted_without_duplicates() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
tokio::fs::write(
|
||||
&path,
|
||||
"[general]\nprefer_ipv6 = false\n\n[general.modes]\ntls = true\n\n\
|
||||
[general.links]\npublic_host = \"old.example\"\n\n[server]\nport = 443\n",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut cfg = ProxyConfig::default();
|
||||
cfg.general.prefer_ipv6 = true;
|
||||
|
||||
save_sections_to_disk(&path, &cfg, &["general"])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let written = tokio::fs::read_to_string(&path).await.unwrap();
|
||||
|
||||
// No bare top-level [modes] / [links] headers leaked.
|
||||
for line in written.lines() {
|
||||
let header = line.trim();
|
||||
assert_ne!(header, "[modes]", "leaked top-level [modes]:\n{written}");
|
||||
assert_ne!(header, "[links]", "leaked top-level [links]:\n{written}");
|
||||
}
|
||||
|
||||
// Sub-tables kept their dotted prefix exactly once each.
|
||||
assert_eq!(
|
||||
written.matches("[general.modes]").count(),
|
||||
1,
|
||||
"[general.modes] must appear exactly once:\n{written}"
|
||||
);
|
||||
assert_eq!(
|
||||
written.matches("[general.links]").count(),
|
||||
1,
|
||||
"[general.links] must appear exactly once:\n{written}"
|
||||
);
|
||||
|
||||
// Result parses (duplicate tables would error here).
|
||||
toml::from_str::<toml::Value>(&written)
|
||||
.unwrap_or_else(|e| panic!("written config must parse: {e}\n{written}"));
|
||||
|
||||
// The unrelated table remains untouched.
|
||||
assert!(written.contains("[server]\nport = 443"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_general_section_is_idempotent_across_repeated_saves() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
tokio::fs::write(
|
||||
&path,
|
||||
"[general]\nprefer_ipv6 = false\n\n[general.modes]\ntls = true\n\n\
|
||||
[general.links]\npublic_host = \"old.example\"\n",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut cfg = ProxyConfig::default();
|
||||
cfg.general.prefer_ipv6 = true;
|
||||
|
||||
save_sections_to_disk(&path, &cfg, &["general"])
|
||||
.await
|
||||
.unwrap();
|
||||
save_sections_to_disk(&path, &cfg, &["general"])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let written = tokio::fs::read_to_string(&path).await.unwrap();
|
||||
assert_eq!(written.matches("[general.modes]").count(), 1, "{written}");
|
||||
assert_eq!(written.matches("[general.links]").count(), 1, "{written}");
|
||||
assert_eq!(written.matches("[general]").count(), 1, "{written}");
|
||||
toml::from_str::<toml::Value>(&written)
|
||||
.unwrap_or_else(|e| panic!("written config must parse: {e}\n{written}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_bounds_spans_dotted_subtables() {
|
||||
let src = "[general]\nprefer_ipv6 = false\n\n[general.modes]\ntls = true\n\n\
|
||||
[general.links]\npublic_host = \"a\"\n\n[server]\nport = 1\n";
|
||||
let bounds = find_toml_table_bounds(src, "general");
|
||||
assert!(bounds.is_some(), "should locate [general] block");
|
||||
let (start, end) = bounds.unwrap();
|
||||
let slice = &src[start..end];
|
||||
assert!(slice.starts_with("[general]"));
|
||||
// Nested sub-tables belong to the parent table bound.
|
||||
assert!(slice.contains("[general.modes]"));
|
||||
assert!(slice.contains("[general.links]"));
|
||||
// The bound terminates before an unrelated header.
|
||||
assert!(!slice.contains("[server]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_bounds_does_not_overrun_sibling_prefix() {
|
||||
// access.users must not swallow access.user_enabled (dot guards the prefix).
|
||||
let src = "[access.users]\nalice = \"x\"\n\n[access.user_enabled]\nalice = true\n";
|
||||
let bounds = find_toml_table_bounds(src, "access.users").unwrap();
|
||||
let slice = &src[bounds.0..bounds.1];
|
||||
assert!(slice.starts_with("[access.users]"));
|
||||
assert!(!slice.contains("[access.user_enabled]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_include_detection_does_not_reject_similar_access_keys() {
|
||||
assert!(has_include_inside_table(
|
||||
"[access.users]\ninclude = \"users.toml\"\n"
|
||||
));
|
||||
assert!(!has_include_inside_table(
|
||||
"[access.users]\ninclude_user = \"00000000000000000000000000000000\"\n"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_general_handles_non_contiguous_subtables() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
// Hand-edited layout: [general.modes] sits AFTER an unrelated [server].
|
||||
tokio::fs::write(
|
||||
&path,
|
||||
"[general]\nprefer_ipv6 = false\n\n[server]\nport = 443\n\n\
|
||||
[general.modes]\ntls = true\n",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut cfg = ProxyConfig::default();
|
||||
cfg.general.prefer_ipv6 = true;
|
||||
|
||||
save_sections_to_disk(&path, &cfg, &["general"])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let written = tokio::fs::read_to_string(&path).await.unwrap();
|
||||
assert_eq!(
|
||||
written.matches("[general.modes]").count(),
|
||||
1,
|
||||
"non-contiguous [general.modes] must not duplicate:\n{written}"
|
||||
);
|
||||
toml::from_str::<toml::Value>(&written)
|
||||
.unwrap_or_else(|e| panic!("written config must parse: {e}\n{written}"));
|
||||
// The unrelated section remains present.
|
||||
assert!(written.contains("[server]"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manifest_revision_changes_when_an_included_source_changes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().join("config.toml");
|
||||
let included = dir.path().join("included.toml");
|
||||
tokio::fs::write(&root, "include = \"included.toml\"\n")
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::fs::write(&included, "[censorship]\ntls_domain = \"one.example\"\n")
|
||||
.await
|
||||
.unwrap();
|
||||
let first = current_revision(&root).await.unwrap();
|
||||
|
||||
tokio::fs::write(&included, "[censorship]\ntls_domain = \"two.example\"\n")
|
||||
.await
|
||||
.unwrap();
|
||||
let second = current_revision(&root).await.unwrap();
|
||||
|
||||
assert_ne!(first, second);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manifest_revision_does_not_require_typed_config_validation() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().join("config.toml");
|
||||
tokio::fs::write(&root, "[server]\nport = \"invalid\"\n")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let revision = current_revision(&root).await.unwrap();
|
||||
|
||||
assert_eq!(revision.len(), 64);
|
||||
assert!(load_config_snapshot(&root, false).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn access_mutation_writes_only_the_single_included_owner() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().join("config.toml");
|
||||
let included = dir.path().join("users.toml");
|
||||
let root_body = "include = \"users.toml\"\n[censorship]\ntls_domain = \"example.com\"\n";
|
||||
let included_body =
|
||||
"[access.users]\nalice = \"00000000000000000000000000000000\"\n";
|
||||
tokio::fs::write(&root, root_body).await.unwrap();
|
||||
tokio::fs::write(&included, included_body).await.unwrap();
|
||||
let mut cfg = load_config_from_disk(&root).await.unwrap();
|
||||
cfg.access.users.insert(
|
||||
"bob".to_string(),
|
||||
"11111111111111111111111111111111".to_string(),
|
||||
);
|
||||
|
||||
let revision = save_access_sections_to_disk(&root, &cfg, &[AccessSection::Users])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(tokio::fs::read_to_string(&root).await.unwrap(), root_body);
|
||||
let written = tokio::fs::read_to_string(&included).await.unwrap();
|
||||
assert!(written.contains("bob = \"11111111111111111111111111111111\""));
|
||||
assert_eq!(revision, current_revision(&root).await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn access_mutation_rejects_sections_with_different_source_owners() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().join("config.toml");
|
||||
let included = dir.path().join("enabled.toml");
|
||||
let root_body = concat!(
|
||||
"include = \"enabled.toml\"\n",
|
||||
"[access.users]\nalice = \"00000000000000000000000000000000\"\n"
|
||||
);
|
||||
let included_body = "[access.user_enabled]\nalice = false\n";
|
||||
tokio::fs::write(&root, root_body).await.unwrap();
|
||||
tokio::fs::write(&included, included_body).await.unwrap();
|
||||
let cfg = load_config_from_disk(&root).await.unwrap();
|
||||
|
||||
let error = save_access_sections_to_disk(
|
||||
&root,
|
||||
&cfg,
|
||||
&[AccessSection::Users, AccessSection::UserEnabled],
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(error.code, "config_patch_not_atomic");
|
||||
assert_eq!(tokio::fs::read_to_string(&root).await.unwrap(), root_body);
|
||||
assert_eq!(
|
||||
tokio::fs::read_to_string(&included).await.unwrap(),
|
||||
included_body
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_user_rate_limits_section() {
|
||||
let mut cfg = ProxyConfig::default();
|
||||
cfg.access.user_rate_limits.insert(
|
||||
"alice".to_string(),
|
||||
RateLimitBps {
|
||||
up_bps: 1024,
|
||||
down_bps: 2048,
|
||||
},
|
||||
);
|
||||
|
||||
let rendered = render_access_section(&cfg, AccessSection::UserRateLimits)
|
||||
.expect("section must render");
|
||||
|
||||
assert!(rendered.starts_with("[access.user_rate_limits]\n"));
|
||||
assert!(rendered.contains("alice = { up_bps = 1024, down_bps = 2048 }"));
|
||||
}
|
||||
@@ -8,7 +8,7 @@ async fn config_file() -> (tempfile::TempDir, PathBuf, String) {
|
||||
config.server.max_connections = 4_242;
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
+23
-28
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user