mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
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:
+5
-7
@@ -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
@@ -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
@@ -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; });
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user