mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-22 03:02:35 +00:00
fix(frontend): localize security docs links
This commit is contained in:
parent
d76e8fc58c
commit
44c529ba92
16 changed files with 1652 additions and 137 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
519
frontend-modern/public/docs/CONFIGURATION.md
Normal file
519
frontend-modern/public/docs/CONFIGURATION.md
Normal 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`.
|
||||
73
frontend-modern/public/docs/PROXY_AUTH.md
Normal file
73
frontend-modern/public/docs/PROXY_AUTH.md
Normal 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
|
||||
```
|
||||
642
frontend-modern/public/docs/SECURITY.md
Normal file
642
frontend-modern/public/docs/SECURITY.md
Normal 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 0‑5 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 (60‑character hash)
|
||||
- Passwords never stored in plain text
|
||||
- Automatic hashing during security setup
|
||||
- **Critical**: bcrypt hashes must be exactly 60 characters
|
||||
- **API token security**
|
||||
- 64‑character hex tokens (32 bytes entropy)
|
||||
- SHA3-256 hashed before storage (64‑character 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
|
||||
|
||||
---
|
||||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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/',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
135
tests/integration/tests/20-local-doc-links.spec.ts
Normal file
135
tests/integration/tests/20-local-doc-links.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue