From 299bddee3b537f464adc293ea759ec4a62db112a Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 10 Oct 2026 04:56:35 +0100 Subject: [PATCH] Keep node configuration API recipes scoped, private and trust-aware Replace root-password and implicit-TLS examples with a private JSON-file recipe using the existing bounded helper. Distinguish live tests, persisted changes, platform credentials and uncertain results; exercise copied commands with synthetic success, denial, redirect and incomplete responses. Contract-Neutral: Documentation, mirrored guidance and copied-recipe regression tests only; no runtime API, authentication, TLS or frontend contract changes. Change-source: pulse-maintainer --- docs/API.md | 115 ++++++++++++++--- frontend-modern/public/docs/API.md | 115 ++++++++++++++--- scripts/tests/test_api_auth_docs.py | 7 +- scripts/tests/test_node_api_docs.py | 184 ++++++++++++++++++++++++++++ 4 files changed, 387 insertions(+), 34 deletions(-) create mode 100644 scripts/tests/test_node_api_docs.py diff --git a/docs/API.md b/docs/API.md index 12ee36475f..1b5a6e56d8 100644 --- a/docs/API.md +++ b/docs/API.md @@ -634,39 +634,122 @@ Version fields are returned as plain semantic versions (no leading `v`). Returns a small public config payload (update channel, auto-update enabled). ### List Nodes -`GET /api/config/nodes` +`GET /api/config/nodes` (admin, `settings:read`) + +This administrative inventory covers saved **PVE, PBS and PMG connections**, +not individual VMs or agents. Keep its response private. Use the returned +connection `id` for updates, deletion and tests; a display name, VMID or another +installation's node name is not that ID. Re-read the inventory before a change +rather than guessing an ID from a list position. ### Add Node -`POST /api/config/nodes` +`POST /api/config/nodes` (admin, `settings:write`) + +Adding a connection saves credentials and enables collection; it is not a +passive validation step. Use an existing dedicated, least-privilege platform +token. The **Pulse API token** in the private header file authorises this +configuration request; the **Proxmox token** in the JSON body authorises reads +from the monitored platform. They are different credentials. Do not use a root +password or widen a department-scoped token to make denied peer reads succeed; +follow the [scoped-access checks](TROUBLESHOOTING.md#check-permissions-proxmox). +For PBS and PMG, use their [PBS](PBS.md#method-1-api-only-connection-recommended) +and [PMG](MAIL_GATEWAY.md) token guidance. PBS password-based onboarding can +also try to create a token and ACLs on the PBS server; it is not read-only. + +Prepare the request in a private editor, not a credential-bearing shell +assignment, here-document or command argument. Use the same Bash session as +the [private-response helper](#api-token-recommended): + +```bash +node_request_dir=$(umask 077; mktemp -d "${TMPDIR:-/tmp}/pulse-node.XXXXXX") && +node_request_file="$node_request_dir/node.json" && +(umask 077; set -C; : > "$node_request_file") && +vi "$node_request_file" +``` + +If preparation or the editor fails, stop. In that editor, enter the following +JSON, replacing the example hostname, connection name, full platform token ID +(`user@realm!token`) and token-secret placeholder with your intended values: + ```json { "type": "pve", - "name": "Proxmox 1", - "host": "https://198.51.100.10:8006", - "user": "root@pam", - "password": "password" + "name": "PVE connection A", + "host": "https://pve.example.net:8006", + "tokenName": "pulse-monitor@pve!pulse-readonly", + "tokenValue": "", + "verifySSL": true } ``` -### Test Connection -`POST /api/config/nodes/test-connection` -Validate credentials before saving. +Set `verifySSL` explicitly to `true`: omission on these new-connection and +test request paths does **not** enable certificate verification. The example +requires a certificate trusted by the Pulse server for that hostname. For a +self-signed/private certificate, also supply `fingerprint` with the SHA-256 +certificate fingerprint verified through an independent trusted channel. +A fingerprint merely fetched from the endpoint is not established trust. +Do not clear an existing pin, set `verifySSL` to `false`, or change to HTTP to +work around a certificate failure. -### Test Node Config (Validation Only) -`POST /api/config/nodes/test-config` -Validates node config without saving. +After reviewing this one intended configuration change, send the file: + +```bash +pulse_api POST /api/config/nodes < "$node_request_file" +``` + +The helper retains the response privately and makes one bounded request, +without retries. A lost response can follow a successful save: re-read the +saved inventory before deciding whether another request is needed. HTTP success +does not establish fresh readings for every node, guest, backup or History view. +Keep both request and response files out of reports and shared repositories; +remove the temporary request under your credential policy when no longer needed. + +### Test Connection +`POST /api/config/nodes/test-connection` (admin, `settings:write`) + +Tests supplied credentials with live platform requests without saving the +connection. Use the same private JSON-file transport and explicit TLS settings, +only when a live connection test is appropriate; it is not a required precursor +to filing a report. Success covers the reads performed by that test, not all +collection permissions, guest recovery or sustained monitoring. + +### Test Node Config (No Save, Live Request) +`POST /api/config/nodes/test-config` (admin, `settings:write`) + +This also contacts the platform using the supplied credentials; **no save** +does not mean offline schema validation. Do not run either test merely to +collect new evidence during a freeze/thaw, backup or unresponsive-host incident. +Use existing observations and the [safe diagnostic guidance](TROUBLESHOOTING.md#collect-diagnostics-safely). ### Update Node -`PUT /api/config/nodes/{id}` +`PUT /api/config/nodes/{id}` (admin, `settings:write`) + +Review the current connection and submit only the intended supported changes +from a private JSON file. Do not copy masked credentials from a GET response +back as new secrets. Credential rotation and trust changes are separate +decisions; preserve the existing credential and fingerprint when not changing +them. A response failure does not prove the update was unapplied. ### Delete Node -`DELETE /api/config/nodes/{id}` +`DELETE /api/config/nodes/{id}` (admin, `settings:write`) + +Removes the Pulse connection, not the platform workloads or the upstream token. +Collection and related alert coverage can stop. Check the exact connection and +arrange any needed independent coverage before an intentional removal; do not +delete and re-add a connection to diagnose missing readings or identity mixing. ### Test Node (Legacy) -`POST /api/config/nodes/{id}/test` +`POST /api/config/nodes/{id}/test` (admin, `settings:write`) + +Uses the saved connection for a live test. It is not a passive read of its +last result or a guarantee that ordinary collection recovered. ### Refresh Cluster Nodes -`POST /api/config/nodes/{id}/refresh-cluster` +`POST /api/config/nodes/{id}/refresh-cluster` (admin, `settings:write`) + +Actively rediscovers a saved PVE connection's cluster membership. It does not +select authorised nodes, grant missing permissions or repair an unrelated +standalone installation. Keep intentional token restrictions intact. ### Export Configuration `POST /api/config/export` (instance admin for the default organization, tenant diff --git a/frontend-modern/public/docs/API.md b/frontend-modern/public/docs/API.md index 12ee36475f..1b5a6e56d8 100644 --- a/frontend-modern/public/docs/API.md +++ b/frontend-modern/public/docs/API.md @@ -634,39 +634,122 @@ Version fields are returned as plain semantic versions (no leading `v`). Returns a small public config payload (update channel, auto-update enabled). ### List Nodes -`GET /api/config/nodes` +`GET /api/config/nodes` (admin, `settings:read`) + +This administrative inventory covers saved **PVE, PBS and PMG connections**, +not individual VMs or agents. Keep its response private. Use the returned +connection `id` for updates, deletion and tests; a display name, VMID or another +installation's node name is not that ID. Re-read the inventory before a change +rather than guessing an ID from a list position. ### Add Node -`POST /api/config/nodes` +`POST /api/config/nodes` (admin, `settings:write`) + +Adding a connection saves credentials and enables collection; it is not a +passive validation step. Use an existing dedicated, least-privilege platform +token. The **Pulse API token** in the private header file authorises this +configuration request; the **Proxmox token** in the JSON body authorises reads +from the monitored platform. They are different credentials. Do not use a root +password or widen a department-scoped token to make denied peer reads succeed; +follow the [scoped-access checks](TROUBLESHOOTING.md#check-permissions-proxmox). +For PBS and PMG, use their [PBS](PBS.md#method-1-api-only-connection-recommended) +and [PMG](MAIL_GATEWAY.md) token guidance. PBS password-based onboarding can +also try to create a token and ACLs on the PBS server; it is not read-only. + +Prepare the request in a private editor, not a credential-bearing shell +assignment, here-document or command argument. Use the same Bash session as +the [private-response helper](#api-token-recommended): + +```bash +node_request_dir=$(umask 077; mktemp -d "${TMPDIR:-/tmp}/pulse-node.XXXXXX") && +node_request_file="$node_request_dir/node.json" && +(umask 077; set -C; : > "$node_request_file") && +vi "$node_request_file" +``` + +If preparation or the editor fails, stop. In that editor, enter the following +JSON, replacing the example hostname, connection name, full platform token ID +(`user@realm!token`) and token-secret placeholder with your intended values: + ```json { "type": "pve", - "name": "Proxmox 1", - "host": "https://198.51.100.10:8006", - "user": "root@pam", - "password": "password" + "name": "PVE connection A", + "host": "https://pve.example.net:8006", + "tokenName": "pulse-monitor@pve!pulse-readonly", + "tokenValue": "", + "verifySSL": true } ``` -### Test Connection -`POST /api/config/nodes/test-connection` -Validate credentials before saving. +Set `verifySSL` explicitly to `true`: omission on these new-connection and +test request paths does **not** enable certificate verification. The example +requires a certificate trusted by the Pulse server for that hostname. For a +self-signed/private certificate, also supply `fingerprint` with the SHA-256 +certificate fingerprint verified through an independent trusted channel. +A fingerprint merely fetched from the endpoint is not established trust. +Do not clear an existing pin, set `verifySSL` to `false`, or change to HTTP to +work around a certificate failure. -### Test Node Config (Validation Only) -`POST /api/config/nodes/test-config` -Validates node config without saving. +After reviewing this one intended configuration change, send the file: + +```bash +pulse_api POST /api/config/nodes < "$node_request_file" +``` + +The helper retains the response privately and makes one bounded request, +without retries. A lost response can follow a successful save: re-read the +saved inventory before deciding whether another request is needed. HTTP success +does not establish fresh readings for every node, guest, backup or History view. +Keep both request and response files out of reports and shared repositories; +remove the temporary request under your credential policy when no longer needed. + +### Test Connection +`POST /api/config/nodes/test-connection` (admin, `settings:write`) + +Tests supplied credentials with live platform requests without saving the +connection. Use the same private JSON-file transport and explicit TLS settings, +only when a live connection test is appropriate; it is not a required precursor +to filing a report. Success covers the reads performed by that test, not all +collection permissions, guest recovery or sustained monitoring. + +### Test Node Config (No Save, Live Request) +`POST /api/config/nodes/test-config` (admin, `settings:write`) + +This also contacts the platform using the supplied credentials; **no save** +does not mean offline schema validation. Do not run either test merely to +collect new evidence during a freeze/thaw, backup or unresponsive-host incident. +Use existing observations and the [safe diagnostic guidance](TROUBLESHOOTING.md#collect-diagnostics-safely). ### Update Node -`PUT /api/config/nodes/{id}` +`PUT /api/config/nodes/{id}` (admin, `settings:write`) + +Review the current connection and submit only the intended supported changes +from a private JSON file. Do not copy masked credentials from a GET response +back as new secrets. Credential rotation and trust changes are separate +decisions; preserve the existing credential and fingerprint when not changing +them. A response failure does not prove the update was unapplied. ### Delete Node -`DELETE /api/config/nodes/{id}` +`DELETE /api/config/nodes/{id}` (admin, `settings:write`) + +Removes the Pulse connection, not the platform workloads or the upstream token. +Collection and related alert coverage can stop. Check the exact connection and +arrange any needed independent coverage before an intentional removal; do not +delete and re-add a connection to diagnose missing readings or identity mixing. ### Test Node (Legacy) -`POST /api/config/nodes/{id}/test` +`POST /api/config/nodes/{id}/test` (admin, `settings:write`) + +Uses the saved connection for a live test. It is not a passive read of its +last result or a guarantee that ordinary collection recovered. ### Refresh Cluster Nodes -`POST /api/config/nodes/{id}/refresh-cluster` +`POST /api/config/nodes/{id}/refresh-cluster` (admin, `settings:write`) + +Actively rediscovers a saved PVE connection's cluster membership. It does not +select authorised nodes, grant missing permissions or repair an unrelated +standalone installation. Keep intentional token restrictions intact. ### Export Configuration `POST /api/config/export` (instance admin for the default organization, tenant diff --git a/scripts/tests/test_api_auth_docs.py b/scripts/tests/test_api_auth_docs.py index 93ace3d22f..c61f713045 100644 --- a/scripts/tests/test_api_auth_docs.py +++ b/scripts/tests/test_api_auth_docs.py @@ -203,14 +203,17 @@ class APIAuthDocsTest(unittest.TestCase): self.assertNotRegex(bash, r"(?:--insecure|--verbose|--trace\S*|--location|\s-k\b)") blocks = re.findall(r"```bash\n(.*?)```", DOC.read_text(), re.DOTALL) requests = [block for block in blocks if block.startswith("pulse_api ")] - self.assertEqual(len(requests), 8) + self.assertEqual(len(requests), 9) self.assertEqual([block for block in blocks if "curl " in block], [request_helper()], "an example must not bypass the private-response helper") self.assertIn("curl --disable --fail-with-body ", request_helper()) for request in requests: if request.startswith("pulse_api POST "): self.assertIn("--data-binary @-", request_helper()) - self.assertIn("<<'JSON'", request) + if request.startswith("pulse_api POST /api/config/nodes "): + self.assertEqual(request.strip(), 'pulse_api POST /api/config/nodes < "$node_request_file"') + else: + self.assertIn("<<'JSON'", request) def test_every_documented_shell_recipe_parses(self): for block in re.findall(r"```bash\n(.*?)```", DOC.read_text(), re.DOTALL): diff --git a/scripts/tests/test_node_api_docs.py b/scripts/tests/test_node_api_docs.py new file mode 100644 index 0000000000..15010eeb9c --- /dev/null +++ b/scripts/tests/test_node_api_docs.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Exercise the existing node API guide with synthetic private credentials. + +All HTTP is guest-local loopback, not a platform or production probe. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import re +import stat +import subprocess +import tempfile +import unittest + +import test_api_auth_docs as auth + + +ROOT = Path(__file__).resolve().parents[2] +DOC = ROOT / "docs/API.md" +PLATFORM_SECRET = "synthetic-platform-token-secret" + + +def section() -> str: + return DOC.read_text().split("## 🖥️ Nodes & Config\n", 1)[1].split("\n## ", 1)[0] + + +def preparation() -> str: + blocks = re.findall(r"```bash\n(.*?)```", section(), re.DOTALL) + return next(block for block in blocks if 'vi "$node_request_file"' in block) + + +def sample() -> dict: + blocks = re.findall(r"```json\n(.*?)```", section(), re.DOTALL) + if len(blocks) != 1: + raise AssertionError("expected one node configuration specimen") + return json.loads(blocks[0]) + + +def request() -> str: + blocks = re.findall(r"```bash\n(.*?)```", section(), re.DOTALL) + return next(block for block in blocks if block.startswith("pulse_api POST ")) + + +class NodeAPIDocsTest(unittest.TestCase): + def test_specimen_uses_scoped_token_and_explicit_tls(self): + payload = sample() + self.assertEqual(payload["type"], "pve") + self.assertTrue(payload["host"].startswith("https://")) + self.assertNotIn("user", payload) + self.assertNotIn("password", payload) + self.assertNotIn("root@pam", json.dumps(payload)) + self.assertEqual(payload["tokenName"], "pulse-monitor@pve!pulse-readonly") + self.assertEqual(payload["tokenValue"], "") + self.assertIs(payload["verifySSL"], True) + schema = (ROOT / "internal/api/configapi/config_handlers.go").read_text() + schema = schema.split("type NodeConfigRequest struct {", 1)[1].split("\n}", 1)[0] + fields = set(re.findall(r'json:"([^",]+)', schema)) + self.assertLessEqual(set(payload), fields, "the copied fields must be accepted by this request type") + + def test_scopes_live_tests_and_connection_identity_are_explicit(self): + text = section() + routes = ("", "/test-connection", "/test-config", "/{id}", "/{id}/test", "/{id}/refresh-cluster") + for route in routes: + method = "PUT" if route == "/{id}" else "POST" + self.assertIn(f"`{method} /api/config/nodes{route}` (admin, `settings:write`)", text) + self.assertIn("`GET /api/config/nodes` (admin, `settings:read`)", text) + self.assertIn("`DELETE /api/config/nodes/{id}` (admin, `settings:write`)", text) + for boundary in ("not individual VMs or agents", "returned\nconnection `id`", + "no save", "live platform requests", "not a passive read", + "freeze/thaw", "not all\ncollection permissions", + "does not\nselect authorised nodes"): + self.assertIn(boundary, text) + self.assertNotIn("Validation Only", text) + + def test_credentials_trust_and_uncertain_changes_are_separate(self): + text = section() + for boundary in ("Pulse API token", "Proxmox token", "different credentials", + "does **not** enable certificate verification", + "independent trusted channel", "not established trust", + "Do not clear an existing pin", "can\nalso try to create a token and ACLs", + "without retries", "lost response", "re-read the\nsaved inventory", + "masked credentials", "not the platform workloads or the upstream token", + "Collection and related alert coverage can stop", + "reports and shared repositories"): + self.assertIn(boundary, text) + self.assertNotRegex("\n".join(re.findall(r"```bash\n(.*?)```", text, re.DOTALL)), + r"root@pam|tokenValue|password|--insecure|--location|--retry") + + def test_copied_preparation_is_private_unique_and_stops_on_failure(self): + with tempfile.TemporaryDirectory() as tmp: + home = Path(tmp) + tools = home / "tools" + tools.mkdir() + calls = home / "editor-calls.jsonl" + editor = tools / "vi" + editor.write_text( + "#!/usr/bin/env python3\nimport json, os, sys\nfrom pathlib import Path\n" + "with Path(os.environ['EDITOR_CALLS']).open('a') as f: f.write(json.dumps(sys.argv[1:]) + '\\n')\n" + "Path(sys.argv[1]).write_text('{\"fixture\": true}')\n" + ) + editor.chmod(0o700) + env = dict(os.environ, HOME=str(home), TMPDIR=str(home), + PATH=f"{tools}:{os.environ['PATH']}", EDITOR_CALLS=str(calls)) + created = [] + for _ in range(2): + result = subprocess.run(["bash", "-eu", "-c", preparation()], env=env, + capture_output=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, b"", "preparation must not print credentials") + argv = json.loads(calls.read_text().splitlines()[-1]) + self.assertEqual(len(argv), 1) + path = Path(argv[0]) + self.assertEqual(path.name, "node.json") + self.assertEqual(path.read_text(), '{"fixture": true}') + self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600) + self.assertEqual(stat.S_IMODE(path.parent.stat().st_mode), 0o700) + created.append(path) + self.assertNotEqual(*created, "a new preparation must not overwrite the previous request") + before = calls.read_bytes() + failed = subprocess.run(["bash", "-eu", "-c", preparation()], + env=dict(env, TMPDIR=str(home / "missing")), + capture_output=True, timeout=10) + self.assertNotEqual(failed.returncode, 0) + self.assertEqual(calls.read_bytes(), before, "failed preparation must not open an editor") + self.assertEqual(created[0].read_text(), '{"fixture": true}') + + def test_copied_file_dispatch_is_private_bounded_and_never_retried(self): + payload = sample() + payload["tokenValue"] = PLATFORM_SECRET + # Independent operation expectation: the fixture must not accept a + # different route, method, credential or body just because it was copied. + self.assertEqual(request().strip(), 'pulse_api POST /api/config/nodes < "$node_request_file"') + for status, partial in ((201, False), (401, False), (403, False), + (302, False), (500, False), (200, True)): + with self.subTest(status=status, partial=partial), tempfile.TemporaryDirectory() as tmp: + home = Path(tmp) + body = home / "node.json" + body.write_text(json.dumps(payload)) + body.chmod(0o600) + script = f'node_request_file={json.dumps(str(body))}\n' + auth.request_helper() + "\n" + request() + with auth.private_recording_server(status, partial) as (port, calls): + result = auth.exercise_curl(self, home, "X-API-Token: " + auth.TEST_TOKEN, + port, script, private_response=True) + self.assertEqual(len(calls), 1) + path, headers, method, data = calls[0] + self.assertEqual((path, method), ("/api/config/nodes", "POST")) + self.assertEqual(headers["X-API-Token"], auth.TEST_TOKEN) + self.assertEqual(headers["Content-Type"], "application/json") + self.assertEqual(json.loads(data), payload) + self.assertEqual(body.read_text(), json.dumps(payload)) + argv = (home / "argv.json").read_text() + self.assertNotIn(PLATFORM_SECRET, argv) + self.assertNotIn(PLATFORM_SECRET.encode(), result.stdout + result.stderr) + self.assertEqual(result.returncode == 0, status == 201 and not partial) + + def test_missing_request_file_sends_nothing(self): + with tempfile.TemporaryDirectory() as tmp: + home = Path(tmp) + with auth.recording_server() as (port, calls): + script = f'node_request_file={json.dumps(str(home / "absent.json"))}\n' + script += auth.request_helper().replace("http://127.0.0.1:7655", f"http://127.0.0.1:{port}") + script += "\n" + request() + result = subprocess.run(["bash", "-eu", "-c", script], capture_output=True, timeout=10) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(calls, [], "missing private input must not fall back to another request body") + + def test_all_documented_node_shell_parses(self): + for block in re.findall(r"```bash\n(.*?)```", section(), re.DOTALL): + subprocess.run(["bash", "-n", "-c", block], check=True, capture_output=True) + + def test_private_response_helper_is_preserved(self): + helper = auth.request_helper() + self.assertIn('curl --disable --fail-with-body --silent --show-error', helper) + self.assertIn('--header "@$auth_file"', helper) + self.assertIn('--data-binary @-', helper) + self.assertIn('--output "$result_file"', helper) + self.assertNotIn('--retry', helper) + + +if __name__ == "__main__": + unittest.main()