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

View File

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

View File

@@ -74,6 +74,19 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
const [formData, setFormData] = createSignal(getCleanFormData());
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
let previousResetKey: number | undefined = undefined;
@@ -91,6 +104,8 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
previousResetKey = key;
setFormData(() => getCleanFormData(props.nodeType));
setQuickSetupCommand('');
setQuickSetupToken('');
setQuickSetupExpiry(null);
setTestResult(null);
return;
}
@@ -100,6 +115,8 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
previousNodeType = nodeType;
setFormData(() => getCleanFormData(props.nodeType));
setQuickSetupCommand('');
setQuickSetupToken('');
setQuickSetupExpiry(null);
setTestResult(null);
return;
}
@@ -109,6 +126,8 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
if (isOpen && !editingNode) {
setFormData(() => getCleanFormData(props.nodeType));
setQuickSetupCommand('');
setQuickSetupToken('');
setQuickSetupExpiry(null);
setTestResult(null);
}
});
@@ -659,6 +678,9 @@ export const NodeModal: Component<NodeModalProps> = (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<NodeModalProps> = (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<NodeModalProps> = (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<NodeModalProps> = (props) => {
{quickSetupCommand()}
</code>
</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 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()}
</code>
</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 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
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/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
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/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
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
}

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

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
}
// 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"

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())
}