From 65e1073ea03895096c24f8ee5958a861485aadd2 Mon Sep 17 00:00:00 2001 From: Stanislaw Grams Date: Fri, 13 Feb 2026 08:18:49 +0100 Subject: [PATCH] [feat](trx-frontend-http): complete HTTP authentication implementation (phases 4-5) Phase 4: Frontend login gate and role-based UI - Add auth-gate HTML overlay with passphrase form - Implement checkAuthStatus, authLogin, authLogout functions - Auth startup sequence checks /auth/session before connecting - Apply role-based restrictions: hide PTT/TX controls for rx role - Handle 401/403 errors in postPath, return to login screen - Add logout button in About tab with auth role display - Passphrase form shows generic error messages (no info leakage) Phase 5: Documentation - Update trx-client.toml.example with [frontends.http.auth] section - All config fields with inline documentation and examples - security notes about cookie settings - Update README.md with HTTP Frontend Authentication section - Role model explanation (rx vs control) - Configuration example - Security considerations for local, LAN, and remote deployments - Architecture overview UI Features: - Login gate blocks main UI until authenticated - Role badge shows authenticated status in About tab - Error messages clear after 5 seconds - Logout confirmation prevents accidental logouts - Smooth transition from auth gate to main UI All code compiles successfully. HTTP frontend build verified. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Stanislaw Grams --- README.md | 41 +++++ .../trx-frontend-http/assets/web/app.js | 168 +++++++++++++++++- .../trx-frontend-http/assets/web/index.html | 18 ++ trx-client.toml.example | 30 ++++ 4 files changed, 250 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index f96971f..38b7002 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,47 @@ Configuration reference: see `CONFIGURATION.md` for all server/client options an - JSON TCP control frontend (`trx-frontend-http-json`) - rigctl-compatible TCP frontend (`trx-frontend-rigctl`, listens on 127.0.0.1:4532) +## HTTP Frontend Authentication + +The HTTP frontend supports optional passphrase-based authentication with two roles: + +- **rx**: Read-only access to status, events, decode history, and audio streams +- **control**: Full access including transmit control (TX/PTT) and power toggling + +Authentication is disabled by default for backward compatibility. When enabled, users must log in via a passphrase before accessing the web UI. Sessions are managed server-side with configurable time-to-live and cookie security settings. + +### Configuration + +Enable authentication in `trx-client.toml`: + +```toml +[frontends.http.auth] +enabled = true +rx_passphrase = "read-only-secret" +control_passphrase = "full-control-secret" +session_ttl_min = 480 # 8 hours +cookie_secure = false # Set to true for HTTPS +cookie_same_site = "Lax" +``` + +### Security Considerations + +- **Local/LAN use**: Default settings are safe for 127.0.0.1 or trusted local networks. +- **Remote access**: For internet-exposed deployments: + - Deploy behind HTTPS (reverse proxy or TLS termination) + - Set `cookie_secure = true` + - Use strong passphrases (random, 16+ chars) + - Consider firewall rules and network segmentation +- **Passphrase storage**: Passphrases are stored in plaintext in the config file. Protect the config file with appropriate file permissions. +- **No rate limiting**: The current implementation does not include login rate limiting. For high-security scenarios, deploy behind a reverse proxy with rate limiting. + +### Architecture + +- **Sessions**: In-memory, expire after configured TTL (default 8 hours) +- **Cookies**: HttpOnly, configurable Secure and SameSite attributes +- **Route protection**: Middleware validates session on protected endpoints; public routes (static assets, login) are always accessible +- **TX/PTT gating**: Control-only endpoints return 404 to rx-authenticated users (when `tx_access_control_enabled=true`) + ## Audio streaming Bidirectional Opus audio streaming between server, client, and browser. diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js index 720e045..8b6abd5 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js @@ -10,6 +10,102 @@ function loadSetting(key, fallback) { } catch(e) { return fallback; } } +// --- Authentication --- +let authRole = null; // null (not authenticated), "rx" (read-only), or "control" (full access) + +async function checkAuthStatus() { + try { + const resp = await fetch("/auth/session"); + if (!resp.ok) return { authenticated: false }; + const data = await resp.json(); + return data; + } catch (e) { + console.error("Auth check failed:", e); + return { authenticated: false }; + } +} + +async function authLogin(passphrase) { + try { + const resp = await fetch("/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ passphrase }), + }); + if (!resp.ok) { + const text = await resp.text(); + throw new Error(text || "Login failed"); + } + const data = await resp.json(); + return data; + } catch (e) { + throw e; + } +} + +async function authLogout() { + try { + const resp = await fetch("/auth/logout", { method: "POST" }); + if (!resp.ok) throw new Error("Logout failed"); + authRole = null; + location.reload(); + } catch (e) { + console.error("Logout failed:", e); + } +} + +function showAuthGate() { + document.getElementById("loading").style.display = "none"; + document.getElementById("content").style.display = "none"; + document.getElementById("auth-gate").style.display = "block"; +} + +function hideAuthGate() { + document.getElementById("auth-gate").style.display = "none"; + document.getElementById("loading").style.display = "block"; +} + +function showAuthError(msg) { + const el = document.getElementById("auth-error"); + el.textContent = msg; + el.style.display = "block"; + setTimeout(() => { + el.style.display = "none"; + }, 5000); +} + +function updateAuthUI() { + const btn = document.getElementById("logout-btn"); + const badge = document.getElementById("auth-badge"); + const badgeRole = document.getElementById("auth-role-badge"); + + if (authRole) { + btn.style.display = "block"; + badge.style.display = "block"; + badgeRole.textContent = authRole === "control" ? "Control (full access)" : "RX (read-only)"; + } else { + btn.style.display = "none"; + badge.style.display = "none"; + } +} + +function applyAuthRestrictions() { + if (!authRole) return; + + // Hide TX/PTT controls for rx role + if (authRole === "rx") { + const pttBtn = document.getElementById("ptt-btn"); + const powerBtn = document.getElementById("power-btn"); + const txLimitRow = document.getElementById("tx-limit-row"); + const txAudioBtn = document.getElementById("tx-audio-btn"); + + if (pttBtn) pttBtn.style.display = "none"; + if (powerBtn) powerBtn.disabled = true; + if (txLimitRow) txLimitRow.style.display = "none"; + if (txAudioBtn) txAudioBtn.style.display = "none"; + } +} + const freqEl = document.getElementById("freq"); const wavelengthEl = document.getElementById("wavelength"); const modeEl = document.getElementById("mode"); @@ -754,10 +850,13 @@ function connect() { } }; es.onerror = () => { - powerHint.textContent = "Disconnected, retrying…"; - es.close(); - pollFreshSnapshot(); - scheduleReconnect(1000); + // Check if this is an auth error by looking at readyState + if (es.readyState === EventSource.CLOSED) { + powerHint.textContent = "Disconnected, retrying…"; + es.close(); + pollFreshSnapshot(); + scheduleReconnect(1000); + } }; esHeartbeat = setInterval(() => { @@ -772,6 +871,13 @@ function connect() { async function postPath(path) { const resp = await fetch(path, { method: "POST" }); + if (resp.status === 401 || resp.status === 403) { + // Auth error - return to login + authRole = null; + if (es) es.close(); + showAuthGate(); + throw new Error("Authentication required"); + } if (!resp.ok) { const text = await resp.text(); throw new Error(text || resp.statusText); @@ -1015,9 +1121,57 @@ document.querySelector(".tab-bar").addEventListener("click", (e) => { document.getElementById(`tab-${btn.dataset.tab}`).style.display = ""; }); -connect(); -resizeHeaderSignalCanvas(); -startHeaderSignalSampling(); +// --- Auth startup sequence --- +async function initializeApp() { + const authStatus = await checkAuthStatus(); + if (authStatus.authenticated) { + authRole = authStatus.role; + updateAuthUI(); + applyAuthRestrictions(); + connect(); + resizeHeaderSignalCanvas(); + startHeaderSignalSampling(); + } else { + showAuthGate(); + } +} + +// Setup auth form +document.getElementById("auth-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const passphrase = document.getElementById("auth-passphrase").value; + const btn = document.querySelector("#auth-form button[type=submit]"); + btn.disabled = true; + btn.textContent = "Logging in..."; + + try { + const result = await authLogin(passphrase); + authRole = result.role; + document.getElementById("auth-passphrase").value = ""; + hideAuthGate(); + updateAuthUI(); + applyAuthRestrictions(); + connect(); + resizeHeaderSignalCanvas(); + startHeaderSignalSampling(); + } catch (err) { + showAuthError("Invalid passphrase"); + console.error("Login error:", err); + } finally { + btn.disabled = false; + btn.textContent = "Login"; + } +}); + +// Setup logout button +document.getElementById("logout-btn").addEventListener("click", async () => { + if (confirm("Are you sure you want to logout?")) { + await authLogout(); + } +}); + +// Start the app +initializeApp(); window.addEventListener("resize", resizeHeaderSignalCanvas); // --- Leaflet Map (lazy-initialized) --- diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html index 362ebce..a7643ad 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html @@ -25,6 +25,20 @@ + +
@@ -224,6 +238,10 @@