Phase 4 enhancements: WebSocket auth, HTTPS UI, plugin hooks, build fixes

Security:
- Add token-based WebSocket authentication (closes critical security gap)
  - In-memory token store with 24h TTL (token_store.py)
  - POST /api/auth/token exchanges Basic Auth for WS token
  - GET /api/auth/status public endpoint for auth check
  - WebSocket validates token query param, rejects with close code 4401
  - Frontend LoginPrompt modal for credential entry
  - WebSocket manager handles full auth flow with auth_required state
  - No-op when AUTH_ENABLED=false (preserves existing behavior)

HTTPS:
- Wire HTTPS toggle in Settings UI (POST /api/system/ssl/toggle)
- Add certificate regeneration button
- Display SSL info (expiration, SANs, SHA256 fingerprint)

Plugins:
- Wire trigger_hook("pm3_command") in PM3 service
- Wire trigger_hook("update_check") in update manager

Build/Infrastructure:
- Enable NetworkManager in pi-gen AP setup stage
- Add HF booster board detection patch for Proxmark3
- Update LED PWM control patch
- Fix BLE adapter, UPS drivers, WiFi manager improvements
- Update HTTPS support stage script

Documentation:
- Update PROJECT_STATUS.md and IMPLEMENTATION_PRIORITIES.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
michael
2026-03-03 11:45:11 -08:00
parent 4f35df1781
commit 2ec89041ef
24 changed files with 1249 additions and 362 deletions

View File

@@ -1,11 +1,13 @@
"""Authentication module for Dangerous Pi API."""
import secrets
from fastapi import Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from .. import config
from .token_store import create_token
security = HTTPBasic()
router = APIRouter()
def verify_credentials(credentials: HTTPBasicCredentials = Depends(security)) -> str:
@@ -61,3 +63,26 @@ def get_optional_auth(credentials: HTTPBasicCredentials = Depends(security)) ->
return verify_credentials(credentials)
except HTTPException:
return None
@router.get("/status")
async def auth_status():
"""Check whether authentication is enabled.
This endpoint is always public so the frontend can determine
whether to prompt for credentials before connecting the WebSocket.
"""
return {"auth_enabled": config.AUTH_ENABLED}
@router.post("/token")
async def get_auth_token(username: str = Depends(verify_credentials)):
"""Exchange Basic Auth credentials for a WebSocket auth token.
The returned token should be passed as a query parameter when
connecting to the WebSocket endpoint: ws://host/ws/events?token=...
Tokens expire after 24 hours.
"""
token = create_token(username)
return {"token": token}

View File

@@ -384,6 +384,8 @@ async def get_power_restrictions():
class PiModelResponse(BaseModel):
"""Pi model information response."""
model_config = {"protected_namespaces": ()} # Allow model_* field names
model: str
model_short: str
total_cores: int
@@ -793,6 +795,93 @@ async def regenerate_ssl_certificate(request: SSLRegenerateRequest):
)
class SSLToggleRequest(BaseModel):
"""Request to enable or disable HTTPS."""
enabled: bool
@router.post("/ssl/toggle")
async def toggle_https(request: SSLToggleRequest):
"""Enable or disable HTTPS.
Updates the HTTPS_ENABLED setting in the .env file and runs
configure-nginx.sh to switch nginx between HTTP and HTTPS configs.
Reloads nginx to apply the change immediately.
Args:
enabled: True to enable HTTPS, False to disable
Returns:
Success status and current HTTPS state
"""
import subprocess
import os
env_file = os.getenv("ENV_FILE", "/opt/dangerous-pi/.env")
configure_script = "/opt/dangerous-pi/scripts/configure-nginx.sh"
# Update .env file
try:
lines = []
found = False
if os.path.exists(env_file):
with open(env_file, "r") as f:
lines = f.readlines()
new_lines = []
for line in lines:
if line.strip().startswith("HTTPS_ENABLED="):
new_lines.append(f"HTTPS_ENABLED={'true' if request.enabled else 'false'}\n")
found = True
else:
new_lines.append(line)
if not found:
new_lines.append(f"HTTPS_ENABLED={'true' if request.enabled else 'false'}\n")
with open(env_file, "w") as f:
f.writelines(new_lines)
except PermissionError:
raise HTTPException(
status_code=500,
detail="Cannot write to .env file - check permissions"
)
# Update runtime config
from .. import config as app_config
app_config.HTTPS_ENABLED = request.enabled
# Run configure-nginx.sh to switch nginx config
nginx_configured = False
if os.path.exists(configure_script):
try:
result = subprocess.run(
[configure_script],
capture_output=True,
text=True,
timeout=15,
env={**os.environ, "HTTPS_ENABLED": "true" if request.enabled else "false"}
)
if result.returncode == 0:
# Reload nginx
subprocess.run(
["systemctl", "reload", "nginx"],
capture_output=True,
timeout=10
)
nginx_configured = True
except Exception:
pass # Non-fatal, .env was still updated
return {
"success": True,
"enabled": request.enabled,
"nginx_configured": nginx_configured,
"message": f"HTTPS {'enabled' if request.enabled else 'disabled'}"
}
# -----------------------------------------------------------------------------
# Header Widgets API
# -----------------------------------------------------------------------------

View File

@@ -0,0 +1,55 @@
"""In-memory token store for WebSocket authentication.
Tokens are short-lived (24h) and stored in memory. Since this is a
single-device appliance with typically one user, persistence is not needed.
Tokens are cleaned up lazily on create/validate calls.
"""
import secrets
from datetime import datetime, timezone, timedelta
_tokens: dict[str, dict] = {}
TOKEN_TTL_HOURS = 24
def create_token(username: str) -> str:
"""Create a new auth token for the given username.
Args:
username: The authenticated username
Returns:
URL-safe token string
"""
token = secrets.token_urlsafe(32)
_tokens[token] = {
"username": username,
"expires_at": datetime.now(timezone.utc) + timedelta(hours=TOKEN_TTL_HOURS),
}
_cleanup_expired()
return token
def validate_token(token: str) -> str | None:
"""Validate a token and return the associated username.
Args:
token: The token to validate
Returns:
Username if valid, None if invalid or expired
"""
entry = _tokens.get(token)
if not entry:
return None
if datetime.now(timezone.utc) > entry["expires_at"]:
del _tokens[token]
return None
return entry["username"]
def _cleanup_expired() -> None:
"""Remove expired tokens."""
now = datetime.now(timezone.utc)
expired = [t for t, e in _tokens.items() if now > e["expires_at"]]
for t in expired:
del _tokens[t]