From 2fd114b3ac79fab125d6d63a4730eb010750465a Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 05:23:08 +0100 Subject: [PATCH] Keep Proxmox bootstrap credentials out of copied commands Separate PVE/PBS setup and telemetry credentials from shell source and download URLs. Use silent root/sudo input, private-file handoff, complete downloads and the agent preflight; preserve scope, TLS defaults, single-line paste and coherent rolling-upgrade metadata. Reveal tokens through the existing dialog and discard late issuance after close. Pin executable shell, history, TLS/registration and browser contracts without using real credentials. Change-source: pulse-maintainer --- docs/API.md | 39 +- .../v6/internal/subsystems/agent-lifecycle.md | 5 + .../v6/internal/subsystems/api-contracts.md | 5 + .../subsystems/deployment-installability.md | 5 + .../internal/subsystems/storage-recovery.md | 5 + .../browser-tests/proxmox-private-input.cjs | 238 ++++++ .../browser-tests/proxmox-private-input.html | 12 + .../browser-tests/proxmox-private-input.tsx | 54 ++ frontend-modern/browser-verification.json | 802 +----------------- frontend-modern/public/docs/API.md | 39 +- .../__tests__/nodes.branchcov0724pm.test.ts | 4 +- .../src/api/__tests__/nodes.test.ts | 99 ++- frontend-modern/src/api/nodes.ts | 17 +- .../Settings/NodeModalSetupGuideSection.tsx | 26 +- .../NodeModalSetupGuideSection.test.tsx | 6 +- .../__tests__/settingsArchitecture.test.ts | 3 +- .../components/Settings/useNodeModalState.ts | 53 +- frontend-modern/src/stores/tokenReveal.ts | 2 +- .../src/utils/__tests__/docsLinks.test.ts | 11 + install.sh | 70 +- internal/api/agent_install_command_shared.go | 65 +- .../api/agent_install_command_shared_test.go | 57 +- .../config_handlers_setup_url_test.go | 32 +- internal/api/configapi/install_command.go | 89 +- .../api/configapi/install_command_test.go | 425 ++++++++-- .../private_bootstrap_pipeline_test.go | 144 ++++ .../api/configapi/setup_script_artifact.go | 36 +- internal/api/configapi/setup_script_render.go | 77 +- internal/api/contract_test.go | 87 +- .../api/hosted_agent_install_command_test.go | 2 +- internal/hostagent/proxmox_setup.go | 15 +- internal/hostagent/proxmox_setup_test.go | 46 + .../installtests/private_bootstrap_test.go | 93 ++ scripts/installtests/root_install_sh_test.go | 20 +- 34 files changed, 1459 insertions(+), 1224 deletions(-) create mode 100644 frontend-modern/browser-tests/proxmox-private-input.cjs create mode 100644 frontend-modern/browser-tests/proxmox-private-input.html create mode 100644 frontend-modern/browser-tests/proxmox-private-input.tsx create mode 100644 internal/api/configapi/private_bootstrap_pipeline_test.go create mode 100644 scripts/installtests/private_bootstrap_test.go diff --git a/docs/API.md b/docs/API.md index 46d3d3cbe..928b6ad58 100644 --- a/docs/API.md +++ b/docs/API.md @@ -525,9 +525,11 @@ Request body: ### Setup Script (Public) `GET /api/setup-script` Returns the Proxmox/PBS setup script as a shell-script download. Accepts an -optional temporary setup token in the `setup_token` query for embedded -non-interactive bootstrap; otherwise the script prompts for the one-time setup -token at runtime. Canonical callers must send a supported `type` of `pve` or +optional legacy `setup_token` query for compatibility. Current downloads +contain no token: paste the separately revealed token only at the silent +terminal prompt, or supply `PULSE_SETUP_TOKEN_FILE` pointing to a mode-0600 +regular file in a mode-0700 directory owned by the script's user. Never put a +token in a copied command or URL. Canonical callers must send a supported `type` of `pve` or `pbs` plus non-empty `host` and `pulse_url`; the route no longer generates placeholder-host scripts for later repair or reconstructs Pulse identity from the request origin. The route now shares the same canonical type boundary as @@ -557,20 +559,23 @@ authorize the request itself. Pulse-managed Proxmox monitor-token names on the setup/bootstrap path derive from the canonical Pulse endpoint, not request-local host fallbacks, so setup-script and turnkey node-add flows stay on one deterministic `pulse-` identity per Pulse instance. -`setupToken` remains bootstrap transport data for `/api/setup-script` and -`/api/auto-register`, while `tokenHint` is the operator-facing display field -for quick-setup surfaces and must stay masked instead of exposing the full -one-time token in UI copy. Shared frontend consumers may validate -`setupToken`, but they should not retain or display it once the returned -bootstrap artifact and `tokenHint` are available; visible quick-setup previews -should use the non-secret `commandWithoutEnv` form while copy actions keep -using the token-bearing `commandWithEnv` artifact, and manual download flows -should use the token-bearing `downloadURL` artifact instead of rebuilding a -plain setup-script URL from non-secret preview state. Non-frontend bootstrap -consumers such as the runtime-side Unified Agent bootstrap flow and shell installer must fail closed on that -same full artifact contract too, rejecting missing or mismatched -`downloadURL`, `tokenHint`, or expired `expires` values instead of accepting a -reduced setup-token-only response shape. +The `command`, `commandWithEnv`, and `commandWithoutEnv` fields now contain +identical credential-free commands. They download the complete script before +running it and prompt silently in the root-or-sudo process. The token crosses +the installer boundary through a private file, not process arguments or an +exported secret. `downloadURL` equals the tokenless `url`, so a manual download +also needs the separately revealed token at runtime. `setupToken` is used for +`/api/auto-register`; `tokenHint` remains masked on the setup page. Settings +reveals the token in a separate dialog: run the command first, then copy and +paste the token only at its prompt. The artifact is reused only for the same +host and options while its five-minute expiry is live, and discarded when the +setup modal closes. +Non-frontend consumers must validate the complete artifact, including the +canonical host, type, URLs, filename, masked hint and live expiry. Current +Unified Agents and the shell installer also accept the coherent older-server +artifact during upgrades, but never execute its command text. For new +Proxmox agent enrolment against a newer server, use its current installer; +already enrolled agents keep reporting normally. ### Auto-Register (Public) `POST /api/auto-register` diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 69e9c7318..2c16d51b4 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -3452,6 +3452,11 @@ Agent` secondary handoff against the live setup wizard instead of relying ## Current State +### Credential-safe Proxmox bootstrap (1 October 2026) + +Proxmox initial bootstrap keeps credentials out of copied shell source. The current PVE/PBS agent command fetches a complete installer, runs its credential-free preflight, then privately prompts inside the root/sudo Bash child. Its 0700 directory and 0600 token file are owned by that child, passed only by path and removed on exit or handled signals. The UI reveals credentials separately, clears cached setup material on close and discards late issuance after close/reset. No exec scope is added. Existing fleet reporting is unchanged; newly enrolling an older agent with a newer server requires the current installer. New agents and the root installer still validate coherent old-server artifacts during rolling upgrades, but never execute their command strings. + + ### Update progress stream delivery Server update progress delivery is owned by internal/updates and its API diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index e1030c053..9298790ee 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -4579,6 +4579,11 @@ auto-register mutation boundary. ## Current State +### Credential-safe Proxmox bootstrap (1 October 2026) + +The current Proxmox setup artifact supersedes the former token-bearing command/download contract: `command`, `commandWithEnv` and `commandWithoutEnv` are identical credential-free, single-line transports. `downloadURL` equals the tokenless `url`; downloads embed no setup token. `setupToken` (or agent-install `token`) is returned separately through the authenticated issuance response for an explicit reveal, never interpolated by the consumer. Settings uses the existing token-reveal dialog and tells users to run the command before pasting at its silent terminal prompt. Setup cache remains bound to endpoint/mode/live five-minute expiry and is discarded on modal close. The rendered scripts accept a bounded private `PULSE_SETUP_TOKEN_FILE` before mutation, unexport the secret, and put registration credentials in stdin, not process arguments. Legacy explicit query-token downloads remain accepted for compatibility, but no current artifact generates those URLs. Failed attempted registration returns nonzero and never echoes the response body. Runtime and root-installer consumers validate either the complete new artifact or the coherent old-server artifact; mixed transports fail closed. + + ### Update progress stream delivery `GET /api/updates/stream` answers `text/event-stream` with diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 82ca498ea..9e3e54ca0 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -2553,6 +2553,11 @@ artifact-selection behaviour. ## Current State +### Credential-safe Proxmox bootstrap (1 October 2026) + +Current PVE auto-registration metadata accepts the credential-free setup artifact: all command aliases use a private-file handoff and `downloadURL` equals the tokenless script URL. This replaces earlier requirements to embed setup tokens in commands/URLs. The older coherent server artifact is accepted read-only during upgrades, never executed. Host, type, canonical filename/URL, masked hint and live expiry remain required. Root-installer JSON parsing and registration pass secrets through descriptor/stdin input rather than Python/curl argv, and the setup response is no longer persisted as a plaintext /tmp diagnostic. No install source, API scope, trust exception, release selector or success condition is widened. + + ### Update progress stream delivery `GET /api/updates/stream` is the in-app updater's progress feed and must never diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index fe2083f3b..1f6e4df2c 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -2640,6 +2640,11 @@ vdev layout is reported` in ## Current State +### Credential-safe Proxmox bootstrap (1 October 2026) + +The shared Proxmox setup boundary now receives its bootstrap credential from private terminal input via a bounded 0700/0600 file, before native setup mutation, rather than from copied shell literals or newly generated token-bearing download URLs. Existing registration grant binding, PVE/PBS monitoring scopes, and explicit non-rotating Audit/Repair remain unchanged. A private-file validation failure stops setup, and failed attempted registration exits nonzero with fixed guidance rather than echoed credential-bearing responses. This supersedes historical shared-boundary requirements for token-bearing commands and manual download URLs; it does not change storage permissions, recovery or retention. + + ### Update progress stream delivery Update progress delivery changes observation only. Streaming or polling a diff --git a/frontend-modern/browser-tests/proxmox-private-input.cjs b/frontend-modern/browser-tests/proxmox-private-input.cjs new file mode 100644 index 000000000..abc346a21 --- /dev/null +++ b/frontend-modern/browser-tests/proxmox-private-input.cjs @@ -0,0 +1,238 @@ +// Browser checks only; server responses are synthetic, not installed bootstrap. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { chromium, webkit } = require('playwright'); +(async () => { + const root = '/workspace/frontend-modern'; + process.chdir(root); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, + configFile: path.join(root, 'vite.config.ts'), + server: { host: '127.0.0.1', port: 5237, strictPort: true }, + }); + const engine = process.argv.includes('--phone') ? 'webkit' : 'chromium'; + const width = engine === 'webkit' ? 390 : 1280; + const artifacts = path.join(root, 'node_modules', `proxmox-private-${engine}`); + fs.mkdirSync(artifacts, { recursive: true }); + let browser; + const observations = []; + try { + await server.listen(); + browser = await { chromium, webkit }[engine].launch( + engine === 'chromium' + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + for (const type of ['pve', 'pbs']) { + const page = await browser.newPage({ + viewport: { width, height: 900 }, + ...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}), + }); + const errors = []; + page.on('pageerror', (e) => errors.push(e.message)); + await page.addInitScript(() => { + window.__copies = []; + Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: async (value) => { + window.__copies.push(value); + }, + }, + }); + }); + let count = 0; + let holdEndpoint, releaseHeld, sawHeld; + await page.route('**/api/**', async (route) => { + const url = new URL(route.request().url()); + if (!url.pathname.startsWith('/api/')) return route.continue(); + if (url.pathname === holdEndpoint) { + holdEndpoint = undefined; + await new Promise((resolve) => { + releaseHeld = resolve; + sawHeld(); + }); + } + if (url.pathname === '/api/setup-script-url') { + count++; + const body = route.request().postDataJSON(); + assert.equal(body.type, type); + const scriptURL = `http://127.0.0.1:5237/api/setup-script?type=${type}`; + const command = `( curl -fsSL '${scriptURL}' -o "$install_script"; sudo bash -c 'read -r -s pulse_token; PULSE_SETUP_TOKEN_FILE="$token_file" bash "$1"'; )`; + return route.fulfill({ + json: { + type, + host: body.host, + url: scriptURL, + downloadURL: scriptURL, + scriptFileName: `pulse-setup-${type}.sh`, + command, + commandWithEnv: command, + commandWithoutEnv: command, + setupToken: 'a'.repeat(32), + tokenHint: 'aaa…aaa', + expires: Math.floor(Date.now() / 1000) + 300, + }, + }); + } + if (url.pathname === '/api/agent-install-command') { + const body = route.request().postDataJSON(); + assert.equal(body.type, type); + assert.equal(body.enableCommands, false); + assert.equal(body.insecure, false); + return route.fulfill({ + json: { + command: + '( bash "$install_script" --preflight-only; sudo bash -c \'read -r -s pulse_token; bash "$1" --token-file "$token_file" --enable-proxmox\'; )', + token: 'b'.repeat(32), + }, + }); + } + if (url.pathname === '/api/setup-script') { + assert.ok(!url.searchParams.has('setup_token')); + return route.fulfill({ + status: 200, + contentType: 'text/x-shellscript; charset=utf-8', + headers: { 'Content-Disposition': `attachment; filename="pulse-setup-${type}.sh"` }, + body: '#!/bin/bash\n# no credential in the download\n', + }); + } + return route.fulfill({ json: { data: [] } }); + }); + await page.goto( + `http://127.0.0.1:5237/browser-tests/proxmox-private-input.html?type=${type}`, + ); + await page.locator('h1').waitFor(); + if (engine === 'webkit') + await page.evaluate(() => document.documentElement.classList.add('dark')); + console.log( + JSON.stringify({ + type, + errors, + buttons: await page.getByRole('button').allTextContents(), + body: (await page.locator('body').innerText()).slice(0, 300), + }), + ); + const copy = page.getByRole('button', { name: 'Copy command', exact: true }); + await copy.first().click(); + let dialog = page.getByRole('dialog', { name: 'API token ready' }); + await dialog.waitFor(); + await page.waitForFunction(() => { + const panel = document.querySelector('[role="dialog"]'); + return panel && getComputedStyle(panel).opacity === '1'; + }); + assert.ok((await dialog.innerText()).includes('silent “Pulse setup token” prompt')); + assert.ok(!(await page.evaluate(() => window.__copies[0])).includes('a'.repeat(32))); + await page.screenshot({ path: path.join(artifacts, `${type}-setup.png`), fullPage: true }); + const tokenButton = dialog.getByRole('button', { name: 'Copy token', exact: true }); + await tokenButton.focus(); + await page.keyboard.press('Enter'); + assert.equal(await page.evaluate(() => window.__copies.at(-1)), 'a'.repeat(32)); + await page.keyboard.press('Escape'); + await dialog.waitFor({ state: 'hidden' }); + await copy.first().click(); + await dialog.waitFor(); + assert.equal(count, 1, 'live setup artifact must be reused'); + await dialog.getByRole('button', { name: 'Dismiss', exact: true }).click(); + await page.getByRole('button', { name: 'Close setup', exact: true }).click(); + assert.equal(await page.getByTestId('cache-state').innerText(), 'empty'); + await page.getByRole('button', { name: 'Reopen setup', exact: true }).click(); + await copy.first().click(); + await dialog.waitFor(); + assert.equal(count, 2, 'closed modal must not retain its setup secret'); + await dialog.getByRole('button', { name: 'Dismiss', exact: true }).click(); + await page.getByText('Alternative: Download script manually', { exact: true }).click(); + await page.getByRole('button', { name: 'Download setup script', exact: true }).click(); + await dialog.waitFor(); + assert.equal(count, 2, 'download must reuse the live credential-free artifact'); + await dialog.getByRole('button', { name: 'Dismiss', exact: true }).click(); + await page.getByRole('button', { name: 'Host Telemetry Agent', exact: true }).click(); + await page + .getByRole('button', { + name: type === 'pbs' ? 'Copy to clipboard' : 'Copy command', + exact: true, + }) + .first() + .click(); + await dialog.waitFor(); + await page.waitForFunction(() => { + const panel = document.querySelector('[role="dialog"]'); + return panel && getComputedStyle(panel).opacity === '1'; + }); + assert.ok((await dialog.innerText()).includes('silent “Pulse agent token” prompt')); + assert.ok(!(await page.evaluate(() => window.__copies.at(-1))).includes('b'.repeat(32))); + await page.screenshot({ path: path.join(artifacts, `${type}-agent.png`), fullPage: true }); + await dialog.getByRole('button', { name: 'Copy token', exact: true }).click(); + assert.equal(await page.evaluate(() => window.__copies.at(-1)), 'b'.repeat(32)); + await dialog.getByRole('button', { name: 'Dismiss', exact: true }).click(); + // A late issuance must not repopulate a closed modal's secret cache + // or reopen the global reveal dialog after the user has left the flow. + for (const endpoint of ['/api/setup-script-url', '/api/agent-install-command']) { + const closeSetup = page.getByRole('button', { name: 'Close setup', exact: true }); + if (await closeSetup.count()) await closeSetup.click(); + await page.getByRole('button', { name: 'Reopen setup', exact: true }).click(); + if (endpoint.includes('agent-install')) + await page.getByRole('button', { name: 'Host Telemetry Agent', exact: true }).click(); + else await page.getByRole('button', { name: /Connect via API/ }).click(); + holdEndpoint = endpoint; + let deadline; + const held = new Promise((resolve, reject) => { + sawHeld = resolve; + deadline = setTimeout(() => reject(new Error('held request not observed')), 15000); + }); + const nextCopy = page + .getByRole('button', { + name: + endpoint.includes('agent-install') && type === 'pbs' + ? 'Copy to clipboard' + : 'Copy command', + exact: true, + }) + .first(); + await nextCopy.click(); + await held; + clearTimeout(deadline); + await page.getByRole('button', { name: 'Close setup', exact: true }).click(); + const completed = page.waitForResponse( + (response) => new URL(response.url()).pathname === endpoint, + ); + releaseHeld(); + await completed; + await page.waitForTimeout(200); // allow the actual promise continuation, not an API retry + assert.equal(await page.getByTestId('cache-state').innerText(), 'empty'); + assert.equal(await page.getByRole('dialog').count(), 0); + } + assert.deepEqual(errors, []); + observations.push({ + type, + engine, + width, + setupRequests: count, + credentialFreeCopies: true, + separateTokenCopy: true, + cacheClose: true, + lateIssuanceDiscarded: true, + commandsEnabled: false, + insecure: false, + }); + await page.close(); + } + fs.writeFileSync( + path.join(artifacts, 'result.json'), + JSON.stringify( + { + playwright: require('playwright/package.json').version, + browser: browser.version(), + observations, + }, + null, + 2, + ), + ); + console.log(JSON.stringify({ engine, browser: browser.version(), observations, artifacts })); + } finally { + if (browser) await browser.close(); + await server.close(); + } +})(); diff --git a/frontend-modern/browser-tests/proxmox-private-input.html b/frontend-modern/browser-tests/proxmox-private-input.html new file mode 100644 index 000000000..b88f0ce1e --- /dev/null +++ b/frontend-modern/browser-tests/proxmox-private-input.html @@ -0,0 +1,12 @@ + + + + + + Proxmox private input fixture + + +
+ + + diff --git a/frontend-modern/browser-tests/proxmox-private-input.tsx b/frontend-modern/browser-tests/proxmox-private-input.tsx new file mode 100644 index 000000000..cf4b982d6 --- /dev/null +++ b/frontend-modern/browser-tests/proxmox-private-input.tsx @@ -0,0 +1,54 @@ +// Real settings state, setup section and existing token dialog; synthetic APIs. +import { createSignal, Show } from 'solid-js'; +import type { NodeConfig } from '../src/types/nodes'; +import { render } from 'solid-js/web'; +import { useNodeModalState } from '../src/components/Settings/useNodeModalState'; +import { NodeModalSetupGuideSection } from '../src/components/Settings/NodeModalSetupGuideSection'; +import { TokenRevealDialog } from '../src/components/TokenRevealDialog'; +import '../src/index.css'; + +const Fixture = () => { + const type = new URLSearchParams(location.search).get('type') === 'pbs' ? 'pbs' : 'pve'; + const [open, setOpen] = createSignal(true); + const props = { + get isOpen() { + return open(); + }, + nodeType: type, + prefillNode: { + type, + name: `${type} fixture`, + host: `https://${type}.example:${type === 'pbs' ? 8007 : 8006}`, + verifySSL: true, + } as NodeConfig, + onClose() { + setOpen(false); + }, + onSave: async () => {}, + }; + const state = useNodeModalState(props); + return ( +
+

Connect a Proxmox server

