mirror of
https://github.com/telemt/telemt.git
synced 2026-09-26 04:25:58 +03:00
Merge pull request #878 from l0nelynx/feat/config-api-server-listeners
feat(api): allow GET/PATCH of server.listeners via config API
This commit is contained in:
@@ -167,7 +167,8 @@ Notes:
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `400` | `bad_request` | Invalid JSON, validation failures, malformed request body. |
|
| `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` | `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. |
|
| `400` | `section_not_editable` | `PATCH /v1/config` body contains `network` or an unknown top-level key. |
|
||||||
|
| `400` | `field_not_editable` | `PATCH /v1/config` body contains a forbidden nested field under a partially editable section (e.g. `server.api`, `server.port`). |
|
||||||
| `401` | `unauthorized` | Missing/invalid `Authorization` when `auth_header` is configured. |
|
| `401` | `unauthorized` | Missing/invalid `Authorization` when `auth_header` is configured. |
|
||||||
| `403` | `forbidden` | Source IP is not allowed by whitelist. |
|
| `403` | `forbidden` | Source IP is not allowed by whitelist. |
|
||||||
| `403` | `read_only` | Mutating endpoint called while `read_only=true`. |
|
| `403` | `read_only` | Mutating endpoint called while `read_only=true`. |
|
||||||
@@ -260,11 +261,12 @@ bob = ["198.51.100.42/32"]
|
|||||||
|
|
||||||
### `PatchConfigRequest`
|
### `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.
|
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`, `dc_overrides`) or the partially editable `server` object (only `listeners` is allowed under `server`; see below). 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:**
|
**Rejected keys:**
|
||||||
- `access` → `400 access_not_editable` (users/secrets are managed via `POST/PATCH /v1/users`).
|
- `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`.
|
- `network`, or any unknown top-level key → `400 section_not_editable`.
|
||||||
|
- `server` with any key other than `listeners` (e.g. `port`, `api`, `admin_api`) → `400 field_not_editable`.
|
||||||
- An object with no editable keys → `400 bad_request` (empty patch).
|
- An object with no editable keys → `400 bad_request` (empty patch).
|
||||||
|
|
||||||
Example — patch only the SNI domain:
|
Example — patch only the SNI domain:
|
||||||
@@ -272,6 +274,11 @@ Example — patch only the SNI domain:
|
|||||||
{"censorship": {"tls_domain": "front.example.com"}}
|
{"censorship": {"tls_domain": "front.example.com"}}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Example — replace `[[server.listeners]]` (other `[server]` fields including `[server.api]` are preserved):
|
||||||
|
```json
|
||||||
|
{"server": {"listeners": [{"ip": "0.0.0.0", "port": 443, "client_mss": "92"}]}}
|
||||||
|
```
|
||||||
|
|
||||||
### `RotateSecretRequest`
|
### `RotateSecretRequest`
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
@@ -291,10 +298,10 @@ Returned by `GET /v1/config` as the envelope `data`. The fields are exactly the
|
|||||||
| `timeouts` | `object?` | `[timeouts]` section, if present. |
|
| `timeouts` | `object?` | `[timeouts]` section, if present. |
|
||||||
| `censorship` | `object?` | `[censorship]` section, if present. |
|
| `censorship` | `object?` | `[censorship]` section, if present. |
|
||||||
| `upstreams` | `object?` | `[upstreams]` 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. |
|
| `dc_overrides` | `object?` | `[dc_overrides]` section, if present. |
|
||||||
|
| `server` | `object?` | Partial `[server]` view when editable nested fields are present. Currently only `listeners` may appear; `api`/`admin_api`, `port`, unix sockets, and other bind-identity fields are never returned. |
|
||||||
|
|
||||||
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.
|
Sections absent from the config file are absent from the response (not `null`). Only the editable sections above are returned; `access` (users/secrets) and `network` (per-node addresses) are always excluded. Under `server`, only the nested field-level allowlist (`listeners`) is exposed.
|
||||||
|
|
||||||
### `PatchConfigResponse`
|
### `PatchConfigResponse`
|
||||||
|
|
||||||
@@ -1404,14 +1411,15 @@ Applies a sparse patch to the editable config sections. The merged config is ful
|
|||||||
| `Content-Type: application/json` | recommended | Not enforced, but body must be valid JSON. |
|
| `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. |
|
| `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`.
|
**Editable sections:** `general`, `timeouts`, `censorship`, `upstreams`, `dc_overrides`, plus partially editable `server` (only nested `listeners`).
|
||||||
|
|
||||||
**Rejected keys and their error codes:**
|
**Rejected keys and their error codes:**
|
||||||
|
|
||||||
| Key | HTTP | `error.code` |
|
| Key | HTTP | `error.code` |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `access` | `400` | `access_not_editable` |
|
| `access` | `400` | `access_not_editable` |
|
||||||
| `server`, `network`, or any unknown key | `400` | `section_not_editable` |
|
| `network`, or any unknown top-level key | `400` | `section_not_editable` |
|
||||||
|
| `server` with keys other than `listeners` | `400` | `field_not_editable` |
|
||||||
| Object with no editable key | `400` | `bad_request` |
|
| 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.
|
**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.
|
||||||
@@ -1466,7 +1474,8 @@ Without a `reload` query parameter, the endpoint preserves the legacy behavior:
|
|||||||
| `202` | — | Patch applied and runtime reload accepted. |
|
| `202` | — | Patch applied and runtime reload accepted. |
|
||||||
| `400` | `bad_request` | Invalid JSON, empty patch, or config validation/deserialization failure. |
|
| `400` | `bad_request` | Invalid JSON, empty patch, or config validation/deserialization failure. |
|
||||||
| `400` | `access_not_editable` | Patch contains an `access` key. |
|
| `400` | `access_not_editable` | Patch contains an `access` key. |
|
||||||
| `400` | `section_not_editable` | Patch contains `server`, `network`, or an unknown top-level key. |
|
| `400` | `section_not_editable` | Patch contains `network` or an unknown top-level key. |
|
||||||
|
| `400` | `field_not_editable` | Patch contains a forbidden nested `server.*` field (anything other than `listeners`). |
|
||||||
| `401` | `unauthorized` | Missing or invalid `Authorization` header. |
|
| `401` | `unauthorized` | Missing or invalid `Authorization` header. |
|
||||||
| `403` | `read_only` | API is in read-only mode. |
|
| `403` | `read_only` | API is in read-only mode. |
|
||||||
| `405` | `method_not_allowed` | Method other than `GET` or `PATCH` used on `/v1/config`. |
|
| `405` | `method_not_allowed` | Method other than `GET` or `PATCH` used on `/v1/config`. |
|
||||||
|
|||||||
+142
-13
@@ -1,13 +1,14 @@
|
|||||||
//! Config-editing API: read managed sections and apply sparse field patches.
|
//! Config-editing API: read managed sections and apply sparse field patches.
|
||||||
//! `access.*` is intentionally not editable here (owned by the users API).
|
//! `access.*` is intentionally not editable here (owned by the users API).
|
||||||
|
//! `[server]` is only partially editable — see [`EDITABLE_SERVER_FIELDS`].
|
||||||
|
|
||||||
use serde_json::Value as Json;
|
use serde_json::Value as Json;
|
||||||
use toml::Value as Toml;
|
use toml::Value as Toml;
|
||||||
|
|
||||||
use super::ApiShared;
|
use super::ApiShared;
|
||||||
use super::config_store::{
|
use super::config_store::{
|
||||||
EDITABLE_SECTIONS, compute_revision, current_revision, load_config_from_disk,
|
EDITABLE_SECTIONS, EDITABLE_SERVER_FIELDS, compute_revision, current_revision,
|
||||||
save_sections_to_disk,
|
is_editable_section, load_config_from_disk, save_sections_to_disk,
|
||||||
};
|
};
|
||||||
use super::model::ApiFailure;
|
use super::model::ApiFailure;
|
||||||
use crate::config::ProxyConfig;
|
use crate::config::ProxyConfig;
|
||||||
@@ -94,7 +95,7 @@ pub(super) async fn apply_patch_to_path(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. convert + reject access / unknown sections
|
// 2. convert + reject access / unknown sections / forbidden server fields
|
||||||
let patch_toml = json_to_toml(patch_json)
|
let patch_toml = json_to_toml(patch_json)
|
||||||
.map_err(|e| ApiFailure::bad_request(format!("invalid patch: {}", e)))?;
|
.map_err(|e| ApiFailure::bad_request(format!("invalid patch: {}", e)))?;
|
||||||
let patch_table = patch_toml
|
let patch_table = patch_toml
|
||||||
@@ -107,19 +108,22 @@ pub(super) async fn apply_patch_to_path(
|
|||||||
"access.* is managed via the users API, not editable here",
|
"access.* is managed via the users API, not editable here",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
for key in patch_table.keys() {
|
for (key, value) in patch_table {
|
||||||
if !EDITABLE_SECTIONS.contains(&key.as_str()) {
|
if !is_editable_section(key.as_str()) {
|
||||||
return Err(ApiFailure::new(
|
return Err(ApiFailure::new(
|
||||||
hyper::StatusCode::BAD_REQUEST,
|
hyper::StatusCode::BAD_REQUEST,
|
||||||
"section_not_editable",
|
"section_not_editable",
|
||||||
format!("section not editable: {}", key),
|
format!("section not editable: {}", key),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if key == "server" {
|
||||||
|
validate_server_patch(value)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let touched: Vec<&str> = patch_table
|
let touched: Vec<&str> = patch_table
|
||||||
.keys()
|
.keys()
|
||||||
.map(|k| k.as_str())
|
.map(|k| k.as_str())
|
||||||
.filter(|k| EDITABLE_SECTIONS.contains(k))
|
.filter(|k| is_editable_section(k))
|
||||||
.collect();
|
.collect();
|
||||||
if touched.is_empty() {
|
if touched.is_empty() {
|
||||||
return Err(ApiFailure::bad_request("empty patch: no editable sections"));
|
return Err(ApiFailure::bad_request("empty patch: no editable sections"));
|
||||||
@@ -182,19 +186,83 @@ pub(super) async fn read_managed_config(config_path: &Path) -> Result<(Toml, Str
|
|||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_else(toml::value::Table::new);
|
.unwrap_or_else(toml::value::Table::new);
|
||||||
// Whitelist: return ONLY the editable sections. A blacklist (just removing
|
// Whitelist: return ONLY the editable sections. A blacklist (just removing
|
||||||
// `access`) would leak `server` (carries the API `auth_header` + per-node
|
// `access`) would leak `server.api` (auth_header) and `network` (per-node
|
||||||
// identity) and `network` (per-node addresses). Mirror the PATCH contract.
|
// addresses). Mirror the PATCH contract, including the nested server
|
||||||
|
// field-level allowlist.
|
||||||
let mut table = toml::value::Table::new();
|
let mut table = toml::value::Table::new();
|
||||||
for section in EDITABLE_SECTIONS {
|
for section in EDITABLE_SECTIONS {
|
||||||
if let Some(value) = parsed_table.get(*section) {
|
if let Some(value) = parsed_table.get(*section) {
|
||||||
table.insert((*section).to_string(), value.clone());
|
table.insert((*section).to_string(), value.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(server) = parsed_table.get("server") {
|
||||||
|
if let Some(filtered) = filter_server_for_read(server) {
|
||||||
|
table.insert("server".to_string(), filtered);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let revision = compute_revision(&original);
|
let revision = compute_revision(&original);
|
||||||
Ok((Toml::Table(table), revision))
|
Ok((Toml::Table(table), revision))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Keep only [`EDITABLE_SERVER_FIELDS`] from a `[server]` table for GET.
|
||||||
|
fn filter_server_for_read(server: &Toml) -> Option<Toml> {
|
||||||
|
let Some(src) = server.as_table() else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let mut out = toml::value::Table::new();
|
||||||
|
for field in EDITABLE_SERVER_FIELDS {
|
||||||
|
if let Some(value) = src.get(*field) {
|
||||||
|
// Skip empty listeners arrays so absent-vs-empty stays consistent
|
||||||
|
// with other optional sections.
|
||||||
|
if *field == "listeners" {
|
||||||
|
if let Some(arr) = value.as_array() {
|
||||||
|
if arr.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.insert((*field).to_string(), value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(Toml::Table(out))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reject any `[server]` patch keys outside [`EDITABLE_SERVER_FIELDS`].
|
||||||
|
fn validate_server_patch(server: &Toml) -> Result<(), ApiFailure> {
|
||||||
|
let Some(table) = server.as_table() else {
|
||||||
|
return Err(ApiFailure::new(
|
||||||
|
hyper::StatusCode::BAD_REQUEST,
|
||||||
|
"section_not_editable",
|
||||||
|
"server patch must be a JSON object",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
if table.is_empty() {
|
||||||
|
return Err(ApiFailure::bad_request(
|
||||||
|
"empty server patch: provide at least one editable field \
|
||||||
|
(currently: listeners)",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for key in table.keys() {
|
||||||
|
if !EDITABLE_SERVER_FIELDS.contains(&key.as_str()) {
|
||||||
|
return Err(ApiFailure::new(
|
||||||
|
hyper::StatusCode::BAD_REQUEST,
|
||||||
|
"field_not_editable",
|
||||||
|
format!(
|
||||||
|
"server.{} is not editable via the config API; allowed server fields: {}",
|
||||||
|
key,
|
||||||
|
EDITABLE_SERVER_FIELDS.join(", ")
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert a serde_json value to a toml value. `null` is dropped from objects
|
/// 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,
|
/// (a patch never sets a key to TOML-null). Numbers become integers when exact,
|
||||||
/// otherwise floats.
|
/// otherwise floats.
|
||||||
@@ -336,8 +404,8 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn read_managed_config_returns_only_editable_sections() {
|
async fn read_managed_config_returns_only_editable_sections() {
|
||||||
// server carries the API auth_header + per-node identity; network carries
|
// Full server (api/port) and network must not leak. Listeners-only server
|
||||||
// per-node addresses. Neither must be exposed by GET /v1/config.
|
// is returned via the nested allowlist (covered in a dedicated test).
|
||||||
let (path, _d) = temp_config(concat!(
|
let (path, _d) = temp_config(concat!(
|
||||||
"[censorship]\ntls_domain = \"a\"\n",
|
"[censorship]\ntls_domain = \"a\"\n",
|
||||||
"[server]\nport = 443\n[server.api]\nauth_header = \"SECRET\"\n",
|
"[server]\nport = 443\n[server.api]\nauth_header = \"SECRET\"\n",
|
||||||
@@ -347,17 +415,78 @@ mod tests {
|
|||||||
let (value, _rev) = read_managed_config(&path).await.unwrap();
|
let (value, _rev) = read_managed_config(&path).await.unwrap();
|
||||||
let table = value.as_table().unwrap();
|
let table = value.as_table().unwrap();
|
||||||
assert!(table.contains_key("censorship"));
|
assert!(table.contains_key("censorship"));
|
||||||
assert!(!table.contains_key("server")); // no API auth_header / identity leak
|
assert!(!table.contains_key("server")); // no listeners → omit whole server
|
||||||
assert!(!table.contains_key("network")); // no per-node identity leak
|
assert!(!table.contains_key("network")); // no per-node identity leak
|
||||||
assert!(!table.contains_key("access")); // no users/secrets
|
assert!(!table.contains_key("access")); // no users/secrets
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn patch_rejects_server_section() {
|
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 (path, _d) = temp_config("[censorship]\ntls_domain = \"a\"\n");
|
||||||
let patch: Json = serde_json::json!({"server": {"port": 1}});
|
let patch: Json = serde_json::json!({"server": {"port": 1}});
|
||||||
let err = apply_patch_to_path(&path, &patch, None).await.unwrap_err();
|
let err = apply_patch_to_path(&path, &patch, None).await.unwrap_err();
|
||||||
assert_eq!(err.code, "section_not_editable");
|
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]
|
#[tokio::test]
|
||||||
|
|||||||
+19
-1
@@ -118,7 +118,6 @@ pub(super) async fn save_config_to_disk(
|
|||||||
/// identity invariant at the Telemt layer too):
|
/// identity invariant at the Telemt layer too):
|
||||||
///
|
///
|
||||||
/// - `access` : owned by the users API.
|
/// - `access` : owned by the users API.
|
||||||
/// - `server` : carries per-node identity (`port`, `api`/`api_bind`, listeners).
|
|
||||||
/// - `network` : carries per-node identity (`ipv4`/`ipv6`).
|
/// - `network` : carries per-node identity (`ipv4`/`ipv6`).
|
||||||
/// - `show_link` : legacy top-level scalar/array (not a `[table]`), superseded
|
/// - `show_link` : legacy top-level scalar/array (not a `[table]`), superseded
|
||||||
/// by the editable `general.links.show` sub-table. The
|
/// by the editable `general.links.show` sub-table. The
|
||||||
@@ -126,6 +125,11 @@ pub(super) async fn save_config_to_disk(
|
|||||||
/// `[[array-of-tables]]` blocks; a bare top-level key cannot be
|
/// `[[array-of-tables]]` blocks; a bare top-level key cannot be
|
||||||
/// located or replaced safely, so it is edited via `general`.
|
/// located or replaced safely, so it is edited via `general`.
|
||||||
///
|
///
|
||||||
|
/// `server` is partially editable: only the nested fields listed in
|
||||||
|
/// [`EDITABLE_SERVER_FIELDS`] (currently `listeners`) may appear in GET/PATCH.
|
||||||
|
/// Secrets and bind identity (`api`/`admin_api`, `port`, unix sockets, …) stay
|
||||||
|
/// blocked. See also the field-level allowlist note below for `network.*`.
|
||||||
|
///
|
||||||
/// A future field-level allowlist can re-admit specific safe fields
|
/// A future field-level allowlist can re-admit specific safe fields
|
||||||
/// (e.g. `network.dns_overrides`) without opening the whole section.
|
/// (e.g. `network.dns_overrides`) without opening the whole section.
|
||||||
pub(super) const EDITABLE_SECTIONS: &[&str] = &[
|
pub(super) const EDITABLE_SECTIONS: &[&str] = &[
|
||||||
@@ -136,6 +140,20 @@ pub(super) const EDITABLE_SECTIONS: &[&str] = &[
|
|||||||
"dc_overrides",
|
"dc_overrides",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Nested fields under `[server]` that may be read/patched via the config API.
|
||||||
|
///
|
||||||
|
/// Arrays (e.g. `listeners`) replace wholesale on PATCH, matching the existing
|
||||||
|
/// merge semantics for non-table values.
|
||||||
|
pub(super) const EDITABLE_SERVER_FIELDS: &[&str] = &["listeners"];
|
||||||
|
|
||||||
|
/// Whether `key` is an allowed top-level PATCH/GET section name.
|
||||||
|
///
|
||||||
|
/// Fully editable sections from [`EDITABLE_SECTIONS`], plus `server` which is
|
||||||
|
/// further restricted by [`EDITABLE_SERVER_FIELDS`].
|
||||||
|
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
|
/// Re-render the given top-level tables from `cfg` and upsert each into the
|
||||||
/// on-disk file, preserving every untouched section (and its comments).
|
/// on-disk file, preserving every untouched section (and its comments).
|
||||||
pub(super) async fn save_sections_to_disk(
|
pub(super) async fn save_sections_to_disk(
|
||||||
|
|||||||
Reference in New Issue
Block a user