From d707df9577ddbf9b4b7ba7c1c055131318bd66e1 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:30:41 +0100 Subject: [PATCH 1/2] Make lockout and incident troubleshooting safe to follow Remove the obsolete inline recovery-token curl recipe: recovery sessions are client-bound, so it neither unlocks a separate browser nor needs a credential copied into command history. Point to deployment-specific bootstrap recovery and give symptom-specific, reviewed and sanitized incident reporting steps without requiring dangerous repeats. Contract-Neutral: Documentation and verification only; clarifies existing browser-bound recovery and sanitized diagnostics without changing authentication, API, schema or runtime behaviour. Change-source: pulse-maintainer --- docs/TROUBLESHOOTING.md | 55 ++++++++---- .../docs-fragment-navigation.tsx | 1 + .../public/docs/TROUBLESHOOTING.md | 55 ++++++++---- .../src/utils/__tests__/docsLinks.test.ts | 21 +++++ scripts/check-community-troubleshooting.cjs | 86 +++++++++++++++++++ 5 files changed, 188 insertions(+), 30 deletions(-) create mode 100644 scripts/check-community-troubleshooting.cjs diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index c20b71a4d5..0882611df1 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -309,25 +309,50 @@ check, report the Pulse version and displayed check time/status separately; API success alone does not confirm the Pulse display has recovered. ### Recovery Mode -If you are completely locked out, you can trigger a recovery token from localhost: -```bash -curl -X POST http://localhost:7655/api/security/recovery \ - -d '{"action":"generate_token","duration":30}' -``` -Use the returned token in `X-Recovery-Token` when calling `/api/security/recovery` to enable or disable local-only auth bypass (`disable_auth` / `enable_auth`). Token generation is localhost-only. -Example (enable recovery mode): -```bash -curl -X POST http://localhost:7655/api/security/recovery \ - -H "X-Recovery-Token: " \ - -d '{"action":"disable_auth"}' -``` +For a forgotten local password, follow [I forgot my password](#i-forgot-my-password) +above, using the steps for your deployment. Enter the host-only bootstrap token +in that instance's setup screen; do not paste it into a command or a report. +For OIDC, SAML or proxy login, use the identity-provider or administrator path +described there instead. + +The advanced `/api/security/recovery` API creates a **browser-bound recovery +session**, not a server-wide authentication bypass. Despite its legacy name, +`disable_auth` does not disable authentication for other clients. Recovery +sessions work only over direct loopback requests; remote and reverse-proxy +requests cannot use them. A successful curl response does not unlock a separate +browser: the session cookie belongs to the client that made the request. +`enable_auth` clears that recovery session; it does not reset a password. + +Do not transfer recovery cookies between clients or paste recovery tokens into +command arguments, URLs, screenshots or GitHub threads. The password-reset steps +above avoid that credential-handling detour. --- ## 🆘 Getting Help If you're still stuck: -1. **Check Logs**: `journalctl -u pulse -n 100` or `docker logs --tail 100 pulse`. -2. **Check Version**: `curl http://localhost:7655/api/version`. -3. **Open Issue**: Report on [GitHub Issues](https://github.com/rcourtman/Pulse/issues) with your logs and version info. + +1. **Keep the original evidence**: note what you did, when it happened and the + exact error. Do not repeat an update, outage or notification storm merely to + reproduce it. A failed update banner does not prove the action left the + target unchanged; check its current state before another attempt. +2. **Identify the affected version**: give the running Pulse and relevant agent + versions, not just the version before an upgrade. For Docker, include the + running image tag or digest. If installation never started Pulse, give the + attempted release and public installer/helper source, or say "unknown". +3. **Choose relevant, safe evidence**: if Pulse is running and collection is + safe, use **Settings → Diagnostics → Export for GitHub (sanitized)** for + connection or data failures. For a visual problem, a screenshot or the exact + error may be enough. If logs are needed, inspect a bounded local excerpt + (`journalctl -u pulse -n 100 --no-pager` or `docker logs --tail 100 pulse`), + not a full configuration or data-directory upload. +4. **Review before posting**: even a sanitized export or screenshot can contain + identifying details. Remove credentials, session cookies, webhook URLs and + private host, network or personal information. Never post bootstrap/recovery + tokens, `.env` files, private keys or an unsanitized export. +5. **Use the appropriate thread**: [GitHub Issues](https://github.com/rcourtman/Pulse/issues) + for a bug, or [Discussions](https://github.com/rcourtman/Pulse/discussions) for + a setup question. Add new evidence to an existing matching report rather + than opening a duplicate. Do not refile information you have already supplied. diff --git a/frontend-modern/browser-tests/docs-fragment-navigation.tsx b/frontend-modern/browser-tests/docs-fragment-navigation.tsx index 2f929f78e7..5a9d65b9fb 100644 --- a/frontend-modern/browser-tests/docs-fragment-navigation.tsx +++ b/frontend-modern/browser-tests/docs-fragment-navigation.tsx @@ -20,6 +20,7 @@ const targets: Record = { reload: '/docs/API#resource-maintenance-and-operator-state', malformed: '/docs/API#%invalid', missing: '/docs/API#does-not-exist', + troubleshooting: '/docs/TROUBLESHOOTING#recovery-mode', }; window.history.replaceState({}, '', targets[scenario] ?? targets.plain); diff --git a/frontend-modern/public/docs/TROUBLESHOOTING.md b/frontend-modern/public/docs/TROUBLESHOOTING.md index c20b71a4d5..0882611df1 100644 --- a/frontend-modern/public/docs/TROUBLESHOOTING.md +++ b/frontend-modern/public/docs/TROUBLESHOOTING.md @@ -309,25 +309,50 @@ check, report the Pulse version and displayed check time/status separately; API success alone does not confirm the Pulse display has recovered. ### Recovery Mode -If you are completely locked out, you can trigger a recovery token from localhost: -```bash -curl -X POST http://localhost:7655/api/security/recovery \ - -d '{"action":"generate_token","duration":30}' -``` -Use the returned token in `X-Recovery-Token` when calling `/api/security/recovery` to enable or disable local-only auth bypass (`disable_auth` / `enable_auth`). Token generation is localhost-only. -Example (enable recovery mode): -```bash -curl -X POST http://localhost:7655/api/security/recovery \ - -H "X-Recovery-Token: " \ - -d '{"action":"disable_auth"}' -``` +For a forgotten local password, follow [I forgot my password](#i-forgot-my-password) +above, using the steps for your deployment. Enter the host-only bootstrap token +in that instance's setup screen; do not paste it into a command or a report. +For OIDC, SAML or proxy login, use the identity-provider or administrator path +described there instead. + +The advanced `/api/security/recovery` API creates a **browser-bound recovery +session**, not a server-wide authentication bypass. Despite its legacy name, +`disable_auth` does not disable authentication for other clients. Recovery +sessions work only over direct loopback requests; remote and reverse-proxy +requests cannot use them. A successful curl response does not unlock a separate +browser: the session cookie belongs to the client that made the request. +`enable_auth` clears that recovery session; it does not reset a password. + +Do not transfer recovery cookies between clients or paste recovery tokens into +command arguments, URLs, screenshots or GitHub threads. The password-reset steps +above avoid that credential-handling detour. --- ## 🆘 Getting Help If you're still stuck: -1. **Check Logs**: `journalctl -u pulse -n 100` or `docker logs --tail 100 pulse`. -2. **Check Version**: `curl http://localhost:7655/api/version`. -3. **Open Issue**: Report on [GitHub Issues](https://github.com/rcourtman/Pulse/issues) with your logs and version info. + +1. **Keep the original evidence**: note what you did, when it happened and the + exact error. Do not repeat an update, outage or notification storm merely to + reproduce it. A failed update banner does not prove the action left the + target unchanged; check its current state before another attempt. +2. **Identify the affected version**: give the running Pulse and relevant agent + versions, not just the version before an upgrade. For Docker, include the + running image tag or digest. If installation never started Pulse, give the + attempted release and public installer/helper source, or say "unknown". +3. **Choose relevant, safe evidence**: if Pulse is running and collection is + safe, use **Settings → Diagnostics → Export for GitHub (sanitized)** for + connection or data failures. For a visual problem, a screenshot or the exact + error may be enough. If logs are needed, inspect a bounded local excerpt + (`journalctl -u pulse -n 100 --no-pager` or `docker logs --tail 100 pulse`), + not a full configuration or data-directory upload. +4. **Review before posting**: even a sanitized export or screenshot can contain + identifying details. Remove credentials, session cookies, webhook URLs and + private host, network or personal information. Never post bootstrap/recovery + tokens, `.env` files, private keys or an unsanitized export. +5. **Use the appropriate thread**: [GitHub Issues](https://github.com/rcourtman/Pulse/issues) + for a bug, or [Discussions](https://github.com/rcourtman/Pulse/discussions) for + a setup question. Add new evidence to an existing matching report rather + than opening a duplicate. Do not refile information you have already supplied. diff --git a/frontend-modern/src/utils/__tests__/docsLinks.test.ts b/frontend-modern/src/utils/__tests__/docsLinks.test.ts index 86f70f2d23..ffe039997f 100644 --- a/frontend-modern/src/utils/__tests__/docsLinks.test.ts +++ b/frontend-modern/src/utils/__tests__/docsLinks.test.ts @@ -131,6 +131,27 @@ describe('docsLinks', () => { } }); + it('keeps lockout and incident reporting guidance safe for the affected client', () => { + const troubleshooting = readFileSync( + path.join(repoRoot, 'docs', 'TROUBLESHOOTING.md'), + 'utf8', + ); + const recovery = troubleshooting.split('### Recovery Mode')[1].split('\n---')[0]; + expect(recovery).toContain('[I forgot my password](#i-forgot-my-password)'); + expect(recovery).toContain('browser-bound recovery'); + expect(recovery).toContain('A successful curl response does not unlock a separate'); + expect(recovery).toContain('remote and reverse-proxy'); + expect(recovery).not.toContain('generate_token'); + expect(recovery).not.toMatch(/```(?:bash|sh)[\s\S]*?X-Recovery-Token/); + + const help = troubleshooting.split('## 🆘 Getting Help')[1]; + expect(help).toContain('Do not repeat an update, outage or notification storm'); + expect(help).toContain('Export for GitHub (sanitized)'); + expect(help).toContain('Review before posting'); + expect(help).toContain('session cookies, webhook URLs'); + expect(help).toContain('If installation never started Pulse'); + }); + it('ships a bounded maintenance example separately from incident snoozing', () => { const apiReference = readFileSync(path.join(repoRoot, 'docs', 'API.md'), 'utf8'); const maintenance = apiReference diff --git a/scripts/check-community-troubleshooting.cjs b/scripts/check-community-troubleshooting.cjs new file mode 100644 index 0000000000..c8046cf560 --- /dev/null +++ b/scripts/check-community-troubleshooting.cjs @@ -0,0 +1,86 @@ +// Render the real shipped troubleshooting guide with the production Docs page. +// Run from the assigned workspace root: +// pulse-worker-browser scripts/check-community-troubleshooting.cjs +const fs = require('node:fs'); +const path = require('node:path'); +const { chromium } = require('playwright'); + +const root = process.cwd(); +const frontend = path.join(root, 'frontend-modern'); +const output = path.join(root, 'tmp', 'community-troubleshooting'); +const port = 5198; +const observations = []; +const errors = []; + +function check(condition, message) { + observations.push({ passed: condition, message }); + if (!condition) throw new Error(message); +} + +(async () => { + fs.mkdirSync(output, { recursive: true }); + const { createServer } = await import( + path.join(frontend, 'node_modules', 'vite', 'dist', 'node', 'index.js') + ); + const server = await createServer({ + root: frontend, + configFile: path.join(frontend, 'vite.config.ts'), + server: { host: '127.0.0.1', port, strictPort: true }, + }); + let browser; + let result; + try { + await server.listen(); + browser = await chromium.launch({ + headless: true, channel: 'chromium', args: ['--no-sandbox'], + }); + const page = await browser.newPage(); + page.on('pageerror', (error) => errors.push(error.message)); + page.on('response', (response) => { + if (response.status() >= 400) errors.push(`${response.status()} ${response.url()}`); + }); + // Only app-runtime auth probes are mocked; the Markdown asset is real. + await page.route(new RegExp(`^http://127\\.0\\.0\\.1:${port}/api/`), (route) => + route.fulfill({ + status: 200, contentType: 'application/json', + body: JSON.stringify({ hasAuthentication: false }), + }), + ); + + for (const [size, width, height] of [['desktop', 1440, 900], ['phone', 390, 844]]) { + await page.setViewportSize({ width, height }); + await page.goto(`http://127.0.0.1:${port}/browser-tests/docs-fragment-navigation.html?scenario=troubleshooting`); + await page.waitForFunction(() => document.activeElement?.id === 'recovery-mode'); + const article = page.locator('article'); + const text = await article.innerText(); + check(text.includes('browser-bound recovery session'), `${size}: browser-bound recovery is explained`); + check(text.includes('A successful curl response does not unlock a separate browser'), `${size}: curl is not presented as a browser unlock`); + check(!(await article.locator('pre').allTextContents()).some((code) => code.includes('X-Recovery-Token') || code.includes('generate_token')), `${size}: no recovery credential command is offered`); + check(text.includes('Export for GitHub (sanitized)') && text.includes('Review before posting'), `${size}: safe reporting evidence is visible`); + check(text.includes('Do not repeat an update, outage or notification storm'), `${size}: unsafe reproduction is not required`); + check(!(await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth)), `${size}: no horizontal page overflow`); + await page.screenshot({ path: path.join(output, `${size}-recovery.png`) }); + + // The supported reset link must work with the real router and heading ID. + await article.getByRole('link', { name: 'I forgot my password', exact: true }).click(); + await page.waitForFunction(() => document.activeElement?.id === 'i-forgot-my-password'); + check((await page.locator('#i-forgot-my-password').innerText()) === 'I forgot my password', `${size}: reset link reaches the deployment-specific steps`); + await article.getByText('Keep the original evidence', { exact: true }).scrollIntoViewIfNeeded(); + await page.screenshot({ path: path.join(output, `${size}-help.png`) }); + } + check(errors.length === 0, 'no page errors or failed HTTP responses'); + result = { + result: 'passed', observations, errors, chromium: browser.version(), + playwright: require('playwright/package.json').version, + sourceAsset: 'frontend-modern/public/docs/TROUBLESHOOTING.md', + }; + } catch (error) { + result = { result: 'failed', observations, errors, error: error.message }; + } finally { + if (browser) await browser.close(); + await server.close(); + } + fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(result, null, 2)); + console.log(JSON.stringify(result, null, 2)); + process.exitCode = result.result === 'passed' ? 0 : 1; +})(); From 5fe29c558d146664320ff8beda49f4a36613737b Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:33:11 +0100 Subject: [PATCH 2/2] Run troubleshooting browser proof with frontend styling Resolve Tailwind's relative config from the frontend module before starting Vite, as the existing Docs fixture runner does. Keep the initial overflow failure in the lane receipts and expose the privacy-review step in the phone screenshot. Contract-Neutral: Browser verification harness only; no product, API, schema or runtime change. Change-source: pulse-maintainer --- scripts/check-community-troubleshooting.cjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/check-community-troubleshooting.cjs b/scripts/check-community-troubleshooting.cjs index c8046cf560..447e8efac0 100644 --- a/scripts/check-community-troubleshooting.cjs +++ b/scripts/check-community-troubleshooting.cjs @@ -19,6 +19,8 @@ function check(condition, message) { (async () => { fs.mkdirSync(output, { recursive: true }); + // Tailwind resolves its config/content globs from the frontend working dir. + process.chdir(frontend); const { createServer } = await import( path.join(frontend, 'node_modules', 'vite', 'dist', 'node', 'index.js') ); @@ -65,7 +67,7 @@ function check(condition, message) { await article.getByRole('link', { name: 'I forgot my password', exact: true }).click(); await page.waitForFunction(() => document.activeElement?.id === 'i-forgot-my-password'); check((await page.locator('#i-forgot-my-password').innerText()) === 'I forgot my password', `${size}: reset link reaches the deployment-specific steps`); - await article.getByText('Keep the original evidence', { exact: true }).scrollIntoViewIfNeeded(); + await article.getByText(size === 'phone' ? 'Review before posting' : 'Keep the original evidence', { exact: true }).scrollIntoViewIfNeeded(); await page.screenshot({ path: path.join(output, `${size}-help.png`) }); } check(errors.length === 0, 'no page errors or failed HTTP responses');