feat: optional auth + backup/restore reliability fixes
Some checks failed
Lint & Test / test (push) Failing after 29s

Auth is now optional: when `auth.api_keys` is empty, all endpoints are
open (no login screen, no Bearer tokens). Health endpoint reports
`auth_required` so the frontend knows which mode to use.

Backup/restore fixes:
- Auto-backup uses atomic writes (was `write_text`, risked corruption)
- Startup backup skipped if recent backup exists (<5 min cooldown),
  preventing rapid restarts from rotating out good backups
- Restore rejects all-empty backups to prevent accidental data wipes
- Store saves frozen after restore to prevent stale in-memory data
  from overwriting freshly-restored files before restart completes
- Missing stores during restore logged as warnings
- STORE_MAP completeness verified at startup against StorageConfig
This commit is contained in:
2026-03-23 14:50:25 +03:00
parent cd3137b0ec
commit 4975a74ff3
18 changed files with 189 additions and 67 deletions

View File

@@ -15,11 +15,19 @@ logger = get_logger(__name__)
security = HTTPBearer(auto_error=False)
def is_auth_enabled() -> bool:
"""Return True when at least one API key is configured."""
return bool(get_config().auth.api_keys)
def verify_api_key(
credentials: Annotated[HTTPAuthorizationCredentials | None, Security(security)]
) -> str:
"""Verify API key from Authorization header.
When no API keys are configured, authentication is disabled and all
requests are allowed through as "anonymous".
Args:
credentials: HTTP authorization credentials
@@ -31,6 +39,10 @@ def verify_api_key(
"""
config = get_config()
# No keys configured → auth disabled, allow all requests
if not config.auth.api_keys:
return "anonymous"
# Check if credentials are provided
if not credentials:
logger.warning("Request missing Authorization header")
@@ -43,14 +55,6 @@ def verify_api_key(
# Extract token
token = credentials.credentials
# Verify against configured API keys
if not config.auth.api_keys:
logger.error("No API keys configured - server misconfiguration")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Server authentication not configured properly",
)
# Find matching key and return its label using constant-time comparison
authenticated_as = None
for label, api_key in config.auth.api_keys.items():
@@ -80,10 +84,14 @@ AuthRequired = Annotated[str, Depends(verify_api_key)]
def verify_ws_token(token: str) -> bool:
"""Check a WebSocket query-param token against configured API keys.
Use this for WebSocket endpoints where FastAPI's Depends() isn't available.
When no API keys are configured, authentication is disabled and all
WebSocket connections are allowed.
"""
config = get_config()
if token and config.auth.api_keys:
# No keys configured → auth disabled, allow all connections
if not config.auth.api_keys:
return True
if token:
for _label, api_key in config.auth.api_keys.items():
if secrets.compare_digest(token, api_key):
return True

View File

@@ -27,6 +27,7 @@ from wled_controller.api.schemas.system import (
)
from wled_controller.core.backup.auto_backup import AutoBackupEngine
from wled_controller.config import get_config
from wled_controller.storage.base_store import freeze_saves
from wled_controller.utils import atomic_write_json, get_logger
logger = get_logger(__name__)
@@ -175,6 +176,7 @@ async def import_store(
return len(incoming)
count = await asyncio.to_thread(_write)
freeze_saves()
logger.info(f"Imported store '{store_key}' ({count} entries, merge={merge}). Scheduling restart...")
_schedule_restart()
return {
@@ -269,6 +271,27 @@ async def restore_config(
if not isinstance(stores[key], dict):
raise HTTPException(status_code=400, detail=f"Store '{key}' in backup is not a valid JSON object")
# Guard: reject backups where every store is empty (version key only, no entities).
# This prevents accidental data wipes from restoring a backup taken when the
# server had no data loaded.
total_entities = 0
for key in present_keys:
store_data = stores[key]
for field_key, field_val in store_data.items():
if field_key != "version" and isinstance(field_val, dict):
total_entities += len(field_val)
if total_entities == 0:
raise HTTPException(
status_code=400,
detail="Backup contains no entity data (all stores are empty). Aborting to prevent data loss.",
)
# Log missing stores as warnings
missing = known_keys - present_keys
if missing:
for store_key in sorted(missing):
logger.warning(f"Restore: backup is missing store '{store_key}' — existing data will be kept")
# Write store files atomically (in thread to avoid blocking event loop)
config = get_config()
@@ -284,10 +307,13 @@ async def restore_config(
written = await asyncio.to_thread(_write_stores)
# Freeze all store saves so the old process can't overwrite restored files
# with stale in-memory data before the restart completes.
freeze_saves()
logger.info(f"Restore complete: {written}/{len(STORE_MAP)} stores written. Scheduling restart...")
_schedule_restart()
missing = known_keys - present_keys
return RestoreResponse(
status="restored",
stores_written=written,

View File

@@ -14,7 +14,7 @@ import psutil
from fastapi import APIRouter, Depends, HTTPException, Query
from wled_controller import __version__
from wled_controller.api.auth import AuthRequired
from wled_controller.api.auth import AuthRequired, is_auth_enabled
from wled_controller.api.dependencies import (
get_audio_source_store,
get_audio_template_store,
@@ -107,6 +107,7 @@ async def health_check():
timestamp=datetime.now(timezone.utc),
version=__version__,
demo_mode=get_config().demo,
auth_required=is_auth_enabled(),
)

View File

@@ -13,6 +13,7 @@ class HealthResponse(BaseModel):
timestamp: datetime = Field(description="Current server time")
version: str = Field(description="Application version")
demo_mode: bool = Field(default=False, description="Whether demo mode is active")
auth_required: bool = Field(default=True, description="Whether API key authentication is required")
class VersionResponse(BaseModel):

View File

@@ -21,7 +21,7 @@ class ServerConfig(BaseSettings):
class AuthConfig(BaseSettings):
"""Authentication configuration."""
api_keys: dict[str, str] = {} # label: key mapping (required for security)
api_keys: dict[str, str] = {} # label: key mapping (empty = auth disabled)
class StorageConfig(BaseSettings):

View File

@@ -3,7 +3,7 @@
import asyncio
import json
import os
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
@@ -18,6 +18,11 @@ DEFAULT_SETTINGS = {
"max_backups": 10,
}
# Skip the immediate-on-start backup if a recent backup exists within this window.
# Prevents rapid restarts from flooding the backup directory and rotating out
# good backups.
_STARTUP_BACKUP_COOLDOWN = timedelta(minutes=5)
class AutoBackupEngine:
"""Creates periodic backups of all configuration stores."""
@@ -83,11 +88,28 @@ class AutoBackupEngine:
self._task.cancel()
self._task = None
def _most_recent_backup_age(self) -> timedelta | None:
"""Return the age of the newest backup file, or None if no backups exist."""
files = list(self._backup_dir.glob("*.json"))
if not files:
return None
newest = max(files, key=lambda p: p.stat().st_mtime)
mtime = datetime.fromtimestamp(newest.stat().st_mtime, tz=timezone.utc)
return datetime.now(timezone.utc) - mtime
async def _backup_loop(self) -> None:
try:
# Perform first backup immediately on start
await self._perform_backup()
self._prune_old_backups()
# Skip immediate backup if a recent one already exists.
# Prevents rapid restarts (crashes, restores) from flooding the
# backup directory and rotating out good backups.
age = self._most_recent_backup_age()
if age is None or age > _STARTUP_BACKUP_COOLDOWN:
await self._perform_backup()
self._prune_old_backups()
else:
logger.info(
f"Skipping startup backup — most recent is only {age.total_seconds():.0f}s old"
)
interval_secs = self._settings["interval_hours"] * 3600
while True:
@@ -133,8 +155,7 @@ class AutoBackupEngine:
filename = f"ledgrab-autobackup-{timestamp}.json"
file_path = self._backup_dir / filename
content = json.dumps(backup, indent=2, ensure_ascii=False)
file_path.write_text(content, encoding="utf-8")
atomic_write_json(file_path, backup)
self._last_backup_time = now
logger.info(f"Auto-backup created: {filename}")
@@ -156,7 +177,6 @@ class AutoBackupEngine:
def get_settings(self) -> dict:
next_backup = None
if self._settings["enabled"] and self._last_backup_time:
from datetime import timedelta
next_backup = (
self._last_backup_time + timedelta(hours=self._settings["interval_hours"])
).isoformat()

View File

@@ -103,23 +103,13 @@ async def lifespan(app: FastAPI):
print(f" Open http://localhost:{config.server.port} in your browser")
print(" =============================================\n")
# Validate authentication configuration
# Log authentication mode
if not config.auth.api_keys:
logger.error("=" * 70)
logger.error("CRITICAL: No API keys configured!")
logger.error("Authentication is REQUIRED for all API requests.")
logger.error("Please add API keys to your configuration:")
logger.error(" 1. Generate keys: openssl rand -hex 32")
logger.error(" 2. Add to config/default_config.yaml under auth.api_keys")
logger.error(" 3. Format: label: \"your-generated-key\"")
logger.error("=" * 70)
raise RuntimeError("No API keys configured - server cannot start without authentication")
# Log authentication status
logger.info(f"API Authentication: ENFORCED ({len(config.auth.api_keys)} clients configured)")
client_labels = ", ".join(config.auth.api_keys.keys())
logger.info(f"Authorized clients: {client_labels}")
logger.info("All API requests require valid Bearer token authentication")
logger.info("Authentication disabled (no API keys configured)")
else:
logger.info(f"Authentication enabled ({len(config.auth.api_keys)} API key(s) configured)")
client_labels = ", ".join(config.auth.api_keys.keys())
logger.info(f"Authorized clients: {client_labels}")
# Create MQTT service (shared broker connection)
mqtt_service = MQTTService(config.mqtt)
@@ -144,6 +134,21 @@ async def lifespan(app: FastAPI):
storage_config=config.storage,
)
# Verify STORE_MAP covers all StorageConfig file fields.
# Catches missed additions early (at startup) rather than silently
# excluding new stores from backups.
storage_attrs = {
attr for attr in config.storage.model_fields
if attr.endswith("_file")
}
mapped_attrs = set(STORE_MAP.values())
unmapped = storage_attrs - mapped_attrs
if unmapped:
logger.warning(
f"StorageConfig fields not in STORE_MAP (missing from backups): "
f"{sorted(unmapped)}"
)
# Initialize API dependencies
init_dependencies(
device_store, template_store, processor_manager,

View File

@@ -3,7 +3,7 @@
*/
// Layer 0: state
import { apiKey, setApiKey, refreshInterval } from './core/state.ts';
import { apiKey, setApiKey, authRequired, refreshInterval } from './core/state.ts';
import { Modal } from './core/modal.ts';
import { queryEl } from './core/dom-utils.ts';
@@ -180,6 +180,9 @@ import {
import { switchTab, initTabs, startAutoRefresh, handlePopState } from './features/tabs.ts';
import { navigateToCard } from './core/navigation.ts';
import { openCommandPalette, closeCommandPalette, initCommandPalette } from './core/command-palette.ts';
import {
applyStylePreset, applyBgEffect, renderAppearanceTab, initAppearance,
} from './features/appearance.ts';
import {
openSettingsModal, closeSettingsModal, switchSettingsTab,
downloadBackup, handleRestoreFileSelected,
@@ -548,6 +551,11 @@ Object.assign(window, {
setLogLevel,
saveExternalUrl,
getBaseOrigin,
// appearance
applyStylePreset,
applyBgEffect,
renderAppearanceTab,
});
// ─── Global keyboard shortcuts ───
@@ -626,6 +634,7 @@ document.addEventListener('DOMContentLoaded', async () => {
// Initialize visual effects
initCardGlare();
initBgAnim();
initAppearance();
initTabIndicator();
updateBgAnimTheme(document.documentElement.getAttribute('data-theme') !== 'light');
const accent = localStorage.getItem('accentColor') || '#4CAF50';
@@ -659,11 +668,15 @@ document.addEventListener('DOMContentLoaded', async () => {
if (addDeviceForm) addDeviceForm.addEventListener('submit', handleAddDevice);
// Always monitor server connection (even before login)
loadServerInfo();
await loadServerInfo();
startConnectionMonitor();
// Show modal if no API key is stored
if (!apiKey) {
// Expose auth state for inline scripts (after loadServerInfo sets it)
(window as any)._authRequired = authRequired;
if (typeof window.updateAuthUI === 'function') window.updateAuthUI();
// Show login modal only when auth is enabled and no API key is stored
if (authRequired && !apiKey) {
setTimeout(() => {
if (typeof window.showApiKeyModal === 'function') {
window.showApiKeyModal(null, true);

View File

@@ -2,7 +2,7 @@
* API utilities — base URL, auth headers, fetch wrapper, helpers.
*/
import { apiKey, setApiKey, refreshInterval, setRefreshInterval, displaysCache } from './state.ts';
import { apiKey, setApiKey, authRequired, setAuthRequired, refreshInterval, setRefreshInterval, displaysCache } from './state.ts';
import { t } from './i18n.ts';
import { showToast } from './ui.ts';
import { getEl, queryEl } from './dom-utils.ts';
@@ -137,6 +137,7 @@ export function isGameSenseDevice(type: string) {
}
export function handle401Error() {
if (!authRequired) return; // Auth disabled — ignore 401s
if (!apiKey) return; // Already handled or no session
localStorage.removeItem('wled_api_key');
setApiKey(null);
@@ -200,6 +201,11 @@ export async function loadServerInfo() {
window.dispatchEvent(new CustomEvent('server:reconnected'));
}
// Auth mode detection
const authNeeded = data.auth_required !== false;
setAuthRequired(authNeeded);
(window as any)._authRequired = authNeeded;
// Demo mode detection
if (data.demo_mode && !demoMode) {
demoMode = true;

View File

@@ -9,7 +9,7 @@
* server:device_health_changed — device online/offline status change
*/
import { apiKey } from './state.ts';
import { apiKey, authRequired } from './state.ts';
let _ws: WebSocket | null = null;
let _reconnectTimer: ReturnType<typeof setTimeout> | null = null;
@@ -19,10 +19,10 @@ const _RECONNECT_MAX = 30000;
export function startEventsWS() {
stopEventsWS();
if (!apiKey) return;
if (authRequired && !apiKey) return;
const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${wsProto}//${location.host}/api/v1/events/ws?token=${encodeURIComponent(apiKey)}`;
const url = `${wsProto}//${location.host}/api/v1/events/ws?token=${encodeURIComponent(apiKey || '')}`;
try {
_ws = new WebSocket(url);

View File

@@ -18,6 +18,9 @@ import type {
export let apiKey: string | null = null;
export function setApiKey(v: string | null) { apiKey = v; }
export let authRequired = true;
export function setAuthRequired(v: boolean) { authRequired = v; }
export let refreshInterval: ReturnType<typeof setInterval> | null = null;
export function setRefreshInterval(v: ReturnType<typeof setInterval> | null) { refreshInterval = v; }

View File

@@ -18,6 +18,7 @@ interface Window {
// ─── Core / state ───
setApiKey: (key: string | null) => void;
_authRequired: boolean | undefined;
// ─── Visual effects (called from inline <script>) ───
_updateBgAnimAccent: (accent: string) => void;
@@ -372,6 +373,11 @@ interface Window {
saveExternalUrl: (...args: any[]) => any;
getBaseOrigin: (...args: any[]) => any;
// ─── Appearance ───
applyStylePreset: (id: string) => void;
applyBgEffect: (id: string) => void;
renderAppearanceTab: () => void;
// ─── Overlay spinner internals ───
overlaySpinnerTimer: ReturnType<typeof setInterval> | null;
_overlayEscHandler: ((e: KeyboardEvent) => void) | null;

View File

@@ -11,6 +11,18 @@ from wled_controller.utils import atomic_write_json, get_logger
T = TypeVar("T")
logger = get_logger(__name__)
# When True, all store saves are suppressed. Set by the restore flow
# to prevent the old server process from overwriting freshly-restored
# files with stale in-memory data before the restart completes.
_saves_frozen = False
def freeze_saves() -> None:
"""Block all store saves until the process exits (used after restore)."""
global _saves_frozen
_saves_frozen = True
logger.info("Store saves frozen — awaiting server restart")
class EntityNotFoundError(ValueError):
"""Raised when an entity is not found in the store."""
@@ -94,6 +106,9 @@ class BaseJsonStore(Generic[T]):
stores). Acceptable for user-initiated CRUD; not suitable for hot loops.
Callers must hold ``self._lock``.
"""
if _saves_frozen:
logger.warning(f"Save blocked (frozen after restore): {self._json_key}")
return
try:
data = {
"version": self._version,

View File

@@ -341,14 +341,27 @@
const savedAccent = localStorage.getItem('accentColor');
if (savedAccent) applyAccentColor(savedAccent, true);
// Early-apply saved background effect class (before bundle loads)
const savedBgEffect = localStorage.getItem('bgEffect');
if (savedBgEffect && savedBgEffect !== 'none') {
const effectClasses = { grid: 'bg-effect-grid', mesh: 'bg-effect-mesh', scanlines: 'bg-effect-scanlines', particles: 'bg-effect-particles' };
if (effectClasses[savedBgEffect]) document.documentElement.classList.add(effectClasses[savedBgEffect]);
}
// Initialize auth state
function updateAuthUI() {
const apiKey = localStorage.getItem('wled_api_key');
const loginBtn = document.getElementById('login-btn');
const logoutBtn = document.getElementById('logout-btn');
const tabBar = document.querySelector('.tab-bar');
const authDisabled = window._authRequired === false;
if (apiKey) {
if (authDisabled) {
// Auth disabled — hide login/logout, always show tabs
loginBtn.style.display = 'none';
logoutBtn.style.display = 'none';
if (tabBar) tabBar.style.display = '';
} else if (apiKey) {
loginBtn.style.display = 'none';
logoutBtn.style.display = 'inline-block';
if (tabBar) tabBar.style.display = '';