Harden setup token flow and enforce encrypted persistence

This commit is contained in:
rcourtman 2025-10-25 16:00:37 +00:00
parent cb37d0de01
commit 5a2d808aa1
9 changed files with 325 additions and 44 deletions

View file

@ -137,13 +137,14 @@ See [Configuration Guide](docs/CONFIGURATION.md#automated-setup-skip-ui) for det
2. Discovered nodes appear automatically 2. Discovered nodes appear automatically
3. Click "Setup Script" next to any node 3. Click "Setup Script" next to any node
4. Click "Generate Setup Code" button (creates a 6-character code valid for 5 minutes) 4. Click "Generate Setup Code" button (creates a 6-character code valid for 5 minutes)
5. Copy and run the provided one-liner on your Proxmox/PBS host 5. Copy and run the provided one-liner on your Proxmox/PBS host (the script prompts for your setup token securely)
6. Node is configured and monitoring starts automatically 6. Node is configured and monitoring starts automatically
**Example:** **Example:**
```bash ```bash
curl -sSL "http://pulse:7655/api/setup-script?type=pve&host=https://pve:8006&auth_token=ABC123" | bash curl -sSL "http://pulse:7655/api/setup-script?type=pve&host=https://pve:8006" | bash
``` ```
> Tip: For non-interactive installs, export `PULSE_SETUP_TOKEN` before running the script or supply `auth_token=YOUR_API_TOKEN` as shown in Method 2.
#### Method 2: Automated Setup (For scripts/automation) #### Method 2: Automated Setup (For scripts/automation)
Use your permanent API token directly in the URL for automation: Use your permanent API token directly in the URL for automation:

View file

@ -758,8 +758,9 @@ Response:
{ {
"url": "http://pulse.local:7655/api/setup-script?type=pve&host=...", "url": "http://pulse.local:7655/api/setup-script?type=pve&host=...",
"command": "curl -sSL \"http://pulse.local:7655/api/setup-script?...\" | bash", "command": "curl -sSL \"http://pulse.local:7655/api/setup-script?...\" | bash",
"setupCode": "A7K9P2", // 6-character one-time code "setupToken": "4c7f3e8c1c5f4b0da580c4477f4b1c2d",
"expires": 1755123456 // Unix timestamp when code expires (5 minutes) "tokenHint": "4c7…c2d",
"expires": 1755123456 // Unix timestamp when token expires (5 minutes)
} }
``` ```
@ -774,8 +775,8 @@ The script will:
1. Create a monitoring user (pulse-monitor@pam or pulse-monitor@pbs) 1. Create a monitoring user (pulse-monitor@pam or pulse-monitor@pbs)
2. Generate an API token for that user 2. Generate an API token for that user
3. Set appropriate permissions 3. Set appropriate permissions
4. Prompt for the setup code 4. Prompt for the setup token (or read `PULSE_SETUP_TOKEN` if set)
5. Auto-register with Pulse if a valid code is provided 5. Auto-register with Pulse if a valid token is provided
### Auto-Register Node ### Auto-Register Node
Register a node automatically (used by setup scripts). Requires either a valid setup code or API token. Register a node automatically (used by setup scripts). Requires either a valid setup code or API token.

View file

@ -74,6 +74,19 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
const [formData, setFormData] = createSignal(getCleanFormData()); const [formData, setFormData] = createSignal(getCleanFormData());
const [quickSetupCommand, setQuickSetupCommand] = createSignal(''); const [quickSetupCommand, setQuickSetupCommand] = createSignal('');
const [quickSetupToken, setQuickSetupToken] = createSignal('');
const [quickSetupExpiry, setQuickSetupExpiry] = createSignal<number | null>(null);
const quickSetupExpiryLabel = () => {
const expiry = quickSetupExpiry();
if (!expiry) {
return '';
}
try {
return new Date(expiry * 1000).toLocaleTimeString();
} catch {
return '';
}
};
// Track previous state to detect changes // Track previous state to detect changes
let previousResetKey: number | undefined = undefined; let previousResetKey: number | undefined = undefined;
@ -91,6 +104,8 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
previousResetKey = key; previousResetKey = key;
setFormData(() => getCleanFormData(props.nodeType)); setFormData(() => getCleanFormData(props.nodeType));
setQuickSetupCommand(''); setQuickSetupCommand('');
setQuickSetupToken('');
setQuickSetupExpiry(null);
setTestResult(null); setTestResult(null);
return; return;
} }
@ -100,6 +115,8 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
previousNodeType = nodeType; previousNodeType = nodeType;
setFormData(() => getCleanFormData(props.nodeType)); setFormData(() => getCleanFormData(props.nodeType));
setQuickSetupCommand(''); setQuickSetupCommand('');
setQuickSetupToken('');
setQuickSetupExpiry(null);
setTestResult(null); setTestResult(null);
return; return;
} }
@ -109,6 +126,8 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
if (isOpen && !editingNode) { if (isOpen && !editingNode) {
setFormData(() => getCleanFormData(props.nodeType)); setFormData(() => getCleanFormData(props.nodeType));
setQuickSetupCommand(''); setQuickSetupCommand('');
setQuickSetupToken('');
setQuickSetupExpiry(null);
setTestResult(null); setTestResult(null);
} }
}); });
@ -659,6 +678,9 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
const data = await response.json(); const data = await response.json();
console.log('[Quick Setup] Setup data received:', data); console.log('[Quick Setup] Setup data received:', data);
setQuickSetupToken(data.setupToken ?? '');
setQuickSetupExpiry(typeof data.expires === 'number' ? data.expires : null);
// Backend returns url, command, and expires // Backend returns url, command, and expires
// Just copy the command - don't show the modal // Just copy the command - don't show the modal
if (data.command) { if (data.command) {
@ -667,7 +689,7 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
const copied = await copyToClipboard(data.command); const copied = await copyToClipboard(data.command);
console.log('[Quick Setup] Copy result:', copied); console.log('[Quick Setup] Copy result:', copied);
if (copied) { if (copied) {
showSuccess('Command copied to clipboard!'); showSuccess('Command copied to clipboard! Paste the setup token shown below when prompted.');
} else { } else {
showError('Failed to copy to clipboard'); showError('Failed to copy to clipboard');
} }
@ -675,10 +697,14 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
console.log('[Quick Setup] No command in response'); console.log('[Quick Setup] No command in response');
} }
} else { } else {
setQuickSetupToken('');
setQuickSetupExpiry(null);
showError('Failed to generate setup URL'); showError('Failed to generate setup URL');
} }
} catch (error) { } catch (error) {
console.error('[Quick Setup] Error:', error); console.error('[Quick Setup] Error:', error);
setQuickSetupToken('');
setQuickSetupExpiry(null);
showError('Failed to copy command'); showError('Failed to copy command');
} }
}} }}
@ -718,6 +744,19 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
{quickSetupCommand()} {quickSetupCommand()}
</code> </code>
</Show> </Show>
<Show when={quickSetupToken().length > 0}>
<div class="mt-2 text-xs text-blue-800 dark:text-blue-200">
<span class="font-semibold">Setup token:</span>
<code class="ml-1 font-mono break-all text-blue-900 dark:text-blue-100">
{quickSetupToken()}
</code>
<Show when={quickSetupExpiry()}>
<span class="ml-2">
Expires at {quickSetupExpiryLabel()}
</span>
</Show>
</div>
</Show>
</div> </div>
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3"> <div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
@ -1175,6 +1214,19 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
{quickSetupCommand()} {quickSetupCommand()}
</code> </code>
</Show> </Show>
<Show when={quickSetupToken().length > 0}>
<div class="mt-2 text-xs text-blue-800 dark:text-blue-200">
<span class="font-semibold">Setup token:</span>
<code class="ml-1 font-mono break-all text-blue-900 dark:text-blue-100">
{quickSetupToken()}
</code>
<Show when={quickSetupExpiry()}>
<span class="ml-2">
Expires at {quickSetupExpiryLabel()}
</span>
</Show>
</div>
</Show>
</div> </div>
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3"> <div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">

View file

@ -3577,9 +3577,32 @@ else
# Try auto-registration # Try auto-registration
echo "Registering node with Pulse..." echo "Registering node with Pulse..."
# Use auth token from URL parameter (much simpler!) # Use auth token from URL parameter when provided (automation workflows)
AUTH_TOKEN="%s" AUTH_TOKEN="%s"
# Allow non-interactive override via environment variable
if [ -z "$AUTH_TOKEN" ] && [ -n "$PULSE_SETUP_TOKEN" ]; then
AUTH_TOKEN="$PULSE_SETUP_TOKEN"
fi
# Prompt the operator if we still don't have a token and a TTY is available
if [ -z "$AUTH_TOKEN" ]; then
if [ -t 0 ]; then
printf "Pulse setup token: "
if command -v stty >/dev/null 2>&1; then stty -echo; fi
IFS= read -r AUTH_TOKEN
if command -v stty >/dev/null 2>&1; then stty echo; fi
printf "\n"
elif { exec 3</dev/tty; } 2>/dev/null; then
printf "Pulse setup token: " >&3
if command -v stty >/dev/null 2>&1; then stty -echo <&3 2>/dev/null || true; fi
IFS= read -r AUTH_TOKEN <&3 || true
if command -v stty >/dev/null 2>&1; then stty echo <&3 2>/dev/null || true; fi
printf "\n" >&3
exec 3<&-
fi
fi
# Only proceed with auto-registration if we have an auth token # Only proceed with auto-registration if we have an auth token
if [ -n "$AUTH_TOKEN" ]; then if [ -n "$AUTH_TOKEN" ]; then
# Get the server's hostname # Get the server's hostname
@ -3618,8 +3641,7 @@ else
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d @- 2>&1) -d @- 2>&1)
else else
echo "Warning: No authentication token provided" echo "⚠️ Auto-registration skipped: no setup token provided"
echo "Auto-registration skipped"
AUTO_REG_SUCCESS=false AUTO_REG_SUCCESS=false
REGISTER_RESPONSE="" REGISTER_RESPONSE=""
fi fi
@ -4615,9 +4637,32 @@ else
echo "🔄 Attempting auto-registration with Pulse..." echo "🔄 Attempting auto-registration with Pulse..."
echo "" echo ""
# Use auth token from URL parameter (much simpler!) # Use auth token from URL parameter when provided (automation workflows)
AUTH_TOKEN="%s" AUTH_TOKEN="%s"
# Allow non-interactive override via environment variable
if [ -z "$AUTH_TOKEN" ] && [ -n "$PULSE_SETUP_TOKEN" ]; then
AUTH_TOKEN="$PULSE_SETUP_TOKEN"
fi
# Prompt the operator if we still don't have a token and a TTY is available
if [ -z "$AUTH_TOKEN" ]; then
if [ -t 0 ]; then
printf "Pulse setup token: "
if command -v stty >/dev/null 2>&1; then stty -echo; fi
IFS= read -r AUTH_TOKEN
if command -v stty >/dev/null 2>&1; then stty echo; fi
printf "\n"
elif { exec 3</dev/tty; } 2>/dev/null; then
printf "Pulse setup token: " >&3
if command -v stty >/dev/null 2>&1; then stty -echo <&3 2>/dev/null || true; fi
IFS= read -r AUTH_TOKEN <&3 || true
if command -v stty >/dev/null 2>&1; then stty echo <&3 2>/dev/null || true; fi
printf "\n" >&3
exec 3<&-
fi
fi
# Only proceed with auto-registration if we have an auth token # Only proceed with auto-registration if we have an auth token
if [ -n "$AUTH_TOKEN" ]; then if [ -n "$AUTH_TOKEN" ]; then
# Get the server's hostname # Get the server's hostname
@ -4667,7 +4712,7 @@ EOF
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "$REGISTER_JSON" 2>&1) -d "$REGISTER_JSON" 2>&1)
else else
echo "⚠️ No setup code provided - skipping auto-registration" echo "⚠️ Auto-registration skipped: no setup token provided"
AUTO_REG_SUCCESS=false AUTO_REG_SUCCESS=false
REGISTER_RESPONSE="" REGISTER_RESPONSE=""
fi fi
@ -4815,16 +4860,23 @@ func (h *ConfigHandlers) HandleSetupScriptURL(w http.ResponseWriter, r *http.Req
backupPerms = "&backup_perms=true" backupPerms = "&backup_perms=true"
} }
// Include the token directly in the URL - much simpler! // Build script URL without embedding the secret token directly
scriptURL := fmt.Sprintf("%s/api/setup-script?type=%s%s&pulse_url=%s%s&auth_token=%s", scriptURL := fmt.Sprintf("%s/api/setup-script?type=%s%s&pulse_url=%s%s",
pulseURL, req.Type, encodedHost, pulseURL, backupPerms, token) pulseURL, req.Type, encodedHost, pulseURL, backupPerms)
// Return a simple curl command - no environment variables needed // Return a simple curl command - no environment variables needed
// Don't include setupCode since it's already embedded in the URL // The setup token is returned separately so the script can prompt the user
tokenHint := token
if len(token) > 6 {
tokenHint = fmt.Sprintf("%s…%s", token[:3], token[len(token)-3:])
}
response := map[string]interface{}{ response := map[string]interface{}{
"url": scriptURL, "url": scriptURL,
"command": fmt.Sprintf(`curl -sSL "%s" | bash`, scriptURL), "command": fmt.Sprintf(`curl -sSL "%s" | bash`, scriptURL),
"expires": expiry.Unix(), "expires": expiry.Unix(),
"setupToken": token,
"tokenHint": tokenHint,
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@ -5052,17 +5104,19 @@ func (h *ConfigHandlers) HandleAutoRegister(w http.ResponseWriter, r *http.Reque
} }
} }
// If still not authenticated and auth is required, reject // Abort when no authentication succeeded. This applies even when API tokens
// BUT: Always allow if a valid setup code/auth token was provided (even if expired/used) // are not configured to ensure one-time setup tokens are always required.
// This ensures the error message is accurate if !authenticated {
if !authenticated && h.config.HasAPITokens() && authCode == "" { log.Warn().
log.Warn().Str("ip", r.RemoteAddr).Msg("Unauthorized auto-register attempt - no authentication provided") Str("ip", r.RemoteAddr).
http.Error(w, "Pulse requires authentication", http.StatusUnauthorized) Bool("has_auth_code", authCode != "").
return Msg("Unauthorized auto-register attempt rejected")
} else if !authenticated && h.config.HasAPITokens() {
// Had a code but it didn't validate if authCode == "" && r.Header.Get("X-API-Token") == "" {
log.Warn().Str("ip", r.RemoteAddr).Msg("Unauthorized auto-register attempt - invalid or expired setup code") http.Error(w, "Pulse requires authentication", http.StatusUnauthorized)
http.Error(w, "Invalid or expired setup code", http.StatusUnauthorized) } else {
http.Error(w, "Invalid or expired setup code", http.StatusUnauthorized)
}
return return
} }

