Adopt multi-token auth across docs, UI, and tooling

This commit is contained in:
rcourtman
2025-10-14 15:47:49 +00:00
parent 86b44bbed3
commit 261bd7ac74
18 changed files with 274 additions and 275 deletions

View File

@@ -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

View File

@@ -102,18 +102,19 @@ curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | b
1. Open `http://<your-server>: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

View File

@@ -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.

View File

@@ -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

View File

@@ -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` |

View File

@@ -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://<your-server>: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

View File

@@ -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

View File

@@ -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)

View File

@@ -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.
A: Yes! API tokens still work for automation/scripts. Proxy auth is for human users via the web UI.

View File

@@ -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
**Forgot password?** Start fresh - delete your Pulse data and restart

View File

@@ -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<APIOnlySetupProps> = (props) => {
const [isGenerating, setIsGenerating] = createSignal(false);
const [token, setToken] = createSignal<string | null>(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 (
<div class="space-y-4">
<Show when={!showToken()}>
<div class="space-y-4">
<p class="text-sm text-gray-700 dark:text-gray-300">
Generate an API token for programmatic access. Use it for:
</p>
<ul class="list-disc list-inside text-xs text-gray-600 dark:text-gray-400 space-y-1">
<li>Automation scripts and CI/CD pipelines</li>
<li>Monitoring integrations</li>
<li>Third-party applications</li>
</ul>
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
<p class="text-xs text-amber-700 dark:text-amber-300">
<strong>Note:</strong> Without password authentication enabled, the UI will remain
publicly accessible.
</p>
</div>
<button
type="button"
onClick={generateToken}
disabled={isGenerating()}
class="px-4 py-2 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isGenerating() ? 'Generating...' : 'Generate API Token'}
</button>
</div>
</Show>
<Show when={showToken() && token()}>
<div class="space-y-4">
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
<h4 class="text-sm font-semibold text-green-800 dark:text-green-200 mb-2">
API Token Generated!
</h4>
<p class="text-xs text-green-700 dark:text-green-300">
Save this token now - it will never be shown again!
</p>
</div>
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
Your API Token
</label>
<div class="flex items-center space-x-2">
<code class="flex-1 font-mono text-sm bg-white dark:bg-gray-800 px-3 py-2 rounded border border-gray-200 dark:border-gray-700 break-all">
{token()}
</code>
<button
type="button"
onClick={handleCopy}
class="px-3 py-2 text-xs bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
>
{copied() ? 'Copied!' : 'Copy'}
</button>
</div>
</div>
<button
type="button"
onClick={() => {
setShowToken(false);
setToken(null);
if (props.onTokenGenerated) {
props.onTokenGenerated();
}
}}
class="px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors"
>
Done - I've Saved My Token
</button>
</div>
</Show>
</div>
);
};

View File

@@ -8,6 +8,7 @@ import { formatRelativeTime } from '@/utils/format';
interface APITokenManagerProps {
currentTokenHint?: string;
onTokensChanged?: () => void;
}
export const APITokenManager: Component<APITokenManagerProps> = (props) => {
@@ -47,6 +48,7 @@ export const APITokenManager: Component<APITokenManagerProps> = (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<APITokenManagerProps> = (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) {

View File

@@ -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<CommandBuilderProps> = (props) => {
// Token generation/revocation state
const [showGenerateModal, setShowGenerateModal] = createSignal(false);
const [showRevokeModal, setShowRevokeModal] = createSignal(false);
const [isGenerating, setIsGenerating] = createSignal(false);
const [newlyGeneratedToken, setNewlyGeneratedToken] = createSignal<string | null>(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<CommandBuilderProps> = (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<CommandBuilderProps> = (props) => {
</div>
<button
type="button"
onClick={() => setShowGenerateModal(true)}
onClick={openGenerateModal}
disabled={isGenerating()}
class="px-3 py-1.5 text-xs font-medium text-white bg-blue-600 rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors whitespace-nowrap"
>
{isGenerating() ? 'Generating...' : 'Generate New Token'}
{isGenerating() ? 'Generating...' : 'Generate API Token'}
</button>
</div>
</div>
@@ -341,15 +343,18 @@ export const CommandBuilder: Component<CommandBuilderProps> = (props) => {
</button>
<button
type="button"
onClick={() => setShowRevokeModal(true)}
onClick={openGenerateModal}
disabled={isGenerating()}
class="px-3 py-1.5 text-xs font-medium text-red-700 dark:text-red-300 bg-red-100 dark:bg-red-900/40 rounded hover:bg-red-200 dark:hover:bg-red-900/60 disabled:opacity-50 disabled:cursor-not-allowed transition-colors whitespace-nowrap"
title="Revoke current token and generate a new one"
class="px-3 py-1.5 text-xs font-medium text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900/40 rounded hover:bg-blue-200 dark:hover:bg-blue-900/60 disabled:opacity-50 disabled:cursor-not-allowed transition-colors whitespace-nowrap"
title="Generate another token for a new host or automation workflow"
>
Revoke & Replace
Generate Token
</button>
</div>
</div>
<p class="mt-2 text-xs text-gray-600 dark:text-gray-400">
Manage or revoke tokens from the table above whenever a credential is no longer needed.
</p>
</div>
</Show>
</Show>
@@ -479,18 +484,29 @@ export const CommandBuilder: Component<CommandBuilderProps> = (props) => {
<Show when={showGenerateModal()}>
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">Generate New API Token?</h3>
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">Generate API Token</h3>
<div class="space-y-3 mb-6">
<p class="text-sm text-gray-600 dark:text-gray-400">
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.
</p>
<Show when={props.hasExistingToken}>
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded p-3">
<p class="text-xs text-red-800 dark:text-red-300 font-medium">
This will immediately invalidate your existing token. All Docker agents using the old token will need to be updated with the new token.
</p>
</div>
</Show>
<div class="space-y-2">
<label class="text-xs font-medium text-gray-600 dark:text-gray-400" for="command-builder-token-name">
Token name (optional)
</label>
<input
id="command-builder-token-name"
type="text"
value={tokenLabel()}
onInput={(event) => 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()}
/>
</div>
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded p-3">
<p class="text-xs text-blue-800 dark:text-blue-300 font-medium">
Tip: Issue one token per host so you can revoke compromised credentials without affecting other agents.
</p>
</div>
</div>
<div class="flex gap-3 justify-end">
<button
@@ -503,44 +519,10 @@ export const CommandBuilder: Component<CommandBuilderProps> = (props) => {
<button
type="button"
onClick={generateNewToken}
class="px-4 py-2 text-sm text-white bg-blue-600 rounded hover:bg-blue-700 transition-colors"
disabled={isGenerating()}
class="px-4 py-2 text-sm text-white bg-blue-600 rounded hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Generate Token
</button>
</div>
</div>
</div>
</Show>
{/* Revoke & Replace Confirmation Modal */}
<Show when={showRevokeModal()}>
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">Revoke & Replace Token?</h3>
<div class="space-y-3 mb-6">
<p class="text-sm text-gray-600 dark:text-gray-400">
This will immediately invalidate your current token and generate a new one.
</p>
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded p-3">
<p class="text-xs text-red-800 dark:text-red-300 font-medium">
All Docker agents using the old token will stop working immediately. You'll need to update them with the new token.
</p>
</div>
</div>
<div class="flex gap-3 justify-end">
<button
type="button"
onClick={() => setShowRevokeModal(false)}
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={revokeAndReplace}
class="px-4 py-2 text-sm text-white bg-red-600 rounded hover:bg-red-700 transition-colors"
>
Revoke & Replace
{isGenerating() ? 'Generating…' : 'Generate Token'}
</button>
</div>
</div>

View File

@@ -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);
}
}
}}
/>

View File

@@ -965,7 +965,7 @@ const Settings: Component<SettingsProps> = (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<SettingsProps> = (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<SettingsProps> = (props) => {
<div class="text-sm text-blue-800 dark:text-blue-200">
<p class="font-medium mb-1">Configuration Priority</p>
<ul class="space-y-1">
<li> Some env vars override settings (API_TOKEN, PORTS, AUTH)</li>
<li> Some env vars override settings (API_TOKENS, legacy API_TOKEN, PORTS, AUTH)</li>
<li> Changes made here are saved to system.json immediately</li>
<li> Settings persist unless overridden by env vars</li>
</ul>
@@ -3588,7 +3588,12 @@ const Settings: Component<SettingsProps> = (props) => {
{/* Content */}
<div class="p-6">
<APITokenManager currentTokenHint={securityStatus()?.apiTokenHint} />
<APITokenManager
currentTokenHint={securityStatus()?.apiTokenHint}
onTokensChanged={() => {
void loadSecurityStatus();
}}
/>
</div>
</Card>
</Show>
@@ -4980,7 +4985,7 @@ const Settings: Component<SettingsProps> = (props) => {
<div class="text-xs text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 rounded p-2">
<p class="font-semibold mb-1">The API token is set as an environment variable:</p>
<code class="block">API_TOKEN=your-secure-token</code>
<code class="block">API_TOKENS=token-for-export,token-for-automation</code>
</div>
</div>

View File

@@ -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
}

View File

@@ -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)
//

View File

@@ -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
fi