[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 <noreply@anthropic.com> Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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) ---
|
||||
|
||||
@@ -25,6 +25,20 @@
|
||||
<button id="theme-toggle" class="theme-toggle-btn" type="button" aria-label="Toggle dark or light theme">Light</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Auth gate (hidden by default, shown if auth is required) -->
|
||||
<div id="auth-gate" style="display:none; padding: 3rem 2rem; text-align: center;">
|
||||
<div style="margin-bottom: 1.5rem;">
|
||||
<img src="/logo.png?v=1" alt="trx logo" style="max-width: 80px; margin-bottom: 1rem;" onerror="this.style.display='none'" />
|
||||
<div style="font-size: 1.1rem; font-weight: 600; margin-bottom: 0.5rem;">Access Required</div>
|
||||
<div style="color: var(--text-muted);">Enter passphrase to continue</div>
|
||||
</div>
|
||||
<form id="auth-form" style="margin: 1.5rem 0;">
|
||||
<input type="password" id="auth-passphrase" placeholder="Passphrase" autocomplete="off" style="width: 100%; padding: 0.5rem; margin-bottom: 1rem; border: 1px solid var(--border); border-radius: 0.25rem; background: var(--bg-secondary); color: var(--text); font-size: 1rem;" />
|
||||
<button type="submit" style="width: 100%; padding: 0.5rem; background: var(--accent-green); color: var(--bg-primary); border: none; border-radius: 0.25rem; font-weight: 600; cursor: pointer; font-size: 1rem;">Login</button>
|
||||
</form>
|
||||
<div id="auth-error" style="color: #ff6b6b; font-size: 0.9rem; margin-top: 1rem; display: none;"></div>
|
||||
<div id="auth-role" style="margin-top: 1rem; color: var(--text-muted); font-size: 0.85rem; display: none;"></div>
|
||||
</div>
|
||||
<div class="tab-bar">
|
||||
<button class="tab active" data-tab="main">Main</button>
|
||||
<button class="tab" data-tab="plugins">Plugins</button>
|
||||
@@ -224,6 +238,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<div id="tab-about" class="tab-panel" style="display:none;">
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<button id="logout-btn" type="button" style="display:none; padding: 0.5rem 1rem; background: var(--accent-red, #ff6b6b); color: var(--bg-primary); border: none; border-radius: 0.25rem; font-weight: 600; cursor: pointer;">Logout</button>
|
||||
<div id="auth-badge" style="display:none; margin-top: 0.5rem; color: var(--text-muted); font-size: 0.85rem;">Authenticated as: <strong id="auth-role-badge">--</strong></div>
|
||||
</div>
|
||||
<table class="about-table">
|
||||
<tr><td>Server version</td><td id="about-server-ver">--</td></tr>
|
||||
<tr><td>Server address</td><td id="about-server-addr">--</td></tr>
|
||||
|
||||
@@ -33,6 +33,36 @@ enabled = true
|
||||
listen = "127.0.0.1"
|
||||
port = 8080
|
||||
|
||||
[frontends.http.auth]
|
||||
# Optional passphrase-based authentication for the HTTP frontend
|
||||
# Disabled by default to preserve backward compatibility
|
||||
|
||||
# Enable authentication (default: false)
|
||||
enabled = false
|
||||
|
||||
# Read-only passphrase: grants access to status/events/audio (rx role)
|
||||
# Leave unset to disable rx access
|
||||
# rx_passphrase = "rx-only-passphrase"
|
||||
|
||||
# Full control passphrase: grants access to all endpoints including TX/PTT (control role)
|
||||
# Leave unset to disable control access
|
||||
# control_passphrase = "full-control-passphrase"
|
||||
|
||||
# Enforce TX/PTT access control (default: true)
|
||||
# When true, TX/PTT endpoints return 404 to authenticated users without control role
|
||||
tx_access_control_enabled = true
|
||||
|
||||
# Session time-to-live in minutes (default: 480 = 8 hours)
|
||||
session_ttl_min = 480
|
||||
|
||||
# Set Secure flag on session cookie (default: false)
|
||||
# Should be true if served over HTTPS; false for HTTP/localhost
|
||||
cookie_secure = false
|
||||
|
||||
# Cookie SameSite attribute: Strict, Lax (default), or None
|
||||
# Lax is a good balance between security and usability
|
||||
cookie_same_site = "Lax"
|
||||
|
||||
[frontends.rigctl]
|
||||
# Enable rigctl-compatible TCP interface (hamlib compatible)
|
||||
enabled = false
|
||||
|
||||
Reference in New Issue
Block a user