diff --git a/.env.example b/.env.example index 9e02dd63f..0f4c781e7 100644 --- a/.env.example +++ b/.env.example @@ -11,7 +11,8 @@ # Plain-text values are automatically hashed on startup, or supply a bcrypt hash. # PULSE_AUTH_USER=admin # PULSE_AUTH_PASS=super-secret-password -# API_TOKEN=your-48-char-hex-token +# API_TOKENS=host-token-1,host-token-2 +# API_TOKEN=legacy-fallback-token # ----------------------------------------------------------------------------- # Optional security toggles diff --git a/README.md b/README.md index c0c0b5610..ef7f0afd7 100644 --- a/README.md +++ b/README.md @@ -102,18 +102,19 @@ curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | b 1. Open `http://:7655` 2. **Complete the mandatory security setup** (first-time only) 3. Create your admin username and password -4. Save the generated API token for automation +4. Use **Settings → Security → API tokens** to mint dedicated tokens for automation (issue one token per integration so you can revoke credentials individually) **Option B: Automated Setup (No UI)** For automated deployments, configure authentication via environment variables: ```bash # Start Pulse with auth pre-configured - skips setup screen -API_TOKEN=your-api-token ./pulse +API_TOKENS="ansible-token,docker-agent-token" ./pulse # Or use basic auth PULSE_AUTH_USER=admin PULSE_AUTH_PASS=password ./pulse # Plain text credentials are automatically hashed for security +# `API_TOKEN` is still accepted for back-compat, but `API_TOKENS` lets you manage multiple credentials # You can also provide pre-hashed values if preferred ``` See [Configuration Guide](docs/CONFIGURATION.md#automated-setup-skip-ui) for details. @@ -196,7 +197,7 @@ docker run -d \ --name pulse \ -p 7655:7655 \ -v pulse_data:/data \ - -e API_TOKEN="your-secure-token" \ + -e API_TOKENS="ansible-token,docker-agent-token" \ -e PULSE_AUTH_USER="admin" \ -e PULSE_AUTH_PASS="your-password" \ --restart unless-stopped \ @@ -229,7 +230,8 @@ services: # Security (all optional - runs open by default) # - PULSE_AUTH_USER=admin # Username for web UI login # - PULSE_AUTH_PASS=your-password # Plain text or bcrypt hash (auto-hashed if plain) - # - API_TOKEN=your-token # Plain text or SHA3-256 hash (auto-hashed if plain) + # - API_TOKENS=token-a,token-b # Comma-separated tokens (plain or SHA3-256 hashed) + # - API_TOKEN=legacy-token # Optional single-token fallback # - ALLOW_UNPROTECTED_EXPORT=false # Allow export without auth (default: false) # Security: Plain text credentials are automatically hashed diff --git a/docs/API.md b/docs/API.md index 0d9d7f58e..b8645eaa5 100644 --- a/docs/API.md +++ b/docs/API.md @@ -26,25 +26,25 @@ docker run -e PULSE_AUTH_USER=admin -e PULSE_AUTH_PASS=your-password rcourtman/p Once set, users must login via the web UI. The password can be changed from Settings → Security. ### API Token Authentication -For programmatic API access and automation. Tokens can be generated via the web UI (Settings → Security → Generate API Token). +For programmatic API access and automation. Manage tokens via **Settings → Security → API tokens** or the `/api/security/tokens` endpoints. -**API-Only Mode**: If only API_TOKEN is configured (no password auth), the UI remains accessible in read-only mode while API modifications require the token. +**API-Only Mode**: If at least one API token is configured (no password auth), the UI remains accessible in read-only mode while API modifications require a valid token. ```bash # Systemd sudo systemctl edit pulse-backend # Add: [Service] -Environment="API_TOKEN=your-48-char-hex-token" +Environment="API_TOKENS=token-a,token-b" # Docker -docker run -e API_TOKEN=your-48-char-hex-token rcourtman/pulse:latest +docker run -e API_TOKENS=token-a,token-b rcourtman/pulse:latest ``` ### Using Authentication ```bash -# With API Token (header) +# With API token (header) curl -H "X-API-Token: your-secure-token" http://localhost:7655/api/health # With API Token (query parameter, for export/import) @@ -54,6 +54,8 @@ curl "http://localhost:7655/api/export?token=your-secure-token" curl -b cookies.txt http://localhost:7655/api/health ``` +> Legacy note: The `API_TOKEN` environment variable is still honored for backwards compatibility. When both `API_TOKEN` and `API_TOKENS` are supplied, Pulse merges them and prefers the newest token when presenting hints. + ### Security Features When authentication is enabled, Pulse provides enterprise-grade security: @@ -431,13 +433,63 @@ Request body: ``` #### API Token Management -Manage API tokens for programmatic access. +Manage API tokens for automation workflows, Docker agents, and tool integrations. +Authentication: Requires an admin session or an existing admin-scoped API token. + +**List tokens** ```bash -POST /api/security/regenerate-token # Generate or regenerate API token +GET /api/security/tokens ``` -Note: The old `/api/system/api-token` endpoints have been deprecated in favor of the simplified regenerate-token endpoint. +Response: +```json +{ + "tokens": [ + { + "id": "9bf9aa59-3b85-4fd8-9aad-3f19b2c9b6f0", + "name": "ansible", + "prefix": "pulse_1a2b", + "suffix": "c3d4", + "createdAt": "2025-10-14T12:12:34Z", + "lastUsedAt": "2025-10-14T12:21:05Z" + } + ] +} +``` + +**Create a token** +```bash +POST /api/security/tokens +Content-Type: application/json +{ + "name": "ansible" +} +``` + +Response (token value is returned once): +```json +{ + "token": "pulse_1a2b3c4d5e6f7g8h9i0j", + "record": { + "id": "9bf9aa59-3b85-4fd8-9aad-3f19b2c9b6f0", + "name": "ansible", + "prefix": "pulse_1a2b", + "suffix": "c3d4", + "createdAt": "2025-10-14T12:12:34Z", + "lastUsedAt": null + } +} +``` + +**Delete a token** +```bash +DELETE /api/security/tokens/{id} +``` + +Returns `204 No Content` when the token is revoked. + +> Legacy compatibility: `POST /api/security/regenerate-token` is still available but now replaces the entire token list with a single regenerated token. Prefer the endpoints above for multi-token environments. #### Login Enhanced login endpoint with lockout feedback. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 26c1722ee..566cf694a 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -38,7 +38,8 @@ All configuration files are stored in `/etc/pulse/` (or `/data/` in Docker conta # User authentication PULSE_AUTH_USER='admin' # Admin username PULSE_AUTH_PASS='$2a$12$...' # Bcrypt hashed password (keep quotes!) -API_TOKEN=abc123... # API token (Pulse hashes it automatically) +API_TOKEN=abc123... # Optional: seed a primary API token (auto-hashed) +API_TOKENS=token-one,token-two # Optional: comma-separated list of API tokens # Security settings DISABLE_AUTH=true # Disable authentication entirely @@ -54,10 +55,11 @@ PROXY_AUTH_LOGOUT_URL=/logout # URL for SSO logout **Important Notes:** - Password hash MUST be in single quotes to prevent shell expansion -- API tokens are stored as SHA3-256 hashes on disk; provide a plain token and Pulse hashes it automatically +- API tokens are stored as SHA3-256 hashes on disk; plain tokens listed in `API_TOKEN` or `API_TOKENS` are auto-hashed at startup +- Multiple tokens can be pre-seeded via `API_TOKENS` (comma separated). Every token—plain text or pre-hashed—becomes a distinct credential. - This file should have restricted permissions (600) - Never commit this file to version control -- ProxmoxVE installations may pre-configure API_TOKEN +- ProxmoxVE installations may pre-configure `API_TOKEN`; you can now add additional tokens without touching the original value - Changes to this file are applied immediately without restart (v4.3.9+) - **DO NOT** put port configuration here - use system.json or systemd overrides - Copy `.env.example` from the repository for a ready-to-edit template @@ -317,7 +319,8 @@ These env vars override system.json values. When set, the UI will show a warning These should be set in the .env file for security: - `PULSE_AUTH_USER`, `PULSE_AUTH_PASS` - Basic authentication -- `API_TOKEN` - API token for authentication +- `API_TOKEN` - Primary API token (auto-hashed if you supply the raw value) +- `API_TOKENS` - Comma-separated list of additional API tokens (plain or SHA3-256 hashed) - `DISABLE_AUTH` - Set to `true` to disable authentication entirely #### OIDC Variables (optional overrides) @@ -384,16 +387,20 @@ For automated deployments (CI/CD, infrastructure as code, ProxmoxVE scripts), yo ### Simple Automated Setup -**Option 1: API Token Authentication** +**Option 1: API Tokens (single or multiple)** ```bash -# Start Pulse with API token - setup screen is skipped -API_TOKEN=your-secure-api-token ./pulse +# Start Pulse with API tokens - setup screen is skipped +API_TOKENS="$ANSIBLE_TOKEN,$DOCKER_AGENT_TOKEN" ./pulse -# The token is hashed and stored securely -# Use this same token for all API calls -curl -H "X-API-Token: your-secure-api-token" http://localhost:7655/api/nodes +# Each token is hashed and stored securely on startup +curl -H "X-API-Token: $ANSIBLE_TOKEN" http://localhost:7655/api/nodes + +# Legacy fallback (not recommended for new installs) +# API_TOKEN=your-secure-api-token ./pulse ``` +> **Tip:** Generate a distinct token for each automation workflow (Ansible, Docker agents, CI runners, etc.) so you can revoke one credential without affecting the others. + **Option 2: Basic Authentication** ```bash # Start Pulse with username/password - setup screen is skipped @@ -406,9 +413,10 @@ PULSE_AUTH_PASS=your-secure-password \ ``` **Option 3: Both (API + Basic Auth)** +Set `PRIMARY_TOKEN` to the token value you want to reuse (plain text or SHA3-256 hash) before starting Pulse: ```bash # Configure both authentication methods -API_TOKEN=your-api-token \ +API_TOKENS="$PRIMARY_TOKEN" \ PULSE_AUTH_USER=admin \ PULSE_AUTH_PASS=your-password \ ./pulse @@ -430,23 +438,52 @@ PULSE_AUTH_PASS=your-password \ ```bash #!/bin/bash -# Generate secure token -API_TOKEN=$(openssl rand -hex 32) +# Generate dedicated tokens for each integration +ANSIBLE_TOKEN=$(openssl rand -hex 32) +DOCKER_AGENT_TOKEN=$(openssl rand -hex 32) # Deploy with authentication pre-configured docker run -d \ --name pulse \ -p 7655:7655 \ - -e API_TOKEN="$API_TOKEN" \ + -e API_TOKENS="$ANSIBLE_TOKEN,$DOCKER_AGENT_TOKEN" \ -v pulse-data:/data \ rcourtman/pulse:latest -echo "Pulse deployed! Use API token: $API_TOKEN" +echo "Pulse deployed!" +echo " Ansible token: $ANSIBLE_TOKEN" +echo " Docker agent token: $DOCKER_AGENT_TOKEN" # Immediately use the API - no setup needed -curl -H "X-API-Token: $API_TOKEN" http://localhost:7655/api/nodes +curl -H "X-API-Token: $ANSIBLE_TOKEN" http://localhost:7655/api/nodes ``` +Remember to store each token securely; the plain values above are displayed only once. + +### Managing tokens via the REST API + +Infrastructure-as-code workflows (Ansible, Terraform, etc.) can drive token lifecycle directly through the new `/api/security/tokens` endpoints: + +- `GET /api/security/tokens` – list existing tokens (metadata only) +- `POST /api/security/tokens` – create a new token; the raw value is returned once in the response +- `DELETE /api/security/tokens/{id}` – revoke a token by its identifier + +Example: create a token named `ansible` and capture the secret for later use. + +```bash +NEW_TOKEN_JSON=$(curl -sS -X POST http://localhost:7655/api/security/tokens \ + -H "Content-Type: application/json" \ + -H "X-API-Token: $ADMIN_TOKEN" \ + -d '{"name":"ansible"}') + +NEW_TOKEN=$(echo "$NEW_TOKEN_JSON" | jq -r '.token') +TOKEN_ID=$(echo "$NEW_TOKEN_JSON" | jq -r '.record.id') +echo "New token value: $NEW_TOKEN" +echo "Token id: $TOKEN_ID" +``` + +Store `NEW_TOKEN` securely; future GET requests only expose token hints (`prefix`/`suffix`). To revoke the credential later, call `DELETE /api/security/tokens/$TOKEN_ID`. + --- ## Security Best Practices diff --git a/docs/DOCKER.md b/docs/DOCKER.md index 38934b96d..67d08b417 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -78,7 +78,11 @@ services: # escape $ as $$ (e.g. $$2a$$12$$...) so docker compose does not treat it # as variable expansion. PULSE_AUTH_PASS: 'super-secret-password' - API_TOKEN: 'your-48-char-hex-token' # Generate: openssl rand -hex 24 + # Provide one or more API tokens. Tokens can be raw values or SHA3-256 hashes. + # Use distinct tokens per automation target for easier revocation. + API_TOKENS: 'ansible-token,docker-agent-token' + # Optional legacy variable kept for compatibility; newest token is used if both are set. + # API_TOKEN: 'your-48-char-hex-token' PULSE_PUBLIC_URL: 'https://pulse.example.com' # Used for webhooks/links # TZ: 'UTC' restart: unless-stopped @@ -95,7 +99,10 @@ Create `.env` file (no escaping needed here). You can copy `.env.example` from t ```env PULSE_AUTH_USER=admin PULSE_AUTH_PASS=super-secret-password # Plain text (auto-hashed) or bcrypt hash +# Optional legacy token (used if API_TOKENS is empty) API_TOKEN=your-48-char-hex-token # Generate with: openssl rand -hex 24 +# Comma-separated list of tokens for automation/agents +API_TOKENS=${ANSIBLE_TOKEN},${DOCKER_AGENT_TOKEN} PULSE_PUBLIC_URL=https://pulse.example.com # Recommended for webhooks TZ=Asia/Kolkata # Optional: matches host timezone ``` @@ -138,17 +145,21 @@ If you change anything in `.env`, run `docker compose up -d` again so the contai docker run -d \ -e PULSE_AUTH_USER=admin \ -e PULSE_AUTH_PASS=mypassword \ - -e API_TOKEN=mytoken123 \ + -e API_TOKENS="ansible-token,docker-agent-token" \ rcourtman/pulse:latest ``` +> Tip: Create one token per automation workflow (Ansible, Docker agents, CI jobs, etc.) so you can revoke individual credentials without touching others. Use **Settings → Security → API tokens** or `POST /api/security/tokens` to mint tokens programmatically. + ### Advanced: Pre-Hashing (Optional) ```bash # Generate bcrypt hash for password docker run --rm -it rcourtman/pulse:latest pulse hash-password -# Generate random API token -openssl rand -hex 32 +# Generate random API tokens +ANSIBLE_TOKEN=$(openssl rand -hex 32) +DOCKER_AGENT_TOKEN=$(openssl rand -hex 32) +# Then pass them to the container via API_TOKENS ``` ## Data Persistence @@ -252,7 +263,8 @@ Common problems: |----------|-------------|-------------------| | `PULSE_AUTH_USER` | Admin username | `admin` | | `PULSE_AUTH_PASS` | Admin password (plain text auto-hashed or bcrypt hash) | `super-secret-password` or `$2a$12$...` | -| `API_TOKEN` | API access token (plain text or SHA3-256 hash) | `openssl rand -hex 24` | +| `API_TOKEN` | Legacy single API token (optional fallback) | `openssl rand -hex 24` | +| `API_TOKENS` | Comma-separated list of API tokens (plain or SHA3-256 hashed) | `ansible-token,docker-agent-token` | | `DISABLE_AUTH` | Disable authentication entirely | `false` | | `PULSE_AUDIT_LOG` | Enable security audit logging | `false` | diff --git a/docs/DOCKER_HUB_README.md b/docs/DOCKER_HUB_README.md index c55cd693d..bbd2dcae0 100644 --- a/docs/DOCKER_HUB_README.md +++ b/docs/DOCKER_HUB_README.md @@ -107,7 +107,7 @@ docker run -d \ --name pulse \ -p 7655:7655 \ -v pulse_data:/data \ - -e API_TOKEN="your-secure-token" \ + -e API_TOKENS="ansible-token,docker-agent-token" \ -e PULSE_AUTH_USER="admin" \ -e PULSE_AUTH_PASS="your-password" \ --restart unless-stopped \ @@ -141,7 +141,8 @@ services: # Security (all optional - runs open by default) # - PULSE_AUTH_USER=admin # Username for web UI login # - PULSE_AUTH_PASS=your-password # Plain text or bcrypt hash (auto-hashed if plain) - # - API_TOKEN=your-token # Plain text or SHA3-256 hash (auto-hashed if plain) + # - API_TOKENS=token-a,token-b # Comma-separated tokens (plain or SHA3-256 hashed) + # - API_TOKEN=legacy-token # Optional single-token fallback # - ALLOW_UNPROTECTED_EXPORT=false # Allow export without auth (default: false) # Security: Plain text credentials are automatically hashed @@ -167,7 +168,7 @@ volumes: 1. Open `http://:7655` 2. **Complete the mandatory security setup** (first-time only) 3. Create your admin username and password -4. Save the generated API token for automation +4. Use **Settings → Security → API tokens** to issue dedicated tokens for automation (one token per integration makes revocation painless) ## Configure Proxmox/PBS Nodes diff --git a/docs/FAQ.md b/docs/FAQ.md index 2fa072fbe..403b07d61 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -85,7 +85,7 @@ Yes! When you add one cluster node, Pulse automatically discovers and monitors a ### Authentication issues? - Password auth: Check `PULSE_AUTH_USER` and `PULSE_AUTH_PASS` environment variables -- API token: Verify `API_TOKEN` is set correctly +- API tokens: Ensure `API_TOKENS` includes an active credential (or `API_TOKEN` for legacy setups) - Session expired: Log in again via web UI - Account locked: Wait 15 minutes after 5 failed attempts diff --git a/docs/PORT_CONFIGURATION.md b/docs/PORT_CONFIGURATION.md index 03882ea58..a789a94bc 100644 --- a/docs/PORT_CONFIGURATION.md +++ b/docs/PORT_CONFIGURATION.md @@ -51,7 +51,8 @@ Environment variables always override configuration files. ## Why not .env? The `/etc/pulse/.env` file is reserved exclusively for authentication credentials: -- `API_TOKEN` - API authentication token (hashed) +- `API_TOKENS` - One or more API authentication tokens (hashed) +- `API_TOKEN` - Legacy single API token (hashed) - `PULSE_AUTH_USER` - Web UI username - `PULSE_AUTH_PASS` - Web UI password (hashed) diff --git a/docs/PROXY_AUTH.md b/docs/PROXY_AUTH.md index 6a917c3fa..8f7045158 100644 --- a/docs/PROXY_AUTH.md +++ b/docs/PROXY_AUTH.md @@ -171,7 +171,7 @@ proxy_set_header X-Authentik-Groups $http_x_authentik_groups; Proxy authentication can work alongside other authentication methods: - If `PROXY_AUTH_SECRET` is set, proxy auth takes precedence -- API tokens (`API_TOKEN`) still work for programmatic access +- API tokens (`API_TOKENS` or legacy `API_TOKEN`) still work for programmatic access - Basic auth (`PULSE_AUTH_USER`/`PULSE_AUTH_PASS`) can be used as fallback ## Troubleshooting @@ -248,4 +248,4 @@ A: Currently, Pulse has admin and non-admin roles. Non-admin users have read-onl A: Yes, the authenticated username appears in the top-right corner of the UI. **Q: Can I use both proxy auth and API tokens?** -A: Yes! API tokens still work for automation/scripts. Proxy auth is for human users via the web UI. \ No newline at end of file +A: Yes! API tokens still work for automation/scripts. Proxy auth is for human users via the web UI. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index b0f9f7854..ab1904597 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -67,7 +67,7 @@ If you're comfortable with your security setup, you can dismiss warnings: ### Security Features - **Logs**: Token values masked with `***` in all outputs - **API**: Frontend receives only `hasToken: true`, never actual values -- **Export**: Requires API_TOKEN authentication to extract credentials +- **Export**: Requires a valid API token (via `X-API-Token` header or `token` query parameter) to extract credentials - **Migration**: Use passphrase-protected export/import (see [Migration Guide](MIGRATION.md)) - **Auto-Migration**: Unencrypted configs automatically migrate to encrypted format @@ -75,19 +75,20 @@ If you're comfortable with your security setup, you can dismiss warnings: By default, configuration export/import is blocked for security. You have two options: -### Option 1: Set API Token (Recommended) +### Option 1: Set API Tokens (Recommended) ```bash # Using systemd (secure) sudo systemctl edit pulse-backend # Add: [Service] -Environment="API_TOKEN=your-48-char-hex-token" +Environment="API_TOKENS=ansible-token,docker-agent-token" +Environment="API_TOKEN=legacy-token" # Optional fallback # Then restart: sudo systemctl restart pulse-backend # Docker -docker run -e API_TOKEN=your-token rcourtman/pulse:latest +docker run -e API_TOKENS=ansible-token,docker-agent-token rcourtman/pulse:latest ``` ### Option 2: Allow Unprotected Export (Homelab) @@ -217,26 +218,30 @@ 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 ```bash -# Using systemd (use SHA3-256 hash, not plain text!) +# Using systemd (plain text values are auto-hashed on startup) sudo systemctl edit pulse-backend # Add: [Service] -Environment="API_TOKEN=<64-char-sha3-256-hash>" +Environment="API_TOKENS=ansible-token,docker-agent-token" # Docker -docker run -e API_TOKEN=<64-char-sha3-256-hash> rcourtman/pulse:latest +docker run -e API_TOKENS=ansible-token,docker-agent-token rcourtman/pulse:latest + +# To provide pre-hashed tokens instead, list the SHA3-256 hashes +# Environment="API_TOKENS=83c8...,b1de..." ``` -**Security Note**: API tokens are automatically hashed with SHA3-256. Never store plain text tokens in configuration. +**Security Note**: Tokens defined via environment variables are hashed with SHA3-256 before being stored on disk. Plain values never persist beyond startup. -#### Token Management (Settings → Security → API Token) -- Generate new tokens via web UI when authenticated -- View existing token anytime (authenticated users only) -- Regenerate tokens without disrupting service -- Delete tokens to disable API access +#### Token Management (Settings → Security → 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 @@ -258,7 +263,7 @@ curl "http://localhost:7655/api/export?token=your-original-token" #### Secure Mode - Require API token for all operations - Protects auto-registration endpoint -- Enable by setting API_TOKEN environment variable +- Enable by setting at least one API token via `API_TOKENS` (or legacy `API_TOKEN`) environment variable ## CORS (Cross-Origin Resource Sharing) @@ -349,9 +354,9 @@ curl -X POST http://localhost:7655/api/security/reset-lockout \ ## Troubleshooting **Account locked?** Wait 15 minutes or contact admin for manual reset -**Export blocked?** You're on a public network - login with password, set API_TOKEN, or set ALLOW_UNPROTECTED_EXPORT=true +**Export blocked?** You're on a public network - login with password, set an API token (`API_TOKENS`), 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 API_TOKEN is correct (use original token, not hash) +**API access denied?** Verify the token you supplied matches one of the values created in Settings → Security → API tokens (use the original token value, not the hash) **CORS errors?** Configure ALLOWED_ORIGINS for your domain -**Forgot password?** Start fresh - delete your Pulse data and restart \ No newline at end of file +**Forgot password?** Start fresh - delete your Pulse data and restart diff --git a/frontend-modern/src/components/Settings/APIOnlySetup.tsx b/frontend-modern/src/components/Settings/APIOnlySetup.tsx deleted file mode 100644 index 4d8c77a33..000000000 --- a/frontend-modern/src/components/Settings/APIOnlySetup.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { Component, createSignal, Show } from 'solid-js'; -import { showSuccess, showError } from '@/utils/toast'; -import { copyToClipboard } from '@/utils/clipboard'; - -interface APIOnlySetupProps { - onTokenGenerated?: () => void; -} - -export const APIOnlySetup: Component = (props) => { - const [isGenerating, setIsGenerating] = createSignal(false); - const [token, setToken] = createSignal(null); - const [showToken, setShowToken] = createSignal(false); - const [copied, setCopied] = createSignal(false); - - const generateToken = async () => { - setIsGenerating(true); - - try { - const response = await fetch('/api/security/regenerate-token', { - method: 'POST', - credentials: 'include', - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(error || 'Failed to generate token'); - } - - const data = await response.json(); - setToken(data.token); - setShowToken(true); - showSuccess("API token generated! Save it now - it won't be shown again."); - } catch (error) { - showError(`Failed to generate token: ${error}`); - } finally { - setIsGenerating(false); - } - }; - - const handleCopy = async () => { - if (!token()) return; - - const success = await copyToClipboard(token()!); - if (success) { - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } else { - showError('Failed to copy to clipboard'); - } - }; - - return ( -
- -
-

- Generate an API token for programmatic access. Use it for: -

-
    -
  • Automation scripts and CI/CD pipelines
  • -
  • Monitoring integrations
  • -
  • Third-party applications
  • -
- -
-

- Note: Without password authentication enabled, the UI will remain - publicly accessible. -

-
- - -
-
- - -
-
-

- ✅ API Token Generated! -

-

- Save this token now - it will never be shown again! -

-
- -
- -
- - {token()} - - -
-
- - -
-
-
- ); -}; diff --git a/frontend-modern/src/components/Settings/APITokenManager.tsx b/frontend-modern/src/components/Settings/APITokenManager.tsx index 308c2da85..208b42f9d 100644 --- a/frontend-modern/src/components/Settings/APITokenManager.tsx +++ b/frontend-modern/src/components/Settings/APITokenManager.tsx @@ -8,6 +8,7 @@ import { formatRelativeTime } from '@/utils/format'; interface APITokenManagerProps { currentTokenHint?: string; + onTokensChanged?: () => void; } export const APITokenManager: Component = (props) => { @@ -47,6 +48,7 @@ export const APITokenManager: Component = (props) => { setNewTokenRecord(record); setNameInput(''); showSuccess('New API token generated! Save it now – it will not be shown again.'); + props.onTokensChanged?.(); try { window.localStorage.setItem('apiToken', token); @@ -88,6 +90,7 @@ export const APITokenManager: Component = (props) => { await SecurityAPI.deleteToken(record.id); setTokens((prev) => prev.filter((token) => token.id !== record.id)); showSuccess('Token revoked'); + props.onTokensChanged?.(); const current = newTokenRecord(); if (current && current.id === record.id) { diff --git a/frontend-modern/src/components/Settings/CommandBuilder.tsx b/frontend-modern/src/components/Settings/CommandBuilder.tsx index 6d96c7ea0..1ba74d666 100644 --- a/frontend-modern/src/components/Settings/CommandBuilder.tsx +++ b/frontend-modern/src/components/Settings/CommandBuilder.tsx @@ -1,5 +1,6 @@ import { Component, createSignal, Show, createMemo, createEffect } from 'solid-js'; import { apiFetch } from '@/utils/apiClient'; +import { SecurityAPI, type APITokenRecord } from '@/api/security'; interface CommandBuilderProps { command: string; @@ -7,7 +8,7 @@ interface CommandBuilderProps { storedToken?: string | null; currentTokenHint?: string; // Masked token preview (e.g., "abc12***...xyz89") onTokenChange?: (token: string) => void; - onTokenGenerated?: (token: string) => void; + onTokenGenerated?: (token: string, record: APITokenRecord) => void; requiresToken: boolean; hasExistingToken?: boolean; } @@ -21,10 +22,16 @@ export const CommandBuilder: Component = (props) => { // Token generation/revocation state const [showGenerateModal, setShowGenerateModal] = createSignal(false); - const [showRevokeModal, setShowRevokeModal] = createSignal(false); const [isGenerating, setIsGenerating] = createSignal(false); const [newlyGeneratedToken, setNewlyGeneratedToken] = createSignal(null); const [showNewTokenModal, setShowNewTokenModal] = createSignal(false); + const [tokenLabel, setTokenLabel] = createSignal('Docker agent token'); + + const defaultTokenLabel = () => `Docker agent token ${new Date().toISOString().slice(0, 10)}`; + const openGenerateModal = () => { + setTokenLabel(defaultTokenLabel()); + setShowGenerateModal(true); + }; // Initialize with stored token if available createEffect(() => { @@ -158,46 +165,41 @@ export const CommandBuilder: Component = (props) => { // Generate new token const generateNewToken = async () => { - setShowGenerateModal(false); + if (isGenerating()) return; setIsGenerating(true); try { - const response = await apiFetch('/api/security/regenerate-token', { - method: 'POST', - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(error || 'Failed to generate token'); - } - - const data = await response.json(); - const newToken = data.token; + const desiredName = tokenLabel().trim() || undefined; + const { token: newToken, record } = await SecurityAPI.createToken(desiredName); + setShowGenerateModal(false); setNewlyGeneratedToken(newToken); setShowNewTokenModal(true); // Auto-populate the command builder - setTokenInput(newToken); - if (props.onTokenGenerated) { - props.onTokenGenerated(newToken); + setTokenInput(newToken); + if (props.onTokenGenerated) { + props.onTokenGenerated(newToken, record); + } + + if (typeof window !== 'undefined') { + try { + window.localStorage.setItem('apiToken', newToken); + window.dispatchEvent(new StorageEvent('storage', { key: 'apiToken', newValue: newToken })); + } catch (storageErr) { + console.warn('Unable to persist API token in localStorage', storageErr); + } } - window.showToast('success', 'New API token generated successfully!'); + window.showToast('success', 'New API token generated. Save it now – it will not be shown again.'); } catch (error) { console.error('Token generation failed:', error); - window.showToast('error', `Failed to generate token: ${error}`); + window.showToast('error', error instanceof Error ? error.message : 'Failed to generate token'); } finally { setIsGenerating(false); } }; - // Revoke and replace token - const revokeAndReplace = async () => { - setShowRevokeModal(false); - await generateNewToken(); - }; - // Use existing token const useExistingToken = () => { if (props.storedToken) { @@ -294,11 +296,11 @@ export const CommandBuilder: Component = (props) => { @@ -341,15 +343,18 @@ export const CommandBuilder: Component = (props) => { +

+ Manage or revoke tokens from the table above whenever a credential is no longer needed. +

@@ -479,18 +484,29 @@ export const CommandBuilder: Component = (props) => {
-

Generate New API Token?

+

Generate API Token

- This will generate a new API token for Docker agent authentication. + Create a dedicated token for this host or automation workflow. Tokens remain active until you revoke them from the API tokens list.

- -
-

- ⚠️ This will immediately invalidate your existing token. All Docker agents using the old token will need to be updated with the new token. -

-
-
+
+ + setTokenLabel(event.currentTarget.value)} + class="w-full rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder={defaultTokenLabel()} + /> +
+
+

+ Tip: Issue one token per host so you can revoke compromised credentials without affecting other agents. +

+
-
-
-
-
- - {/* Revoke & Replace Confirmation Modal */} - -
-
-

Revoke & Replace Token?

-
-

- This will immediately invalidate your current token and generate a new one. -

-
-

- ⚠️ All Docker agents using the old token will stop working immediately. You'll need to update them with the new token. -

-
-
-
- -
diff --git a/frontend-modern/src/components/Settings/DockerAgents.tsx b/frontend-modern/src/components/Settings/DockerAgents.tsx index e9a54f113..62c5a5af2 100644 --- a/frontend-modern/src/components/Settings/DockerAgents.tsx +++ b/frontend-modern/src/components/Settings/DockerAgents.tsx @@ -358,10 +358,31 @@ WantedBy=multi-user.target`; currentTokenHint={securityStatus()?.apiTokenHint} requiresToken={requiresToken()} hasExistingToken={Boolean(securityStatus()?.apiTokenConfigured)} - onTokenGenerated={(token) => { + onTokenGenerated={(token, record) => { setApiToken(token); - if (typeof window !== 'undefined' && window.localStorage.getItem('apiToken')) { - window.localStorage.setItem('apiToken', token); + setAvailableTokens((prev) => { + const filtered = prev.filter((existing) => existing.id !== record.id); + return [record, ...filtered]; + }); + setSecurityStatus((prev) => { + if (!prev) return prev; + const hint = + record.prefix && record.suffix + ? `${record.prefix}…${record.suffix}` + : prev.apiTokenHint; + return { + ...prev, + apiTokenConfigured: true, + apiTokenHint: hint || prev.apiTokenHint, + }; + }); + if (typeof window !== 'undefined') { + try { + window.localStorage.setItem('apiToken', token); + window.dispatchEvent(new StorageEvent('storage', { key: 'apiToken', newValue: token })); + } catch (err) { + console.warn('Unable to persist API token in localStorage', err); + } } }} /> diff --git a/frontend-modern/src/components/Settings/Settings.tsx b/frontend-modern/src/components/Settings/Settings.tsx index b74f0c87d..ee3b21e4d 100644 --- a/frontend-modern/src/components/Settings/Settings.tsx +++ b/frontend-modern/src/components/Settings/Settings.tsx @@ -965,7 +965,7 @@ const Settings: Component = (props) => { setShowApiTokenModal(true); return; } - if (errorText.includes('API_TOKEN')) { + if (errorText.includes('API_TOKEN') || errorText.includes('API_TOKENS')) { setApiTokenModalSource('export'); setShowApiTokenModal(true); return; @@ -1082,7 +1082,7 @@ const Settings: Component = (props) => { setShowApiTokenModal(true); return; } - if (errorText.includes('API_TOKEN')) { + if (errorText.includes('API_TOKEN') || errorText.includes('API_TOKENS')) { setApiTokenModalSource('import'); setShowApiTokenModal(true); return; @@ -2661,7 +2661,7 @@ const Settings: Component = (props) => {

Configuration Priority

    -
  • • Some env vars override settings (API_TOKEN, PORTS, AUTH)
  • +
  • • Some env vars override settings (API_TOKENS, legacy API_TOKEN, PORTS, AUTH)
  • • Changes made here are saved to system.json immediately
  • • Settings persist unless overridden by env vars
@@ -3588,7 +3588,12 @@ const Settings: Component = (props) => { {/* Content */}
- + { + void loadSecurityStatus(); + }} + />
@@ -4980,7 +4985,7 @@ const Settings: Component = (props) => {

The API token is set as an environment variable:

- API_TOKEN=your-secure-token + API_TOKENS=token-for-export,token-for-automation
diff --git a/frontend-modern/src/types/config.ts b/frontend-modern/src/types/config.ts index da3a8844c..dd5080a49 100644 --- a/frontend-modern/src/types/config.ts +++ b/frontend-modern/src/types/config.ts @@ -16,7 +16,8 @@ export interface AuthConfig { PULSE_AUTH_USER: string; // Admin username PULSE_AUTH_PASS: string; // Bcrypt hashed password - API_TOKEN: string; // API authentication token + API_TOKEN: string; // Legacy API authentication token (hashed) + API_TOKENS?: string; // Optional comma-separated list of hashed tokens ENABLE_AUDIT_LOG?: boolean; // @deprecated - use PULSE_AUDIT_LOG PULSE_AUDIT_LOG?: boolean; // Enable audit logging } diff --git a/internal/config/config.go b/internal/config/config.go index 82e4067dc..b2cf0b1ea 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,7 +1,7 @@ // Package config manages Pulse configuration from multiple sources. // // Configuration File Separation: -// - .env: Authentication credentials ONLY (PULSE_AUTH_USER, PULSE_AUTH_PASS, API_TOKEN) +// - .env: Authentication credentials ONLY (PULSE_AUTH_USER, PULSE_AUTH_PASS, API_TOKEN/API_TOKENS) // - system.json: Application settings (polling interval, timeouts, update settings, etc.) // - nodes.enc: Encrypted node credentials (PVE/PBS passwords and tokens) // diff --git a/scripts/run-tests-mock.sh b/scripts/run-tests-mock.sh index 4588d88ed..205bef609 100755 --- a/scripts/run-tests-mock.sh +++ b/scripts/run-tests-mock.sh @@ -11,6 +11,11 @@ NC='\033[0m' MODE="full" API_TOKEN="${API_TOKEN:-}" +# Allow new multi-token env var to provide the test credential +if [ -z "$API_TOKEN" ] && [ -n "${API_TOKENS:-}" ]; then + # Use the first token in the comma-separated list + API_TOKEN="$(printf '%s\n' "$API_TOKENS" | tr ',' '\n' | head -n1)" +fi PULSE_URL="${PULSE_URL:-http://localhost:7655}" FAILED=0 PASSED=0 @@ -139,4 +144,4 @@ else echo -e "${YELLOW}⚠️ Some tests failed. Check logs in /tmp/${NC}" echo -e "${BLUE}Note: Your production nodes were protected during testing${NC}" exit 1 -fi \ No newline at end of file +fi