mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-13 06:56:06 +00:00
feat: implement API token management UI (addresses #302)
- Add interactive API token management in Settings > Security tab - Users can now generate, view, regenerate, and delete API tokens from the UI - Tokens are persisted in system.json and survive restarts - Environment variable API_TOKEN still takes precedence for backward compatibility - Proper authentication enforcement when tokens are configured - Secure token generation using crypto/rand (32 bytes, hex encoded) - Clean UI with copy-to-clipboard functionality for newly generated tokens
This commit is contained in:
parent
ef3789e9e0
commit
75f4b74b83
9 changed files with 576 additions and 86 deletions
111
frontend-modern/src/api/system.ts
Normal file
111
frontend-modern/src/api/system.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
// System API for managing system settings including API tokens
|
||||
|
||||
export interface APITokenStatus {
|
||||
hasToken: boolean;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export interface SystemSettings {
|
||||
pollingInterval: number;
|
||||
updateChannel?: string;
|
||||
autoUpdateEnabled: boolean;
|
||||
autoUpdateCheckInterval?: number;
|
||||
autoUpdateTime?: string;
|
||||
apiToken?: string;
|
||||
}
|
||||
|
||||
export class SystemAPI {
|
||||
// API Token Management
|
||||
static async getAPITokenStatus(): Promise<APITokenStatus> {
|
||||
const response = await fetch('/api/system/api-token');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get API token status');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
static async generateAPIToken(): Promise<APITokenStatus> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
// Include existing token if we have one
|
||||
const existingToken = localStorage.getItem('apiToken');
|
||||
if (existingToken) {
|
||||
headers['X-API-Token'] = existingToken;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/system/api-token/generate', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to generate API token');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Store the new token locally
|
||||
if (result.token) {
|
||||
localStorage.setItem('apiToken', result.token);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static async deleteAPIToken(): Promise<void> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
// Include existing token for auth
|
||||
const existingToken = localStorage.getItem('apiToken');
|
||||
if (existingToken) {
|
||||
headers['X-API-Token'] = existingToken;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/system/api-token/delete', {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete API token');
|
||||
}
|
||||
|
||||
// Clear local storage
|
||||
localStorage.removeItem('apiToken');
|
||||
}
|
||||
|
||||
// System Settings
|
||||
static async getSystemSettings(): Promise<SystemSettings> {
|
||||
const response = await fetch('/api/system/settings');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get system settings');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
static async updateSystemSettings(settings: Partial<SystemSettings>): Promise<void> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
// Include API token if configured
|
||||
const apiToken = localStorage.getItem('apiToken');
|
||||
if (apiToken) {
|
||||
headers['X-API-Token'] = apiToken;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/system/settings/update', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update system settings');
|
||||
}
|
||||
}
|
||||
}
|
||||
193
frontend-modern/src/components/Settings/APITokenManager.tsx
Normal file
193
frontend-modern/src/components/Settings/APITokenManager.tsx
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
import { createSignal, Show, onMount } from 'solid-js';
|
||||
import { SystemAPI, APITokenStatus } from '@/api/system';
|
||||
|
||||
export function APITokenManager() {
|
||||
const [tokenStatus, setTokenStatus] = createSignal<APITokenStatus | null>(null);
|
||||
const [loading, setLoading] = createSignal(false);
|
||||
const [showToken, setShowToken] = createSignal(false);
|
||||
const [generatedToken, setGeneratedToken] = createSignal<string | null>(null);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [copied, setCopied] = createSignal(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = createSignal(false);
|
||||
|
||||
// Load initial status
|
||||
onMount(async () => {
|
||||
try {
|
||||
const status = await SystemAPI.getAPITokenStatus();
|
||||
setTokenStatus(status);
|
||||
} catch (err) {
|
||||
console.error('Failed to load API token status:', err);
|
||||
}
|
||||
});
|
||||
|
||||
const generateToken = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await SystemAPI.generateAPIToken();
|
||||
setTokenStatus(result);
|
||||
setGeneratedToken(result.token || null);
|
||||
setShowToken(true);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to generate token');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteToken = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await SystemAPI.deleteAPIToken();
|
||||
setTokenStatus({ hasToken: false });
|
||||
setGeneratedToken(null);
|
||||
setShowToken(false);
|
||||
setShowDeleteConfirm(false);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to delete token');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
if (generatedToken()) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(generatedToken()!);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4">API Token Management</h4>
|
||||
|
||||
<Show when={error()}>
|
||||
<div class="mb-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p class="text-sm text-red-700 dark:text-red-300">{error()}</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={tokenStatus()?.hasToken}
|
||||
fallback={
|
||||
<div class="space-y-4">
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400">
|
||||
No API token is currently configured. Generate one to secure your Pulse instance.
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={generateToken}
|
||||
disabled={loading()}
|
||||
class="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading() ? 'Generating...' : 'Generate API Token'}
|
||||
</button>
|
||||
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
|
||||
<p class="text-xs text-blue-700 dark:text-blue-300">
|
||||
<strong>Note:</strong> Once generated, the API token will be required for all configuration changes,
|
||||
exports, and imports. Make sure to save it securely!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
API token is configured and active
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={localStorage.getItem('apiToken')}>
|
||||
<span class="text-xs px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded">
|
||||
Token stored locally
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={generatedToken() && showToken()}>
|
||||
<div class="p-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
|
||||
<p class="text-sm font-medium text-amber-800 dark:text-amber-200 mb-2">
|
||||
New API Token Generated - Save This Now!
|
||||
</p>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={generatedToken()!}
|
||||
readonly
|
||||
class="w-full px-3 py-2 pr-20 text-xs font-mono bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md"
|
||||
onClick={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
<button
|
||||
onClick={copyToClipboard}
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 px-3 py-1 text-xs bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
{copied() ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-amber-700 dark:text-amber-300 mt-2">
|
||||
This token will not be shown again. Copy it now and store it securely.
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onClick={generateToken}
|
||||
disabled={loading()}
|
||||
class="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading() ? 'Generating...' : 'Regenerate Token'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
disabled={loading()}
|
||||
class="px-4 py-2 bg-red-600 text-white text-sm rounded-md hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Remove Token
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<Show when={showDeleteConfirm()}>
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">
|
||||
Remove API Token?
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||
This will remove API authentication from your Pulse instance. All configuration endpoints
|
||||
will be accessible without credentials.
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
class="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={deleteToken}
|
||||
disabled={loading()}
|
||||
class="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading() ? 'Removing...' : 'Remove Token'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { useWebSocket } from '@/App';
|
|||
import { showSuccess, showError } from '@/utils/toast';
|
||||
import { NodeModal } from './NodeModal';
|
||||
import RegistrationTokens from './RegistrationTokens';
|
||||
import { APITokenManager } from './APITokenManager';
|
||||
import { SettingsAPI } from '@/api/settings';
|
||||
import { NodesAPI } from '@/api/nodes';
|
||||
import { UpdatesAPI } from '@/api/updates';
|
||||
|
|
@ -1472,90 +1473,7 @@ const Settings: Component = () => {
|
|||
<div class="space-y-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">API Security</h3>
|
||||
|
||||
<Show when={securityStatus()}>
|
||||
<Show
|
||||
when={!securityStatus()?.apiTokenConfigured}
|
||||
fallback={
|
||||
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4 mb-6">
|
||||
<div class="flex items-start">
|
||||
<svg class="h-5 w-5 text-green-600 dark:text-green-400 mt-0.5 mr-3 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-green-800 dark:text-green-200">API Protection Enabled</p>
|
||||
<p class="text-xs text-green-700 dark:text-green-300 mt-1">
|
||||
Your Pulse instance is protected with API token authentication.
|
||||
</p>
|
||||
<Show when={localStorage.getItem('apiToken')}>
|
||||
<button
|
||||
onClick={() => {
|
||||
localStorage.removeItem('apiToken');
|
||||
showSuccess('API token cleared from browser storage');
|
||||
}}
|
||||
class="mt-2 text-xs text-green-700 dark:text-green-300 underline hover:no-underline"
|
||||
>
|
||||
Clear stored token
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 mb-6">
|
||||
<div class="flex items-start">
|
||||
<svg class="h-5 w-5 text-yellow-600 dark:text-yellow-400 mt-0.5 mr-3 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-yellow-800 dark:text-yellow-200">API Protection Not Configured</p>
|
||||
<p class="text-xs text-yellow-700 dark:text-yellow-300 mt-1">
|
||||
Your Pulse instance is currently running without API authentication.
|
||||
All configuration endpoints are accessible without credentials.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4">Configure API Token</h4>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mb-4">
|
||||
Setting an API token will require authentication for all configuration changes and exports.
|
||||
</p>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="bg-gray-50 dark:bg-gray-900 rounded p-3">
|
||||
<p class="text-xs font-mono text-gray-700 dark:text-gray-300 mb-2">For systemd service:</p>
|
||||
<pre class="text-xs bg-black text-green-400 p-2 rounded overflow-x-auto">
|
||||
sudo systemctl edit pulse
|
||||
# Add these lines:
|
||||
[Service]
|
||||
Environment="API_TOKEN=your-secure-token-here"
|
||||
|
||||
# Then restart:
|
||||
sudo systemctl restart pulse</pre>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-gray-900 rounded p-3">
|
||||
<p class="text-xs font-mono text-gray-700 dark:text-gray-300 mb-2">For Docker:</p>
|
||||
<pre class="text-xs bg-black text-green-400 p-2 rounded overflow-x-auto">
|
||||
docker run -d \
|
||||
-e API_TOKEN=your-secure-token \
|
||||
-p 7655:7655 \
|
||||
-v pulse-data:/data \
|
||||
rcourtman/pulse:latest</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded p-3 mt-4">
|
||||
<p class="text-xs text-blue-700 dark:text-blue-300">
|
||||
<strong>Security Note:</strong> Choose a strong, random token. You can generate one with: <code class="font-mono">openssl rand -hex 32</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<APITokenManager />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -6,12 +6,11 @@
|
|||
<meta name="theme-color" content="#000000" />
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||
<title>Pulse</title>
|
||||
<script type="module" crossorigin src="/assets/index-Dw5R8pC-.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CqoxKn9z.css">
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
|
||||
<script src="/src/index.tsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -113,6 +113,9 @@ function App() {
|
|||
<circle class="pulse-center fill-white dark:fill-[#dbeafe]" cx="128" cy="128" r="26"/>
|
||||
</svg>
|
||||
<span class="text-lg font-medium text-gray-800 dark:text-gray-200">Pulse</span>
|
||||
<Show when={versionInfo()?.channel === 'rc'}>
|
||||
<span class="text-xs px-1.5 py-0.5 bg-orange-500 text-white rounded font-bold">RC</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="header-controls flex justify-end items-center gap-4 md:flex-1">
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ type Router struct {
|
|||
updateManager *updates.Manager
|
||||
exportLimiter *RateLimiter
|
||||
tokenManager *tokens.TokenManager
|
||||
persistence *config.ConfigPersistence
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -42,6 +43,7 @@ func NewRouter(cfg *config.Config, monitor *monitoring.Monitor, wsHub *websocket
|
|||
updateManager: updates.NewManager(cfg),
|
||||
exportLimiter: NewRateLimiter(5, 1*time.Minute), // 5 attempts per minute
|
||||
tokenManager: tokens.NewTokenManager(cfg.DataPath),
|
||||
persistence: config.NewConfigPersistence(cfg.DataPath),
|
||||
}
|
||||
|
||||
r.setupRoutes()
|
||||
|
|
@ -302,6 +304,14 @@ func (r *Router) setupRoutes() {
|
|||
// Settings routes
|
||||
r.mux.HandleFunc("/api/settings", getSettings)
|
||||
r.mux.HandleFunc("/api/settings/update", updateSettings)
|
||||
|
||||
// System settings and API token management
|
||||
systemSettingsHandler := NewSystemSettingsHandler(r.config, r.persistence)
|
||||
r.mux.HandleFunc("/api/system/settings", systemSettingsHandler.HandleGetSystemSettings)
|
||||
r.mux.HandleFunc("/api/system/settings/update", systemSettingsHandler.HandleUpdateSystemSettings)
|
||||
r.mux.HandleFunc("/api/system/api-token", systemSettingsHandler.HandleGetAPIToken)
|
||||
r.mux.HandleFunc("/api/system/api-token/generate", systemSettingsHandler.HandleGenerateAPIToken)
|
||||
r.mux.HandleFunc("/api/system/api-token/delete", systemSettingsHandler.HandleDeleteAPIToken)
|
||||
|
||||
// WebSocket endpoint
|
||||
r.mux.HandleFunc("/ws", r.handleWebSocket)
|
||||
|
|
|
|||
250
internal/api/system_settings.go
Normal file
250
internal/api/system_settings.go
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// SystemSettingsHandler handles system settings including API token management
|
||||
type SystemSettingsHandler struct {
|
||||
config *config.Config
|
||||
persistence *config.ConfigPersistence
|
||||
}
|
||||
|
||||
// NewSystemSettingsHandler creates a new system settings handler
|
||||
func NewSystemSettingsHandler(cfg *config.Config, persistence *config.ConfigPersistence) *SystemSettingsHandler {
|
||||
return &SystemSettingsHandler{
|
||||
config: cfg,
|
||||
persistence: persistence,
|
||||
}
|
||||
}
|
||||
|
||||
// APITokenResponse represents the API token response
|
||||
type APITokenResponse struct {
|
||||
HasToken bool `json:"hasToken"`
|
||||
Token string `json:"token,omitempty"`
|
||||
}
|
||||
|
||||
// HandleGetAPIToken returns the current API token status
|
||||
func (h *SystemSettingsHandler) HandleGetAPIToken(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if API token is set
|
||||
hasToken := h.config.APIToken != ""
|
||||
|
||||
response := APITokenResponse{
|
||||
HasToken: hasToken,
|
||||
}
|
||||
|
||||
// Only return the token if it's requested with proper auth
|
||||
if hasToken && r.URL.Query().Get("reveal") == "true" {
|
||||
// Verify the request is authenticated
|
||||
providedToken := r.Header.Get("X-API-Token")
|
||||
if providedToken == h.config.APIToken {
|
||||
response.Token = h.config.APIToken
|
||||
} else {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// HandleGenerateAPIToken generates a new API token
|
||||
func (h *SystemSettingsHandler) HandleGenerateAPIToken(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// If a token already exists, require authentication
|
||||
if h.config.APIToken != "" {
|
||||
providedToken := r.Header.Get("X-API-Token")
|
||||
if providedToken != h.config.APIToken {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a new secure 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)
|
||||
|
||||
// Save to system settings
|
||||
settings, err := h.persistence.LoadSystemSettings()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to load system settings")
|
||||
settings = &config.SystemSettings{}
|
||||
}
|
||||
|
||||
settings.APIToken = newToken
|
||||
|
||||
if err := h.persistence.SaveSystemSettings(*settings); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to save system settings")
|
||||
http.Error(w, "Failed to save token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Update the running config
|
||||
h.config.APIToken = newToken
|
||||
|
||||
// Don't override if env var is set
|
||||
if os.Getenv("API_TOKEN") != "" {
|
||||
log.Warn().Msg("API_TOKEN environment variable is set and will override UI-configured token on restart")
|
||||
}
|
||||
|
||||
log.Info().Msg("API token generated via UI")
|
||||
|
||||
response := APITokenResponse{
|
||||
HasToken: true,
|
||||
Token: newToken,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// HandleDeleteAPIToken removes the API token
|
||||
func (h *SystemSettingsHandler) HandleDeleteAPIToken(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Require authentication if token exists
|
||||
if h.config.APIToken != "" {
|
||||
providedToken := r.Header.Get("X-API-Token")
|
||||
if providedToken != h.config.APIToken {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Save to system settings
|
||||
settings, err := h.persistence.LoadSystemSettings()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to load system settings")
|
||||
settings = &config.SystemSettings{}
|
||||
}
|
||||
|
||||
settings.APIToken = ""
|
||||
|
||||
if err := h.persistence.SaveSystemSettings(*settings); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to save system settings")
|
||||
http.Error(w, "Failed to remove token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Update the running config
|
||||
h.config.APIToken = ""
|
||||
|
||||
// Warn if env var is set
|
||||
if os.Getenv("API_TOKEN") != "" {
|
||||
log.Warn().Msg("API_TOKEN environment variable is set and will override this change on restart")
|
||||
}
|
||||
|
||||
log.Info().Msg("API token removed via UI")
|
||||
|
||||
response := APITokenResponse{
|
||||
HasToken: false,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// HandleGetSystemSettings returns all system settings
|
||||
func (h *SystemSettingsHandler) HandleGetSystemSettings(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := h.persistence.LoadSystemSettings()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to load system settings")
|
||||
http.Error(w, "Failed to load settings", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Don't expose the actual token in this endpoint
|
||||
if settings.APIToken != "" {
|
||||
settings.APIToken = "***HIDDEN***"
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(settings)
|
||||
}
|
||||
|
||||
// HandleUpdateSystemSettings updates system settings
|
||||
func (h *SystemSettingsHandler) HandleUpdateSystemSettings(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Require authentication if token exists
|
||||
if h.config.APIToken != "" {
|
||||
providedToken := r.Header.Get("X-API-Token")
|
||||
if providedToken != h.config.APIToken {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var settings config.SystemSettings
|
||||
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Don't allow updating API token through this endpoint
|
||||
existingSettings, _ := h.persistence.LoadSystemSettings()
|
||||
if existingSettings != nil {
|
||||
settings.APIToken = existingSettings.APIToken
|
||||
}
|
||||
|
||||
if err := h.persistence.SaveSystemSettings(settings); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to save system settings")
|
||||
http.Error(w, "Failed to save settings", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Update relevant config fields
|
||||
if settings.PollingInterval > 0 {
|
||||
h.config.PollingInterval = time.Duration(settings.PollingInterval) * time.Second
|
||||
}
|
||||
if settings.UpdateChannel != "" {
|
||||
h.config.UpdateChannel = settings.UpdateChannel
|
||||
}
|
||||
h.config.AutoUpdateEnabled = settings.AutoUpdateEnabled
|
||||
if settings.AutoUpdateCheckInterval > 0 {
|
||||
h.config.AutoUpdateCheckInterval = time.Duration(settings.AutoUpdateCheckInterval) * time.Hour
|
||||
}
|
||||
if settings.AutoUpdateTime != "" {
|
||||
h.config.AutoUpdateTime = settings.AutoUpdateTime
|
||||
}
|
||||
|
||||
log.Info().Msg("System settings updated via UI")
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
|
||||
}
|
||||
|
|
@ -200,9 +200,13 @@ func Load() (*Config, error) {
|
|||
if systemSettings.ConnectionTimeout > 0 {
|
||||
cfg.ConnectionTimeout = time.Duration(systemSettings.ConnectionTimeout) * time.Second
|
||||
}
|
||||
if systemSettings.APIToken != "" {
|
||||
cfg.APIToken = systemSettings.APIToken
|
||||
}
|
||||
log.Info().
|
||||
Dur("interval", cfg.PollingInterval).
|
||||
Str("updateChannel", cfg.UpdateChannel).
|
||||
Bool("hasAPIToken", cfg.APIToken != "").
|
||||
Msg("Loaded system configuration")
|
||||
}
|
||||
}
|
||||
|
|
@ -316,6 +320,7 @@ func SaveConfig(cfg *Config) error {
|
|||
AutoUpdateTime: cfg.AutoUpdateTime,
|
||||
AllowedOrigins: cfg.AllowedOrigins,
|
||||
ConnectionTimeout: int(cfg.ConnectionTimeout.Seconds()),
|
||||
APIToken: cfg.APIToken,
|
||||
}
|
||||
if err := globalPersistence.SaveSystemSettings(systemSettings); err != nil {
|
||||
return fmt.Errorf("failed to save system config: %w", err)
|
||||
|
|
|
|||
|
|
@ -295,6 +295,7 @@ type SystemSettings struct {
|
|||
AutoUpdateEnabled bool `json:"autoUpdateEnabled"` // Removed omitempty so false is saved
|
||||
AutoUpdateCheckInterval int `json:"autoUpdateCheckInterval,omitempty"`
|
||||
AutoUpdateTime string `json:"autoUpdateTime,omitempty"`
|
||||
APIToken string `json:"apiToken,omitempty"`
|
||||
}
|
||||
|
||||
// SaveNodesConfig saves nodes configuration to file (encrypted)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue