mirror of
https://github.com/telemt/telemt.git
synced 2026-06-10 13:01:44 +03:00
Merge pull request #828 from amirotin/feat/config-edit-api
Add config-edit HTTP API: PATCH/GET /v1/config
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -2834,6 +2834,7 @@ dependencies = [
|
||||
"socket2",
|
||||
"static_assertions",
|
||||
"subtle",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
|
||||
@@ -90,6 +90,7 @@ tokio-test = "0.4"
|
||||
criterion = "0.8"
|
||||
proptest = "1.4"
|
||||
futures = "0.3"
|
||||
tempfile = "3.27.0"
|
||||
|
||||
[[bench]]
|
||||
name = "crypto_bench"
|
||||
|
||||
@@ -106,6 +106,8 @@ Notes:
|
||||
| `GET` | `/v1/runtime/tls-fingerprints` | optional `limit=1..1000` | `200` | `RuntimeEdgeTlsFingerprintsData` |
|
||||
| `GET` | `/v1/stats/users/active-ips` | none | `200` | `UserActiveIps[]` |
|
||||
| `GET` | `/v1/stats/users` | none | `200` | `UserInfo[]` |
|
||||
| `GET` | `/v1/config` | none | `200` | `ConfigData` |
|
||||
| `PATCH` | `/v1/config` | sparse JSON object | `200` | `PatchConfigResponse` |
|
||||
| `GET` | `/v1/users` | none | `200` | `UserInfo[]` |
|
||||
| `POST` | `/v1/users` | `CreateUserRequest` | `201` or `202` | `CreateUserResponse` |
|
||||
| `GET` | `/v1/users/{username}` | none | `200` | `UserInfo` |
|
||||
@@ -143,6 +145,8 @@ Notes:
|
||||
| `GET /v1/runtime/events/recent` | Returns recent API/runtime event records with optional `limit` query. |
|
||||
| `GET /v1/stats/users/active-ips` | Returns users that currently have non-empty active source-IP lists. |
|
||||
| `GET /v1/stats/users` | Alias of `GET /v1/users`; returns disk-first user views with runtime lag flag. |
|
||||
| `GET /v1/config` | Returns the current editable config sections as JSON (no `access.*`) plus the revision. |
|
||||
| `PATCH /v1/config` | Applies a sparse patch to editable config sections; validates, writes, and reports restart impact. |
|
||||
| `GET /v1/users` | Returns disk-first user views sorted by username. |
|
||||
| `POST /v1/users` | Creates a user and returns the effective user view plus secret. |
|
||||
| `GET /v1/users/{username}` | Returns one disk-first user view or `404` when absent. |
|
||||
@@ -158,6 +162,8 @@ Notes:
|
||||
| HTTP | `error.code` | Trigger |
|
||||
| --- | --- | --- |
|
||||
| `400` | `bad_request` | Invalid JSON, validation failures, malformed request body. |
|
||||
| `400` | `access_not_editable` | `PATCH /v1/config` body contains an `access` key (managed via users API). |
|
||||
| `400` | `section_not_editable` | `PATCH /v1/config` body contains `server`, `network`, or an unknown top-level key. |
|
||||
| `401` | `unauthorized` | Missing/invalid `Authorization` when `auth_header` is configured. |
|
||||
| `403` | `forbidden` | Source IP is not allowed by whitelist. |
|
||||
| `403` | `read_only` | Mutating endpoint called while `read_only=true`. |
|
||||
@@ -177,6 +183,7 @@ Notes:
|
||||
| Path matching | Exact match on `req.uri().path()`. Query string does not affect route matching. |
|
||||
| Trailing slash | Trimmed for route matching when path length is greater than 1. Example: `/v1/users/` matches `/v1/users`. |
|
||||
| Username route with extra slash | `/v1/users/{username}/...` is not treated as user route and returns `404`. |
|
||||
| `DELETE /v1/config` (or any method not in `GET`, `PATCH`) | `405 method_not_allowed` with `Allow: GET, PATCH`. |
|
||||
| `PUT /v1/users/{username}` | `405 method_not_allowed`. |
|
||||
| `POST /v1/users/{username}` | `404 not_found`. |
|
||||
| `POST /v1/users/{username}/rotate-secret/` | Trailing slash is trimmed and the route matches `rotate-secret`. |
|
||||
@@ -245,6 +252,20 @@ alice = ["203.0.113.0/24", "2001:db8:abcd::/48"]
|
||||
bob = ["198.51.100.42/32"]
|
||||
```
|
||||
|
||||
### `PatchConfigRequest`
|
||||
|
||||
A sparse JSON object containing only the top-level config sections to modify. Each key must be one of the editable sections (`general`, `timeouts`, `censorship`, `upstreams`, `show_link`, `dc_overrides`). Tables within a section are deep-merged field-by-field into the existing config; arrays and scalar values replace the existing value wholesale. Untouched sections and file comments are preserved.
|
||||
|
||||
**Rejected keys:**
|
||||
- `access` → `400 access_not_editable` (users/secrets are managed via `POST/PATCH /v1/users`).
|
||||
- `server`, `network`, or any unknown top-level key → `400 section_not_editable`.
|
||||
- An object with no editable keys → `400 bad_request` (empty patch).
|
||||
|
||||
Example — patch only the SNI domain:
|
||||
```json
|
||||
{"censorship": {"tls_domain": "front.example.com"}}
|
||||
```
|
||||
|
||||
### `RotateSecretRequest`
|
||||
| Field | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
@@ -254,6 +275,31 @@ An empty request body is accepted and generates a new secret automatically.
|
||||
|
||||
## Response Data Contracts
|
||||
|
||||
### `ConfigData`
|
||||
|
||||
Returned by `GET /v1/config` as the envelope `data`. The fields are exactly the editable TOML sections. The current revision is returned in the envelope `revision` field (same value as `config_hash` in `SystemInfoData`), **not** inside `data`.
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `general` | `object?` | `[general]` section, if present in config. |
|
||||
| `timeouts` | `object?` | `[timeouts]` section, if present. |
|
||||
| `censorship` | `object?` | `[censorship]` section, if present. |
|
||||
| `upstreams` | `object?` | `[upstreams]` section, if present. |
|
||||
| `show_link` | `object?` | `[show_link]` section, if present. |
|
||||
| `dc_overrides` | `object?` | `[dc_overrides]` section, if present. |
|
||||
|
||||
Sections absent from the config file are absent from the response (not `null`). Only the editable sections above are returned; `access` (users/secrets), `server` (carries the API `auth_header` and per-node identity), and `network` (per-node addresses) are always excluded.
|
||||
|
||||
### `PatchConfigResponse`
|
||||
|
||||
Returned by `PATCH /v1/config` on success (`200`).
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `revision` | `string` | SHA-256 hex of the config file after the patch was written. |
|
||||
| `restart_required` | `bool` | `true` when one or more changed fields require a process restart to take effect. Hot-reloadable fields (e.g. `general.log_level`) are applied automatically by the config file watcher; restart-required fields (e.g. any `censorship.*`, `timeouts.*`, `upstreams`, or `general.modes` change) are written to disk but only take effect after the Telemt process is restarted. The caller is responsible for triggering a restart when this flag is `true`. |
|
||||
| `changed` | `string[]` | Top-level section names that differed between the old and new config (e.g. `["censorship"]`). |
|
||||
|
||||
### `HealthData`
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
@@ -1279,10 +1325,101 @@ Link generation uses active config and enabled modes:
|
||||
| `used_bytes` | `u64` | Current used bytes after reset; always `0` on success. |
|
||||
| `last_reset_epoch_secs` | `u64` | Unix timestamp of the reset operation. |
|
||||
|
||||
## Config Endpoints
|
||||
|
||||
### `GET /v1/config`
|
||||
|
||||
Returns the current editable config sections as TOML-shaped JSON, plus the current revision. The `access` section (users and secrets) is always stripped and never appears in the response.
|
||||
|
||||
**Auth:** requires `Authorization` header when `auth_header` is configured (same as all other endpoints).
|
||||
|
||||
**Success `200` response body** (`data` field of the standard envelope):
|
||||
```json
|
||||
{
|
||||
"revision": "<sha256-hex>",
|
||||
"censorship": {"tls_domain": "front.example.com"},
|
||||
"general": {"log_level": "normal"}
|
||||
}
|
||||
```
|
||||
|
||||
Top-level sections absent from the config file are absent from the response. Only `GET` and `PATCH` are accepted; any other method returns `405 Method Not Allowed` with `Allow: GET, PATCH`.
|
||||
|
||||
---
|
||||
|
||||
### `PATCH /v1/config`
|
||||
|
||||
Applies a sparse patch to the editable config sections. The merged config is fully validated before writing; if validation fails the file is not modified.
|
||||
|
||||
**Auth:** requires `Authorization` header when `auth_header` is configured.
|
||||
|
||||
**Headers:**
|
||||
|
||||
| Header | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `Authorization` | when configured | Same token as all other endpoints. |
|
||||
| `Content-Type: application/json` | recommended | Not enforced, but body must be valid JSON. |
|
||||
| `If-Match: <revision>` | no | Optimistic concurrency. `<revision>` is the `revision` value from `GET /v1/config` or `config_hash` from `GET /v1/system/info`. If supplied and it does not match the current on-disk revision, returns `409 revision_conflict`. If omitted, the patch applies unconditionally. |
|
||||
|
||||
**Editable sections:** `general`, `timeouts`, `censorship`, `upstreams`, `show_link`, `dc_overrides`.
|
||||
|
||||
**Rejected keys and their error codes:**
|
||||
|
||||
| Key | HTTP | `error.code` |
|
||||
| --- | --- | --- |
|
||||
| `access` | `400` | `access_not_editable` |
|
||||
| `server`, `network`, or any unknown key | `400` | `section_not_editable` |
|
||||
| Object with no editable key | `400` | `bad_request` |
|
||||
|
||||
**Merge semantics:** tables are deep-merged field-by-field; arrays and scalar values replace the existing value wholesale. File comments and untouched sections are preserved.
|
||||
|
||||
**Validation:** the merged config is deserialized into the full `ProxyConfig` type and validated before writing. Failures return `400` with a descriptive message; the file is not modified.
|
||||
|
||||
**Read-only mode:** returns `403 read_only` when the API runs with `read_only = true`.
|
||||
|
||||
**Success `200` response body** (`data` field of the standard envelope):
|
||||
```json
|
||||
{
|
||||
"revision": "<new-sha256-hex>",
|
||||
"restart_required": true,
|
||||
"changed": ["censorship"]
|
||||
}
|
||||
```
|
||||
|
||||
- `revision` — SHA-256 hex of the config file after the write.
|
||||
- `restart_required` — `true` when the change affects a field that Telemt cannot hot-reload (e.g. `censorship.*`, `timeouts.*`, `upstreams`, `general.modes`). Hot-reloadable fields (e.g. `general.log_level`) are applied automatically by the config file watcher. Restart-required fields are written to disk but only take effect after the Telemt process is restarted; the caller is responsible for triggering the restart.
|
||||
- `changed` — list of top-level section names that differed.
|
||||
|
||||
**Status codes:**
|
||||
|
||||
| HTTP | `error.code` | Condition |
|
||||
| --- | --- | --- |
|
||||
| `200` | — | Patch applied successfully. |
|
||||
| `400` | `bad_request` | Invalid JSON, empty patch, or config validation/deserialization failure. |
|
||||
| `400` | `access_not_editable` | Patch contains an `access` key. |
|
||||
| `400` | `section_not_editable` | Patch contains `server`, `network`, or an unknown top-level key. |
|
||||
| `401` | `unauthorized` | Missing or invalid `Authorization` header. |
|
||||
| `403` | `read_only` | API is in read-only mode. |
|
||||
| `405` | `method_not_allowed` | Method other than `GET` or `PATCH` used on `/v1/config`. |
|
||||
| `409` | `revision_conflict` | `If-Match` header supplied but does not match current revision. |
|
||||
| `500` | `internal_error` | I/O or serialization failure. |
|
||||
|
||||
**curl example:**
|
||||
```bash
|
||||
# get current revision
|
||||
curl -s -H "Authorization: <token>" http://127.0.0.1:<api>/v1/system/info | jq -r .config_hash
|
||||
|
||||
# patch the SNI domain with optimistic concurrency
|
||||
curl -s -X PATCH -H "Authorization: <token>" -H "If-Match: <revision>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"censorship":{"tls_domain":"front.example.com"}}' \
|
||||
http://127.0.0.1:<api>/v1/config
|
||||
```
|
||||
|
||||
## Mutation Semantics
|
||||
|
||||
| Endpoint | Notes |
|
||||
| --- | --- |
|
||||
| `PATCH /v1/config` | Deep-merges the patch into editable config sections (tables merged per-field; arrays/scalars replaced wholesale). Validates the merged result before writing. Writes only the touched sections via atomic `tmp + rename`. Returns the new revision and which sections changed. |
|
||||
| `POST /v1/users` | Creates user, validates config, then atomically updates only affected `access.*` TOML tables (`access.users` always, plus optional per-user tables present in request). |
|
||||
| `PATCH /v1/users/{username}` | Partial update of provided fields only. Missing fields remain unchanged; explicit `null` removes optional per-user entries. The write path updates only affected `access.*` TOML tables. |
|
||||
| `POST /v1/users/{username}/rotate-secret` | Replaces the user's secret with a provided valid 32-hex value or a generated value, then returns the effective secret in `CreateUserResponse`. |
|
||||
|
||||
334
src/api/config_edit.rs
Normal file
334
src/api/config_edit.rs
Normal file
@@ -0,0 +1,334 @@
|
||||
//! Config-editing API: read managed sections and apply sparse field patches.
|
||||
//! `access.*` is intentionally not editable here (owned by the users API).
|
||||
|
||||
use serde_json::Value as Json;
|
||||
use toml::Value as Toml;
|
||||
|
||||
use super::ApiShared;
|
||||
use super::config_store::{
|
||||
EDITABLE_SECTIONS, compute_revision, current_revision, save_sections_to_disk,
|
||||
};
|
||||
use super::model::ApiFailure;
|
||||
use crate::config::ProxyConfig;
|
||||
use crate::config::hot_reload::classify_config_changes;
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct PatchConfigResponse {
|
||||
pub revision: String,
|
||||
pub restart_required: bool,
|
||||
pub changed: Vec<String>,
|
||||
}
|
||||
|
||||
/// Shared-state wrapper around [`apply_patch_to_path`]: serializes config
|
||||
/// mutations behind `mutation_lock`, then records a runtime event. The route
|
||||
/// handler calls this; the core logic stays decoupled for unit tests.
|
||||
pub(super) async fn patch_config(
|
||||
patch_json: Json,
|
||||
expected_revision: Option<String>,
|
||||
shared: &ApiShared,
|
||||
) -> Result<PatchConfigResponse, ApiFailure> {
|
||||
let _guard = shared.mutation_lock.lock().await;
|
||||
let resp = apply_patch_to_path(&shared.config_path, &patch_json, expected_revision).await?;
|
||||
drop(_guard);
|
||||
shared
|
||||
.runtime_events
|
||||
.record("api.config.patch.ok", format!("changed={:?}", resp.changed));
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
/// Core patch logic, decoupled from hyper/shared-state so it is unit-testable
|
||||
/// against a temp file. The route handler holds `mutation_lock` while calling this.
|
||||
pub(super) async fn apply_patch_to_path(
|
||||
config_path: &Path,
|
||||
patch_json: &Json,
|
||||
expected_revision: Option<String>,
|
||||
) -> Result<PatchConfigResponse, ApiFailure> {
|
||||
// 1. optimistic concurrency
|
||||
let current = current_revision(config_path).await?;
|
||||
if expected_revision.is_some_and(|expected| expected != current) {
|
||||
return Err(ApiFailure::new(
|
||||
hyper::StatusCode::CONFLICT,
|
||||
"revision_conflict",
|
||||
"Config revision mismatch",
|
||||
));
|
||||
}
|
||||
|
||||
// 2. convert + reject access / unknown sections
|
||||
let patch_toml = json_to_toml(patch_json)
|
||||
.map_err(|e| ApiFailure::bad_request(format!("invalid patch: {}", e)))?;
|
||||
let patch_table = patch_toml
|
||||
.as_table()
|
||||
.ok_or_else(|| ApiFailure::bad_request("patch must be a JSON object"))?;
|
||||
if patch_table.contains_key("access") {
|
||||
return Err(ApiFailure::new(
|
||||
hyper::StatusCode::BAD_REQUEST,
|
||||
"access_not_editable",
|
||||
"access.* is managed via the users API, not editable here",
|
||||
));
|
||||
}
|
||||
for key in patch_table.keys() {
|
||||
if !EDITABLE_SECTIONS.contains(&key.as_str()) {
|
||||
return Err(ApiFailure::new(
|
||||
hyper::StatusCode::BAD_REQUEST,
|
||||
"section_not_editable",
|
||||
format!("section not editable: {}", key),
|
||||
));
|
||||
}
|
||||
}
|
||||
let touched: Vec<&str> = patch_table
|
||||
.keys()
|
||||
.map(|k| k.as_str())
|
||||
.filter(|k| EDITABLE_SECTIONS.contains(k))
|
||||
.collect();
|
||||
if touched.is_empty() {
|
||||
return Err(ApiFailure::bad_request("empty patch: no editable sections"));
|
||||
}
|
||||
|
||||
// 3. Parse old + merged from the SAME deserialize path so the classifier
|
||||
// sees only the delta this patch introduces. `ProxyConfig::load` applies
|
||||
// include-expansion / legacy-compat / normalization that a bare
|
||||
// `try_into` does not; mixing the two paths would make unrelated fields
|
||||
// compare unequal and spuriously force `restart_required`.
|
||||
let original = tokio::fs::read_to_string(config_path)
|
||||
.await
|
||||
.map_err(|e| ApiFailure::internal(format!("failed to read config: {}", e)))?;
|
||||
let original_toml: Toml = toml::from_str(&original)
|
||||
.map_err(|e| ApiFailure::internal(format!("failed to parse config: {}", e)))?;
|
||||
let old_cfg: ProxyConfig = original_toml
|
||||
.clone()
|
||||
.try_into()
|
||||
.map_err(|e| ApiFailure::internal(format!("config does not deserialize: {}", e)))?;
|
||||
|
||||
let mut merged = original_toml;
|
||||
deep_merge(&mut merged, &patch_toml);
|
||||
|
||||
let new_cfg: ProxyConfig = merged
|
||||
.clone()
|
||||
.try_into()
|
||||
.map_err(|e| ApiFailure::bad_request(format!("config does not deserialize: {}", e)))?;
|
||||
new_cfg
|
||||
.validate()
|
||||
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
|
||||
|
||||
// 4. classify changes (Telemt's own hot/restart rule)
|
||||
let class = classify_config_changes(&old_cfg, &new_cfg);
|
||||
|
||||
// 5. write only the touched top-level sections
|
||||
let revision = save_sections_to_disk(config_path, &new_cfg, &touched).await?;
|
||||
|
||||
Ok(PatchConfigResponse {
|
||||
revision,
|
||||
restart_required: class.restart_required,
|
||||
changed: class.changed,
|
||||
})
|
||||
}
|
||||
|
||||
/// Return 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 parsed_table = parsed
|
||||
.as_table()
|
||||
.cloned()
|
||||
.unwrap_or_else(toml::value::Table::new);
|
||||
// Whitelist: return ONLY the editable sections. A blacklist (just removing
|
||||
// `access`) would leak `server` (carries the API `auth_header` + per-node
|
||||
// identity) and `network` (per-node addresses). Mirror the PATCH contract.
|
||||
let mut table = toml::value::Table::new();
|
||||
for section in EDITABLE_SECTIONS {
|
||||
if let Some(value) = parsed_table.get(*section) {
|
||||
table.insert((*section).to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let revision = compute_revision(&original);
|
||||
Ok((Toml::Table(table), revision))
|
||||
}
|
||||
|
||||
/// Convert a serde_json value to a toml value. `null` is dropped from objects
|
||||
/// (a patch never sets a key to TOML-null). Numbers become integers when exact,
|
||||
/// otherwise floats.
|
||||
fn json_to_toml(j: &Json) -> Result<Toml, String> {
|
||||
Ok(match j {
|
||||
Json::Null => return Err("null is not representable in TOML".into()),
|
||||
Json::Bool(b) => Toml::Boolean(*b),
|
||||
Json::Number(n) => {
|
||||
if let Some(i) = n.as_i64() {
|
||||
Toml::Integer(i)
|
||||
} else if let Some(f) = n.as_f64() {
|
||||
Toml::Float(f)
|
||||
} else {
|
||||
return Err(format!("unrepresentable number: {}", n));
|
||||
}
|
||||
}
|
||||
Json::String(s) => Toml::String(s.clone()),
|
||||
Json::Array(items) => {
|
||||
let mut out = Vec::with_capacity(items.len());
|
||||
for item in items {
|
||||
out.push(json_to_toml(item)?);
|
||||
}
|
||||
Toml::Array(out)
|
||||
}
|
||||
Json::Object(map) => {
|
||||
let mut table = toml::value::Table::new();
|
||||
for (k, v) in map {
|
||||
if v.is_null() {
|
||||
continue; // skip nulls instead of erroring at object level
|
||||
}
|
||||
table.insert(k.clone(), json_to_toml(v)?);
|
||||
}
|
||||
Toml::Table(table)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Recursively overlay `patch` onto `base`. Tables merge key-by-key; every
|
||||
/// other value type (scalars, arrays) replaces wholesale.
|
||||
fn deep_merge(base: &mut Toml, patch: &Toml) {
|
||||
match (base, patch) {
|
||||
(Toml::Table(b), Toml::Table(p)) => {
|
||||
for (k, pv) in p {
|
||||
match b.get_mut(k) {
|
||||
Some(bv) => deep_merge(bv, pv),
|
||||
None => {
|
||||
b.insert(k.clone(), pv.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(b, p) => *b = p.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn json_object_converts_to_toml_table() {
|
||||
let j: Json = serde_json::json!({"censorship": {"tls_domain": "a.com"}, "default_dc": 2});
|
||||
let t = json_to_toml(&j).expect("convertible");
|
||||
let table = t.as_table().unwrap();
|
||||
assert_eq!(table["censorship"]["tls_domain"].as_str(), Some("a.com"));
|
||||
assert_eq!(table["default_dc"].as_integer(), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_merge_overlays_tables_and_replaces_scalars() {
|
||||
let mut base: Toml =
|
||||
toml::from_str("[censorship]\ntls_domain = \"old\"\nfake_cert_len = 100\n").unwrap();
|
||||
let patch: Toml = toml::from_str("[censorship]\ntls_domain = \"new\"\n").unwrap();
|
||||
|
||||
deep_merge(&mut base, &patch);
|
||||
|
||||
let cens = base["censorship"].as_table().unwrap();
|
||||
assert_eq!(cens["tls_domain"].as_str(), Some("new")); // overlaid
|
||||
assert_eq!(cens["fake_cert_len"].as_integer(), Some(100)); // preserved
|
||||
}
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn temp_config(body: &str) -> (PathBuf, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
std::fs::write(&path, body).unwrap();
|
||||
(path, dir)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn patch_rejects_access_section() {
|
||||
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
|
||||
let patch: Json = serde_json::json!({"access": {"users": {"x": "y"}}});
|
||||
let err = apply_patch_to_path(&path, &patch, None).await.unwrap_err();
|
||||
assert_eq!(err.code, "access_not_editable");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn patch_revision_conflict() {
|
||||
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
|
||||
let patch: Json = serde_json::json!({"censorship": {"tls_domain": "b"}});
|
||||
let err = apply_patch_to_path(&path, &patch, Some("deadbeef".into()))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code, "revision_conflict");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn patch_sni_reports_restart_required() {
|
||||
let (path, _d) =
|
||||
temp_config("[censorship]\ntls_domain = \"a.com\"\n[server]\nport = 443\n");
|
||||
let patch: Json = serde_json::json!({"censorship": {"tls_domain": "b.com"}});
|
||||
let resp = apply_patch_to_path(&path, &patch, None).await.unwrap();
|
||||
assert!(resp.restart_required);
|
||||
assert!(resp.changed.iter().any(|c| c == "censorship"));
|
||||
let written = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(written.contains("tls_domain = \"b.com\""));
|
||||
assert_eq!(
|
||||
resp.revision,
|
||||
crate::api::config_store::compute_revision(&written)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_managed_config_strips_access() {
|
||||
let (path, _d) = temp_config(
|
||||
"[censorship]\ntls_domain = \"a.com\"\n[access.users]\nbob = \"deadbeef\"\n",
|
||||
);
|
||||
let (value, revision) = read_managed_config(&path).await.unwrap();
|
||||
let table = value.as_table().unwrap();
|
||||
assert!(table.contains_key("censorship"));
|
||||
assert!(!table.contains_key("access")); // secrets never leave the box here
|
||||
assert_eq!(revision, current_revision(&path).await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_managed_config_returns_only_editable_sections() {
|
||||
// server carries the API auth_header + per-node identity; network carries
|
||||
// per-node addresses. Neither must be exposed by GET /v1/config.
|
||||
let (path, _d) = temp_config(concat!(
|
||||
"[censorship]\ntls_domain = \"a\"\n",
|
||||
"[server]\nport = 443\n[server.api]\nauth_header = \"SECRET\"\n",
|
||||
"[network]\nipv4 = \"1.2.3.4\"\n",
|
||||
"[access.users]\nbob = \"deadbeef\"\n",
|
||||
));
|
||||
let (value, _rev) = read_managed_config(&path).await.unwrap();
|
||||
let table = value.as_table().unwrap();
|
||||
assert!(table.contains_key("censorship"));
|
||||
assert!(!table.contains_key("server")); // no API auth_header / identity leak
|
||||
assert!(!table.contains_key("network")); // no per-node identity leak
|
||||
assert!(!table.contains_key("access")); // no users/secrets
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn patch_rejects_server_section() {
|
||||
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
|
||||
let patch: Json = serde_json::json!({"server": {"port": 1}});
|
||||
let err = apply_patch_to_path(&path, &patch, None).await.unwrap_err();
|
||||
assert_eq!(err.code, "section_not_editable");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn patch_empty_is_rejected() {
|
||||
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
|
||||
let patch: Json = serde_json::json!({});
|
||||
assert!(apply_patch_to_path(&path, &patch, None).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn patch_log_level_is_hot() {
|
||||
// general.log_level is hot-reloadable -> a patch changing only it must
|
||||
// report restart_required = false (exercises the full apply path, not
|
||||
// just the classifier). Default LogLevel is Normal; patch to "debug".
|
||||
let (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
|
||||
let patch: Json = serde_json::json!({"general": {"log_level": "debug"}});
|
||||
let resp = apply_patch_to_path(&path, &patch, None).await.unwrap();
|
||||
assert!(!resp.restart_required);
|
||||
assert!(resp.changed.iter().any(|c| c == "general"));
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,81 @@ pub(super) async fn save_config_to_disk(
|
||||
Ok(compute_revision(&serialized))
|
||||
}
|
||||
|
||||
/// Top-level config tables that may be edited via the config API.
|
||||
///
|
||||
/// Intentionally excluded (defense-in-depth, enforces the spec's per-node
|
||||
/// identity invariant at the Telemt layer too):
|
||||
///
|
||||
/// - `access` : owned by the users API.
|
||||
/// - `server` : carries per-node identity (`port`, `api`/`api_bind`, listeners).
|
||||
/// - `network` : carries per-node identity (`ipv4`/`ipv6`).
|
||||
///
|
||||
/// A future field-level allowlist can re-admit specific safe fields
|
||||
/// (e.g. `network.dns_overrides`) without opening the whole section.
|
||||
pub(super) const EDITABLE_SECTIONS: &[&str] = &[
|
||||
"general",
|
||||
"timeouts",
|
||||
"censorship",
|
||||
"upstreams",
|
||||
"show_link",
|
||||
"dc_overrides",
|
||||
];
|
||||
|
||||
/// Re-render the given top-level tables from `cfg` and upsert each into the
|
||||
/// on-disk file, preserving every untouched section (and its comments).
|
||||
pub(super) async fn save_sections_to_disk(
|
||||
config_path: &Path,
|
||||
cfg: &ProxyConfig,
|
||||
sections: &[&str],
|
||||
) -> Result<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);
|
||||
}
|
||||
|
||||
let body = toml::to_string(table)
|
||||
.map_err(|e| ApiFailure::internal(format!("failed to serialize {}: {}", section, e)))?;
|
||||
let mut out = format!("[{}]\n", section);
|
||||
out.push_str(&body);
|
||||
if !out.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(super) async fn save_access_sections_to_disk(
|
||||
config_path: &Path,
|
||||
cfg: &ProxyConfig,
|
||||
@@ -273,17 +348,22 @@ fn upsert_toml_table(source: &str, table_name: &str, replacement: &str) -> Strin
|
||||
}
|
||||
|
||||
fn find_toml_table_bounds(source: &str, table_name: &str) -> Option<(usize, usize)> {
|
||||
let target = format!("[{}]", table_name);
|
||||
let single = format!("[{}]", table_name);
|
||||
let array = format!("[[{}]]", table_name);
|
||||
let mut offset = 0usize;
|
||||
let mut start = None;
|
||||
|
||||
for line in source.split_inclusive('\n') {
|
||||
let trimmed = line.trim();
|
||||
// Drop any inline comment so a hand-edited header like
|
||||
// `[censorship] # note` still matches. Section names never contain `#`.
|
||||
let header = line.trim().split('#').next().unwrap_or("").trim();
|
||||
if let Some(start_offset) = start {
|
||||
if trimmed.starts_with('[') {
|
||||
let is_same_array = header == array;
|
||||
let is_new_header = header.starts_with('[');
|
||||
if is_new_header && !is_same_array {
|
||||
return Some((start_offset, offset));
|
||||
}
|
||||
} else if trimmed == target {
|
||||
} else if header == single || header == array {
|
||||
start = Some(offset);
|
||||
}
|
||||
offset = offset.saturating_add(line.len());
|
||||
@@ -336,6 +416,57 @@ fn write_atomic_sync(path: &Path, contents: &str) -> std::io::Result<()> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_sections_preserves_other_tables_and_comments() {
|
||||
let dir = std::env::temp_dir().join(format!("cfgtest-{}", rand::random::<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
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_user_rate_limits_section() {
|
||||
let mut cfg = ProxyConfig::default();
|
||||
|
||||
@@ -28,6 +28,7 @@ use crate::stats::Stats;
|
||||
use crate::transport::UpstreamManager;
|
||||
use crate::transport::middle_proxy::MePool;
|
||||
|
||||
mod config_edit;
|
||||
mod config_store;
|
||||
mod events;
|
||||
mod http_utils;
|
||||
@@ -84,6 +85,7 @@ const ALLOW_GET: &str = "GET";
|
||||
const ALLOW_POST: &str = "POST";
|
||||
const ALLOW_GET_POST: &str = "GET, POST";
|
||||
const ALLOW_GET_PATCH_DELETE: &str = "GET, PATCH, DELETE";
|
||||
const ALLOW_GET_PATCH: &str = "GET, PATCH";
|
||||
|
||||
pub(super) struct ApiRuntimeState {
|
||||
pub(super) process_started_at_epoch_secs: u64,
|
||||
@@ -174,6 +176,7 @@ fn allowed_methods_for_path(path: &str) -> Option<&'static str> {
|
||||
| "/v1/stats/users/quota"
|
||||
| "/v1/stats/users" => Some(ALLOW_GET),
|
||||
"/v1/users" => Some(ALLOW_GET_POST),
|
||||
"/v1/config" => Some(ALLOW_GET_PATCH),
|
||||
_ if user_action_route_matches(path, "/reset-quota") => Some(ALLOW_POST),
|
||||
_ if user_action_route_matches(path, "/rotate-secret") => Some(ALLOW_POST),
|
||||
_ if user_action_route_matches(path, "/enable") => Some(ALLOW_POST),
|
||||
@@ -643,6 +646,37 @@ async fn handle(
|
||||
};
|
||||
Ok(success_response(status, data, revision))
|
||||
}
|
||||
("GET", "/v1/config") => {
|
||||
let (value, revision) =
|
||||
config_edit::read_managed_config(&shared.config_path).await?;
|
||||
Ok(success_response(StatusCode::OK, value, revision))
|
||||
}
|
||||
("PATCH", "/v1/config") => {
|
||||
if api_cfg.read_only {
|
||||
return Ok(error_response(
|
||||
request_id,
|
||||
ApiFailure::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"read_only",
|
||||
"API runs in read-only mode",
|
||||
),
|
||||
));
|
||||
}
|
||||
let expected_revision = parse_if_match(req.headers());
|
||||
let body = read_json::<serde_json::Value>(req.into_body(), body_limit).await?;
|
||||
match config_edit::patch_config(body, expected_revision, &shared).await {
|
||||
Ok(resp) => {
|
||||
let revision = resp.revision.clone();
|
||||
Ok(success_response(StatusCode::OK, resp, revision))
|
||||
}
|
||||
Err(error) => {
|
||||
shared
|
||||
.runtime_events
|
||||
.record("api.config.patch.failed", error.code);
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if method == Method::POST
|
||||
&& let Some(base_user) = normalized_path
|
||||
|
||||
@@ -1489,6 +1489,48 @@ pub fn spawn_config_watcher(
|
||||
(config_rx, log_rx)
|
||||
}
|
||||
|
||||
// ── Change classification ─────────────────────────────────────────────────────
|
||||
|
||||
/// Which top-level config sections changed and whether any require a restart.
|
||||
#[derive(Debug, Default, Clone, serde::Serialize)]
|
||||
pub struct ChangeClassification {
|
||||
pub changed: Vec<String>,
|
||||
pub restart_required: bool,
|
||||
}
|
||||
|
||||
/// Classify old->new using Telemt's OWN reload rule: overlay the hot fields and
|
||||
/// see if anything non-hot remains different. This guarantees `restart_required`
|
||||
/// matches actual runtime behavior and never drifts as new fields are added.
|
||||
pub fn classify_config_changes(old: &ProxyConfig, new: &ProxyConfig) -> ChangeClassification {
|
||||
let applied = overlay_hot_fields(old, new);
|
||||
let restart_required = !config_equal(&applied, new);
|
||||
ChangeClassification {
|
||||
changed: changed_sections(old, new),
|
||||
restart_required,
|
||||
}
|
||||
}
|
||||
|
||||
/// Top-level config sections whose canonical serialized form differs between
|
||||
/// old and new. Uses the same serialize+canonicalize path as `config_equal`.
|
||||
fn changed_sections(old: &ProxyConfig, new: &ProxyConfig) -> Vec<String> {
|
||||
let mut lhs = serde_json::to_value(old).unwrap_or(serde_json::Value::Null);
|
||||
let mut rhs = serde_json::to_value(new).unwrap_or(serde_json::Value::Null);
|
||||
canonicalize_json(&mut lhs);
|
||||
canonicalize_json(&mut rhs);
|
||||
|
||||
let mut out = Vec::new();
|
||||
if let (Some(lo), Some(ro)) = (lhs.as_object(), rhs.as_object()) {
|
||||
let mut keys: std::collections::BTreeSet<&String> = lo.keys().collect();
|
||||
keys.extend(ro.keys());
|
||||
for key in keys {
|
||||
if lo.get(key) != ro.get(key) {
|
||||
out.push(key.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1661,6 +1703,41 @@ mod tests {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_sni_change_requires_restart() {
|
||||
// censorship.* is not in overlay_hot_fields -> restart.
|
||||
let old = ProxyConfig::default();
|
||||
let mut new = ProxyConfig::default();
|
||||
new.censorship.tls_domain = "front.example".to_string();
|
||||
|
||||
let class = classify_config_changes(&old, &new);
|
||||
assert!(class.restart_required);
|
||||
assert!(class.changed.iter().any(|c| c == "censorship"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_dns_overrides_change_is_hot() {
|
||||
// network.dns_overrides IS in overlay_hot_fields -> no restart.
|
||||
let old = ProxyConfig::default();
|
||||
let mut new = ProxyConfig::default();
|
||||
new.network.dns_overrides.push("1.1.1.1".to_string());
|
||||
|
||||
let class = classify_config_changes(&old, &new);
|
||||
assert!(!class.restart_required);
|
||||
assert!(class.changed.iter().any(|c| c == "network"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_timeouts_change_requires_restart() {
|
||||
// timeouts.* is NOT in overlay_hot_fields -> restart.
|
||||
let old = ProxyConfig::default();
|
||||
let mut new = ProxyConfig::default();
|
||||
new.timeouts.client_handshake = old.timeouts.client_handshake + 1;
|
||||
|
||||
let class = classify_config_changes(&old, &new);
|
||||
assert!(class.restart_required);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_recovers_after_parse_error_on_next_attempt() {
|
||||
let initial_tag = "cccccccccccccccccccccccccccccccc";
|
||||
|
||||
Reference in New Issue
Block a user