Harden setup token flow and enforce encrypted persistence
This commit is contained in:
parent
cb37d0de01
commit
5a2d808aa1
9 changed files with 325 additions and 44 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
110
internal/api/config_handlers_auto_register_test.go
Normal file
110
internal/api/config_handlers_auto_register_test.go
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
20
internal/config/persistence_fail_test.go
Normal file
20
internal/config/persistence_fail_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
|
||||||
|
|
|
||||||
20
internal/monitoring/main_test.go
Normal file
20
internal/monitoring/main_test.go
Normal 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())
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue