Recover local logins without deleting authentication state

Route Docker and FAQ readers through one credential-source-aware recovery guide. Replace only the active password, retain unrelated settings and agent tokens, and account for systemd drop-ins and container environment application. Check private prompt output, failure stops, bcrypt loading, precedence and rendered help links.

Contract-Neutral: Documentation and test coverage only; existing authentication, setup and deployment contracts are unchanged.
Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-03 09:25:52 +01:00
parent 200ce831a8
commit 462cdfb170
9 changed files with 490 additions and 138 deletions
+5 -7
View File
@@ -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
+7 -24
View File
@@ -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 <ctid> -- rm /etc/pulse/.env
pct exec <ctid> -- systemctl restart pulse
pct exec <ctid> -- /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).
+75 -38
View File
@@ -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 <ctid>`.
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 <ctid> -- rm /etc/pulse/.env
pct exec <ctid> -- systemctl restart pulse
pct exec <ctid> -- /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.
@@ -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; });
+5 -7
View File
@@ -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
+7 -24
View File
@@ -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 <ctid> -- rm /etc/pulse/.env
pct exec <ctid> -- systemctl restart pulse
pct exec <ctid> -- /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).
+75 -38
View File
@@ -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 <ctid>`.
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 <ctid> -- rm /etc/pulse/.env
pct exec <ctid> -- systemctl restart pulse
pct exec <ctid> -- /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.
@@ -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)
}
@@ -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()