mirror of
https://github.com/telemt/telemt.git
synced 2026-09-09 12:04:09 +03:00
feat(api): allow PATCH/GET of server.listeners via config API
Add a nested field-level allowlist under [server] so listeners can be managed remotely without exposing server.api auth_header or other bind-identity fields.
This commit is contained in:
+138
-12
@@ -1,12 +1,14 @@
|
||||
//! Config-editing API: read managed sections and apply sparse field patches.
|
||||
//! `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 toml::Value as Toml;
|
||||
|
||||
use super::ApiShared;
|
||||
use super::config_store::{
|
||||
EDITABLE_SECTIONS, compute_revision, current_revision, save_sections_to_disk,
|
||||
EDITABLE_SECTIONS, EDITABLE_SERVER_FIELDS, compute_revision, current_revision,
|
||||
is_editable_section, save_sections_to_disk,
|
||||
};
|
||||
use super::model::ApiFailure;
|
||||
use crate::config::ProxyConfig;
|
||||
@@ -55,7 +57,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)
|
||||
.map_err(|e| ApiFailure::bad_request(format!("invalid patch: {}", e)))?;
|
||||
let patch_table = patch_toml
|
||||
@@ -68,19 +70,22 @@ pub(super) async fn apply_patch_to_path(
|
||||
"access.* is managed via the users API, not editable here",
|
||||
));
|
||||
}
|
||||
for key in patch_table.keys() {
|
||||
if !EDITABLE_SECTIONS.contains(&key.as_str()) {
|
||||
for (key, value) in patch_table {
|
||||
if !is_editable_section(key.as_str()) {
|
||||
return Err(ApiFailure::new(
|
||||
hyper::StatusCode::BAD_REQUEST,
|
||||
"section_not_editable",
|
||||
format!("section not editable: {}", key),
|
||||
));
|
||||
}
|
||||
if key == "server" {
|
||||
validate_server_patch(value)?;
|
||||
}
|
||||
}
|
||||
let touched: Vec<&str> = patch_table
|
||||
.keys()
|
||||
.map(|k| k.as_str())
|
||||
.filter(|k| EDITABLE_SECTIONS.contains(k))
|
||||
.filter(|k| is_editable_section(k))
|
||||
.collect();
|
||||
if touched.is_empty() {
|
||||
return Err(ApiFailure::bad_request("empty patch: no editable sections"));
|
||||
@@ -138,19 +143,83 @@ pub(super) async fn read_managed_config(config_path: &Path) -> Result<(Toml, Str
|
||||
.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.
|
||||
// `access`) would leak `server.api` (auth_header) and `network` (per-node
|
||||
// addresses). Mirror the PATCH contract, including the nested server
|
||||
// field-level allowlist.
|
||||
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());
|
||||
}
|
||||
}
|
||||
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);
|
||||
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
|
||||
/// (a patch never sets a key to TOML-null). Numbers become integers when exact,
|
||||
/// otherwise floats.
|
||||
@@ -289,8 +358,8 @@ mod tests {
|
||||
|
||||
#[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.
|
||||
// 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",
|
||||
@@ -300,17 +369,74 @@ mod tests {
|
||||
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("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 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 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");
|
||||
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]
|
||||
|
||||
+19
-1
@@ -103,7 +103,6 @@ pub(super) async fn save_config_to_disk(
|
||||
/// 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`).
|
||||
/// - `show_link` : legacy top-level scalar/array (not a `[table]`), superseded
|
||||
/// by the editable `general.links.show` sub-table. The
|
||||
@@ -111,6 +110,11 @@ pub(super) async fn save_config_to_disk(
|
||||
/// `[[array-of-tables]]` blocks; a bare top-level key cannot be
|
||||
/// 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
|
||||
/// (e.g. `network.dns_overrides`) without opening the whole section.
|
||||
pub(super) const EDITABLE_SECTIONS: &[&str] = &[
|
||||
@@ -121,6 +125,20 @@ pub(super) const EDITABLE_SECTIONS: &[&str] = &[
|
||||
"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
|
||||
/// on-disk file, preserving every untouched section (and its comments).
|
||||
pub(super) async fn save_sections_to_disk(
|
||||
|
||||
Reference in New Issue
Block a user