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:
@@ -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}
|
||||
|
||||
@@ -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
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
55
app/backend/api/token_store.py
Normal file
55
app/backend/api/token_store.py
Normal 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]
|
||||
Reference in New Issue
Block a user