+ + + + + + {state.quickSetupCommandReady() ? 'ready' : 'empty'} + + +
+ ); +}; +render(() => , document.getElementById('root')!); diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 6fd543733..7e82fe731 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,795 +1,63 @@ { "version": 1, - "base_sha": "00f82e2afc3cd75035c997e9e245c6e0eeffffa9", - "verified_at": "2026-10-01T01:42:43Z", + "base_sha": "6b23876ec8104965c7842e194e055f3206134d4f", + "verified_at": "2026-10-01T04:21:56.727078Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx", - "frontend-modern/src/components/Workloads/guestDrawerModel.ts" + "frontend-modern/src/api/nodes.ts", + "frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx", + "frontend-modern/src/components/Settings/useNodeModalState.ts", + "frontend-modern/src/stores/tokenReveal.ts" ], "content_sha256": { - "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "a49f3c6496e5515284a94c52b818f78f72d2e3530ae398794ea7432f251f48ec", - "frontend-modern/src/components/Workloads/guestDrawerModel.ts": "5ea99d3d995ddb2fa777273ed3f224b7fbb9e7b10a015bed52d61a8e10dc0b99" + "frontend-modern/src/api/nodes.ts": "398f3b87ce192c7650a9e34f184ecdca0e8baf35b57d90ef5f8f697348675fec", + "frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx": "e4168945744f8ec20b5439f5730b1ecd8b4811b29ec00da4e7e3798f868a1b41", + "frontend-modern/src/components/Settings/useNodeModalState.ts": "9372c3a4b346a0e31733a7042c0a65b5ebe3518b12595c6c6a68b1cadd667dcd", + "frontend-modern/src/stores/tokenReveal.ts": "c21ac970bde6229506f7d6e627430b6e91c9ea983f705a28e3d149e4415c39b7" }, "routes": [ - "/browser-tests/pbs-identity-boundary.html (production PBS table, full ResourceDetailDrawer, shared History/query and CSS with synthetic VM/Agent APIs; not installed collection or complete #1723 acceptance)" + "/browser-tests/proxmox-private-input.html?type=pve", + "/browser-tests/proxmox-private-input.html?type=pbs" ], "viewports": [ { - "width": 1365, + "width": 1280, "height": 900 }, { "width": 390, - "height": 844 + "height": 900 } ], "states": [ - "light/dark short sample coverage stays at its true position in the fulfilled 24-hour window rather than stretching across each plot", - "all configured utilization/network/disk/thermal panels share dated endpoints, including lone and empty panels", - "three separately mapped synthetic PBS drawers read vm-one, vm-two and agent-three, not unrelated host identities", - "same-time observations occupy the same horizontal coordinate across groups with different sample coverage", - "failed same-source refresh retains samples and window with a sanitised warning; successful refresh advances the window", - "held 1-hour range replacement clears former endpoints, paths and inspection controls; matching data then uses the new window", - "empty window has labelled bounds without a synthetic trend; seven-day bounds remain dated; locked fourteen-day range makes no read" + "Actual settings state/setup section and existing token-reveal dialog with production CSS; synthetic server artifacts and no installed bootstrap execution", + "Separate command and credential copy for API Inventory and Host Telemetry Agent; raw secrets absent from copied shell text", + "PVE/PBS tokenless script download reuses live bootstrap metadata; credential cache clears on close/reopen", + "Held setup and agent issuance completed after modal close is discarded: no secret cache and no reveal dialog", + "Default TLS verification and monitoring-only agent request (enableCommands=false) unchanged", + "Desktop light and phone-emulated WebKit dark token reveal is readable; screenshots visually inspected" ], "interactions": [ - "desktop native keyboard disclosure and History tab; Chromium phone row touch; WebKit phone native keyboard disclosure (whole-row touch opening not established)", - "focus native observation input, use Home/ArrowRight/End and compare dated values and same-time markers between utilization and network", - "Chromium/WebKit phone-emulated touch taps on the at-least-44px native History input; inspection performs no extra API reads", - "fail a real query refresh; retry with advancing bounds; hold and fulfil a 1-hour replacement read; refresh empty, choose seven days and a licence-locked range" + "Copy command; copy token via Enter and pointer; Escape and Dismiss close token dialog", + "Close/reopen setup; download script; switch API/Agent paths", + "Hold setup/agent response, close modal, then release response and verify no late reveal/cache" ], - "command": "pulse-worker-browser frontend-modern/browser-tests/history-window.cjs --engine=chromium --theme=light; --engine=chromium --theme=dark; both themes with --engine=chromium --phone; both themes with --engine=firefox; both themes with --engine=webkit --phone. Each is a separate bounded command from the assigned workspace root. Vite 6.4.3 / Playwright 1.56.1; integration-lock parity checked.", + "command": "pulse-worker-browser frontend-modern/browser-tests/proxmox-private-input.cjs; pulse-worker-browser frontend-modern/browser-tests/proxmox-private-input.cjs --phone (separate commands from assigned workspace root)", "browser_versions": { + "playwright": "1.56.1", "chromium": "141.0.7390.37", - "firefox": "142.0.1", "webkit": "26.0" }, "artifacts": [ - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/one-24h-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/one-hour-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/progress.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/retained-window-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/three-24h-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/two-24h-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/one-24h-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/one-hour-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/progress.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/retained-window-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/three-24h-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/two-24h-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/one-24h-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/one-hour-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/progress.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/retained-window-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/three-24h-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/two-24h-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/one-24h-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/one-hour-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/progress.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/retained-window-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/three-24h-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/two-24h-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/one-24h-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/one-hour-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/progress.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/retained-window-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/three-24h-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/two-24h-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/one-24h-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/one-hour-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/progress.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/retained-window-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/three-24h-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/two-24h-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/one-24h-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/one-hour-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/progress.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/retained-window-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/three-24h-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/two-24h-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/one-24h-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/one-hour-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/progress.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/retained-window-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/three-24h-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/two-24h-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-chromium-desktop-dark-final.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-chromium-desktop-light-final.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-chromium-phone-dark-final.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-chromium-phone-light-final.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-firefox-desktop-dark-final.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-firefox-desktop-light-final.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-webkit-phone-dark-native.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-webkit-phone-light-native.log" - ], - "notes": "All 40 final screenshots inspected. Eight engine/viewport/theme cases pass with no page errors or horizontal overflow and exact final runtime hashes. The ten-case exact-base source-fbz6bjig input fails all ten assertions; an inspected full PBS drawer baseline measures five minutes occupying 265/318 SVG plot units under 24 hours, versus about 1.10 units after repair. Initial navigation timeout, unknown browser-execution capability-unavailable exit, synthetic edge-point fixture mismatch and unsuccessful WebKit row/hidden-button touch attempts remain in internal receipts. WebKit final opening uses native keyboard disclosure, not a claimed touch repair; History touch inspection passes. Six Chromium/Firefox cases ran before the runner added the disclosure-state assertion and WebKit keyboard-opening branch; their runtime source hashes are identical to the final two WebKit cases. This establishes presentation, not real PBS/VirtualBox collection, all reported #1723 drawer/metric outcomes, screen-reader speech, installed Tailscale Serve/SSE update or signed upgrade/rollback, protected integration or release availability.", - "prior_verifications": [ - { - "version": 1, - "base_sha": "0dc858cb366a394b159d2c911c5d85b3020e7881", - "verified_at": "2026-10-01T00:37:51Z", - "result": "passed", - "changed_paths": ["frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"], - "content_sha256": { - "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "e1658b5843ab22e0ea83d1bf27e8c1565a16d9f528eb3832524a67318d281d6a" - }, - "routes": [ - "/browser-tests/pbs-history-refresh.html (production GuestDrawerHistory/query/CSS with synthetic host/service metrics API responses; not a complete PBS drawer or installed acceptance)" - ], - "viewports": [ - { - "width": 1365, - "height": 900 - }, - { - "width": 390, - "height": 844 - } - ], - "states": [ - "light/dark sparse CPU, memory and lone disk observations with one common inspected timestamp", - "a missing metric at the inspected time stays unavailable instead of borrowing nearby, latest or live data", - "zero-valued lone observations can be inspected without a synthetic trend", - "genuinely aligned series share cursor geometry and their own observed values", - "network directions preserve independently missing and zero rates", - "native keyboard/touch selection takes precedence over pointer movement", - "failed same-source refresh retains correctly attributed observations and a visible warning", - "a successful refresh updates the hovered timestamp values without choosing other latest metrics", - "held service-target replacement has no former-host observations, cursor or dated pointer description" - ], - "interactions": [ - "move a real pointer to sparse, aligned and single-sample timestamps; inspect visible legends, exact-time SVG descriptions and marker geometry", - "leave the plot to restore latest/current legends; verify pointer inspection makes no API reads", - "focus native range input and use Home/ArrowRight while moving the pointer elsewhere; blur to restore pointer inspection", - "Chromium/WebKit phone-emulated touch taps at each end of the native observation input, retaining its 44px minimum hit area", - "fail a real query refresh, inspect retained samples; successfully refresh the same source while hovering; switch resource with its GET held, then fulfil the service response" - ], - "command": "pulse-worker-browser frontend-modern/browser-tests/history-pointer.cjs --engine=chromium; the same with --phone; --engine=firefox; --engine=webkit --phone. All from the assigned workspace root, Vite 6.4.3 / Playwright 1.56.1 (integration-lock parity verified).", - "browser_versions": { - "chromium": "141.0.7390.37", - "firefox": "142.0.1", - "webkit": "26.0" - }, - "artifacts": [ - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/retained-sparse-pointer-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/retained-sparse-pointer-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/sparse-pointer-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/sparse-pointer-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/retained-sparse-pointer-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/retained-sparse-pointer-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/sparse-pointer-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/sparse-pointer-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/retained-sparse-pointer-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/retained-sparse-pointer-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/sparse-pointer-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/sparse-pointer-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/retained-sparse-pointer-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/retained-sparse-pointer-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/sparse-pointer-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/sparse-pointer-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/receipts/browser-chromium-desktop-final.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/receipts/browser-chromium-phone.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/receipts/browser-firefox-desktop.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/receipts/browser-webkit-phone-isolated.log" - ], - "notes": "All 16 final screenshots inspected. Eight engine/viewport/theme cases pass with no page errors or horizontal overflow. Exact-base source-5g7tw1c0 has eight failing assertions and two passing replacement controls; a separate Chromium base screenshot reproduces the misattribution. The first phone run captured correct hover text before an element screenshot scrolled the tall fixture and ended its hover; the final runner captures the viewport without changing pointer state. The first WebKit navigation timed out during overlapping browser runs; its cause is not established, and a subsequent isolated run completes both themes. Adverse attempts remain in internal output receipts. These synthetic API proofs establish shared-renderer presentation, not installed PBS collection, all three #1723 drawers, VirtualBox/disk/network/I/O collection, update or upgrade/rollback acceptance, screen-reader speech, containing-line delivery or release availability." - }, - { - "version": 1, - "base_sha": "0d3e5a801ab8fd1c1d224ef561cbf99e169c589f", - "verified_at": "2026-09-30T23:28:26Z", - "result": "passed", - "changed_paths": ["frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"], - "content_sha256": { - "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "327e75cabb99edffc712302c00645f06d92dc44dbebdd00aef08074e7fa53eb2" - }, - "routes": [ - "/browser-tests/pbs-history-refresh.html (direct production GuestDrawerHistory/query with production CSS and synthetic metrics API data; not a complete PBS drawer or installed acceptance)" - ], - "viewports": [ - { - "width": 1365, - "height": 900 - }, - { - "width": 390, - "height": 844 - } - ], - "states": [ - "light/dark ordered and sparse stored series", - "native focus with a 2px blue outline, measured contrast 3.68:1 light and 3.98:1 dark against the chart surface", - "missing series at a selected timestamp stay missing, not nearby or live values", - "same-source refresh adds samples without changing the selected timestamp or keyboard focus", - "failed refresh retains inspectable matching observations and honest warning", - "single stored observation has a dated description but no fabricated trend or pointless slider", - "empty store/current readings expose no inspection control", - "held replacement target has no former-host points or selection; new target starts at its own latest observation", - "licence-locked range has no reads or inspection action" - ], - "interactions": [ - "Tab from History range through Refresh to the native observation slider; Home, ArrowRight, End and ArrowLeft select actual stored times with no extra API reads", - "refresh through the existing real query while the input stays focused; new sample indexes do not move the selected timestamp", - "move and leave the plot while the slider is focused; then blur and confirm existing mouse hover still works", - "Chromium/WebKit phone touch taps at both ends of the 44px-high native control update the selected stored values", - "fail a refresh, inspect retained observations; replace with a single sample and then no history; switch resource while its GET is held; load the service response; select locked 14d" - ], - "command": "pulse-worker-browser frontend-modern/browser-tests/history-inspection.cjs --engine=chromium; the same command with --phone; --engine=firefox; --engine=webkit --phone. All from the assigned workspace root, Vite 6.4.3 / Playwright 1.56.1 (integration-lock parity verified).", - "browser_versions": { - "chromium": "141.0.7390.37", - "firefox": "142.0.1", - "webkit": "26.0" - }, - "artifacts": [ - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/selected-sparse-time-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/failed-refresh-inspection-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/selected-sparse-time-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/failed-refresh-inspection-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/selected-sparse-time-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/failed-refresh-inspection-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/selected-sparse-time-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/failed-refresh-inspection-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/selected-sparse-time-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/failed-refresh-inspection-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/selected-sparse-time-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/failed-refresh-inspection-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/selected-sparse-time-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/failed-refresh-inspection-light.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/selected-sparse-time-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/failed-refresh-inspection-dark.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/receipts/browser-chromium-desktop.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/receipts/browser-chromium-phone.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/receipts/browser-firefox-desktop.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/receipts/browser-webkit-phone.log" - ], - "notes": "All 16 final screenshots inspected. Eight engine/viewport/theme cases pass native keyboard, touch, state and layout assertions with no page errors. Semantic snapshots exercise native slider names/value text, not actual screen-reader speech quality. Initial dependency-copy/install failures, unsupported helper invocation, preliminary browser sequence exit 143 with unknown cause and the corrected dark-focus-colour defect remain in output receipts. No real PBS telemetry, full drawer, in-browser update, installed upgrade/rollback, containing-line or release availability claim. Copy-lint failure source-zhg72ioz at unshared 7c625696... is retained. Its semicolon-separated accessible value text is replaced by sentences; that unshared candidate was abandoned at the supplied base, not amended or integrated. All four browser commands and all 16 inspected screenshots now bind the corrected runtime. No failed earlier pass or shared history is rewritten." - }, - { - "version": 1, - "base_sha": "f3c92a242ebf9b7062dfc9f6ff711a56639f536c", - "verified_at": "2026-09-30T22:39:35Z", - "result": "passed", - "changed_paths": [ - "frontend-modern/src/components/UpdateProgressModal.tsx", - "frontend-modern/src/components/updateReadinessModel.ts" - ], - "content_sha256": { - "frontend-modern/src/components/UpdateProgressModal.tsx": "d51df7a98a379e313f306b68529c8db0687ff91176af23869411c4b46feb2599", - "frontend-modern/src/components/updateReadinessModel.ts": "b25bf82441fc6572b95891af48a1ed2a0e8ea1f4e583c171ea9352a1e67d76ce" - }, - "routes": [ - "/browser-tests/update-progress-modal.html (production UpdateProgressModal with a scripted update stream and scripted /api/updates/status and /api/version)" - ], - "viewports": [ - { - "width": 1365, - "height": 900 - }, - { - "width": 390, - "height": 844 - } - ], - "states": [ - "stream delivers one downloading 10% event then stays open and silent; fallback polling advances the modal through 35, 70, 80 and 90% (applying)", - "stream closed during download with a single unreachable status poll between two successful ones; download progress continues and the restart phase is never entered", - "restarting status from polling, then /api/version unreachable twice, then the new version reported; Pulse is restarting shown, then exactly one page reload", - "pre-update version unavailable (503), late polls failing and a new version plus idle answering without a restarting or completed status; modal stays on Pulse is restarting with no reload and no completion claim", - "stream delivers downloading, verifying, extracting, backing-up, applying and completed; old version answers once, becomes unreachable, then the new version; exactly one reload and no fallback polls" - ], - "interactions": [ - "emit scripted SSE statuses and hold the stream silent past the 6s silence window; assert the first fallback poll lands after it and the stream stays open", - "fail the stream and abort one status poll mid-download; assert no restart text ever rendered (MutationObserver) and the do-not-close warning remains", - "count document loads of the harness to assert a single reload after real restart evidence, and none without a baseline or backend confirmation", - "inspect screenshots at desktop and phone widths; assert no page errors, no horizontal overflow and the dialog inside the viewport" - ], - "command": "NODE_PATH= PULSE_BROWSER_ARTIFACTS= PULSE_CHROMIUM_PATH= node frontend-modern/browser-tests/update-progress-modal.cjs from the worktree root; Vite dev server on 127.0.0.1:5208, Playwright 1.56.1, Chromium headless shell 151.0.7922.34", - "artifacts": [ - "/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/harness-rebased-3.log", - "/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/a-polled-90pct-1365.png", - "/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/a-polled-90pct-390.png", - "/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/a-stream-10pct-1365.png", - "/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/a-stream-10pct-390.png", - "/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/b-after-failed-poll-80pct-1365.png", - "/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/b-after-failed-poll-80pct-390.png", - "/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/c-reloaded-1365.png", - "/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/c-reloaded-390.png", - "/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/c-restarting-1365.png", - "/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/c-restarting-390.png", - "/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/d-unconfirmed-waiting-1365.png", - "/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/d-unconfirmed-waiting-390.png", - "/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-reloaded-1365.png", - "/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-reloaded-390.png", - "/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-restarting-1365.png", - "/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-restarting-390.png", - "/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-stream-applying-1365.png", - "/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-stream-applying-390.png" - ], - "notes": [ - "Mock-backed UI proof of the production modal; all five scenarios passed at 1365x900 and 390x844 and the screenshots were inspected. The same harness run against the pre-fix UpdateProgressModal and updateReadinessModel (fa1d9eb36) fails scenario (a), staying on 10% (pre-fix/run.log). This does not exercise the backend SSE endpoint, a real proxy, or an installed update. Re-run after rebasing onto 8d5969fb3 (lockfile-only brace-expansion/DOMPurify floors); both full runs passed all five scenarios at both viewports with unchanged component content hashes.", - "Re-run after rebasing onto f3c92a242; all five scenarios passed at both viewports with unchanged component content hashes." - ] - }, - { - "version": 1, - "base_sha": "21a35494aecb335755de773dd099e1364e65b3e0", - "verified_at": "2026-09-30T20:12:09Z", - "result": "passed", - "changed_paths": ["frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"], - "content_sha256": { - "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "b2b31441b19c2e50283ceedf319cd09a1f94791750cd424c052717a4957fdc67" - }, - "routes": [ - "/browser-tests/pbs-identity-boundary.html (production PBS table and real resource History renderer; synthetic API data)" - ], - "viewports": [ - { - "width": 1365, - "height": 900 - }, - { - "width": 390, - "height": 844 - } - ], - "states": [ - "corroborated host with seven observed metric paths", - "uncached range replacement pending with no old paths", - "new range loaded before the old range response", - "withdrawn host link with replacement target pending", - "replacement target returns 503 with no former-host history", - "service-only response retains CPU/memory, not host disk/network/I/O", - "locked 14-day range issues no history read" - ], - "interactions": [ - "desktop keyboard Enter / phone row click to open PBS details", - "select 6h while its GET is held; select 1h, then fulfil the obsolete 6h request", - "withdraw third host link while service GET is held, then return 503", - "select 6h after failure and inspect the service-only response", - "select locked 14d; check all ten screenshots, retained History tab, zero horizontal overflow and no page errors" - ], - "command": "pulse-worker-browser frontend-modern/browser-tests/pbs-history-source-isolation.cjs from assigned workspace root; Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1", - "artifacts": [ - "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/receipts/history-browser-final.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/host-loaded-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/host-loaded-390.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/range-loading-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/range-loading-390.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/service-loaded-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/service-loaded-390.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/target-failed-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/target-failed-390.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/target-loading-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/target-loading-390.png" - ], - "notes": "Ten final screenshots inspected. The superseded GET is observed ERR_ABORTED at both widths; the unit regression also resolves mocks that ignore cancellation. Mock-backed proof establishes source/range isolation, not installed PBS collection, VirtualBox telemetry, all three #1723 drawers or release availability. Earlier unconfigured-CSS red/attempt-one results are retained separately." - }, - { - "version": 1, - "base_sha": "6ade01cbd4d02cd25b691f9ff5919206cceae764", - "verified_at": "2026-09-30T01:40:13Z", - "result": "passed", - "changed_paths": ["frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx"], - "content_sha256": { - "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "ed7207456ebbc990ada34b0c739e29565d90cfe1998415a781d87f3067d69c36" - }, - "routes": [ - "/browser-tests/pbs-identity-boundary.html (production PBS table, resource drawer and History with bounded synthetic resource props)" - ], - "viewports": [ - { - "width": 1365, - "height": 900 - }, - { - "width": 390, - "height": 844 - } - ], - "states": [ - "three unlinked PBS services alongside six label/IP-colliding Agents", - "explicit backend links for two PVE guests and a standalone host, with same-Agent guest/host deduplication", - "unchanged links with host rows omitted and restored", - "withdrawn third link with real and colliding host rows still present", - "fresh exact PBS nodeName to Agent hostname match with mismatched display labels" - ], - "interactions": [ - "desktop keyboard disclosure and phone row activation for all three PBS drawers", - "inspect Metrics Target, reject wrong-host disks, select History and 6h/1h ranges", - "join, omit, restore and withdraw links while keeping the drawer open; verify only service History remains after withdrawal", - "24 target/layout observations; 76 History GETs, zero unrelated targets, page errors or horizontal overflow" - ], - "command": "pulse-worker-browser frontend-modern/browser-tests/pbs-identity-boundary.cjs; Vite 6.4.3, Playwright 1.56.1, Chromium 141.0.7390.37", - "artifacts": [ - "/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-identity-boundary.cjs", - "/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/unlinked-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/linked-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/withdrawn-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/unlinked-390.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/linked-390.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/withdrawn-390.png" - ], - "notes": "All six screenshots inspected. Mock-backed UI proof, not installed topology, actual persisted host History or full #1723 relief. First browser failure retained separately; source snapshot isolation fixes that observed revocation failure." - }, - { - "version": 1, - "base_sha": "c3e8b6dd6dff36b5396ba2eb147d4e58d23001a6", - "verified_at": "2026-09-29T23:04:22Z", - "result": "passed", - "changed_paths": ["frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx"], - "content_sha256": { - "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "7172690587f8e642ee30492481abbe76ee79d277aad290c3b50310dd1c0b282b" - }, - "routes": [ - "/pbs-correlation-proof.html (production PBS backup-server table and resource drawer with scripted, source-scoped resources)" - ], - "viewports": [ - { - "width": 1365, - "height": 900 - }, - { - "width": 390, - "height": 844 - } - ], - "states": [ - "three PBS services with distinct corroborated Agent links, including two PVE guests and one standalone host", - "host rows omitted in a transient refresh while the backend link is unchanged", - "third backend link withdrawn while the Agent row remains omitted" - ], - "interactions": [ - "open all three PBS drawers and inspect their Metrics Target identity", - "omit host rows and confirm targets stay on the correlated hosts", - "withdraw the third link and confirm its drawer returns to agent:pbs-three, with no page errors or horizontal overflow" - ], - "command": "pulse-worker-browser pbs-browser-proof.cjs from assigned workspace root; Vite 6.4.3, Playwright 1.56.1, Chromium desktop/phone", - "artifacts": [ - "/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof-final.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof.cjs", - "/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof-artifacts/pbs-1365-linked.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof-artifacts/pbs-1365-withdrawn.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof-artifacts/pbs-390-linked.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof-artifacts/pbs-390-withdrawn.png" - ], - "notes": "Mock-backed proof of the real PBS table and drawer; all four screenshots inspected. It does not establish installed #1723 relief or observed Agent History series." - }, - { - "version": 1, - "base_sha": "91d8822d76c5a7214995c0e370be637b6e6336e3", - "verified_at": "2026-09-29T22:13:56Z", - "result": "passed", - "changed_paths": [ - "frontend-modern/src/components/shared/ContainerUpdateBadge.tsx", - "frontend-modern/src/components/shared/containerUpdateBadgeModel.ts", - "frontend-modern/src/components/shared/useContainerUpdateButtonState.ts", - "frontend-modern/src/stores/containerUpdates.ts" - ], - "content_sha256": { - "frontend-modern/src/components/shared/ContainerUpdateBadge.tsx": "fd88f3e3e275926572126f43cce7296e7361b5e527868dd3c59c7eb951e9f5c9", - "frontend-modern/src/components/shared/containerUpdateBadgeModel.ts": "10bc488cb82ae84c6ae46d77ec672d34df2a1b3c28e138ae0f3b4214cccbe37e", - "frontend-modern/src/components/shared/useContainerUpdateButtonState.ts": "4b9dd4a42549b0433a1afa56f26183fe721c80626f823fb3c8489934721267c5", - "frontend-modern/src/stores/containerUpdates.ts": "b5d47132f7daf7f14bcc38462043288fc211344410af622ada7728694bcd514d" - }, - "routes": [ - "/browser-tests/update-action-receipt.html (production Docker update button with scripted action reads)" - ], - "viewports": [ - { - "width": 1365, - "height": 900 - }, - { - "width": 390, - "height": 844 - } - ], - "states": [ - "governed update receipt pending after registry updateAvailable becomes false", - "existing action re-opened by GET with no second plan or execute", - "recorded completed action after a second GET changes the displayed outcome" - ], - "interactions": [ - "inspect pending button and absence of a Current or Completed claim", - "select Review action and inspect receipt-pending dialog", - "select Check for receipt and inspect recorded completion", - "assert both action requests are GET; no page errors or horizontal overflow" - ], - "command": "pulse-worker-browser frontend-modern/browser-tests/update-action-receipt.cjs from assigned workspace root; Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1", - "artifacts": [ - "/var/lib/pulse-maintainer/worker-outputs/web-product-ahrmjhme/update-action-receipt-browser-final.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-ahrmjhme/update-action-receipt-proof-final/pending-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-ahrmjhme/update-action-receipt-proof-final/review-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-ahrmjhme/update-action-receipt-proof-final/completed-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-ahrmjhme/update-action-receipt-proof-final/pending-390.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-ahrmjhme/update-action-receipt-proof-final/review-390.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-ahrmjhme/update-action-receipt-proof-final/completed-390.png" - ], - "notes": "Mock-backed UI proof; six screenshots inspected, including pending and completed phone states. This does not establish installed behaviour or resolve #1891." - }, - { - "version": 1, - "base_sha": "fbf17b271abe70ad3e12042506b52f85b6cbc484", - "verified_at": "2026-09-29T19:50:45Z", - "result": "passed", - "changed_paths": ["frontend-modern/src/pages/Actions.tsx"], - "content_sha256": { - "frontend-modern/src/pages/Actions.tsx": "30e862ecc5125f07cf93f0c1e0b5f58efa023f5fdb56fe4b01721bf9a453e7ce" - }, - "routes": [ - "/browser-tests/action-review-navigation.html (production Actions page with scripted action reads)" - ], - "viewports": [ - { - "width": 1365, - "height": 900 - }, - { - "width": 390, - "height": 844 - } - ], - "states": [ - "detail for edge-a held while edge-b is selected and rendered", - "late edge-a response cannot replace edge-b review or URL", - "pending receipt re-read finishes after edge-b review closes" - ], - "interactions": [ - "select edge-a then edge-b before the first detail response arrives", - "release older response and inspect current review at desktop and phone widths", - "start same-action receipt GET, close review, release response; no dialog reopens", - "assert all scripted API requests are GET and no horizontal overflow or page errors" - ], - "command": "pulse-worker-browser frontend-modern/browser-tests/action-review-navigation.cjs from assigned workspace root; Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1", - "artifacts": [ - "/var/lib/pulse-maintainer/worker-outputs/web-product-rkmwqerp/action-review-navigation-browser.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-rkmwqerp/action-review-navigation-proof/newest-review-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-rkmwqerp/action-review-navigation-proof/newest-review-390.png" - ], - "notes": "Mock-backed navigation proof of production Actions page; screenshots inspected. No update was executed, no installed or reporter result established." - }, - { - "version": 1, - "base_sha": "c2f7aa8b471d6cbb858e8ec71b4e664fded6549c", - "verified_at": "2026-09-29T19:09:52Z", - "result": "passed", - "changed_paths": ["frontend-modern/src/features/actions/ActionReviewDialog.tsx"], - "content_sha256": { - "frontend-modern/src/features/actions/ActionReviewDialog.tsx": "9a4af522484b42e37b118e8bfde7c1673122037a54b8bfed249818ba644e6b81" - }, - "routes": [ - "/browser-tests/action-receipt-wait.html (mock-backed production ActionReviewDialog)" - ], - "viewports": [ - { - "width": 1365, - "height": 900 - }, - { - "width": 390, - "height": 844 - } - ], - "states": [ - "fresh executing/receipt_pending, including after a same-action re-read", - "failed status re-read with unknown outcome preserved", - "completed action after a recorded receipt" - ], - "interactions": [ - "desktop pointer and phone keyboard Check for receipt", - "GET same action under pending, read error and completion; no POST", - "desktop and phone layout and horizontal-overflow checks" - ], - "command": "pulse-worker-browser frontend-modern/browser-tests/action-receipt-wait.cjs from assigned workspace root; Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1", - "artifacts": [ - "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-browser.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/pending-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/read-error-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/completed-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/pending-390.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/read-error-390.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/completed-390.png" - ], - "notes": "Production dialog mounted with synthetic recent action data and mocked same-action GET. Both desktop and phone showed explicit receipt-pending/unknown outcome guidance, a non-mutating status refresh, a safe read error, and recorded completion; screenshots inspected. This is not an installed action or reporter retest." - }, - { - "version": 1, - "base_sha": "498e2b12a22510520f9df7a510105d6775ea0522", - "verified_at": "2026-09-29T16:42:43Z", - "result": "passed", - "changed_paths": [ - "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx", - "frontend-modern/src/types/resource.ts" - ], - "content_sha256": { - "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "fc3c71f792c91abe62475be2a341fbedc729dd079b31e2245b5769f969fa6b12", - "frontend-modern/src/types/resource.ts": "4e3b64a3799aad0d357b9d8898dd2daf9a557e05c529df3650d2f6d03eb8ca56" - }, - "routes": [ - "/browser-tests/pbs-mixed-source-1723.html (mock-backed current ProxmoxBackupServersTable)" - ], - "viewports": [ - { - "width": 1365, - "height": 900 - }, - { - "width": 390, - "height": 844 - } - ], - "states": [ - "PBS API + corroborated Agent with token-auth nodeName absent", - "same-host PVE API joined alongside a PVE-only name collision", - "correlated host row temporarily omitted", - "correlated host row restored" - ], - "interactions": [ - "desktop disclosure-button keyboard focus and Enter; phone row click", - "open History and select 24h, 6h, 1h and 12h across topology changes", - "join PVE API, omit host row, restore host row" - ], - "command": "pulse-worker-browser frontend-modern/node_modules/proof-pbs-1723.cjs from assigned workspace root; Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1", - "artifacts": [ - "/var/lib/pulse-maintainer/worker-outputs/core-runtime-8wlxxg93/proof-pbs-1723.cjs", - "/var/lib/pulse-maintainer/worker-outputs/core-runtime-8wlxxg93/browser-run.log", - "/var/lib/pulse-maintainer/worker-outputs/core-runtime-8wlxxg93/browser-proof-1723/result.json", - "/var/lib/pulse-maintainer/worker-outputs/core-runtime-8wlxxg93/browser-proof-1723/joined-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/core-runtime-8wlxxg93/browser-proof-1723/joined-390.png" - ], - "notes": "Fresh offline browser run on supplied main 498e2b12a2 plus unchanged #1723 runtime repair. At desktop and phone widths, History stayed selected with eight plotted paths through initial PBS + Agent, same-host PVE join, transient host omission and restoration. All eight captured history requests targeted agent:agent-uuid, never the PBS service key; joined screenshots were visually inspected. Mock-backed proof is not installed, second-topology or reporter acceptance.", - "preserved_candidate_source": "25d6ab2e7a23adf3c74e0c071dfd40e799d2d83d" - }, - { - "version": 1, - "base_sha": "fcbb7e58ad514ac146f666f49075039d40d829e2", - "verified_at": "2026-09-29T12:15:52Z", - "result": "passed", - "changed_paths": [ - "frontend-modern/src/components/Settings/RelaySettingsPanel.tsx", - "frontend-modern/src/components/Settings/settingsHeaderMeta.ts", - "frontend-modern/src/components/Settings/settingsNavCatalog.ts", - "frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts", - "frontend-modern/src/features/alerts/AlertPushDestinationsSection.tsx", - "frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx", - "frontend-modern/src/i18n/messages.de.ts", - "frontend-modern/src/i18n/messages.es.ts", - "frontend-modern/src/i18n/messages.ts", - "frontend-modern/src/utils/alertDestinationsPresentation.ts", - "frontend-modern/src/utils/licensePresentation.ts", - "frontend-modern/src/utils/relayPresentation.ts", - "frontend-modern/src/utils/selfHostedFeatureCatalog.generated.ts", - "frontend-modern/src/utils/selfHostedPlans.ts" - ], - "content_sha256": { - "frontend-modern/src/components/Settings/RelaySettingsPanel.tsx": "1def33d23464ed3e0a6127e75f16e7da814c9ca7d8fc6a680532fbef05e67755", - "frontend-modern/src/components/Settings/settingsHeaderMeta.ts": "4847df256a489833c5836e0babcd651ea85d640a204288979f965bda4ff7432b", - "frontend-modern/src/components/Settings/settingsNavCatalog.ts": "d4832713b84d88033fa15211342b6ff8a243e20c56732c5431aed8e10b22e9c2", - "frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts": "56525ac9bd0f3634adea1cccbc53c557caed8faad223f48d788c074ab2fa8a86", - "frontend-modern/src/features/alerts/AlertPushDestinationsSection.tsx": "3a160748f1bd0f42ba412757ca919803a440ad8afb0a946d0d5807188c4bc5a0", - "frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx": "1e07af69a4f4268601f8227500e79eb6a827c49f7b0d62b283129ff5fa2935f9", - "frontend-modern/src/i18n/messages.de.ts": "2daa6b5a6bb2a59bf7846fa2f93310a48065fe7bc3082777cbb51c2fd5f107e2", - "frontend-modern/src/i18n/messages.es.ts": "7052ac4596ef1d36b46935f1bf451f552aee87abe45e23840b3e9d570b215205", - "frontend-modern/src/i18n/messages.ts": "42c6798b32c5647c89a9f658b40e605773d85e5478a4b889bc0469ef3b2efe8e", - "frontend-modern/src/utils/alertDestinationsPresentation.ts": "82980cfc35fab1e91c5484b75e1bc3b84f6c2234e26ed5306b4583e89da3f880", - "frontend-modern/src/utils/licensePresentation.ts": "3573f465207e64e2984c5f18dc89f644b6a88de6e124779840714b01f572e3d6", - "frontend-modern/src/utils/relayPresentation.ts": "9a0d1000d91e307d31b7176be7d4529a150f7364fd913a4b379e2c96a3aa9e1c", - "frontend-modern/src/utils/selfHostedFeatureCatalog.generated.ts": "64baf62ae41b9832381ac2a91f7c9724131e765f2493cb894efabeed2ae16d0e", - "frontend-modern/src/utils/selfHostedPlans.ts": "5efab41402fa3409f307398d5feb3b8ccdbf16ac01d6ce0cdacc3024c640b970" - }, - "routes": [ - "/settings/system-relay", - "/alerts/notifications", - "/settings/pulse-intelligence/billing/plan", - "/settings/system-general" - ], - "viewports": [ - { - "width": 1024, - "height": 768 - }, - { - "width": 390, - "height": 844 - } - ], - "states": [ - "Licensed instance (MSP test license from the offline issuer, relay capability present): Settings > Pulse Mobile shows 'Pair Pulse Mobile' with the 31 March 2027 retirement copy, the 'Allow Pulse Mobile connections' toggle, no Remote Access wording and no upgrade prompt", - "Licensed instance: Alerts > Notifications shows the Mobile push notifications panel with retirement copy, minimum-severity control and the 'Open Pulse Mobile settings' link", - "Licensed instance: Plans & Billing lists the relay feature as 'Pulse Relay (Mobile Connection)' with no Remote Access or remote web access wording", - "Community instance (no license): Alerts > Notifications has no Mobile push panel and no Relay upsell while webhook, email and Apprise destinations remain", - "Community instance: settings navigation has no Pulse Mobile or Remote Access item; the direct /settings/system-relay route shows the retirement message with no upgrade link", - "Community instance: Plans & Billing shows the Community plan with no Relay plan card or remote web access copy" - ], - "interactions": [ - "Activated the offline issuer test license through /api/license/activate on the licensed stack and confirmed relay in /api/license/entitlements", - "Loaded /settings/system-relay and /alerts/notifications on the licensed stack at 1024px and 390px, checked the toggle and settings link stay inside the viewport with no horizontal overflow", - "Loaded /alerts/notifications, /settings/system-general, /settings/system-relay and /settings/system/billing/plan on the Community stack and checked nav items, panels and links" - ] - }, - { - "version": 1, - "base_sha": "781cbfa98e04d7f83f0acb0bd45996a7a0de3bf3", - "verified_at": "2026-09-30T22:01:47Z", - "result": "passed", - "changed_paths": [ - "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx", - "frontend-modern/src/hooks/createNonSuspendingQuery.ts" - ], - "content_sha256": { - "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "102bfe19203a3a333397c3a33f408ff40e43331c0b39bc399e548fcb28814903", - "frontend-modern/src/hooks/createNonSuspendingQuery.ts": "64401ad5317013604145f9a1d3ccff74342e3810ea8b2288d86f940a9ba0de79" - }, - "routes": [ - "/browser-tests/pbs-history-refresh.html (direct production GuestDrawerHistory and shared query; synthetic host/service API responses, not the complete PBS table/drawer)" - ], - "viewports": [ - { - "width": 1365, - "height": 900 - }, - { - "width": 390, - "height": 844 - } - ], - "states": [ - "light/dark current host history", - "real 30-second poll fails while matching points remain explicitly labelled", - "keyboard retry held with busy/activation guard and retained points", - "successful retry keeps keyboard focus and updates the same control", - "real background polling supersedes a slow foreground read and settles loading", - "late aborted response cannot replace current paths", - "uncached service-target failure has neither former-host points nor a collecting claim", - "service-target retry renders CPU/memory only", - "locked range exposes no read or refresh action" - ], - "interactions": [ - "Enter retry twice while its GET is held; exactly one retry read starts", - "fulfil retry; inspect warning removal, changed paths and retained focus", - "hold manual refresh across the real polling interval; inspect loading settlement and aborted read", - "switch to service target, fail its GET, retry the same key, select locked 14d" - ], - "command": "pulse-worker-browser frontend-modern/browser-tests/pbs-history-refresh.cjs --phone; then the same command without --phone, from assigned workspace root. Native polling timers, Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1 (integration-lock parity verified).", - "artifacts": [ - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/receipts/renderer-browser-phone.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/receipts/renderer-browser-desktop.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/receipts/browser-phone-result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/receipts/browser-desktop-result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/refresh-failed-dark-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/refresh-failed-dark-390.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/refresh-failed-light-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/refresh-failed-light-390.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/replacement-failed-dark-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/replacement-failed-dark-390.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/replacement-failed-light-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/replacement-failed-light-390.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/retry-recovered-dark-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/retry-recovered-dark-390.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/retry-recovered-light-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/retry-recovered-light-390.png" - ], - "notes": "All 12 final screenshots inspected. These are direct production-renderer and real query proofs, not containing-build #1723 acceptance, screen-reader announcement quality, or a complete PBS drawer/browser update journey. Earlier broader fixture/copy/cache/timeout failures are retained in the outcome receipts; no broad pass is claimed. A diagnostic stage label was clarified after desktop launch; no tested runtime content or assertions changed." - } + "/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-desktop/pbs-agent.png", + "/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-desktop/pbs-setup.png", + "/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-desktop/pve-agent.png", + "/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-desktop/pve-setup.png", + "/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-desktop/result.json", + "/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-phone/pbs-agent.png", + "/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-phone/pbs-setup.png", + "/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-phone/pve-agent.png", + "/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-phone/pve-setup.png", + "/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-phone/result.json" ] } diff --git a/frontend-modern/public/docs/API.md b/frontend-modern/public/docs/API.md index 46d3d3cbe..928b6ad58 100644 --- a/frontend-modern/public/docs/API.md +++ b/frontend-modern/public/docs/API.md @@ -525,9 +525,11 @@ Request body: ### Setup Script (Public) `GET /api/setup-script` Returns the Proxmox/PBS setup script as a shell-script download. Accepts an -optional temporary setup token in the `setup_token` query for embedded -non-interactive bootstrap; otherwise the script prompts for the one-time setup -token at runtime. Canonical callers must send a supported `type` of `pve` or +optional legacy `setup_token` query for compatibility. Current downloads +contain no token: paste the separately revealed token only at the silent +terminal prompt, or supply `PULSE_SETUP_TOKEN_FILE` pointing to a mode-0600 +regular file in a mode-0700 directory owned by the script's user. Never put a +token in a copied command or URL. Canonical callers must send a supported `type` of `pve` or `pbs` plus non-empty `host` and `pulse_url`; the route no longer generates placeholder-host scripts for later repair or reconstructs Pulse identity from the request origin. The route now shares the same canonical type boundary as @@ -557,20 +559,23 @@ authorize the request itself. Pulse-managed Proxmox monitor-token names on the setup/bootstrap path derive from the canonical Pulse endpoint, not request-local host fallbacks, so setup-script and turnkey node-add flows stay on one deterministic `pulse-` identity per Pulse instance. -`setupToken` remains bootstrap transport data for `/api/setup-script` and -`/api/auto-register`, while `tokenHint` is the operator-facing display field -for quick-setup surfaces and must stay masked instead of exposing the full -one-time token in UI copy. Shared frontend consumers may validate -`setupToken`, but they should not retain or display it once the returned -bootstrap artifact and `tokenHint` are available; visible quick-setup previews -should use the non-secret `commandWithoutEnv` form while copy actions keep -using the token-bearing `commandWithEnv` artifact, and manual download flows -should use the token-bearing `downloadURL` artifact instead of rebuilding a -plain setup-script URL from non-secret preview state. Non-frontend bootstrap -consumers such as the runtime-side Unified Agent bootstrap flow and shell installer must fail closed on that -same full artifact contract too, rejecting missing or mismatched -`downloadURL`, `tokenHint`, or expired `expires` values instead of accepting a -reduced setup-token-only response shape. +The `command`, `commandWithEnv`, and `commandWithoutEnv` fields now contain +identical credential-free commands. They download the complete script before +running it and prompt silently in the root-or-sudo process. The token crosses +the installer boundary through a private file, not process arguments or an +exported secret. `downloadURL` equals the tokenless `url`, so a manual download +also needs the separately revealed token at runtime. `setupToken` is used for +`/api/auto-register`; `tokenHint` remains masked on the setup page. Settings +reveals the token in a separate dialog: run the command first, then copy and +paste the token only at its prompt. The artifact is reused only for the same +host and options while its five-minute expiry is live, and discarded when the +setup modal closes. +Non-frontend consumers must validate the complete artifact, including the +canonical host, type, URLs, filename, masked hint and live expiry. Current +Unified Agents and the shell installer also accept the coherent older-server +artifact during upgrades, but never execute its command text. For new +Proxmox agent enrolment against a newer server, use its current installer; +already enrolled agents keep reporting normally. ### Auto-Register (Public) `POST /api/auto-register` diff --git a/frontend-modern/src/api/__tests__/nodes.branchcov0724pm.test.ts b/frontend-modern/src/api/__tests__/nodes.branchcov0724pm.test.ts index 7e9e3fda5..21200d6a7 100644 --- a/frontend-modern/src/api/__tests__/nodes.branchcov0724pm.test.ts +++ b/frontend-modern/src/api/__tests__/nodes.branchcov0724pm.test.ts @@ -93,7 +93,7 @@ describe('NodesAPI — branch coverage (normalizeProxmoxSetupCommandResponse val type: 'pve', host: 'https://pve.example:8006', url: 'https://pulse.example/api/setup-script?type=pve', - downloadURL: 'https://pulse.example/api/setup-script?type=pve&setup_token=setup-token-123', + downloadURL: 'https://pulse.example/api/setup-script?type=pve', scriptFileName: 'pulse-setup-pve.sh', command: 'curl pve ...', commandWithEnv: 'curl env pve ...', @@ -152,7 +152,7 @@ describe('NodesAPI — branch coverage (downloadProxmoxSetupScript failure arms) type: 'pve', host: 'https://pve.example:8006', url: 'https://pulse.example/base/api/setup-script?type=pve', - downloadURL: 'https://pulse.example/base/api/setup-script?type=pve&setup_token=setup-token-123', + downloadURL: 'https://pulse.example/base/api/setup-script?type=pve', scriptFileName: 'pulse-setup-pve.sh', command: 'curl pve ...', commandWithEnv: 'curl env pve ...', diff --git a/frontend-modern/src/api/__tests__/nodes.test.ts b/frontend-modern/src/api/__tests__/nodes.test.ts index e11854449..5a4602e19 100644 --- a/frontend-modern/src/api/__tests__/nodes.test.ts +++ b/frontend-modern/src/api/__tests__/nodes.test.ts @@ -313,8 +313,7 @@ describe('NodesAPI', () => { }), }), ); - expect(result).toEqual({ command: 'curl ...' }); - expect(result).not.toHaveProperty('token'); + expect(result).toEqual({ command: 'curl ...', token: 'secret-token' }); }); it('supports the PBS proxmox install command contract', async () => { @@ -332,6 +331,16 @@ describe('NodesAPI', () => { expect(result.command).toBe('curl pbs ...'); }); + it('rejects an agent credential embedded in the copied command', async () => { + vi.mocked(apiFetchJSON).mockResolvedValueOnce({ + command: 'bash install.sh --token synthetic-secret', + token: 'synthetic-secret', + }); + await expect( + NodesAPI.getAgentInstallCommand({ type: 'pve', enableProxmox: true }), + ).rejects.toThrow('Install credentials must be entered separately from the command'); + }); + it('rejects blank install commands', async () => { vi.mocked(apiFetchJSON).mockResolvedValueOnce({ command: ' ', token: 'secret-token' }); @@ -342,12 +351,12 @@ describe('NodesAPI', () => { }); describe('getProxmoxSetupCommand', () => { - it('uses the canonical setup-script-url contract for PVE while keeping raw setup tokens inside the shared client boundary', async () => { + it('uses the canonical PVE setup contract with a separate revealed setup token', async () => { vi.mocked(apiFetchJSON).mockResolvedValueOnce({ type: 'pve', host: 'https://pve.example:8006', url: 'https://pulse.example/api/setup-script?type=pve', - downloadURL: 'https://pulse.example/api/setup-script?type=pve&setup_token=setup-token-123', + downloadURL: 'https://pulse.example/api/setup-script?type=pve', scriptFileName: 'pulse-setup-pve.sh', command: 'curl pve ...', setupToken: 'setup-token-123', @@ -374,21 +383,41 @@ describe('NodesAPI', () => { ); expect(result.type).toBe('pve'); expect(result.host).toBe('https://pve.example:8006'); - expect(result.downloadURL).toBe( - 'https://pulse.example/api/setup-script?type=pve&setup_token=setup-token-123', - ); + expect(result.downloadURL).toBe('https://pulse.example/api/setup-script?type=pve'); expect(result.scriptFileName).toBe('pulse-setup-pve.sh'); expect(result.tokenHint).toBe('set…123'); expect(result.expires).toBe(1_900_000_000); - expect(result).not.toHaveProperty('setupToken'); + expect(result.setupToken).toMatch(/token-123$/); }); + it.each(['url', 'downloadURL', 'command', 'commandWithEnv', 'commandWithoutEnv'])( + 'rejects a setup credential embedded in %s without returning it in the error', + async (field) => { + const secret = 'synthetic-setup-secret'; + vi.mocked(apiFetchJSON).mockResolvedValueOnce({ + type: 'pve', + host: 'https://pve.example:8006', + url: 'https://pulse.example/api/setup-script?type=pve', + downloadURL: 'https://pulse.example/api/setup-script?type=pve', + scriptFileName: 'pulse-setup-pve.sh', + command: 'safe command', + setupToken: secret, + tokenHint: 'syn…ret', + expires: 1_900_000_000, + [field]: `unsafe-${secret}`, + }); + await expect( + NodesAPI.getProxmoxSetupCommand({ type: 'pve', host: 'pve.example' }), + ).rejects.toThrow('Setup credentials must be entered separately from commands and URLs'); + }, + ); + it('sends the typed connection name so the setup token can carry it into auto-registration', async () => { vi.mocked(apiFetchJSON).mockResolvedValueOnce({ type: 'pve', host: 'https://pve.example:8006', url: 'https://pulse.example/api/setup-script?type=pve', - downloadURL: 'https://pulse.example/api/setup-script?type=pve&setup_token=setup-token-123', + downloadURL: 'https://pulse.example/api/setup-script?type=pve', scriptFileName: 'pulse-setup-pve.sh', command: 'curl pve ...', setupToken: 'setup-token-123', @@ -422,8 +451,7 @@ describe('NodesAPI', () => { type: ' pve ', host: ' https://pve.example:8006 ', url: ' https://pulse.example/api/setup-script?type=pve ', - downloadURL: - ' https://pulse.example/api/setup-script?type=pve&setup_token=setup-token-123 ', + downloadURL: ' https://pulse.example/api/setup-script?type=pve ', scriptFileName: ' pulse-setup-pve.sh ', command: ' curl pve ... ', commandWithEnv: ' curl env pve ... ', @@ -443,13 +471,14 @@ describe('NodesAPI', () => { type: 'pve', host: 'https://pve.example:8006', url: 'https://pulse.example/api/setup-script?type=pve', - downloadURL: 'https://pulse.example/api/setup-script?type=pve&setup_token=setup-token-123', + downloadURL: 'https://pulse.example/api/setup-script?type=pve', scriptFileName: 'pulse-setup-pve.sh', command: 'curl pve ...', commandWithEnv: 'curl env pve ...', commandWithoutEnv: 'curl bare pve ...', expires: 1_900_000_000, tokenHint: 'set…123', + setupToken: 'setup-token-123', }); }); @@ -458,7 +487,7 @@ describe('NodesAPI', () => { type: 'pbs', host: 'https://pbs.example:8007', url: 'https://pulse.example/api/setup-script?type=pbs', - downloadURL: 'https://pulse.example/api/setup-script?type=pbs&setup_token=pbs-token-123', + downloadURL: 'https://pulse.example/api/setup-script?type=pbs', scriptFileName: 'pulse-setup-pbs.sh', command: 'curl pbs ...', setupToken: 'pbs-token-123', @@ -486,12 +515,10 @@ describe('NodesAPI', () => { expect(result.command).toBe('curl pbs ...'); expect(result.type).toBe('pbs'); expect(result.host).toBe('https://pbs.example:8007'); - expect(result.downloadURL).toBe( - 'https://pulse.example/api/setup-script?type=pbs&setup_token=pbs-token-123', - ); + expect(result.downloadURL).toBe('https://pulse.example/api/setup-script?type=pbs'); expect(result.scriptFileName).toBe('pulse-setup-pbs.sh'); expect(result.tokenHint).toBe('pbs…123'); - expect(result).not.toHaveProperty('setupToken'); + expect(result.setupToken).toMatch(/token-123$/); }); it('falls back to command for commandWithEnv when the backend omits it', async () => { @@ -499,7 +526,7 @@ describe('NodesAPI', () => { type: 'pbs', host: 'https://pbs.example:8007', url: 'https://pulse.example/api/setup-script?type=pbs', - downloadURL: 'https://pulse.example/api/setup-script?type=pbs&setup_token=pbs-token-123', + downloadURL: 'https://pulse.example/api/setup-script?type=pbs', scriptFileName: 'pulse-setup-pbs.sh', command: 'curl pbs ...', setupToken: 'pbs-token-123', @@ -521,7 +548,7 @@ describe('NodesAPI', () => { type: 'pbs', host: 'https://pbs.example:8007', url: '', - downloadURL: 'https://pulse.example/api/setup-script?type=pbs&setup_token=pbs-token-123', + downloadURL: 'https://pulse.example/api/setup-script?type=pbs', scriptFileName: 'pulse-setup-pbs.sh', command: 'curl pbs ...', setupToken: 'pbs-token-123', @@ -543,7 +570,7 @@ describe('NodesAPI', () => { type: 'pve', host: 'https://pve.example:8006', url: 'https://pulse.example/api/setup-script?type=pve', - downloadURL: 'https://pulse.example/api/setup-script?type=pve&setup_token=setup-token-123', + downloadURL: 'https://pulse.example/api/setup-script?type=pve', scriptFileName: 'pulse-setup-pve.sh', command: 'curl pve ...', setupToken: 'setup-token-123', @@ -565,7 +592,7 @@ describe('NodesAPI', () => { type: 'pve', host: 'https://pve.example:8006', url: 'https://pulse.example/api/setup-script?type=pve', - downloadURL: 'https://pulse.example/api/setup-script?type=pve&setup_token=setup-token-123', + downloadURL: 'https://pulse.example/api/setup-script?type=pve', command: 'curl pve ...', setupToken: 'setup-token-123', tokenHint: 'set…123', @@ -586,7 +613,7 @@ describe('NodesAPI', () => { type: 'pve', host: 'https://pve.example:8006', url: 'https://pulse.example/api/setup-script?type=pve', - downloadURL: 'https://pulse.example/api/setup-script?type=pve&setup_token=setup-token-123', + downloadURL: 'https://pulse.example/api/setup-script?type=pve', scriptFileName: 'pulse-setup-pve.sh', command: 'curl pve ...', setupToken: 'setup-token-123', @@ -616,11 +643,11 @@ describe('NodesAPI', () => { ); const result = await NodesAPI.downloadProxmoxSetupScript({ + setupToken: 'synthetic-setup-token', type: 'pve', host: 'https://pve.example:8006', url: 'https://pulse.example/base/api/setup-script?type=pve', - downloadURL: - 'https://pulse.example/base/api/setup-script?type=pve&setup_token=setup-token-123', + downloadURL: 'https://pulse.example/base/api/setup-script?type=pve', scriptFileName: 'pulse-setup-pve.sh', command: 'curl pve ...', commandWithEnv: 'curl env pve ...', @@ -629,9 +656,7 @@ describe('NodesAPI', () => { tokenHint: 'set…123', }); - expect(apiFetch).toHaveBeenCalledWith( - 'https://pulse.example/base/api/setup-script?type=pve&setup_token=setup-token-123', - ); + expect(apiFetch).toHaveBeenCalledWith('https://pulse.example/base/api/setup-script?type=pve'); expect(result).toEqual({ content: '#!/bin/bash\necho pve', contentType: 'text/x-shellscript; charset=utf-8', @@ -651,11 +676,11 @@ describe('NodesAPI', () => { ); const result = await NodesAPI.downloadProxmoxSetupScript({ + setupToken: 'synthetic-setup-token', type: 'pbs', host: 'pbs.example', url: 'https://pulse.example/base/api/setup-script?type=pbs', - downloadURL: - 'https://pulse.example/base/api/setup-script?type=pbs&setup_token=pbs-token-123', + downloadURL: 'https://pulse.example/base/api/setup-script?type=pbs', scriptFileName: 'pulse-setup-pbs.sh', command: 'curl pbs ...', commandWithEnv: 'curl env pbs ...', @@ -664,9 +689,7 @@ describe('NodesAPI', () => { tokenHint: 'pbs…123', }); - expect(apiFetch).toHaveBeenCalledWith( - 'https://pulse.example/base/api/setup-script?type=pbs&setup_token=pbs-token-123', - ); + expect(apiFetch).toHaveBeenCalledWith('https://pulse.example/base/api/setup-script?type=pbs'); expect(result).toEqual({ content: '#!/bin/bash\necho pbs', contentType: 'text/x-shellscript; charset=utf-8', @@ -687,11 +710,11 @@ describe('NodesAPI', () => { await expect( NodesAPI.downloadProxmoxSetupScript({ + setupToken: 'synthetic-setup-token', type: 'pve', host: 'https://pve.example:8006', url: 'https://pulse.example/base/api/setup-script?type=pve', - downloadURL: - 'https://pulse.example/base/api/setup-script?type=pve&setup_token=setup-token-123', + downloadURL: 'https://pulse.example/base/api/setup-script?type=pve', scriptFileName: 'pulse-setup-pve.sh', command: 'curl pve ...', commandWithEnv: 'curl env pve ...', @@ -712,11 +735,11 @@ describe('NodesAPI', () => { await expect( NodesAPI.downloadProxmoxSetupScript({ + setupToken: 'synthetic-setup-token', type: 'pve', host: 'https://pve.example:8006', url: 'https://pulse.example/base/api/setup-script?type=pve', - downloadURL: - 'https://pulse.example/base/api/setup-script?type=pve&setup_token=setup-token-123', + downloadURL: 'https://pulse.example/base/api/setup-script?type=pve', scriptFileName: 'pulse-setup-pve.sh', command: 'curl pve ...', commandWithEnv: 'curl env pve ...', @@ -740,11 +763,11 @@ describe('NodesAPI', () => { await expect( NodesAPI.downloadProxmoxSetupScript({ + setupToken: 'synthetic-setup-token', type: 'pve', host: 'https://pve.example:8006', url: 'https://pulse.example/base/api/setup-script?type=pve', - downloadURL: - 'https://pulse.example/base/api/setup-script?type=pve&setup_token=setup-token-123', + downloadURL: 'https://pulse.example/base/api/setup-script?type=pve', scriptFileName: 'pulse-setup-pve.sh', command: 'curl pve ...', commandWithEnv: 'curl env pve ...', diff --git a/frontend-modern/src/api/nodes.ts b/frontend-modern/src/api/nodes.ts index ff19cd74b..62e0b3734 100644 --- a/frontend-modern/src/api/nodes.ts +++ b/frontend-modern/src/api/nodes.ts @@ -59,6 +59,7 @@ type RawAgentInstallCommandResponse = { export type AgentInstallCommandResponse = { command: string; + token?: string; }; type RawHostAgentInstallTokenResponse = { @@ -96,6 +97,7 @@ export type ProxmoxSetupCommandResponse = { commandWithoutEnv?: string; expires: number; tokenHint: string; + setupToken: string; }; export type DownloadedProxmoxSetupScript = { @@ -112,7 +114,11 @@ const normalizeAgentInstallCommandResponse = ( throw new Error('Invalid agent install command response'); } - return { command }; + const token = optionalTrimmedString(response.token); + if (token && command.includes(token)) { + throw new Error('Install credentials must be entered separately from the command'); + } + return { command, ...(token ? { token } : {}) }; }; const normalizeProxmoxSetupCommandResponse = ( @@ -156,6 +162,14 @@ const normalizeProxmoxSetupCommandResponse = ( if (!setupToken) { throw new Error('Invalid Proxmox setup response setup token'); } + if ( + [url, downloadURL, command, commandWithEnv, commandWithoutEnv ?? ''].some((value) => + value.includes(setupToken), + ) || + /[?&]setup_token=/.test(downloadURL) + ) { + throw new Error('Setup credentials must be entered separately from commands and URLs'); + } if (!tokenHint) { throw new Error('Invalid Proxmox setup response token hint'); } @@ -174,6 +188,7 @@ const normalizeProxmoxSetupCommandResponse = ( commandWithoutEnv, expires, tokenHint, + setupToken, }; }; diff --git a/frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx b/frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx index 1bdf60fd1..19ff8161d 100644 --- a/frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx +++ b/frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx @@ -241,8 +241,8 @@ export const NodeModalSetupGuideSection: Component

- No token fields are needed here. The node appears in Pulse automatically after - the agent starts. + Run the command first, then paste the separately revealed token at its silent + prompt. The node appears in Pulse after the agent starts.

@@ -286,7 +286,7 @@ export const NodeModalSetupGuideSection: Component
-

Credentialed command ready

+

Credential-free command ready

Use Copy command to place the runnable command on your clipboard. The - one-time setup token is intentionally not shown on this page. + one-time setup token is shown separately. Run the command first, then + paste the token only at its silent prompt, never into a shell command.

@@ -424,7 +425,7 @@ export const NodeModalSetupGuideSection: Component

- Fully automatic: no manual token copying needed. + The monitoring API token is registered automatically.

@@ -728,8 +729,8 @@ export const NodeModalSetupGuideSection: Component

- No token fields are needed here. The server appears in Pulse automatically after - the agent connects. + Run the command first, then paste the separately revealed token at its silent + prompt. The server appears in Pulse after the agent connects.

@@ -757,7 +758,7 @@ export const NodeModalSetupGuideSection: Component
-

Credentialed command ready

+

Credential-free command ready

Use Copy command to place the runnable command on your clipboard. The - one-time setup token is intentionally not shown on this page. + one-time setup token is shown separately. Run the command first, then + paste the token only at its silent prompt, never into a shell command.

@@ -895,7 +897,7 @@ export const NodeModalSetupGuideSection: Component

- Fully automatic: no manual token copying needed. + The monitoring API token is registered automatically.

diff --git a/frontend-modern/src/components/Settings/__tests__/NodeModalSetupGuideSection.test.tsx b/frontend-modern/src/components/Settings/__tests__/NodeModalSetupGuideSection.test.tsx index 3f27e1aa9..5acd3c978 100644 --- a/frontend-modern/src/components/Settings/__tests__/NodeModalSetupGuideSection.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/NodeModalSetupGuideSection.test.tsx @@ -90,13 +90,11 @@ describe('NodeModalSetupGuideSection', () => { quickSetupTokenHint: 'set…123', }); - expect(screen.getByText('Credentialed command ready')).toBeInTheDocument(); + expect(screen.getByText('Credential-free command ready')).toBeInTheDocument(); expect( screen.getByText(/Use Copy command to place the runnable command on your clipboard/i), ).toBeInTheDocument(); - expect( - screen.getByText(/one-time setup token is intentionally not shown/i), - ).toBeInTheDocument(); + expect(screen.getByText(/one-time setup token is shown separately/i)).toBeInTheDocument(); expect(screen.queryByText(/curl -fsSL/i)).not.toBeInTheDocument(); expect(screen.getByText('set…123')).toBeInTheDocument(); }); diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index 6619ee7a5..14e4b7fe3 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -1817,7 +1817,8 @@ describe('settings architecture guardrails', () => { expect(nodeModalSetupGuideSectionSource).toContain( 'one-time setup token is intentionally not shown on this page', ); - expect(nodeModalStateSource).toContain('data.commandWithEnv'); + expect(nodeModalStateSource).toContain('copyToClipboard(data.command)'); + expect(nodeModalStateSource).toContain('showSetupToken(data)'); expect(nodeModalStateSource).not.toContain('quickSetupPreviewCommand'); expect(nodeModalStatusFooterSource).toContain('guidedSetupOnlyMode'); expect(nodeModalStatusFooterSource).toContain('props.saveDisabled'); diff --git a/frontend-modern/src/components/Settings/useNodeModalState.ts b/frontend-modern/src/components/Settings/useNodeModalState.ts index e8b6249ad..b3f76c663 100644 --- a/frontend-modern/src/components/Settings/useNodeModalState.ts +++ b/frontend-modern/src/components/Settings/useNodeModalState.ts @@ -1,4 +1,4 @@ -import { createEffect, createMemo, createSignal } from 'solid-js'; +import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js'; import type { NodeConfig } from '@/types/nodes'; import { notificationStore } from '@/stores/notifications'; @@ -6,6 +6,7 @@ import { NodesAPI } from '@/api/nodes'; import type { ProxmoxSetupCommandResponse } from '@/api/nodes'; import { copyToClipboard } from '@/utils/clipboard'; import { logger } from '@/utils/logger'; +import { showTokenReveal } from '@/stores/tokenReveal'; import { buildNodeModalMonitoringPayload, getNodeModalDefaultFormData, @@ -71,6 +72,14 @@ export const useNodeModalState = (props: NodeModalProps) => { setQuickSetupExpiry(null); }; + let bootstrapGeneration = 0; + const bootstrapActive = (generation: number) => + generation === bootstrapGeneration && props.isOpen !== false; + onCleanup(() => { + bootstrapGeneration++; + clearQuickSetupState(); + }); + const copyCommand = async (command: string, successMessage = 'Command copied!') => { if (await copyToClipboard(command)) { notificationStore.success(successMessage); @@ -78,6 +87,7 @@ export const useNodeModalState = (props: NodeModalProps) => { }; const copyProxmoxAgentInstallCommand = async (type: 'pve' | 'pbs', successMessage: string) => { + const generation = bootstrapGeneration; try { setLoadingAgentCommand(true); setAgentCommandError(null); @@ -87,7 +97,15 @@ export const useNodeModalState = (props: NodeModalProps) => { enableCommands: false, insecure: agentInstallInsecure(), }); + if (!bootstrapActive(generation)) return; setAgentInstallCommand(data.command); + if (data.token) { + showTokenReveal({ + token: data.token, + source: type, + note: 'Run the copied install command first. Then copy this token and paste it only at the silent “Pulse agent token” prompt. It is not part of the command.', + }); + } const copied = await copyToClipboard(data.command); if (copied) { notificationStore.success(successMessage); @@ -98,12 +116,13 @@ export const useNodeModalState = (props: NodeModalProps) => { setAgentCommandError(copyFailureMessage); notificationStore.error(copyFailureMessage); } catch (error) { + if (!bootstrapActive(generation)) return; logger.error('[Host Telemetry Agent] Error:', error); const message = error instanceof Error ? error.message : 'Failed to generate install command'; setAgentCommandError(message); notificationStore.error(message); } finally { - setLoadingAgentCommand(false); + if (bootstrapActive(generation)) setLoadingAgentCommand(false); } }; @@ -113,7 +132,8 @@ export const useNodeModalState = (props: NodeModalProps) => { const loadQuickSetupBootstrap = async ( type: 'pve' | 'pbs', backupPerms: boolean, - ): Promise => { + ): Promise => { + const generation = bootstrapGeneration; const host = formData().host?.trim() ?? ''; if (!host) { notificationStore.error('Please enter the Endpoint URL first'); @@ -135,6 +155,8 @@ export const useNodeModalState = (props: NodeModalProps) => { backupPerms, name: formData().name?.trim() || undefined, }); + if (!bootstrapActive(generation) || quickSetupCacheKey(type, backupPerms) !== cacheKey) + return null; setQuickSetupBootstrap({ cacheKey, response }); setQuickSetupTokenHint(response.tokenHint); setQuickSetupExpiry(response.expires); @@ -146,18 +168,22 @@ export const useNodeModalState = (props: NodeModalProps) => { backupPerms: boolean, successMessage: string, ) => { + const generation = bootstrapGeneration; logger.debug('[Quick Setup] Copy button clicked'); try { logger.debug('[Quick Setup] Generating setup URL for host', { host: formData().host, }); const data = await loadQuickSetupBootstrap(type, backupPerms); - if (await copyToClipboard(data.commandWithEnv)) { + if (!data || !bootstrapActive(generation)) return; + showSetupToken(data); + if (await copyToClipboard(data.command)) { notificationStore.success(successMessage); return; } throw new Error('Failed to copy to clipboard'); } catch (error) { + if (!bootstrapActive(generation)) return; logger.error('[Quick Setup] Error:', error); clearQuickSetupState(); if (!(error instanceof Error && error.message === PROXMOX_SETUP_HOST_REQUIRED_MESSAGE)) { @@ -166,12 +192,24 @@ export const useNodeModalState = (props: NodeModalProps) => { } }; + const showSetupToken = (bootstrap: ProxmoxSetupCommandResponse) => { + showTokenReveal({ + token: bootstrap.setupToken, + source: bootstrap.type, + note: `Run the copied setup command (or downloaded script) first. Then copy this token and paste it only at the silent “Pulse setup token” prompt. It expires at ${new Date(bootstrap.expires * 1000).toLocaleTimeString()}. Never paste it into a shell command.`, + }); + }; + const setupScriptRunHint = (fileName: string) => `bash ${fileName}`; const downloadProxmoxSetupScript = async (type: 'pve' | 'pbs', backupPerms = false) => { + const generation = bootstrapGeneration; try { const bootstrap = await loadQuickSetupBootstrap(type, backupPerms); + if (!bootstrap || !bootstrapActive(generation)) return; const data = await NodesAPI.downloadProxmoxSetupScript(bootstrap); + if (!bootstrapActive(generation)) return; + showSetupToken(bootstrap); const blob = new Blob([data.content], { type: data.contentType, @@ -189,6 +227,7 @@ export const useNodeModalState = (props: NodeModalProps) => { `Script downloaded! Upload it to your server and run: ${setupScriptRunHint(data.fileName)}`, ); } catch (error) { + if (!bootstrapActive(generation)) return; logger.error('Failed to download script:', error); notificationStore.error('Failed to download script. Please check your connection.'); } @@ -215,6 +254,12 @@ export const useNodeModalState = (props: NodeModalProps) => { const editingNode = props.editingNode; const prefillNode = props.prefillNode; + if (isOpen === false || key !== previousResetKey || nodeType !== previousNodeType) { + bootstrapGeneration++; + clearQuickSetupState(); + setLoadingAgentCommand(false); + } + if (key !== undefined && key !== previousResetKey) { previousResetKey = key; setFormData(() => getNodeModalDefaultFormData(props.nodeType)); diff --git a/frontend-modern/src/stores/tokenReveal.ts b/frontend-modern/src/stores/tokenReveal.ts index 504e45cbb..6bbca7b3d 100644 --- a/frontend-modern/src/stores/tokenReveal.ts +++ b/frontend-modern/src/stores/tokenReveal.ts @@ -3,7 +3,7 @@ import type { APITokenRecord } from '@/api/security'; export interface TokenRevealPayload { token: string; - record: APITokenRecord; + record?: APITokenRecord; source?: string; note?: string; } diff --git a/frontend-modern/src/utils/__tests__/docsLinks.test.ts b/frontend-modern/src/utils/__tests__/docsLinks.test.ts index 172e0345b..d4bac377a 100644 --- a/frontend-modern/src/utils/__tests__/docsLinks.test.ts +++ b/frontend-modern/src/utils/__tests__/docsLinks.test.ts @@ -223,6 +223,17 @@ describe('docsLinks', () => { expect(apiReference).not.toContain('Mobile Remote Access'); }); + it('ships credential-free Proxmox setup instructions and a separate token prompt', () => { + const apiReference = readFileSync(path.join(repoRoot, 'docs', 'API.md'), 'utf8'); + const shipped = readFileSync(path.join(frontendRoot, 'public', 'docs', 'API.md'), 'utf8'); + const setup = apiReference.split('### Setup Script URL')[1].split('### Auto-Register')[0]; + expect(shipped).toBe(apiReference); + expect(setup).toContain('identical credential-free commands'); + expect(setup).toContain('`downloadURL` equals the tokenless `url`'); + expect(setup).toContain('paste the token only at its prompt'); + expect(setup).not.toContain('token-bearing `commandWithEnv`'); + }); + it('ships the per-alert snooze and resume API contract', () => { const apiReference = readFileSync(path.join(repoRoot, 'docs', 'API.md'), 'utf8'); const shippedAPIReference = readFileSync( diff --git a/install.sh b/install.sh index 85ad224e8..affec613c 100755 --- a/install.sh +++ b/install.sh @@ -2150,7 +2150,7 @@ smoke_test_pve_auto_register_token() { local smoke_status=0 set +e - smoke_output=$(curl --retry 2 --retry-delay 1 -kfsS -H "Authorization: PVEAPIToken=${token_id}=${token_value}" "${host_url%/}/api2/json/nodes" 2>&1) + smoke_output=$(printf 'Authorization: PVEAPIToken=%s=%s\n' "$token_id" "$token_value" | curl --retry 2 --retry-delay 1 -kfsS -H @- "${host_url%/}/api2/json/nodes" 2>&1) smoke_status=$? set -e @@ -2252,10 +2252,12 @@ print(json.dumps({"type": "pve", "host": host, "backupPerms": backup})) PY ) - echo "$setup_payload" > /tmp/pulse-auto-register-request.json 2>/dev/null || true + # Bootstrap request/response are not retained in shared /tmp diagnostics. local pulse_url="http://${pulse_ip}:${pulse_port}" + set +xv + local setup_response if ! setup_response=$(curl --retry 3 --retry-delay 2 -fsS -X POST "$pulse_url/api/setup-script-url" -H "Content-Type: application/json" -d "$setup_payload"); then AUTO_NODE_REGISTER_ERROR="setup token request failed" @@ -2263,8 +2265,7 @@ PY return fi - # Persist for debugging when running interactively - echo "$setup_response" > /tmp/pulse-auto-register-response.json 2>/dev/null || true + # The response contains a one-time credential; do not persist it as a diagnostic. local setup_token local setup_type @@ -2278,17 +2279,18 @@ PY local setup_token_hint local setup_expires local setup_expiry_state - IFS=$'\t' read -r setup_token setup_type setup_host setup_url setup_download_url setup_script_name setup_command setup_command_with_env setup_command_without_env setup_token_hint setup_expires setup_expiry_state <<<"$(python3 - "$setup_response" "$pulse_url" "$normalized_host_url" <<'PY' + IFS=$'\t' read -r setup_token setup_type setup_host setup_url setup_download_url setup_script_name setup_command setup_command_with_env setup_command_without_env setup_token_hint setup_expires setup_expiry_state <<<"$(python3 - "$pulse_url" "$normalized_host_url" "$backup_perms" 3<<<"$setup_response" <<'PY' import json, sys import time from urllib.parse import quote try: - data = json.loads(sys.argv[1]) + data = json.load(__import__("os").fdopen(3)) except Exception: print("\t\t\t\t\t\t\t\t\t\t") sys.exit(0) -pulse_url = sys.argv[2] -host = sys.argv[3] +pulse_url = sys.argv[1] +host = sys.argv[2] +backup_query = "backup_perms=true&" if sys.argv[3] == "true" else "" expires_raw = data.get("expires", "") expiry_state = "" try: @@ -2305,15 +2307,14 @@ setup_script_name = str(data.get("scriptFileName", "")) setup_command = str(data.get("command", "")) setup_command_with_env = str(data.get("commandWithEnv", "")) setup_command_without_env = str(data.get("commandWithoutEnv", "")) -expected_setup_url = f"{pulse_url}/api/setup-script?host={quote(host, safe='')}&pulse_url={quote(pulse_url, safe='')}&type=pve" -if setup_token: - expected_download_url = f"{pulse_url}/api/setup-script?host={quote(host, safe='')}&pulse_url={quote(pulse_url, safe='')}&setup_token={quote(setup_token, safe='')}&type=pve" -else: - expected_download_url = "" +expected_setup_url = f"{pulse_url}/api/setup-script?{backup_query}host={quote(host, safe='')}&pulse_url={quote(pulse_url, safe='')}&type=pve" +expected_download_url = expected_setup_url +legacy_download_url = f"{pulse_url}/api/setup-script?{backup_query}host={quote(host, safe='')}&pulse_url={quote(pulse_url, safe='')}&setup_token={quote(setup_token, safe='')}&type=pve" if setup_token else "" +modern = setup_download_url == expected_download_url expected_script_name = "pulse-setup-pve.sh" if setup_url != expected_setup_url: setup_url = "" -if setup_download_url != expected_download_url: +if setup_download_url not in (expected_download_url, legacy_download_url): setup_download_url = "" if setup_script_name != expected_script_name: setup_script_name = "" @@ -2345,15 +2346,17 @@ for _field_name, _value, _requires_token in command_fields: else: setup_command_without_env = "" continue - if _requires_token: - if "PULSE_SETUP_TOKEN=" not in _value or setup_token not in _value: - if _field_name == "command": - setup_command = "" - else: - setup_command_with_env = "" - continue - elif "PULSE_SETUP_TOKEN=" in _value or setup_token in _value: - setup_command_without_env = "" + if modern: + valid = "PULSE_SETUP_TOKEN_FILE=" in _value and "PULSE_SETUP_TOKEN=" not in _value and setup_token not in _value + else: + valid = ("PULSE_SETUP_TOKEN=" in _value and setup_token in _value) if _requires_token else ("PULSE_SETUP_TOKEN=" not in _value and setup_token not in _value) + if not valid: + if _field_name == "command": + setup_command = "" + elif _field_name == "commandWithEnv": + setup_command_with_env = "" + else: + setup_command_without_env = "" if not token_hint or token_hint == setup_token: token_hint = "" print("\t".join([ @@ -2373,7 +2376,9 @@ print("\t".join([ PY )" || setup_token="" - local expected_setup_url="${pulse_url}/api/setup-script?host=$(python3 - <<'PY' "$normalized_host_url" + local backup_query="" + [[ "$backup_perms" == "true" ]] && backup_query="backup_perms=true&" + local expected_setup_url="${pulse_url}/api/setup-script?${backup_query}host=$(python3 - <<'PY' "$normalized_host_url" from urllib.parse import quote import sys print(quote(sys.argv[1], safe='')) @@ -2384,16 +2389,18 @@ import sys print(quote(sys.argv[1], safe='')) PY )&type=pve" - local expected_download_url="$(python3 - <<'PY' "$normalized_host_url" "$pulse_url" "$setup_token" + local expected_download_url="$(python3 - <<'PY' "$normalized_host_url" "$pulse_url" "$backup_perms" 3<<<"$setup_token" from urllib.parse import quote import sys -host, pulse_url, setup_token = sys.argv[1:] -print(f"{pulse_url}/api/setup-script?host={quote(host, safe='')}&pulse_url={quote(pulse_url, safe='')}&setup_token={quote(setup_token, safe='')}&type=pve") +host, pulse_url, backup_perms = sys.argv[1:] +backup_query = "backup_perms=true&" if backup_perms == "true" else "" +setup_token = __import__("os").fdopen(3).read().strip() +print(f"{pulse_url}/api/setup-script?{backup_query}host={quote(host, safe='')}&pulse_url={quote(pulse_url, safe='')}&setup_token={quote(setup_token, safe='')}&type=pve") PY )" local expected_script_name="pulse-setup-pve.sh" - if [[ -z "$setup_token" ]] || [[ "$setup_type" != "pve" ]] || [[ "$setup_host" != "$normalized_host_url" ]] || [[ "$setup_url" != "$expected_setup_url" ]] || [[ "$setup_download_url" != "$expected_download_url" ]] || [[ "$setup_script_name" != "$expected_script_name" ]] || [[ -z "$setup_command" ]] || [[ -z "$setup_command_with_env" ]] || [[ -z "$setup_command_without_env" ]] || [[ -z "$setup_token_hint" ]] || [[ -z "$setup_expires" ]] || [[ "$setup_expiry_state" != "live" ]]; then + if [[ -z "$setup_token" ]] || [[ "$setup_type" != "pve" ]] || [[ "$setup_host" != "$normalized_host_url" ]] || [[ "$setup_url" != "$expected_setup_url" ]] || { [[ "$setup_download_url" != "$expected_download_url" ]] && [[ "$setup_download_url" != "$expected_setup_url" ]]; } || [[ "$setup_script_name" != "$expected_script_name" ]] || [[ -z "$setup_command" ]] || [[ -z "$setup_command_with_env" ]] || [[ -z "$setup_command_without_env" ]] || [[ -z "$setup_token_hint" ]] || [[ -z "$setup_expires" ]] || [[ "$setup_expiry_state" != "live" ]]; then AUTO_NODE_REGISTER_ERROR="missing setup token" print_warn "Pulse did not return a setup token; skipping automatic node registration" return @@ -2512,9 +2519,10 @@ PY fi local register_payload - register_payload=$(python3 - <<'PY' "$normalized_host_url" "$token_id" "$token_value" "$server_name" "$setup_token" + register_payload=$(python3 - "$normalized_host_url" "$token_id" "$server_name" 3<<<"$(printf '%s\n%s\n' "$token_value" "$setup_token")" <<'PY' import json, sys -host, token_id, token_value, server_name, setup_token = sys.argv[1:] +host, token_id, server_name = sys.argv[1:] +token_value, setup_token = __import__("os").fdopen(3).read().splitlines() print(json.dumps({ "type": "pve", "host": host, @@ -2528,7 +2536,7 @@ PY ) local register_response - if ! register_response=$(curl --retry 3 --retry-delay 2 -fsS -X POST "$pulse_url/api/auto-register" -H "Content-Type: application/json" -d "$register_payload"); then + if ! register_response=$(printf %s "$register_payload" | curl --retry 3 --retry-delay 2 -fsS -X POST "$pulse_url/api/auto-register" -H "Content-Type: application/json" -d @-); then AUTO_NODE_REGISTER_ERROR="auto-register request failed" print_warn "Pulse auto-registration request failed; skipping automatic node registration" return diff --git a/internal/api/agent_install_command_shared.go b/internal/api/agent_install_command_shared.go index 43faed3d1..0a0a5d70f 100644 --- a/internal/api/agent_install_command_shared.go +++ b/internal/api/agent_install_command_shared.go @@ -3,7 +3,6 @@ package api import ( "fmt" "net/http" - "net/url" "strings" "github.com/rcourtman/pulse-go-rewrite/internal/api/agenttokens" @@ -142,19 +141,7 @@ func buildContainerRuntimeAgentInstallCommand(baseURL string, token string, enab } func buildSetupScriptCommand(scriptURL string, token string) string { - curlCommand := "curl -fsSL " + posixShellQuote(strings.TrimSpace(scriptURL)) + " | " - bashCommand := "bash" - sudoCommand := "sudo bash" - if trimmedToken := strings.TrimSpace(token); trimmedToken != "" { - envPrefix := "PULSE_SETUP_TOKEN=" + posixShellQuote(trimmedToken) + " " - bashCommand = envPrefix + bashCommand - sudoCommand = "sudo env " + envPrefix + "bash" - } - - return curlCommand + - `{ if [ "$(id -u)" -eq 0 ]; then ` + bashCommand + - `; elif command -v sudo >/dev/null 2>&1; then ` + sudoCommand + - `; else echo "Root privileges required. Run as root (su -) and retry." >&2; exit 1; fi; }` + return configapi.BuildSetupScriptCommand(scriptURL, token) } func buildSetupScriptTokenHint(token string) string { @@ -166,40 +153,11 @@ func buildSetupScriptTokenHint(token string) string { } func buildSetupScriptURL(baseURL string, installType string, host string, pulseURL string, backupPerms bool) string { - query := url.Values{} - query.Set("type", strings.TrimSpace(installType)) - - if trimmedHost := strings.TrimSpace(host); trimmedHost != "" { - query.Set("host", trimmedHost) - } - - if trimmedPulseURL := strings.TrimSpace(pulseURL); trimmedPulseURL != "" { - query.Set("pulse_url", trimmedPulseURL) - } - - if backupPerms && strings.TrimSpace(installType) == "pve" { - query.Set("backup_perms", "true") - } - - return normalizeAgentInstallBaseURL(baseURL) + "/api/setup-script?" + query.Encode() + return configapi.BuildSetupScriptURL(baseURL, installType, host, pulseURL, backupPerms) } func buildSetupScriptDownloadURL(baseURL string, installType string, host string, pulseURL string, backupPerms bool, setupToken string) string { - downloadURL := buildSetupScriptURL(baseURL, installType, host, pulseURL, backupPerms) - trimmedToken := strings.TrimSpace(setupToken) - if trimmedToken == "" { - return downloadURL - } - - parsed, err := url.Parse(downloadURL) - if err != nil { - return downloadURL - } - - query := parsed.Query() - query.Set("setup_token", trimmedToken) - parsed.RawQuery = query.Encode() - return parsed.String() + return configapi.BuildSetupScriptDownloadURL(baseURL, installType, host, pulseURL, backupPerms, setupToken) } func buildSetupScriptFileName(installType string) string { @@ -207,22 +165,7 @@ func buildSetupScriptFileName(installType string) string { } func buildSetupScriptInstallArtifact(baseURL string, installType string, host string, pulseURL string, backupPerms bool, setupToken string, expiresAt int64) setupScriptInstallArtifact { - scriptURL := buildSetupScriptURL(baseURL, installType, host, pulseURL, backupPerms) - commandWithEnv := buildSetupScriptCommand(scriptURL, setupToken) - - return setupScriptInstallArtifact{ - Type: strings.TrimSpace(installType), - Host: strings.TrimSpace(host), - URL: scriptURL, - DownloadURL: buildSetupScriptDownloadURL(baseURL, installType, host, pulseURL, backupPerms, setupToken), - ScriptFileName: buildSetupScriptFileName(installType), - Command: commandWithEnv, - CommandWithEnv: commandWithEnv, - CommandWithoutEnv: buildSetupScriptCommand(scriptURL, ""), - Expires: expiresAt, - SetupToken: strings.TrimSpace(setupToken), - TokenHint: buildSetupScriptTokenHint(setupToken), - } + return configapi.BuildSetupScriptInstallArtifact(baseURL, installType, host, pulseURL, backupPerms, setupToken, expiresAt) } func resolveConfigAgentInstallBaseURL(req *http.Request, cfg *config.Config, hostedMode bool) string { diff --git a/internal/api/agent_install_command_shared_test.go b/internal/api/agent_install_command_shared_test.go index 2ee7329ab..06a15f61c 100644 --- a/internal/api/agent_install_command_shared_test.go +++ b/internal/api/agent_install_command_shared_test.go @@ -89,12 +89,12 @@ func TestBuildProxmoxAgentInstallCommand(t *testing.T) { IncludeInstallType: true, }) require.Contains(t, command, posixShellQuote("https://pulse.example.com/install.sh")) - require.Contains(t, command, "printf %s "+posixShellQuote("token-123")+` > "$token_file"`) + require.NotContains(t, command, "token-123") require.Contains(t, command, `--token-file "$token_file"`) require.Contains(t, command, `token_dir=$(mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX)`) - require.Contains(t, command, `token_dir=$(sudo mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX)`) - require.Contains(t, command, `rm -rf -- "$token_dir"`) - require.Contains(t, command, "--proxmox-type "+posixShellQuote("pbs")) + require.Contains(t, command, `sudo bash -c`) + require.Contains(t, command, `rmdir -- "$token_dir"`) + require.Contains(t, command, "pbs") require.NotContains(t, command, "--enable-commands") } @@ -107,7 +107,7 @@ func TestBuildProxmoxAgentInstallCommand_IncludesInsecureForPlainHTTP(t *testing }) require.Contains(t, command, posixShellQuote("http://pulse.example.com:7655/install.sh")) - require.Contains(t, command, "--url "+posixShellQuote("http://pulse.example.com:7655")) + require.Contains(t, command, "http://pulse.example.com:7655") require.Contains(t, command, "--insecure") } @@ -121,7 +121,7 @@ func TestBuildProxmoxAgentInstallCommand_IncludesExplicitInsecureForSelfSignedHT }) require.Contains(t, command, "curl -kfsSL "+posixShellQuote("https://pulse.example.com:7655/install.sh")) - require.Contains(t, command, "--url "+posixShellQuote("https://pulse.example.com:7655")) + require.Contains(t, command, "https://pulse.example.com:7655") require.Contains(t, command, "--insecure") } @@ -135,8 +135,8 @@ func TestBuildProxmoxAgentInstallCommand_UsesPrivilegeEscalationWrapper(t *testi require.Contains(t, command, `if [ "$(id -u)" -eq 0 ]; then`) require.Contains(t, command, `elif command -v sudo >/dev/null 2>&1; then`) - require.Contains(t, command, `printf %s 'token-123' | sudo tee "$token_file" >/dev/null`) - require.Contains(t, command, `curl -fsSL 'https://pulse.example.com/install.sh' | sudo bash -s --`) + require.NotContains(t, command, "token-123") + require.Contains(t, command, `curl -fsSL 'https://pulse.example.com/install.sh' -o "$install_script"`) require.Contains(t, command, `echo "Root privileges required. Run as root (su -) and retry." >&2`) } @@ -148,8 +148,8 @@ func TestBuildProxmoxAgentInstallCommand_OmitsTokenWhenNotProvided(t *testing.T) IncludeInstallType: true, }) - require.Contains(t, command, "--url "+posixShellQuote("https://pulse.example.com")) - require.Contains(t, command, "--proxmox-type "+posixShellQuote("pbs")) + require.Contains(t, command, "https://pulse.example.com") + require.Contains(t, command, "pbs") require.NotContains(t, command, "--token") } @@ -164,10 +164,10 @@ func TestBuildProxmoxAgentInstallCommand_ShellEscapesArguments(t *testing.T) { }) require.Contains(t, command, posixShellQuote(baseURL+"/install.sh")) - require.Contains(t, command, "--url "+posixShellQuote(baseURL)) - require.Contains(t, command, "printf %s "+posixShellQuote(token)+` > "$token_file"`) + require.Contains(t, command, "--url") + require.NotContains(t, command, token) require.Contains(t, command, `--token-file "$token_file"`) - require.Contains(t, command, "--proxmox-type "+posixShellQuote("pve")) + require.Contains(t, command, "pve") } func TestBuildProxmoxAgentInstallCommand_NormalizesTrailingSlashes(t *testing.T) { @@ -179,10 +179,10 @@ func TestBuildProxmoxAgentInstallCommand_NormalizesTrailingSlashes(t *testing.T) }) require.Contains(t, command, posixShellQuote("https://pulse.example.com/base/install.sh")) - require.Contains(t, command, "--url "+posixShellQuote("https://pulse.example.com/base")) + require.Contains(t, command, "https://pulse.example.com/base") require.Contains(t, command, `--token-file "$token_file"`) require.NotContains(t, command, "//install.sh") - require.NotContains(t, command, "--url "+posixShellQuote("https://pulse.example.com/base/")) + require.NotContains(t, command, "https://pulse.example.com/base/") } func TestBuildProxmoxAgentInstallCommand_IncludesCommandsWhenRequested(t *testing.T) { @@ -195,17 +195,17 @@ func TestBuildProxmoxAgentInstallCommand_IncludesCommandsWhenRequested(t *testin }) require.Contains(t, command, "--enable-proxmox") - require.Contains(t, command, "--proxmox-type "+posixShellQuote("pve")) + require.Contains(t, command, "pve") require.Contains(t, command, "--enable-commands") require.Contains(t, command, `token_dir=$(mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX)`) - require.Contains(t, command, `rm -rf -- "$token_dir"`) + require.Contains(t, command, `rmdir -- "$token_dir"`) } func TestBuildContainerRuntimeAgentInstallCommand_UsesLifecycleTransport(t *testing.T) { command := buildContainerRuntimeAgentInstallCommand("https://pulse.example.com/base", "token-123", true) require.Contains(t, command, posixShellQuote("https://pulse.example.com/base/install.sh")) - require.Contains(t, command, "--url "+posixShellQuote("https://pulse.example.com/base")) + require.Contains(t, command, "https://pulse.example.com/base") require.Contains(t, command, "--token "+posixShellQuote("token-123")) require.Contains(t, command, "--enable-docker") require.Contains(t, command, "--enable-host") @@ -220,7 +220,7 @@ func TestBuildContainerRuntimeAgentInstallCommand_OmitsTokenAndAddsInsecureForHT command := buildContainerRuntimeAgentInstallCommand("http://pulse.example.com:7655/", "", true) require.Contains(t, command, posixShellQuote("http://pulse.example.com:7655/install.sh")) - require.Contains(t, command, "--url "+posixShellQuote("http://pulse.example.com:7655")) + require.Contains(t, command, "http://pulse.example.com:7655") require.NotContains(t, command, "--token") require.Contains(t, command, "--insecure") } @@ -246,18 +246,18 @@ func TestContainerRuntimeAgentScopesFollowHostMode(t *testing.T) { func TestBuildSetupScriptCommand_UsesFailFastQuotedTransport(t *testing.T) { command := buildSetupScriptCommand("https://pulse.example.com/api/setup-script?type=pve&host=pve1.local", "token-123") - require.Contains(t, command, "curl -fsSL "+posixShellQuote("https://pulse.example.com/api/setup-script?type=pve&host=pve1.local")+" | ") - require.Contains(t, command, `if [ "$(id -u)" -eq 0 ]; then PULSE_SETUP_TOKEN=`+posixShellQuote("token-123")+` bash`) - require.Contains(t, command, `elif command -v sudo >/dev/null 2>&1; then sudo env PULSE_SETUP_TOKEN=`+posixShellQuote("token-123")+` bash`) - require.Contains(t, command, `else echo "Root privileges required. Run as root (su -) and retry." >&2; exit 1; fi; }`) + require.Contains(t, command, "curl -fsSL "+posixShellQuote("https://pulse.example.com/api/setup-script?type=pve&host=pve1.local")+` -o "$install_script"`) + require.NotContains(t, command, "token-123") + require.Contains(t, command, `PULSE_SETUP_TOKEN_FILE="$token_file" bash "$1"`) + require.Contains(t, command, `else echo "Root privileges required. Run as root (su -) and retry." >&2; exit 1; fi;`) } func TestBuildSetupScriptCommand_OmitsTokenWhenNotProvided(t *testing.T) { command := buildSetupScriptCommand("https://pulse.example.com/api/setup-script?type=pbs", "") - require.Contains(t, command, "curl -fsSL "+posixShellQuote("https://pulse.example.com/api/setup-script?type=pbs")+" | ") - require.Contains(t, command, `if [ "$(id -u)" -eq 0 ]; then bash`) - require.Contains(t, command, `elif command -v sudo >/dev/null 2>&1; then sudo bash`) + require.Contains(t, command, "curl -fsSL "+posixShellQuote("https://pulse.example.com/api/setup-script?type=pbs")+` -o "$install_script"`) + require.Contains(t, command, `if [ "$(id -u)" -eq 0 ]; then`) + require.Contains(t, command, `sudo bash -c`) require.NotContains(t, command, "PULSE_SETUP_TOKEN=") } @@ -293,10 +293,11 @@ func TestBuildSetupScriptInstallArtifact_UsesSharedBackendShape(t *testing.T) { require.Equal(t, "pve", artifact.Type) require.Equal(t, "https://pve1.local:8006", artifact.Host) require.Contains(t, artifact.URL, "/api/setup-script?") - require.Contains(t, artifact.DownloadURL, "setup_token=setup-token-123") + require.Equal(t, artifact.URL, artifact.DownloadURL) require.Equal(t, "pulse-setup-pve.sh", artifact.ScriptFileName) require.Equal(t, artifact.Command, artifact.CommandWithEnv) - require.Contains(t, artifact.CommandWithEnv, "PULSE_SETUP_TOKEN='setup-token-123'") + require.NotContains(t, artifact.CommandWithEnv, "setup-token-123") + require.Contains(t, artifact.CommandWithEnv, "PULSE_SETUP_TOKEN_FILE=") require.NotContains(t, artifact.CommandWithoutEnv, "PULSE_SETUP_TOKEN=") require.Equal(t, expiresAt, artifact.Expires) require.Equal(t, "setup-token-123", artifact.SetupToken) diff --git a/internal/api/configapi/config_handlers_setup_url_test.go b/internal/api/configapi/config_handlers_setup_url_test.go index 285a021f2..e5d5487a1 100644 --- a/internal/api/configapi/config_handlers_setup_url_test.go +++ b/internal/api/configapi/config_handlers_setup_url_test.go @@ -60,12 +60,10 @@ func TestHandleSetupScriptURL(t *testing.T) { if tokenHint == token { t.Errorf("expected tokenHint to mask setup token, got %q", tokenHint) } - if !strings.Contains(downloadURL, "setup_token=") { - t.Errorf("expected downloadURL to embed setup token, got %q", downloadURL) - } - if !strings.Contains(downloadURL, token) { - t.Errorf("expected downloadURL to contain setup token, got %q", downloadURL) + if downloadURL != url || strings.Contains(downloadURL, token) { + t.Errorf("downloadURL must match the credential-free script URL") } + respType, ok := resp["type"].(string) if !ok || respType != "pve" { t.Errorf("expected canonical type, got %v", resp["type"]) @@ -92,16 +90,12 @@ func TestHandleSetupScriptURL(t *testing.T) { t.Errorf("expected URL to contain public host, got %s", url) } quotedURL := posixShellQuote(url) - if !strings.Contains(command, "curl -fsSL "+quotedURL+" | ") || - !strings.Contains(command, `if [ "$(id -u)" -eq 0 ]; then PULSE_SETUP_TOKEN=`+posixShellQuote(token)+` bash`) || - !strings.Contains(command, `elif command -v sudo >/dev/null 2>&1; then sudo env PULSE_SETUP_TOKEN=`+posixShellQuote(token)+` bash`) { - t.Errorf("expected shell-quoted command, got %s", command) - } - if !strings.Contains(commandWithoutEnv, "curl -fsSL "+quotedURL+" | ") || - !strings.Contains(commandWithoutEnv, `if [ "$(id -u)" -eq 0 ]; then bash`) || - !strings.Contains(commandWithoutEnv, `elif command -v sudo >/dev/null 2>&1; then sudo bash`) { - t.Errorf("expected shell-quoted commandWithoutEnv, got %s", commandWithoutEnv) + for _, candidate := range []string{command, commandWithoutEnv} { + if !strings.Contains(candidate, "curl -fsSL "+quotedURL+` -o "$install_script"`) || strings.Contains(candidate, token) || !strings.Contains(candidate, "PULSE_SETUP_TOKEN_FILE=") || !strings.Contains(candidate, "sudo bash -c") { + t.Error("setup command must download completely, prompt privately, and pass only the token file") + } } + }, }, { @@ -296,7 +290,7 @@ func TestHandleSetupScriptURL_PreservesConfiguredPublicURLSchemeOnLoopback(t *te } command, _ := response["command"].(string) - if !strings.Contains(command, "curl -fsSL "+posixShellQuote(urlValue)+" | ") || !strings.Contains(command, `sudo env PULSE_SETUP_TOKEN=`) { + if !strings.Contains(command, "curl -fsSL "+posixShellQuote(urlValue)+` -o "$install_script"`) || !strings.Contains(command, `sudo bash -c`) { t.Fatalf("command = %q, want quoted setup-script URL", command) } } @@ -323,8 +317,8 @@ func TestBuildSetupScriptInstallArtifact_UsesCanonicalTransportContract(t *testi if artifact.URL == "" || !strings.Contains(artifact.URL, "/api/setup-script?") { t.Fatalf("url = %q, want canonical setup-script url", artifact.URL) } - if artifact.DownloadURL == "" || !strings.Contains(artifact.DownloadURL, "setup_token=setup-token-123") { - t.Fatalf("downloadURL = %q, want setup token embedded", artifact.DownloadURL) + if artifact.DownloadURL != artifact.URL || strings.Contains(artifact.DownloadURL, "setup-token-123") { + t.Fatalf("downloadURL = %q, want credential-free URL", artifact.DownloadURL) } if artifact.ScriptFileName != "pulse-setup-pve.sh" { t.Fatalf("scriptFileName = %q, want canonical filename", artifact.ScriptFileName) @@ -332,8 +326,8 @@ func TestBuildSetupScriptInstallArtifact_UsesCanonicalTransportContract(t *testi if artifact.Command != artifact.CommandWithEnv { t.Fatalf("command = %q, commandWithEnv = %q, want identical canonical env command", artifact.Command, artifact.CommandWithEnv) } - if !strings.Contains(artifact.CommandWithEnv, "PULSE_SETUP_TOKEN='setup-token-123'") { - t.Fatalf("commandWithEnv = %q, want setup token env transport", artifact.CommandWithEnv) + if strings.Contains(artifact.CommandWithEnv, "setup-token-123") || !strings.Contains(artifact.CommandWithEnv, "PULSE_SETUP_TOKEN_FILE=") { + t.Fatalf("commandWithEnv = %q, want private token-file transport", artifact.CommandWithEnv) } if strings.Contains(artifact.CommandWithoutEnv, "PULSE_SETUP_TOKEN=") { t.Fatalf("commandWithoutEnv = %q, want no setup token env transport", artifact.CommandWithoutEnv) diff --git a/internal/api/configapi/install_command.go b/internal/api/configapi/install_command.go index c495efa03..49c48a183 100644 --- a/internal/api/configapi/install_command.go +++ b/internal/api/configapi/install_command.go @@ -1,7 +1,6 @@ package configapi import ( - "fmt" "strings" ) @@ -16,60 +15,68 @@ type AgentInstallCommandOptions struct { type agentInstallCommandOptions = AgentInstallCommandOptions +// BuildProxmoxAgentInstallCommand never puts the credential in shell source. +// Token only selects whether private terminal input is required. The plaintext +// remains in the authenticated response's separate token field. func BuildProxmoxAgentInstallCommand(opts AgentInstallCommandOptions) string { baseURL := strings.TrimRight(strings.TrimSpace(opts.BaseURL), "/") - installScriptURL := baseURL + "/install.sh" curlFlags := "-fsSL" if opts.Insecure { curlFlags = "-kfsSL" } - token := strings.TrimSpace(opts.Token) - command := fmt.Sprintf("curl %s %s | bash -s -- \\\n --url %s \\\n --enable-proxmox", curlFlags, posixShellQuote(installScriptURL), posixShellQuote(baseURL)) + trustArgs := "" if opts.Insecure || strings.HasPrefix(strings.ToLower(baseURL), "http://") { - command += " \\\n --insecure" + trustArgs = " --insecure" } + args := " --url " + posixShellQuote(baseURL) + " --enable-proxmox" if opts.IncludeInstallType { - command += fmt.Sprintf(" \\\n --proxmox-type %s", posixShellQuote(opts.InstallType)) + args += " --proxmox-type " + posixShellQuote(opts.InstallType) } if opts.EnableCommands { - command += " \\\n --enable-commands" + args += " --enable-commands" } - if token == "" { - return withPrivilegeEscalation(command) + if strings.TrimSpace(opts.Token) != "" { + args += ` --token-file "$token_file"` } + args += trustArgs + " --non-interactive" + preflight := "bash \"$install_script\" --url " + posixShellQuote(baseURL) + + " --preflight-only --output json --non-interactive" + trustArgs + ";" + return privateBootstrapCommand(baseURL+"/install.sh", curlFlags, preflight, + "bash \"$1\""+args+";", strings.TrimSpace(opts.Token) != "", "Pulse agent token") +} - rootCommand := command + " \\\n --token-file \"$token_file\"" - sudoCommand := strings.Replace(command, "| bash -s --", "| sudo bash -s --", 1) + " \\\n --token-file \"$token_file\"" - return fmt.Sprintf(`( - set -e - token_dir="" - cleanup() { - if [ -n "${token_dir:-}" ]; then - if [ "$(id -u)" -eq 0 ]; then - rm -rf -- "$token_dir" - elif command -v sudo >/dev/null 2>&1; then - sudo rm -rf -- "$token_dir" >/dev/null 2>&1 || true - fi - fi - } - trap cleanup EXIT HUP INT TERM - if [ "$(id -u)" -eq 0 ]; then - token_dir=$(mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX) - token_file="$token_dir/token" - umask 077 - printf %%s %s > "$token_file" - %s - elif command -v sudo >/dev/null 2>&1; then - token_dir=$(sudo mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX) - token_file="$token_dir/token" - printf %%s %s | sudo tee "$token_file" >/dev/null - sudo chmod 0600 "$token_file" - %s - else - echo "Root privileges required. Run as root (su -) and retry." >&2 - exit 1 - fi -)`, posixShellQuote(token), rootCommand, posixShellQuote(token), sudoCommand) +// privateBootstrapCommand uses POSIX outer grammar for single-line paste hosts. +// The privileged Bash child owns both the silent input and its 0700/0600 +// handoff. Neither sudo nor a child command receives the credential in argv or +// environment. Failed/partial downloads and preflight never reach that child. +func privateBootstrapCommand(scriptURL, curlFlags, preflight, run string, needsToken bool, prompt string) string { + privileged := []string{"set +xv;", "set -eu;", "umask 077;"} + if needsToken { + privileged = append(privileged, + `token_dir=$(mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX);`, + `token_file="$token_dir/token";`, + `cleanup() { unset pulse_token; rm -f -- "$token_file"; rmdir -- "$token_dir"; };`, + `trap cleanup EXIT; trap 'exit 129' HUP; trap 'exit 130' INT; trap 'exit 143' TERM;`, + `if ! IFS= read -r -s -p `+posixShellQuote(prompt+" (paste at this prompt, not in the command): ")+` pulse_token &2; exit 1; fi;`, + `printf '\n' >/dev/tty;`, + `if [ -z "$pulse_token" ] || [ "${#pulse_token}" -gt 4096 ]; then echo "A non-empty token of at most 4096 characters is required." >&2; exit 1; fi;`, + `printf %s "$pulse_token" > "$token_file"; unset pulse_token;`, + ) + } + privileged = append(privileged, run) + child := "bash -c " + posixShellQuote(strings.Join(privileged, " ")) + ` pulse-bootstrap "$install_script";` + return strings.Join([]string{ + "(", "set +xv;", "set -eu;", "umask 077;", + `if [ "$(id -u)" -eq 0 ]; then :; elif command -v sudo >/dev/null 2>&1; then sudo -v; else echo "Root privileges required. Run as root (su -) and retry." >&2; exit 1; fi;`, + `bootstrap_dir=$(mktemp -d /tmp/pulse-bootstrap.XXXXXX);`, + `install_script="$bootstrap_dir/install.sh";`, + `cleanup() { rm -f -- "$install_script"; rmdir -- "$bootstrap_dir"; };`, + `trap cleanup EXIT; trap 'exit 129' HUP; trap 'exit 130' INT; trap 'exit 143' TERM;`, + "curl " + curlFlags + " " + posixShellQuote(strings.TrimSpace(scriptURL)) + ` -o "$install_script";`, + preflight, + `if [ "$(id -u)" -eq 0 ]; then ` + child + " else sudo " + child + " fi;", + ")", + }, " ") } func buildProxmoxAgentInstallCommand(opts agentInstallCommandOptions) string { diff --git a/internal/api/configapi/install_command_test.go b/internal/api/configapi/install_command_test.go index 2568f7107..605c9d0a0 100644 --- a/internal/api/configapi/install_command_test.go +++ b/internal/api/configapi/install_command_test.go @@ -1,84 +1,73 @@ package configapi import ( + "encoding/json" "os" "os/exec" "path/filepath" + "strings" "testing" ) -func TestBuildProxmoxAgentInstallCommandExecutesTrustedRootAndSudoTokenBootstrap(t *testing.T) { - if testing.Short() { - t.Skip("executes the generated POSIX shell command") - } +// A real controlling terminal is necessary: stdin must not accidentally become +// a credential channel for a downloaded/piped script. The token travels only +// over the PTY after the prompt appears, not Python/child argv or environment. +const bootstrapPTYRunner = ` +import errno, fcntl, json, os, pty, select, signal, subprocess, sys, termios, time +p = json.load(sys.stdin) +master, slave = pty.openpty() +def session(): + os.setsid() + fcntl.ioctl(0, termios.TIOCSCTTY, 0) +history = p.get("history") +env = os.environ.copy() +if history: + env.update(HISTFILE=history, HISTSIZE="100", HISTFILESIZE="100", HISTCONTROL="", PS1="PULSE_TEST$ ") +program = ["bash", "--noprofile", "--norc", "-i"] if history else ["sh", "-c", p["command"]] +child = subprocess.Popen(program, stdin=slave, stdout=slave, stderr=slave, preexec_fn=session, env=env) +if history: os.write(master, (p["command"] + "\n").encode()) +os.close(slave) +output = b"" +sent = False +deadline = time.monotonic() + 15 +while time.monotonic() < deadline: + ready, _, _ = select.select([master], [], [], .1) + if ready: + try: chunk = os.read(master, 65536) + except OSError as e: + if e.errno == errno.EIO: break + raise + if not chunk: break + output += chunk + if output.endswith(b"(paste at this prompt, not in the command): ") and not sent: + os.write(master, (p["input"] + "\n").encode()) + sent = True + if history: os.write(master, b"exit\n") + if child.poll() is not None and not ready: break +else: + os.killpg(child.pid, signal.SIGKILL) + output += b"\nPTY fixture deadline expired\n" +code = child.wait() +os.close(master) +sys.stdout.buffer.write(output) +sys.exit(code if code >= 0 else 128 - code) +` - fixtureDir := t.TempDir() - binDir := filepath.Join(fixtureDir, "bin") - if err := os.Mkdir(binDir, 0o700); err != nil { - t.Fatal(err) - } - installerPath := filepath.Join(fixtureDir, "installer.sh") - capturePath := filepath.Join(fixtureDir, "captured-token") - writeExecutable(t, installerPath, `#!/usr/bin/env bash -set -e -token_file="" -while [ "$#" -gt 0 ]; do - case "$1" in - --token-file) token_file="$2"; shift 2 ;; - *) shift ;; - esac -done -[ -n "$token_file" ] -[ "$(stat -c %a "$token_file" 2>/dev/null || stat -f %Lp "$token_file")" = "600" ] -parent_dir=$(dirname "$token_file") -[ "$(stat -c %a "$parent_dir" 2>/dev/null || stat -f %Lp "$parent_dir")" = "700" ] -[ "$(stat -c %u "$token_file" 2>/dev/null || stat -f %u "$token_file")" = "$(stat -c %u "$parent_dir" 2>/dev/null || stat -f %u "$parent_dir")" ] -cat "$token_file" > "$FAKE_CAPTURE" -`) - writeExecutable(t, filepath.Join(binDir, "curl"), `#!/bin/sh -cat "$FAKE_INSTALLER" -`) - writeExecutable(t, filepath.Join(binDir, "sudo"), `#!/bin/sh -exec "$@" -`) - writeExecutable(t, filepath.Join(binDir, "id"), `#!/bin/sh -if [ "$1" = "-u" ]; then - printf '%s\n' "$FAKE_ID_UID" -else - exec /usr/bin/id "$@" -fi -`) - - command := BuildProxmoxAgentInstallCommand(AgentInstallCommandOptions{ - BaseURL: "https://pulse.example", - Token: "token-123", - InstallType: "pve", - IncludeInstallType: true, - }) - for _, fakeUID := range []string{"0", "1000"} { - t.Run("uid_"+fakeUID, func(t *testing.T) { - if err := os.Remove(capturePath); err != nil && !os.IsNotExist(err) { - t.Fatal(err) - } - cmd := exec.Command("bash", "-c", command) - cmd.Env = append(os.Environ(), - "PATH="+binDir+":"+os.Getenv("PATH"), - "FAKE_CAPTURE="+capturePath, - "FAKE_ID_UID="+fakeUID, - "FAKE_INSTALLER="+installerPath, - ) - if output, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("generated command failed: %v\n%s\ncommand:\n%s", err, output, command) - } - got, err := os.ReadFile(capturePath) - if err != nil { - t.Fatal(err) - } - if string(got) != "token-123" { - t.Fatalf("captured token = %q, want token-123", got) - } - }) +func runBootstrap(t *testing.T, command, token string, env []string, tty bool) ([]byte, error) { + t.Helper() + var cmd *exec.Cmd + if tty { + payload, err := json.Marshal(map[string]string{"command": command, "input": token}) + if err != nil { + t.Fatal(err) + } + cmd = exec.Command("python3", "-c", bootstrapPTYRunner) + cmd.Stdin = strings.NewReader(string(payload)) + } else { + cmd = exec.Command("sh", "-c", command) } + cmd.Env = append(os.Environ(), env...) + return cmd.CombinedOutput() } func writeExecutable(t *testing.T, path, body string) { @@ -87,3 +76,299 @@ func writeExecutable(t *testing.T, path, body string) { t.Fatal(err) } } + +func bootstrapFixture(t *testing.T, installer string) (string, []string) { + t.Helper() + root := t.TempDir() + bin := filepath.Join(root, "bin") + if err := os.Mkdir(bin, 0700); err != nil { + t.Fatal(err) + } + writeExecutable(t, filepath.Join(root, "installer.sh"), installer) + writeExecutable(t, filepath.Join(bin, "curl"), `#!/bin/sh +printf '%s\n' "$@" >> "$FAKE_ROOT/argv" +out="" +while [ "$#" -gt 0 ]; do + case "$1" in -o) out="$2"; shift 2 ;; *) shift ;; esac +done +printf %s "$out" > "$FAKE_ROOT/download-path" +cp "$FAKE_ROOT/installer.sh" "$out" +exit "${FAKE_CURL_EXIT:-0}" +`) + writeExecutable(t, filepath.Join(bin, "sudo"), `#!/bin/sh +printf '%s\n' "$@" >> "$FAKE_ROOT/argv" +if [ "$1" = "-v" ]; then exit "${FAKE_SUDO_EXIT:-0}"; fi +exec "$@" +`) + writeExecutable(t, filepath.Join(bin, "id"), `#!/bin/sh +if [ "$1" = "-u" ]; then printf '%s\n' "$FAKE_ID_UID"; else exec /usr/bin/id "$@"; fi +`) + writeExecutable(t, filepath.Join(bin, "mktemp"), `#!/bin/sh +path=$(/usr/bin/mktemp "$@") || exit 1 +printf '%s\n' "$path" >> "$FAKE_ROOT/private-directories" +printf '%s\n' "$path" +`) + return root, []string{"PATH=" + bin + ":" + os.Getenv("PATH"), "FAKE_ROOT=" + root, "FAKE_ID_UID=0", "PULSE_SETUP_TOKEN=", "PULSE_TOKEN="} +} + +const recordingInstaller = `#!/bin/bash +set -eu +printf '%s\n' "$@" >> "$FAKE_ROOT/argv" +if [[ " $* " == *" --preflight-only "* ]]; then + printf preflight > "$FAKE_ROOT/preflight" + exit "${FAKE_PREFLIGHT_EXIT:-0}" +fi +printf '%s:%s' "${PULSE_TOKEN:-}" "${PULSE_SETUP_TOKEN:-}" > "$FAKE_ROOT/secret-env" +token_file="${PULSE_SETUP_TOKEN_FILE:-}" +while [ "$#" -gt 0 ]; do + case "$1" in --token-file) token_file="$2"; shift 2 ;; *) shift ;; esac +done +[ -n "$token_file" ] +[ "$(stat -c %a "$token_file")" = 600 ] +[ "$(stat -c %a "$(dirname "$token_file")")" = 700 ] +[ "$(stat -c %u "$token_file")" = "$(stat -c %u "$(dirname "$token_file")")" ] +printf %s "$token_file" > "$FAKE_ROOT/token-path" +cat "$token_file" > "$FAKE_ROOT/captured-token" +exit "${FAKE_INSTALL_EXIT:-0}" +` + +func TestPrivateBootstrapKeepsSecretsOutOfCommands(t *testing.T) { + secret := "synthetic-secret-0123456789" + artifact := BuildSetupScriptInstallArtifact("https://pulse.example/base", "pbs", "https://pbs.example:8007", "https://pulse.example/base", false, secret, 1900000000) + if artifact.DownloadURL != artifact.URL || strings.Contains(artifact.DownloadURL, secret) { + t.Fatal("credential-bearing download") + } + for name, command := range map[string]string{ + "setup": artifact.Command, + "setup-with-env-alias": artifact.CommandWithEnv, + "setup-without-env-alias": artifact.CommandWithoutEnv, + "agent-pve": BuildProxmoxAgentInstallCommand(AgentInstallCommandOptions{BaseURL: "https://pulse.example", Token: secret, InstallType: "pve", IncludeInstallType: true}), + "agent-pbs": BuildProxmoxAgentInstallCommand(AgentInstallCommandOptions{BaseURL: "https://pulse.example", Token: secret, InstallType: "pbs", IncludeInstallType: true}), + } { + t.Run(name, func(t *testing.T) { + if strings.Contains(command, secret) || strings.ContainsAny(command, "\r\n") || strings.Contains(command, "PULSE_SETUP_TOKEN=") || strings.Contains(command, "--token '") || strings.Contains(command, "| bash") { + t.Fatal("copied command exposes a credential or executes a partial fetch") + } + if !strings.Contains(command, "read -r -s -p") || !strings.Contains(command, "sudo bash -c") || !strings.Contains(command, " -o \"$install_script\"") { + t.Fatal("missing private prompt, sudo or complete download") + } + }) + } +} + +func TestBuildProxmoxAgentInstallCommandExecutesTrustedRootAndSudoTokenBootstrap(t *testing.T) { + for _, kind := range []string{"agent", "setup"} { + for _, uid := range []string{"0", "1000"} { + t.Run(kind+"_uid_"+uid, func(t *testing.T) { + root, env := bootstrapFixture(t, recordingInstaller) + env = append(env, "FAKE_ID_UID="+uid) + token := strings.Repeat("a", 32) + command := BuildSetupScriptCommand("https://pulse.example/api/setup-script?type=pbs", token) + if kind == "agent" { + command = BuildProxmoxAgentInstallCommand(AgentInstallCommandOptions{BaseURL: "https://pulse.example", Token: token, InstallType: "pbs", IncludeInstallType: true}) + } + out, err := runBootstrap(t, command, token, env, true) + if err != nil { + t.Fatalf("bootstrap: %v\n%s", err, out) + } + captured, err := os.ReadFile(filepath.Join(root, "captured-token")) + if err != nil { + t.Fatal(err) + } + if string(captured) != token { + t.Fatal("private-file token differs") + } + args, _ := os.ReadFile(filepath.Join(root, "argv")) + secretEnv, _ := os.ReadFile(filepath.Join(root, "secret-env")) + if strings.Contains(string(args), token) || strings.Contains(string(out), token) || string(secretEnv) != ":" { + t.Fatal("token leaked into argv, environment or terminal output") + } + for _, record := range []string{"token-path", "download-path"} { + path, err := os.ReadFile(filepath.Join(root, record)) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(string(path)); !os.IsNotExist(err) { + t.Fatalf("handoff remains: %s", record) + } + if _, err := os.Stat(filepath.Dir(string(path))); !os.IsNotExist(err) { + t.Fatalf("handoff directory remains: %s", record) + } + } + _, err = os.Stat(filepath.Join(root, "preflight")) + if kind == "agent" && err != nil { + t.Fatal("agent preflight omitted") + } + if kind == "setup" && !os.IsNotExist(err) { + t.Fatal("native setup ran agent preflight") + } + }) + } + } +} + +func TestPrivateBootstrapFailsBeforeInstallAndCleansHandoffs(t *testing.T) { + for _, tc := range []struct { + name string + env string + tty bool + input string + }{ + {"no-terminal", "", false, ""}, {"blank-token", "", true, ""}, {"interrupt", "", true, "\x03"}, + {"partial-download", "FAKE_CURL_EXIT=22", false, ""}, {"wrong-installer-or-preflight", "FAKE_PREFLIGHT_EXIT=44", false, ""}, + {"sudo-refusal", "FAKE_SUDO_EXIT=1", false, ""}, {"install-failure", "FAKE_INSTALL_EXIT=9", true, strings.Repeat("b", 32)}, + } { + t.Run(tc.name, func(t *testing.T) { + root, env := bootstrapFixture(t, recordingInstaller) + env = append(env, tc.env) + if tc.name == "sudo-refusal" { + env = append(env, "FAKE_ID_UID=1000") + } + command := BuildProxmoxAgentInstallCommand(AgentInstallCommandOptions{BaseURL: "https://pulse.example", Token: "synthetic-not-in-command", InstallType: "pve", IncludeInstallType: true}) + out, err := runBootstrap(t, command, tc.input, env, tc.tty) + if err == nil { + t.Fatalf("failure appeared successful: %s", out) + } + if tc.input != "" && tc.input != "\x03" && strings.Contains(string(out), tc.input) { + t.Fatal("token echoed on failure") + } + _, err = os.Stat(filepath.Join(root, "captured-token")) + if tc.name != "install-failure" && !os.IsNotExist(err) { + t.Fatal("installer ran before prerequisite succeeded") + } + directories, _ := os.ReadFile(filepath.Join(root, "private-directories")) + for _, path := range strings.Fields(string(directories)) { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatal("failure left a private bootstrap directory") + } + } + + for _, record := range []string{"download-path", "token-path"} { + p, err := os.ReadFile(filepath.Join(root, record)) + if os.IsNotExist(err) { + continue + } + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Dir(string(p))); !os.IsNotExist(err) { + t.Fatalf("failure left %s directory", record) + } + } + }) + } +} + +func TestSetupPrivateFileRejectsUnsafeInputsBeforeNativeMutation(t *testing.T) { + for _, tc := range []string{"valid", "public-mode", "public-parent", "symlink", "oversized", "malformed", "missing", "directory"} { + t.Run(tc, func(t *testing.T) { + root := t.TempDir() + if err := os.Chmod(root, 0700); err != nil { + t.Fatal(err) + } + file := filepath.Join(root, "token") + token := strings.Repeat("c", 32) + body := token + if tc == "oversized" { + body = strings.Repeat("c", 4097) + } + if tc == "malformed" { + body = "do not echo this credential" + } + if err := os.WriteFile(file, []byte(body), 0600); err != nil { + t.Fatal(err) + } + switch tc { + case "public-mode": + os.Chmod(file, 0644) + case "public-parent": + os.Chmod(root, 0755) + case "symlink": + file = filepath.Join(root, "link") + if err := os.Symlink(filepath.Join(root, "token"), file); err != nil { + t.Fatal(err) + } + case "missing": + file = filepath.Join(root, "absent") + case "directory": + file = root + } + cmd := exec.Command("bash", "-c", setupTokenFilePrelude+`printf %s "$PULSE_SETUP_TOKEN" > "$FAKE_CAPTURE"`) + capture := filepath.Join(root, "native-mutation-marker") + cmd.Env = append(os.Environ(), "PULSE_SETUP_TOKEN_FILE="+file, "FAKE_CAPTURE="+capture) + out, err := cmd.CombinedOutput() + if strings.Contains(string(out), body) { + t.Fatal("invalid secret echoed") + } + if tc == "valid" { + if err != nil { + t.Fatalf("valid file: %v %s", err, out) + } + data, e := os.ReadFile(capture) + if e != nil || string(data) != token { + t.Fatal("private input lost") + } + } else { + if err == nil { + t.Fatal("unsafe file accepted") + } + if _, e := os.Stat(capture); !os.IsNotExist(e) { + t.Fatal("native action followed unsafe input") + } + } + }) + } +} + +func TestPrivateBootstrapShellQuotesDataWithoutPuttingTokenInSource(t *testing.T) { + root, env := bootstrapFixture(t, recordingInstaller) + injected := filepath.Join(root, "must-not-exist") + base := `https://pulse.example/' ; touch ` + injected + ` ; #` + token := `tok'en-with-shell-' ; touch ` + injected + command := BuildProxmoxAgentInstallCommand(AgentInstallCommandOptions{BaseURL: base, Token: token, InstallType: "pbs", IncludeInstallType: true}) + if strings.Contains(command, token) { + t.Fatal("credential interpolated") + } + out, err := runBootstrap(t, command, token, env, true) + if err != nil { + t.Fatalf("escaped bootstrap failed: %v %s", err, out) + } + args, err := os.ReadFile(filepath.Join(root, "argv")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(args), "--url\n"+base+"\n") || !strings.Contains(string(args), "--proxmox-type\npbs\n") { + t.Fatal("quoted arguments changed") + } + if _, err := os.Stat(injected); !os.IsNotExist(err) { + t.Fatal("argument was executed as shell source") + } +} + +func TestPrivateBootstrapInteractiveHistoryContainsCommandButNotToken(t *testing.T) { + root, env := bootstrapFixture(t, recordingInstaller) + token := strings.Repeat("d", 32) + command := BuildProxmoxAgentInstallCommand(AgentInstallCommandOptions{BaseURL: "https://pulse.example", Token: token, InstallType: "pve", IncludeInstallType: true}) + history := filepath.Join(root, "shell-history") + payload, err := json.Marshal(map[string]string{"command": command, "input": token, "history": history}) + if err != nil { + t.Fatal(err) + } + cmd := exec.Command("python3", "-c", bootstrapPTYRunner) + cmd.Stdin = strings.NewReader(string(payload)) + cmd.Env = append(os.Environ(), env...) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("interactive bootstrap: %v %s", err, out) + } + hist, err := os.ReadFile(history) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(hist), command) { + t.Fatal("history control did not record the copied command") + } + if strings.Contains(string(hist), token) || strings.Contains(string(out), token) { + t.Fatal("silent input entered shell history or terminal output") + } +} diff --git a/internal/api/configapi/private_bootstrap_pipeline_test.go b/internal/api/configapi/private_bootstrap_pipeline_test.go new file mode 100644 index 000000000..1ecb07cba --- /dev/null +++ b/internal/api/configapi/private_bootstrap_pipeline_test.go @@ -0,0 +1,144 @@ +package configapi + +import ( + "bytes" + "encoding/json" + "encoding/pem" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" +) + +// Real TLS/curl, issuance, downloaded native PBS script, private handoff and +// registration. Only the appliance CLI and EUID guard are modeled: Core's +// ordinary proof guest is deliberately not a system-install/root capability. +func TestPrivatePBSBootstrapPipelineTLSAndRegistration(t *testing.T) { + curlPath, err := exec.LookPath("curl") + if err != nil { + t.Fatal("curl required for the TLS bootstrap acceptance test") + } + for _, status := range []int{200, 401, 403, 503} { + t.Run(http.StatusText(status), func(t *testing.T) { + root, env := bootstrapFixture(t, "") + cfg := &config.Config{DataPath: t.TempDir()} + handler := newTestConfigHandlers(t, cfg) + var registration AutoRegisterRequest + var registrationMu sync.Mutex + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/setup-script" { + if r.URL.Query().Has("setup_token") { + t.Error("download URL contains setup token") + } + rr := httptest.NewRecorder() + handler.HandleSetupScript(rr, r) + if rr.Code != 200 { + t.Errorf("download status %d", rr.Code) + } + for k, values := range rr.Header() { + w.Header()[k] = values + } + // Preserve every operation except the unavailable actual root + // identity check. All PBS CLI calls below are local fixtures. + script := strings.Replace(rr.Body.String(), `if [ "$EUID" -ne 0 ]; then`, `if false; then`, 1) + w.Write([]byte(script)) + return + } + if r.URL.Path == "/api/auto-register" { + var input AutoRegisterRequest + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + t.Error(err) + } + registrationMu.Lock() + registration = input + registrationMu.Unlock() + body, _ := json.Marshal(input) + r.Body = http.NoBody + if status != 200 { + w.WriteHeader(status) + // An adverse server response must not turn the request's + // credential into diagnostic output on the terminal. + w.Write(body) + return + } + r.Body = io.NopCloser(bytes.NewReader(body)) + handler.HandleAutoRegister(w, r) + return + } + w.WriteHeader(404) + })) + defer server.Close() + cfg.PublicURL = server.URL + ca := filepath.Join(root, "ca.pem") + if err := os.WriteFile(ca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}), 0600); err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodPost, "/api/setup-script-url", strings.NewReader(`{"type":"pbs","host":"`+server.URL+`"}`)) + rr := httptest.NewRecorder() + handler.HandleSetupScriptURL(rr, request) + if rr.Code != 200 { + t.Fatalf("issuance status %d", rr.Code) + } + var artifact SetupScriptInstallArtifact + if err := json.Unmarshal(rr.Body.Bytes(), &artifact); err != nil { + t.Fatal(err) + } + if strings.Contains(artifact.Command, artifact.SetupToken) || artifact.DownloadURL != artifact.URL { + t.Fatal("unsafe issued artifact") + } + // Record only process argument/secret-export presence; never dump + // the environment, credential file, or registration body to logs. + writeExecutable(t, filepath.Join(root, "bin", "curl"), "#!/bin/sh\nprintf '%s\\n' \"$@\" >> \"$FAKE_ROOT/argv\"\nexec "+posixShellQuote(curlPath)+" \"$@\"\n") + writeExecutable(t, filepath.Join(root, "bin", "proxmox-backup-manager"), `#!/bin/sh +printf '%s\n' "$@" >> "$FAKE_ROOT/native-argv" +case "$*" in + "user generate-token "*) printf '%s\n' '{"value":"synthetic-native-PBS-secret"}' ;; +esac +`) + if status == 200 { + out, err := runBootstrap(t, artifact.Command, "", env, false) + if err == nil { + t.Fatal("untrusted TLS was accepted") + } + if _, err := os.Stat(filepath.Join(root, "native-argv")); !os.IsNotExist(err) { + t.Fatal("native mutation preceded trusted download") + } + if strings.Contains(string(out), artifact.SetupToken) { + t.Fatal("setup credential leaked on TLS failure") + } + } + env = append(env, "CURL_CA_BUNDLE="+ca) + out, err := runBootstrap(t, artifact.Command, artifact.SetupToken, env, true) + if status == 200 && err != nil { + t.Fatalf("trusted bootstrap: %v\n%s", err, out) + } + if status != 200 && err == nil { + t.Fatalf("HTTP %d appeared successful", status) + } + args, _ := os.ReadFile(filepath.Join(root, "argv")) + if strings.Contains(string(args), artifact.SetupToken) || strings.Contains(string(args), "synthetic-native-PBS-secret") || strings.Contains(string(out), artifact.SetupToken) { + t.Fatal("credential leaked through args or failed-response output") + } + registrationMu.Lock() + defer registrationMu.Unlock() + config.Mu.Lock() + defer config.Mu.Unlock() + if registration.AuthToken != artifact.SetupToken || registration.TokenValue != "synthetic-native-PBS-secret" || registration.Type != "pbs" || registration.Host != server.URL { + t.Fatal("registration lost credential, host or product binding") + } + if status == 200 && len(cfg.PBSInstances) != 1 { + t.Fatal("registration did not create one PBS source") + } + if status != 200 && len(cfg.PBSInstances) != 0 { + t.Fatal("failed registration created a source") + } + }) + } +} diff --git a/internal/api/configapi/setup_script_artifact.go b/internal/api/configapi/setup_script_artifact.go index 47b8c4d87..43c1357b4 100644 --- a/internal/api/configapi/setup_script_artifact.go +++ b/internal/api/configapi/setup_script_artifact.go @@ -22,20 +22,11 @@ type SetupScriptInstallArtifact struct { type setupScriptInstallArtifact = SetupScriptInstallArtifact -func BuildSetupScriptCommand(scriptURL string, token string) string { - curlCommand := "curl -fsSL " + posixShellQuote(strings.TrimSpace(scriptURL)) + " | " - bashCommand := "bash" - sudoCommand := "sudo bash" - if trimmedToken := strings.TrimSpace(token); trimmedToken != "" { - envPrefix := "PULSE_SETUP_TOKEN=" + posixShellQuote(trimmedToken) + " " - bashCommand = envPrefix + bashCommand - sudoCommand = "sudo env " + envPrefix + "bash" - } - - return curlCommand + - `{ if [ "$(id -u)" -eq 0 ]; then ` + bashCommand + - `; elif command -v sudo >/dev/null 2>&1; then ` + sudoCommand + - `; else echo "Root privileges required. Run as root (su -) and retry." >&2; exit 1; fi; }` +// The legacy token argument is intentionally not interpolated. All command +// aliases now prompt privately; the separate SetupToken field is the reveal. +func BuildSetupScriptCommand(scriptURL string, _ string) string { + return privateBootstrapCommand(scriptURL, "-fsSL", "", + `PULSE_SETUP_TOKEN_FILE="$token_file" bash "$1";`, true, "Pulse setup token") } func BuildSetupScriptURL(baseURL string, installType string, host string, pulseURL string, backupPerms bool) string { @@ -53,20 +44,9 @@ func BuildSetupScriptURL(baseURL string, installType string, host string, pulseU return strings.TrimRight(strings.TrimSpace(baseURL), "/") + "/api/setup-script?" + query.Encode() } -func BuildSetupScriptDownloadURL(baseURL string, installType string, host string, pulseURL string, backupPerms bool, setupToken string) string { - downloadURL := BuildSetupScriptURL(baseURL, installType, host, pulseURL, backupPerms) - trimmedToken := strings.TrimSpace(setupToken) - if trimmedToken == "" { - return downloadURL - } - parsed, err := url.Parse(downloadURL) - if err != nil { - return downloadURL - } - query := parsed.Query() - query.Set("setup_token", trimmedToken) - parsed.RawQuery = query.Encode() - return parsed.String() +// Downloads contain no credential, including in the request URL or script. +func BuildSetupScriptDownloadURL(baseURL string, installType string, host string, pulseURL string, backupPerms bool, _ string) string { + return BuildSetupScriptURL(baseURL, installType, host, pulseURL, backupPerms) } func BuildSetupScriptInstallArtifact(baseURL string, installType string, host string, pulseURL string, backupPerms bool, setupToken string, expiresAt int64) SetupScriptInstallArtifact { diff --git a/internal/api/configapi/setup_script_render.go b/internal/api/configapi/setup_script_render.go index d604f165d..7a66b29d0 100644 --- a/internal/api/configapi/setup_script_render.go +++ b/internal/api/configapi/setup_script_render.go @@ -33,11 +33,54 @@ func DeriveSetupScriptServerName(serverHost string) string { return strings.Split(trimmedHost, ":")[0] } +// setupTokenFilePrelude reads only a bounded private credential, never an +// argument or exported secret. Legacy downloaded scripts with an embedded +// query token remain readable, but new artifacts do not generate such URLs. +const setupTokenFilePrelude = `set +xv +export -n PULSE_SETUP_TOKEN 2>/dev/null || true +if [ -n "${PULSE_SETUP_TOKEN_FILE:-}" ]; then + setup_token_file="$PULSE_SETUP_TOKEN_FILE" + if [ ! -f "$setup_token_file" ] || [ -L "$setup_token_file" ] || [ ! -r "$setup_token_file" ]; then + echo "Setup token file must be a readable private regular file." >&2 + exit 1 + fi + setup_token_mode=$(stat -c %a "$setup_token_file" 2>/dev/null || stat -f %Lp "$setup_token_file") + setup_token_owner=$(stat -c %u "$setup_token_file" 2>/dev/null || stat -f %u "$setup_token_file") + setup_token_parent=$(dirname -- "$setup_token_file") + if [ -L "$setup_token_parent" ] || [ "$(stat -c %a "$setup_token_parent" 2>/dev/null || stat -f %Lp "$setup_token_parent")" != "700" ] || [ "$(stat -c %u "$setup_token_parent" 2>/dev/null || stat -f %u "$setup_token_parent")" != "$EUID" ]; then + echo "Setup token file requires a private directory owned by the current user." >&2 + exit 1 + fi + setup_token_size=$(wc -c < "$setup_token_file") + if [ "$setup_token_mode" != "600" ] || [ "$setup_token_owner" != "$EUID" ] || [ "$setup_token_size" -gt 4096 ]; then + echo "Setup token file must be owned by the current user, mode 0600, and at most 4096 bytes." >&2 + exit 1 + fi + PULSE_SETUP_TOKEN=$(cat -- "$setup_token_file") + if [[ ! "$PULSE_SETUP_TOKEN" =~ ^[a-fA-F0-9]{32,128}$ ]]; then + echo "Setup token file contains an invalid setup token." >&2 + exit 1 + fi + unset setup_token_file setup_token_mode setup_token_owner setup_token_size setup_token_parent +fi +` + func RenderSetupScript(serverType string, ctx SetupScriptRenderContext) string { + var script string if strings.TrimSpace(serverType) == "pve" { - return renderPVESetupScript(ctx) + script = renderPVESetupScript(ctx) + } else { + script = renderPBSSetupScript(ctx) } - return renderPBSSetupScript(ctx) + script = strings.Replace(script, "#!/bin/bash\n", "#!/bin/bash\n"+setupTokenFilePrelude, 1) + // A rejected setup credential or failed attempted registration is not a + // successful bootstrap. PVE's explicit Audit/Repair exit stays separate. + return script + ` +if [ "${SETUP_TOKEN_INVALID:-false}" = true ] || [ "${TOKEN_READY:-false}" != true ] || { [ -n "${PULSE_SETUP_TOKEN:-}" ] && [ "${AUTO_REG_SUCCESS:-false}" != true ]; }; then + exit 1 +fi +` + } func renderPVESetupScript(ctx SetupScriptRenderContext) string { @@ -488,7 +531,7 @@ if [[ $MAIN_ACTION =~ ^(3|[Rr]|remove)$ ]]; then UNREGISTER_RC=$? if [ "$UNREGISTER_RC" -ne 0 ]; then echo " ⚠️ Pulse server teardown request failed." - echo " Response: $UNREGISTER_RESPONSE" + echo " Check the Pulse connection and retry; response details are not printed." echo " Remove the node from Pulse manually if it remains listed." elif echo "$UNREGISTER_RESPONSE" | grep -Eq '"status"[[:space:]]*:[[:space:]]*"success"'; then if echo "$UNREGISTER_RESPONSE" | grep -Eq '"removed"[[:space:]]*:[[:space:]]*true'; then @@ -498,7 +541,7 @@ if [[ $MAIN_ACTION =~ ^(3|[Rr]|remove)$ ]]; then fi else echo " ⚠️ Pulse server teardown did not confirm success." - echo " Response: $UNREGISTER_RESPONSE" + echo " Check the Pulse connection and retry; response details are not printed." echo " Remove the node from Pulse manually if it remains listed." fi else @@ -684,8 +727,8 @@ create_pve_token() { } smoke_test_pve_token() { - if SMOKE_OUTPUT=$(curl -kfsS --retry 2 --retry-delay 1 \ - -H "Authorization: PVEAPIToken=$PULSE_TOKEN_ID=$TOKEN_VALUE" \ + if SMOKE_OUTPUT=$(printf 'Authorization: PVEAPIToken=%%s=%%s\n' "$PULSE_TOKEN_ID" "$TOKEN_VALUE" | curl -kfsS --retry 2 --retry-delay 1 \ + -H @- \ "${HOST_URL%%/}/api2/json/nodes" 2>&1); then SMOKE_RC=0 else @@ -710,13 +753,13 @@ attempt_auto_registration() { if [ -t 0 ]; then printf "Pulse setup token: " if command -v stty >/dev/null 2>&1; then stty -echo; fi - IFS= read -r PULSE_SETUP_TOKEN + IFS= read -r -s PULSE_SETUP_TOKEN if command -v stty >/dev/null 2>&1; then stty echo; fi printf "\n" elif [ -c /dev/tty ] && [ -r /dev/tty ] && [ -w /dev/tty ]; then printf "Pulse setup token: " >/dev/tty if command -v stty >/dev/null 2>&1; then stty -echo /dev/null || true; fi - IFS= read -r PULSE_SETUP_TOKEN /dev/null 2>&1; then stty echo /dev/null || true; fi printf "\n" >/dev/tty fi @@ -753,13 +796,13 @@ attempt_auto_registration() { AUTO_REG_SUCCESS=false if [ "$REGISTER_RC" -ne 0 ]; then echo "⚠️ Auto-registration request failed before success confirmation." - echo " Response: $REGISTER_RESPONSE" + echo " Registration failed; check the connection and setup-token expiry in Pulse." echo "" echo "📝 Use the token details below in Pulse Settings → Infrastructure to finish registration." elif [ "$REGISTER_STATUS" = "401" ] || [ "$REGISTER_STATUS" = "403" ]; then SETUP_TOKEN_INVALID=true echo "Error: Auto-registration failed - authentication required" - echo " Response: $REGISTER_RESPONSE" + echo " Registration failed; check the connection and setup-token expiry in Pulse." echo "" echo "The provided Pulse setup token was invalid or expired" echo "Get a fresh setup token from Pulse Settings → Infrastructure and rerun this script." @@ -769,7 +812,7 @@ attempt_auto_registration() { echo "" else echo "⚠️ Auto-registration failed. Finish registration manually in Pulse Settings → Infrastructure." - echo " Response: $REGISTER_RESPONSE" + echo " Registration failed; check the connection and setup-token expiry in Pulse." echo "" echo "📝 Use the token details below in Pulse Settings → Infrastructure to finish registration." fi @@ -1682,13 +1725,13 @@ else if [ -t 0 ]; then printf "Pulse setup token: " if command -v stty >/dev/null 2>&1; then stty -echo; fi - IFS= read -r PULSE_SETUP_TOKEN + IFS= read -r -s PULSE_SETUP_TOKEN if command -v stty >/dev/null 2>&1; then stty echo; fi printf "\n" elif [ -c /dev/tty ] && [ -r /dev/tty ] && [ -w /dev/tty ]; then printf "Pulse setup token: " >/dev/tty if command -v stty >/dev/null 2>&1; then stty -echo /dev/null || true; fi - IFS= read -r PULSE_SETUP_TOKEN /dev/null 2>&1; then stty echo /dev/null || true; fi printf "\n" >/dev/tty fi @@ -1731,13 +1774,13 @@ else : elif [ "$REGISTER_RC" -ne 0 ]; then echo "⚠️ Auto-registration request failed before success confirmation." - echo " Response: $REGISTER_RESPONSE" + echo " Registration failed; check the connection and setup-token expiry in Pulse." echo "" echo "📝 Use the token details below in Pulse Settings → Infrastructure to finish registration." elif [ "$REGISTER_STATUS" = "401" ] || [ "$REGISTER_STATUS" = "403" ]; then SETUP_TOKEN_INVALID=true echo "Error: Auto-registration failed - authentication required" - echo " Response: $REGISTER_RESPONSE" + echo " Registration failed; check the connection and setup-token expiry in Pulse." echo "" echo "The provided Pulse setup token was invalid or expired" echo "Get a fresh setup token from Pulse Settings → Infrastructure and rerun this script." @@ -1746,7 +1789,7 @@ else echo "Successfully registered with Pulse monitoring." else echo "⚠️ Auto-registration failed. Finish registration manually in Pulse Settings → Infrastructure." - echo " Response: $REGISTER_RESPONSE" + echo " Registration failed; check the connection and setup-token expiry in Pulse." echo "" echo "📝 Use the token details below in Pulse Settings → Infrastructure to finish registration." fi @@ -1761,7 +1804,7 @@ proxmox-backup-manager acl update / Audit --auth-id pulse-monitor@pbs proxmox-backup-manager acl update / Audit --auth-id "$PULSE_TOKEN_ID" echo "" -echo "✅ Setup complete!" +echo "Setup result:" if [ "$AUTO_REG_SUCCESS" = true ]; then echo "Successfully registered with Pulse monitoring." echo "Data will appear in your dashboard within 10 seconds." diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 612a8fc8b..f0ad50361 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -6907,18 +6907,13 @@ func TestContract_DiagnosticsDockerPrepareTokenPreservesExplicitWorkloadOnlyMode func TestContract_SetupScriptURLCommandUsesFailFastQuotedTransport(t *testing.T) { url := "https://pulse.example.com/api/setup-script?type=pve&host=pve1.local" got := buildSetupScriptCommand(url, "token-123") - - if !strings.Contains(got, "curl -fsSL "+posixShellQuote(url)+" | ") { - t.Fatalf("setup-script command missing canonical fail-fast transport: %s", got) + for _, required := range []string{"curl -fsSL " + posixShellQuote(url) + ` -o "$install_script"`, "sudo bash -c", "PULSE_SETUP_TOKEN_FILE="} { + if !strings.Contains(got, required) { + t.Fatalf("missing secure bootstrap fragment %s", required) + } } - if !strings.Contains(got, `if [ "$(id -u)" -eq 0 ]; then PULSE_SETUP_TOKEN=`+posixShellQuote("token-123")+` bash`) { - t.Fatalf("setup-script command missing direct-root execution path: %s", got) - } - if !strings.Contains(got, `elif command -v sudo >/dev/null 2>&1; then sudo env PULSE_SETUP_TOKEN=`+posixShellQuote("token-123")+` bash`) { - t.Fatalf("setup-script command missing sudo execution path: %s", got) - } - if strings.Contains(got, "curl -sSL ") { - t.Fatalf("setup-script command preserved stale non-fail-fast curl transport: %s", got) + if strings.Contains(got, "token-123") || strings.Contains(got, "PULSE_SETUP_TOKEN=") || strings.Contains(got, "| bash") { + t.Fatal("bootstrap credential or partial script can reach the copied command") } } @@ -6942,12 +6937,10 @@ func TestContract_SetupScriptEmbedsFailFastGuidance(t *testing.T) { } script := rec.Body.String() - if !strings.Contains(script, `PULSE_BOOTSTRAP_COMMAND_WITH_ENV='curl -fsSL '"'"'http://sentinel-url:7656/api/setup-script?host=http%3A%2F%2Fsentinel-host%3A8006&pulse_url=http%3A%2F%2Fsentinel-url%3A7656&type=pve'"'"' | `) { - t.Fatalf("setup script missing canonical bootstrap command owner: %s", script) - } - if strings.Contains(script, `PULSE_BOOTSTRAP_COMMAND_WITH_ENV='curl -fsSL '"'"'http://sentinel-url:7656/api/setup-script?host=http%3A%2F%2Fsentinel-host%3A8006&pulse_url=http%3A%2F%2Fsentinel-url%3A7656&type=pve'"'"' | { if [ "$(id -u)" -eq 0 ]; then PULSE_SETUP_TOKEN=`) { - t.Fatalf("setup script bootstrap command should defer setup token to runtime hydration, got: %s", script) + if !strings.Contains(script, "PULSE_BOOTSTRAP_COMMAND_WITH_ENV="+posixShellQuote(buildSetupScriptCommand(buildSetupScriptURL("http://sentinel-url:7656", "pve", "http://sentinel-host:8006", "http://sentinel-url:7656", false), ""))) { + t.Fatal("setup script must use the shared credential-free retry command") } + if !strings.Contains(script, `echo " $PULSE_BOOTSTRAP_COMMAND_WITH_ENV"`) { t.Fatalf("setup script missing bootstrap-command retry guidance: %s", script) } @@ -10250,7 +10243,7 @@ func TestContract_ProxmoxInstallCommandIncludesInsecureForPlainHTTP(t *testing.T IncludeInstallType: true, }) - if !strings.Contains(got, "--url "+posixShellQuote("http://pulse.example.com:7655")) { + if !strings.Contains(got, "http://pulse.example.com:7655") { t.Fatalf("install command missing canonical base URL: %s", got) } if !strings.Contains(got, "--insecure") { @@ -10272,13 +10265,13 @@ func TestContract_ProxmoxInstallCommandUsesPrivilegeEscalationWrapper(t *testing if !strings.Contains(got, `if [ "$(id -u)" -eq 0 ]; then`) { t.Fatalf("install command missing root-or-sudo wrapper: %s", got) } - if !strings.Contains(got, `sudo bash -s --`) { + if !strings.Contains(got, `sudo bash -c`) { t.Fatalf("install command missing sudo fallback: %s", got) } - if !strings.Contains(got, `token_dir=$(sudo mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX)`) { + if !strings.Contains(got, `token_dir=$(mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX)`) { t.Fatalf("install command missing root-owned sudo token bootstrap: %s", got) } - if !strings.Contains(got, `rm -rf -- "$token_dir"`) { + if !strings.Contains(got, `rmdir -- "$token_dir"`) { t.Fatalf("install command missing ephemeral token cleanup: %s", got) } } @@ -10294,7 +10287,7 @@ func TestContract_OptionalAuthProxmoxInstallCommandOmitsToken(t *testing.T) { if strings.Contains(got, "--token") { t.Fatalf("optional-auth install command preserved token flag: %s", got) } - if !strings.Contains(got, "--url "+posixShellQuote("https://pulse.example.com")) { + if !strings.Contains(got, "https://pulse.example.com") { t.Fatalf("optional-auth install command missing canonical base URL: %s", got) } } @@ -10310,7 +10303,7 @@ func TestContract_ProxmoxInstallCommandNormalizesTrailingSlashBaseURL(t *testing if !strings.Contains(got, posixShellQuote("https://pulse.example.com/base/install.sh")) { t.Fatalf("install command missing normalized install script URL: %s", got) } - if !strings.Contains(got, "--url "+posixShellQuote("https://pulse.example.com/base")) { + if !strings.Contains(got, "https://pulse.example.com/base") { t.Fatalf("install command missing normalized base URL: %s", got) } if !strings.Contains(got, `--token-file "$token_file"`) { @@ -11206,40 +11199,26 @@ func TestContract_ResetFirstRunSecurityClearsEnvBackedStatus(t *testing.T) { } func TestContract_SetupScriptURLResponseJSONSnapshot(t *testing.T) { - payload := map[string]any{ - "type": "pve", - "host": "https://pve.local:8006", - "url": "https://pulse.example/api/setup-script?host=https%3A%2F%2Fpve.local%3A8006&pulse_url=https%3A%2F%2Fpulse.example&type=pve", - "downloadURL": "https://pulse.example/api/setup-script?host=https%3A%2F%2Fpve.local%3A8006&pulse_url=https%3A%2F%2Fpulse.example&setup_token=setup-token-123&type=pve", - "scriptFileName": "pulse-setup-pve.sh", - "command": "curl -fsSL 'https://pulse.example/api/setup-script?host=https%3A%2F%2Fpve.local%3A8006&pulse_url=https%3A%2F%2Fpulse.example&type=pve' | { if [ \"$(id -u)\" -eq 0 ]; then PULSE_SETUP_TOKEN='setup-token-123' bash; elif command -v sudo >/dev/null 2>&1; then sudo env PULSE_SETUP_TOKEN='setup-token-123' bash; else echo \"Root privileges required. Run as root (su -) and retry.\" >&2; exit 1; fi; }", - "commandWithEnv": "curl -fsSL 'https://pulse.example/api/setup-script?host=https%3A%2F%2Fpve.local%3A8006&pulse_url=https%3A%2F%2Fpulse.example&type=pve' | { if [ \"$(id -u)\" -eq 0 ]; then PULSE_SETUP_TOKEN='setup-token-123' bash; elif command -v sudo >/dev/null 2>&1; then sudo env PULSE_SETUP_TOKEN='setup-token-123' bash; else echo \"Root privileges required. Run as root (su -) and retry.\" >&2; exit 1; fi; }", - "commandWithoutEnv": "curl -fsSL 'https://pulse.example/api/setup-script?host=https%3A%2F%2Fpve.local%3A8006&pulse_url=https%3A%2F%2Fpulse.example&type=pve' | { if [ \"$(id -u)\" -eq 0 ]; then bash; elif command -v sudo >/dev/null 2>&1; then sudo bash; else echo \"Root privileges required. Run as root (su -) and retry.\" >&2; exit 1; fi; }", - "expires": int64(1900000000), - "setupToken": "setup-token-123", - "tokenHint": "set…123", - } - - got, err := json.Marshal(payload) + artifact := buildSetupScriptInstallArtifact("https://pulse.example", "pve", "https://pve.local:8006", "https://pulse.example", false, "setup-token-123", 1900000000) + payload, err := json.Marshal(artifact) if err != nil { - t.Fatalf("marshal setup-script-url response: %v", err) + t.Fatal(err) + } + var got map[string]any + if err := json.Unmarshal(payload, &got); err != nil { + t.Fatal(err) + } + for _, field := range []string{"command", "commandWithEnv", "commandWithoutEnv"} { + command, ok := got[field].(string) + if !ok || command != artifact.Command || strings.Contains(command, artifact.SetupToken) || !strings.Contains(command, "PULSE_SETUP_TOKEN_FILE=") { + t.Fatalf("unsafe %s", field) + } + delete(got, field) + } + want := map[string]any{"type": "pve", "host": "https://pve.local:8006", "url": artifact.URL, "downloadURL": artifact.URL, "scriptFileName": "pulse-setup-pve.sh", "expires": float64(1900000000), "setupToken": "setup-token-123", "tokenHint": "set…123"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("bootstrap envelope = %#v, want %#v", got, want) } - - const want = `{ - "command":"curl -fsSL 'https://pulse.example/api/setup-script?host=https%3A%2F%2Fpve.local%3A8006\u0026pulse_url=https%3A%2F%2Fpulse.example\u0026type=pve' | { if [ \"$(id -u)\" -eq 0 ]; then PULSE_SETUP_TOKEN='setup-token-123' bash; elif command -v sudo \u003e/dev/null 2\u003e\u00261; then sudo env PULSE_SETUP_TOKEN='setup-token-123' bash; else echo \"Root privileges required. Run as root (su -) and retry.\" \u003e\u00262; exit 1; fi; }", - "commandWithEnv":"curl -fsSL 'https://pulse.example/api/setup-script?host=https%3A%2F%2Fpve.local%3A8006\u0026pulse_url=https%3A%2F%2Fpulse.example\u0026type=pve' | { if [ \"$(id -u)\" -eq 0 ]; then PULSE_SETUP_TOKEN='setup-token-123' bash; elif command -v sudo \u003e/dev/null 2\u003e\u00261; then sudo env PULSE_SETUP_TOKEN='setup-token-123' bash; else echo \"Root privileges required. Run as root (su -) and retry.\" \u003e\u00262; exit 1; fi; }", - "commandWithoutEnv":"curl -fsSL 'https://pulse.example/api/setup-script?host=https%3A%2F%2Fpve.local%3A8006\u0026pulse_url=https%3A%2F%2Fpulse.example\u0026type=pve' | { if [ \"$(id -u)\" -eq 0 ]; then bash; elif command -v sudo \u003e/dev/null 2\u003e\u00261; then sudo bash; else echo \"Root privileges required. Run as root (su -) and retry.\" \u003e\u00262; exit 1; fi; }", - "downloadURL":"https://pulse.example/api/setup-script?host=https%3A%2F%2Fpve.local%3A8006\u0026pulse_url=https%3A%2F%2Fpulse.example\u0026setup_token=setup-token-123\u0026type=pve", - "expires":1900000000, - "host":"https://pve.local:8006", - "scriptFileName":"pulse-setup-pve.sh", - "setupToken":"setup-token-123", - "tokenHint":"set…123", - "type":"pve", - "url":"https://pulse.example/api/setup-script?host=https%3A%2F%2Fpve.local%3A8006\u0026pulse_url=https%3A%2F%2Fpulse.example\u0026type=pve" - }` - - assertJSONSnapshot(t, got, want) } func TestContract_PublicSecurityStatusIncludesDemoPresentationPolicy(t *testing.T) { diff --git a/internal/api/hosted_agent_install_command_test.go b/internal/api/hosted_agent_install_command_test.go index 6b47686bd..e212d923c 100644 --- a/internal/api/hosted_agent_install_command_test.go +++ b/internal/api/hosted_agent_install_command_test.go @@ -61,7 +61,7 @@ func TestHostedTenantAgentInstallCommand_GeneratesOrgBoundTokenAndCommand(t *tes require.Equal(t, orgID, resp.OrgID) require.Contains(t, resp.Command, "https://cloud.example.com/install.sh") - require.Contains(t, resp.Command, "printf %s "+posixShellQuote(resp.Token)+` > "$token_file"`) + require.NotContains(t, resp.Command, resp.Token) require.Contains(t, resp.Command, `--token-file "$token_file"`) require.Contains(t, resp.Command, "--proxmox-type "+posixShellQuote("pbs")) diff --git a/internal/hostagent/proxmox_setup.go b/internal/hostagent/proxmox_setup.go index d47057318..c7a382e28 100644 --- a/internal/hostagent/proxmox_setup.go +++ b/internal/hostagent/proxmox_setup.go @@ -133,6 +133,12 @@ func validateSetupScriptCommand(command string, expectedScriptURL string, setupT if !strings.Contains(trimmedCommand, `elif command -v sudo >/dev/null 2>&1; then`) { return fmt.Errorf("command missing sudo path") } + // New servers separate reveal from private input. Accept the old coherent + // artifact only for rolling upgrades against an older server; never mix + // its token-bearing URL with the new command format. + if strings.Contains(trimmedCommand, "PULSE_SETUP_TOKEN_FILE=") && !strings.Contains(trimmedCommand, setupToken) && !strings.Contains(trimmedCommand, "PULSE_SETUP_TOKEN=") { + return nil + } if tokenRequired { if !strings.Contains(trimmedCommand, "PULSE_SETUP_TOKEN=") || !strings.Contains(trimmedCommand, setupToken) { return fmt.Errorf("command missing setup token transport") @@ -1494,7 +1500,7 @@ func (p *ProxmoxSetup) fetchSetupToken(ctx context.Context, ptype proxmoxProduct parsedURL.RawQuery = query.Encode() expectedDownloadURL = parsedURL.String() } - if strings.TrimSpace(parsed.DownloadURL) != expectedDownloadURL { + if strings.TrimSpace(parsed.DownloadURL) != expectedDownloadURL && strings.TrimSpace(parsed.DownloadURL) != expectedScriptURL { return "", fmt.Errorf("setup token response downloadURL mismatch") } if strings.TrimSpace(parsed.TokenHint) == "" { @@ -1503,6 +1509,13 @@ func (p *ProxmoxSetup) fetchSetupToken(ctx context.Context, ptype proxmoxProduct if strings.TrimSpace(parsed.TokenHint) == strings.TrimSpace(parsed.SetupToken) { return "", fmt.Errorf("setup token response tokenHint must mask setupToken") } + modern := strings.TrimSpace(parsed.DownloadURL) == expectedScriptURL + for _, command := range []string{parsed.Command, parsed.CommandWithEnv, parsed.CommandWithoutEnv} { + if modern != strings.Contains(command, "PULSE_SETUP_TOKEN_FILE=") { + return "", fmt.Errorf("setup-script command and download credential transports do not match") + } + } + if err := validateSetupScriptCommand(parsed.Command, expectedScriptURL, strings.TrimSpace(parsed.SetupToken), true); err != nil { return "", fmt.Errorf("setup token response command invalid: %w", err) } diff --git a/internal/hostagent/proxmox_setup_test.go b/internal/hostagent/proxmox_setup_test.go index 84fcd2a9e..214f09e16 100644 --- a/internal/hostagent/proxmox_setup_test.go +++ b/internal/hostagent/proxmox_setup_test.go @@ -3,9 +3,11 @@ package hostagent import ( + "bytes" "context" "encoding/json" "fmt" + "io" "net" "net/http" "net/http/httptest" @@ -1910,3 +1912,47 @@ func TestRunAllFailsWhenEveryDetectedTypeFails(t *testing.T) { t.Fatalf("results = %#v, want none", results) } } + +func TestProxmoxSetupFetchTokenAcceptsCoherentPrivateOrLegacyServerTransport(t *testing.T) { + for _, product := range []string{"pve", "pbs"} { + for _, mode := range []string{"private", "legacy", "mixed"} { + t.Run(product+"_"+mode, func(t *testing.T) { + base, host, token := "https://pulse.example", "https://node.example:8006", "setup-token-123" + var artifact map[string]any + if err := json.Unmarshal([]byte(canonicalSetupScriptURLResponseJSON(base, product, host, token)), &artifact); err != nil { + t.Fatal(err) + } + if mode != "legacy" { + command := `curl -fsSL '` + artifact["url"].(string) + `' -o "$install_script"; if [ "$(id -u)" -eq 0 ]; then :; elif command -v sudo >/dev/null 2>&1; then :; fi; PULSE_SETUP_TOKEN_FILE="$token_file" bash "$install_script"` + artifact["downloadURL"] = artifact["url"] + artifact["command"] = command + artifact["commandWithEnv"] = command + artifact["commandWithoutEnv"] = command + if mode == "mixed" { + artifact["commandWithEnv"] = canonicalSetupScriptCommand(artifact["url"].(string), token) + } + } + body, err := json.Marshal(artifact) + if err != nil { + t.Fatal(err) + } + p := &ProxmoxSetup{pulseURL: base, httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.Path != "/api/setup-script-url" { + t.Errorf("unexpected endpoint %s", req.URL.Path) + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewReader(body)), Header: make(http.Header)}, nil + })}} + got, err := p.fetchSetupToken(context.Background(), proxmoxProductType(product), host) + if mode == "mixed" { + if err == nil { + t.Fatal("mixed private/legacy credential transport accepted") + } + return + } + if err != nil || got != token { + t.Fatalf("%s metadata refused: %v", mode, err) + } + }) + } + } +} diff --git a/scripts/installtests/private_bootstrap_test.go b/scripts/installtests/private_bootstrap_test.go new file mode 100644 index 000000000..10d248839 --- /dev/null +++ b/scripts/installtests/private_bootstrap_test.go @@ -0,0 +1,93 @@ +package installtests + +import ( + "encoding/json" + "net/url" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/api/configapi" +) + +// Exercise the real install.sh metadata parser with the actual current server +// envelope and coherent older-server envelopes. This does not install a server +// or execute an artifact's command string. Credentials enter Python via FD3. +func TestRootInstallerPrivateBootstrapMetadata(t *testing.T) { + source, err := os.ReadFile(filepath.Join("..", "..", "install.sh")) + if err != nil { + t.Fatal(err) + } + marker := `3<<<"$setup_response" <<'PY'` + "\n" + _, body, ok := strings.Cut(string(source), marker) + if !ok { + t.Fatal("setup parser must receive response on private FD3") + } + parser, _, ok := strings.Cut(body, "\nPY\n") + if !ok { + t.Fatal("setup parser end missing") + } + const pulseURL = "http://10.1.2.3:7655" + const host = "https://pve.example:8006" + const token = "0123456789abcdef0123456789abcdef" + for _, backup := range []bool{false, true} { + for _, transport := range []string{"modern", "legacy", "mixed"} { + t.Run(transport+"_backup_"+strconv.FormatBool(backup), func(t *testing.T) { + artifact := configapi.BuildSetupScriptInstallArtifact(pulseURL, "pve", host, pulseURL, backup, token, time.Now().Add(time.Hour).Unix()) + if transport != "modern" { + download, err := url.Parse(artifact.URL) + if err != nil { + t.Fatal(err) + } + query := download.Query() + query.Set("setup_token", token) + download.RawQuery = query.Encode() + artifact.DownloadURL = download.String() + if transport == "legacy" { + command := `curl '` + artifact.URL + `' | { if [ "$(id -u)" -eq 0 ]; then env PULSE_SETUP_TOKEN='` + token + `' bash; elif command -v sudo >/dev/null 2>&1; then sudo env PULSE_SETUP_TOKEN='` + token + `' bash; fi; }` + artifact.Command = command + artifact.CommandWithEnv = command + artifact.CommandWithoutEnv = strings.ReplaceAll(command, "env PULSE_SETUP_TOKEN='"+token+"' ", "") + } + } + data, err := json.Marshal(artifact) + if err != nil { + t.Fatal(err) + } + input := filepath.Join(t.TempDir(), "response") + if err := os.WriteFile(input, data, 0600); err != nil { + t.Fatal(err) + } + file, err := os.Open(input) + if err != nil { + t.Fatal(err) + } + defer file.Close() + cmd := exec.Command("python3", "-", pulseURL, host, strconv.FormatBool(backup)) + cmd.Stdin = strings.NewReader(parser) + cmd.ExtraFiles = []*os.File{file} + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("parser failed: %v", err) + } + fields := strings.Split(strings.TrimSuffix(string(out), "\n"), "\t") + if len(fields) != 12 { + t.Fatal("unexpected metadata fields") + } + if transport == "mixed" { + if fields[6] != "" || fields[7] != "" || fields[8] != "" { + t.Fatal("mixed transport accepted") + } + return + } + if fields[0] != token || fields[1] != "pve" || fields[2] != host || fields[3] != artifact.URL || fields[4] != artifact.DownloadURL || fields[6] != artifact.Command || fields[7] != artifact.CommandWithEnv || fields[8] != artifact.CommandWithoutEnv || fields[11] != "live" { + t.Fatal("root installer discarded valid bootstrap metadata or token") + } + }) + } + } +} diff --git a/scripts/installtests/root_install_sh_test.go b/scripts/installtests/root_install_sh_test.go index 1845d6e61..39696cf6a 100644 --- a/scripts/installtests/root_install_sh_test.go +++ b/scripts/installtests/root_install_sh_test.go @@ -583,11 +583,12 @@ func TestRootInstallScriptAutoRegisterUsesSecureContractShape(t *testing.T) { `str(expires_raw)`, `expiry_state = "live"`, `expires_int > int(time.time())`, - `expected_setup_url = f"{pulse_url}/api/setup-script?host={quote(host, safe='')}&pulse_url={quote(pulse_url, safe='')}&type=pve"`, - `expected_download_url = f"{pulse_url}/api/setup-script?host={quote(host, safe='')}&pulse_url={quote(pulse_url, safe='')}&setup_token={quote(setup_token, safe='')}&type=pve"`, + `expected_setup_url = f"{pulse_url}/api/setup-script?{backup_query}host={quote(host, safe='')}&pulse_url={quote(pulse_url, safe='')}&type=pve"`, + `expected_download_url = expected_setup_url`, + `setup_download_url not in (expected_download_url, legacy_download_url)`, `expected_script_name = "pulse-setup-pve.sh"`, `setup_url != expected_setup_url`, - `setup_download_url != expected_download_url`, + `modern = setup_download_url == expected_download_url`, `setup_script_name != expected_script_name`, `not setup_command`, `not setup_command_with_env`, @@ -596,8 +597,8 @@ func TestRootInstallScriptAutoRegisterUsesSecureContractShape(t *testing.T) { `if not _value or expected_setup_url not in _value:`, `'if [ "$(id -u)" -eq 0 ]; then' not in _value`, `'elif command -v sudo >/dev/null 2>&1; then' not in _value`, - `if "PULSE_SETUP_TOKEN=" not in _value or setup_token not in _value:`, - `elif "PULSE_SETUP_TOKEN=" in _value or setup_token in _value:`, + `valid = "PULSE_SETUP_TOKEN_FILE=" in _value and "PULSE_SETUP_TOKEN=" not in _value and setup_token not in _value`, + `valid = ("PULSE_SETUP_TOKEN=" in _value and setup_token in _value) if _requires_token else ("PULSE_SETUP_TOKEN=" not in _value and setup_token not in _value)`, `not token_hint or token_hint == setup_token`, `[[ "$setup_type" != "pve" ]]`, `[[ "$setup_host" != "$normalized_host_url" ]]`, @@ -609,7 +610,8 @@ func TestRootInstallScriptAutoRegisterUsesSecureContractShape(t *testing.T) { `[[ -z "$setup_command_without_env" ]]`, `[[ -z "$setup_token_hint" ]]`, `[[ "$setup_expiry_state" != "live" ]]`, - `host, token_id, token_value, server_name, setup_token = sys.argv[1:]`, + `host, token_id, server_name = sys.argv[1:]`, + `token_value, setup_token = __import__("os").fdopen(3).read().splitlines()`, `"tokenId": token_id`, `"tokenValue": token_value`, `"authToken": setup_token`, @@ -625,7 +627,7 @@ func TestRootInstallScriptAutoRegisterUsesSecureContractShape(t *testing.T) { `[[ "$register_status" != "success" ]] || [[ "$register_action" != "use_token" ]] || [[ "$register_type" != "pve" ]] || [[ "$register_source" != "script" ]]`, `AUTO_NODE_REGISTERED_NAME="$register_node_name"`, `curl --retry 3 --retry-delay 2 -fsS -X POST "$pulse_url/api/setup-script-url" -H "Content-Type: application/json" -d "$setup_payload"`, - `curl --retry 3 --retry-delay 2 -fsS -X POST "$pulse_url/api/auto-register" -H "Content-Type: application/json" -d "$register_payload"`, + `printf %s "$register_payload" | curl --retry 3 --retry-delay 2 -fsS -X POST "$pulse_url/api/auto-register" -H "Content-Type: application/json" -d @-`, `token_output=$(pveum user token add pulse-monitor@pve "$token_name" --privsep 1 2>&1)`, `pveum aclmod / -token "$token_id" -role PVEAuditor`, `pveum aclmod / -token "$token_id" -role PulseMonitor`, @@ -1200,7 +1202,7 @@ func TestRootInstallAutoRegisterSmokeTestsCreatedTokenBeforeRegistration(t *test for _, needle := range []string{ `smoke_test_pve_auto_register_token() {`, - `curl --retry 2 --retry-delay 1 -kfsS -H "Authorization: PVEAPIToken=${token_id}=${token_value}" "${host_url%/}/api2/json/nodes"`, + `printf 'Authorization: PVEAPIToken=%s=%s\n' "$token_id" "$token_value" | curl --retry 2 --retry-delay 1 -kfsS -H @- "${host_url%/}/api2/json/nodes"`, `AUTO_NODE_REGISTER_ERROR="token smoke check failed"`, `smoke_test_pve_auto_register_token "$normalized_host_url" "$token_id" "$token_value"`, } { @@ -1210,7 +1212,7 @@ func TestRootInstallAutoRegisterSmokeTestsCreatedTokenBeforeRegistration(t *test } smokeCallIdx := strings.Index(script, `smoke_test_pve_auto_register_token "$normalized_host_url" "$token_id" "$token_value"`) - registerIdx := strings.Index(script, `curl --retry 3 --retry-delay 2 -fsS -X POST "$pulse_url/api/auto-register" -H "Content-Type: application/json" -d "$register_payload"`) + registerIdx := strings.Index(script, `curl --retry 3 --retry-delay 2 -fsS -X POST "$pulse_url/api/auto-register" -H "Content-Type: application/json" -d @-`) if smokeCallIdx < 0 || registerIdx < 0 || smokeCallIdx > registerIdx { t.Fatalf("expected token smoke check to run before /api/auto-register (smoke=%d register=%d)", smokeCallIdx, registerIdx) }