fix(frontend): localize security docs links

This commit is contained in:
rcourtman 2026-03-28 19:30:24 +00:00
parent d76e8fc58c
commit 44c529ba92
16 changed files with 1652 additions and 137 deletions

View file

@ -1732,6 +1732,14 @@ both sides instead of relying only on broad settings-surface coverage on the
security side: token settings changes must continue to carry the direct
`api-token-management-surface` API-contract proof together with the
security-side surface proof.
That same governed token-settings boundary also owns its operator-facing scope
reference path. `frontend-modern/src/components/Settings/apiTokenManagerModel.ts`
may expose the scope-reference URL consumed by the API token settings shell,
but that URL must resolve through the shared shipped-doc owner in
`frontend-modern/src/utils/docsLinks.ts` and the local
`/docs/CONFIGURATION.md` asset instead of hardcoding a GitHub `main` document
that can drift from the payload contract actually shipped with the running
build.
That same shared commercial API boundary now also owns the local trial-start
transport contract. `/api/license/trial/start` may allow a short human-scale
burst of retries while the hosted redirect handoff remains canonical, but once

View file

@ -126,6 +126,7 @@ work extends shared components instead of creating new local variants.
95. `frontend-modern/src/components/shared/EnvironmentLockBadge.tsx`
96. `frontend-modern/src/utils/environmentLockPresentation.ts`
97. `frontend-modern/src/utils/docsLinks.ts`
98. `tests/integration/tests/20-local-doc-links.spec.ts`
## Shared Boundaries
@ -238,6 +239,13 @@ than hardcoding panel copy, routes, or range presets in the frontend. The
frontend models may validate and present the catalog, but the canonical panel
title, descriptions, endpoints, filename prefixes, range windows, and column
list belong to the API reporting contract.
That same settings-shell boundary now also owns operator-facing docs referrals
for governed security panels. `APIAccessPanel.tsx` and
`SecurityOverviewPanel.tsx` must route scope and proxy-auth guidance through
the shared shipped-doc helper in `frontend-modern/src/utils/docsLinks.ts`
instead of hardcoding GitHub `main` URLs that can drift from the running
build, and `tests/integration/tests/20-local-doc-links.spec.ts` must keep
browser proof on those settings-shell surfaces.
The same reporting catalog ownership now also governs the operator resource-
selection cap for performance reports. `ReportingPanel.tsx` and
`ResourcePicker.tsx` may present or enforce that limit, but they must receive

View file

@ -125,6 +125,14 @@ owner for privacy-document URLs, while `frontend-modern/public/docs/PRIVACY.md`
is the version-matched asset served by the running build. Privacy disclosures
must not drift back to GitHub `main` links that can describe a different
revision than the installed runtime.
That same shipped-doc boundary now also governs the rest of the operator
security trust surface: API token scope reference links, proxy-auth guidance,
and the runtime security warning must open the local
`/docs/CONFIGURATION.md`, `/docs/PROXY_AUTH.md`, and `/docs/SECURITY.md`
assets instead of version-drifting GitHub URLs, and
`frontend-modern/src/components/SecurityWarning.tsx` must stay reactive after
its async status fetch so low-security installs actually surface that governed
security guidance in the running UI.
That same governed settings trust boundary now also includes
`frontend-modern/src/components/Settings/QuickSecuritySetup.tsx`,
`frontend-modern/src/components/Settings/SecurityPostureSummary.tsx`,

View file

