From 4323339c5eef1df35b6827bbc11ff348a2d598d4 Mon Sep 17 00:00:00 2001 From: Pulse Monitor Date: Fri, 15 Aug 2025 09:58:36 +0000 Subject: [PATCH] feat: add Generate New API Token functionality - Add backend endpoint to regenerate API tokens without resetting auth - Updates .env file with new token while preserving other settings - Frontend component with clear UX for token generation - Shows new token once with copy functionality - Indicates restart required to activate new token - Works across all deployment types (Docker, LXC, native) Much better UX than telling users to 'reconfigure security' just for a new token --- .../components/Settings/GenerateAPIToken.tsx | 147 ++++++++++++++++++ .../src/components/Settings/Settings.tsx | 31 +--- internal/api/router.go | 3 + internal/api/security_setup_fix.go | 83 ++++++++++ 4 files changed, 235 insertions(+), 29 deletions(-) create mode 100644 frontend-modern/src/components/Settings/GenerateAPIToken.tsx diff --git a/frontend-modern/src/components/Settings/GenerateAPIToken.tsx b/frontend-modern/src/components/Settings/GenerateAPIToken.tsx new file mode 100644 index 000000000..08d1ff7e1 --- /dev/null +++ b/frontend-modern/src/components/Settings/GenerateAPIToken.tsx @@ -0,0 +1,147 @@ +import { Component, createSignal, Show } from 'solid-js'; +import { showSuccess, showError } from '@/utils/toast'; +import { copyToClipboard } from '@/utils/clipboard'; + +export const GenerateAPIToken: Component = () => { + const [isGenerating, setIsGenerating] = createSignal(false); + const [newToken, setNewToken] = createSignal(null); + const [showToken, setShowToken] = createSignal(false); + const [copied, setCopied] = createSignal(false); + const [deploymentType, setDeploymentType] = createSignal(''); + + const generateNewToken = async () => { + if (!confirm('Generate a new API token? The old token will stop working immediately.')) { + return; + } + + 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(); + setNewToken(data.token); + setDeploymentType(data.deploymentType); + setShowToken(true); + showSuccess('New 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 (!newToken()) return; + + const success = await copyToClipboard(newToken()!); + if (success) { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } else { + showError('Failed to copy to clipboard'); + } + }; + + const getRestartInstructions = () => { + switch(deploymentType()) { + case 'docker': + return 'Restart your Docker container to activate the new token.'; + case 'proxmoxve': + return 'Restart Pulse from the ProxmoxVE host to activate the new token.'; + case 'systemd': + return 'Run: sudo systemctl restart pulse'; + default: + return 'Restart the Pulse service to activate the new token.'; + } + }; + + return ( +
+ +
+

+ API Token Active +

+

+ An API token is configured for this instance. Use it with the X-API-Token header for automation. +

+ + +
+ +
+

Using the API Token:

+ + curl -H "X-API-Token: YOUR_TOKEN" http://pulse:7655/api/... + +
+
+ + +
+
+

+ ✅ New API Token Generated! +

+

+ Save this token now - it will never be shown again! +

+
+ +
+ +
+ + {newToken()} + + +
+
+ +
+
+ + + +
+

Restart Required

+

{getRestartInstructions()}

+

The old token has been invalidated and will no longer work.

+
+
+
+ + +
+
+
+ ); +}; \ No newline at end of file diff --git a/frontend-modern/src/components/Settings/Settings.tsx b/frontend-modern/src/components/Settings/Settings.tsx index c734a974e..cd061dc73 100644 --- a/frontend-modern/src/components/Settings/Settings.tsx +++ b/frontend-modern/src/components/Settings/Settings.tsx @@ -3,6 +3,7 @@ import { useWebSocket } from '@/App'; import { showSuccess, showError } from '@/utils/toast'; import { NodeModal } from './NodeModal'; import { QuickSecuritySetup } from './QuickSecuritySetup'; +import { GenerateAPIToken } from './GenerateAPIToken'; import { ChangePasswordModal } from './ChangePasswordModal'; import { RemovePasswordModal } from './RemovePasswordModal'; import { SettingsAPI } from '@/api/settings'; @@ -1559,35 +1560,7 @@ const Settings: Component = () => { {/* Content */}
-
-
-

- API Token Active -

-

- An API token is configured for this instance. Use it with the X-API-Token header for automation. -

-
- -
-
- - - -
-

Security Notice

-

API tokens are only visible during initial setup. If you've lost your token, you'll need to reconfigure security to generate a new one.

-
-
-
- -
-

Using the API Token:

- - curl -H "X-API-Token: YOUR_TOKEN" http://pulse:7655/api/... - -
-
+
diff --git a/internal/api/router.go b/internal/api/router.go index c8be3757f..4308a0a80 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -260,6 +260,9 @@ func (r *Router) setupRoutes() { // Quick security setup route - using fixed version r.mux.HandleFunc("/api/security/quick-setup", handleQuickSecuritySetupFixed(r)) + // API token regeneration endpoint + r.mux.HandleFunc("/api/security/regenerate-token", r.HandleRegenerateAPIToken) + // Apply security restart endpoint r.mux.HandleFunc("/api/security/apply-restart", func(w http.ResponseWriter, req *http.Request) { if req.Method == http.MethodPost { diff --git a/internal/api/security_setup_fix.go b/internal/api/security_setup_fix.go index 44c811acd..281e5d36d 100644 --- a/internal/api/security_setup_fix.go +++ b/internal/api/security_setup_fix.go @@ -1,6 +1,8 @@ package api import ( + "crypto/rand" + "encoding/hex" "encoding/json" "fmt" "net/http" @@ -284,4 +286,85 @@ ENABLE_AUDIT_LOG=true json.NewEncoder(w).Encode(response) } } +} + +// HandleRegenerateAPIToken generates a new API token and updates the .env file +func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Request) { + // Require authentication + if !CheckAuth(r.config, w, rq) { + return + } + + if rq.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Generate new token + tokenBytes := make([]byte, 32) + if _, err := rand.Read(tokenBytes); err != nil { + log.Error().Err(err).Msg("Failed to generate random token") + http.Error(w, "Failed to generate token", http.StatusInternalServerError) + return + } + newToken := hex.EncodeToString(tokenBytes) + + // Determine env file path + envPath := filepath.Join(r.config.ConfigPath, ".env") + if r.config.ConfigPath == "" { + envPath = "/etc/pulse/.env" + } + + // Docker uses /data/.env + if _, err := os.Stat("/data/.env"); err == nil { + envPath = "/data/.env" + } + + // Read existing .env file + content, err := os.ReadFile(envPath) + if err != nil { + log.Error().Err(err).Str("path", envPath).Msg("Failed to read .env file") + http.Error(w, "Security configuration not found", http.StatusNotFound) + return + } + + // Update the API_TOKEN line + lines := strings.Split(string(content), "\n") + var updated bool + for i, line := range lines { + if strings.HasPrefix(line, "API_TOKEN=") { + lines[i] = fmt.Sprintf("API_TOKEN='%s'", newToken) + updated = true + break + } + } + + if !updated { + // API_TOKEN line not found, add it + lines = append(lines, fmt.Sprintf("API_TOKEN='%s'", newToken)) + } + + // Write updated content back + newContent := strings.Join(lines, "\n") + if err := os.WriteFile(envPath, []byte(newContent), 0600); err != nil { + log.Error().Err(err).Msg("Failed to update .env file") + http.Error(w, "Failed to save new token", http.StatusInternalServerError) + return + } + + log.Info().Msg("API token regenerated successfully") + + // Get deployment type for restart instructions + deploymentType := updates.GetDeploymentType() + + response := map[string]interface{}{ + "success": true, + "token": newToken, + "deploymentType": deploymentType, + "requiresRestart": true, + "message": "New API token generated. Restart required to activate.", + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) } \ No newline at end of file