diff --git a/docs/DOCKER.md b/docs/DOCKER.md index c87e2e9d0f..1398425542 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -270,13 +270,11 @@ Pulse can start, stop, and restart Docker / Podman containers directly from the ## 🛠️ Troubleshooting - **Forgot Password?** - ```bash - docker exec pulse rm /data/.env - docker restart pulse - # Access UI again. Pulse will require a bootstrap token for setup. - # Get it with: - docker exec pulse /app/pulse bootstrap-token - ``` + Follow the [password recovery guide](TROUBLESHOOTING.md#i-forgot-my-password). + Update the active credential source; do not delete `.env`, remove the data + volume or repeat setup. A deployment-supplied password overrides the generated + file, and changing Docker's managed environment needs a recreate/redeploy, + not just a restart. SSO accounts and temporary lockouts have separate paths. - **Logs** ```bash diff --git a/docs/FAQ.md b/docs/FAQ.md index 2786c6e5af..e5d221ca21 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -118,30 +118,13 @@ discovery are disabled by default. See trust boundary and rollout checklist. ### I forgot my password. How do I reset it? -**Docker**: -```bash -docker exec pulse rm /data/.env -docker restart pulse -# Access UI again. Pulse will require a bootstrap token for setup. -# Get it with: -docker exec pulse /app/pulse bootstrap-token -``` -**Systemd**: -Delete `/etc/pulse/.env` and restart the service. Pulse will require a bootstrap token for setup: - -```bash -sudo pulse bootstrap-token -``` -**Proxmox LXC** (installed from the Proxmox shell): -Pulse runs inside the container, so run the same steps through `pct exec` on the Proxmox host. The binary needs its absolute path here, because `pct exec` runs with `PATH=/sbin:/bin:/usr/sbin:/usr/bin` and that does not include `/usr/local/bin`: - -```bash -pct exec -- rm /etc/pulse/.env -pct exec -- systemctl restart pulse -pct exec -- /usr/local/bin/pulse bootstrap-token -``` - -If you only missed the token during a fresh install (no password set yet), skip the first two commands and just read it back with the last one. +Use the [password recovery guide](TROUBLESHOOTING.md#i-forgot-my-password). +For the local administrator, update only the password in its active credential +source, preserving the username, tokens, configuration and data; deleting `.env` +is not a universal reset. The guide covers Docker, systemd and Proxmox LXC, +including deployment overrides, private backups and verification. SSO login, +temporary lockouts and a fresh install's missed bootstrap token need different +steps; do not reset a working installation to retrieve that token. ### How do I enable HTTPS? Set `HTTPS_ENABLED=true` and provide `TLS_CERT_FILE` and `TLS_KEY_FILE` environment variables. See [Configuration](CONFIGURATION.md#-https--tls). diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index f7f21b286a..2ff1a7b0eb 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -7,8 +7,9 @@ Pulse does not provide an email password-reset flow. Choose the path that matches how this self-hosted instance authenticates: -- **Local Pulse username and password**: recovery requires shell access to the - Pulse host or container. Follow the deployment-specific steps below. +- **Local Pulse administrator username and password**: recovery requires + authorised access to the host or deployment's credential source. Follow the + steps below; keep the existing username. - **OIDC, SAML, or proxy authentication**: contact the identity-provider or Pulse administrator. If the administrator deliberately kept local login as a fallback, they can open the Pulse URL with `?show_local=true`; this only @@ -17,45 +18,81 @@ matches how this self-hosted instance authenticates: administrator can use the lockout reset in Pulse; password recovery is not required. -The local recovery steps remove only the Pulse-generated authentication file. -After restart, Pulse still requires the host-only bootstrap token before it -will accept replacement credentials. If `PULSE_AUTH_USER` or `PULSE_AUTH_PASS` -is supplied by Docker Compose, Kubernetes, systemd, or another deployment -manager, update that deployment configuration instead; its environment values -override the generated file. +#### Recover the existing local administrator login -**Docker**: +Do not delete `.env` or repeat first-time setup to recover a password. The file +can also hold deployment settings, and setup replaces the primary API token. +Replace only the password in its active credential source instead, preserving +the username, API tokens, encryption keys, monitoring configuration and data. +This is login recovery, not a complete response to a compromised account; +existing sessions and API tokens need separate review if credentials leaked. -```bash -docker exec pulse rm /data/.env -docker restart pulse -# Access UI again. Pulse will require a bootstrap token for setup. -# Get it with: -docker exec pulse /app/pulse bootstrap-token -``` +1. **Find the active credential source locally.** Deployment-supplied + `PULSE_AUTH_USER` and `PULSE_AUTH_PASS` take precedence over Pulse's `.env`. + Do not post full service units, container inspections or resolved Compose + configuration; they can contain secrets. + - **Docker**: the generated file is in the mounted data directory, normally + `/data/.env` inside the container. If Compose, `--env-file`, Kubernetes or + another manager supplies the password, edit that managed source instead. + - **Systemd**: check the active service's unit, environment files and + drop-ins privately. Root-run setup can also save credentials in + `/etc/systemd/system/pulse.service.d/override.conf` (or the corresponding + `pulse-backend.service.d` path on legacy installs). Editing only + `/etc/pulse/.env` will not override those values. A custom + `PULSE_DATA_DIR` changes the generated file's location. + - **Proxmox LXC**: perform the systemd steps inside the Pulse container, not + on the Proxmox host. Use its console or `pct enter `. +2. **Prepare a new password hash privately.** Use a trusted local bcrypt tool + that prompts for a password of at least 12 characters, never one that needs + the password in its command arguments. For example, if Apache's `htpasswd` + is installed on your trusted administration machine: -**Systemd**: + ```bash + ( + set -eu + umask 077 + recovery_dir="$(mktemp -d "${TMPDIR:-/tmp}/pulse-password.XXXXXX")" + htpasswd -nB -C 12 pulse-recovery > "$recovery_dir/password-record" + printf 'Private password record saved in %s/password-record\n' "$recovery_dir" + ) + ``` -```bash -sudo rm /etc/pulse/.env -sudo systemctl restart pulse -sudo pulse bootstrap-token -``` + This prints only the file location. Open the record in a private editor and + copy the complete 60-character hash after `pulse-recovery:`; that label is + not a new Pulse username. Do not post the record or hash. If the tool fails, + stop rather than saving a partial hash or putting the password in a shell + command. +3. **Back up and edit only the active password setting.** Keep an owner-only + backup of each file you change, then replace `PULSE_AUTH_PASS` in a private + editor. Keep `PULSE_AUTH_USER` and unrelated settings unchanged. In a Pulse + `.env`, put the hash in single quotes with literal `$` characters. Compose + YAML has different interpolation rules; follow the + [authentication guide](CONFIGURATION.md#private-docker-authentication-file) + for the source you actually use. If a systemd drop-in supplies the password, + update it and the generated authentication file consistently so a later + restart or reload does not restore the old value. Preserve file permissions + and ownership; hashes and backups are sensitive too. +4. **Apply through the existing deployment.** For a generated Docker data-file + change, restart the same container. Changing Docker's managed environment + requires its recreate/redeploy operation, not just `docker restart`; preserve + the same image, mounted data and other settings. For systemd, reload the + service manager after a unit or drop-in change, then restart the active Pulse + service during a suitable maintenance window. Do not remove a data volume, + reinstall Pulse or re-enrol agents. +5. **Verify the login.** Use a fresh browser session to sign in with the same + local administrator username and new password, then check that monitoring + and agent connections remain intact. If it still fails, stop and reconcile + the effective source and any lockout; do not delete more state. Restore the + private backup through the same deployment path if you need to undo the + change. Remove the temporary password record after verification, + and retain or dispose of the backup under your normal credential policy. -**Proxmox LXC** (installed from the Proxmox shell): -Pulse runs inside the container, so run the same steps through `pct exec` on the Proxmox host. The binary needs its absolute path here, because `pct exec` runs with `PATH=/sbin:/bin:/usr/sbin:/usr/bin` and that does not include `/usr/local/bin`: +#### A fresh install's bootstrap token was missed -```bash -pct exec -- rm /etc/pulse/.env -pct exec -- systemctl restart pulse -pct exec -- /usr/local/bin/pulse bootstrap-token -``` - -If you only missed the token during a fresh install (no password set yet), skip the first two commands and just read it back with the last one. - -Treat the bootstrap token like a password: enter it only in the Pulse setup -screen for this instance and do not paste it into support requests or issue -reports. +If no local password has ever been set, there is nothing to reset. Follow +[first login](INSTALL.md#step-1-get-the-token) to read the existing bootstrap +token for that instance, using its actual data directory. Enter it only in the +Pulse setup screen, not in command arguments, URLs or issue reports. ### Port change didn't take effect @@ -605,9 +642,9 @@ success alone does not confirm the Pulse display has recovered. ### Recovery Mode -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 a forgotten local administrator password, follow +[I forgot my password](#i-forgot-my-password) above to update its active +credential source without deleting configuration or repeating first-time setup. For OIDC, SAML or proxy login, use the identity-provider or administrator path described there instead. diff --git a/frontend-modern/browser-tests/password-recovery-help.cjs b/frontend-modern/browser-tests/password-recovery-help.cjs new file mode 100644 index 0000000000..e92a6d7130 --- /dev/null +++ b/frontend-modern/browser-tests/password-recovery-help.cjs @@ -0,0 +1,91 @@ +// Render actual shipped help through the production Docs/router/Markdown/CSS. +// No API, Docker, systemd or real credential is used here. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { createHash } = require('node:crypto'); +const { chromium, webkit } = require('playwright'); + +(async () => { + const root = path.resolve('frontend-modern'); + const artifacts = path.join(root, 'node_modules', 'password-recovery-browser'); + fs.mkdirSync(artifacts, { recursive: true }); + 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'), + cacheDir: path.join(root, 'node_modules', '.vite-password-recovery'), + server: { host: '127.0.0.1', port: 5252, strictPort: true }, + optimizeDeps: { noDiscovery: true, include: [] }, + }); + const results = []; + let browser; + try { + await server.listen(); + for (const [engine, width, tone] of [['chromium', 1280, 'light'], ['webkit', 390, 'dark']]) { + browser = await { chromium, webkit }[engine].launch(engine === 'chromium' + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }); + const page = await browser.newPage({ viewport: { width, height: 1000 }, + ...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}) }); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + page.on('console', (message) => { if (message.type() === 'error') errors.push(message.text()); }); + await page.goto('http://127.0.0.1:5252/browser-tests/docs-fragment-navigation.html', + { waitUntil: 'domcontentloaded', timeout: 60_000 }); + if (tone === 'dark') await page.evaluate(() => document.documentElement.classList.add('dark')); + for (const guide of ['DOCKER', 'FAQ']) { + await page.getByRole('link', { name: '← All documentation' }).click(); + const entry = page.locator(`a[data-doc-link][href="/docs/${guide}"]`).first(); + await entry.focus(); + await page.keyboard.press('Enter'); + const link = page.getByRole('link', { name: 'password recovery guide', exact: true }); + await link.waitFor(); + await link.focus(); + await page.keyboard.press('Enter'); + await page.waitForFunction(() => { + const h = document.getElementById('i-forgot-my-password'); + return h && h.getBoundingClientRect().top >= 0 && h.getBoundingClientRect().top < innerHeight; + }); + assert.ok(page.url().endsWith('/docs/TROUBLESHOOTING#i-forgot-my-password')); + const text = await page.locator('article').innerText(); + for (const phrase of ['Do not delete .env', 'setup replaces the primary API token', + 'pulse.service.d/override.conf', 'not just docker restart', + 'existing sessions and API tokens need separate review', 'If it still fails, stop']) + assert.ok(text.includes(phrase), phrase); + assert.ok(!text.includes('rm /data/.env') && !text.includes('rm /etc/pulse/.env')); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + await page.screenshot({ path: path.join(artifacts, `${engine}-${guide.toLowerCase()}-recovery.png`) }); + } + const code = page.locator('pre').filter({ hasText: 'htpasswd -nB -C 12' }); + await code.scrollIntoViewIfNeeded(); + assert.ok((await code.innerText()).includes('> "$recovery_dir/password-record"')); + await page.screenshot({ path: path.join(artifacts, `${engine}-private-hash.png`) }); + const auth = page.getByRole('link', { name: 'authentication guide', exact: true }); + await auth.focus(); + await page.keyboard.press('Enter'); + await page.waitForFunction(() => { + const h = document.getElementById('private-docker-authentication-file'); + return h && h.getBoundingClientRect().top >= 0 && h.getBoundingClientRect().top < innerHeight; + }); + assert.ok(page.url().endsWith('/docs/CONFIGURATION#private-docker-authentication-file')); + assert.deepEqual(errors, []); + results.push({ engine, browserVersion: browser.version(), width, tone, + keyboardDockerAndFAQLinks: true, fragmentInView: true, sourcePrecedenceWarning: true, + privateHashRecipeRendered: true, configurationLink: true, noBodyOverflow: true, errors }); + await browser.close(); + browser = undefined; + } + const docs = Object.fromEntries(['DOCKER', 'FAQ', 'TROUBLESHOOTING'].map((guide) => [ + `frontend-modern/public/docs/${guide}.md`, + createHash('sha256').update(fs.readFileSync(path.join(root, 'public/docs', `${guide}.md`))).digest('hex'), + ])); + const result = { result: 'passed', playwrightVersion: require('playwright/package.json').version, + scope: 'rendered recovery guidance/links; not native deployment/password/session recovery', docs, results }; + fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2) + '\n'); + console.log(JSON.stringify(result)); + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/frontend-modern/public/docs/DOCKER.md b/frontend-modern/public/docs/DOCKER.md index c87e2e9d0f..1398425542 100644 --- a/frontend-modern/public/docs/DOCKER.md +++ b/frontend-modern/public/docs/DOCKER.md @@ -270,13 +270,11 @@ Pulse can start, stop, and restart Docker / Podman containers directly from the ## 🛠️ Troubleshooting - **Forgot Password?** - ```bash - docker exec pulse rm /data/.env - docker restart pulse - # Access UI again. Pulse will require a bootstrap token for setup. - # Get it with: - docker exec pulse /app/pulse bootstrap-token - ``` + Follow the [password recovery guide](TROUBLESHOOTING.md#i-forgot-my-password). + Update the active credential source; do not delete `.env`, remove the data + volume or repeat setup. A deployment-supplied password overrides the generated + file, and changing Docker's managed environment needs a recreate/redeploy, + not just a restart. SSO accounts and temporary lockouts have separate paths. - **Logs** ```bash diff --git a/frontend-modern/public/docs/FAQ.md b/frontend-modern/public/docs/FAQ.md index 2786c6e5af..e5d221ca21 100644 --- a/frontend-modern/public/docs/FAQ.md +++ b/frontend-modern/public/docs/FAQ.md @@ -118,30 +118,13 @@ discovery are disabled by default. See trust boundary and rollout checklist. ### I forgot my password. How do I reset it? -**Docker**: -```bash -docker exec pulse rm /data/.env -docker restart pulse -# Access UI again. Pulse will require a bootstrap token for setup. -# Get it with: -docker exec pulse /app/pulse bootstrap-token -``` -**Systemd**: -Delete `/etc/pulse/.env` and restart the service. Pulse will require a bootstrap token for setup: - -```bash -sudo pulse bootstrap-token -``` -**Proxmox LXC** (installed from the Proxmox shell): -Pulse runs inside the container, so run the same steps through `pct exec` on the Proxmox host. The binary needs its absolute path here, because `pct exec` runs with `PATH=/sbin:/bin:/usr/sbin:/usr/bin` and that does not include `/usr/local/bin`: - -```bash -pct exec -- rm /etc/pulse/.env -pct exec -- systemctl restart pulse -pct exec -- /usr/local/bin/pulse bootstrap-token -``` - -If you only missed the token during a fresh install (no password set yet), skip the first two commands and just read it back with the last one. +Use the [password recovery guide](TROUBLESHOOTING.md#i-forgot-my-password). +For the local administrator, update only the password in its active credential +source, preserving the username, tokens, configuration and data; deleting `.env` +is not a universal reset. The guide covers Docker, systemd and Proxmox LXC, +including deployment overrides, private backups and verification. SSO login, +temporary lockouts and a fresh install's missed bootstrap token need different +steps; do not reset a working installation to retrieve that token. ### How do I enable HTTPS? Set `HTTPS_ENABLED=true` and provide `TLS_CERT_FILE` and `TLS_KEY_FILE` environment variables. See [Configuration](CONFIGURATION.md#-https--tls). diff --git a/frontend-modern/public/docs/TROUBLESHOOTING.md b/frontend-modern/public/docs/TROUBLESHOOTING.md index f7f21b286a..2ff1a7b0eb 100644 --- a/frontend-modern/public/docs/TROUBLESHOOTING.md +++ b/frontend-modern/public/docs/TROUBLESHOOTING.md @@ -7,8 +7,9 @@ Pulse does not provide an email password-reset flow. Choose the path that matches how this self-hosted instance authenticates: -- **Local Pulse username and password**: recovery requires shell access to the - Pulse host or container. Follow the deployment-specific steps below. +- **Local Pulse administrator username and password**: recovery requires + authorised access to the host or deployment's credential source. Follow the + steps below; keep the existing username. - **OIDC, SAML, or proxy authentication**: contact the identity-provider or Pulse administrator. If the administrator deliberately kept local login as a fallback, they can open the Pulse URL with `?show_local=true`; this only @@ -17,45 +18,81 @@ matches how this self-hosted instance authenticates: administrator can use the lockout reset in Pulse; password recovery is not required. -The local recovery steps remove only the Pulse-generated authentication file. -After restart, Pulse still requires the host-only bootstrap token before it -will accept replacement credentials. If `PULSE_AUTH_USER` or `PULSE_AUTH_PASS` -is supplied by Docker Compose, Kubernetes, systemd, or another deployment -manager, update that deployment configuration instead; its environment values -override the generated file. +#### Recover the existing local administrator login -**Docker**: +Do not delete `.env` or repeat first-time setup to recover a password. The file +can also hold deployment settings, and setup replaces the primary API token. +Replace only the password in its active credential source instead, preserving +the username, API tokens, encryption keys, monitoring configuration and data. +This is login recovery, not a complete response to a compromised account; +existing sessions and API tokens need separate review if credentials leaked. -```bash -docker exec pulse rm /data/.env -docker restart pulse -# Access UI again. Pulse will require a bootstrap token for setup. -# Get it with: -docker exec pulse /app/pulse bootstrap-token -``` +1. **Find the active credential source locally.** Deployment-supplied + `PULSE_AUTH_USER` and `PULSE_AUTH_PASS` take precedence over Pulse's `.env`. + Do not post full service units, container inspections or resolved Compose + configuration; they can contain secrets. + - **Docker**: the generated file is in the mounted data directory, normally + `/data/.env` inside the container. If Compose, `--env-file`, Kubernetes or + another manager supplies the password, edit that managed source instead. + - **Systemd**: check the active service's unit, environment files and + drop-ins privately. Root-run setup can also save credentials in + `/etc/systemd/system/pulse.service.d/override.conf` (or the corresponding + `pulse-backend.service.d` path on legacy installs). Editing only + `/etc/pulse/.env` will not override those values. A custom + `PULSE_DATA_DIR` changes the generated file's location. + - **Proxmox LXC**: perform the systemd steps inside the Pulse container, not + on the Proxmox host. Use its console or `pct enter `. +2. **Prepare a new password hash privately.** Use a trusted local bcrypt tool + that prompts for a password of at least 12 characters, never one that needs + the password in its command arguments. For example, if Apache's `htpasswd` + is installed on your trusted administration machine: -**Systemd**: + ```bash + ( + set -eu + umask 077 + recovery_dir="$(mktemp -d "${TMPDIR:-/tmp}/pulse-password.XXXXXX")" + htpasswd -nB -C 12 pulse-recovery > "$recovery_dir/password-record" + printf 'Private password record saved in %s/password-record\n' "$recovery_dir" + ) + ``` -```bash -sudo rm /etc/pulse/.env -sudo systemctl restart pulse -sudo pulse bootstrap-token -``` + This prints only the file location. Open the record in a private editor and + copy the complete 60-character hash after `pulse-recovery:`; that label is + not a new Pulse username. Do not post the record or hash. If the tool fails, + stop rather than saving a partial hash or putting the password in a shell + command. +3. **Back up and edit only the active password setting.** Keep an owner-only + backup of each file you change, then replace `PULSE_AUTH_PASS` in a private + editor. Keep `PULSE_AUTH_USER` and unrelated settings unchanged. In a Pulse + `.env`, put the hash in single quotes with literal `$` characters. Compose + YAML has different interpolation rules; follow the + [authentication guide](CONFIGURATION.md#private-docker-authentication-file) + for the source you actually use. If a systemd drop-in supplies the password, + update it and the generated authentication file consistently so a later + restart or reload does not restore the old value. Preserve file permissions + and ownership; hashes and backups are sensitive too. +4. **Apply through the existing deployment.** For a generated Docker data-file + change, restart the same container. Changing Docker's managed environment + requires its recreate/redeploy operation, not just `docker restart`; preserve + the same image, mounted data and other settings. For systemd, reload the + service manager after a unit or drop-in change, then restart the active Pulse + service during a suitable maintenance window. Do not remove a data volume, + reinstall Pulse or re-enrol agents. +5. **Verify the login.** Use a fresh browser session to sign in with the same + local administrator username and new password, then check that monitoring + and agent connections remain intact. If it still fails, stop and reconcile + the effective source and any lockout; do not delete more state. Restore the + private backup through the same deployment path if you need to undo the + change. Remove the temporary password record after verification, + and retain or dispose of the backup under your normal credential policy. -**Proxmox LXC** (installed from the Proxmox shell): -Pulse runs inside the container, so run the same steps through `pct exec` on the Proxmox host. The binary needs its absolute path here, because `pct exec` runs with `PATH=/sbin:/bin:/usr/sbin:/usr/bin` and that does not include `/usr/local/bin`: +#### A fresh install's bootstrap token was missed -```bash -pct exec -- rm /etc/pulse/.env -pct exec -- systemctl restart pulse -pct exec -- /usr/local/bin/pulse bootstrap-token -``` - -If you only missed the token during a fresh install (no password set yet), skip the first two commands and just read it back with the last one. - -Treat the bootstrap token like a password: enter it only in the Pulse setup -screen for this instance and do not paste it into support requests or issue -reports. +If no local password has ever been set, there is nothing to reset. Follow +[first login](INSTALL.md#step-1-get-the-token) to read the existing bootstrap +token for that instance, using its actual data directory. Enter it only in the +Pulse setup screen, not in command arguments, URLs or issue reports. ### Port change didn't take effect @@ -605,9 +642,9 @@ success alone does not confirm the Pulse display has recovered. ### Recovery Mode -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 a forgotten local administrator password, follow +[I forgot my password](#i-forgot-my-password) above to update its active +credential source without deleting configuration or repeating first-time setup. For OIDC, SAML or proxy login, use the identity-provider or administrator path described there instead. diff --git a/internal/config/password_recovery_docs_test.go b/internal/config/password_recovery_docs_test.go new file mode 100644 index 0000000000..d004ac36a6 --- /dev/null +++ b/internal/config/password_recovery_docs_test.go @@ -0,0 +1,94 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/pkg/auth" + "github.com/stretchr/testify/require" +) + +// Exercise the documented existing-login recovery at the real configuration +// loader, not by re-running setup. This is not a native deployment/session test. +func TestPasswordRecoveryDocsLoadPreservesSettingsAndAgentToken(t *testing.T) { + dir := t.TempDir() + t.Setenv("PULSE_DATA_DIR", dir) + clearFileEnv := func() { + for _, key := range []string{"PULSE_AUTH_USER", "PULSE_AUTH_PASS", "FRONTEND_PORT"} { + require.NoError(t, os.Unsetenv(key)) + } + } + for _, key := range []string{"PULSE_AUTH_USER", "PULSE_AUTH_PASS", "FRONTEND_PORT"} { + // Register restoration even though dotenv sets these outside t.Setenv. + t.Setenv(key, "") + } + clearFileEnv() + + oldPassword, newPassword := "synthetic-old-password", "synthetic-new-password" + oldHash, err := auth.HashPassword(oldPassword) + require.NoError(t, err) + newHash, err := auth.HashPassword(newPassword) + require.NoError(t, err) + // Apache's bcrypt records use the compatible $2y$ prefix. + newHash = strings.Replace(newHash, "$2a$", "$2y$", 1) + require.Len(t, newHash, 60) + require.True(t, auth.CheckPasswordHash(newPassword, newHash)) + + envPath := filepath.Join(dir, ".env") + content := "PULSE_AUTH_USER='existing-admin'\nPULSE_AUTH_PASS='" + oldHash + "'\nFRONTEND_PORT=8765\n# preserve unrelated settings\n" + require.NoError(t, os.WriteFile(envPath, []byte(content), 0o600)) + token, err := NewAPITokenRecord("synthetic-existing-agent-token", "Existing agent", []string{ScopeAgentReport}) + require.NoError(t, err) + persistence := NewConfigPersistence(dir) + require.NoError(t, persistence.SaveAPITokens([]APITokenRecord{*token})) + tokenPath := filepath.Join(dir, "api_tokens.json") + originalTokens, err := os.ReadFile(tokenPath) + require.NoError(t, err) + + cfg, err := LoadWithoutLoggingInit() + require.NoError(t, err) + require.True(t, auth.CheckPasswordHash(oldPassword, cfg.AuthPass)) + + // Model a private editor changing only the complete quoted hash. + updated := strings.Replace(content, oldHash, newHash, 1) + require.NoError(t, os.WriteFile(envPath, []byte(updated), 0o600)) + clearFileEnv() // A fresh process does not inherit the last dotenv load. + cfg, err = LoadWithoutLoggingInit() + require.NoError(t, err) + require.Equal(t, "existing-admin", cfg.AuthUser) + require.Equal(t, 8765, cfg.FrontendPort) + require.Equal(t, newHash, cfg.AuthPass) + require.True(t, auth.CheckPasswordHash(newPassword, cfg.AuthPass)) + require.False(t, auth.CheckPasswordHash(oldPassword, cfg.AuthPass)) + _, valid := cfg.ValidateAPIToken("synthetic-existing-agent-token") + require.True(t, valid) + gotTokens, err := os.ReadFile(tokenPath) + require.NoError(t, err) + require.Equal(t, originalTokens, gotTokens) + gotEnv, err := os.ReadFile(envPath) + require.NoError(t, err) + require.Equal(t, updated, string(gotEnv)) + + // The old deployment-supplied value still wins until its owning source is + // updated too: editing the mounted file alone cannot repair that install. + clearFileEnv() + t.Setenv("PULSE_AUTH_PASS", oldHash) + cfg, err = LoadWithoutLoggingInit() + require.NoError(t, err) + require.Equal(t, oldHash, cfg.AuthPass) + require.False(t, auth.CheckPasswordHash(newPassword, cfg.AuthPass)) + + // Restoring the saved file returns the former credential and port, without + // changing the persisted agent token. This models source rollback only. + clearFileEnv() + require.NoError(t, os.WriteFile(envPath, []byte(content), 0o600)) + cfg, err = LoadWithoutLoggingInit() + require.NoError(t, err) + require.True(t, auth.CheckPasswordHash(oldPassword, cfg.AuthPass)) + require.False(t, auth.CheckPasswordHash(newPassword, cfg.AuthPass)) + require.Equal(t, 8765, cfg.FrontendPort) + _, valid = cfg.ValidateAPIToken("synthetic-existing-agent-token") + require.True(t, valid) +} diff --git a/scripts/tests/test_password_recovery_docs.py b/scripts/tests/test_password_recovery_docs.py new file mode 100644 index 0000000000..2cfc7d1cfd --- /dev/null +++ b/scripts/tests/test_password_recovery_docs.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Check recovery guidance and execute its private hash recipe with a recorder. + +The recorder proves shell disclosure/permission/failure behaviour, not Apache +hash generation or native Docker/systemd password recovery. The connected Go +config controls exercise bcrypt acceptance, source precedence and token retention. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import re +import stat +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +GUIDES = ("TROUBLESHOOTING.md", "FAQ.md", "DOCKER.md") +RECORD = "pulse-recovery:$2y$12$" + "a" * 53 + "\n" # Synthetic disclosure sentinel. + + +def recovery() -> str: + return (ROOT / "docs/TROUBLESHOOTING.md").read_text().split( + "### I forgot my password\n", 1)[1].split("### Port change", 1)[0] + + +def recipe() -> str: + matches = re.findall(r"```bash\n(.*?)```", recovery(), re.S) + if len(matches) != 1: + raise AssertionError("expected one private hash preparation recipe") + return matches[0] + + +HTPASSWD = '''#!/usr/bin/env python3 +import json, os, sys +from pathlib import Path +assert sys.argv[1:] == ['-nB', '-C', '12', 'pulse-recovery'] +Path(os.environ['ARGV_RECEIPT']).write_text(json.dumps(sys.argv[1:])) +if os.environ.get('FAIL_HASH'): + print('hash preparation failed', file=sys.stderr) + sys.exit(23) +sys.stdout.write(os.environ['FIXTURE_RECORD']) +''' + + +class PasswordRecoveryDocsTest(unittest.TestCase): + def test_entry_points_do_not_delete_auth_or_restart_setup(self): + for name in GUIDES: + with self.subTest(guide=name): + text = (ROOT / "docs" / name).read_text() + self.assertNotRegex(text, r"(?:rm\s+/(?:data|etc/pulse)/\.env|Delete `/etc/pulse/\.env`)") + if name != "TROUBLESHOOTING.md": + self.assertIn("TROUBLESHOOTING.md#i-forgot-my-password", text) + text = recovery() + self.assertIn("Replace only the password", text) + self.assertIn("setup replaces the primary API token", text) + self.assertIn("same\n local administrator username", text) + + def test_precedence_apply_and_failure_guidance_matches_current_sources(self): + text = " ".join(recovery().split()) + for expected in ("take precedence", "pulse.service.d/override.conf", + "pulse-backend.service.d", "PULSE_DATA_DIR", + "inside the Pulse container", "owner-only backup", + "complete 60-character hash", "literal `$`", + "not just `docker restart`", "same image, mounted data", + "reload the service manager", "If it still fails, stop", + "existing sessions and API tokens need separate review", + "If no local password has ever been set"): + with self.subTest(expected=expected): + self.assertIn(expected, text) + setup = (ROOT / "internal/api/security_setup_fix.go").read_text() + self.assertIn('/etc/systemd/system/%s.service.d/override.conf', setup) + self.assertIn('r.config.APITokens = []config.APITokenRecord{*tokenRecord}', setup) + loader = (ROOT / "internal/config/config.go").read_text() + self.assertIn('godotenv.Load(envFile)', loader) + self.assertIn('normalizeEnvAuthPassword(authPass)', loader) + + def run_recipe(self, directory: Path, *, fail: bool = False): + tools = directory / "tools" + tools.mkdir() + tool = tools / "htpasswd" + tool.write_text(HTPASSWD) + tool.chmod(0o700) + env = {**os.environ, "TMPDIR": str(directory), + "PATH": f"{tools}:{os.environ['PATH']}", + "ARGV_RECEIPT": str(directory / "argv.json"), + "FIXTURE_RECORD": RECORD, "FAIL_HASH": "yes" if fail else ""} + result = subprocess.run(["bash", "-c", recipe()], env=env, + capture_output=True, text=True, timeout=10) + self.assertNotIn(RECORD.strip(), result.stdout + result.stderr) + self.assertNotIn(RECORD.split(":", 1)[1].strip(), result.stdout + result.stderr) + self.assertEqual(json.loads((directory / "argv.json").read_text()), + ['-nB', '-C', '12', 'pulse-recovery']) + records = list(directory.glob("pulse-password.*/password-record")) + self.assertEqual(len(records), 1) + record = records[0] + self.assertEqual(stat.S_IMODE(record.parent.stat().st_mode), 0o700) + self.assertEqual(stat.S_IMODE(record.stat().st_mode), 0o600) + return result, record + + def test_copied_recipe_keeps_hash_out_of_output_and_arguments(self): + # Include spaces in the directory to exercise the copied quoting. + with tempfile.TemporaryDirectory(prefix="password docs ") as temporary: + result, record = self.run_recipe(Path(temporary)) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(record.read_text(), RECORD) + self.assertEqual(result.stdout, + f"Private password record saved in {record}\n") + + def test_hash_failure_stops_before_success_and_retains_private_partial_file(self): + with tempfile.TemporaryDirectory() as temporary: + result, record = self.run_recipe(Path(temporary), fail=True) + self.assertEqual(result.returncode, 23) + self.assertEqual(result.stdout, "") + self.assertEqual(record.read_text(), "") + self.assertIn("hash preparation failed", result.stderr) + + def test_recovery_guides_match_shipped_copies(self): + for name in GUIDES: + with self.subTest(guide=name): + self.assertEqual((ROOT / "docs" / name).read_bytes(), + (ROOT / "frontend-modern/public/docs" / name).read_bytes()) + subprocess.run(["bash", "-n", "-c", recipe()], check=True, capture_output=True) + + +if __name__ == "__main__": + unittest.main()