Adopt multi-token auth across docs, UI, and tooling
This commit is contained in:
parent
86b44bbed3
commit
261bd7ac74
18 changed files with 274 additions and 275 deletions
|
|
@ -11,7 +11,8 @@
|
||||||
# Plain-text values are automatically hashed on startup, or supply a bcrypt hash.
|
# Plain-text values are automatically hashed on startup, or supply a bcrypt hash.
|
||||||
# PULSE_AUTH_USER=admin
|
# PULSE_AUTH_USER=admin
|
||||||
# PULSE_AUTH_PASS=super-secret-password
|
# 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
|
# Optional security toggles
|
||||||
|
|
|
||||||
10
README.md
10
README.md
|
|
@ -102,18 +102,19 @@ curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | b
|
||||||
1. Open `http://<your-server>:7655`
|
1. Open `http://<your-server>:7655`
|
||||||
2. **Complete the mandatory security setup** (first-time only)
|
2. **Complete the mandatory security setup** (first-time only)
|
||||||
3. Create your admin username and password
|
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)**
|
**Option B: Automated Setup (No UI)**
|
||||||
For automated deployments, configure authentication via environment variables:
|
For automated deployments, configure authentication via environment variables:
|
||||||
```bash
|
```bash
|
||||||
# Start Pulse with auth pre-configured - skips setup screen
|
# 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
|
# Or use basic auth
|
||||||
PULSE_AUTH_USER=admin PULSE_AUTH_PASS=password ./pulse
|
PULSE_AUTH_USER=admin PULSE_AUTH_PASS=password ./pulse
|
||||||
|
|
||||||
# Plain text credentials are automatically hashed for security
|
# 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
|
# You can also provide pre-hashed values if preferred
|
||||||
```
|
```
|
||||||
See [Configuration Guide](docs/CONFIGURATION.md#automated-setup-skip-ui) for details.
|
See [Configuration Guide](docs/CONFIGURATION.md#automated-setup-skip-ui) for details.
|
||||||
|
|
@ -196,7 +197,7 @@ docker run -d \
|
||||||
--name pulse \
|
--name pulse \
|
||||||
-p 7655:7655 \
|
-p 7655:7655 \
|
||||||
-v pulse_data:/data \
|
-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_USER="admin" \
|
||||||
-e PULSE_AUTH_PASS="your-password" \
|
-e PULSE_AUTH_PASS="your-password" \
|
||||||
--restart unless-stopped \
|
--restart unless-stopped \
|
||||||
|
|
@ -229,7 +230,8 @@ services:
|
||||||
# Security (all optional - runs open by default)
|
# Security (all optional - runs open by default)
|
||||||
# - PULSE_AUTH_USER=admin # Username for web UI login
|
# - PULSE_AUTH_USER=admin # Username for web UI login
|
||||||
# - PULSE_AUTH_PASS=your-password # Plain text or bcrypt hash (auto-hashed if plain)
|
# - 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)
|
# - ALLOW_UNPROTECTED_EXPORT=false # Allow export without auth (default: false)
|
||||||
|
|
||||||
# Security: Plain text credentials are automatically hashed
|
# Security: Plain text credentials are automatically hashed
|
||||||
|
|
|
||||||
68
docs/API.md
68
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.
|
Once set, users must login via the web UI. The password can be changed from Settings → Security.
|
||||||
|
|
||||||
### API Token Authentication
|
### 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
|
```bash
|
||||||
# Systemd
|
# Systemd
|
||||||
sudo systemctl edit pulse-backend
|
sudo systemctl edit pulse-backend
|
||||||
# Add:
|
# Add:
|
||||||
[Service]
|
[Service]
|
||||||
Environment="API_TOKEN=your-48-char-hex-token"
|
Environment="API_TOKENS=token-a,token-b"
|
||||||
|
|
||||||
# Docker
|
# 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
|
### Using Authentication
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# With API Token (header)
|
# With API token (header)
|
||||||
curl -H "X-API-Token: your-secure-token" http://localhost:7655/api/health
|
curl -H "X-API-Token: your-secure-token" http://localhost:7655/api/health
|
||||||
|
|
||||||
# With API Token (query parameter, for export/import)
|
# 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
|
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
|
### Security Features
|
||||||
|
|
||||||
When authentication is enabled, Pulse provides enterprise-grade security:
|
When authentication is enabled, Pulse provides enterprise-grade security:
|
||||||
|
|
@ -431,13 +433,63 @@ Request body:
|
||||||
```
|
```
|
||||||
|
|
||||||
#### API Token Management
|
#### 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
|
```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
|
#### Login
|
||||||
Enhanced login endpoint with lockout feedback.
|
Enhanced login endpoint with lockout feedback.
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,8 @@ All configuration files are stored in `/etc/pulse/` (or `/data/` in Docker conta
|
||||||
# User authentication
|
# User authentication
|
||||||
PULSE_AUTH_USER='admin' # Admin username
|
PULSE_AUTH_USER='admin' # Admin username
|
||||||
PULSE_AUTH_PASS='$2a$12$...' # Bcrypt hashed password (keep quotes!)
|
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
|
# Security settings
|
||||||
DISABLE_AUTH=true # Disable authentication entirely
|
DISABLE_AUTH=true # Disable authentication entirely
|
||||||
|
|
@ -54,10 +55,11 @@ PROXY_AUTH_LOGOUT_URL=/logout # URL for SSO logout
|
||||||
|
|
||||||
**Important Notes:**
|
**Important Notes:**
|
||||||
- Password hash MUST be in single quotes to prevent shell expansion
|
- 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)
|
- This file should have restricted permissions (600)
|
||||||
- Never commit this file to version control
|
- 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+)
|
- Changes to this file are applied immediately without restart (v4.3.9+)
|
||||||
- **DO NOT** put port configuration here - use system.json or systemd overrides
|
- **DO NOT** put port configuration here - use system.json or systemd overrides
|
||||||
- Copy `.env.example` from the repository for a ready-to-edit template
|
- 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:
|
These should be set in the .env file for security:
|
||||||
|
|
||||||
- `PULSE_AUTH_USER`, `PULSE_AUTH_PASS` - Basic authentication
|
- `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
|
- `DISABLE_AUTH` - Set to `true` to disable authentication entirely
|
||||||
|
|
||||||
#### OIDC Variables (optional overrides)
|
#### OIDC Variables (optional overrides)
|
||||||
|
|
@ -384,16 +387,20 @@ For automated deployments (CI/CD, infrastructure as code, ProxmoxVE scripts), yo
|
||||||
|
|
||||||
### Simple Automated Setup
|
### Simple Automated Setup
|
||||||
|
|
||||||
**Option 1: API Token Authentication**
|
**Option 1: API Tokens (single or multiple)**
|
||||||
```bash
|
```bash
|
||||||
# Start Pulse with API token - setup screen is skipped
|
# Start Pulse with API tokens - setup screen is skipped
|
||||||
API_TOKEN=your-secure-api-token ./pulse
|
API_TOKENS="$ANSIBLE_TOKEN,$DOCKER_AGENT_TOKEN" ./pulse
|
||||||
|
|
||||||
# The token is hashed and stored securely
|
# Each token is hashed and stored securely on startup
|
||||||
# Use this same token for all API calls
|
curl -H "X-API-Token: $ANSIBLE_TOKEN" http://localhost:7655/api/nodes
|
||||||
curl -H "X-API-Token: your-secure-api-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**
|
**Option 2: Basic Authentication**
|
||||||
```bash
|
```bash
|
||||||
# Start Pulse with username/password - setup screen is skipped
|
# 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)**
|
**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
|
```bash
|
||||||
# Configure both authentication methods
|
# Configure both authentication methods
|
||||||
API_TOKEN=your-api-token \
|
API_TOKENS="$PRIMARY_TOKEN" \
|
||||||
PULSE_AUTH_USER=admin \
|
PULSE_AUTH_USER=admin \
|
||||||
PULSE_AUTH_PASS=your-password \
|
PULSE_AUTH_PASS=your-password \
|
||||||
./pulse
|
./pulse
|
||||||
|
|
@ -430,23 +438,52 @@ PULSE_AUTH_PASS=your-password \
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Generate secure token
|
# Generate dedicated tokens for each integration
|
||||||
API_TOKEN=$(openssl rand -hex 32)
|
ANSIBLE_TOKEN=$(openssl rand -hex 32)
|
||||||
|
DOCKER_AGENT_TOKEN=$(openssl rand -hex 32)
|
||||||
|
|
||||||
# Deploy with authentication pre-configured
|
# Deploy with authentication pre-configured
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name pulse \
|
--name pulse \
|
||||||
-p 7655:7655 \
|
-p 7655:7655 \
|
||||||
-e API_TOKEN="$API_TOKEN" \
|
-e API_TOKENS="$ANSIBLE_TOKEN,$DOCKER_AGENT_TOKEN" \
|
||||||
-v pulse-data:/data \
|
-v pulse-data:/data \
|
||||||
rcourtman/pulse:latest
|
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
|
# 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
|
## Security Best Practices
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,11 @@ services:
|
||||||
# escape $ as $$ (e.g. $$2a$$12$$...) so docker compose does not treat it
|
# escape $ as $$ (e.g. $$2a$$12$$...) so docker compose does not treat it
|
||||||
# as variable expansion.
|
# as variable expansion.
|
||||||
PULSE_AUTH_PASS: 'super-secret-password'
|
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
|
PULSE_PUBLIC_URL: 'https://pulse.example.com' # Used for webhooks/links
|
||||||
# TZ: 'UTC'
|
# TZ: 'UTC'
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
@ -95,7 +99,10 @@ Create `.env` file (no escaping needed here). You can copy `.env.example` from t
|
||||||
```env
|
```env
|
||||||
PULSE_AUTH_USER=admin
|
PULSE_AUTH_USER=admin
|
||||||
PULSE_AUTH_PASS=super-secret-password # Plain text (auto-hashed) or bcrypt hash
|
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
|
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
|
PULSE_PUBLIC_URL=https://pulse.example.com # Recommended for webhooks
|
||||||
TZ=Asia/Kolkata # Optional: matches host timezone
|
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 \
|
docker run -d \
|
||||||
-e PULSE_AUTH_USER=admin \
|
-e PULSE_AUTH_USER=admin \
|
||||||
-e PULSE_AUTH_PASS=mypassword \
|
-e PULSE_AUTH_PASS=mypassword \
|
||||||
-e API_TOKEN=mytoken123 \
|
-e API_TOKENS="ansible-token,docker-agent-token" \
|
||||||
rcourtman/pulse:latest
|
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)
|
### Advanced: Pre-Hashing (Optional)
|
||||||
```bash
|
```bash
|
||||||
# Generate bcrypt hash for password
|
# Generate bcrypt hash for password
|
||||||
docker run --rm -it rcourtman/pulse:latest pulse hash-password
|
docker run --rm -it rcourtman/pulse:latest pulse hash-password
|
||||||
|
|
||||||
# Generate random API token
|
# Generate random API tokens
|
||||||
openssl rand -hex 32
|
ANSIBLE_TOKEN=$(openssl rand -hex 32)
|
||||||
|
DOCKER_AGENT_TOKEN=$(openssl rand -hex 32)
|
||||||
|
# Then pass them to the container via API_TOKENS
|
||||||
```
|
```
|
||||||
|
|
||||||
## Data Persistence
|
## Data Persistence
|
||||||
|
|
@ -252,7 +263,8 @@ Common problems:
|
||||||
|----------|-------------|-------------------|
|
|----------|-------------|-------------------|
|
||||||
| `PULSE_AUTH_USER` | Admin username | `admin` |
|
| `PULSE_AUTH_USER` | Admin username | `admin` |
|
||||||
| `PULSE_AUTH_PASS` | Admin password (plain text auto-hashed or bcrypt hash) | `super-secret-password` or `$2a$12$...` |
|
| `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` |
|
| `DISABLE_AUTH` | Disable authentication entirely | `false` |
|
||||||
| `PULSE_AUDIT_LOG` | Enable security audit logging | `false` |
|
| `PULSE_AUDIT_LOG` | Enable security audit logging | `false` |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,7 @@ docker run -d \
|
||||||
--name pulse \
|
--name pulse \
|
||||||
-p 7655:7655 \
|
-p 7655:7655 \
|
||||||
-v pulse_data:/data \
|
-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_USER="admin" \
|
||||||
-e PULSE_AUTH_PASS="your-password" \
|
-e PULSE_AUTH_PASS="your-password" \
|
||||||
--restart unless-stopped \
|
--restart unless-stopped \
|
||||||
|
|
@ -141,7 +141,8 @@ services:
|
||||||
# Security (all optional - runs open by default)
|
# Security (all optional - runs open by default)
|
||||||
# - PULSE_AUTH_USER=admin # Username for web UI login
|
# - PULSE_AUTH_USER=admin # Username for web UI login
|
||||||
# - PULSE_AUTH_PASS=your-password # Plain text or bcrypt hash (auto-hashed if plain)
|
# - 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)
|
# - ALLOW_UNPROTECTED_EXPORT=false # Allow export without auth (default: false)
|
||||||
|
|
||||||
# Security: Plain text credentials are automatically hashed
|
# Security: Plain text credentials are automatically hashed
|
||||||
|
|
@ -167,7 +168,7 @@ volumes:
|
||||||
1. Open `http://<your-server>:7655`
|
1. Open `http://<your-server>:7655`
|
||||||
2. **Complete the mandatory security setup** (first-time only)
|
2. **Complete the mandatory security setup** (first-time only)
|
||||||
3. Create your admin username and password
|
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
|
## Configure Proxmox/PBS Nodes
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ Yes! When you add one cluster node, Pulse automatically discovers and monitors a
|
||||||
|
|
||||||
### Authentication issues?
|
### Authentication issues?
|
||||||
- Password auth: Check `PULSE_AUTH_USER` and `PULSE_AUTH_PASS` environment variables
|
- 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
|
- Session expired: Log in again via web UI
|
||||||
- Account locked: Wait 15 minutes after 5 failed attempts
|
- Account locked: Wait 15 minutes after 5 failed attempts
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,8 @@ Environment variables always override configuration files.
|
||||||
## Why not .env?
|
## Why not .env?
|
||||||
|
|
||||||
The `/etc/pulse/.env` file is reserved exclusively for authentication credentials:
|
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_USER` - Web UI username
|
||||||
- `PULSE_AUTH_PASS` - Web UI password (hashed)
|
- `PULSE_AUTH_PASS` - Web UI password (hashed)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -171,7 +171,7 @@ proxy_set_header X-Authentik-Groups $http_x_authentik_groups;
|
||||||
Proxy authentication can work alongside other authentication methods:
|
Proxy authentication can work alongside other authentication methods:
|
||||||
|
|
||||||
- If `PROXY_AUTH_SECRET` is set, proxy auth takes precedence
|
- 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
|
- Basic auth (`PULSE_AUTH_USER`/`PULSE_AUTH_PASS`) can be used as fallback
|
||||||
|
|
||||||
## Troubleshooting
|
## 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.
|
A: Yes, the authenticated username appears in the top-right corner of the UI.
|
||||||
|
|
||||||
**Q: Can I use both proxy auth and API tokens?**
|
**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.
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ If you're comfortable with your security setup, you can dismiss warnings:
|
||||||
### Security Features
|
### Security Features
|
||||||
- **Logs**: Token values masked with `***` in all outputs
|
- **Logs**: Token values masked with `***` in all outputs
|
||||||
- **API**: Frontend receives only `hasToken: true`, never actual values
|
- **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))
|
- **Migration**: Use passphrase-protected export/import (see [Migration Guide](MIGRATION.md))
|
||||||
- **Auto-Migration**: Unencrypted configs automatically migrate to encrypted format
|
- **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:
|
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
|
```bash
|
||||||
# Using systemd (secure)
|
# Using systemd (secure)
|
||||||
sudo systemctl edit pulse-backend
|
sudo systemctl edit pulse-backend
|
||||||
# Add:
|
# Add:
|
||||||
[Service]
|
[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:
|
# Then restart:
|
||||||
sudo systemctl restart pulse-backend
|
sudo systemctl restart pulse-backend
|
||||||
|
|
||||||
# Docker
|
# 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)
|
### Option 2: Allow Unprotected Export (Homelab)
|
||||||
|
|
@ -217,26 +218,30 @@ The Quick Security Setup automatically:
|
||||||
- Generates a cryptographically secure token
|
- Generates a cryptographically secure token
|
||||||
- Hashes it with SHA3-256
|
- Hashes it with SHA3-256
|
||||||
- Stores only the 64-character hash
|
- Stores only the 64-character hash
|
||||||
|
- Adds the token to the managed token list
|
||||||
|
|
||||||
#### Manual Token Setup
|
#### Manual Token Setup
|
||||||
```bash
|
```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
|
sudo systemctl edit pulse-backend
|
||||||
# Add:
|
# Add:
|
||||||
[Service]
|
[Service]
|
||||||
Environment="API_TOKEN=<64-char-sha3-256-hash>"
|
Environment="API_TOKENS=ansible-token,docker-agent-token"
|
||||||
|
|
||||||
# Docker
|
# 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)
|
#### Token Management (Settings → Security → API tokens)
|
||||||
- Generate new tokens via web UI when authenticated
|
- Issue dedicated tokens for automation/agents without sharing a global credential
|
||||||
- View existing token anytime (authenticated users only)
|
- View prefixes/suffixes and last-used timestamps for auditing
|
||||||
- Regenerate tokens without disrupting service
|
- Revoke tokens individually without downtime
|
||||||
- Delete tokens to disable API access
|
- Regenerate tokens when rotating credentials (new value displayed once)
|
||||||
- All tokens stored as SHA3-256 hashes
|
- All tokens stored as SHA3-256 hashes
|
||||||
|
|
||||||
#### Usage
|
#### Usage
|
||||||
|
|
@ -258,7 +263,7 @@ curl "http://localhost:7655/api/export?token=your-original-token"
|
||||||
#### Secure Mode
|
#### Secure Mode
|
||||||
- Require API token for all operations
|
- Require API token for all operations
|
||||||
- Protects auto-registration endpoint
|
- 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)
|
## CORS (Cross-Origin Resource Sharing)
|
||||||
|
|
||||||
|
|
@ -349,9 +354,9 @@ curl -X POST http://localhost:7655/api/security/reset-lockout \
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
**Account locked?** Wait 15 minutes or contact admin for manual reset
|
**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
|
**Rate limited?** Wait 1 minute and try again
|
||||||
**Can't login?** Check PULSE_AUTH_USER and PULSE_AUTH_PASS environment variables
|
**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
|
**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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { formatRelativeTime } from '@/utils/format';
|
||||||
|
|
||||||
interface APITokenManagerProps {
|
interface APITokenManagerProps {
|
||||||
currentTokenHint?: string;
|
currentTokenHint?: string;
|
||||||
|
onTokensChanged?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const APITokenManager: Component<APITokenManagerProps> = (props) => {
|
export const APITokenManager: Component<APITokenManagerProps> = (props) => {
|
||||||
|
|
@ -47,6 +48,7 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
|
||||||
setNewTokenRecord(record);
|
setNewTokenRecord(record);
|
||||||
setNameInput('');
|
setNameInput('');
|
||||||
showSuccess('New API token generated! Save it now – it will not be shown again.');
|
showSuccess('New API token generated! Save it now – it will not be shown again.');
|
||||||
|
props.onTokensChanged?.();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem('apiToken', token);
|
window.localStorage.setItem('apiToken', token);
|
||||||
|
|
@ -88,6 +90,7 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
|
||||||
await SecurityAPI.deleteToken(record.id);
|
await SecurityAPI.deleteToken(record.id);
|
||||||
setTokens((prev) => prev.filter((token) => token.id !== record.id));
|
setTokens((prev) => prev.filter((token) => token.id !== record.id));
|
||||||
showSuccess('Token revoked');
|
showSuccess('Token revoked');
|
||||||
|
props.onTokensChanged?.();
|
||||||
|
|
||||||
const current = newTokenRecord();
|
const current = newTokenRecord();
|
||||||
if (current && current.id === record.id) {
|
if (current && current.id === record.id) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Component, createSignal, Show, createMemo, createEffect } from 'solid-js';
|
import { Component, createSignal, Show, createMemo, createEffect } from 'solid-js';
|
||||||
import { apiFetch } from '@/utils/apiClient';
|
import { apiFetch } from '@/utils/apiClient';
|
||||||
|
import { SecurityAPI, type APITokenRecord } from '@/api/security';
|
||||||
|
|
||||||
interface CommandBuilderProps {
|
interface CommandBuilderProps {
|
||||||
command: string;
|
command: string;
|
||||||
|
|
@ -7,7 +8,7 @@ interface CommandBuilderProps {
|
||||||
storedToken?: string | null;
|
storedToken?: string | null;
|
||||||
currentTokenHint?: string; // Masked token preview (e.g., "abc12***...xyz89")
|
currentTokenHint?: string; // Masked token preview (e.g., "abc12***...xyz89")
|
||||||
onTokenChange?: (token: string) => void;
|
onTokenChange?: (token: string) => void;
|
||||||
onTokenGenerated?: (token: string) => void;
|
onTokenGenerated?: (token: string, record: APITokenRecord) => void;
|
||||||
requiresToken: boolean;
|
requiresToken: boolean;
|
||||||
hasExistingToken?: boolean;
|
hasExistingToken?: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -21,10 +22,16 @@ export const CommandBuilder: Component<CommandBuilderProps> = (props) => {
|
||||||
|
|
||||||
// Token generation/revocation state
|
// Token generation/revocation state
|
||||||
const [showGenerateModal, setShowGenerateModal] = createSignal(false);
|
const [showGenerateModal, setShowGenerateModal] = createSignal(false);
|
||||||
const [showRevokeModal, setShowRevokeModal] = createSignal(false);
|
|
||||||
const [isGenerating, setIsGenerating] = createSignal(false);
|
const [isGenerating, setIsGenerating] = createSignal(false);
|
||||||
const [newlyGeneratedToken, setNewlyGeneratedToken] = createSignal<string | null>(null);
|
const [newlyGeneratedToken, setNewlyGeneratedToken] = createSignal<string | null>(null);
|
||||||
const [showNewTokenModal, setShowNewTokenModal] = createSignal(false);
|
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
|
// Initialize with stored token if available
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
|
@ -158,46 +165,41 @@ export const CommandBuilder: Component<CommandBuilderProps> = (props) => {
|
||||||
|
|
||||||
// Generate new token
|
// Generate new token
|
||||||
const generateNewToken = async () => {
|
const generateNewToken = async () => {
|
||||||
setShowGenerateModal(false);
|
if (isGenerating()) return;
|
||||||
setIsGenerating(true);
|
setIsGenerating(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch('/api/security/regenerate-token', {
|
const desiredName = tokenLabel().trim() || undefined;
|
||||||
method: 'POST',
|
const { token: newToken, record } = await SecurityAPI.createToken(desiredName);
|
||||||
});
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
|
setShowGenerateModal(false);
|
||||||
setNewlyGeneratedToken(newToken);
|
setNewlyGeneratedToken(newToken);
|
||||||
setShowNewTokenModal(true);
|
setShowNewTokenModal(true);
|
||||||
|
|
||||||
// Auto-populate the command builder
|
// Auto-populate the command builder
|
||||||
setTokenInput(newToken);
|
setTokenInput(newToken);
|
||||||
if (props.onTokenGenerated) {
|
if (props.onTokenGenerated) {
|
||||||
props.onTokenGenerated(newToken);
|
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) {
|
} catch (error) {
|
||||||
console.error('Token generation failed:', 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 {
|
} finally {
|
||||||
setIsGenerating(false);
|
setIsGenerating(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Revoke and replace token
|
|
||||||
const revokeAndReplace = async () => {
|
|
||||||
setShowRevokeModal(false);
|
|
||||||
await generateNewToken();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Use existing token
|
// Use existing token
|
||||||
const useExistingToken = () => {
|
const useExistingToken = () => {
|
||||||
if (props.storedToken) {
|
if (props.storedToken) {
|
||||||
|
|
@ -294,11 +296,11 @@ export const CommandBuilder: Component<CommandBuilderProps> = (props) => {
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowGenerateModal(true)}
|
onClick={openGenerateModal}
|
||||||
disabled={isGenerating()}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -341,15 +343,18 @@ export const CommandBuilder: Component<CommandBuilderProps> = (props) => {
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowRevokeModal(true)}
|
onClick={openGenerateModal}
|
||||||
disabled={isGenerating()}
|
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"
|
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="Revoke current token and generate a new one"
|
title="Generate another token for a new host or automation workflow"
|
||||||
>
|
>
|
||||||
Revoke & Replace
|
Generate Token
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
@ -479,18 +484,29 @@ export const CommandBuilder: Component<CommandBuilderProps> = (props) => {
|
||||||
<Show when={showGenerateModal()}>
|
<Show when={showGenerateModal()}>
|
||||||
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
<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">
|
<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">
|
<div class="space-y-3 mb-6">
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
<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>
|
</p>
|
||||||
<Show when={props.hasExistingToken}>
|
<div class="space-y-2">
|
||||||
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded p-3">
|
<label class="text-xs font-medium text-gray-600 dark:text-gray-400" for="command-builder-token-name">
|
||||||
<p class="text-xs text-red-800 dark:text-red-300 font-medium">
|
Token name (optional)
|
||||||
⚠️ This will immediately invalidate your existing token. All Docker agents using the old token will need to be updated with the new token.
|
</label>
|
||||||
</p>
|
<input
|
||||||
</div>
|
id="command-builder-token-name"
|
||||||
</Show>
|
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>
|
||||||
<div class="flex gap-3 justify-end">
|
<div class="flex gap-3 justify-end">
|
||||||
<button
|
<button
|
||||||
|
|
@ -503,44 +519,10 @@ export const CommandBuilder: Component<CommandBuilderProps> = (props) => {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={generateNewToken}
|
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
|
{isGenerating() ? 'Generating…' : '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
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -358,10 +358,31 @@ WantedBy=multi-user.target`;
|
||||||
currentTokenHint={securityStatus()?.apiTokenHint}
|
currentTokenHint={securityStatus()?.apiTokenHint}
|
||||||
requiresToken={requiresToken()}
|
requiresToken={requiresToken()}
|
||||||
hasExistingToken={Boolean(securityStatus()?.apiTokenConfigured)}
|
hasExistingToken={Boolean(securityStatus()?.apiTokenConfigured)}
|
||||||
onTokenGenerated={(token) => {
|
onTokenGenerated={(token, record) => {
|
||||||
setApiToken(token);
|
setApiToken(token);
|
||||||
if (typeof window !== 'undefined' && window.localStorage.getItem('apiToken')) {
|
setAvailableTokens((prev) => {
|
||||||
window.localStorage.setItem('apiToken', token);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -965,7 +965,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||||
setShowApiTokenModal(true);
|
setShowApiTokenModal(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (errorText.includes('API_TOKEN')) {
|
if (errorText.includes('API_TOKEN') || errorText.includes('API_TOKENS')) {
|
||||||
setApiTokenModalSource('export');
|
setApiTokenModalSource('export');
|
||||||
setShowApiTokenModal(true);
|
setShowApiTokenModal(true);
|
||||||
return;
|
return;
|
||||||
|
|
@ -1082,7 +1082,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||||
setShowApiTokenModal(true);
|
setShowApiTokenModal(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (errorText.includes('API_TOKEN')) {
|
if (errorText.includes('API_TOKEN') || errorText.includes('API_TOKENS')) {
|
||||||
setApiTokenModalSource('import');
|
setApiTokenModalSource('import');
|
||||||
setShowApiTokenModal(true);
|
setShowApiTokenModal(true);
|
||||||
return;
|
return;
|
||||||
|
|
@ -2661,7 +2661,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||||
<div class="text-sm text-blue-800 dark:text-blue-200">
|
<div class="text-sm text-blue-800 dark:text-blue-200">
|
||||||
<p class="font-medium mb-1">Configuration Priority</p>
|
<p class="font-medium mb-1">Configuration Priority</p>
|
||||||
<ul class="space-y-1">
|
<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>• Changes made here are saved to system.json immediately</li>
|
||||||
<li>• Settings persist unless overridden by env vars</li>
|
<li>• Settings persist unless overridden by env vars</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
@ -3588,7 +3588,12 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<APITokenManager currentTokenHint={securityStatus()?.apiTokenHint} />
|
<APITokenManager
|
||||||
|
currentTokenHint={securityStatus()?.apiTokenHint}
|
||||||
|
onTokensChanged={() => {
|
||||||
|
void loadSecurityStatus();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</Show>
|
</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">
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,8 @@
|
||||||
export interface AuthConfig {
|
export interface AuthConfig {
|
||||||
PULSE_AUTH_USER: string; // Admin username
|
PULSE_AUTH_USER: string; // Admin username
|
||||||
PULSE_AUTH_PASS: string; // Bcrypt hashed password
|
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
|
ENABLE_AUDIT_LOG?: boolean; // @deprecated - use PULSE_AUDIT_LOG
|
||||||
PULSE_AUDIT_LOG?: boolean; // Enable audit logging
|
PULSE_AUDIT_LOG?: boolean; // Enable audit logging
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// Package config manages Pulse configuration from multiple sources.
|
// Package config manages Pulse configuration from multiple sources.
|
||||||
//
|
//
|
||||||
// Configuration File Separation:
|
// 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.)
|
// - system.json: Application settings (polling interval, timeouts, update settings, etc.)
|
||||||
// - nodes.enc: Encrypted node credentials (PVE/PBS passwords and tokens)
|
// - nodes.enc: Encrypted node credentials (PVE/PBS passwords and tokens)
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,11 @@ NC='\033[0m'
|
||||||
|
|
||||||
MODE="full"
|
MODE="full"
|
||||||
API_TOKEN="${API_TOKEN:-}"
|
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}"
|
PULSE_URL="${PULSE_URL:-http://localhost:7655}"
|
||||||
FAILED=0
|
FAILED=0
|
||||||
PASSED=0
|
PASSED=0
|
||||||
|
|
@ -139,4 +144,4 @@ else
|
||||||
echo -e "${YELLOW}⚠️ Some tests failed. Check logs in /tmp/${NC}"
|
echo -e "${YELLOW}⚠️ Some tests failed. Check logs in /tmp/${NC}"
|
||||||
echo -e "${BLUE}Note: Your production nodes were protected during testing${NC}"
|
echo -e "${BLUE}Note: Your production nodes were protected during testing${NC}"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue