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]

View File

@@ -259,9 +259,13 @@ class BlueZGATTAdapter:
"""Handle unsubscription from characteristic notifications."""
logger.info("Client unsubscribed from %s", characteristic.uuid)
async def start(self) -> bool:
async def start(self, retries: int = 3, retry_delay: float = 2.0) -> bool:
"""Start the BLE GATT server.
Args:
retries: Number of startup attempts (for boot timing issues)
retry_delay: Delay between retries in seconds
Returns:
True if started successfully, False otherwise
"""
@@ -269,39 +273,57 @@ class BlueZGATTAdapter:
logger.warning("BLE server already running")
return True
try:
self._loop = asyncio.get_running_loop()
last_error = None
for attempt in range(retries):
try:
self._loop = asyncio.get_running_loop()
# Create bless server
self.server = BlessServer(name=self.device_name, loop=self._loop)
# Create bless server
self.server = BlessServer(name=self.device_name, loop=self._loop)
# Set up callbacks (bless 0.3.0+ API)
self.server.read_request_func = self._on_read
self.server.write_request_func = self._on_write
# Set up callbacks (bless 0.3.0+ API)
self.server.read_request_func = self._on_read
self.server.write_request_func = self._on_write
# Build and add GATT structure
gatt = self._build_gatt_dict()
await self.server.add_gatt(gatt)
# Build and add GATT structure
gatt = self._build_gatt_dict()
await self.server.add_gatt(gatt)
# Start the server (begins advertising)
await self.server.start()
# Start the server (begins advertising)
await self.server.start()
# Start GATT server handlers
await self.gatt_server.start()
# Start GATT server handlers
await self.gatt_server.start()
# Register notification callbacks
self._setup_notification_callbacks()
# Register notification callbacks
self._setup_notification_callbacks()
self._is_running = True
logger.info("BLE GATT server started, advertising as '%s'", self.device_name)
return True
self._is_running = True
logger.info("BLE GATT server started, advertising as '%s'", self.device_name)
return True
except Exception as e:
logger.error("Failed to start BLE server: %s", e)
import traceback
traceback.print_exc()
self._is_running = False
return False
except Exception as e:
last_error = e
if attempt < retries - 1:
logger.warning(
"BLE server start attempt %d/%d failed: %s, retrying in %.1fs...",
attempt + 1, retries, e, retry_delay
)
# Clean up failed server before retry
if self.server:
try:
await self.server.stop()
except Exception:
pass
self.server = None
await asyncio.sleep(retry_delay)
else:
logger.error("Failed to start BLE server after %d attempts: %s", retries, e)
import traceback
traceback.print_exc()
self._is_running = False
return False
def _setup_notification_callbacks(self):
"""Set up notification callbacks for characteristics that support notify."""

View File

@@ -11,7 +11,7 @@ from fastapi.staticfiles import StaticFiles
from . import config
from .models.database import init_db
from .api import health, pm3, system, wifi, updates, plugins
from .api.auth import verify_credentials
from .api.auth import verify_credentials, router as auth_router
from .websocket import router as ws_router
from .websocket import notifications as ws_notifications
from .managers.update_manager import get_update_manager
@@ -201,6 +201,7 @@ app.include_router(system.router, prefix="/api/system", tags=["system"], depende
app.include_router(wifi.router, prefix="/api/wifi", tags=["wifi"], dependencies=auth_dependency)
app.include_router(updates.router, prefix="/api/updates", tags=["updates"], dependencies=auth_dependency)
app.include_router(plugins.router, prefix="/api/plugins", tags=["plugins"], dependencies=auth_dependency)
app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
app.include_router(ws_router, prefix="/ws", tags=["websocket"])
# Serve frontend static files if build directory exists

View File

@@ -111,6 +111,13 @@ class UpdateManager:
"message": "No releases found"
}
# Trigger plugin hooks (non-critical)
try:
from .plugin_manager import get_plugin_manager
await get_plugin_manager().trigger_hook("update_check")
except Exception:
pass
# Compare versions
if self._is_newer_version(release.version, self._current_version):
self._latest_release = release

View File