View file

@ -0,0 +1,110 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
internalauth "github.com/rcourtman/pulse-go-rewrite/internal/auth"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
func newTestConfigHandlers(t *testing.T, cfg *config.Config) *ConfigHandlers {
t.Helper()
h := &ConfigHandlers{
config: cfg,
persistence: config.NewConfigPersistence(cfg.DataPath),
setupCodes: make(map[string]*SetupCode),
recentSetupTokens: make(map[string]time.Time),
lastClusterDetection: make(map[string]time.Time),
recentAutoRegistered: make(map[string]time.Time),
}
return h
}
func TestHandleAutoRegisterRejectsWithoutAuth(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("PULSE_DATA_DIR", tempDir)
cfg := &config.Config{
DataPath: tempDir,
ConfigPath: tempDir,
}
handler := newTestConfigHandlers(t, cfg)
reqBody := AutoRegisterRequest{
Type: "pve",
Host: "https://pve.local:8006",
TokenID: "pulse-monitor@pam!token",
TokenValue: "secret-token",
ServerName: "pve.local",
}
body, err := json.Marshal(reqBody)
if err != nil {
t.Fatalf("failed to marshal request: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/auto-register", bytes.NewReader(body))
rec := httptest.NewRecorder()
handler.HandleAutoRegister(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected status 401, got %d, body=%s", rec.Code, rec.Body.String())
}
}
func TestHandleAutoRegisterAcceptsWithSetupToken(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("PULSE_DATA_DIR", tempDir)
cfg := &config.Config{
DataPath: tempDir,
ConfigPath: tempDir,
}
handler := newTestConfigHandlers(t, cfg)
const tokenValue = "TEMP-TOKEN"
tokenHash := internalauth.HashAPIToken(tokenValue)
handler.codeMutex.Lock()
handler.setupCodes[tokenHash] = &SetupCode{
ExpiresAt: time.Now().Add(5 * time.Minute),
NodeType: "pve",
}
handler.codeMutex.Unlock()
reqBody := AutoRegisterRequest{
Type: "pve",
Host: "https://pve.local:8006",
TokenID: "pulse-monitor@pam!token",
TokenValue: "secret-token",
ServerName: "pve.local",
AuthToken: tokenValue,
}
body, err := json.Marshal(reqBody)
if err != nil {
t.Fatalf("failed to marshal request: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/auto-register", bytes.NewReader(body))
rec := httptest.NewRecorder()
handler.HandleAutoRegister(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d, body=%s", rec.Code, rec.Body.String())
}
if len(cfg.PVEInstances) != 1 {
t.Fatalf("expected 1 PVE instance stored, got %d", len(cfg.PVEInstances))
}
}

View file

@ -33,17 +33,33 @@ type ConfigPersistence struct {
crypto *crypto.CryptoManager crypto *crypto.CryptoManager
} }
// NewConfigPersistence creates a new config persistence manager // NewConfigPersistence creates a new config persistence manager.
// The process terminates if encryption cannot be initialized to avoid
// writing secrets to disk in plaintext.
func NewConfigPersistence(configDir string) *ConfigPersistence { func NewConfigPersistence(configDir string) *ConfigPersistence {
cp, err := newConfigPersistence(configDir)
if err != nil {
log.Fatal().
Str("configDir", configDir).
Err(err).
Msg("Failed to initialize config persistence")
}
return cp
}
func newConfigPersistence(configDir string) (*ConfigPersistence, error) {
if configDir == "" { if configDir == "" {
configDir = "/etc/pulse" if envDir := os.Getenv("PULSE_DATA_DIR"); envDir != "" {
configDir = envDir
} else {
configDir = "/etc/pulse"
}
} }
// Initialize crypto manager // Initialize crypto manager
cryptoMgr, err := crypto.NewCryptoManager() cryptoMgr, err := crypto.NewCryptoManagerAt(configDir)
if err != nil { if err != nil {
log.Error().Err(err).Msg("Failed to initialize crypto manager, using unencrypted storage") return nil, err
cryptoMgr = nil
} }
cp := &ConfigPersistence{ cp := &ConfigPersistence{
@ -66,7 +82,7 @@ func NewConfigPersistence(configDir string) *ConfigPersistence {
Bool("encryptionEnabled", cryptoMgr != nil). Bool("encryptionEnabled", cryptoMgr != nil).
Msg("Config persistence initialized") Msg("Config persistence initialized")
return cp return cp, nil
} }
// EnsureConfigDir ensures the configuration directory exists // EnsureConfigDir ensures the configuration directory exists

View file

@ -0,0 +1,20 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestNewConfigPersistenceFailsWhenEncryptedDataPresentWithoutKey(t *testing.T) {
dir := t.TempDir()
// Simulate existing encrypted data without providing the encryption key.
if err := os.WriteFile(filepath.Join(dir, "nodes.enc"), []byte("ciphertext"), 0600); err != nil {
t.Fatalf("failed to write simulated encrypted file: %v", err)
}
if _, err := newConfigPersistence(dir); err == nil {
t.Fatalf("expected error when initializing persistence without encryption key")
}
}

View file

@ -19,9 +19,14 @@ type CryptoManager struct {
key []byte key []byte
} }
// NewCryptoManager creates a new crypto manager // NewCryptoManager creates a new crypto manager using the default data directory.
func NewCryptoManager() (*CryptoManager, error) { func NewCryptoManager() (*CryptoManager, error) {
key, err := getOrCreateKey() return NewCryptoManagerAt(utils.GetDataDir())
}
// NewCryptoManagerAt creates a new crypto manager with an explicit data directory override.
func NewCryptoManagerAt(dataDir string) (*CryptoManager, error) {
key, err := getOrCreateKeyAt(dataDir)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get encryption key: %w", err) return nil, fmt.Errorf("failed to get encryption key: %w", err)
} }
@ -31,10 +36,12 @@ func NewCryptoManager() (*CryptoManager, error) {
}, nil }, nil
} }
// getOrCreateKey gets the encryption key or creates one if it doesn't exist // getOrCreateKeyAt gets the encryption key or creates one if it doesn't exist
func getOrCreateKey() ([]byte, error) { func getOrCreateKeyAt(dataDir string) ([]byte, error) {
// Use data directory for key storage (for Docker persistence) if dataDir == "" {
dataDir := utils.GetDataDir() dataDir = utils.GetDataDir()
}
keyPath := filepath.Join(dataDir, ".encryption.key") keyPath := filepath.Join(dataDir, ".encryption.key")
oldKeyPath := "/etc/pulse/.encryption.key" oldKeyPath := "/etc/pulse/.encryption.key"

View file

@ -0,0 +1,20 @@
package monitoring
import (
"os"
"testing"
)
func TestMain(m *testing.M) {
dataDir, err := os.MkdirTemp("", "monitoring-test-data-*")
if err != nil {
panic(err)
}
defer os.RemoveAll(dataDir)
if err := os.Setenv("PULSE_DATA_DIR", dataDir); err != nil {
panic(err)
}
os.Exit(m.Run())
}