Harden API auth and token handling

This commit is contained in:
rcourtman 2025-10-25 14:54:03 +00:00
parent 77282bd3a6
commit 7075cef326
11 changed files with 268 additions and 87 deletions

View file

@ -1,6 +1,8 @@
import { Component, createSignal, Show, onMount } from 'solid-js';
import { showSuccess, showError } from '@/utils/toast';
import { copyToClipboard } from '@/utils/clipboard';
import { clearStoredAPIToken } from '@/utils/tokenStorage';
import { clearApiToken as clearApiClientToken, setApiToken as setApiClientToken } from '@/utils/apiClient';
import { STORAGE_KEYS } from '@/constants';
import { SectionHeader } from '@/components/shared/SectionHeader';
import { showTokenReveal } from '@/stores/tokenReveal';
@ -11,7 +13,6 @@ export const FirstRunSetup: Component = () => {
const [password, setPassword] = createSignal('');
const [confirmPassword, setConfirmPassword] = createSignal('');
const [useCustomPassword, setUseCustomPassword] = createSignal(false);
const [, setApiToken] = createSignal('');
const [isSettingUp, setIsSettingUp] = createSignal(false);
const [showCredentials, setShowCredentials] = createSignal(false);
const [savedUsername, setSavedUsername] = createSignal('');
@ -98,7 +99,7 @@ export const FirstRunSetup: Component = () => {
// Generate API token
const token = generateToken();
setApiToken(token);
setApiClientToken(token);
try {
const response = await fetch('/api/security/quick-setup', {
@ -139,7 +140,8 @@ export const FirstRunSetup: Component = () => {
}
try {
localStorage.removeItem('apiToken');
clearStoredAPIToken();
clearApiClientToken();
} catch (storageError) {
console.warn('Unable to clear cached API token', storageError);
}

View file

@ -5,6 +5,8 @@ import { formatRelativeTime } from '@/utils/format';
import { useWebSocket } from '@/App';
import type { DockerHost, Host } from '@/types/api';
import { showTokenReveal, useTokenRevealState } from '@/stores/tokenReveal';
import { setStoredAPIToken } from '@/utils/tokenStorage';
import { setApiToken as setApiClientToken } from '@/utils/apiClient';
import { Card } from '@/components/shared/Card';
import { SectionHeader } from '@/components/shared/SectionHeader';
import { ApiIcon } from '@/components/icons/ApiIcon';
@ -261,14 +263,8 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
showSuccess('New API token generated. Copy it below while it is still visible.');
props.onTokensChanged?.();
try {
window.localStorage.setItem('apiToken', token);
window.dispatchEvent(
new StorageEvent('storage', { key: 'apiToken', newValue: token }),
);
} catch (storageErr) {
console.warn('Unable to persist API token in localStorage', storageErr);
}
setStoredAPIToken(token);
setApiClientToken(token);
} catch (err) {
console.error('Failed to generate API token', err);
showError('Failed to generate API token');

View file

@ -2,6 +2,8 @@ import { Component, createSignal, Show, createMemo, createEffect } from 'solid-j
import { apiFetch } from '@/utils/apiClient';
import { SecurityAPI, type APITokenRecord } from '@/api/security';
import { showTokenReveal, useTokenRevealState } from '@/stores/tokenReveal';
import { setStoredAPIToken } from '@/utils/tokenStorage';
import { setApiToken as setApiClientToken } from '@/utils/apiClient';
interface CommandBuilderProps {
command: string;
@ -203,20 +205,14 @@ export const CommandBuilder: Component<CommandBuilderProps> = (props) => {
reopenLatestTokenDialog();
// Auto-populate the command builder
setTokenInput(newToken);
if (props.onTokenGenerated) {
props.onTokenGenerated(newToken, record);
}
if (typeof window !== 'undefined') {
try {
window.localStorage.setItem('apiToken', newToken);
window.dispatchEvent(new StorageEvent('storage', { key: 'apiToken', newValue: newToken }));
} catch (storageErr) {
console.warn('Unable to persist API token in localStorage', storageErr);
}
setTokenInput(newToken);
if (props.onTokenGenerated) {
props.onTokenGenerated(newToken, record);
}
setStoredAPIToken(newToken);
setApiClientToken(newToken);
window.showToast('success', 'New API token generated. Copy it from the dialog while it is visible.');
} catch (error) {
console.error('Token generation failed:', error);

View file

@ -1,6 +1,8 @@
import { Component, createSignal, Show } from 'solid-js';
import { showSuccess, showError } from '@/utils/toast';
import { copyToClipboard } from '@/utils/clipboard';
import { clearStoredAPIToken } from '@/utils/tokenStorage';
import { clearApiToken as clearApiClientToken } from '@/utils/apiClient';
import { SectionHeader } from '@/components/shared/SectionHeader';
import { formField, labelClass, controlClass, formHelpText } from '@/components/shared/Form';
@ -125,7 +127,8 @@ export const QuickSecuritySetup: Component<QuickSecuritySetupProps> = (props) =>
}
try {
localStorage.removeItem('apiToken');
clearStoredAPIToken();
clearApiClientToken();
} catch (storageError) {
console.warn('Unable to clear cached API token', storageError);
}

View file

@ -4,6 +4,12 @@ import { useNavigate, useLocation } from '@solidjs/router';
import { useWebSocket } from '@/App';
import { showSuccess, showError } from '@/utils/toast';
import { copyToClipboard } from '@/utils/clipboard';
import {
clearStoredAPIToken,
getStoredAPIToken,
setStoredAPIToken,
} from '@/utils/tokenStorage';
import { clearApiToken as clearApiClientToken, setApiToken as setApiClientToken } from '@/utils/apiClient';
import { NodeModal } from './NodeModal';
import { ChangePasswordModal } from './ChangePasswordModal';
import { DockerAgents } from './DockerAgents';
@ -1723,7 +1729,7 @@ const Settings: Component<SettingsProps> = (props) => {
if (
!hasPasswordAuth &&
securityStatus()?.apiTokenConfigured &&
!localStorage.getItem('apiToken')
!getStoredAPIToken()
) {
setApiTokenModalSource('export');
setShowApiTokenModal(true);
@ -1747,7 +1753,7 @@ const Settings: Component<SettingsProps> = (props) => {
}
// Add API token if configured
const apiToken = localStorage.getItem('apiToken');
const apiToken = getStoredAPIToken();
if (apiToken) {
headers['X-API-Token'] = apiToken;
}
@ -1767,9 +1773,10 @@ const Settings: Component<SettingsProps> = (props) => {
const hasPasswordAuth = securityStatus()?.hasAuthentication;
if (!hasPasswordAuth) {
// Clear invalid token if we had one
const hadToken = localStorage.getItem('apiToken');
const hadToken = getStoredAPIToken();
if (hadToken) {
localStorage.removeItem('apiToken');
clearStoredAPIToken();
clearApiClientToken();
showError('Invalid or expired API token. Please re-enter.');
setApiTokenModalSource('export');
setShowApiTokenModal(true);
@ -1827,7 +1834,7 @@ const Settings: Component<SettingsProps> = (props) => {
if (
!hasPasswordAuth &&
securityStatus()?.apiTokenConfigured &&
!localStorage.getItem('apiToken')
!getStoredAPIToken()
) {
setApiTokenModalSource('import');
setShowApiTokenModal(true);
@ -1861,7 +1868,7 @@ const Settings: Component<SettingsProps> = (props) => {
}
// Add API token if configured
const apiToken = localStorage.getItem('apiToken');
const apiToken = getStoredAPIToken();
if (apiToken) {
headers['X-API-Token'] = apiToken;
}
@ -1884,9 +1891,10 @@ const Settings: Component<SettingsProps> = (props) => {
const hasPasswordAuth = securityStatus()?.hasAuthentication;
if (!hasPasswordAuth) {
// Clear invalid token if we had one
const hadToken = localStorage.getItem('apiToken');
const hadToken = getStoredAPIToken();
if (hadToken) {
localStorage.removeItem('apiToken');
clearStoredAPIToken();
clearApiClientToken();
showError('Invalid or expired API token. Please re-enter.');
setApiTokenModalSource('import');
setShowApiTokenModal(true);
@ -6437,7 +6445,9 @@ const Settings: Component<SettingsProps> = (props) => {
type="button"
onClick={() => {
if (apiTokenInput()) {
localStorage.setItem('apiToken', apiTokenInput());
const tokenValue = apiTokenInput()!;
setStoredAPIToken(tokenValue);
setApiClientToken(tokenValue);
const source = apiTokenModalSource();
setShowApiTokenModal(false);
setApiTokenInput('');

View file

@ -39,12 +39,21 @@ const readStoredToken = (primaryKey: string, legacyKeys: readonly string[] = [])
if (typeof window === 'undefined') {
return null;
}
const primary = window.localStorage.getItem(primaryKey);
let storage: Storage | undefined;
try {
storage = window.sessionStorage;
} catch {
storage = undefined;
}
if (!storage) {
return null;
}
const primary = storage.getItem(primaryKey);
if (primary) {
return primary;
}
for (const key of legacyKeys) {
const value = window.localStorage.getItem(key);
const value = storage.getItem(key);
if (value) {
return value;
}
@ -69,16 +78,22 @@ export const useScopedTokenManager = (options: UseScopedTokenManagerOptions): Sc
if (typeof window === 'undefined') {
return;
}
let storage: Storage | undefined;
try {
storage = window.sessionStorage;
} catch {
storage = undefined;
}
try {
if (current) {
window.localStorage.setItem(storageKey, current);
storage?.setItem(storageKey, current);
try {
window.dispatchEvent(new StorageEvent('storage', { key: storageKey, newValue: current }));
} catch (eventErr) {
console.debug('Unable to dispatch storage event for token update', eventErr);
}
} else {
window.localStorage.removeItem(storageKey);
storage?.removeItem(storageKey);
try {
window.dispatchEvent(new StorageEvent('storage', { key: storageKey, newValue: null }));
} catch (eventErr) {
@ -86,7 +101,7 @@ export const useScopedTokenManager = (options: UseScopedTokenManagerOptions): Sc
}
}
} catch (err) {
console.warn('Unable to persist API token in localStorage', err);
console.warn('Unable to persist API token in sessionStorage', err);
}
});

View file

@ -87,6 +87,21 @@ class ApiClient {
sessionStorage.removeItem('pulse_auth_user');
}
clearApiToken() {
this.apiToken = null;
try {
const stored = sessionStorage.getItem('pulse_auth');
if (stored) {
const parsed = JSON.parse(stored);
if (parsed?.type === 'token') {
sessionStorage.removeItem('pulse_auth');
}
}
} catch {
sessionStorage.removeItem('pulse_auth');
}
}
// Check if we have any auth configured
hasAuth(): boolean {
return !!(this.authHeader || this.apiToken);
@ -263,5 +278,6 @@ export const setBasicAuth = (username: string, password: string) =>
apiClient.setBasicAuth(username, password);
export const setApiToken = (token: string) => apiClient.setApiToken(token);
export const clearAuth = () => apiClient.clearAuth();
export const clearApiToken = () => apiClient.clearApiToken();
export const hasAuth = () => apiClient.hasAuth();
export const checkAuthRequired = () => apiClient.checkAuthRequired();

View file

@ -0,0 +1,98 @@
import { onCleanup } from 'solid-js';
const STORAGE_KEY = 'pulse_api_token';
let memoryToken: string | null = null;
const listeners = new Set<(value: string | null) => void>();
const getSessionStorage = (): Storage | undefined => {
if (typeof window === 'undefined') {
return undefined;
}
try {
return window.sessionStorage;
} catch {
return undefined;
}
};
const notify = (value: string | null) => {
listeners.forEach((listener) => {
try {
listener(value);
} catch {
// Ignore listener failures to avoid breaking notification flow.
}
});
if (typeof window !== 'undefined') {
try {
window.dispatchEvent(new StorageEvent('storage', { key: STORAGE_KEY, newValue: value ?? null }));
} catch {
// Some browsers disallow programmatic StorageEvent dispatch; ignore failures.
}
}
};
const writeValue = (value: string | null) => {
memoryToken = value;
const storage = getSessionStorage();
if (!storage) {
notify(value);
return;
}
try {
if (value === null) {
storage.removeItem(STORAGE_KEY);
} else {
storage.setItem(STORAGE_KEY, value);
}
} catch {
// If sessionStorage is unavailable (e.g., Safari private mode), fall back to memory token only.
}
notify(value);
};
export const getStoredAPIToken = (): string | null => {
const storage = getSessionStorage();
if (!storage) {
return memoryToken;
}
try {
const stored = storage.getItem(STORAGE_KEY);
if (stored !== null) {
memoryToken = stored;
}
return stored ?? memoryToken;
} catch {
return memoryToken;
}
};
export const setStoredAPIToken = (token: string): void => {
writeValue(token);
};
export const clearStoredAPIToken = (): void => {
writeValue(null);
};
export const subscribeAPIToken = (listener: (token: string | null) => void): (() => void) => {
listeners.add(listener);
listener(getStoredAPIToken());
return () => {
listeners.delete(listener);
};
};
export const useAPITokenSubscription = (listener: (token: string | null) => void): void => {
if (typeof window === 'undefined') {
listener(getStoredAPIToken());
return;
}
const unsubscribe = subscribeAPIToken(listener);
onCleanup(unsubscribe);
};

View file

@ -230,7 +230,6 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool
}
// API-only mode: when only API token is configured (no password auth)
// Allow read-only endpoints for the UI to work
if cfg.AuthUser == "" && cfg.AuthPass == "" && cfg.HasAPITokens() {
// Check if an API token was provided
providedToken := r.Header.Get("X-API-Token")
@ -251,32 +250,7 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool
return false
}
// No token provided - allow read-only endpoints for UI
if r.Method == "GET" || r.URL.Path == "/ws" {
// Allow these endpoints without auth for UI to function
allowedPaths := []string{
"/api/state",
"/api/config/nodes",
"/api/config/system",
"/api/settings",
"/api/discover",
"/api/security/status",
"/api/version",
"/api/health",
"/api/updates/check",
"/api/system/diagnostics",
"/api/guests/metadata",
"/ws", // WebSocket for real-time updates
}
for _, path := range allowedPaths {
if r.URL.Path == path || strings.HasPrefix(r.URL.Path, path+"/") {
log.Debug().Str("path", r.URL.Path).Msg("Allowing read-only access in API-only mode")
return true
}
}
}
// Require token for everything else
// Require a valid token for all requests in API-only mode
if w != nil {
w.Header().Set("WWW-Authenticate", `Bearer realm="API Token Required"`)
http.Error(w, "API token required", http.StatusUnauthorized)
@ -342,7 +316,15 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool
// Check basic auth
if cfg.AuthUser != "" && cfg.AuthPass != "" {
auth := r.Header.Get("Authorization")
log.Debug().Str("auth_header", auth).Str("url", r.URL.Path).Msg("Checking auth")
authScheme := "none"
if auth != "" {
if idx := strings.IndexByte(auth, ' '); idx != -1 {
authScheme = strings.ToLower(auth[:idx])
} else {
authScheme = strings.ToLower(auth)
}
}
log.Debug().Str("auth_scheme", authScheme).Str("url", r.URL.Path).Msg("Checking Authorization header")
if auth != "" {
const prefix = "Basic "
if strings.HasPrefix(auth, prefix) {

View file

@ -7,6 +7,7 @@ import (
"net/http"
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
"github.com/rcourtman/pulse-go-rewrite/internal/notifications"
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
@ -525,32 +526,85 @@ func (h *NotificationHandlers) TestWebhook(w http.ResponseWriter, r *http.Reques
func (h *NotificationHandlers) HandleNotifications(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/notifications")
requireAnyScope := func(required string, scopes ...string) bool {
record := getAPITokenRecordFromRequest(r)
if record == nil {
return true
}
for _, scope := range scopes {
if scope != "" && record.HasScope(scope) {
return true
}
}
respondMissingScope(w, required)
return false
}
switch {
case path == "/email" && r.Method == http.MethodGet:
if !requireAnyScope(config.ScopeSettingsRead, config.ScopeSettingsRead, config.ScopeSettingsWrite) {
return
}
h.GetEmailConfig(w, r)
case path == "/email" && r.Method == http.MethodPut:
if !requireAnyScope(config.ScopeSettingsWrite, config.ScopeSettingsWrite) {
return
}
h.UpdateEmailConfig(w, r)
case path == "/apprise" && r.Method == http.MethodGet:
if !requireAnyScope(config.ScopeSettingsRead, config.ScopeSettingsRead, config.ScopeSettingsWrite) {
return
}
h.GetAppriseConfig(w, r)
case path == "/apprise" && r.Method == http.MethodPut:
if !requireAnyScope(config.ScopeSettingsWrite, config.ScopeSettingsWrite) {
return
}
h.UpdateAppriseConfig(w, r)
case path == "/webhooks" && r.Method == http.MethodGet:
if !requireAnyScope(config.ScopeSettingsRead, config.ScopeSettingsRead, config.ScopeSettingsWrite) {
return
}
h.GetWebhooks(w, r)
case path == "/webhooks" && r.Method == http.MethodPost:
if !requireAnyScope(config.ScopeSettingsWrite, config.ScopeSettingsWrite) {
return
}
h.CreateWebhook(w, r)
case path == "/webhooks/test" && r.Method == http.MethodPost:
if !requireAnyScope(config.ScopeSettingsWrite, config.ScopeSettingsWrite) {
return
}
h.TestWebhook(w, r)
case strings.HasPrefix(path, "/webhooks/") && r.Method == http.MethodPut:
if !requireAnyScope(config.ScopeSettingsWrite, config.ScopeSettingsWrite) {
return
}
h.UpdateWebhook(w, r)
case strings.HasPrefix(path, "/webhooks/") && r.Method == http.MethodDelete:
if !requireAnyScope(config.ScopeSettingsWrite, config.ScopeSettingsWrite) {
return
}
h.DeleteWebhook(w, r)
case path == "/webhook-templates" && r.Method == http.MethodGet:
if !requireAnyScope(config.ScopeSettingsRead, config.ScopeSettingsRead, config.ScopeSettingsWrite) {
return
}
h.GetWebhookTemplates(w, r)
case path == "/webhook-history" && r.Method == http.MethodGet:
if !requireAnyScope(config.ScopeSettingsRead, config.ScopeSettingsRead, config.ScopeSettingsWrite) {
return
}
h.GetWebhookHistory(w, r)
case path == "/email-providers" && r.Method == http.MethodGet:
if !requireAnyScope(config.ScopeSettingsRead, config.ScopeSettingsRead, config.ScopeSettingsWrite) {
return
}
h.GetEmailProviders(w, r)
case path == "/test" && r.Method == http.MethodPost:
if !requireAnyScope(config.ScopeSettingsWrite, config.ScopeSettingsWrite) {
return
}
h.TestNotification(w, r)
default:
http.Error(w, "Not found", http.StatusNotFound)

View file

@ -137,45 +137,54 @@ func (r *Router) setupRoutes() {
r.mux.HandleFunc("/api/agents/docker/commands/", RequireAuth(r.config, RequireScope(config.ScopeDockerManage, r.dockerAgentHandlers.HandleCommandAck)))
r.mux.HandleFunc("/api/agents/docker/hosts/", RequireAdmin(r.config, RequireScope(config.ScopeDockerManage, r.dockerAgentHandlers.HandleDockerHostActions)))
r.mux.HandleFunc("/api/version", r.handleVersion)
r.mux.HandleFunc("/api/storage/", r.handleStorage)
r.mux.HandleFunc("/api/storage-charts", r.handleStorageCharts)
r.mux.HandleFunc("/api/charts", r.handleCharts)
r.mux.HandleFunc("/api/storage/", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleStorage)))
r.mux.HandleFunc("/api/storage-charts", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleStorageCharts)))
r.mux.HandleFunc("/api/charts", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleCharts)))
r.mux.HandleFunc("/api/diagnostics", RequireAuth(r.config, r.handleDiagnostics))
r.mux.HandleFunc("/api/diagnostics/temperature-proxy/register-nodes", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.handleDiagnosticsRegisterProxyNodes)))
r.mux.HandleFunc("/api/diagnostics/docker/prepare-token", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.handleDiagnosticsDockerPrepareToken)))
r.mux.HandleFunc("/api/install/pulse-sensor-proxy", r.handleDownloadPulseSensorProxy)
r.mux.HandleFunc("/api/install/install-sensor-proxy.sh", r.handleDownloadInstallerScript)
r.mux.HandleFunc("/api/install/install-docker.sh", r.handleDownloadDockerInstallerScript)
r.mux.HandleFunc("/api/config", r.handleConfig)
r.mux.HandleFunc("/api/backups", r.handleBackups)
r.mux.HandleFunc("/api/backups/", r.handleBackups)
r.mux.HandleFunc("/api/backups/unified", r.handleBackups)
r.mux.HandleFunc("/api/backups/pve", r.handleBackupsPVE)
r.mux.HandleFunc("/api/backups/pbs", r.handleBackupsPBS)
r.mux.HandleFunc("/api/snapshots", r.handleSnapshots)
r.mux.HandleFunc("/api/config", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleConfig)))
r.mux.HandleFunc("/api/backups", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleBackups)))
r.mux.HandleFunc("/api/backups/", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleBackups)))
r.mux.HandleFunc("/api/backups/unified", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleBackups)))
r.mux.HandleFunc("/api/backups/pve", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleBackupsPVE)))
r.mux.HandleFunc("/api/backups/pbs", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleBackupsPBS)))
r.mux.HandleFunc("/api/snapshots", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleSnapshots)))
// Guest metadata routes
r.mux.HandleFunc("/api/guests/metadata", guestMetadataHandler.HandleGetMetadata)
r.mux.HandleFunc("/api/guests/metadata/", func(w http.ResponseWriter, req *http.Request) {
r.mux.HandleFunc("/api/guests/metadata", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, guestMetadataHandler.HandleGetMetadata)))
r.mux.HandleFunc("/api/guests/metadata/", RequireAuth(r.config, func(w http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodGet:
if !ensureScope(w, req, config.ScopeMonitoringRead) {
return
}
guestMetadataHandler.HandleGetMetadata(w, req)
case http.MethodPut, http.MethodPost:
if !ensureScope(w, req, config.ScopeMonitoringWrite) {
return
}
guestMetadataHandler.HandleUpdateMetadata(w, req)
case http.MethodDelete:
if !ensureScope(w, req, config.ScopeMonitoringWrite) {
return
}
guestMetadataHandler.HandleDeleteMetadata(w, req)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
}))
// Update routes
r.mux.HandleFunc("/api/updates/check", updateHandlers.HandleCheckUpdates)
r.mux.HandleFunc("/api/updates/apply", updateHandlers.HandleApplyUpdate)
r.mux.HandleFunc("/api/updates/status", updateHandlers.HandleUpdateStatus)
r.mux.HandleFunc("/api/updates/plan", updateHandlers.HandleGetUpdatePlan)
r.mux.HandleFunc("/api/updates/history", updateHandlers.HandleListUpdateHistory)
r.mux.HandleFunc("/api/updates/history/entry", updateHandlers.HandleGetUpdateHistoryEntry)
r.mux.HandleFunc("/api/updates/check", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, updateHandlers.HandleCheckUpdates)))
r.mux.HandleFunc("/api/updates/apply", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, updateHandlers.HandleApplyUpdate)))
r.mux.HandleFunc("/api/updates/status", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, updateHandlers.HandleUpdateStatus)))
r.mux.HandleFunc("/api/updates/plan", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, updateHandlers.HandleGetUpdatePlan)))
r.mux.HandleFunc("/api/updates/history", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, updateHandlers.HandleListUpdateHistory)))
r.mux.HandleFunc("/api/updates/history/entry", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, updateHandlers.HandleGetUpdateHistoryEntry)))
// Config management routes
r.mux.HandleFunc("/api/config/nodes", func(w http.ResponseWriter, req *http.Request) {
@ -860,10 +869,10 @@ func (r *Router) setupRoutes() {
})
// Alert routes
r.mux.HandleFunc("/api/alerts/", r.alertHandlers.HandleAlerts)
r.mux.HandleFunc("/api/alerts/", RequireAuth(r.config, r.alertHandlers.HandleAlerts))
// Notification routes
r.mux.HandleFunc("/api/notifications/", r.notificationHandlers.HandleNotifications)
r.mux.HandleFunc("/api/notifications/", RequireAdmin(r.config, r.notificationHandlers.HandleNotifications))
// Settings routes
r.mux.HandleFunc("/api/settings", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, getSettings)))