From 5a2d808aa16e01de3e1b163f1df3c711005891ce Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 25 Oct 2025 16:00:37 +0000 Subject: [PATCH] Harden setup token flow and enforce encrypted persistence --- README.md | 5 +- docs/API.md | 9 +- .../src/components/Settings/NodeModal.tsx | 54 ++++++++- internal/api/config_handlers.go | 104 +++++++++++++---- .../api/config_handlers_auto_register_test.go | 110 ++++++++++++++++++ internal/config/persistence.go | 28 ++++- internal/config/persistence_fail_test.go | 20 ++++ internal/crypto/crypto.go | 19 ++- internal/monitoring/main_test.go | 20 ++++ 9 files changed, 325 insertions(+), 44 deletions(-) create mode 100644 internal/api/config_handlers_auto_register_test.go create mode 100644 internal/config/persistence_fail_test.go create mode 100644 internal/monitoring/main_test.go diff --git a/README.md b/README.md index 56df67768..ba71b9982 100644 --- a/README.md +++ b/README.md @@ -137,13 +137,14 @@ See [Configuration Guide](docs/CONFIGURATION.md#automated-setup-skip-ui) for det 2. Discovered nodes appear automatically 3. Click "Setup Script" next to any node 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 **Example:** ```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) Use your permanent API token directly in the URL for automation: diff --git a/docs/API.md b/docs/API.md index 480b4e681..ce4c72099 100644 --- a/docs/API.md +++ b/docs/API.md @@ -758,8 +758,9 @@ Response: { "url": "http://pulse.local:7655/api/setup-script?type=pve&host=...", "command": "curl -sSL \"http://pulse.local:7655/api/setup-script?...\" | bash", - "setupCode": "A7K9P2", // 6-character one-time code - "expires": 1755123456 // Unix timestamp when code expires (5 minutes) + "setupToken": "4c7f3e8c1c5f4b0da580c4477f4b1c2d", + "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) 2. Generate an API token for that user 3. Set appropriate permissions -4. Prompt for the setup code -5. Auto-register with Pulse if a valid code is provided +4. Prompt for the setup token (or read `PULSE_SETUP_TOKEN` if set) +5. Auto-register with Pulse if a valid token is provided ### Auto-Register Node Register a node automatically (used by setup scripts). Requires either a valid setup code or API token. diff --git a/frontend-modern/src/components/Settings/NodeModal.tsx b/frontend-modern/src/components/Settings/NodeModal.tsx index 1e86b990e..666fecc83 100644 --- a/frontend-modern/src/components/Settings/NodeModal.tsx +++ b/frontend-modern/src/components/Settings/NodeModal.tsx @@ -74,6 +74,19 @@ export const NodeModal: Component = (props) => { const [formData, setFormData] = createSignal(getCleanFormData()); const [quickSetupCommand, setQuickSetupCommand] = createSignal(''); + const [quickSetupToken, setQuickSetupToken] = createSignal(''); + const [quickSetupExpiry, setQuickSetupExpiry] = createSignal(null); + const quickSetupExpiryLabel = () => { + const expiry = quickSetupExpiry(); + if (!expiry) { + return ''; + } + try { + return new Date(expiry * 1000).toLocaleTimeString(); + } catch { + return ''; + } + }; // Track previous state to detect changes let previousResetKey: number | undefined = undefined; @@ -91,6 +104,8 @@ export const NodeModal: Component = (props) => { previousResetKey = key; setFormData(() => getCleanFormData(props.nodeType)); setQuickSetupCommand(''); + setQuickSetupToken(''); + setQuickSetupExpiry(null); setTestResult(null); return; } @@ -100,6 +115,8 @@ export const NodeModal: Component = (props) => { previousNodeType = nodeType; setFormData(() => getCleanFormData(props.nodeType)); setQuickSetupCommand(''); + setQuickSetupToken(''); + setQuickSetupExpiry(null); setTestResult(null); return; } @@ -109,6 +126,8 @@ export const NodeModal: Component = (props) => { if (isOpen && !editingNode) { setFormData(() => getCleanFormData(props.nodeType)); setQuickSetupCommand(''); + setQuickSetupToken(''); + setQuickSetupExpiry(null); setTestResult(null); } }); @@ -659,6 +678,9 @@ export const NodeModal: Component = (props) => { const data = await response.json(); 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 // Just copy the command - don't show the modal if (data.command) { @@ -667,7 +689,7 @@ export const NodeModal: Component = (props) => { const copied = await copyToClipboard(data.command); console.log('[Quick Setup] Copy result:', copied); if (copied) { - showSuccess('Command copied to clipboard!'); + showSuccess('Command copied to clipboard! Paste the setup token shown below when prompted.'); } else { showError('Failed to copy to clipboard'); } @@ -675,10 +697,14 @@ export const NodeModal: Component = (props) => { console.log('[Quick Setup] No command in response'); } } else { + setQuickSetupToken(''); + setQuickSetupExpiry(null); showError('Failed to generate setup URL'); } } catch (error) { console.error('[Quick Setup] Error:', error); + setQuickSetupToken(''); + setQuickSetupExpiry(null); showError('Failed to copy command'); } }} @@ -718,6 +744,19 @@ export const NodeModal: Component = (props) => { {quickSetupCommand()} + 0}> +
+ Setup token: + + {quickSetupToken()} + + + + Expires at {quickSetupExpiryLabel()} + + +
+
@@ -1175,6 +1214,19 @@ export const NodeModal: Component = (props) => { {quickSetupCommand()} + 0}> +
+ Setup token: + + {quickSetupToken()} + + + + Expires at {quickSetupExpiryLabel()} + + +
+
diff --git a/internal/api/config_handlers.go b/internal/api/config_handlers.go index 5a4b05b02..14598d363 100644 --- a/internal/api/config_handlers.go +++ b/internal/api/config_handlers.go @@ -3577,9 +3577,32 @@ else # Try auto-registration 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" - + + # 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/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 if [ -n "$AUTH_TOKEN" ]; then # Get the server's hostname @@ -3618,8 +3641,7 @@ else -H "Content-Type: application/json" \ -d @- 2>&1) else - echo "Warning: No authentication token provided" - echo "Auto-registration skipped" + echo "⚠️ Auto-registration skipped: no setup token provided" AUTO_REG_SUCCESS=false REGISTER_RESPONSE="" fi @@ -4615,9 +4637,32 @@ else echo "🔄 Attempting auto-registration with Pulse..." echo "" - # Use auth token from URL parameter (much simpler!) + # Use auth token from URL parameter when provided (automation workflows) 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/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 if [ -n "$AUTH_TOKEN" ]; then # Get the server's hostname @@ -4667,7 +4712,7 @@ EOF -H "Content-Type: application/json" \ -d "$REGISTER_JSON" 2>&1) else - echo "⚠️ No setup code provided - skipping auto-registration" + echo "⚠️ Auto-registration skipped: no setup token provided" AUTO_REG_SUCCESS=false REGISTER_RESPONSE="" fi @@ -4815,16 +4860,23 @@ func (h *ConfigHandlers) HandleSetupScriptURL(w http.ResponseWriter, r *http.Req backupPerms = "&backup_perms=true" } - // Include the token directly in the URL - much simpler! - scriptURL := fmt.Sprintf("%s/api/setup-script?type=%s%s&pulse_url=%s%s&auth_token=%s", - pulseURL, req.Type, encodedHost, pulseURL, backupPerms, token) + // Build script URL without embedding the secret token directly + scriptURL := fmt.Sprintf("%s/api/setup-script?type=%s%s&pulse_url=%s%s", + pulseURL, req.Type, encodedHost, pulseURL, backupPerms) // 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{}{ - "url": scriptURL, - "command": fmt.Sprintf(`curl -sSL "%s" | bash`, scriptURL), - "expires": expiry.Unix(), + "url": scriptURL, + "command": fmt.Sprintf(`curl -sSL "%s" | bash`, scriptURL), + "expires": expiry.Unix(), + "setupToken": token, + "tokenHint": tokenHint, } 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 - // BUT: Always allow if a valid setup code/auth token was provided (even if expired/used) - // This ensures the error message is accurate - if !authenticated && h.config.HasAPITokens() && authCode == "" { - log.Warn().Str("ip", r.RemoteAddr).Msg("Unauthorized auto-register attempt - no authentication provided") - http.Error(w, "Pulse requires authentication", http.StatusUnauthorized) - return - } else if !authenticated && h.config.HasAPITokens() { - // Had a code but it didn't validate - log.Warn().Str("ip", r.RemoteAddr).Msg("Unauthorized auto-register attempt - invalid or expired setup code") - http.Error(w, "Invalid or expired setup code", http.StatusUnauthorized) + // Abort when no authentication succeeded. This applies even when API tokens + // are not configured to ensure one-time setup tokens are always required. + if !authenticated { + log.Warn(). + Str("ip", r.RemoteAddr). + Bool("has_auth_code", authCode != ""). + Msg("Unauthorized auto-register attempt rejected") + + if authCode == "" && r.Header.Get("X-API-Token") == "" { + http.Error(w, "Pulse requires authentication", http.StatusUnauthorized) + } else { + http.Error(w, "Invalid or expired setup code", http.StatusUnauthorized) + } return } diff --git a/internal/api/config_handlers_auto_register_test.go b/internal/api/config_handlers_auto_register_test.go new file mode 100644 index 000000000..429180124 --- /dev/null +++ b/internal/api/config_handlers_auto_register_test.go @@ -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)) + } +} diff --git a/internal/config/persistence.go b/internal/config/persistence.go index a58ea21b0..32373ff3a 100644 --- a/internal/config/persistence.go +++ b/internal/config/persistence.go @@ -33,17 +33,33 @@ type ConfigPersistence struct { 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 { + 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 == "" { - configDir = "/etc/pulse" + if envDir := os.Getenv("PULSE_DATA_DIR"); envDir != "" { + configDir = envDir + } else { + configDir = "/etc/pulse" + } } // Initialize crypto manager - cryptoMgr, err := crypto.NewCryptoManager() + cryptoMgr, err := crypto.NewCryptoManagerAt(configDir) if err != nil { - log.Error().Err(err).Msg("Failed to initialize crypto manager, using unencrypted storage") - cryptoMgr = nil + return nil, err } cp := &ConfigPersistence{ @@ -66,7 +82,7 @@ func NewConfigPersistence(configDir string) *ConfigPersistence { Bool("encryptionEnabled", cryptoMgr != nil). Msg("Config persistence initialized") - return cp + return cp, nil } // EnsureConfigDir ensures the configuration directory exists diff --git a/internal/config/persistence_fail_test.go b/internal/config/persistence_fail_test.go new file mode 100644 index 000000000..05b48c5fd --- /dev/null +++ b/internal/config/persistence_fail_test.go @@ -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") + } +} diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go index 4ed602112..ca2d84ed2 100644 --- a/internal/crypto/crypto.go +++ b/internal/crypto/crypto.go @@ -19,9 +19,14 @@ type CryptoManager struct { key []byte } -// NewCryptoManager creates a new crypto manager +// NewCryptoManager creates a new crypto manager using the default data directory. 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 { return nil, fmt.Errorf("failed to get encryption key: %w", err) } @@ -31,10 +36,12 @@ func NewCryptoManager() (*CryptoManager, error) { }, nil } -// getOrCreateKey gets the encryption key or creates one if it doesn't exist -func getOrCreateKey() ([]byte, error) { - // Use data directory for key storage (for Docker persistence) - dataDir := utils.GetDataDir() +// getOrCreateKeyAt gets the encryption key or creates one if it doesn't exist +func getOrCreateKeyAt(dataDir string) ([]byte, error) { + if dataDir == "" { + dataDir = utils.GetDataDir() + } + keyPath := filepath.Join(dataDir, ".encryption.key") oldKeyPath := "/etc/pulse/.encryption.key" diff --git a/internal/monitoring/main_test.go b/internal/monitoring/main_test.go new file mode 100644 index 000000000..ea5c6eee0 --- /dev/null +++ b/internal/monitoring/main_test.go @@ -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()) +}