@@ -16,7 +16,9 @@ from .i2c_driver import I2CFuelGaugeDriver
async def auto_detect_driver(
pisugar_host: str = "127.0.0.1",
pisugar_port: int = 8423,
prefer_native_i2c: bool = True
prefer_native_i2c: bool = True,
i2c_retries: int = 5,
i2c_retry_delay: float = 1.0
) -> Tuple[Optional[UPSDriver], str]:
"""Auto-detect available UPS hardware and return appropriate driver.
@@ -29,6 +31,8 @@ async def auto_detect_driver(
pisugar_host: PiSugar server host for daemon detection (legacy)
pisugar_port: PiSugar server port (legacy)
prefer_native_i2c: If True, prefer native I2C driver over daemon
i2c_retries: Number of I2C detection retries (for boot timing)
i2c_retry_delay: Delay between I2C retries in seconds
Returns:
Tuple of (driver_instance or None, detection_message)
@@ -37,13 +41,16 @@ async def auto_detect_driver(
# Try native PiSugar I2C first (no daemon overhead, minimal CPU)
if prefer_native_i2c:
print("UPS auto-detect: Trying PiSugar I2C (native)...")
detected, driver = await PiSugarI2CDriver.detect()
print(f"UPS auto-detect: Trying PiSugar I2C (native, {i2c_retries} retries)...")
detected, driver = await PiSugarI2CDriver.detect(
retries=i2c_retries,
retry_delay=i2c_retry_delay
)
if detected and driver:
model = driver.get_model_name()
print(f"UPS auto-detect: SUCCESS - PiSugar I2C: {model}")
return (driver, f"Detected PiSugar UPS via I2C: {model}")
print("UPS auto-detect: PiSugar I2C not detected")
print("UPS auto-detect: PiSugar I2C not detected after all retries")
# Try PiSugar daemon (legacy fallback)
print("UPS auto-detect: Trying PiSugar daemon...")

View File

@@ -103,7 +103,7 @@ class UPSManager:
print(f"Unknown UPS type '{ups_type}', will auto-detect")
return None
async def initialize(self, startup_delay: float = 1.0) -> bool:
async def initialize(self, startup_delay: float = 2.0) -> bool:
"""Initialize connection to UPS hardware.
Args:
@@ -124,15 +124,19 @@ class UPSManager:
self._status.error_message = "UPS disabled"
return False
# Small delay to ensure I2C bus is ready after boot
# Delay to ensure I2C bus is ready after boot
# PiSugar and other I2C devices may not be ready immediately
if startup_delay > 0:
print(f"UPS: Waiting {startup_delay}s for I2C bus to settle...")
await asyncio.sleep(startup_delay)
# Run auto-detection
# Run auto-detection with extended retries for boot scenarios
print("Auto-detecting UPS hardware...")
driver, message = await auto_detect_driver(
pisugar_host=config.UPS_PISUGAR_HOST,
pisugar_port=config.UPS_PISUGAR_PORT
pisugar_port=config.UPS_PISUGAR_PORT,
i2c_retries=5,
i2c_retry_delay=1.0
)
if driver:

View File

@@ -714,16 +714,27 @@ class WiFiManager:
if "successfully activated" in result.lower():
logger.warning(f"[WiFi Connect] SUCCESS - connected to {ssid}")
# Verify we have an IP address
# Verify we have a non-AP IP address
await asyncio.sleep(2)
ip_result = await self._run_command(f"ip addr show {interface}")
logger.warning(f"[WiFi Connect] IP result: {ip_result}")
if "inet " in ip_result and "192.168.4." not in ip_result:
# Extract all IP addresses and check for non-AP IPs
# AP uses 192.168.4.x subnet
ip_matches = re.findall(r'inet (\d+\.\d+\.\d+\.\d+)', ip_result)
client_ips = [ip for ip in ip_matches if not ip.startswith("192.168.4.")]
if client_ips:
logger.warning(f"[WiFi Connect] Found client IP(s): {client_ips}")
# Remove the AP static IP since we're in client mode now
await self._run_command(f"ip addr del 192.168.4.1/24 dev {interface}", check=False)
# Save client mode for boot persistence
self._current_mode = WiFiMode.CLIENT
self._save_mode(WiFiMode.CLIENT)
logger.info(f"WiFi client mode saved for boot persistence")
return True
else:
logger.warning(f"[WiFi Connect] No client IP found, only AP IPs: {ip_matches}")
# Connection failed
logger.warning(f"[WiFi Connect] FAILED - {result}")

View File

@@ -139,15 +139,22 @@ class PM3Service:
)
)
# 3. Execute command
# 3. Trigger plugin hooks (non-critical)
try:
from ..managers.plugin_manager import get_plugin_manager
await get_plugin_manager().trigger_hook("pm3_command", command)
except Exception:
pass
# 4. Execute command
try:
result = await worker.execute_command(command)
# 4. Update session activity
# 5. Update session activity
if session_id:
self.session_manager.update_activity(session_id, device_id)
# 5. Return result
# 6. Return result
if result.success:
return PM3ServiceResult(
success=True,

View File

@@ -1,6 +1,8 @@
"""WebSocket endpoint routes."""
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from .manager import ws_manager
from ..api.token_store import validate_token
from .. import config
router = APIRouter()
@@ -9,6 +11,11 @@ router = APIRouter()
async def websocket_endpoint(websocket: WebSocket):
"""WebSocket endpoint for real-time event streaming.
When AUTH_ENABLED=true, a valid token must be provided as a query
parameter: ws://host/ws/events?token=<token>
Tokens are obtained via POST /api/auth/token with Basic Auth.
Unauthorized connections are closed with code 4401.
Events include:
- connected: Initial connection confirmation
- system_stats: CPU, memory, temperature updates (every 5s)
@@ -22,6 +29,15 @@ async def websocket_endpoint(websocket: WebSocket):
- update_available: New version available
- update_downloading: Download progress
"""
# Validate auth token when authentication is enabled
if config.AUTH_ENABLED:
token = websocket.query_params.get("token")
if not token or not validate_token(token):
# Must accept before sending close code so client receives it
await websocket.accept()
await websocket.close(code=4401, reason="Authentication required")
return
await ws_manager.connect(websocket)
try:
while True: