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..447e8efac0 --- /dev/null +++ b/scripts/check-community-troubleshooting.cjs @@ -0,0 +1,88 @@ +// 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 }); + // 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') + ); + 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(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'); + 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; +})();