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]
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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...")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
94
app/frontend/app/components/LoginPrompt.tsx
Normal file
94
app/frontend/app/components/LoginPrompt.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { getWebSocketManager } from "../hooks/useWebSocket";
|
||||
|
||||
export function LoginPrompt() {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await getWebSocketManager().fetchToken(username, password);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Authentication failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 9999,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(0, 0, 0, 0.75)",
|
||||
backdropFilter: "blur(4px)",
|
||||
}}
|
||||
>
|
||||
<div className="card" style={{ maxWidth: "360px", width: "100%", margin: "1rem" }}>
|
||||
<div className="card-title" style={{ textAlign: "center", marginBottom: "1.5rem" }}>
|
||||
Dangerous Pi
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
<div>
|
||||
<label htmlFor="login-user" style={{ display: "block", marginBottom: "0.25rem", fontSize: "0.875rem" }}>
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="login-user"
|
||||
className="input"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
required
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="login-pass" style={{ display: "block", marginBottom: "0.25rem", fontSize: "0.875rem" }}>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="login-pass"
|
||||
className="input"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{ color: "var(--color-error, #ff4444)", fontSize: "0.875rem" }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={loading}
|
||||
style={{ width: "100%", marginTop: "0.5rem" }}
|
||||
>
|
||||
{loading ? "Authenticating..." : "Log In"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,11 +6,16 @@
|
||||
* - Exponential backoff reconnection (1s -> 2s -> 4s -> ... -> 30s max)
|
||||
* - Component-level event subscriptions
|
||||
* - Connection state management
|
||||
* - Token-based authentication when AUTH_ENABLED=true
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
// Connection states
|
||||
export type ConnectionState = "connecting" | "connected" | "disconnected";
|
||||
export type ConnectionState =
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "disconnected"
|
||||
| "auth_required";
|
||||
|
||||
// Event message format from backend
|
||||
interface WebSocketMessage {
|
||||
@@ -23,6 +28,9 @@ interface WebSocketMessage {
|
||||
// Event handler type
|
||||
type EventHandler = (data: Record<string, unknown>) => void;
|
||||
|
||||
// Custom close code for auth failure
|
||||
const WS_CLOSE_AUTH_REQUIRED = 4401;
|
||||
|
||||
// Singleton WebSocket manager
|
||||
class WebSocketManager {
|
||||
private static instance: WebSocketManager | null = null;
|
||||
@@ -32,6 +40,8 @@ class WebSocketManager {
|
||||
private reconnectAttempts = 0;
|
||||
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private state: ConnectionState = "disconnected";
|
||||
private token: string | null = null;
|
||||
private authChecked = false;
|
||||
|
||||
// Backoff configuration
|
||||
private readonly INITIAL_DELAY = 1000; // 1 second
|
||||
@@ -56,10 +66,23 @@ class WebSocketManager {
|
||||
const host = window.location.hostname;
|
||||
const port = window.location.port || (protocol === "wss:" ? "443" : "80");
|
||||
|
||||
return `${protocol}//${host}:${port}/ws/events`;
|
||||
let url = `${protocol}//${host}:${port}/ws/events`;
|
||||
if (this.token) {
|
||||
url += `?token=${encodeURIComponent(this.token)}`;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
private getApiBaseUrl(): string {
|
||||
if (typeof window === "undefined") {
|
||||
return "http://localhost:8000";
|
||||
}
|
||||
const { protocol, hostname, port } = window.location;
|
||||
const p = port || (protocol === "https:" ? "443" : "80");
|
||||
return `${protocol}//${hostname}:${p}`;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (
|
||||
this.ws?.readyState === WebSocket.OPEN ||
|
||||
this.ws?.readyState === WebSocket.CONNECTING
|
||||
@@ -67,6 +90,16 @@ class WebSocketManager {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check auth status on first connect attempt
|
||||
if (!this.authChecked) {
|
||||
this.authChecked = true;
|
||||
const authRequired = await this.checkAuthRequired();
|
||||
if (authRequired && !this.token) {
|
||||
this.setState("auth_required");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.setState("connecting");
|
||||
const url = this.getWebSocketUrl();
|
||||
|
||||
@@ -74,7 +107,7 @@ class WebSocketManager {
|
||||
this.ws = new WebSocket(url);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
console.log("[WS] Connected to", url);
|
||||
console.log("[WS] Connected to", url.replace(/token=[^&]+/, "token=***"));
|
||||
this.reconnectAttempts = 0;
|
||||
this.setState("connected");
|
||||
};
|
||||
@@ -93,6 +126,15 @@ class WebSocketManager {
|
||||
this.ws.onclose = (event) => {
|
||||
console.log(`[WS] Disconnected (code: ${event.code})`);
|
||||
this.ws = null;
|
||||
|
||||
if (event.code === WS_CLOSE_AUTH_REQUIRED) {
|
||||
// Auth required - clear stale token and prompt for login
|
||||
this.token = null;
|
||||
this.setState("auth_required");
|
||||
// Do NOT auto-reconnect - wait for user to authenticate
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState("disconnected");
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
@@ -106,6 +148,49 @@ class WebSocketManager {
|
||||
}
|
||||
}
|
||||
|
||||
private async checkAuthRequired(): Promise<boolean> {
|
||||
try {
|
||||
const resp = await fetch(`${this.getApiBaseUrl()}/api/auth/status`);
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
return data.auth_enabled === true;
|
||||
}
|
||||
} catch {
|
||||
// If we can't reach the server, proceed without auth
|
||||
// (the WS connection will fail and retry anyway)
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch an auth token using Basic Auth credentials.
|
||||
* On success, stores the token and initiates the WebSocket connection.
|
||||
*
|
||||
* @throws Error with message if authentication fails
|
||||
*/
|
||||
async fetchToken(username: string, password: string): Promise<void> {
|
||||
const resp = await fetch(`${this.getApiBaseUrl()}/api/auth/token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Basic " + btoa(`${username}:${password}`),
|
||||
},
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
if (resp.status === 401) {
|
||||
throw new Error("Invalid username or password");
|
||||
}
|
||||
throw new Error(`Authentication failed (${resp.status})`);
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
this.token = data.token;
|
||||
this.authChecked = true;
|
||||
|
||||
// Now connect with the token
|
||||
await this.connect();
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
// Only reconnect if there are still subscribers
|
||||
if (this.getTotalSubscriberCount() === 0) {
|
||||
@@ -218,6 +303,11 @@ class WebSocketManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use by LoginPrompt
|
||||
export function getWebSocketManager(): WebSocketManager {
|
||||
return WebSocketManager.getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to subscribe to WebSocket events.
|
||||
*
|
||||
@@ -256,7 +346,7 @@ export function useWebSocketEvent(
|
||||
/**
|
||||
* Hook to get current WebSocket connection state.
|
||||
*
|
||||
* @returns Current connection state: "connecting" | "connected" | "disconnected"
|
||||
* @returns Current connection state: "connecting" | "connected" | "disconnected" | "auth_required"
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
|
||||
@@ -11,6 +11,8 @@ import { useState, useEffect } from "react";
|
||||
import styles from "./styles.css?url";
|
||||
import { PowerWidget } from "./components/PowerWidget";
|
||||
import { HeaderWidgets } from "./components/HeaderWidgets";
|
||||
import { LoginPrompt } from "./components/LoginPrompt";
|
||||
import { useWebSocketState } from "./hooks/useWebSocket";
|
||||
|
||||
export const links: LinksFunction = () => [
|
||||
{ rel: "stylesheet", href: styles },
|
||||
@@ -38,6 +40,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
export default function App() {
|
||||
const location = useLocation();
|
||||
const wsState = useWebSocketState();
|
||||
const [theme, setTheme] = useState<"auto" | "dark" | "light">("dark");
|
||||
|
||||
// Initialize theme (default to dark, honor system preference if available)
|
||||
@@ -153,6 +156,8 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{wsState === "auth_required" && <LoginPrompt />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,12 +8,13 @@ const QRScanner = lazy(() => import("~/components/QRScanner"));
|
||||
|
||||
export async function loader() {
|
||||
try {
|
||||
const [configRes, wifiStatusRes, savedNetworksRes, cpuCoresRes, pluginsRes] = await Promise.all([
|
||||
const [configRes, wifiStatusRes, savedNetworksRes, cpuCoresRes, pluginsRes, sslInfoRes] = await Promise.all([
|
||||
fetch("http://localhost:8000/api/system/config"),
|
||||
fetch("http://localhost:8000/api/wifi/status"),
|
||||
fetch("http://localhost:8000/api/wifi/saved"),
|
||||
fetch("http://localhost:8000/api/system/cpu/cores"),
|
||||
fetch("http://localhost:8000/api/plugins/list"),
|
||||
fetch("http://localhost:8000/api/system/ssl/info"),
|
||||
]);
|
||||
|
||||
const config = await configRes.json();
|
||||
@@ -21,13 +22,15 @@ export async function loader() {
|
||||
const savedNetworksData = await savedNetworksRes.json();
|
||||
const cpuCores = await cpuCoresRes.json();
|
||||
const plugins = pluginsRes.ok ? await pluginsRes.json() : [];
|
||||
const sslInfo = sslInfoRes.ok ? await sslInfoRes.json() : null;
|
||||
|
||||
return json({
|
||||
config,
|
||||
wifiStatus,
|
||||
savedNetworks: savedNetworksData.networks || [],
|
||||
cpuCores,
|
||||
plugins
|
||||
plugins,
|
||||
sslInfo
|
||||
});
|
||||
} catch (error) {
|
||||
return json({
|
||||
@@ -57,6 +60,7 @@ export async function loader() {
|
||||
pi_model: null
|
||||
},
|
||||
plugins: [],
|
||||
sslInfo: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -183,6 +187,43 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "toggle_https") {
|
||||
const enabled = formData.get("enabled") === "true";
|
||||
try {
|
||||
const response = await fetch("http://localhost:8000/api/system/ssl/toggle", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
return json({ success: true, message: result.message });
|
||||
} else {
|
||||
return json({ success: false, error: "Failed to toggle HTTPS" });
|
||||
}
|
||||
} catch (error) {
|
||||
return json({ success: false, error: "Failed to toggle HTTPS" });
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "regenerate_ssl") {
|
||||
try {
|
||||
const response = await fetch("http://localhost:8000/api/system/ssl/regenerate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reload_nginx: true }),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
return json({ success: true, message: "SSL certificate regenerated" });
|
||||
} else {
|
||||
return json({ success: false, error: "Failed to regenerate certificate" });
|
||||
}
|
||||
} catch (error) {
|
||||
return json({ success: false, error: "Failed to regenerate certificate" });
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "discover_plugins") {
|
||||
try {
|
||||
const response = await fetch("http://localhost:8000/api/plugins/discover");
|
||||
@@ -233,7 +274,7 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
}
|
||||
|
||||
export default function Settings() {
|
||||
const { config, wifiStatus, savedNetworks, cpuCores, plugins } = useLoaderData<typeof loader>();
|
||||
const { config, wifiStatus, savedNetworks, cpuCores, plugins, sslInfo } = useLoaderData<typeof loader>();
|
||||
const actionData = useActionData<typeof action>();
|
||||
const navigation = useNavigation();
|
||||
const [activeSection, setActiveSection] = useState<"general" | "wifi" | "plugins" | "advanced" | "system">("general");
|
||||
@@ -384,13 +425,34 @@ export default function Settings() {
|
||||
<span className={`badge ${config.https_enabled ? "badge-success" : "badge-info"}`}>
|
||||
{config.https_enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
<button className="btn btn-secondary" disabled>
|
||||
Configure
|
||||
</button>
|
||||
<Form method="post" style={{ display: "inline" }}>
|
||||
<input type="hidden" name="_action" value="toggle_https" />
|
||||
<input type="hidden" name="enabled" value={config.https_enabled ? "false" : "true"} />
|
||||
<button className={`btn ${config.https_enabled ? "btn-secondary" : "btn-primary"}`} type="submit" disabled={isSubmitting}>
|
||||
{config.https_enabled ? "Disable" : "Enable"}
|
||||
</button>
|
||||
</Form>
|
||||
<Form method="post" style={{ display: "inline" }}>
|
||||
<input type="hidden" name="_action" value="regenerate_ssl" />
|
||||
<button className="btn btn-secondary" type="submit" disabled={isSubmitting}>
|
||||
Regenerate Cert
|
||||
</button>
|
||||
</Form>
|
||||
</div>
|
||||
<p className="text-muted" style={{ fontSize: "0.75rem", marginTop: "var(--space-2)" }}>
|
||||
Use self-signed certificate for secure connections
|
||||
</p>
|
||||
{sslInfo?.certificate_exists && (
|
||||
<div style={{ fontSize: "0.75rem", marginTop: "var(--space-2)", display: "flex", flexDirection: "column", gap: "2px" }}>
|
||||
<span className="text-muted">Expires: {sslInfo.not_after || "Unknown"}</span>
|
||||
{sslInfo.san && <span className="text-muted">SANs: {sslInfo.san.join(", ")}</span>}
|
||||
{sslInfo.fingerprint_sha256 && (
|
||||
<span className="text-muted" style={{ fontFamily: "var(--font-mono)", fontSize: "0.65rem", wordBreak: "break-all" }}>
|
||||
SHA256: {sslInfo.fingerprint_sha256}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user