mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
Keep Docker setup credentials private across help languages
Remove the shared-password override from German and Spanish Compose setup and the password-on-argv example from configuration help. Default to bootstrap setup; exercise private-file permissions, argument boundaries and failed preparation with synthetic credentials. Contract-Neutral: Help and regression fixtures only; authentication, container and API contracts are unchanged. Change-source: pulse-maintainer
This commit is contained in:
+79
-16
@@ -4,7 +4,7 @@ Pulse uses a split-configuration model to ensure security and flexibility.
|
||||
|
||||
| File | Purpose | Security Level |
|
||||
| ------ | --------- | ---------------- |
|
||||
| `.env` | Authentication & Secrets | 🔒 **Critical** (Read-only by owner) |
|
||||
| `.env` | Authentication & Secrets | 🔒 **Critical** (Owner access only) |
|
||||
| `.encryption.key` | Encryption key for `.enc` files | 🔒 **Critical** |
|
||||
| `audit/.audit-signing.key` | Encrypted signing key for the default audit store; preserve with its history and matching encryption key | 🔒 **Sensitive** |
|
||||
| `system.json` | General Settings | 📝 Standard |
|
||||
@@ -66,30 +66,93 @@ Multi-tenant layout:
|
||||
|
||||
## 🔐 Authentication (`.env`)
|
||||
|
||||
This file controls access to Pulse. It is **never** exposed to the UI.
|
||||
For a new install, leave `PULSE_AUTH_USER` and `PULSE_AUTH_PASS` unset and
|
||||
complete the bootstrap-token setup in your browser. See
|
||||
[First login](INSTALL.md#step-1-get-the-token). Do not deploy a shared example password.
|
||||
|
||||
```bash
|
||||
# /etc/pulse/.env
|
||||
The Pulse-generated `.env` controls local authentication and is not exposed
|
||||
to the UI. Preserve its owner-only permissions. A bcrypt hash is sensitive too:
|
||||
someone who obtains it can attempt offline password cracking.
|
||||
|
||||
# Admin Credentials (bcrypt hashed; plain text auto-hashes on startup)
|
||||
```dotenv
|
||||
# Pulse-generated file, not a command to paste into a shell
|
||||
PULSE_AUTH_USER='admin'
|
||||
PULSE_AUTH_PASS='$2a$12$...'
|
||||
PULSE_AUTH_PASS='<complete-bcrypt-hash>'
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><strong>Advanced: Automated Setup (Skip UI)</strong></summary>
|
||||
Deployment-supplied environment values take precedence over this file. Changing
|
||||
the generated file or using Pulse's password-change UI does not replace a
|
||||
password supplied by your deployment manager; update that managed source too.
|
||||
|
||||
You can pre-configure Pulse by setting environment variables. Plain text credentials are automatically hashed on startup.
|
||||
### Private Docker authentication file
|
||||
|
||||
If automation must skip browser setup, keep the credentials in a private file,
|
||||
not a `docker run -e PULSE_AUTH_PASS=...` command, shell history or a shared
|
||||
Compose file. On the Docker host, prepare the file below. These commands do not
|
||||
erase an existing file; edit it locally, without printing its contents:
|
||||
|
||||
```bash
|
||||
# Docker Example
|
||||
docker run -d \
|
||||
-e PULSE_DEPLOYMENT_METHOD=docker_run \
|
||||
-e PULSE_AUTH_USER=admin \
|
||||
-e PULSE_AUTH_PASS=secret123 \
|
||||
rcourtman/pulse:latest
|
||||
(
|
||||
set -e
|
||||
umask 077
|
||||
auth_dir="$HOME/.config/pulse"
|
||||
auth_file="$auth_dir/docker-auth.env"
|
||||
if [ -L "$auth_dir" ] || [ -L "$auth_file" ]; then
|
||||
printf 'Refusing a symlinked credential path.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$auth_dir"
|
||||
chmod 700 "$auth_dir"
|
||||
touch "$auth_file"
|
||||
chmod 600 "$auth_file"
|
||||
vi "$auth_file"
|
||||
)
|
||||
```
|
||||
</details>
|
||||
|
||||
In that editor, enter these two settings and replace the placeholder with a
|
||||
**complete bcrypt hash** generated by a trusted local tool with an interactive
|
||||
password prompt. Do not put the password in that tool's command arguments.
|
||||
Unlike Pulse's own quoted `.env`, Docker's `--env-file` input uses unquoted
|
||||
values; keep the hash's `$` characters literal, with no `$$` substitution:
|
||||
|
||||
```dotenv
|
||||
PULSE_AUTH_USER=admin
|
||||
PULSE_AUTH_PASS=<complete-bcrypt-hash>
|
||||
```
|
||||
|
||||
For a **new Community container**, replace `vX.Y.Z` with the exact published
|
||||
tag you intend to install, then run:
|
||||
|
||||
```bash
|
||||
(
|
||||
set -e
|
||||
if [ ! -s "$HOME/.config/pulse/docker-auth.env" ]; then
|
||||
printf 'Prepare the private authentication file first.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
docker run -d \
|
||||
--name pulse \
|
||||
-p 7655:7655 \
|
||||
-v pulse_data:/data \
|
||||
--env-file "$HOME/.config/pulse/docker-auth.env" \
|
||||
-e PULSE_DEPLOYMENT_METHOD=docker_run \
|
||||
--restart unless-stopped \
|
||||
rcourtman/pulse:vX.Y.Z
|
||||
)
|
||||
```
|
||||
|
||||
This keeps credential values out of the command arguments, **not out of Docker's
|
||||
container environment**: administrators with Docker access can still read them.
|
||||
Pulse hashes a plaintext `PULSE_AUTH_PASS` for authentication at startup, but
|
||||
does not scrub its original value from Docker or your deployment file. Keep the
|
||||
file private, outside the repository, and do not share `docker inspect` or
|
||||
resolved Compose configuration. Do not source the file into your shell.
|
||||
|
||||
For an existing or paid Pro deployment, retain its image, mounts and managed
|
||||
configuration; this new-container example is not an upgrade procedure. Compose
|
||||
interpolation and Docker `--env-file` are different formats. Prefer bootstrap
|
||||
setup for Compose unless your deployment manager already has a private
|
||||
credential mechanism. See the [Docker guide](DOCKER.md) for setup and updates.
|
||||
|
||||
<details>
|
||||
<summary><strong>Advanced: OIDC / SSO</strong></summary>
|
||||
|
||||
+11
-4
@@ -45,9 +45,6 @@ services:
|
||||
environment:
|
||||
- TZ=Europe/London
|
||||
- PULSE_DEPLOYMENT_METHOD=docker_compose
|
||||
# Optional: Pre-configure auth (skips setup wizard)
|
||||
# - PULSE_AUTH_USER=admin
|
||||
# - PULSE_AUTH_PASS=secret123
|
||||
|
||||
volumes:
|
||||
pulse_data:
|
||||
@@ -55,6 +52,13 @@ volumes:
|
||||
|
||||
Run with: `docker compose up -d`
|
||||
|
||||
Leave authentication overrides unset for a new install and complete
|
||||
[bootstrap-token setup](INSTALL.md#step-1-get-the-token) in your browser. Do not add a
|
||||
shared example password to the Compose file. If automation must skip setup,
|
||||
use a private deployment-managed credential source; the
|
||||
[private Docker authentication file](CONFIGURATION.md#private-docker-authentication-file)
|
||||
example is for `docker run`, not Compose interpolation.
|
||||
|
||||
The `PULSE_IMAGE` variable lets the same compose file run either the public
|
||||
community image or, for eligible paid customers, the private Pulse Pro image
|
||||
shown on <https://pulserelay.pro/download.html>.
|
||||
@@ -79,7 +83,10 @@ Pulse is configured via the UI (`system.json`) with optional environment overrid
|
||||
|
||||
> **Tip**: Set `LOG_LEVEL=warn` to reduce log volume while still capturing important events.
|
||||
> **Note**: API tokens are managed in the UI and stored in `api_tokens.json`.
|
||||
> **Note**: Plain text values in `PULSE_AUTH_PASS` are auto-hashed on startup.
|
||||
> **Note**: Plain text values in `PULSE_AUTH_PASS` are hashed for authentication
|
||||
> at startup, but remain in the container environment and deployment file.
|
||||
> Docker administrators can read those values. Prefer bootstrap setup; never
|
||||
> share full `docker inspect` or resolved Compose output.
|
||||
|
||||
For SSD-sensitive installs, keep `/data` persistent and put only metrics
|
||||
history on tmpfs:
|
||||
|
||||
@@ -90,14 +90,20 @@ services:
|
||||
- "7655:7655"
|
||||
volumes:
|
||||
- pulse_data:/data
|
||||
environment:
|
||||
- PULSE_AUTH_USER=admin
|
||||
- PULSE_AUTH_PASS=secret123
|
||||
|
||||
volumes:
|
||||
pulse_data:
|
||||
```
|
||||
|
||||
Lass `PULSE_AUTH_USER` und `PULSE_AUTH_PASS` bei einer neuen Installation
|
||||
ungesetzt und richte den Zugang beim ersten Anmelden mit dem Bootstrap-Token
|
||||
ein. Verwende kein gemeinsames Beispielpasswort. Für automatisierte Einrichtung
|
||||
nutze eine private, vom Deployment verwaltete Zugangsdatenquelle; siehe
|
||||
[Configuration](../../CONFIGURATION.md#private-docker-authentication-file).
|
||||
Passwörter gehören nicht in Befehlsargumente oder eine geteilte Compose-Datei.
|
||||
Ein Docker-Administrator kann Zugangsdaten in der Container-Umgebung weiterhin
|
||||
lesen, auch wenn Pulse sie für die Anmeldung hasht.
|
||||
|
||||
## Erste Anmeldung
|
||||
|
||||
Beim ersten Start musst du ein **Bootstrap Token** abrufen und damit den
|
||||
|
||||
@@ -89,14 +89,20 @@ services:
|
||||
- "7655:7655"
|
||||
volumes:
|
||||
- pulse_data:/data
|
||||
environment:
|
||||
- PULSE_AUTH_USER=admin
|
||||
- PULSE_AUTH_PASS=secret123
|
||||
|
||||
volumes:
|
||||
pulse_data:
|
||||
```
|
||||
|
||||
Deja `PULSE_AUTH_USER` y `PULSE_AUTH_PASS` sin configurar en una instalación
|
||||
nueva y completa el primer acceso con el token de arranque. No uses una
|
||||
contraseña de ejemplo compartida. Para una configuración automatizada, usa
|
||||
una fuente privada de credenciales gestionada por el despliegue; consulta
|
||||
[Configuration](../../CONFIGURATION.md#private-docker-authentication-file).
|
||||
No pongas contraseñas en argumentos de comandos ni en un archivo Compose
|
||||
compartido. Un administrador de Docker aún puede leer las credenciales en el
|
||||
entorno del contenedor, aunque Pulse las convierta en hash para la autenticación.
|
||||
|
||||
## Primer inicio de sesión
|
||||
|
||||
En el primer arranque debes recuperar un **Bootstrap Token** y usarlo para
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// Verify shipped setup guidance in the production Docs/router/Markdown/CSS.
|
||||
// No backend, Docker daemon, credentials or container startup is exercised.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { chromium, webkit } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
const root = path.resolve('frontend-modern');
|
||||
const artifacts = path.join(root, 'node_modules', 'private-auth-help-browser');
|
||||
fs.mkdirSync(artifacts, { recursive: true });
|
||||
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-private-auth-help'),
|
||||
server: { host: '127.0.0.1', port: 5249, 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: 900 },
|
||||
...(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:5249/browser-tests/docs-fragment-navigation.html',
|
||||
{ waitUntil: 'domcontentloaded', timeout: 60_000 });
|
||||
if (tone === 'dark') await page.evaluate(async () => {
|
||||
document.documentElement.classList.add('dark');
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
await page.getByRole('link', { name: '← All documentation' }).click();
|
||||
await page.getByRole('link', { name: 'Configure Pulse', exact: true }).click();
|
||||
const privateHeading = page.getByRole('heading', { name: 'Private Docker authentication file', exact: true });
|
||||
await privateHeading.waitFor();
|
||||
let text = await page.locator('article').innerText();
|
||||
for (const phrase of ['complete bcrypt hash', 'no $$ substitution', 'Do not source the file',
|
||||
'Deployment-supplied environment values take precedence', 'not out of Docker\'s',
|
||||
'does not scrub its original value', 'this new-container example is not an upgrade procedure'])
|
||||
assert.ok(text.includes(phrase), phrase);
|
||||
assert.ok(!text.includes('secret123'));
|
||||
const shell = await page.locator('pre code.language-bash').allTextContents();
|
||||
assert.ok(shell.some((block) => block.includes('--env-file "$HOME/.config/pulse/docker-auth.env"')));
|
||||
assert.ok(shell.every((block) => !/-e\s+PULSE_AUTH_(PASS|USER)=/.test(block)));
|
||||
const firstLogin = page.getByRole('link', { name: 'First login', exact: true });
|
||||
await firstLogin.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForFunction(() => {
|
||||
const h = document.getElementById('step-1-get-the-token');
|
||||
return h && h.getBoundingClientRect().top >= 0 && h.getBoundingClientRect().top < innerHeight;
|
||||
});
|
||||
assert.ok(page.url().endsWith('/docs/INSTALL#step-1-get-the-token'));
|
||||
await page.goBack();
|
||||
await privateHeading.waitFor();
|
||||
await privateHeading.evaluate((h) => h.scrollIntoView({ block: 'start' }));
|
||||
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
|
||||
await page.screenshot({ path: path.join(artifacts, `${engine}-private-file.png`) });
|
||||
const launchCode = page.locator('pre').filter({ hasText: 'docker run -d' });
|
||||
await launchCode.scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: path.join(artifacts, `${engine}-docker-credentials.png`) });
|
||||
|
||||
await page.getByRole('link', { name: '← All documentation' }).click();
|
||||
await page.getByRole('link', { name: 'Docker and Podman', exact: true }).click();
|
||||
await page.getByRole('heading', { name: '📦 Docker Compose', exact: true }).waitFor();
|
||||
text = await page.locator('article').innerText();
|
||||
assert.ok(!text.includes('secret123'));
|
||||
assert.ok(text.includes('Leave authentication overrides unset'));
|
||||
assert.ok(text.includes('remain in the container environment and deployment file'));
|
||||
const privateLink = page.getByRole('link', { name: 'private Docker authentication file', exact: true });
|
||||
await privateLink.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'));
|
||||
|
||||
for (const [locale, label, heading, safeguard] of [
|
||||
['de', 'Deutsch', 'Erste Anmeldung', 'Verwende kein gemeinsames Beispielpasswort'],
|
||||
['es', 'Español', 'Primer inicio de sesión', 'No uses una contraseña de ejemplo compartida'],
|
||||
]) {
|
||||
await page.getByRole('link', { name: '← All documentation' }).click();
|
||||
const link = page.getByRole('link', { name: label, exact: true });
|
||||
await link.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.getByRole('heading', { name: heading, exact: true }).waitFor();
|
||||
text = await page.locator('article').innerText();
|
||||
assert.ok(!text.includes('secret123'));
|
||||
assert.ok(!text.includes('PULSE_AUTH_PASS='));
|
||||
assert.ok(text.includes('docker exec pulse /app/pulse bootstrap-token'));
|
||||
assert.ok(text.includes(safeguard));
|
||||
const compose = page.locator('pre').filter({ hasText: 'services:' });
|
||||
await compose.scrollIntoViewIfNeeded();
|
||||
assert.ok(!(await compose.innerText()).includes('PULSE_AUTH_'));
|
||||
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
|
||||
await page.screenshot({ path: path.join(artifacts, `${engine}-${locale}-bootstrap.png`) });
|
||||
const target = page.locator('a[href="/docs/CONFIGURATION#private-docker-authentication-file"]');
|
||||
await target.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await privateHeading.waitFor();
|
||||
assert.ok(page.url().endsWith('/docs/CONFIGURATION#private-docker-authentication-file'));
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
results.push({ engine, version: browser.version(), width, tone, firstLoginKeyboard: true,
|
||||
privateFileRendering: true, noCommandCredential: true, envVisibilityWarning: true,
|
||||
dockerFragmentKeyboard: true, translatedBootstrap: ['de', 'es'], errors });
|
||||
await browser.close();
|
||||
browser = undefined;
|
||||
}
|
||||
const result = { result: 'passed', playwright: require('playwright/package.json').version,
|
||||
scope: 'shipped setup help and links, not native Docker or password changes', 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; });
|
||||
@@ -4,7 +4,7 @@ Pulse uses a split-configuration model to ensure security and flexibility.
|
||||
|
||||
| File | Purpose | Security Level |
|
||||
| ------ | --------- | ---------------- |
|
||||
| `.env` | Authentication & Secrets | 🔒 **Critical** (Read-only by owner) |
|
||||
| `.env` | Authentication & Secrets | 🔒 **Critical** (Owner access only) |
|
||||
| `.encryption.key` | Encryption key for `.enc` files | 🔒 **Critical** |
|
||||
| `audit/.audit-signing.key` | Encrypted signing key for the default audit store; preserve with its history and matching encryption key | 🔒 **Sensitive** |
|
||||
| `system.json` | General Settings | 📝 Standard |
|
||||
@@ -66,30 +66,93 @@ Multi-tenant layout:
|
||||
|
||||
## 🔐 Authentication (`.env`)
|
||||
|
||||
This file controls access to Pulse. It is **never** exposed to the UI.
|
||||
For a new install, leave `PULSE_AUTH_USER` and `PULSE_AUTH_PASS` unset and
|
||||
complete the bootstrap-token setup in your browser. See
|
||||
[First login](INSTALL.md#step-1-get-the-token). Do not deploy a shared example password.
|
||||
|
||||
```bash
|
||||
# /etc/pulse/.env
|
||||
The Pulse-generated `.env` controls local authentication and is not exposed
|
||||
to the UI. Preserve its owner-only permissions. A bcrypt hash is sensitive too:
|
||||
someone who obtains it can attempt offline password cracking.
|
||||
|
||||
# Admin Credentials (bcrypt hashed; plain text auto-hashes on startup)
|
||||
```dotenv
|
||||
# Pulse-generated file, not a command to paste into a shell
|
||||
PULSE_AUTH_USER='admin'
|
||||
PULSE_AUTH_PASS='$2a$12$...'
|
||||
PULSE_AUTH_PASS='<complete-bcrypt-hash>'
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><strong>Advanced: Automated Setup (Skip UI)</strong></summary>
|
||||
Deployment-supplied environment values take precedence over this file. Changing
|
||||
the generated file or using Pulse's password-change UI does not replace a
|
||||
password supplied by your deployment manager; update that managed source too.
|
||||
|
||||
You can pre-configure Pulse by setting environment variables. Plain text credentials are automatically hashed on startup.
|
||||
### Private Docker authentication file
|
||||
|
||||
If automation must skip browser setup, keep the credentials in a private file,
|
||||
not a `docker run -e PULSE_AUTH_PASS=...` command, shell history or a shared
|
||||
Compose file. On the Docker host, prepare the file below. These commands do not
|
||||
erase an existing file; edit it locally, without printing its contents:
|
||||
|
||||
```bash
|
||||
# Docker Example
|
||||
docker run -d \
|
||||
-e PULSE_DEPLOYMENT_METHOD=docker_run \
|
||||
-e PULSE_AUTH_USER=admin \
|
||||
-e PULSE_AUTH_PASS=secret123 \
|
||||
rcourtman/pulse:latest
|
||||
(
|
||||
set -e
|
||||
umask 077
|
||||
auth_dir="$HOME/.config/pulse"
|
||||
auth_file="$auth_dir/docker-auth.env"
|
||||
if [ -L "$auth_dir" ] || [ -L "$auth_file" ]; then
|
||||
printf 'Refusing a symlinked credential path.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$auth_dir"
|
||||
chmod 700 "$auth_dir"
|
||||
touch "$auth_file"
|
||||
chmod 600 "$auth_file"
|
||||
vi "$auth_file"
|
||||
)
|
||||
```
|
||||
</details>
|
||||
|
||||
In that editor, enter these two settings and replace the placeholder with a
|
||||
**complete bcrypt hash** generated by a trusted local tool with an interactive
|
||||
password prompt. Do not put the password in that tool's command arguments.
|
||||
Unlike Pulse's own quoted `.env`, Docker's `--env-file` input uses unquoted
|
||||
values; keep the hash's `$` characters literal, with no `$$` substitution:
|
||||
|
||||
```dotenv
|
||||
PULSE_AUTH_USER=admin
|
||||
PULSE_AUTH_PASS=<complete-bcrypt-hash>
|
||||
```
|
||||
|
||||
For a **new Community container**, replace `vX.Y.Z` with the exact published
|
||||
tag you intend to install, then run:
|
||||
|
||||
```bash
|
||||
(
|
||||
set -e
|
||||
if [ ! -s "$HOME/.config/pulse/docker-auth.env" ]; then
|
||||
printf 'Prepare the private authentication file first.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
docker run -d \
|
||||
--name pulse \
|
||||
-p 7655:7655 \
|
||||
-v pulse_data:/data \
|
||||
--env-file "$HOME/.config/pulse/docker-auth.env" \
|
||||
-e PULSE_DEPLOYMENT_METHOD=docker_run \
|
||||
--restart unless-stopped \
|
||||
rcourtman/pulse:vX.Y.Z
|
||||
)
|
||||
```
|
||||
|
||||
This keeps credential values out of the command arguments, **not out of Docker's
|
||||
container environment**: administrators with Docker access can still read them.
|
||||
Pulse hashes a plaintext `PULSE_AUTH_PASS` for authentication at startup, but
|
||||
does not scrub its original value from Docker or your deployment file. Keep the
|
||||
file private, outside the repository, and do not share `docker inspect` or
|
||||
resolved Compose configuration. Do not source the file into your shell.
|
||||
|
||||
For an existing or paid Pro deployment, retain its image, mounts and managed
|
||||
configuration; this new-container example is not an upgrade procedure. Compose
|
||||
interpolation and Docker `--env-file` are different formats. Prefer bootstrap
|
||||
setup for Compose unless your deployment manager already has a private
|
||||
credential mechanism. See the [Docker guide](DOCKER.md) for setup and updates.
|
||||
|
||||
<details>
|
||||
<summary><strong>Advanced: OIDC / SSO</strong></summary>
|
||||
|
||||
@@ -45,9 +45,6 @@ services:
|
||||
environment:
|
||||
- TZ=Europe/London
|
||||
- PULSE_DEPLOYMENT_METHOD=docker_compose
|
||||
# Optional: Pre-configure auth (skips setup wizard)
|
||||
# - PULSE_AUTH_USER=admin
|
||||
# - PULSE_AUTH_PASS=secret123
|
||||
|
||||
volumes:
|
||||
pulse_data:
|
||||
@@ -55,6 +52,13 @@ volumes:
|
||||
|
||||
Run with: `docker compose up -d`
|
||||
|
||||
Leave authentication overrides unset for a new install and complete
|
||||
[bootstrap-token setup](INSTALL.md#step-1-get-the-token) in your browser. Do not add a
|
||||
shared example password to the Compose file. If automation must skip setup,
|
||||
use a private deployment-managed credential source; the
|
||||
[private Docker authentication file](CONFIGURATION.md#private-docker-authentication-file)
|
||||
example is for `docker run`, not Compose interpolation.
|
||||
|
||||
The `PULSE_IMAGE` variable lets the same compose file run either the public
|
||||
community image or, for eligible paid customers, the private Pulse Pro image
|
||||
shown on <https://pulserelay.pro/download.html>.
|
||||
@@ -79,7 +83,10 @@ Pulse is configured via the UI (`system.json`) with optional environment overrid
|
||||
|
||||
> **Tip**: Set `LOG_LEVEL=warn` to reduce log volume while still capturing important events.
|
||||
> **Note**: API tokens are managed in the UI and stored in `api_tokens.json`.
|
||||
> **Note**: Plain text values in `PULSE_AUTH_PASS` are auto-hashed on startup.
|
||||
> **Note**: Plain text values in `PULSE_AUTH_PASS` are hashed for authentication
|
||||
> at startup, but remain in the container environment and deployment file.
|
||||
> Docker administrators can read those values. Prefer bootstrap setup; never
|
||||
> share full `docker inspect` or resolved Compose output.
|
||||
|
||||
For SSD-sensitive installs, keep `/data` persistent and put only metrics
|
||||
history on tmpfs:
|
||||
|
||||
@@ -90,14 +90,20 @@ services:
|
||||
- "7655:7655"
|
||||
volumes:
|
||||
- pulse_data:/data
|
||||
environment:
|
||||
- PULSE_AUTH_USER=admin
|
||||
- PULSE_AUTH_PASS=secret123
|
||||
|
||||
volumes:
|
||||
pulse_data:
|
||||
```
|
||||
|
||||
Lass `PULSE_AUTH_USER` und `PULSE_AUTH_PASS` bei einer neuen Installation
|
||||
ungesetzt und richte den Zugang beim ersten Anmelden mit dem Bootstrap-Token
|
||||
ein. Verwende kein gemeinsames Beispielpasswort. Für automatisierte Einrichtung
|
||||
nutze eine private, vom Deployment verwaltete Zugangsdatenquelle; siehe
|
||||
[Configuration](../../CONFIGURATION.md#private-docker-authentication-file).
|
||||
Passwörter gehören nicht in Befehlsargumente oder eine geteilte Compose-Datei.
|
||||
Ein Docker-Administrator kann Zugangsdaten in der Container-Umgebung weiterhin
|
||||
lesen, auch wenn Pulse sie für die Anmeldung hasht.
|
||||
|
||||
## Erste Anmeldung
|
||||
|
||||
Beim ersten Start musst du ein **Bootstrap Token** abrufen und damit den
|
||||
|
||||
@@ -89,14 +89,20 @@ services:
|
||||
- "7655:7655"
|
||||
volumes:
|
||||
- pulse_data:/data
|
||||
environment:
|
||||
- PULSE_AUTH_USER=admin
|
||||
- PULSE_AUTH_PASS=secret123
|
||||
|
||||
volumes:
|
||||
pulse_data:
|
||||
```
|
||||
|
||||
Deja `PULSE_AUTH_USER` y `PULSE_AUTH_PASS` sin configurar en una instalación
|
||||
nueva y completa el primer acceso con el token de arranque. No uses una
|
||||
contraseña de ejemplo compartida. Para una configuración automatizada, usa
|
||||
una fuente privada de credenciales gestionada por el despliegue; consulta
|
||||
[Configuration](../../CONFIGURATION.md#private-docker-authentication-file).
|
||||
No pongas contraseñas en argumentos de comandos ni en un archivo Compose
|
||||
compartido. Un administrador de Docker aún puede leer las credenciales en el
|
||||
entorno del contenedor, aunque Pulse las convierta en hash para la autenticación.
|
||||
|
||||
## Primer inicio de sesión
|
||||
|
||||
En el primer arranque debes recuperar un **Bootstrap Token** y usarlo para
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise private-file auth recipes with synthetic credentials, not Docker.
|
||||
|
||||
The recorders verify file permissions, argument boundaries and failure stops.
|
||||
They do not establish native container startup or a password-change outcome.
|
||||
"""
|
||||
|
||||
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 = ("CONFIGURATION.md", "DOCKER.md", "i18n/de/README.md", "i18n/es/README.md")
|
||||
HASH = "$2a$12$" + "a" * 53 # Synthetic file/argv sentinel, not a real password.
|
||||
CONTENT = f"PULSE_AUTH_USER=admin\nPULSE_AUTH_PASS={HASH}\n"
|
||||
|
||||
|
||||
def recipe(needle: str) -> str:
|
||||
text = (ROOT / "docs/CONFIGURATION.md").read_text(encoding="utf-8")
|
||||
matches = [block for block in re.findall(r"```bash\n(.*?)```", text, re.S)
|
||||
if needle in block]
|
||||
if len(matches) != 1:
|
||||
raise AssertionError(f"expected one configuration recipe containing {needle!r}")
|
||||
return matches[0]
|
||||
|
||||
|
||||
EDITOR = '''#!/usr/bin/env python3
|
||||
import json, os, stat, sys
|
||||
from pathlib import Path
|
||||
p = Path(sys.argv[1])
|
||||
assert p == Path.home() / '.config/pulse/docker-auth.env'
|
||||
assert stat.S_IMODE(p.stat().st_mode) == 0o600
|
||||
assert stat.S_IMODE(p.parent.stat().st_mode) == 0o700
|
||||
(Path.home() / 'editor.json').write_text(json.dumps(sys.argv[1:]))
|
||||
sys.exit(int(os.environ.get('EDITOR_EXIT', '0')))
|
||||
'''
|
||||
|
||||
DOCKER = '''#!/usr/bin/env python3
|
||||
import json, os, sys
|
||||
from pathlib import Path
|
||||
args = sys.argv[1:]
|
||||
p = Path(args[args.index('--env-file') + 1])
|
||||
assert p == Path.home() / '.config/pulse/docker-auth.env'
|
||||
assert p.read_text() == os.environ['EXPECTED_FILE']
|
||||
assert os.environ['FIXTURE_HASH'] not in ' '.join(args)
|
||||
assert not any(a.startswith('PULSE_AUTH_PASS=') for a in args)
|
||||
(Path.home() / 'docker.json').write_text(json.dumps(args))
|
||||
sys.exit(int(os.environ.get('DOCKER_EXIT', '0')))
|
||||
'''
|
||||
|
||||
|
||||
def environment(home: Path, **overrides: str) -> dict[str, str]:
|
||||
tools = home / "tools"
|
||||
tools.mkdir(exist_ok=True)
|
||||
for name, body in (("vi", EDITOR), ("docker", DOCKER)):
|
||||
script = tools / name
|
||||
script.write_text(body, encoding="utf-8")
|
||||
script.chmod(0o700)
|
||||
return {**os.environ, "HOME": str(home), "PATH": f"{tools}:{os.environ['PATH']}",
|
||||
"FIXTURE_HASH": HASH, "EXPECTED_FILE": CONTENT, **overrides}
|
||||
|
||||
|
||||
def private_file(home: Path, content: str = CONTENT) -> Path:
|
||||
path = home / ".config/pulse/docker-auth.env"
|
||||
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
path.chmod(0o600)
|
||||
return path
|
||||
|
||||
|
||||
class ConfigurationAuthDocsTest(unittest.TestCase):
|
||||
def test_no_shared_password_or_secret_in_command_arguments(self):
|
||||
for name in GUIDES:
|
||||
with self.subTest(guide=name):
|
||||
text = (ROOT / "docs" / name).read_text(encoding="utf-8")
|
||||
self.assertNotIn("secret123", text)
|
||||
shell = "\n".join(re.findall(r"```bash\n(.*?)```", text, re.S))
|
||||
self.assertNotRegex(shell, r"(?:-e|--env)\s+PULSE_AUTH_(?:PASS|USER)=")
|
||||
self.assertNotRegex(shell, r"\b(?:export\s+)?PULSE_AUTH_PASS=")
|
||||
if name != "CONFIGURATION.md":
|
||||
self.assertNotRegex(text, r"PULSE_AUTH_PASS\s*[:=]")
|
||||
|
||||
def test_mirrors_and_sensitive_runtime_limit(self):
|
||||
for name in GUIDES:
|
||||
self.assertEqual((ROOT / "docs" / name).read_bytes(),
|
||||
(ROOT / "frontend-modern/public/docs" / name).read_bytes())
|
||||
text = (ROOT / "docs/CONFIGURATION.md").read_text(encoding="utf-8")
|
||||
for expected in ("not out of Docker's", "does not scrub its original value",
|
||||
"Do not source the file", "Deployment-supplied environment values take precedence",
|
||||
"complete bcrypt hash", "unquoted", "no `$$` substitution",
|
||||
"this new-container example is not an upgrade procedure"):
|
||||
self.assertIn(expected, text)
|
||||
|
||||
def test_preparation_preserves_existing_file_and_protects_both_paths(self):
|
||||
for existing in (False, True):
|
||||
with self.subTest(existing=existing), tempfile.TemporaryDirectory(prefix="auth docs ") as temp:
|
||||
home = Path(temp)
|
||||
path = home / ".config/pulse/docker-auth.env"
|
||||
if existing:
|
||||
private_file(home)
|
||||
path.chmod(0o644)
|
||||
path.parent.chmod(0o755)
|
||||
result = subprocess.run(["bash", "-c", recipe('vi "$auth_file"')],
|
||||
env=environment(home), capture_output=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr.decode())
|
||||
self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600)
|
||||
self.assertEqual(stat.S_IMODE(path.parent.stat().st_mode), 0o700)
|
||||
self.assertEqual(path.read_text(), CONTENT if existing else "")
|
||||
self.assertEqual(json.loads((home / "editor.json").read_text()), [str(path)])
|
||||
self.assertNotIn(HASH.encode(), result.stdout + result.stderr)
|
||||
|
||||
def test_preparation_refuses_symlink_without_touching_target(self):
|
||||
for linked in ("file", "directory"):
|
||||
with self.subTest(linked=linked), tempfile.TemporaryDirectory(prefix="auth docs ") as temp:
|
||||
home = Path(temp)
|
||||
target = home / "retained"
|
||||
target.mkdir()
|
||||
credential = target / "docker-auth.env"
|
||||
credential.write_text(CONTENT)
|
||||
credential.chmod(0o644)
|
||||
directory = home / ".config/pulse"
|
||||
directory.parent.mkdir()
|
||||
if linked == "directory":
|
||||
directory.symlink_to(target, target_is_directory=True)
|
||||
else:
|
||||
directory.mkdir()
|
||||
(directory / "docker-auth.env").symlink_to(credential)
|
||||
result = subprocess.run(["bash", "-c", recipe('vi "$auth_file"')],
|
||||
env=environment(home), capture_output=True, timeout=10)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn(b"Refusing a symlinked credential path", result.stderr)
|
||||
self.assertEqual(credential.read_text(), CONTENT)
|
||||
self.assertEqual(stat.S_IMODE(credential.stat().st_mode), 0o644)
|
||||
self.assertFalse((home / "editor.json").exists())
|
||||
|
||||
def test_editor_failure_is_not_reported_as_preparation_success(self):
|
||||
with tempfile.TemporaryDirectory(prefix="auth docs ") as temp:
|
||||
home = Path(temp)
|
||||
path = private_file(home)
|
||||
result = subprocess.run(["bash", "-c", recipe('vi "$auth_file"')],
|
||||
env=environment(home, EDITOR_EXIT="23"), capture_output=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 23)
|
||||
self.assertEqual(path.read_text(), CONTENT)
|
||||
|
||||
def test_launch_passes_only_private_path_and_preserves_hash_bytes(self):
|
||||
with tempfile.TemporaryDirectory(prefix="auth docs ") as temp:
|
||||
home = Path(temp)
|
||||
path = private_file(home)
|
||||
result = subprocess.run(["bash", "-c", recipe("docker run -d")],
|
||||
env=environment(home), capture_output=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr.decode())
|
||||
argv = json.loads((home / "docker.json").read_text())
|
||||
self.assertEqual(argv, ["run", "-d", "--name", "pulse", "-p", "7655:7655",
|
||||
"-v", "pulse_data:/data", "--env-file", str(path), "-e",
|
||||
"PULSE_DEPLOYMENT_METHOD=docker_run", "--restart", "unless-stopped",
|
||||
"rcourtman/pulse:vX.Y.Z"])
|
||||
self.assertNotIn(HASH, json.dumps(argv))
|
||||
self.assertNotIn(HASH.encode(), result.stdout + result.stderr)
|
||||
self.assertEqual(path.read_text(), CONTENT)
|
||||
|
||||
def test_absent_or_empty_file_prevents_docker_start(self):
|
||||
for empty in (False, True):
|
||||
with self.subTest(empty=empty), tempfile.TemporaryDirectory(prefix="auth docs ") as temp:
|
||||
home = Path(temp)
|
||||
if empty:
|
||||
private_file(home, "")
|
||||
result = subprocess.run(["bash", "-c", recipe("docker run -d")],
|
||||
env=environment(home), capture_output=True, timeout=10)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn(b"Prepare the private authentication file first", result.stderr)
|
||||
self.assertFalse((home / "docker.json").exists())
|
||||
|
||||
def test_docker_failure_remains_failed(self):
|
||||
with tempfile.TemporaryDirectory(prefix="auth docs ") as temp:
|
||||
home = Path(temp)
|
||||
path = private_file(home)
|
||||
result = subprocess.run(["bash", "-c", recipe("docker run -d")],
|
||||
env=environment(home, DOCKER_EXIT="31"), capture_output=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 31)
|
||||
self.assertEqual(path.read_text(), CONTENT)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -28,8 +28,6 @@ MACHINE_FACING_STRINGS = (
|
||||
"docker run -d",
|
||||
"rcourtman/pulse:vX.Y.Z",
|
||||
"image: ${PULSE_IMAGE:-rcourtman/pulse:vX.Y.Z}",
|
||||
"PULSE_AUTH_USER=admin",
|
||||
"PULSE_AUTH_PASS=secret123",
|
||||
"docker exec pulse /app/pulse bootstrap-token",
|
||||
"kubectl exec -it <pod> -- /app/pulse bootstrap-token",
|
||||
"sudo pulse bootstrap-token",
|
||||
@@ -70,6 +68,16 @@ class LocalizedPublicDocsTest(unittest.TestCase):
|
||||
self.assertIn("[Agent Security](../../AGENT_SECURITY.md)", content)
|
||||
self.assertIn("[Plans and entitlements](../../PULSE_PRO.md)", content)
|
||||
|
||||
def test_localized_compose_uses_bootstrap_not_shared_credentials(self) -> None:
|
||||
for rel in LOCALIZED_DOCS:
|
||||
content = read(rel)
|
||||
with self.subTest(path=rel):
|
||||
self.assertNotIn("secret123", content)
|
||||
self.assertNotIn("PULSE_AUTH_USER=", content)
|
||||
self.assertNotIn("PULSE_AUTH_PASS=", content)
|
||||
self.assertIn("../../CONFIGURATION.md#private-docker-authentication-file", content)
|
||||
self.assertIn("docker exec pulse /app/pulse bootstrap-token", content)
|
||||
|
||||
def test_localized_docs_do_not_reintroduce_paid_capacity_claims(self) -> None:
|
||||
forbidden = (
|
||||
"unlimited",
|
||||
|
||||
Reference in New Issue
Block a user