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

@@ -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>
);
}

View File

@@ -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

View File

@@ -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 />}
</>
);
}

View File

@@ -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>
)}