feat: optional auth + backup/restore reliability fixes
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
+18 -10
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
@@ -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,
@@ -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(),
)
@@ -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):