@ -0,0 +1,519 @@
# ⚙️ Configuration Guide
Pulse uses a split-configuration model to ensure security and flexibility.
| File | Purpose | Security Level |
| ------ | --------- | ---------------- |
| `.env` | Authentication & Secrets | 🔒 **Critical** (Read-only by owner) |
| `.encryption.key` | Encryption key for `.enc` files | 🔒 **Critical** |
| `.audit-signing.key` | Audit log signing key (Pro/Pro+/Cloud, encrypted) | 🔒 **Sensitive** |
| `system.json` | General Settings | 📝 Standard |
| `nodes.enc` | Node Credentials | 🔒 **Encrypted** (AES-256-GCM) |
| `alerts.json` | Alert Rules | 📝 Standard |
| `email.enc` | SMTP settings | 🔒 **Encrypted** |
| `webhooks.enc` | Webhook URLs + headers | 🔒 **Encrypted** |
| `apprise.enc` | Apprise notification config | 🔒 **Encrypted** |
| `oidc.enc` | OIDC provider config | 🔒 **Encrypted** |
| `sso.enc` | SAML/SSO provider config | 🔒 **Encrypted** |
| `api_tokens.json` | API token records (hashed) | 🔒 **Sensitive** |
| `ai.enc` | AI settings and credentials | 🔒 **Encrypted** |
| `ai_findings.json` | AI Patrol findings | 📝 Standard |
| `ai_patrol_runs.json` | AI Patrol run history | 📝 Standard |
| `ai_usage_history.json` | AI usage history | 📝 Standard |
| `ai_chat_sessions.json` | Legacy AI chat sessions (UI sync) | 📝 Standard |
| `license.enc` | Relay/Pro/Pro+/Cloud license key | 🔒 **Encrypted** |
| `host_metadata.json` | Host notes, tags, and AI command overrides | 📝 Standard |
| `docker_metadata.json` | Docker metadata cache | 📝 Standard |
| `guest_metadata.json` | Guest notes and metadata | 📝 Standard |
| `agent_profiles.json` | Agent configuration profiles (Pro/Pro+/Cloud) | 📝 Standard |
| `agent_profile_assignments.json` | Agent profile assignments (Pro/Pro+/Cloud) | 📝 Standard |
| `profile-versions.json` | Agent profile version history (Pro/Pro+/Cloud) | 📝 Standard |
| `profile-deployments.json` | Agent profile deployment status (Pro/Pro+/Cloud) | 📝 Standard |
| `profile-changelog.json` | Agent profile change log (Pro/Pro+/Cloud) | 📝 Standard |
| `recovery_tokens.json` | Recovery tokens (short-lived) | 🔒 **Sensitive** |
| `sessions.json` | Persistent sessions (includes OIDC refresh tokens) | 🔒 **Sensitive** |
| `update-history.jsonl` | Update history log (in-app updates) | 📝 Standard |
| `metrics.db` | Persistent metrics history (SQLite) | 📝 Standard |
| `audit.db` | Audit log database (Pro/Pro+/Cloud, SQLite) | 🔒 **Sensitive** |
| `baselines.json` | AI baseline data for anomaly detection | 📝 Standard |
| `ai_correlations.json` | AI correlation analysis cache | 📝 Standard |
| `ai_patterns.json` | AI pattern detection data | 📝 Standard |
| `ai_remediations.json` | AI remediation suggestions | 📝 Standard |
| `ai_incidents.json` | AI incident tracking | 📝 Standard |
| `org.json` | Organization metadata (multi-tenant) | 📝 Standard |
Guest metadata entries are keyed by the canonical guest ID format `instance:node:vmid` (for example, `pve1:node1:100`). Legacy dash-separated keys are migrated automatically.
All files are located in `/etc/pulse/` (Systemd) or `/data/` (Docker/Kubernetes) by default.
Path overrides:
- `PULSE_DATA_DIR` sets the base directory for `system.json`, encrypted files, and the bootstrap token.
Multi-tenant layout:
- Default org uses the root data directory for backward compatibility.
- Non-default orgs store data under `/orgs/<org-id>/`.
- Migration may create `/orgs/default/` and symlinks in the root data directory.
---
## 🔐 Authentication (`.env`)
This file controls access to Pulse. It is **never** exposed to the UI.
```bash
# /etc/pulse/.env
# Admin Credentials (bcrypt hashed; plain text auto-hashes on startup)
PULSE_AUTH_USER='admin'
PULSE_AUTH_PASS='$2a$12$...'
```
<details>
<summary><strong>Advanced: Automated Setup (Skip UI)</strong></summary>
You can pre-configure Pulse by setting environment variables. Plain text credentials are automatically hashed on startup.
```bash
# Docker Example
docker run -d \
-e PULSE_AUTH_USER=admin \
-e PULSE_AUTH_PASS=secret123 \
rcourtman/pulse:latest
```
</details>
<details>
<summary><strong>Advanced: OIDC / SSO</strong></summary>
Configure Single Sign-On in **Settings → Security → Single Sign-On**, or use environment variables to lock the configuration.
See [OIDC Documentation](OIDC.md) and [Proxy Auth](PROXY_AUTH.md) for details.
Environment overrides (lock the corresponding UI fields):
| Variable | Description |
| ---------- | ------------- |
| `OIDC_ENABLED` | Enable OIDC (`true`/`false`) |
| `OIDC_ISSUER_URL` | Issuer URL from your IdP |
| `OIDC_CLIENT_ID` | Client ID |
| `OIDC_CLIENT_SECRET` | Client secret |
| `OIDC_REDIRECT_URL` | Override redirect URL (defaults to `<public-url>/api/oidc/<provider-id>/callback`) |
| `OIDC_LOGOUT_URL` | Optional logout URL |
| `OIDC_SCOPES` | Space or comma-separated scopes |
| `OIDC_USERNAME_CLAIM` | Claim for username (default: `preferred_username`) |
| `OIDC_EMAIL_CLAIM` | Claim for email (default: `email`) |
| `OIDC_GROUPS_CLAIM` | Claim for groups |
| `OIDC_ALLOWED_GROUPS` | Allowed groups (space or comma-separated) |
| `OIDC_ALLOWED_DOMAINS` | Allowed email domains (space or comma-separated) |
| `OIDC_ALLOWED_EMAILS` | Allowed emails (space or comma-separated) |
| `OIDC_GROUP_ROLE_MAPPINGS` | Comma-separated group=role mappings (Pro/Pro+/Cloud) |
| `OIDC_CA_BUNDLE` | Custom CA bundle path |
</details>
> **Note**: `API_TOKEN` / `API_TOKENS` in `.env` are legacy and ignored at runtime in v6.
> Manage API tokens in the UI (`api_tokens.json`) for supported behavior.
---
## 🖥️ System Settings (`system.json`)
Controls runtime behavior like logging, polling intervals, and UI preferences. Legacy port fields in `system.json` are ignored; use `FRONTEND_PORT` instead.
<details>
<summary><strong>Example system.json</strong></summary>
```json
{
"pvePollingInterval": 10, // Seconds
"backendPort": 3000, // Legacy (unused)
"frontendPort": 7655, // Legacy (ignored; use FRONTEND_PORT)
"logLevel": "info", // debug, info, warn, error
"autoUpdateEnabled": false, // Enable auto-update checks
"adaptivePollingEnabled": false, // Smart polling for large clusters
"allowedOrigins": "", // CORS allowlist (single origin or "*")
"allowEmbedding": false, // Allow iframe embedding
"allowedEmbedOrigins": "", // Comma-separated origins for iframe embedding
"webhookAllowedPrivateCIDRs": "" // Allowlist for private webhook targets
}
```
> **Note**: `logFormat` is only configurable via the `LOG_FORMAT` environment variable, not in `system.json`.
> **Note**: `autoUpdateTime` is stored by the UI, but the systemd timer uses its own schedule.
</details>
### Supported system.json Keys
Numeric intervals are **seconds** unless noted otherwise.
| Key | Description |
| ----- | ----------- |
| `pvePollingInterval` | PVE polling interval |
| `pbsPollingInterval` | PBS polling interval |
| `pmgPollingInterval` | PMG polling interval |
| `backupPollingInterval` | Backup polling interval (`0` = auto) |
| `backupPollingEnabled` | Enable backup polling |
| `adaptivePollingEnabled` | Enable adaptive polling |
| `adaptivePollingBaseInterval` | Base interval for adaptive polling |
| `adaptivePollingMinInterval` | Minimum adaptive polling interval |
| `adaptivePollingMaxInterval` | Maximum adaptive polling interval |
| `connectionTimeout` | API connection timeout |
| `logLevel` | Server log level (`debug`, `info`, `warn`, `error`) |
| `allowedOrigins` | CORS allowlist (single origin or `*`) |
| `allowEmbedding` | Allow iframe embedding |
| `allowedEmbedOrigins` | Comma-separated `frame-ancestors` allowlist |
| `webhookAllowedPrivateCIDRs` | Allowlist for private webhook targets |
| `updateChannel` | Update channel (`stable` or `rc`) |
| `autoUpdateEnabled` | Allow one-click updates |
| `autoUpdateCheckInterval` | Update check interval (hours) |
| `autoUpdateTime` | UI-stored preferred update time |
| `publicURL` | Public URL used in links/notifications |
| `hideLocalLogin` | Hide username/password login form |
| `temperatureMonitoringEnabled` | Enable temperature monitoring (where supported) |
| `dnsCacheTimeout` | DNS cache timeout |
| `sshPort` | Default SSH port for temperature collection |
| `discoveryEnabled` | Enable auto-discovery |
| `discoverySubnet` | CIDR or `auto` |
| `discoveryConfig` | Discovery tuning object (see below) |
| `theme` | UI theme (`light`, `dark`, or empty for system) |
| `fullWidthMode` | UI layout preference |
| `metricsRetentionRawHours` | Raw metrics retention (hours) |
| `metricsRetentionMinuteHours` | Minute metrics retention (hours) |
| `metricsRetentionHourlyDays` | Hourly metrics retention (days) |
| `metricsRetentionDailyDays` | Daily metrics retention (days) |
| `disableDockerUpdateActions` | Hide Docker update actions in UI |
| `reduceProUpsellNoise` | Reduce proactive Pro prompts (paywalls still appear when accessing gated features) |
| `disableLocalUpgradeMetrics` | Disable local-only upgrade metrics collection |
| `backendPort` | Legacy (unused) |
| `frontendPort` | Legacy (ignored; use `FRONTEND_PORT`) |
`discoveryConfig` supports:
- `environmentOverride`, `subnetAllowlist`, `subnetBlocklist`
- `maxHostsPerScan`, `maxConcurrent`, `enableReverseDns`, `scanGateways`
- `dialTimeoutMs`, `httpTimeoutMs`
### Common Overrides (Environment Variables)
Environment variables take precedence over `system.json`.
| Variable | Description | Default |
| ---------- | ------------- | --------- |
| `FRONTEND_PORT` | Public listening port | `7655` |
| `LOG_LEVEL` | Log verbosity (see below) | `info` |
| `LOG_FORMAT` | Log output format (`auto`, `json`, `console`) | `auto` |
| `LOG_FILE` | Log file path (enables file logging) | *(unset)* |
| `LOG_MAX_SIZE` | Log rotation size (MB) | `100` |
| `LOG_MAX_AGE` | Keep rotated logs for N days (`0` disables cleanup) | `30` |
| `LOG_COMPRESS` | Gzip rotated logs | `true` |
#### Log Levels
| Level | Description |
| ------- | ------------- |
| `error` | Only errors and critical issues |
| `warn` | Errors + warnings (recommended for minimal logging) |
| `info` | Standard operational messages (startup, connections, alerts) |
| `debug` | Verbose output including per-guest/storage polling details |
> **Tip**: If your syslog is being flooded with Pulse messages, set `LOG_LEVEL=warn` to significantly reduce log volume while still capturing important events.
| Variable | Description | Default |
| ---------- | ------------- | --------- |
| `PULSE_PUBLIC_URL` | URL for UI links, notifications, and OIDC. For reverse proxies, keep this as the public URL and use `PULSE_AGENT_CONNECT_URL` for agent installs if you need a direct/internal address. | Auto-detected |
| `PULSE_PRO_TRIAL_SIGNUP_URL` | Hosted signup/checkout URL used when users click **Start Free Pro Trial**. Must be absolute `http(s)` URL. | `https://cloud.pulserelay.pro/start-pro-trial?...` |
| `PULSE_AGENT_CONNECT_URL` | Dedicated direct URL for agents (overrides `PULSE_PUBLIC_URL` for agent install commands). Alias: `PULSE_AGENT_URL`. | *(unset)* |
| `PULSE_AGENT_CONFIG_SIGNING_KEY` | Base64 Ed25519 private key used to sign remote agent config payloads. | *(unset)* |
| `PULSE_AGENT_CONFIG_PUBLIC_KEYS` | Comma-separated base64 Ed25519 public keys (raw 32-byte or PKIX-encoded) trusted by agents. | *(unset)* |
| `PULSE_AGENT_CONFIG_SIGNATURE_REQUIRED` | Require signed remote config payloads (set on Pulse and agents). | `false` |
| `ALLOWED_ORIGINS` | CORS allowed origin (`*` or a single origin). Empty = same-origin only. | *(unset)* |
| `DISCOVERY_ENABLED` | Auto-discover nodes | `false` |
| `DISCOVERY_SUBNET` | CIDR or `auto` | `auto` |
| `DISCOVERY_ENVIRONMENT_OVERRIDE` | Force discovery environment (`auto`, `native`, `docker-host`, `docker-bridge`, `lxc-privileged`, `lxc-unprivileged`) | `auto` |
| `DISCOVERY_SUBNET_ALLOWLIST` | Comma-separated CIDRs allowed for discovery | *(empty)* |
| `DISCOVERY_SUBNET_BLOCKLIST` | Comma-separated CIDRs excluded from discovery | `169.254.0.0/16` |
| `DISCOVERY_MAX_HOSTS_PER_SCAN` | Max hosts to scan per run | `1024` |
| `DISCOVERY_MAX_CONCURRENT` | Max concurrent discovery probes | `50` |
| `DISCOVERY_ENABLE_REVERSE_DNS` | Enable reverse DNS lookup (`true`/`false`) | `true` |
| `DISCOVERY_SCAN_GATEWAYS` | Include gateway IPs in discovery (`true`/`false`) | `true` |
| `DISCOVERY_DIAL_TIMEOUT_MS` | TCP dial timeout (ms) | `1000` |
| `DISCOVERY_HTTP_TIMEOUT_MS` | HTTP probe timeout (ms) | `2000` |
| `PULSE_AUTH_HIDE_LOCAL_LOGIN` | Hide username/password form | `false` |
| `DEMO_MODE` | Enable read-only demo mode | `false` |
| `PULSE_TRUSTED_PROXY_CIDRS` | Comma-separated IPs/CIDRs trusted to supply `X-Forwarded-For`/`X-Real-IP` | *(unset)* |
| `PULSE_TRUSTED_NETWORKS` | Comma-separated CIDRs treated as trusted local networks (does not bypass auth) | *(unset)* |
| `ALLOW_UNPROTECTED_EXPORT` | Allow unauthenticated config export on public networks when no auth is configured (use with caution) | `false` |
### Iframe Embedding (system.json)
Embedding is controlled by `system.json` and the UI (**Settings → System → Network**):
- `allowEmbedding` (boolean): enables iframe embedding
- `allowedEmbedOrigins` (comma-separated): restricts `frame-ancestors` when embedding is enabled
When `allowEmbedding` is `false`, Pulse sends `X-Frame-Options: DENY` and `frame-ancestors 'none'`.
### Monitoring Overrides
| Variable | Description | Default |
| ---------- | ------------- | --------- |
| `PVE_POLLING_INTERVAL` | PVE metrics polling frequency | `10s` |
| `PBS_POLLING_INTERVAL` | PBS metrics polling frequency | `60s` |
| `PMG_POLLING_INTERVAL` | PMG metrics polling frequency | `60s` |
| `CONNECTION_TIMEOUT` | API connection timeout | `60s` |
| `BACKUP_POLLING_CYCLES` | Poll cycles between backup checks | `10` |
| `ENABLE_BACKUP_POLLING` | Enable backup job monitoring | `true` |
| `BACKUP_POLLING_INTERVAL` | Backup polling frequency | `0` (Auto) |
| `ENABLE_TEMPERATURE_MONITORING` | Enable temperature monitoring (where supported) | `true` |
| `SSH_PORT` | SSH port for temperature collection over SSH | `22` |
| `ADAPTIVE_POLLING_ENABLED` | Enable smart polling for large clusters | `false` |
| `ADAPTIVE_POLLING_BASE_INTERVAL` | Base interval for adaptive polling | `10s` |
| `ADAPTIVE_POLLING_MIN_INTERVAL` | Minimum adaptive polling interval | `5s` |
| `ADAPTIVE_POLLING_MAX_INTERVAL` | Maximum adaptive polling interval | `5m` |
| `GUEST_METADATA_MIN_REFRESH_INTERVAL` | Minimum refresh for guest metadata | `2m` |
| `GUEST_METADATA_REFRESH_JITTER` | Jitter for guest metadata refresh | `45s` |
| `GUEST_METADATA_RETRY_BACKOFF` | Retry backoff for guest metadata | `30s` |
| `GUEST_METADATA_MAX_CONCURRENT` | Max concurrent guest metadata fetches | `4` |
| `DNS_CACHE_TIMEOUT` | Cache TTL for DNS lookups | `5m` |
| `MAX_POLL_TIMEOUT` | Maximum time per polling cycle | `3m` |
| `PULSE_DISABLE_DOCKER_UPDATE_ACTIONS` | Hide Docker update buttons (read-only mode) | `false` |
| `PULSE_DISABLE_LOCAL_UPGRADE_METRICS` | Disable local-only upgrade metrics collection | `false` |
| `PULSE_TELEMETRY` | Anonymous usage telemetry ([details](PRIVACY.md)); set `false` to disable | `true` |
### Logging Overrides
| Variable | Description | Default |
| ---------- | ------------- | --------- |
| `LOG_FILE` | Log file path (empty = stderr only) | *(unset)* |
| `LOG_MAX_SIZE` | Log file max size (MB) | `100` |
| `LOG_MAX_AGE` | Log file retention (days, `0` disables cleanup) | `30` |
| `LOG_COMPRESS` | Compress rotated logs | `true` |
### Update Settings (system.json)
These are stored in `system.json` and managed via the UI.
| Key | Description | Default |
| ----- | ------------- | --------- |
| `updateChannel` | Update channel (`stable` or `rc`) | `stable` |
| `autoUpdateEnabled` | Allow one-click updates | `false` |
| `autoUpdateCheckInterval` | Background update check interval in hours (`0` disables) | `24` |
| `autoUpdateTime` | Stored UI preference (systemd timer has its own schedule) | `03:00` |
> **Note**: Update settings are stored in `system.json`. Legacy `.env` entries (`UPDATE_CHANNEL`, `AUTO_UPDATE_ENABLED`, `AUTO_UPDATE_CHECK_INTERVAL`, `AUTO_UPDATE_TIME`) are kept in sync for backwards compatibility but are not read at runtime.
>
> `stable` is the default and recommended production channel. `rc` is an
> opt-in preview channel. In v6, unattended systemd auto-updates remain
> `stable`-only even when `updateChannel` is set to `rc`.
### Auto-Import (Bootstrap)
You can auto-import an encrypted backup on first startup. This is useful for automated provisioning and test environments.
| Variable | Description |
| ---------- | ------------- |
| `PULSE_INIT_CONFIG_DATA` | Base64 or raw contents of an export bundle (auto-imports on first start) |
| `PULSE_INIT_CONFIG_FILE` | Path to an export bundle on disk (auto-imports on first start) |
| `PULSE_INIT_CONFIG_PASSPHRASE` | Passphrase for the export bundle (required) |
> **Note**: `PULSE_INIT_CONFIG_URL` is only supported by the hidden `pulse config auto-import` command, not by the server startup auto-import.
### Developer/Test Overrides (Environment Variables)
These are primarily for development or test harnesses and should not be used in production.
| Variable | Description | Default |
| ---------- | ------------- | --------- |
| `PULSE_UPDATE_SERVER` | Override update server base URL (testing only) | *(unset)* |
| `PULSE_UPDATE_STAGE_DELAY_MS` | Adds artificial delays between update stages (testing only) | *(unset)* |
| `PULSE_ALLOW_DOCKER_UPDATES` | Expose update UI/actions in Docker (debug only) | `false` |
| `PULSE_DEV_ALLOW_CONTAINER_SSH` | Allow SSH-based temperature collection from containers (dev/test only) | `false` |
| `PULSE_AI_ALLOW_LOOPBACK` | Allow AI tool HTTP fetches to loopback addresses | `false` |
| `PULSE_LICENSE_PUBLIC_KEY` | Override embedded license public key (base64, dev only) | *(unset)* |
| `PULSE_LICENSE_DEV_MODE` | Skip license verification (development only) | `false` |
### Metrics Retention (Tiered)
Persistent metrics history uses tiered retention windows. These values are stored in `system.json` and can be adjusted for storage vs history depth:
- `metricsRetentionRawHours`
- `metricsRetentionMinuteHours`
- `metricsRetentionHourlyDays`
- `metricsRetentionDailyDays`
See [METRICS_HISTORY.md](METRICS_HISTORY.md) for details.
---
## 🔔 Alerts (`alerts.json`)
Pulse uses a powerful alerting engine with hysteresis (separate trigger/clear thresholds) to prevent flapping.
**Managed via UI**: Alerts → Thresholds
<details>
<summary><strong>Manual Configuration (JSON)</strong></summary>
```json
{
"guestDefaults": {
"cpu": { "trigger": 90, "clear": 80 },
"memory": { "trigger": 85, "clear": 72.5 }
},
"schedule": {
"quietHours": {
"enabled": true,
"start": "22:00",
"end": "06:00"
}
}
}
```
</details>
---
## 🔒 HTTPS / TLS
Enable HTTPS by providing certificate files via environment variables.
```bash
# Systemd
HTTPS_ENABLED=true
TLS_CERT_FILE=/etc/pulse/cert.pem
TLS_KEY_FILE=/etc/pulse/key.pem
# Docker
docker run --init -e HTTPS_ENABLED=true \
-v /path/to/certs:/certs \
-e TLS_CERT_FILE=/certs/cert.pem \
-e TLS_KEY_FILE=/certs/key.pem ...
```
> **Important (Docker with HTTPS)**: Always use `--init` (or `init: true` in docker-compose) when enabling HTTPS. The Alpine-based healthcheck uses busybox `wget`, which spawns `ssl_client` subprocesses. Without an init process to reap them, these become zombie processes over time.
---
## 🛡️ Security Best Practices
1. **Permissions**: Ensure `.env` and `nodes.enc` are `600` (read/write by owner only).
2. **Backup hygiene**: Back up `.env` separately from `system.json`.
3. **Tokens**: Use scoped API tokens for agents instead of the admin password.
---
## 🔑 API Tokens
API tokens provide scoped, revocable access to Pulse. Manage tokens in **Settings → Security → API Tokens**.
### Token Scopes
| Scope | Description |
| ------- | ------------- |
| `*` (Full access) | All permissions (legacy, not recommended) |
| `monitoring:read` | View dashboards, metrics, alerts |
| `monitoring:write` | Acknowledge/silence alerts |
| `docker:report` | Container agent telemetry submission |
| `docker:manage` | Container lifecycle actions (restart, stop) |
| `kubernetes:report` | Kubernetes agent telemetry submission |
| `kubernetes:manage` | Kubernetes cluster management |
| `agent:report` | Agent host telemetry submission |
| `agent:config:read` | Read agent config payloads |
| `agent:manage` | Manage registered agents (unlink/delete/config) |
| `settings:read` | Read configuration |
| `settings:write` | Modify configuration |
### Presets
The UI offers quick presets for common use cases:
| Preset | Scopes | Use Case |
| -------- | -------- | ---------- |
| **Kiosk / Dashboard** | `monitoring:read` | Read-only dashboard displays |
| **Agent host** | `agent:report` | Agent host telemetry authentication |
| **Container report** | `docker:report` | Container agent (read-only) |
| **Container manage** | `docker:report`, `docker:manage` | Container agent with actions |
| **Settings read** | `settings:read` | Read-only config access |
| **Settings admin** | `settings:read`, `settings:write` | Full config access |
### Kiosk Mode
For unattended displays (wall monitors, dashboards), use a kiosk token to avoid cookie persistence issues:
1. Go to **Settings → Security → API Tokens**
2. Click **New token** and select the **Kiosk / Dashboard** preset
3. Copy the generated token
4. Access Pulse via URL with token:
```text
https://your-pulse-url/?token=YOUR_TOKEN_HERE
```
**Kiosk tokens:**
- Grant read-only dashboard access (`monitoring:read` scope)
- Hide the Settings tab automatically
- Work without cookies (token in URL)
- Can be revoked anytime from the UI
> **Security note**: URL tokens appear in browser history and server logs. Use only for read-only dashboard access on trusted networks.
---
## TrueNAS Integration {#truenas}
Pulse v6 supports first-class TrueNAS SCALE and CORE monitoring.
### Adding a TrueNAS Instance
1. Go to **Settings → TrueNAS**.
2. Click **Add Connection**.
3. Enter the URL (e.g., `https://truenas.local`) and an API key.
4. Click **Test Connection** to verify, then **Save**.
### Creating a TrueNAS API Key
On your TrueNAS system:
1. Navigate to the TrueNAS UI → **Settings → API Keys**.
2. Click **Add** and create a new read-only key.
3. Copy the key value and paste it into Pulse.
### What Gets Monitored
| Data | Where it appears |
|---|---|
| System info (CPU, memory, uptime) | Infrastructure page |
| ZFS Pools & datasets | Storage page |
| Physical disks | Storage page |
| ZFS Snapshots | Recovery page |
| Replication tasks | Recovery page |
| TrueNAS alerts | Alerts page |
TrueNAS connections are stored encrypted in `truenas.enc`.
---
## Relay / Mobile Remote Access (Relay and Above) {#relay}
The relay protocol provides end-to-end encrypted remote access foundations for Pulse mobile connectivity.
> Supported Pulse Mobile clients pair here using the generated QR code or deep link once relay is enabled for this instance.
### Configuration
1. Go to **Settings → Relay**.
2. Toggle relay **On**.
3. Use the **QR Code** or **Deep Link** to pair a supported Pulse Mobile client.
### Environment Overrides
| Variable | Description | Default |
|---|---|---|
| `PULSE_RELAY_ENABLED` | Enable/disable relay | `false` |
| `PULSE_RELAY_SERVER` | Override relay server URL | `relay.pulserelay.pro` |
### Security
- All data is encrypted end-to-end using ECDH key exchange.
- The relay server never sees plaintext monitoring data.
- Each mobile session has its own encryption channel.
- Requires a valid Relay, Pro, Pro+, or Cloud license (gated by the `relay` feature key).
Relay config is stored encrypted in `relay.enc`.

View file

@ -0,0 +1,73 @@
# 🛡️ Proxy Authentication
Authenticate users via your existing reverse proxy (Authentik, Authelia, Cloudflare Zero Trust, etc.).
## 🚀 Quick Start
1. **Generate Secret**: Create a strong random string.
2. **Configure Pulse**:
```bash
PROXY_AUTH_SECRET=your-random-secret
PROXY_AUTH_USER_HEADER=X-Authentik-Username
```
3. **Configure Proxy**: Set the proxy to send `X-Proxy-Secret` and the user header.
## ⚙️ Configuration
| Variable | Description | Default |
| :--- | :--- | :--- |
| `PROXY_AUTH_SECRET` | **Required**. Shared secret to verify requests. | - |
| `PROXY_AUTH_USER_HEADER` | **Required**. Header containing the username. | - |
| `PROXY_AUTH_ROLE_HEADER` | Header containing user groups/roles. | - |
| `PROXY_AUTH_ROLE_SEPARATOR` | Separator for multiple roles in the header. | `\|` |
| `PROXY_AUTH_ADMIN_ROLE` | Role name that grants admin access. | `admin` |
| `PROXY_AUTH_LOGOUT_URL` | URL to redirect to after logout. | - |
## 📦 Examples
### Authentik (with Traefik)
**docker-compose.yml**:
```yaml
environment:
- PROXY_AUTH_SECRET=secure-secret
- PROXY_AUTH_USER_HEADER=X-Authentik-Username
```
**Traefik Middleware**:
```yaml
headers:
customRequestHeaders:
X-Proxy-Secret: "secure-secret"
```
### Authelia (Nginx)
```nginx
location / {
auth_request /authelia;
proxy_set_header X-Proxy-Secret "secure-secret";
proxy_set_header Remote-User $upstream_http_remote_user;
proxy_pass http://pulse:7655;
}
```
### Cloudflare Tunnel
1. **Zero Trust Dashboard**: Applications → Add Application.
2. **Settings**: HTTP Settings → HTTP Headers.
3. **Add Header**: `X-Proxy-Secret` = `your-secret`.
4. **Pulse Config**: `PROXY_AUTH_USER_HEADER=Cf-Access-Authenticated-User-Email`.
## 🔧 Troubleshooting
| Issue | Check |
| :--- | :--- |
| **401 Unauthorized** | Verify `X-Proxy-Secret` matches `PROXY_AUTH_SECRET`. Check if headers are being stripped by intermediate proxies. |
| **Not Admin** | Verify `PROXY_AUTH_ROLE_HEADER` is set and contains `PROXY_AUTH_ADMIN_ROLE`. |
| **Logout Fails** | Ensure `PROXY_AUTH_LOGOUT_URL` is set to your IdP's logout endpoint. |
### Verify Headers
Use `curl` to simulate a proxy request:
```bash
curl -H "X-Proxy-Secret: your-secret" \
-H "X-Authentik-Username: admin" \
http://localhost:7655/api/state
```

View file

@ -0,0 +1,642 @@
# Pulse Security
This document is the canonical security policy for Pulse. It combines our
ongoing hardening guidance with the operational checklists that previously lived
in `docs/SECURITY.md`.
For a high-level overview of the system design and data flow, please refer to
[`ARCHITECTURE.md`](ARCHITECTURE.md).
---
## Critical Security Notice for Container Deployments
### Container SSH Key Policy (BREAKING CHANGE)
**Effective immediately, SSH-based temperature monitoring is blocked in
containerized Pulse deployments.**
#### Why This Change?
Storing SSH private keys inside Docker/LXC containers creates an unacceptable
risk in production environments:
- **Container compromise = infrastructure compromise** if an attacker gains
shell access to the Pulse container they obtain the SSH private keys used to
reach your Proxmox hosts.
- **Keys persist in images** private keys survive in image layers and can leak
when images are pushed to registries or shared.
- **No key rotation** long-lived keys inside containers are difficult to
rotate safely.
- **Violates least-privilege** monitoring containers should not hold
credentials that grant host-level access to the infrastructure they observe.
#### Affected Deployments
**Not affected** Pulse installed directly on a VM or bare-metal host (no
containers), or homelab environments where you explicitly accept the risk.
**Blocked** Pulse running in Docker containers, LXC containers, or any
environment where `PULSE_DOCKER=true`/`/.dockerenv` is detected.
#### Migration Path (Production)
Preferred option (no SSH keys, no proxy wiring):
1. Install the unified agent (`pulse-agent`) on each Proxmox host with Proxmox integration enabled.
- Use the UI to generate an install command in **Settings → Agents → Installation commands**, or run:
```bash
curl -fsSL http://pulse.example.com:7655/install.sh | \
sudo bash -s -- --url http://pulse.example.com:7655 --token <api-token> --enable-proxmox
```
Legacy sensor proxy (removed):
- `pulse-sensor-proxy` is no longer supported. Migrate to `pulse-agent --enable-proxmox` or SSH-based collection.
- Cleanup steps are in `docs/TEMPERATURE_MONITORING.md`.
#### Removing Old SSH Keys
If you previously generated SSH keys inside containers:
```bash
# On each Proxmox host
sed -i '/# pulse-/d' /root/.ssh/authorized_keys
# Inside the Pulse container (or rebuild the container)
docker exec pulse rm -rf /home/pulse/.ssh/id_ed25519*
```
#### Security Boundary
```text
┌─────────────────────────────────────┐
│ Proxmox Host │
│ ┌───────────────────────────────┐ │
│ │ pulse-agent │ │
│ │ · Reads sensors locally │ │
│ │ · Sends metrics via HTTPS │ │
│ └───────────────────────────────┘ │
│ │ │
│ │ HTTPS + API token │
│ │ │
│ ┌─────────▼─────────────────────┐ │
│ │ Pulse (Docker/LXC container) │ │
│ │ · No SSH keys │ │
│ │ · No host root privileges │ │
│ └───────────────────────────────┘ │
└─────────────────────────────────────┘
```
#### Homelab Exception
If you fully understand the risk and are **not** containerized (VM/bare-metal
install), the legacy SSH flow still works. Use a dedicated monitoring user,
restrict the key with `command="sensors -j"` and `from="<pulse-ip>"`, and
rotate keys regularly.
#### Auditing Your Deployment
```bash
# Detect vulnerable containers
ls /home/pulse/.ssh/id_ed25519* 2>/dev/null && echo "⚠️ SSH keys present"
```
Verify temperature collection is agent-based:
- UI: **Settings → Agents** shows each Proxmox host connected and reporting.
- On each Proxmox host:
```bash
systemctl status pulse-agent
journalctl -u pulse-agent -n 200 --no-pager
```
**Documentation:** <https://github.com/rcourtman/Pulse/blob/main/SECURITY.md#critical-security-notice-for-container-deployments>
**Issues:** <https://github.com/rcourtman/pulse/issues>
**Private disclosures:** <security@pulseapp.io>
---
## Mandatory Authentication
Authentication setup is prompted for all new Pulse installations. This protects your Proxmox API credentials from unauthorized
access.
> **Service name note:** systemd deployments use `pulse.service`. If you're
> upgrading from an older install that still registers `pulse-backend.service`,
> substitute that name in the commands below.
### First-Run Security Setup
When you first access Pulse, you'll be guided through a mandatory security
setup:
- Create your admin username and password
- Automatic API token generation for automation
- Settings are applied immediately without restart
- **Your existing nodes and settings are preserved**
## Smart Security Context
### Public Access Detection
Pulse automatically detects when it's being accessed from public networks:
- **Private networks**: local/RFC1918 addresses (192.168.x.x, 10.x.x.x, etc.)
- **Public networks**: any non-private IP address
- **Stronger warnings**: red alerts when accessed from public IPs without
authentication
### Trusted Networks Configuration (Deprecated)
**Note:** authentication is now mandatory regardless of network location.
Legacy configuration (no longer applicable):
```bash
# Environment variable (comma-separated CIDR blocks)
PULSE_TRUSTED_NETWORKS=192.168.1.0/24,10.0.0.0/24
# Or in systemd
sudo systemctl edit pulse
[Service]
Environment="PULSE_TRUSTED_NETWORKS=192.168.1.0/24,10.0.0.0/24"
```
When configured:
- Access still requires authentication (no bypass).
- The trusted list only influences security posture warnings and diagnostics.
## Security Warning System
Pulse includes a non-intrusive security warning system that helps you
understand your security posture.
### Security Score
Your instance receives a score from 05 based on:
- ✅ Credentials encrypted at rest (always enabled)
- ✅ Export/import protection
- ⚠️ Authentication enabled
- ⚠️ HTTPS connection
- ⚠️ Audit logging
### Dismissing Warnings
If you're comfortable with your security setup, you can dismiss warnings:
- **For 1 day** reminder tomorrow
- **For 1 week** reminder next week
- **Forever** won't show again
## Credential Security
### Encrypted at Rest (AES-256-GCM)
- **Node credentials**: passwords and API tokens (`/etc/pulse/nodes.enc`)
- **Email settings**: SMTP passwords (`/etc/pulse/email.enc`)
- **Webhook data**: URLs and auth headers (`/etc/pulse/webhooks.enc`)
- **Encryption key**: auto-generated (`/etc/pulse/.encryption.key`)
### Security Features
- **Logs**: token values masked with `***` in all outputs
- **API**: frontend receives only `hasToken: true`, never actual values
- **Export**: requires authentication (session, proxy auth, or `X-API-Token`
header) to extract credentials
- **Migration**: use passphrase-protected export/import (see
[Migration Guide](docs/MIGRATION.md))
- **Auto-migration**: unencrypted configs automatically migrate to encrypted
format
## Export/Import Protection
By default, configuration export/import is blocked. You have two options:
### Option 1: Create an API Token (Recommended)
Create a token in **Settings → API Tokens**, then use it for exports.
For automation-only environments, you can seed tokens via environment variables (legacy) and
they will be persisted to `api_tokens.json` on startup.
Legacy environment seeding:
```bash
# Using systemd (secure)
sudo systemctl edit pulse
# Add:
[Service]
Environment="API_TOKENS=ansible-token,agent-token"
Environment="API_TOKEN=legacy-token"
# Then restart:
sudo systemctl restart pulse
# Docker
docker run -e API_TOKENS=ansible-token,agent-token rcourtman/pulse:latest
```
### Option 2: Allow Unprotected Export (Homelab)
```bash
# Using systemd
sudo systemctl edit pulse
# Add:
[Service]
Environment="ALLOW_UNPROTECTED_EXPORT=true"
# Docker
docker run -e ALLOW_UNPROTECTED_EXPORT=true rcourtman/pulse:latest
```
**Note:** for production, prefer Docker secrets or systemd environment files
for sensitive data.
## Security Features Summary
### Core Protection
- **Encryption**: credentials encrypted at rest (AES-256-GCM)
- **Export protection**: exports always encrypted with a passphrase
- **Minimum passphrase**: 12 characters required for exports
- **Security tab**: check status in *Settings → Security → Overview*
### Advanced Security (When Authentication Enabled)
- **Password security**
- Bcrypt hashing with cost factor 12 (60character hash)
- Passwords never stored in plain text
- Automatic hashing during security setup
- **Critical**: bcrypt hashes must be exactly 60 characters
- **API token security**
- 64character hex tokens (32 bytes entropy)
- SHA3-256 hashed before storage (64character hash)
- Raw token shown only once
- Tokens never stored in plain text
- Stored in `api_tokens.json` and managed via the UI
- API-only mode supported (no password auth required)
- **CSRF protection**: all state-changing operations require CSRF tokens
- **Rate limiting**
- Auth endpoints: 10 attempts/minute per IP
- Config changes: 30 requests/minute per IP
- Exports: 5 requests per 5 minutes per IP
- Recovery operations: 3 requests per 10 minutes per IP
- Update checks/actions: 60 requests/minute per IP
- WebSocket connects: 30 requests/minute per IP
- General API: 500 requests/minute per IP
- Public endpoints: 1000 requests/minute per IP
- 429 responses include rate limit headers:
- `X-RateLimit-Limit`: Maximum requests per window
- `X-RateLimit-Remaining`: Requests remaining in current window
- `X-RateLimit-Reset`: Window reset timestamp
- `Retry-After`: Seconds to wait before retrying (on 429 responses)
- **Account lockout**
- Locks after 5 failed login attempts
- 15-minute automatic lockout duration
- Clear feedback showing remaining attempts
- Time remaining displayed when locked
- Manual reset available via API for admins
- **Session management**
- Secure HttpOnly cookies
- 24-hour session expiry (30 days when "Remember me" is enabled)
- Session invalidation on password change
- **Security headers**
- Content-Security-Policy
- X-Frame-Options: `DENY` by default (adjusted when `allowEmbedding` is enabled in system settings)
- X-Content-Type-Options: nosniff
- X-XSS-Protection: 1; mode=block
- Referrer-Policy: strict-origin-when-cross-origin
- Permissions-Policy restricting sensitive APIs
- **Audit logging**
- Authentication events include IP addresses
- Rollback actions are logged with timestamps and metadata
- Scheduler health escalations recorded in audit trail
- Runtime logging configuration changes tracked
- Security status reflects whether persistent audit logging is active (Pulse Pro)
### What's Encrypted in Exports
- Node credentials (passwords, API tokens)
- PBS credentials
- Email settings passwords
- Webhook URLs and authentication headers
### What's **Not** Encrypted
- Node hostnames and IPs
- Threshold settings
- General configuration
- Alert rules and schedules
## Authentication Workflows
Pulse supports multiple authentication methods that can be used independently or
together.
> **Note**: `DISABLE_AUTH` is deprecated and no longer disables authentication. Remove it from your environment and restart if it's still present.
### SSO / Single Sign-On
Pulse supports **OIDC** and **SAML** SSO providers with multi-provider configuration:
- **OIDC**: Google, Authentik, Keycloak, Auth0, or any compliant provider.
- **SAML**: For enterprise IdPs that use SAML assertions.
- Multiple providers can be enabled simultaneously; the login page shows all available SSO buttons.
- Configure via **Settings → Security → SSO Providers** (admin required).
See `docs/PROXY_AUTH.md` for proxy-based auth (Authentik, Authelia, Cloudflare).
### Password Authentication
#### Quick Security Setup (Recommended)
1. Navigate to *Settings → Security → Authentication*.
2. Click **Setup**.
3. Enter username and password.
4. Save the generated API token (shown only once!).
5. Security is enabled immediately (no restart needed).
This automatically:
- Generates a secure random password
- Hashes it with bcrypt (cost factor 12)
- Creates secure API token (SHA3-256 hashed, raw token shown once)
- For systemd: Configures systemd with hashed credentials
- For Docker: Saves to `/data/.env` with hashed credentials (properly quoted to prevent shell expansion)
- Applies credentials immediately and persists them for future restarts
#### Manual Setup (Advanced)
```bash
# Using systemd (plain text will be auto-hashed)
sudo systemctl edit pulse
# Add:
[Service]
Environment="PULSE_AUTH_USER=admin"
Environment="PULSE_AUTH_PASS=$2a$12$..." # Prefer bcrypt hash for production; plain text is auto-hashed.
# Docker (credentials persist in volume via .env file)
# IMPORTANT: Always quote bcrypt hashes to prevent shell expansion!
docker run -e PULSE_AUTH_USER=admin -e PULSE_AUTH_PASS='$2a$12$...' rcourtman/pulse:latest
# Or use Quick Security Setup and restart container
```
**Important**: Always use hashed passwords in configuration. Use the Quick Security Setup or generate bcrypt hashes manually.
#### Features
- Web UI login required when authentication enabled
- Change/remove password from Settings → Security → Authentication
- Passwords ALWAYS hashed with bcrypt (cost 12)
- Session-based authentication with secure HttpOnly cookies
- 24-hour session expiry
- CSRF protection for all state-changing operations
- Session invalidation on password change
### API Token Authentication
For programmatic access and automation. API tokens are SHA3-256 hashed for security.
#### Token Setup via Quick Security
The Quick Security Setup automatically:
- Generates a cryptographically secure token
- Hashes it with SHA3-256
- Stores only the 64-character hash
- Adds the token to the managed token list
#### Manual Token Setup (Legacy Seeding)
```bash
# Using systemd (plain text values are auto-hashed on startup)
sudo systemctl edit pulse
# Add:
[Service]
Environment="API_TOKENS=ansible-token,agent-token"
# Docker
docker run -e API_TOKENS=ansible-token,agent-token rcourtman/pulse:latest
# To provide pre-hashed tokens instead, list the SHA3-256 hashes
# Environment="API_TOKENS=83c8...,b1de..."
```
**Security Note**: Tokens defined via environment variables are hashed with SHA3-256 before being stored in `api_tokens.json`. Plain values never persist beyond startup.
#### Token Management (Settings → API Tokens)
- Issue dedicated tokens for automation/agents without sharing a global credential
- View prefixes/suffixes and last-used timestamps for auditing
- Revoke tokens individually without downtime
- Regenerate tokens when rotating credentials (new value displayed once)
- All tokens stored as SHA3-256 hashes
#### Usage
```bash
# Include the ORIGINAL token (not hash) in X-API-Token header
curl -H "X-API-Token: your-original-token" http://localhost:7655/api/health
# Export config requires auth + passphrase (min 12 chars)
curl -X POST \
-H "Content-Type: application/json" \
-H "X-API-Token: your-original-token" \
-d '{"passphrase":"use-a-strong-passphrase"}' \
http://localhost:7655/api/config/export
```
Most API endpoints also accept `Authorization: Bearer <token>`, but export/import uses the `X-API-Token` header.
### Scoped API Tokens
API tokens can be scoped to limit access. Available scopes:
| Scope | Purpose |
|---|---|
| `monitoring:read` | Read resource data, metrics, charts |
| `monitoring:write` | Update metadata, trigger discovery |
| `settings:read` | Read configuration, export |
| `settings:write` | Modify settings, import, manage nodes |
| `ai:chat` | Use the AI chat assistant |
| `ai:execute` | Run AI commands, view patrol findings |
| `docker:report` | Docker agent metric reporting |
| `kubernetes:report` | Kubernetes agent metric reporting |
| `agent:report` | Agent metric reporting |
| `docker:manage` | Docker host management actions |
| `kubernetes:manage` | Kubernetes cluster management actions |
| `agent:manage` | Agent configuration updates |
Endpoints enforce scope checks before processing. A token without the required scope receives `403 Forbidden`.
### Auto-Registration Security
#### Default Mode
- All access requires authentication
- Nodes can auto-register with the API token
- Setup scripts work without additional configuration
#### Secure Mode
- Require API token for all operations
- Protects auto-registration endpoint
- Enable by creating at least one API token (UI or legacy env seeding)
### Runtime Logging Configuration
Pulse supports configurable logging (level, format, optional file output, rotation) via environment variables.
#### Security Benefits
- Enable debug logging temporarily for incident investigation
- Switch to JSON format for SIEM integration
- Adjust verbosity based on security posture
- Control file rotation to manage audit log retention
#### Configuration Options
**Via environment variables:**
```bash
# Systemd
sudo systemctl edit pulse
[Service]
Environment="LOG_LEVEL=info"
Environment="LOG_FORMAT=json"
Environment="LOG_MAX_SIZE=100" # MB per log file
Environment="LOG_MAX_AGE=30" # Days to retain logs
Environment="LOG_COMPRESS=true" # Compress rotated logs
# Docker
docker run \
-e LOG_LEVEL=info \
-e LOG_FORMAT=json \
-e LOG_MAX_SIZE=100 \
-e LOG_MAX_AGE=30 \
-e LOG_COMPRESS=true \
rcourtman/pulse:latest
```
**Security Considerations:**
- Debug logs may contain sensitive data—enable only when needed
- JSON format recommended for security monitoring and SIEM
- Adjust retention based on compliance requirements
- Changes take effect on restart
## CORS (Cross-Origin Resource Sharing)
By default, Pulse does **not** enable CORS (same-origin only). Configure allowed origins only when
you need cross-origin access (for example, a separate UI domain or external tooling).
### Configuring CORS for External Access
If you need to access the Pulse API from a different domain, configure **Settings → System → Network**
or use environment overrides:
```bash
# Docker
docker run -e ALLOWED_ORIGINS="https://app.example.com" rcourtman/pulse:latest
# systemd
sudo systemctl edit pulse
[Service]
Environment="ALLOWED_ORIGINS=https://app.example.com"
# Development (allow localhost)
ALLOWED_ORIGINS="http://localhost:5173"
```
Notes:
- `ALLOWED_ORIGINS` supports a single origin or `*` (it is written directly to `Access-Control-Allow-Origin`).
- In production, set a specific origin to avoid exposing the API to arbitrary sites.
- For local dev, Pulse auto-allows `http://localhost:5173` and `http://localhost:7655` when `NODE_ENV=development` or `PULSE_DEV=true`.
## Monitoring and Observability
### Scheduler Health API
#### Endpoint
```bash
curl -s http://localhost:7655/api/monitoring/scheduler/health | jq
```
#### Security Use Cases
1. **Anomaly Detection**
- Watch for unusual queue depths (possible DoS)
- Monitor circuit breaker trips (connectivity issues or attacks)
- Track backoff patterns (rate limiting, potential probes)
2. **Performance Monitoring**
- Identify performance degradation
- Detect resource exhaustion
- Track API response times
3. **Incident Response**
- Real-time visibility into system health
- Historical metrics for post-incident analysis
- Circuit breaker status for failover decisions
#### Key Security Metrics
- **Queue Depth**: High values may indicate attack or overload
- **Circuit Breaker Status**: Half-open/open states suggest connectivity issues
- **Backoff Delays**: Increased backoff may indicate rate limiting or errors
- **Error Rates**: Track failed API calls and authentication attempts
Use the API endpoint above or export diagnostics from **Settings → Diagnostics** when troubleshooting.
### Relay Security (Pro)
The relay protocol provides mobile remote access with end-to-end encryption:
- **ECDH key exchange**: Per-channel encryption keys are derived via Elliptic Curve Diffie-Hellman, meaning the relay server never sees plaintext data.
- **Per-channel authentication**: Each mobile session authenticates independently.
- **Back-pressure**: Data limiters prevent channel flooding.
- **License-gated**: Relay functionality requires a Pro or Cloud license.
- **Configurable**: Enable/disable via **Settings → Relay** (admin only).
### Agent Command Security
**Agent commands are disabled by default.** This prevents the AI subsystem from executing arbitrary commands on monitored hosts.
- Operators must explicitly opt in with `--enable-commands` on the agent.
- Even when enabled, commands require `ai:execute` scope and admin privileges.
- All command executions are logged to the audit trail.
- Circuit breakers automatically halt execution when error thresholds are exceeded.
## Security Best Practices
### Credential Storage
- ✅ **DO**: Use Quick Security Setup for automatic hashing
- ✅ **DO**: Store only bcrypt hashes for passwords
- ✅ **DO**: Store only SHA3-256 hashes for API tokens
- ❌ **DON'T**: Store plain text passwords in config files
- ❌ **DON'T**: Store plain text API tokens in config files
- ❌ **DON'T**: Log credentials or include them in backups
### Authentication Setup
- ✅ **DO**: Use strong, unique passwords (16+ characters)
- ✅ **DO**: Rotate API tokens periodically
- ✅ **DO**: Use HTTPS in production environments
- ❌ **DON'T**: Share API tokens between users/services
- ❌ **DON'T**: Embed credentials in client-side code
### Verification Checklist
Manually verify your deployment follows security best practices:
- No hardcoded credentials in environment files
- No credentials exposed in logs (check `docker logs pulse`)
- All passwords stored as bcrypt hashes (60 characters, starting with `$2a$` or `$2b$`)
- All API tokens stored as SHA3-256 hashes (64 characters)
- Secure file permissions on `/etc/pulse/.env` (600)
- No credential leaks in API responses (test with `curl`)
## Account Lockout and Recovery
### Lockout Behavior
- After **5 failed login attempts**, the account is locked for **15 minutes**
- Lockout applies to both username and IP address
- Login form shows remaining attempts after each failure
- Clear message when locked with time remaining
### Automatic Recovery
- Lockouts automatically expire after 15 minutes
- No action needed - just wait for the timer to expire
- Successful login clears all failed attempt counters
### Manual Recovery (Admin)
Administrators with API access can manually reset lockouts:
```bash
# Reset lockout for a specific username
curl -X POST http://localhost:7655/api/security/reset-lockout \
-H "X-API-Token: your-api-token" \
-H "Content-Type: application/json" \
-d '{"identifier":"username"}'
# Reset lockout for an IP address
curl -X POST http://localhost:7655/api/security/reset-lockout \
-H "X-API-Token: your-api-token" \
-H "Content-Type: application/json" \
-d '{"identifier":"198.51.100.100"}'
```
## Troubleshooting
**Account locked?** Wait 15 minutes or contact admin for manual reset
**Export blocked?** You're on a public network login with password, create an API token, or set `ALLOW_UNPROTECTED_EXPORT=true`
**Rate limited?** Wait 1 minute and try again
**Can't login?** Check `PULSE_AUTH_USER` and `PULSE_AUTH_PASS` environment variables
**API access denied?** Verify the token you supplied matches one of the values created in *Settings → API Tokens* (use the original token, not the hash)
**CORS errors?** Configure Allowed Origins in the UI or set `ALLOWED_ORIGINS` for your domain
**Forgot password?** Remove `.env` and restart Pulse, then use the bootstrap token to set new credentials
---

View file

@ -6,16 +6,21 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const frontendRoot = path.resolve(__dirname, '..');
const repoRoot = path.resolve(frontendRoot, '..');
const sourceDocsDir = path.join(repoRoot, 'docs');
const targetDocsDir = path.join(frontendRoot, 'public', 'docs');
const shippedDocs = ['README.md', 'PRIVACY.md'];
const shippedDocs = [
{ source: path.join(repoRoot, 'docs', 'README.md'), target: 'README.md' },
{ source: path.join(repoRoot, 'docs', 'PRIVACY.md'), target: 'PRIVACY.md' },
{ source: path.join(repoRoot, 'docs', 'CONFIGURATION.md'), target: 'CONFIGURATION.md' },
{ source: path.join(repoRoot, 'docs', 'PROXY_AUTH.md'), target: 'PROXY_AUTH.md' },
{ source: path.join(repoRoot, 'SECURITY.md'), target: 'SECURITY.md' },
];
export async function syncPublicDocs() {
await mkdir(targetDocsDir, { recursive: true });
for (const filename of shippedDocs) {
await copyFile(path.join(sourceDocsDir, filename), path.join(targetDocsDir, filename));
for (const { source, target } of shippedDocs) {
await copyFile(source, path.join(targetDocsDir, target));
}
console.log(`Synced shipped docs to ${targetDocsDir}`);

View file

@ -4,6 +4,7 @@ import { SectionHeader } from '@/components/shared/SectionHeader';
import { isPulseHttps } from '@/utils/url';
import { logger } from '@/utils/logger';
import { apiFetchJSON } from '@/utils/apiClient';
import { SECURITY_DOC_URL } from '@/utils/docsLinks';
import {
getSecurityFeatureStatePresentation,
getSecurityScorePresentation,
@ -105,10 +106,6 @@ export const SecurityWarning: Component = () => {
return status()!.score < 4;
};
if (!shouldShow()) {
return null;
}
const scorePercentage = () => (status()!.score / status()!.maxScore) * 100;
const scorePresentation = () => getSecurityScorePresentation(scorePercentage());
const warningPresentation = () =>
@ -119,128 +116,134 @@ export const SecurityWarning: Component = () => {
});
return (
<Portal>
<div
class={`fixed top-0 left-0 right-0 z-50 border-b shadow-sm ${warningPresentation().background} ${warningPresentation().border}`}
>
<div class="max-w-7xl mx-auto px-4 py-3">
<div class="flex items-start justify-between">
<div class="flex items-start space-x-3">
<span class={`text-2xl ${scorePresentation().tone.icon}`}>
{getSecurityScoreSymbol(scorePercentage())}
</span>
<div>
<div class="flex items-center gap-3">
<SectionHeader
title={
<span>
Security score:{' '}
<span class={getSecurityScoreTextClass(scorePercentage())}>
{status()!.score}/{status()!.maxScore}
<Show when={shouldShow()}>
<Portal>
<div
class={`fixed top-0 left-0 right-0 z-50 border-b shadow-sm ${warningPresentation().background} ${warningPresentation().border}`}
>
<div class="max-w-7xl mx-auto px-4 py-3">
<div class="flex items-start justify-between">
<div class="flex items-start space-x-3">
<span class={`text-2xl ${scorePresentation().tone.icon}`}>
{getSecurityScoreSymbol(scorePercentage())}
</span>
<div>
<div class="flex items-center gap-3">
<SectionHeader
title={
<span>
Security score:{' '}
<span class={getSecurityScoreTextClass(scorePercentage())}>
{status()!.score}/{status()!.maxScore}
</span>
</span>
</span>
}
size="sm"
class="flex-1"
titleClass="text-base-content"
/>
<button
type="button"
onClick={() => setShowDetails(!showDetails())}
class="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
{showDetails() ? 'Hide' : 'Show'} Details
</button>
</div>
<p class="text-sm text-base-content mt-1">
<span class={warningPresentation().messageClass}>
{warningPresentation().message}
</span>
</p>
<Show when={showDetails()}>
<div class="mt-3 space-y-1">
<div class="text-xs space-y-1">
<div class="flex items-center gap-2">
<span class={getSecurityFeatureStatePresentation(status()!.credentialsEncrypted).className}>
{getSecurityFeatureStatePresentation(status()!.credentialsEncrypted).label}
</span>
<span>Credentials encrypted at rest</span>
</div>
<div class="flex items-center gap-2">
<span class={getSecurityFeatureStatePresentation(status()!.exportProtected).className}>
{getSecurityFeatureStatePresentation(status()!.exportProtected).label}
</span>
<span>Export requires authentication</span>
</div>
<div class="flex items-center gap-2">
<span class={getSecurityFeatureStatePresentation(status()!.hasAuthentication).className}>
{getSecurityFeatureStatePresentation(status()!.hasAuthentication).label}
</span>
<span>Authentication enabled</span>
</div>
<div class="flex items-center gap-2">
<span class={getSecurityFeatureStatePresentation(status()!.hasHTTPS).className}>
{getSecurityFeatureStatePresentation(status()!.hasHTTPS).label}
</span>
<span>HTTPS connection</span>
</div>
<div class="flex items-center gap-2">
<span class={getSecurityFeatureStatePresentation(status()!.hasAuditLogging).className}>
{getSecurityFeatureStatePresentation(status()!.hasAuditLogging).label}
</span>
<span>Audit logging enabled</span>
</div>
</div>
</div>
</Show>
<div class="flex items-center gap-3 mt-3">
<a
href="/settings/security-overview"
class="text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline"
>
Enable Security
</a>
<a
href="https://github.com/rcourtman/Pulse/blob/main/docs/SECURITY.md"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-muted hover:underline"
>
Learn More
</a>
<div class="relative group">
}
size="sm"
class="flex-1"
titleClass="text-base-content"
/>
<button
type="button"
onClick={() => handleDismiss('day')}
class="text-sm text-muted hover:text-base-content"
onClick={() => setShowDetails(!showDetails())}
class="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
Dismiss
{showDetails() ? 'Hide' : 'Show'} Details
</button>
<div class="absolute left-0 top-full mt-1 bg-surface rounded shadow-sm border border-border opacity-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto transition-opacity">
</div>
<p class="text-sm text-base-content mt-1">
<span class={warningPresentation().messageClass}>
{warningPresentation().message}
</span>
</p>
<Show when={showDetails()}>
<div class="mt-3 space-y-1">
<div class="text-xs space-y-1">
<div class="flex items-center gap-2">
<span
class={getSecurityFeatureStatePresentation(status()!.credentialsEncrypted).className}
>
{getSecurityFeatureStatePresentation(status()!.credentialsEncrypted).label}
</span>
<span>Credentials encrypted at rest</span>
</div>
<div class="flex items-center gap-2">
<span class={getSecurityFeatureStatePresentation(status()!.exportProtected).className}>
{getSecurityFeatureStatePresentation(status()!.exportProtected).label}
</span>
<span>Export requires authentication</span>
</div>
<div class="flex items-center gap-2">
<span
class={getSecurityFeatureStatePresentation(status()!.hasAuthentication).className}
>
{getSecurityFeatureStatePresentation(status()!.hasAuthentication).label}
</span>
<span>Authentication enabled</span>
</div>
<div class="flex items-center gap-2">
<span class={getSecurityFeatureStatePresentation(status()!.hasHTTPS).className}>
{getSecurityFeatureStatePresentation(status()!.hasHTTPS).label}
</span>
<span>HTTPS connection</span>
</div>
<div class="flex items-center gap-2">
<span class={getSecurityFeatureStatePresentation(status()!.hasAuditLogging).className}>
{getSecurityFeatureStatePresentation(status()!.hasAuditLogging).label}
</span>
<span>Audit logging enabled</span>
</div>
</div>
</div>
</Show>
<div class="flex items-center gap-3 mt-3">
<a
href="/settings/security-overview"
class="text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline"
>
Enable Security
</a>
<a
href={SECURITY_DOC_URL}
target="_blank"
rel="noopener noreferrer"
class="text-sm text-muted hover:underline"
>
Learn More
</a>
<div class="relative group">
<button
type="button"
onClick={() => handleDismiss('day')}
class="block w-full text-left px-3 py-1.5 text-sm hover:bg-surface-hover"
class="text-sm text-muted hover:text-base-content"
>
For 1 day
</button>
<button
type="button"
onClick={() => handleDismiss('week')}
class="block w-full text-left px-3 py-1.5 text-sm hover:bg-surface-hover"
>
For 1 week
</button>
<button
type="button"
onClick={() => handleDismiss('forever')}
class="block w-full text-left px-3 py-1.5 text-sm hover:bg-surface-hover"
>
Forever
Dismiss
</button>
<div class="absolute left-0 top-full mt-1 bg-surface rounded shadow-sm border border-border opacity-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto transition-opacity">
<button
type="button"
onClick={() => handleDismiss('day')}
class="block w-full text-left px-3 py-1.5 text-sm hover:bg-surface-hover"
>
For 1 day
</button>
<button
type="button"
onClick={() => handleDismiss('week')}
class="block w-full text-left px-3 py-1.5 text-sm hover:bg-surface-hover"
>
For 1 week
</button>
<button
type="button"
onClick={() => handleDismiss('forever')}
class="block w-full text-left px-3 py-1.5 text-sm hover:bg-surface-hover"
>
Forever
</button>
</div>
</div>
</div>
</div>
@ -248,7 +251,7 @@ export const SecurityWarning: Component = () => {
</div>
</div>
</div>
</div>
</Portal>
</Portal>
</Show>
);
};

View file

@ -1,5 +1,6 @@
import { Component } from 'solid-js';
import SettingsPanel from '@/components/shared/SettingsPanel';
import { API_TOKEN_SCOPES_DOC_URL } from '@/utils/docsLinks';
import APITokenManager from './APITokenManager';
import BadgeCheck from 'lucide-solid/icons/badge-check';
@ -26,7 +27,7 @@ export const APIAccessPanel: Component<APIAccessPanelProps> = (props) => {
changes.
</p>
<a
href="https://github.com/rcourtman/Pulse/blob/main/docs/CONFIGURATION.md#token-scopes"
href={API_TOKEN_SCOPES_DOC_URL}
target="_blank"
rel="noreferrer"
class="inline-flex min-h-10 sm:min-h-10 w-fit items-center gap-2 rounded-md border border-blue-200 bg-blue-50 px-3 py-2 text-sm font-semibold text-blue-700 transition-colors hover:bg-blue-100 dark:border-blue-700 dark:bg-blue-900 dark:text-blue-200"

View file

@ -1,5 +1,6 @@
import { Component, Show, Accessor } from 'solid-js';
import SettingsPanel from '@/components/shared/SettingsPanel';
import { PROXY_AUTH_DOC_URL } from '@/utils/docsLinks';
import { SecurityPostureSummary } from './SecurityPostureSummary';
import Shield from 'lucide-solid/icons/shield';
import Info from 'lucide-solid/icons/info';
@ -122,7 +123,7 @@ export const SecurityOverviewPanel: Component<SecurityOverviewPanelProps> = (pro
</a>
</Show>
<a
href="https://github.com/rcourtman/Pulse/blob/main/docs/PROXY_AUTH.md"
href={PROXY_AUTH_DOC_URL}
target="_blank"
rel="noreferrer"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md text-blue-600 dark:text-blue-300 hover:underline"

View file

@ -1251,6 +1251,12 @@ describe('Settings architecture guardrails', () => {
expect(generalSettingsPanelSource).toContain('@/utils/systemSettingsPresentation');
expect(generalSettingsPanelSource).toContain('@/utils/docsLinks');
expect(generalSettingsPanelSource).toContain('PRIVACY_DOC_URL');
expect(apiAccessPanelSource).toContain('@/utils/docsLinks');
expect(apiAccessPanelSource).toContain('API_TOKEN_SCOPES_DOC_URL');
expect(securityOverviewPanelSource).toContain('@/utils/docsLinks');
expect(securityOverviewPanelSource).toContain('PROXY_AUTH_DOC_URL');
expect(apiTokenManagerModelSource).toContain('@/utils/docsLinks');
expect(apiTokenManagerModelSource).toContain('API_TOKEN_SCOPES_DOC_URL');
expect(recoverySettingsPanelSource).toContain('@/utils/systemSettingsPresentation');
expect(networkDiscoverySectionSource).toContain('@/utils/systemSettingsPresentation');
expect(systemSettingsStateSource).toContain('@/utils/systemSettingsPresentation');
@ -1263,6 +1269,13 @@ describe('Settings architecture guardrails', () => {
);
expect(docsLinksSource).toContain("export const SHIPPED_DOCS_ROOT = '/docs'");
expect(docsLinksSource).toContain("export const PRIVACY_DOC_URL = getShippedDocUrl('PRIVACY.md')");
expect(docsLinksSource).toContain(
"export const CONFIGURATION_DOC_URL = getShippedDocUrl('CONFIGURATION.md')",
);
expect(docsLinksSource).toContain(
"export const PROXY_AUTH_DOC_URL = getShippedDocUrl('PROXY_AUTH.md')",
);
expect(docsLinksSource).toContain("export const SECURITY_DOC_URL = getShippedDocUrl('SECURITY.md')");
});
it('routes every top-level settings surface through the canonical panel shell framing', () => {

View file

@ -14,12 +14,11 @@ import {
getActionableDockerRuntimeIdFromResource,
hasAgentFacet as resourceHasAgentFacet,
} from '@/utils/agentResources';
import { API_TOKEN_SCOPES_DOC_URL as SHIPPED_API_TOKEN_SCOPES_DOC_URL } from '@/utils/docsLinks';
import { getPreferredInfrastructureDisplayName } from '@/utils/resourceIdentity';
import type { Resource } from '@/types/resource';
export const API_TOKEN_SCOPES_DOC_URL =
'https://github.com/rcourtman/Pulse/blob/main/docs/CONFIGURATION.md#token-scopes';
export const API_TOKEN_SCOPES_DOC_URL = SHIPPED_API_TOKEN_SCOPES_DOC_URL;
export const API_TOKEN_WILDCARD_SCOPE = '*';
export interface APITokenPreset {

View file

@ -0,0 +1,62 @@
import { cleanup, render, screen, waitFor } from '@solidjs/testing-library';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const apiFetchJSONMock = vi.hoisted(() => vi.fn());
vi.mock('@/utils/apiClient', () => ({
apiFetchJSON: apiFetchJSONMock,
}));
vi.mock('@/utils/logger', () => ({
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
vi.mock('@/utils/url', () => ({
isPulseHttps: vi.fn(() => false),
}));
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}
describe('SecurityWarning', () => {
beforeEach(() => {
apiFetchJSONMock.mockReset();
localStorage.clear();
});
afterEach(cleanup);
it('renders after the async security status resolves to a low score', async () => {
const pendingStatus = deferred<any>();
apiFetchJSONMock.mockReturnValue(pendingStatus.promise);
const { SecurityWarning } = await import('../SecurityWarning');
render(() => <SecurityWarning />);
expect(screen.queryByText(/Security score:/i)).not.toBeInTheDocument();
pendingStatus.resolve({
apiTokenConfigured: false,
credentialsEncrypted: true,
exportProtected: false,
hasAuditLogging: false,
hasAuthentication: true,
hasHTTPS: false,
publicAccess: false,
});
await waitFor(() => {
expect(screen.getByText(/Security score:/i)).toBeInTheDocument();
});
expect(screen.getByRole('link', { name: 'Learn More' })).toHaveAttribute(
'href',
'/docs/SECURITY.md',
);
});
});

View file

@ -3,11 +3,19 @@ import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
API_TOKEN_SCOPES_DOC_URL,
CONFIGURATION_DOC_URL,
PRIVACY_DOC_URL,
PROXY_AUTH_DOC_URL,
README_DOC_URL,
SECURITY_DOC_URL,
SHIPPED_DOCS_ROOT,
getShippedDocUrl,
} from '@/utils/docsLinks';
import apiAccessPanelSource from '@/components/Settings/APIAccessPanel.tsx?raw';
import apiTokenManagerModelSource from '@/components/Settings/apiTokenManagerModel.ts?raw';
import securityOverviewPanelSource from '@/components/Settings/SecurityOverviewPanel.tsx?raw';
import securityWarningSource from '@/components/SecurityWarning.tsx?raw';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@ -20,18 +28,44 @@ describe('docsLinks', () => {
expect(getShippedDocUrl('PRIVACY.md')).toBe('/docs/PRIVACY.md');
expect(PRIVACY_DOC_URL).toBe('/docs/PRIVACY.md');
expect(README_DOC_URL).toBe('/docs/README.md');
expect(CONFIGURATION_DOC_URL).toBe('/docs/CONFIGURATION.md');
expect(PROXY_AUTH_DOC_URL).toBe('/docs/PROXY_AUTH.md');
expect(SECURITY_DOC_URL).toBe('/docs/SECURITY.md');
expect(API_TOKEN_SCOPES_DOC_URL).toBe('/docs/CONFIGURATION.md');
});
it('keeps shipped privacy and docs content synced with repo docs', () => {
const rootPrivacy = readFileSync(path.join(repoRoot, 'docs', 'PRIVACY.md'), 'utf8');
const publicPrivacy = readFileSync(
path.join(frontendRoot, 'public', 'docs', 'PRIVACY.md'),
'utf8',
);
const rootReadme = readFileSync(path.join(repoRoot, 'docs', 'README.md'), 'utf8');
const publicReadme = readFileSync(path.join(frontendRoot, 'public', 'docs', 'README.md'), 'utf8');
it('keeps shipped docs content synced with repo docs', () => {
const docPairs = [
{ source: path.join(repoRoot, 'docs', 'README.md'), target: 'README.md' },
{ source: path.join(repoRoot, 'docs', 'PRIVACY.md'), target: 'PRIVACY.md' },
{ source: path.join(repoRoot, 'docs', 'CONFIGURATION.md'), target: 'CONFIGURATION.md' },
{ source: path.join(repoRoot, 'docs', 'PROXY_AUTH.md'), target: 'PROXY_AUTH.md' },
{ source: path.join(repoRoot, 'SECURITY.md'), target: 'SECURITY.md' },
];
expect(publicPrivacy).toBe(rootPrivacy);
expect(publicReadme).toBe(rootReadme);
for (const { source, target } of docPairs) {
const rootDoc = readFileSync(source, 'utf8');
const publicDoc = readFileSync(path.join(frontendRoot, 'public', 'docs', target), 'utf8');
expect(publicDoc).toBe(rootDoc);
}
});
it('routes runtime docs links through shipped local docs instead of GitHub main', () => {
expect(apiAccessPanelSource).toContain('API_TOKEN_SCOPES_DOC_URL');
expect(apiAccessPanelSource).not.toContain('https://github.com/rcourtman/Pulse/blob/main/docs/');
expect(apiTokenManagerModelSource).toContain("from '@/utils/docsLinks'");
expect(apiTokenManagerModelSource).toContain('SHIPPED_API_TOKEN_SCOPES_DOC_URL');
expect(apiTokenManagerModelSource).toContain('export const API_TOKEN_SCOPES_DOC_URL =');
expect(apiTokenManagerModelSource).not.toContain(
'https://github.com/rcourtman/Pulse/blob/main/docs/',
);
expect(securityOverviewPanelSource).toContain('PROXY_AUTH_DOC_URL');
expect(securityOverviewPanelSource).not.toContain(
'https://github.com/rcourtman/Pulse/blob/main/docs/',
);
expect(securityWarningSource).toContain('SECURITY_DOC_URL');
expect(securityWarningSource).not.toContain(
'https://github.com/rcourtman/Pulse/blob/main/docs/',
);
});
});

View file

@ -6,3 +6,7 @@ export function getShippedDocUrl(filename: string): string {
export const README_DOC_URL = getShippedDocUrl('README.md');
export const PRIVACY_DOC_URL = getShippedDocUrl('PRIVACY.md');
export const CONFIGURATION_DOC_URL = getShippedDocUrl('CONFIGURATION.md');
export const PROXY_AUTH_DOC_URL = getShippedDocUrl('PROXY_AUTH.md');
export const SECURITY_DOC_URL = getShippedDocUrl('SECURITY.md');
export const API_TOKEN_SCOPES_DOC_URL = CONFIGURATION_DOC_URL;

View file

@ -0,0 +1,135 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { test as base, expect, type Locator, type Page } from '@playwright/test';
import { createAuthenticatedStorageState } from './helpers';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
type WorkerFixtures = {
authStorageStatePath: string;
};
const test = base.extend<{}, WorkerFixtures>({
storageState: async ({ authStorageStatePath }, use) => {
await use(authStorageStatePath);
},
authStorageStatePath: [async ({ browser }, use, workerInfo) => {
const storageStatePath = path.resolve(
__dirname,
'..',
'..',
'tmp',
'playwright-auth',
`local-doc-links-${workerInfo.project.name}.json`,
);
fs.mkdirSync(path.dirname(storageStatePath), { recursive: true });
await createAuthenticatedStorageState(browser, storageStatePath);
try {
await use(storageStatePath);
} finally {
fs.rmSync(storageStatePath, { force: true });
}
}, { scope: 'worker' }],
});
async function expectPopupDoc(
page: Page,
link: Locator,
pathname: string,
expectedText: string,
) {
const [popup] = await Promise.all([
page.waitForEvent('popup'),
link.click(),
]);
await popup.waitForLoadState('domcontentloaded');
expect(new URL(popup.url()).pathname).toBe(pathname);
await expect(popup.locator('body')).toContainText(expectedText);
await popup.close();
}
test.describe('Local docs links', () => {
test.setTimeout(180_000);
test('security overview opens the shipped proxy auth guide', async ({ page }, testInfo) => {
test.skip(testInfo.project.name.startsWith('mobile-'), 'Desktop-only local docs coverage');
await page.route('**/api/security/status', async (route) => {
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
apiTokenConfigured: true,
clientIP: '127.0.0.1',
exportProtected: true,
hasAPIToken: true,
hasAuditLogging: true,
hasAuthentication: true,
hasHTTPS: false,
hasProxyAuth: true,
isPrivateNetwork: true,
proxyAuthIsAdmin: true,
proxyAuthLogoutURL: 'https://idp.example.test/logout',
proxyAuthUsername: 'admin@example.test',
publicAccess: false,
}),
});
});
await page.goto('/settings/security-overview', { waitUntil: 'domcontentloaded' });
await page.waitForURL(/\/settings/, { timeout: 15_000 });
const guideLink = page.getByRole('link', { name: /Read proxy auth guide/i });
await expect(guideLink).toHaveAttribute('href', '/docs/PROXY_AUTH.md');
await expectPopupDoc(page, guideLink, '/docs/PROXY_AUTH.md', 'Proxy Authentication');
});
test('api access opens the shipped token scope reference', async ({ page }, testInfo) => {
test.skip(testInfo.project.name.startsWith('mobile-'), 'Desktop-only local docs coverage');
await page.goto('/settings/security/api', { waitUntil: 'domcontentloaded' });
await page.waitForURL(/\/settings/, { timeout: 15_000 });
const scopeReferenceLink = page.getByRole('link', { name: 'View scope reference' });
await expect(scopeReferenceLink).toHaveAttribute('href', '/docs/CONFIGURATION.md');
await expectPopupDoc(
page,
scopeReferenceLink,
'/docs/CONFIGURATION.md',
'API tokens provide scoped, revocable access to Pulse.',
);
});
test('security warning opens the shipped security guide', async ({ page }, testInfo) => {
test.skip(testInfo.project.name.startsWith('mobile-'), 'Desktop-only local docs coverage');
await page.addInitScript(() => {
localStorage.removeItem('securityWarningDismissed');
});
await page.route('**/api/security/status', async (route) => {
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
apiTokenConfigured: false,
clientIP: '127.0.0.1',
credentialsEncrypted: true,
exportProtected: false,
hasAPIToken: false,
hasAuditLogging: false,
hasAuthentication: true,
hasHTTPS: false,
isPrivateNetwork: true,
publicAccess: false,
}),
});
});
await page.goto('/dashboard', { waitUntil: 'domcontentloaded' });
const securityGuideLink = page.getByRole('link', { name: 'Learn More' }).first();
await expect(securityGuideLink).toHaveAttribute('href', '/docs/SECURITY.md');
await expectPopupDoc(page, securityGuideLink, '/docs/SECURITY.md', 'Pulse Security');
});
});