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
This commit is contained in:
pulse-triage[bot]
2026-10-10 04:56:35 +01:00
parent e9992e4257
commit 299bddee3b
4 changed files with 387 additions and 34 deletions
+99 -16
View File
@@ -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": "<platform-token-secret>",
"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
+99 -16
View File
@@ -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": "<platform-token-secret>",
"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
+5 -2
View File
@@ -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):
+184
View File
@@ -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"], "<platform-token-secret>")
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()