From c08583dd54a8462566ad34094fcc7e8f577bf854 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:25:48 +0100 Subject: [PATCH] Distinguish command permission from a connected agent channel The LXC discovery report reached command-capable credentials but could not distinguish them from live command-channel admission. Explain the blocked state, keep successful guest checks, and provide a bounded local journal check without credential resets, broader permissions or full-log disclosure. Bind the guidance and command syntax to the existing service events. Change-source: pulse-maintainer --- docs/UNIFIED_AGENT.md | 37 ++++++++++++++ frontend-modern/public/docs/UNIFIED_AGENT.md | 37 ++++++++++++++ .../tests/test_docker_troubleshooting_docs.py | 51 ++++++++++++++++++- 3 files changed, 124 insertions(+), 1 deletion(-) diff --git a/docs/UNIFIED_AGENT.md b/docs/UNIFIED_AGENT.md index 86ee75dcc..adf837179 100644 --- a/docs/UNIFIED_AGENT.md +++ b/docs/UNIFIED_AGENT.md @@ -1000,6 +1000,11 @@ LXC guests** in Settings → System → General and any configured guests and guests already linked to an online guest-local agent. In the latter case, check that guest agent's Docker monitoring instead. +If the owning node says **Remote control blocked**, first check its +[command channel](#commands-enabled-but-remote-control-blocked). A successful +root `pct exec` check does not establish that Pulse can reach that node's agent; +do not repeat the guest probe when those results are already known. + The host-side path requires the guest's `docker` executable and `/var/run/docker.sock` in the root `pct exec` context. A working Docker CLI in a user's login session does not prove that this context can reach the daemon; @@ -1036,6 +1041,38 @@ Review output before posting and redact any private endpoint in an error. Do not change LXC privilege, `keyctl`, socket permissions or Docker versions merely to test a guess. +### Commands enabled but remote control blocked + +**Remote control blocked** means the agent reports commands enabled, but Pulse +has no admitted command channel connected for it. Agent Doctor's +`commands command-capable · credential grants exec` line describes the reported +local command ceiling and credential scope, not a connected session. A fresh +monitoring report or **Automatic updates ready** also does not prove that this +separate WebSocket channel is working. Changing Proxmox API permissions cannot +repair a Pulse command-channel connection. + +For a systemd agent, inspect the recent journal **locally on the affected node**: + +```bash +sudo journalctl -u pulse-agent.service --since '15 minutes ago' -n 200 --no-pager --output=cat +``` + +Look for **Connected and registered with Pulse command server**, or +**WebSocket connection failed repeatedly, reconnecting** and its error. A +`dial websocket` error is a connection/handshake failure; `registration failed` +means registration was attempted but not accepted. Scope alone does not prove +that the credential's host/agent binding was admitted. No matching entry is +inconclusive: initial retries may be debug-only; do not restart the host or +enable server-wide debug logging to manufacture an error. + +If reporting the problem, share only the failure stage and redacted error +reason (or say no command-channel entry is visible), not the full journal or +service configuration. Omit tokens, cookies, URLs, hostnames, addresses and +agent/token IDs. Keep saved identity and credentials intact while distinguishing +connection failures from admission failures; do not delete state, loosen TLS +verification or broaden permissions to force a connection. Guest-local Docker +monitoring with commands disabled remains an alternative to host-side discovery. + ### Check Status ```bash # Linux diff --git a/frontend-modern/public/docs/UNIFIED_AGENT.md b/frontend-modern/public/docs/UNIFIED_AGENT.md index 86ee75dcc..adf837179 100644 --- a/frontend-modern/public/docs/UNIFIED_AGENT.md +++ b/frontend-modern/public/docs/UNIFIED_AGENT.md @@ -1000,6 +1000,11 @@ LXC guests** in Settings → System → General and any configured guests and guests already linked to an online guest-local agent. In the latter case, check that guest agent's Docker monitoring instead. +If the owning node says **Remote control blocked**, first check its +[command channel](#commands-enabled-but-remote-control-blocked). A successful +root `pct exec` check does not establish that Pulse can reach that node's agent; +do not repeat the guest probe when those results are already known. + The host-side path requires the guest's `docker` executable and `/var/run/docker.sock` in the root `pct exec` context. A working Docker CLI in a user's login session does not prove that this context can reach the daemon; @@ -1036,6 +1041,38 @@ Review output before posting and redact any private endpoint in an error. Do not change LXC privilege, `keyctl`, socket permissions or Docker versions merely to test a guess. +### Commands enabled but remote control blocked + +**Remote control blocked** means the agent reports commands enabled, but Pulse +has no admitted command channel connected for it. Agent Doctor's +`commands command-capable · credential grants exec` line describes the reported +local command ceiling and credential scope, not a connected session. A fresh +monitoring report or **Automatic updates ready** also does not prove that this +separate WebSocket channel is working. Changing Proxmox API permissions cannot +repair a Pulse command-channel connection. + +For a systemd agent, inspect the recent journal **locally on the affected node**: + +```bash +sudo journalctl -u pulse-agent.service --since '15 minutes ago' -n 200 --no-pager --output=cat +``` + +Look for **Connected and registered with Pulse command server**, or +**WebSocket connection failed repeatedly, reconnecting** and its error. A +`dial websocket` error is a connection/handshake failure; `registration failed` +means registration was attempted but not accepted. Scope alone does not prove +that the credential's host/agent binding was admitted. No matching entry is +inconclusive: initial retries may be debug-only; do not restart the host or +enable server-wide debug logging to manufacture an error. + +If reporting the problem, share only the failure stage and redacted error +reason (or say no command-channel entry is visible), not the full journal or +service configuration. Omit tokens, cookies, URLs, hostnames, addresses and +agent/token IDs. Keep saved identity and credentials intact while distinguishing +connection failures from admission failures; do not delete state, loosen TLS +verification or broaden permissions to force a connection. Guest-local Docker +monitoring with commands disabled remains an alternative to host-side discovery. + ### Check Status ```bash # Linux diff --git a/scripts/tests/test_docker_troubleshooting_docs.py b/scripts/tests/test_docker_troubleshooting_docs.py index 3041dd147..de42c4823 100644 --- a/scripts/tests/test_docker_troubleshooting_docs.py +++ b/scripts/tests/test_docker_troubleshooting_docs.py @@ -20,7 +20,10 @@ SECRET = "synthetic-docker-docs-secret" def section(heading): guide = (ROOT / "docs/UNIFIED_AGENT.md").read_text() - return guide.split("### " + heading + "\n", 1)[1].split("\n### ", 1)[0] + marker = "### " + heading + "\n" + if marker not in guide: + raise AssertionError(f"missing diagnostic section: {heading}") + return guide.split(marker, 1)[1].split("\n### ", 1)[0] def command(heading): @@ -106,6 +109,52 @@ class DockerTroubleshootingDocsTest(unittest.TestCase): self.assertIn(operation, recipe) self.assertIn(operation, source) + def test_command_channel_guidance_does_not_confuse_scope_with_admission(self): + text = section("Commands enabled but remote control blocked") + for required in ( + "no admitted command channel connected", "not a connected session", + "Automatic updates ready", "Changing Proxmox API permissions cannot", + "locally on the affected node", "`dial websocket`", "`registration failed`", + "No matching entry is\ninconclusive", "not the full journal", + "Omit tokens, cookies, URLs, hostnames, addresses", "Keep saved identity and credentials intact", + "do not delete state, loosen TLS", "commands disabled remains an alternative", + ): + with self.subTest(required=required): + self.assertIn(required, text) + for label in ("Connected and registered with Pulse command server", + "WebSocket connection failed repeatedly, reconnecting"): + self.assertIn(label, text) + self.assertIn(label, (ROOT / "internal/hostagent/commands.go").read_text()) + policy = (ROOT / "internal/api/connections_aggregator.go").read_text() + self.assertIn("agent reports command execution enabled, but no admitted command channel is connected", policy) + guest = section("Docker visible in one LXC but missing in another") + self.assertIn("#commands-enabled-but-remote-control-blocked", guest) + self.assertIn("do not repeat the guest probe", guest) + self.assertNotRegex(text, r"--property=Environment|systemctl (?:cat|restart)|--insecure") + + def test_command_channel_journal_check_is_bounded_and_read_only(self): + recipe = command("Commands enabled but remote control blocked") + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + for name, body in ( + ("sudo", '#!/bin/sh\nexec "$@"\n'), + ("journalctl", '#!/usr/bin/python3\nimport json, sys\nfrom pathlib import Path\n' + 'Path("' + str(root / "argv.json") + '").write_text(json.dumps(sys.argv[1:]))\n' + 'print("WebSocket connection failed repeatedly, reconnecting")\n'), + ): + path = root / name + path.write_text(body) + path.chmod(0o700) + env = dict(os.environ, PATH=f"{root}:{os.environ['PATH']}", SYNTHETIC_SECRET=SECRET) + result = subprocess.run(["bash", "-eu", "-c", recipe], env=env, + capture_output=True, text=True, timeout=5) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads((root / "argv.json").read_text()), [ + "-u", "pulse-agent.service", "--since", "15 minutes ago", "-n", "200", + "--no-pager", "--output=cat", + ]) + self.assertNotIn(SECRET, result.stdout + result.stderr) + def run_guest(self, *, socket="present", docker="ok", pct="ok", cli=True): with tempfile.TemporaryDirectory() as temporary: root = Path(temporary)