diff --git a/docs/TRUENAS.md b/docs/TRUENAS.md index 13ef48257b..80dbb83387 100644 --- a/docs/TRUENAS.md +++ b/docs/TRUENAS.md @@ -89,24 +89,51 @@ All endpoints require admin authentication. | `DELETE` | `/api/truenas/connections/{id}` | Remove a TrueNAS connection | | `POST` | `/api/truenas/connections/test` | Test a connection before saving | -### Adding a connection (API) +### Testing and adding a connection (API) + +The **Settings → TrueNAS** flow is the simplest option. For an API client, +prepare the private Pulse header file as described in the +[API authentication guide](API.md). The Pulse API token authenticates to +Pulse; the separate TrueNAS API key belongs in the connection's JSON file. +Do not put either credential in shell arguments, URLs or a public thread. + +Create or protect the connection file before opening it in a trusted editor: ```bash -curl -X POST http://localhost:7655/api/truenas/connections \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"name":"nas-1","host":"https://truenas.local","username":"key-owner","apiKey":"your-api-key"}' +umask 077 +mkdir -p "$HOME/.config/pulse" +touch "$HOME/.config/pulse/truenas-connection.json" +chmod 600 "$HOME/.config/pulse/truenas-connection.json" +vi "$HOME/.config/pulse/truenas-connection.json" ``` -### Testing a connection (API) +In that file, enter a JSON object with `name`, `host` (the TrueNAS HTTPS URL), +`username` (the key owner) and `apiKey`. Keep both credential files private; +do not paste their contents into a terminal command or upload them. + +Test the connection before saving it: ```bash -curl -X POST http://localhost:7655/api/truenas/connections/test \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"name":"nas-1","host":"https://truenas.local","username":"key-owner","apiKey":"your-api-key"}' +curl --fail-with-body --silent --show-error \ + --header "@$HOME/.config/pulse/api-header" \ + --header 'Content-Type: application/json' \ + --data-binary "@$HOME/.config/pulse/truenas-connection.json" \ + http://127.0.0.1:7655/api/truenas/connections/test ``` +After checking the test response, save the connection with: + +```bash +curl --fail-with-body --silent --show-error \ + --header "@$HOME/.config/pulse/api-header" \ + --header 'Content-Type: application/json' \ + --data-binary "@$HOME/.config/pulse/truenas-connection.json" \ + http://127.0.0.1:7655/api/truenas/connections +``` + +These Pulse URLs are loopback-only examples. For a remote Pulse server, use +its trusted HTTPS origin without disabling certificate verification. + ## Troubleshooting ### "TrueNAS service unavailable" @@ -122,21 +149,43 @@ curl -X POST http://localhost:7655/api/truenas/connections/test \ ### No data appearing after adding connection - Wait at least 30 seconds for the first poll cycle. -- Check Pulse logs for TrueNAS-related errors: +- **Test Connection** checks authentication and the selected transport, not + successful collection of every metric. The legacy REST diagnostic is + expected for recognized CORE 13 systems; it is not itself a connection error. +- Inspect a bounded local log excerpt for TrueNAS-related errors: ```bash - journalctl -u pulse | grep -i truenas + journalctl -u pulse -n 100 --no-pager | grep -i truenas # or - docker logs pulse | grep -i truenas + docker logs --tail 100 pulse 2>&1 | grep -i truenas ``` +- If collection is safe, **Settings → Diagnostics → Export for GitHub + (sanitized)** can provide connection evidence. Review the export or log + excerpt before sharing: remove credentials, cookies, private hostnames, + addresses and webhook URLs. Do not upload configuration or credential files. + +### Inventory works but CPU, memory or History is missing + +Services, uptime, hardware capacity and disk temperatures can be collected +without CPU or memory usage. A Host panel showing core count and total memory +does not establish a live reading; check the CPU/memory usage columns and +History series separately. A disk-utilisation line does not establish that +CPU, memory or network History is available. + +Check whether the corresponding graphs work in the TrueNAS web UI. If Pulse +still shows missing readings, report the affected panels, running Pulse and +TrueNAS versions, and the relevant sanitized evidence above. Do not replace +an otherwise working key or clear stored History merely because a graph is +empty. When sharing a requested native graph response, include only its graph +name, `legend`, timing fields and one `data` row or error; omit authentication +messages and identifying details. Do not upload a full browser network capture. ### Stale TrueNAS data - If TrueNAS data stops updating, the source status transitions to `stale` after ~120 seconds. - Check TrueNAS connectivity and API key validity. -- Verify with the API: - ```bash - curl -H "Authorization: Bearer $TOKEN" http://localhost:7655/api/resources \ - | jq '.resources[] | select(.platformType == "truenas")' - ``` +- In your signed-in Pulse browser session, open `/api/resources` on the same + Pulse origin and inspect the entries with `platformType: "truenas"`. This + uses your existing session without copying a token or cookie. Do not post + the full response; use the sanitized export or a reviewed relevant excerpt. ### Disabling TrueNAS integration Set `PULSE_ENABLE_TRUENAS=false` and restart Pulse. Existing connection data is preserved but polling stops. diff --git a/frontend-modern/public/docs/TRUENAS.md b/frontend-modern/public/docs/TRUENAS.md index 13ef48257b..80dbb83387 100644 --- a/frontend-modern/public/docs/TRUENAS.md +++ b/frontend-modern/public/docs/TRUENAS.md @@ -89,24 +89,51 @@ All endpoints require admin authentication. | `DELETE` | `/api/truenas/connections/{id}` | Remove a TrueNAS connection | | `POST` | `/api/truenas/connections/test` | Test a connection before saving | -### Adding a connection (API) +### Testing and adding a connection (API) + +The **Settings → TrueNAS** flow is the simplest option. For an API client, +prepare the private Pulse header file as described in the +[API authentication guide](API.md). The Pulse API token authenticates to +Pulse; the separate TrueNAS API key belongs in the connection's JSON file. +Do not put either credential in shell arguments, URLs or a public thread. + +Create or protect the connection file before opening it in a trusted editor: ```bash -curl -X POST http://localhost:7655/api/truenas/connections \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"name":"nas-1","host":"https://truenas.local","username":"key-owner","apiKey":"your-api-key"}' +umask 077 +mkdir -p "$HOME/.config/pulse" +touch "$HOME/.config/pulse/truenas-connection.json" +chmod 600 "$HOME/.config/pulse/truenas-connection.json" +vi "$HOME/.config/pulse/truenas-connection.json" ``` -### Testing a connection (API) +In that file, enter a JSON object with `name`, `host` (the TrueNAS HTTPS URL), +`username` (the key owner) and `apiKey`. Keep both credential files private; +do not paste their contents into a terminal command or upload them. + +Test the connection before saving it: ```bash -curl -X POST http://localhost:7655/api/truenas/connections/test \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"name":"nas-1","host":"https://truenas.local","username":"key-owner","apiKey":"your-api-key"}' +curl --fail-with-body --silent --show-error \ + --header "@$HOME/.config/pulse/api-header" \ + --header 'Content-Type: application/json' \ + --data-binary "@$HOME/.config/pulse/truenas-connection.json" \ + http://127.0.0.1:7655/api/truenas/connections/test ``` +After checking the test response, save the connection with: + +```bash +curl --fail-with-body --silent --show-error \ + --header "@$HOME/.config/pulse/api-header" \ + --header 'Content-Type: application/json' \ + --data-binary "@$HOME/.config/pulse/truenas-connection.json" \ + http://127.0.0.1:7655/api/truenas/connections +``` + +These Pulse URLs are loopback-only examples. For a remote Pulse server, use +its trusted HTTPS origin without disabling certificate verification. + ## Troubleshooting ### "TrueNAS service unavailable" @@ -122,21 +149,43 @@ curl -X POST http://localhost:7655/api/truenas/connections/test \ ### No data appearing after adding connection - Wait at least 30 seconds for the first poll cycle. -- Check Pulse logs for TrueNAS-related errors: +- **Test Connection** checks authentication and the selected transport, not + successful collection of every metric. The legacy REST diagnostic is + expected for recognized CORE 13 systems; it is not itself a connection error. +- Inspect a bounded local log excerpt for TrueNAS-related errors: ```bash - journalctl -u pulse | grep -i truenas + journalctl -u pulse -n 100 --no-pager | grep -i truenas # or - docker logs pulse | grep -i truenas + docker logs --tail 100 pulse 2>&1 | grep -i truenas ``` +- If collection is safe, **Settings → Diagnostics → Export for GitHub + (sanitized)** can provide connection evidence. Review the export or log + excerpt before sharing: remove credentials, cookies, private hostnames, + addresses and webhook URLs. Do not upload configuration or credential files. + +### Inventory works but CPU, memory or History is missing + +Services, uptime, hardware capacity and disk temperatures can be collected +without CPU or memory usage. A Host panel showing core count and total memory +does not establish a live reading; check the CPU/memory usage columns and +History series separately. A disk-utilisation line does not establish that +CPU, memory or network History is available. + +Check whether the corresponding graphs work in the TrueNAS web UI. If Pulse +still shows missing readings, report the affected panels, running Pulse and +TrueNAS versions, and the relevant sanitized evidence above. Do not replace +an otherwise working key or clear stored History merely because a graph is +empty. When sharing a requested native graph response, include only its graph +name, `legend`, timing fields and one `data` row or error; omit authentication +messages and identifying details. Do not upload a full browser network capture. ### Stale TrueNAS data - If TrueNAS data stops updating, the source status transitions to `stale` after ~120 seconds. - Check TrueNAS connectivity and API key validity. -- Verify with the API: - ```bash - curl -H "Authorization: Bearer $TOKEN" http://localhost:7655/api/resources \ - | jq '.resources[] | select(.platformType == "truenas")' - ``` +- In your signed-in Pulse browser session, open `/api/resources` on the same + Pulse origin and inspect the entries with `platformType: "truenas"`. This + uses your existing session without copying a token or cookie. Do not post + the full response; use the sanitized export or a reviewed relevant excerpt. ### Disabling TrueNAS integration Set `PULSE_ENABLE_TRUENAS=false` and restart Pulse. Existing connection data is preserved but polling stops. diff --git a/scripts/tests/test_truenas_docs.py b/scripts/tests/test_truenas_docs.py new file mode 100644 index 0000000000..d0dfd83c90 --- /dev/null +++ b/scripts/tests/test_truenas_docs.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Exercise TrueNAS connection recipes without putting credentials in argv. + +All credentials are synthetic. HTTP checks require the offline source-proof VM. +""" + +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, HTTPServer +import json +import os +from pathlib import Path +import re +import shutil +import stat +import subprocess +import tempfile +import threading +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +DOC = ROOT / "docs/TRUENAS.md" +TOKEN = "synthetic-pulse-token" +NAS_KEY = "synthetic-truenas-key" +PAYLOAD = {"name": "test-nas", "host": "https://nas.example.invalid", + "username": "key-owner", "apiKey": NAS_KEY} + + +def commands(): + blocks = re.findall(r"```bash\n(.*?)```", DOC.read_text(), re.DOTALL) + preparation = next(block for block in blocks if "umask 077" in block) + requests = [block for block in blocks if block.startswith("curl ")] + return preparation, requests + + +@contextmanager +def server(status=200): + requests = [] + + class Handler(BaseHTTPRequestHandler): + def do_POST(self): + body = self.rfile.read(int(self.headers["Content-Length"])) + requests.append((self.path, dict(self.headers), json.loads(body))) + self.send_response(status) + self.end_headers() + self.wfile.write(b'{"fixture":true}\n') + + def log_message(self, *_args): + pass + + http = HTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=http.serve_forever, daemon=True) + thread.start() + try: + yield http.server_address[1], requests + finally: + http.shutdown() + http.server_close() + thread.join(timeout=5) + + +class TrueNASDocsTest(unittest.TestCase): + def test_shipped_copy_and_safe_diagnostics(self): + self.assertEqual(DOC.read_bytes(), (ROOT / "frontend-modern/public/docs/TRUENAS.md").read_bytes()) + text = DOC.read_text() + bash = "\n".join(re.findall(r"```bash\n(.*?)```", text, re.DOTALL)) + self.assertNotRegex(bash, r"Authorization:|Bearer\s|\$TOKEN|apiKey|--insecure") + self.assertIn("journalctl -u pulse -n 100 --no-pager", bash) + self.assertIn("docker logs --tail 100 pulse 2>&1", bash) + self.assertIn("does not establish a live reading", text) + self.assertIn("Do not upload a full browser network capture", text) + + def test_preparation_protects_new_and_existing_payload(self): + preparation, _ = commands() + with tempfile.TemporaryDirectory() as temporary: + home = Path(temporary) + tools = home / "tools" + tools.mkdir() + editor = tools / "vi" + editor.write_text('#!/bin/sh\n[ "$#" = 1 ] && [ "$1" = "$HOME/.config/pulse/truenas-connection.json" ]\n') + editor.chmod(0o700) + env = dict(os.environ, HOME=str(home), PATH=f"{tools}:{os.environ['PATH']}") + payload = home / ".config/pulse/truenas-connection.json" + for existing in (False, True): + if existing: + payload.write_text(json.dumps(PAYLOAD)) + payload.chmod(0o644) + subprocess.run(["bash", "-eu", "-c", preparation], env=env, check=True) + self.assertEqual(stat.S_IMODE(payload.stat().st_mode), 0o600) + self.assertEqual(payload.read_text(), json.dumps(PAYLOAD) if existing else "") + + def request(self, home, command, port): + private = home / ".config/pulse" + private.mkdir(parents=True, exist_ok=True) + for name, content in (("api-header", f"X-API-Token: {TOKEN}\n"), + ("truenas-connection.json", json.dumps(PAYLOAD))): + path = private / name + path.write_text(content) + path.chmod(0o600) + real_curl = shutil.which("curl") + self.assertIsNotNone(real_curl) + tools = home / "tools" + tools.mkdir(exist_ok=True) + recorder = tools / "curl" + recorder.write_text( + "#!/usr/bin/env python3\nimport json, os, sys\nfrom pathlib import Path\n" + "Path(os.environ['ARGV_RECEIPT']).write_text(json.dumps(sys.argv[1:]))\n" + "os.execv(os.environ['REAL_CURL'], [os.environ['REAL_CURL'], *sys.argv[1:]])\n" + ) + recorder.chmod(0o700) + receipt = home / "argv.json" + env = dict(os.environ, HOME=str(home), PATH=f"{tools}:{os.environ['PATH']}", + REAL_CURL=real_curl, ARGV_RECEIPT=str(receipt)) + command = command.replace("http://127.0.0.1:7655", f"http://127.0.0.1:{port}") + result = subprocess.run(["bash", "-eu", "-c", command], env=env, capture_output=True, timeout=10) + argv = json.loads(receipt.read_text()) + self.assertIn("@" + str(private / "api-header"), argv) + self.assertIn("@" + str(private / "truenas-connection.json"), argv) + for secret in (TOKEN, NAS_KEY): + self.assertNotIn(secret, " ".join(argv)) + self.assertNotIn(secret.encode(), result.stdout + result.stderr) + return result + + def test_test_and_save_send_header_and_json_from_private_files(self): + _, recipes = commands() + self.assertEqual(len(recipes), 2) + with tempfile.TemporaryDirectory() as temporary, server() as (port, requests): + for command, endpoint in zip(recipes, ("/api/truenas/connections/test", "/api/truenas/connections")): + result = self.request(Path(temporary), command, port) + self.assertEqual(result.returncode, 0, result.stderr.decode()) + path, headers, payload = requests[-1] + self.assertEqual(path, endpoint) + self.assertEqual(headers["X-API-Token"], TOKEN) + self.assertEqual(headers["Content-Type"], "application/json") + self.assertEqual(payload, PAYLOAD) + + def test_http_auth_failure_is_not_reported_as_success(self): + _, recipes = commands() + for status in (401, 403): + for command in recipes: + with self.subTest(status=status, command=command), tempfile.TemporaryDirectory() as temporary: + with server(status) as (port, _): + result = self.request(Path(temporary), command, port) + self.assertEqual(result.returncode, 22, result.stderr.decode()) + self.assertIn(b'{"fixture":true}', result.stdout) + + +if __name__ == "__main__": + unittest.main()