Compare commits

...
Author SHA1 Message Date
sjg 3c3fc69542 Refactor HTTP account system
CI / frontend (pull_request) Successful in 5m13s
CI / reuse (pull_request) Successful in 29s
CI / frontend (push) Successful in 4m15s
CI / reuse (push) Successful in 5s
CI / lint (pull_request) Successful in 2m25s
CI / test (pull_request) Successful in 9m24s
CI / lint (push) Successful in 2m23s
CI / test (push) Successful in 8m19s
2026-08-10 23:47:51 +02:00
30 changed files with 1115 additions and 462 deletions
Generated
+93 -5
View File
@@ -316,6 +316,18 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures 0.2.17",
"password-hash",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
@@ -345,6 +357,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bindgen"
version = "0.66.1"
@@ -395,6 +413,24 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest 0.10.7",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "block-buffer"
version = "0.12.0"
@@ -525,7 +561,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.3.0",
"rand_core 0.10.1",
]
@@ -721,6 +757,15 @@ dependencies = [
"windows",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.0"
@@ -739,6 +784,16 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "crypto-common"
version = "0.2.1"
@@ -799,15 +854,26 @@ dependencies = [
"zeroize",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer 0.10.4",
"crypto-common 0.1.7",
"subtle",
]
[[package]]
name = "digest"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c"
dependencies = [
"block-buffer",
"block-buffer 0.12.0",
"const-oid",
"crypto-common",
"crypto-common 0.2.1",
]
[[package]]
@@ -1019,6 +1085,16 @@ dependencies = [
"slab",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -1890,6 +1966,17 @@ dependencies = [
"windows-link",
]
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "peeking_take_while"
version = "0.1.2"
@@ -2492,8 +2579,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
"cpufeatures 0.3.0",
"digest 0.11.2",
]
[[package]]
@@ -3215,6 +3302,7 @@ version = "0.1.0"
dependencies = [
"actix-web",
"actix-ws",
"argon2",
"base64",
"brotli 7.0.0",
"bytes",
+1 -1
View File
@@ -1043,7 +1043,7 @@ The `FrontendRuntimeContext` struct in `trx-frontend/src/lib.rs` is decomposed i
|-----------|---------|------------|
| `AudioContext` | Audio streaming channels | `rx`, `tx`, `info`, `decode_rx`, `clients` |
| `DecodeHistoryContext` | Decode history for all types | `ais`, `vdes`, `aprs`, `hf_aprs`, `cw`, `ft8`, `ft4`, `ft2`, `wspr` |
| `HttpAuthConfig` | HTTP auth settings | `enabled`, `rx_passphrase`, `session_ttl_secs`, `tokens` |
| `HttpAuthConfig` | HTTP auth settings | `enabled`, `users_file`, bootstrap admin, `session_ttl_secs`, `tokens` |
| `HttpUiConfig` | HTTP UI display config | `show_sdr_gain_control`, `initial_map_zoom`, `spectrum_*` |
| `RigRoutingContext` | Remote rig state & routing | `active_rig_id`, `remote_rigs`, `rig_states`, `server_connected` |
| `OwnerInfo` | Station metadata | `callsign`, `website_url`, `ais_vessel_url_base` |
+3 -3
View File
@@ -477,7 +477,7 @@ first wins.
| `GET` | `/api/logbook/now` | The server's UTC clock, for checking the browser's |
| `GET` | `/api/logbook/prefill` | The six fields an entry opens with |
Writes require the control role, as the rig endpoints do.
Writes require the admin role, as the rig endpoints do.
### Frontend
@@ -516,7 +516,7 @@ All five are implemented.
| Phase | Lands |
|-------|-------|
| 1 | `trx-logbook`: `Qso`, the ADI reader and writer, round-trip tests against files from other loggers |
| 2 | Store, dedupe, and the HTTP API behind the control role |
| 2 | Store, dedupe, and the HTTP API behind the admin role |
| 3 | Logbook tab: entry, table, filters, import, export |
| 4 | Ham layout, pre-filled entry from a decode row or the map, worked-before |
| 5 | Contest exchange fields and Cabrillo export; QSL and LoTW/eQSL fields; per-band worked/confirmed statistics |
@@ -541,7 +541,7 @@ setting, which is also what LoTW's station locations expect.
rotate operators through one station callsign, which is why contest loggers record it per QSO.
It is stored per QSO, defaulted from the configured callsign so a single operator never touches
it, and changed on the station line at the top of the panel where it sticks for the session.
It cannot be taken from the session's identity: the auth roles are `control` and `rx`, with no
It cannot be taken from the session's identity: the auth roles are `admin` and `user`, with no
notion of who is logged in.
**Server clock, and the log says so.** The server is the machine at the radio; the browser may
+10 -11
View File
@@ -121,13 +121,12 @@ The spectrum panel uses `<canvas>` elements (WebGL renderer optional) and offers
When auth is enabled, an **auth gate** blocks the UI with:
- Title: "Access Required"
- Subtitle: "Enter passphrase to continue"
- Password input + Login button (green accent, full-width)
- Optional "Continue as Guest" button (shown when RX passphrase is not set)
- Subtitle: "Sign in to continue"
- Username and password inputs + Login button (green accent, full-width)
- Error message area (red `#ff6b6b`)
- Role badge display
Two roles: **Rx** (read-only) and **Control** (full access including TX/PTT).
Two roles: **User** (read-only) and **Admin** (full access including user management).
Session cookie: `trx_http_sid`, HttpOnly, configurable Secure and SameSite attributes.
@@ -340,21 +339,21 @@ Routes are classified into three tiers:
| Tier | Examples | Requirement |
|---|---|---|
| **Public** | `/`, `/index.html`, `/map`, `/auth/*`, static assets | None |
| **Read** | `/status`, `/events`, `/audio`, `/decode`, `/spectrum`, `/bookmarks` | Rx or Control role |
| **Control** | `/set_freq`, `/set_mode`, `/set_ptt`, `/toggle_power`, all other POST | Control role only |
| **Public** | `/`, `/index.html`, `/map`, login/session endpoints, static assets | None |
| **Read** | `/status`, `/events`, `/audio`, `/decode`, `/spectrum`, `/bookmarks` | User or Admin role |
| **Control** | `/set_freq`, `/set_mode`, `/set_ptt`, `/toggle_power`, all other POST | Admin role only |
### 7.2 Session Management
- Sessions are 128-bit random hex tokens stored in HttpOnly cookies
- Configurable TTL (default from TOML config)
- Expired sessions auto-pruned on access
- Constant-time passphrase comparison to mitigate timing attacks
- Passwords are verified against salted Argon2id hashes
### 7.3 TX Access Control
### 7.3 User Management
An additional `tx_access_control_enabled` flag can restrict transmit-related actions even
for Control-role users, providing an extra safety layer.
Only administrators can list, add, update, or remove accounts. The final
administrator cannot be removed or demoted.
---
+27 -23
View File
@@ -66,8 +66,7 @@ both:
|------------|----------|----------|
| `[listen.auth].tokens` | `tokens_file` | one token per line |
| `[[remotes]].auth.token` | `token_file` | the token |
| `[frontends.http.auth].rx_passphrase` | `rx_passphrase_file` | the passphrase |
| `[frontends.http.auth].control_passphrase` | `control_passphrase_file` | the passphrase |
| `[frontends.http.auth].bootstrap_admin_password` | `bootstrap_admin_password_file` | the initial administrator password |
| `[frontends.http_json.auth].tokens` | `tokens_file` | one token per line |
Blank lines and `#` comments are ignored in the list files. A config that holds
@@ -350,17 +349,17 @@ A name in any of those maps that no remote answers to is a config error.
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `false` | Require a passphrase |
| `rx_passphrase` | string | — | Passphrase granting receive-only access |
| `rx_passphrase_file` | string | — | Read it from this file instead |
| `control_passphrase` | string | — | Passphrase granting full control |
| `control_passphrase_file` | string | — | Read it from this file instead |
| `tx_access_control_enabled` | bool | `true` | Hide TX from unauthenticated users |
| `enabled` | bool | `false` | Enable the user/password ACL |
| `users_file` | string | `"trx-http-users.json"` | Persistent managed user database |
| `bootstrap_admin_username` | string | — | First administrator, used only if the database is absent |
| `bootstrap_admin_password` | string | — | First administrator password |
| `bootstrap_admin_password_file` | string | — | Read the bootstrap password from this file instead |
| `session_ttl_min` | u64 | `480` | Session lifetime |
| `cookie_secure` | bool | `false` | Set Secure on the session cookie (needs HTTPS) |
| `cookie_same_site` | string | `"Lax"` | `Strict`, `Lax`, or `None` |
With `enabled = true`, at least one passphrase must be set.
When enabling ACL for the first time, configure both bootstrap fields. After
the database exists, remove the bootstrap credentials from configuration.
#### `[frontends.rigctl]`
@@ -578,7 +577,7 @@ The link button in the top bar copies the current link to the clipboard. The
address bar itself is updated as you tune, using `replaceState`, so sweeping
the dial does not fill the browser's history.
Applying a link changes the radio, so it needs the `control` role; an `rx`
Applying a link changes the radio, so it needs the `admin` role; a `user`
session opens the page and says the link was not applied. Links describe the
rig's own dial — while a tab is listening to a virtual channel the address is
left as it was, rather than publishing a frequency the rig is not on.
@@ -587,53 +586,58 @@ left as it was, rather than publishing a frequency the rig is not on.
## Authentication
The HTTP frontend supports optional passphrase-based authentication with two
The HTTP frontend supports an optional user/password ACL with multiple accounts and two
roles:
- **rx** — read-only access (monitoring, audio, decode streams)
- **control** — full access (frequency, mode, PTT, and all settings)
- **user** — read-only access (monitoring, audio, decode streams)
- **admin** — full radio control, settings, and user management
### Configuration
```toml
[frontends.http.auth]
enabled = false
rx_passphrase = "rx-only-passphrase"
control_passphrase = "full-control-passphrase"
tx_access_control_enabled = true
users_file = "trx-http-users.json"
bootstrap_admin_username = "admin"
bootstrap_admin_password = "change-this-password"
session_ttl_min = 480
cookie_secure = false # true if served via HTTPS
cookie_same_site = "Lax" # Strict|Lax|None
```
When `enabled = false` (the default), all auth is bypassed and the UI behaves
as before. When enabled, at least one passphrase must be set.
as before. When enabling it for the first time, bootstrap credentials create
the initial administrator and the Argon2id-hashed user database.
### Behaviour
- On login, the server issues an `HttpOnly` session cookie.
- Sessions are in-memory; a server restart invalidates all sessions.
- Rate limiting is applied per IP to mitigate brute-force attempts.
- When `tx_access_control_enabled = true`, TX/PTT controls are hidden and
rejected for unauthenticated or `rx`-role users.
- User records persist in `users_file`; passwords are stored as salted Argon2id hashes.
- `user` sessions cannot call control routes. There is no guest-access mode.
- Administrators can add/remove users and change roles/passwords in Settings.
- Removing an account or changing its password/role revokes its sessions.
### Routes
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/auth/login` | POST | Submit `{ "passphrase": "..." }` |
| `/auth/login` | POST | Submit `{ "username": "...", "password": "..." }` |
| `/auth/logout` | POST | Clear session |
| `/auth/session` | GET | Check current session/role |
| `/auth/users` | GET/POST | List or add users (admin only) |
| `/auth/users/{username}` | PATCH/DELETE | Change password/role or remove user (admin only) |
Protected routes require at least `rx` role. Control routes (set frequency,
mode, PTT, etc.) require `control` role.
Protected routes require at least `user` role. Control routes (set frequency,
mode, PTT, etc.) require `admin` role.
### Frontend Flow
1. On load, the UI calls `/auth/session`.
2. If unauthenticated, a login screen is shown.
3. On successful login, the normal UI loads.
4. `rx` users see a read-only interface; `control` users get full controls.
4. `user` accounts see a read-only interface; admins get full controls.
5. If a session expires mid-use, streams stop and the login screen returns.
### Transport Security
+5 -5
View File
@@ -252,11 +252,11 @@ async fn async_init() -> DynResult<AppState> {
// Set HTTP frontend authentication config
frontend_runtime.http_auth.enabled = cfg.frontends.http.auth.enabled;
frontend_runtime.http_auth.rx_passphrase = cfg.frontends.http.auth.rx_passphrase.clone();
frontend_runtime.http_auth.control_passphrase =
cfg.frontends.http.auth.control_passphrase.clone();
frontend_runtime.http_auth.tx_access_control_enabled =
cfg.frontends.http.auth.tx_access_control_enabled;
frontend_runtime.http_auth.users_file = cfg.frontends.http.auth.users_file.clone();
frontend_runtime.http_auth.bootstrap_admin_username =
cfg.frontends.http.auth.bootstrap_admin_username.clone();
frontend_runtime.http_auth.bootstrap_admin_password =
cfg.frontends.http.auth.bootstrap_admin_password.clone();
frontend_runtime.http_auth.session_ttl_secs = cfg.frontends.http.auth.session_ttl().as_secs();
frontend_runtime.http_auth.cookie_secure = cfg.frontends.http.auth.cookie_secure;
frontend_runtime.http_auth.cookie_same_site = match cfg.frontends.http.auth.cookie_same_site {
+6 -6
View File
@@ -257,9 +257,9 @@ impl Default for DecodeHistoryContext {
/// HTTP authentication configuration.
pub struct HttpAuthConfig {
pub enabled: bool,
pub rx_passphrase: Option<String>,
pub control_passphrase: Option<String>,
pub tx_access_control_enabled: bool,
pub users_file: String,
pub bootstrap_admin_username: Option<String>,
pub bootstrap_admin_password: Option<String>,
pub session_ttl_secs: u64,
pub cookie_secure: bool,
pub cookie_same_site: String,
@@ -271,9 +271,9 @@ impl Default for HttpAuthConfig {
fn default() -> Self {
Self {
enabled: false,
rx_passphrase: None,
control_passphrase: None,
tx_access_control_enabled: true,
users_file: "trx-http-users.json".to_string(),
bootstrap_admin_username: None,
bootstrap_admin_password: None,
session_ttl_secs: 480 * 60,
cookie_secure: false,
cookie_same_site: "Lax".to_string(),
@@ -28,6 +28,7 @@ flate2 = { workspace = true }
brotli = "7"
rand = "0.8"
hex = "0.4"
argon2 = "0.5"
pickledb = "0.5"
dirs = "6"
uuid = { workspace = true }
@@ -1323,7 +1323,7 @@ function decodeAuthSession(value) {
if (typeof session.authenticated !== "boolean") {
throw new TypeError("The authentication response has no authenticated flag");
}
if (session.role !== void 0 && session.role !== "rx" && session.role !== "control") {
if (session.role !== void 0 && session.role !== "user" && session.role !== "admin") {
throw new TypeError("The authentication response has an invalid role");
}
if (session.auth_disabled !== void 0 && typeof session.auth_disabled !== "boolean") {
@@ -1331,12 +1331,16 @@ function decodeAuthSession(value) {
}
const decoded = { authenticated: session.authenticated };
if (session.role !== void 0) decoded.role = session.role;
if (session.username !== void 0) {
if (typeof session.username !== "string") throw new TypeError("The authentication response has an invalid username");
decoded.username = session.username;
}
if (session.auth_disabled !== void 0) decoded.auth_disabled = session.auth_disabled;
return decoded;
}
var authDisabledSession = {
authenticated: true,
role: "control",
role: "admin",
auth_disabled: true
};
async function fetchAuthSession() {
@@ -1350,11 +1354,11 @@ async function fetchAuthSession() {
return { authenticated: false };
}
}
async function login(passphrase) {
async function login(username, password) {
const response = await fetch("/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ passphrase })
body: JSON.stringify({ username, password })
});
if (response.status === 404) return authDisabledSession;
if (!response.ok) {
@@ -1363,6 +1367,34 @@ async function login(passphrase) {
}
return decodeAuthSession(await response.json());
}
async function userRequest(path, init) {
const response = await fetch(path, init);
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
throw new Error(payload.error || `User operation failed (${response.status})`);
}
return response;
}
async function listUsers() {
const value = await userRequest("/auth/users").then((response) => response.json());
if (!Array.isArray(value) || !value.every((user) => {
if (typeof user !== "object" || user === null) return false;
const record = user;
return typeof record.username === "string" && (record.role === "user" || record.role === "admin");
})) {
throw new TypeError("The user list response is malformed");
}
return value;
}
async function createUser(username, password, role) {
await userRequest("/auth/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password, role }) });
}
async function updateUser(username, changes) {
await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(changes) });
}
async function deleteUser(username) {
await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "DELETE" });
}
async function logout() {
const response = await fetch("/auth/logout", { method: "POST" });
if (response.status !== 404 && !response.ok) throw new Error("Logout failed");
@@ -1863,32 +1895,32 @@ function isVchanRdsEntry(value) {
}
void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
var authRole = null;
var authUsername = null;
var authEnabled = true;
async function checkAuthStatus() {
return fetchAuthSession();
}
async function authLogin(passphrase) {
return login(passphrase);
async function authLogin(username, password) {
return login(username, password);
}
async function authLogout() {
try {
await logout();
authRole = null;
authUsername = null;
disconnect();
setDecodeHistoryOverlayVisible(false);
requiredElement("content").style.display = "none";
requiredElement("loading").style.display = "none";
requiredElement("auth-passphrase").value = "";
requiredElement("auth-password").value = "";
updateAuthUI();
const authStatus = await checkAuthStatus();
const allowGuest = authStatus.role === "rx";
showAuthGate(allowGuest);
showAuthGate();
} catch (e) {
console.error("Logout failed:", e);
showAuthError("Logout failed");
}
}
function showAuthGate(allowGuest = false) {
function showAuthGate() {
if (!authEnabled) return;
setDecodeHistoryOverlayVisible(false);
requiredElement("loading").style.display = "none";
@@ -1905,10 +1937,6 @@ function showAuthGate(allowGuest = false) {
document.querySelectorAll(".tab-panel").forEach((panel) => {
panel.style.display = "none";
});
const guestBtn2 = document.getElementById("auth-guest-btn");
if (guestBtn2) {
guestBtn2.style.display = allowGuest ? "block" : "none";
}
document.querySelectorAll(".tab-bar .tab").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.tab === "main");
});
@@ -1952,7 +1980,7 @@ function updateAuthUI() {
}
if (authRole) {
if (badge) badge.style.display = "block";
if (badgeRole) badgeRole.textContent = authRole === "control" ? "Control (full access)" : "RX (read-only)";
if (badgeRole) badgeRole.textContent = `${authUsername || "local"}${authRole === "admin" ? "Admin" : "User (read-only)"}`;
if (headerAuthBtn2) {
headerAuthBtn2.textContent = "Logout";
headerAuthBtn2.style.display = "block";
@@ -1968,7 +1996,7 @@ function updateAuthUI() {
}
function applyAuthRestrictions() {
if (!authRole) return;
if (authRole === "rx") {
if (authRole === "user") {
const pttBtn2 = document.getElementById("ptt-btn");
const powerBtn2 = document.getElementById("power-btn");
const lockBtn2 = document.getElementById("lock-btn");
@@ -2271,7 +2299,7 @@ function syncTopBarAccess() {
rigSwitch.style.display = loggedOut ? "none" : "";
}
if (headerRigSwitchSelect) {
headerRigSwitchSelect.disabled = loggedOut || authRole === "rx" || lastRigIds.length === 0;
headerRigSwitchSelect.disabled = loggedOut || authRole === "user" || lastRigIds.length === 0;
}
}
var overviewDrawPending = false;
@@ -2907,7 +2935,7 @@ function applyRigList(activeRigId, rigIds, displayNames) {
}
const nextKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || "");
const rigListChanged = prevKey !== nextKey;
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx";
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "user";
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
updateRigSubtitle(lastActiveRigId);
window.trxUi?.setActiveRig(lastActiveRigId);
@@ -4510,7 +4538,7 @@ function scheduleTuneLinkSync() {
async function applyTuneLink(link) {
const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null;
if (!wanted) return;
if (authRole === "rx") {
if (authRole === "user") {
showHint("Read-only session — link not applied", 2500);
return;
}
@@ -5253,8 +5281,8 @@ async function switchRigFromSelect(selectEl) {
showHint("No rig selected", 1500);
return;
}
if (authRole === "rx") {
showHint("Control role required", 1500);
if (authRole === "user") {
showHint("Admin role required", 1500);
return;
}
if (!lastRigIds.includes(selectEl.value)) {
@@ -5868,7 +5896,7 @@ function navigateToTab(name, options = {}) {
const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
const { updateHistory = true, replaceHistory = false } = options;
if (authEnabled && !authRole && name !== "main") {
showAuthGate(false);
showAuthGate();
return;
}
const btn = document.querySelector(`.tab-bar .tab[data-tab="${name}"]`);
@@ -5981,11 +6009,11 @@ window.addEventListener("resize", () => {
scheduleSpectrumLayout();
});
async function initializeApp() {
showAuthGate(false);
showAuthGate();
const authStatus = await checkAuthStatus();
authEnabled = !authStatus.auth_disabled;
if (!authEnabled) {
authRole = "control";
authRole = "admin";
hideAuthGate();
updateAuthUI();
connect();
@@ -5997,6 +6025,7 @@ async function initializeApp() {
}
if (authStatus.authenticated) {
authRole = authStatus.role ?? null;
authUsername = authStatus.username ?? null;
hideAuthGate();
updateAuthUI();
applyAuthRestrictions();
@@ -6006,8 +6035,7 @@ async function initializeApp() {
resizeHeaderSignalCanvas();
startHeaderSignalSampling();
} else {
const allowGuest = authStatus.role === "rx";
showAuthGate(allowGuest);
showAuthGate();
}
}
var settingsUiReady = false;
@@ -6019,19 +6047,104 @@ function initSettingsUI() {
window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRole);
window.trx.modules.backgroundDecode.wireEvents();
}
void refreshUserManagement();
}
async function refreshUserManagement() {
const section = document.getElementById("user-management");
if (!section) return;
section.style.display = authEnabled && authRole === "admin" ? "block" : "none";
if (section.style.display === "none") return;
const list = requiredElement("user-list");
try {
const users = await listUsers();
list.replaceChildren(...users.map((user) => {
const row = document.createElement("div");
row.className = "sch-row";
row.style.cssText = "display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;margin:.4rem 0";
const name = document.createElement("strong");
name.textContent = user.username;
name.style.minWidth = "10rem";
const role = document.createElement("select");
role.className = "auth-input";
for (const value of ["user", "admin"]) {
const option = document.createElement("option");
option.value = value;
option.textContent = value === "admin" ? "Admin" : "User";
option.selected = user.role === value;
role.append(option);
}
const password = document.createElement("input");
password.type = "password";
password.placeholder = "New password";
password.autocomplete = "new-password";
password.className = "auth-input";
password.minLength = 8;
const save = document.createElement("button");
save.type = "button";
save.textContent = "Save";
save.addEventListener("click", async () => {
const changes = { role: role.value };
if (password.value) changes.password = password.value;
await runUserOperation(() => updateUser(user.username, changes));
});
const remove = document.createElement("button");
remove.type = "button";
remove.textContent = "Remove";
remove.className = "danger";
remove.disabled = user.username === authUsername;
remove.addEventListener("click", async () => {
if (await window.trxUi.confirm({ title: "Remove user?", message: `Remove ${user.username} and revoke their sessions?`, confirmLabel: "Remove", danger: true })) {
await runUserOperation(() => deleteUser(user.username));
}
});
row.append(name, role, password, save, remove);
return row;
}));
} catch (error) {
showUserManagementError(error);
}
}
function showUserManagementError(error) {
const element = document.getElementById("user-management-error");
if (!element) return;
element.textContent = error instanceof Error ? error.message : String(error);
element.style.display = "block";
}
async function runUserOperation(operation) {
try {
await operation();
const error = document.getElementById("user-management-error");
if (error) error.style.display = "none";
await refreshUserManagement();
} catch (reason) {
showUserManagementError(reason);
}
}
document.getElementById("user-create-form")?.addEventListener("submit", (event) => {
event.preventDefault();
const username = requiredElement("user-create-username");
const password = requiredElement("user-create-password");
const role = requiredElement("user-create-role");
void runUserOperation(async () => {
await createUser(username.value, password.value, role.value);
username.value = "";
password.value = "";
role.value = "user";
});
});
requiredElement("auth-form").addEventListener("submit", async (e) => {
e.preventDefault();
const passphraseEl = requiredElement("auth-passphrase");
const passphrase = passphraseEl.value;
const usernameEl = requiredElement("auth-username");
const passwordEl = requiredElement("auth-password");
const btn = requiredElement("auth-form").querySelector("button[type=submit]");
if (!btn) return;
btn.disabled = true;
btn.textContent = "Logging in...";
try {
const result = await authLogin(passphrase);
const result = await authLogin(usernameEl.value, passwordEl.value);
authRole = result.role ?? null;
passphraseEl.value = "";
authUsername = result.username ?? usernameEl.value;
passwordEl.value = "";
hideAuthGate();
updateAuthUI();
applyAuthRestrictions();
@@ -6041,28 +6154,13 @@ requiredElement("auth-form").addEventListener("submit", async (e) => {
resizeHeaderSignalCanvas();
startHeaderSignalSampling();
} catch (err) {
showAuthError("Invalid passphrase");
showAuthError("Invalid username or password");
console.error("Login error:", err);
} finally {
btn.disabled = false;
btn.textContent = "Login";
}
});
var guestBtn = document.getElementById("auth-guest-btn");
if (guestBtn) {
guestBtn.addEventListener("click", () => {
authRole = "rx";
requiredElement("auth-passphrase").value = "";
hideAuthGate();
updateAuthUI();
applyAuthRestrictions();
connect();
connectDecode();
initSettingsUI();
resizeHeaderSignalCanvas();
startHeaderSignalSampling();
});
}
var headerAuthBtn = document.getElementById("header-auth-btn");
if (headerAuthBtn) {
headerAuthBtn.addEventListener("click", async () => {
@@ -6071,7 +6169,7 @@ if (headerAuthBtn) {
await authLogout();
}
} else {
showAuthGate(false);
showAuthGate();
}
});
}
@@ -358,7 +358,7 @@ var bgdWindow = window;
btn.title = bgdDirty ? "Apply these bookmarks to the background decoder" : "No changes to save";
}
function isControlRole() {
return backgroundDecodeRole === "control" || hostState.authEnabled === false;
return backgroundDecodeRole === "admin" || hostState.authEnabled === false;
}
function showToast(msg, isError) {
const el = document.getElementById("background-decode-toast");
@@ -42,7 +42,7 @@ function bmEsc(str) {
return d.innerHTML;
}
function bmCanControl() {
return !hostState.authEnabled || hostState.authRole === "control";
return !hostState.authEnabled || hostState.authRole === "admin";
}
function bmSyncAccess() {
const canCtrl = bmCanControl();
@@ -272,7 +272,7 @@ function schedulerOptionalEl(id) {
const nextBtn = schedulerEl("scheduler-next-btn");
if (!prevBtn || !nextBtn) return;
const state = schedulerInterleaveState(currentConfig);
const enabled = schedulerRole === "control" && !!currentRigId && !schedulerStepPending && state.activeEntries.length > 1;
const enabled = schedulerRole === "admin" && !!currentRigId && !schedulerStepPending && state.activeEntries.length > 1;
prevBtn.disabled = !enabled;
nextBtn.disabled = !enabled;
const hint = enabled ? "Select a different active scheduler entry" : "Available only when multiple scheduler entries are active";
@@ -354,7 +354,7 @@ function schedulerOptionalEl(id) {
const panel = schedulerEl("scheduler-panel");
if (!panel) return;
const mode = currentConfig && currentConfig.mode || "disabled";
const isControl = schedulerRole === "control";
const isControl = schedulerRole === "admin";
setSelected("scheduler-mode-select", mode);
const satEnabled = currentConfig && currentConfig.satellites && currentConfig.satellites.enabled;
const controlRow = document.querySelector(".scheduler-control-row");
@@ -123,13 +123,13 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div id="auth-gate" class="auth-gate" style="display:none;">
<div class="auth-gate-head">
<div class="auth-gate-title">Access Required</div>
<div class="auth-gate-sub">Enter passphrase to continue</div>
<div class="auth-gate-sub">Sign in to continue</div>
</div>
<form id="auth-form" class="auth-form">
<input type="password" id="auth-passphrase" class="auth-input" placeholder="Passphrase" autocomplete="off" />
<input type="text" id="auth-username" class="auth-input" placeholder="Username" autocomplete="username" required />
<input type="password" id="auth-password" class="auth-input" placeholder="Password" autocomplete="current-password" required />
<button type="submit" class="auth-submit">Login</button>
</form>
<button id="auth-guest-btn" type="button" class="auth-guest" style="display: none;">Continue as Guest</button>
<div id="auth-error" class="auth-error" style="display: none;"></div>
<div id="auth-role" class="auth-role" style="display: none;"></div>
</div>
@@ -1750,6 +1750,19 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div>
</div>
</div>
<section id="user-management" style="display:none; margin-top:1rem;">
<h2 class="section-heading">User management</h2>
<div class="settings-card">
<form id="user-create-form" class="sch-row" style="flex-wrap:wrap; gap:.5rem;">
<input id="user-create-username" class="auth-input" placeholder="Username" autocomplete="off" required />
<input id="user-create-password" class="auth-input" type="password" placeholder="Password (8+ characters)" autocomplete="new-password" minlength="8" required />
<select id="user-create-role" class="auth-input"><option value="user">User</option><option value="admin">Admin</option></select>
<button type="submit" class="auth-submit">Add user</button>
</form>
<div id="user-management-error" class="auth-error" style="display:none;"></div>
<div id="user-list" style="margin-top:.75rem;"></div>
</div>
</section>
</div>
<div id="tab-about" class="tab-panel" style="display:none;">
<h2 class="section-heading">About</h2>
@@ -196,8 +196,7 @@ body {
font-size: var(--fs-base);
box-sizing: border-box;
}
.auth-submit,
.auth-guest {
.auth-submit {
width: 100%;
padding: 0.65rem 0.75rem;
border-radius: var(--radius-md);
@@ -213,16 +212,11 @@ body {
font-weight: 700;
}
.auth-submit:hover:not(:disabled) { background: var(--accent-green-hover); }
.auth-guest {
background: var(--btn-bg);
color: var(--text);
border: 1px solid var(--border-light);
font-weight: 600;
margin-top: var(--space-4);
}
.auth-guest:hover:not(:disabled) { background: var(--btn-hover-bg); }
.auth-error { color: var(--accent-red); font-size: var(--fs-sm); margin-top: var(--space-4); }
.auth-role { margin-top: var(--space-4); color: var(--text-muted); font-size: var(--fs-sm); }
#user-management .auth-input { width: auto; min-width: 9rem; flex: 1 1 10rem; margin-bottom: 0; }
#user-management .auth-submit { width: auto; }
#user-management button { padding: 0.55rem 0.75rem; }
.label { color: var(--text-muted); font-size: 0.9rem; margin-bottom: 6px; display: block; }
#tab-main .label > span {
@@ -2,11 +2,12 @@
//
// SPDX-License-Identifier: GPL-2.0-or-later
export type AuthRole = "rx" | "control";
export type AuthRole = "user" | "admin";
export interface AuthSession {
authenticated: boolean;
role?: AuthRole;
username?: string;
auth_disabled?: boolean;
}
@@ -18,7 +19,7 @@ function decodeAuthSession(value: unknown): AuthSession {
if (typeof session.authenticated !== "boolean") {
throw new TypeError("The authentication response has no authenticated flag");
}
if (session.role !== undefined && session.role !== "rx" && session.role !== "control") {
if (session.role !== undefined && session.role !== "user" && session.role !== "admin") {
throw new TypeError("The authentication response has an invalid role");
}
if (session.auth_disabled !== undefined && typeof session.auth_disabled !== "boolean") {
@@ -26,13 +27,17 @@ function decodeAuthSession(value: unknown): AuthSession {
}
const decoded: AuthSession = { authenticated: session.authenticated };
if (session.role !== undefined) decoded.role = session.role;
if (session.username !== undefined) {
if (typeof session.username !== "string") throw new TypeError("The authentication response has an invalid username");
decoded.username = session.username;
}
if (session.auth_disabled !== undefined) decoded.auth_disabled = session.auth_disabled;
return decoded;
}
const authDisabledSession: AuthSession = {
authenticated: true,
role: "control",
role: "admin",
auth_disabled: true,
};
@@ -48,11 +53,11 @@ export async function fetchAuthSession(): Promise<AuthSession> {
}
}
export async function login(passphrase: string): Promise<AuthSession> {
export async function login(username: string, password: string): Promise<AuthSession> {
const response = await fetch("/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ passphrase }),
body: JSON.stringify({ username, password }),
});
if (response.status === 404) return authDisabledSession;
if (!response.ok) {
@@ -62,6 +67,41 @@ export async function login(passphrase: string): Promise<AuthSession> {
return decodeAuthSession(await response.json());
}
export interface ManagedUser { username: string; role: AuthRole }
async function userRequest(path: string, init?: RequestInit): Promise<Response> {
const response = await fetch(path, init);
if (!response.ok) {
const payload = await response.json().catch(() => ({})) as { error?: string };
throw new Error(payload.error || `User operation failed (${response.status})`);
}
return response;
}
export async function listUsers(): Promise<ManagedUser[]> {
const value: unknown = await userRequest("/auth/users").then(response => response.json());
if (!Array.isArray(value) || !value.every((user: unknown) => {
if (typeof user !== "object" || user === null) return false;
const record = user as Record<string, unknown>;
return typeof record.username === "string" && (record.role === "user" || record.role === "admin");
})) {
throw new TypeError("The user list response is malformed");
}
return value as ManagedUser[];
}
export async function createUser(username: string, password: string, role: AuthRole): Promise<void> {
await userRequest("/auth/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password, role }) });
}
export async function updateUser(username: string, changes: { password?: string; role?: AuthRole }): Promise<void> {
await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(changes) });
}
export async function deleteUser(username: string): Promise<void> {
await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "DELETE" });
}
export async function logout(): Promise<void> {
const response = await fetch("/auth/logout", { method: "POST" });
if (response.status !== 404 && !response.ok) throw new Error("Logout failed");
@@ -21,6 +21,10 @@ import {
fetchAuthSession,
login,
logout,
listUsers,
createUser,
updateUser,
deleteUser,
} from "./api/auth.js";
import {
formatByteSize as recorderFormatSize,
@@ -407,39 +411,38 @@ void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
// --- Authentication ---
let authRole: AuthRole | null = null;
let authUsername: string | null = null;
let authEnabled = true;
async function checkAuthStatus() {
return fetchAuthSession();
}
async function authLogin(passphrase: string) {
return login(passphrase);
async function authLogin(username: string, password: string) {
return login(username, password);
}
async function authLogout() {
try {
await logout();
authRole = null;
authUsername = null;
// Disconnect and show auth gate without page reload
disconnect();
setDecodeHistoryOverlayVisible(false);
requiredElement("content").style.display = "none";
requiredElement("loading").style.display = "none";
requiredElement<HTMLInputElement>("auth-passphrase").value = "";
requiredElement<HTMLInputElement>("auth-password").value = "";
updateAuthUI();
// Check if guest mode is available after logout
const authStatus = await checkAuthStatus();
const allowGuest = authStatus.role === "rx";
showAuthGate(allowGuest);
showAuthGate();
} catch (e) {
console.error("Logout failed:", e);
showAuthError("Logout failed");
}
}
function showAuthGate(allowGuest = false) {
function showAuthGate() {
if (!authEnabled) return;
setDecodeHistoryOverlayVisible(false);
requiredElement("loading").style.display = "none";
@@ -459,12 +462,6 @@ function showAuthGate(allowGuest = false) {
panel.style.display = "none";
});
// Show guest button if guest mode is available
const guestBtn = document.getElementById("auth-guest-btn");
if (guestBtn) {
guestBtn.style.display = allowGuest ? "block" : "none";
}
document.querySelectorAll<HTMLElement>(".tab-bar .tab").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.tab === "main");
});
@@ -516,7 +513,7 @@ function updateAuthUI() {
if (authRole) {
if (badge) badge.style.display = "block";
if (badgeRole) badgeRole.textContent = authRole === "control" ? "Control (full access)" : "RX (read-only)";
if (badgeRole) badgeRole.textContent = `${authUsername || "local"}${authRole === "admin" ? "Admin" : "User (read-only)"}`;
if (headerAuthBtn) {
headerAuthBtn.textContent = "Logout";
headerAuthBtn.style.display = "block";
@@ -534,8 +531,8 @@ function updateAuthUI() {
function applyAuthRestrictions() {
if (!authRole) return;
// Disable TX/PTT/frequency/mode/VFO controls for rx role
if (authRole === "rx") {
// Disable TX/PTT/frequency/mode/VFO controls for user role
if (authRole === "user") {
const pttBtn = document.getElementById("ptt-btn") as HTMLButtonElement | null;
const powerBtn = document.getElementById("power-btn") as HTMLButtonElement | null;
const lockBtn = document.getElementById("lock-btn") as HTMLButtonElement | null;
@@ -883,7 +880,7 @@ function syncTopBarAccess() {
}
if (headerRigSwitchSelect) {
headerRigSwitchSelect.disabled = loggedOut || authRole === "rx" || lastRigIds.length === 0;
headerRigSwitchSelect.disabled = loggedOut || authRole === "user" || lastRigIds.length === 0;
}
}
@@ -1464,7 +1461,7 @@ function applyRigList(activeRigId: string | null, rigIds: string[], displayNames
}
const nextKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || "");
const rigListChanged = prevKey !== nextKey;
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx";
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "user";
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
updateRigSubtitle(lastActiveRigId);
window.trxUi?.setActiveRig(lastActiveRigId);
@@ -3352,7 +3349,7 @@ function scheduleTuneLinkSync() {
async function applyTuneLink(link: TuneLink) {
const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null;
if (!wanted) return;
if (authRole === "rx") {
if (authRole === "user") {
showHint("Read-only session — link not applied", 2500);
return;
}
@@ -4191,8 +4188,8 @@ async function switchRigFromSelect(selectEl: HTMLSelectElement) {
showHint("No rig selected", 1500);
return;
}
if (authRole === "rx") {
showHint("Control role required", 1500);
if (authRole === "user") {
showHint("Admin role required", 1500);
return;
}
if (!lastRigIds.includes(selectEl.value)) {
@@ -4875,7 +4872,7 @@ function navigateToTab(name: TabName, options: { updateHistory?: boolean; replac
const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
const { updateHistory = true, replaceHistory = false } = options;
if (authEnabled && !authRole && name !== "main") {
showAuthGate(false);
showAuthGate();
return;
}
const btn = document.querySelector<HTMLElement>(`.tab-bar .tab[data-tab="${name}"]`);
@@ -5008,12 +5005,12 @@ window.addEventListener("resize", () => { scheduleSpectrumLayout(); });
// --- Auth startup sequence ---
async function initializeApp() {
showAuthGate(false);
showAuthGate();
const authStatus = await checkAuthStatus();
authEnabled = !authStatus.auth_disabled;
if (!authEnabled) {
authRole = "control";
authRole = "admin";
hideAuthGate();
updateAuthUI();
connect();
@@ -5027,6 +5024,7 @@ async function initializeApp() {
if (authStatus.authenticated) {
// User has valid session
authRole = authStatus.role ?? null;
authUsername = authStatus.username ?? null;
hideAuthGate();
updateAuthUI();
applyAuthRestrictions();
@@ -5036,10 +5034,7 @@ async function initializeApp() {
resizeHeaderSignalCanvas();
startHeaderSignalSampling();
} else {
// No valid session - show auth gate
// Guest button is shown if guest mode is available (role granted without auth)
const allowGuest = authStatus.role === "rx";
showAuthGate(allowGuest);
showAuthGate();
}
}
@@ -5055,22 +5050,96 @@ function initSettingsUI() {
window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRole);
window.trx.modules.backgroundDecode.wireEvents();
}
void refreshUserManagement();
}
async function refreshUserManagement() {
const section = document.getElementById("user-management");
if (!section) return;
section.style.display = authEnabled && authRole === "admin" ? "block" : "none";
if (section.style.display === "none") return;
const list = requiredElement("user-list");
try {
const users = await listUsers();
list.replaceChildren(...users.map((user) => {
const row = document.createElement("div");
row.className = "sch-row";
row.style.cssText = "display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;margin:.4rem 0";
const name = document.createElement("strong");
name.textContent = user.username;
name.style.minWidth = "10rem";
const role = document.createElement("select");
role.className = "auth-input";
for (const value of ["user", "admin"] as AuthRole[]) {
const option = document.createElement("option"); option.value = value; option.textContent = value === "admin" ? "Admin" : "User"; option.selected = user.role === value; role.append(option);
}
const password = document.createElement("input");
password.type = "password"; password.placeholder = "New password"; password.autocomplete = "new-password"; password.className = "auth-input"; password.minLength = 8;
const save = document.createElement("button"); save.type = "button"; save.textContent = "Save";
save.addEventListener("click", async () => {
const changes: { role?: AuthRole; password?: string } = { role: role.value as AuthRole };
if (password.value) changes.password = password.value;
await runUserOperation(() => updateUser(user.username, changes));
});
const remove = document.createElement("button"); remove.type = "button"; remove.textContent = "Remove"; remove.className = "danger";
remove.disabled = user.username === authUsername;
remove.addEventListener("click", async () => {
if (await window.trxUi.confirm({ title: "Remove user?", message: `Remove ${user.username} and revoke their sessions?`, confirmLabel: "Remove", danger: true })) {
await runUserOperation(() => deleteUser(user.username));
}
});
row.append(name, role, password, save, remove);
return row;
}));
} catch (error) {
showUserManagementError(error);
}
}
function showUserManagementError(error: unknown) {
const element = document.getElementById("user-management-error");
if (!element) return;
element.textContent = error instanceof Error ? error.message : String(error);
element.style.display = "block";
}
async function runUserOperation(operation: () => Promise<void>) {
try {
await operation();
const error = document.getElementById("user-management-error");
if (error) error.style.display = "none";
await refreshUserManagement();
} catch (reason) {
showUserManagementError(reason);
}
}
document.getElementById("user-create-form")?.addEventListener("submit", (event) => {
event.preventDefault();
const username = requiredElement<HTMLInputElement>("user-create-username");
const password = requiredElement<HTMLInputElement>("user-create-password");
const role = requiredElement<HTMLSelectElement>("user-create-role");
void runUserOperation(async () => {
await createUser(username.value, password.value, role.value as AuthRole);
username.value = ""; password.value = ""; role.value = "user";
});
});
// Setup auth form
requiredElement<HTMLFormElement>("auth-form").addEventListener("submit", async (e) => {
e.preventDefault();
const passphraseEl = requiredElement<HTMLInputElement>("auth-passphrase");
const passphrase = passphraseEl.value;
const usernameEl = requiredElement<HTMLInputElement>("auth-username");
const passwordEl = requiredElement<HTMLInputElement>("auth-password");
const btn = requiredElement<HTMLFormElement>("auth-form").querySelector<HTMLButtonElement>("button[type=submit]");
if (!btn) return;
btn.disabled = true;
btn.textContent = "Logging in...";
try {
const result = await authLogin(passphrase);
const result = await authLogin(usernameEl.value, passwordEl.value);
authRole = result.role ?? null;
passphraseEl.value = "";
authUsername = result.username ?? usernameEl.value;
passwordEl.value = "";
hideAuthGate();
updateAuthUI();
applyAuthRestrictions();
@@ -5080,7 +5149,7 @@ requiredElement<HTMLFormElement>("auth-form").addEventListener("submit", async (
resizeHeaderSignalCanvas();
startHeaderSignalSampling();
} catch (err) {
showAuthError("Invalid passphrase");
showAuthError("Invalid username or password");
console.error("Login error:", err);
} finally {
btn.disabled = false;
@@ -5088,23 +5157,6 @@ requiredElement<HTMLFormElement>("auth-form").addEventListener("submit", async (
}
});
// Setup guest button
const guestBtn = document.getElementById("auth-guest-btn") as HTMLButtonElement | null;
if (guestBtn) {
guestBtn.addEventListener("click", () => {
authRole = "rx";
requiredElement<HTMLInputElement>("auth-passphrase").value = "";
hideAuthGate();
updateAuthUI();
applyAuthRestrictions();
connect();
connectDecode();
initSettingsUI();
resizeHeaderSignalCanvas();
startHeaderSignalSampling();
});
}
// Setup header auth button (Login/Logout)
const headerAuthBtn = document.getElementById("header-auth-btn") as HTMLButtonElement | null;
if (headerAuthBtn) {
@@ -5116,7 +5168,7 @@ if (headerAuthBtn) {
}
} else {
// Not logged in - show auth gate
showAuthGate(false);
showAuthGate();
}
});
}
@@ -468,7 +468,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
}
function isControlRole(): boolean {
return backgroundDecodeRole === "control" || hostState.authEnabled === false;
return backgroundDecodeRole === "admin" || hostState.authEnabled === false;
}
function showToast(msg: string, isError: boolean): void {
@@ -100,7 +100,7 @@ function bmEsc(str: unknown): string {
}
function bmCanControl() {
return !hostState.authEnabled || hostState.authRole === "control";
return !hostState.authEnabled || hostState.authRole === "admin";
}
// Show/hide the Add Bookmark / Select All buttons based on the current auth role.
@@ -356,7 +356,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
if (!prevBtn || !nextBtn) return;
const state = schedulerInterleaveState(currentConfig);
const enabled =
schedulerRole === "control" &&
schedulerRole === "admin" &&
!!currentRigId &&
!schedulerStepPending &&
state.activeEntries.length > 1;
@@ -466,7 +466,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
if (!panel) return;
const mode = (currentConfig && currentConfig.mode) || "disabled";
const isControl = schedulerRole === "control";
const isControl = schedulerRole === "admin";
// Mode selector
setSelected("scheduler-mode-select", mode);
@@ -40,7 +40,7 @@ test("background decode loads configuration for the explicitly selected rig", as
const source = await bundleEntry(new URL("../src/plugins/background-decode.ts", import.meta.url));
new vm.Script(source).runInContext(context);
window.trx.modules.backgroundDecode.initialize("rig/a", "control");
window.trx.modules.backgroundDecode.initialize("rig/a", "admin");
await new Promise((resolve) => setTimeout(resolve, 0));
assert.ok(requested.includes("/background-decode/rig%2Fa"));
assert.ok(requested.includes("/bookmarks"));
@@ -32,7 +32,7 @@ function hostFixture(overrides = {}) {
const calls = { postPath: [], setRigFrequency: [], armOptimisticFrequency: [], applyLocalTunedFrequency: [], syncBandwidthInput: [], scheduleSpectrumDraw: 0, syncModePicker: 0 };
const state = {
authEnabled: false,
authRole: "control",
authRole: "admin",
lastActiveRigId: null,
lastRigIds: [],
lastRigDisplayNames: {},
@@ -157,7 +157,7 @@ test("bookmark controls follow the host authentication state", async () => {
if (!elements.has(id)) elements.set(id, new ElementFixture());
return elements.get(id);
};
const { window } = hostFixture({ authEnabled: true, authRole: "rx" });
const { window } = hostFixture({ authEnabled: true, authRole: "user" });
const context = vm.createContext({
window,
document: documentFixture(element),
@@ -171,7 +171,7 @@ test("bookmark controls follow the host authentication state", async () => {
assert.equal(element("bm-add-btn").style.display, "none");
window.trx.state.authRole = "control";
window.trx.state.authRole = "admin";
await window.trx.modules.bookmarks.fetch("");
assert.equal(element("bm-add-btn").style.display, "");
});
@@ -19,7 +19,7 @@ export function createHost({ state = {}, core = {}, modules = {} } = {}) {
serverLat: null,
serverLon: null,
authEnabled: false,
authRole: "control",
authRole: "admin",
lastActiveRigId: null,
lastRigIds: [],
lastRigDisplayNames: {},
@@ -76,7 +76,7 @@ test("scheduler self-initializes for the active rig when a role is already known
return elements.get(id);
};
const window = {
...createHost({ state: { authRole: "control", lastActiveRigId: "sdr" } }),
...createHost({ state: { authRole: "admin", lastActiveRigId: "sdr" } }),
trxUi: { confirm: async () => true },
};
const context = vm.createContext({
@@ -232,7 +232,7 @@ export async function startWebFixture({
};
const jsonRoutes = new Map([
["/auth/session", { authenticated: true, role: "control", auth_disabled: true }],
["/auth/session", { authenticated: true, role: "admin", auth_disabled: true }],
["/decoders", DECODER_REGISTRY],
["/rigs", rigsResponse],
["/status", status],
@@ -399,8 +399,8 @@ fn require_control(
return Ok(());
}
match crate::server::auth::get_session_role(req, auth_state) {
Some(crate::server::auth::AuthRole::Control) => Ok(()),
_ => Err(actix_web::error::ErrorForbidden("control role required")),
Some(crate::server::auth::AuthRole::Admin) => Ok(()),
_ => Err(actix_web::error::ErrorForbidden("admin role required")),
}
}
@@ -709,6 +709,10 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(crate::server::auth::login)
.service(crate::server::auth::logout)
.service(crate::server::auth::session_status)
.service(crate::server::auth::list_users)
.service(crate::server::auth::create_user)
.service(crate::server::auth::update_user)
.service(crate::server::auth::delete_user)
// Logbook
.service(logbook::list_qsos)
.service(logbook::add_qso)
@@ -949,30 +953,33 @@ mod tests {
);
}
/// Auth off: every session may write, as a station with no passphrase set.
/// Auth off: every session may write without an account.
fn auth_state_disabled() -> crate::server::auth::AuthState {
crate::server::auth::AuthState::new(crate::server::auth::AuthConfig::new(
false,
std::path::PathBuf::from("unused-users.json"),
None,
None,
false,
std::time::Duration::from_secs(3600),
false,
crate::server::auth::SameSite::Lax,
))
.unwrap()
}
/// Auth on with no session presented, which is what a listener is.
fn auth_state_locked() -> crate::server::auth::AuthState {
let directory = tempfile::tempdir().unwrap();
crate::server::auth::AuthState::new(crate::server::auth::AuthConfig::new(
true,
Some("listen".to_string()),
Some("control".to_string()),
false,
directory.path().join("users.json"),
Some("admin".to_string()),
Some("password123".to_string()),
std::time::Duration::from_secs(3600),
false,
crate::server::auth::SameSite::Lax,
))
.unwrap()
}
/// A contact written over HTTP comes back out of the log, and out of an
@@ -4,18 +4,24 @@
//! HTTP authentication module for trx-frontend-http.
//!
//! Provides optional session-based authentication with two roles:
//! - `Rx`: read-only access to status/events/audio
//! - `Control`: full access including TX/PTT control
//! Provides optional session-based authentication backed by a persistent,
//! administrator-managed user database.
use actix_web::{
cookie::Cookie,
delete,
dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
get, post, web, Error, HttpRequest, HttpResponse, Responder,
get, patch, post, web, Error, HttpRequest, HttpResponse, Responder,
};
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
use futures_util::future::LocalBoxFuture;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, RwLock};
use std::time::{Duration, Instant, SystemTime};
use tracing::warn;
@@ -27,17 +33,17 @@ pub type SessionId = String;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AuthRole {
/// Read-only access (rx passphrase)
Rx,
/// Full control access (control passphrase)
Control,
/// Read-only access.
User,
/// Full control and user-management access.
Admin,
}
impl AuthRole {
pub fn as_str(&self) -> &'static str {
match self {
Self::Rx => "rx",
Self::Control => "control",
Self::User => "user",
Self::Admin => "admin",
}
}
}
@@ -45,6 +51,7 @@ impl AuthRole {
/// Session record stored in the session store
#[derive(Debug, Clone)]
pub struct SessionRecord {
pub username: String,
pub role: AuthRole,
pub issued_at: SystemTime,
pub expires_at: SystemTime,
@@ -75,12 +82,13 @@ impl SessionStore {
}
/// Create a new session with the given role and TTL
pub fn create(&self, role: AuthRole, ttl: Duration) -> SessionId {
pub fn create(&self, username: String, role: AuthRole, ttl: Duration) -> SessionId {
let now = SystemTime::now();
let expires_at = now + ttl;
let session_id = Self::generate_session_id();
let record = SessionRecord {
username,
role,
issued_at: now,
expires_at,
@@ -121,6 +129,12 @@ impl SessionStore {
store.remove(session_id);
}
/// Invalidate every session belonging to a user.
pub fn remove_user(&self, username: &str) {
let mut store = self.sessions.write().unwrap_or_else(|e| e.into_inner());
store.retain(|_, record| record.username != username);
}
/// Remove all expired sessions
pub fn cleanup_expired(&self) {
let mut store = self.sessions.write().unwrap_or_else(|e| {
@@ -167,9 +181,9 @@ impl SameSite {
#[derive(Debug, Clone)]
pub struct AuthConfig {
pub enabled: bool,
pub rx_passphrase: Option<String>,
pub control_passphrase: Option<String>,
pub tx_access_control_enabled: bool,
pub users_file: PathBuf,
pub bootstrap_admin_username: Option<String>,
pub bootstrap_admin_password: Option<String>,
pub session_ttl: Duration,
pub cookie_secure: bool,
pub cookie_same_site: SameSite,
@@ -179,39 +193,236 @@ impl AuthConfig {
/// Create a new auth config with all fields
pub fn new(
enabled: bool,
rx_passphrase: Option<String>,
control_passphrase: Option<String>,
tx_access_control_enabled: bool,
users_file: PathBuf,
bootstrap_admin_username: Option<String>,
bootstrap_admin_password: Option<String>,
session_ttl: Duration,
cookie_secure: bool,
cookie_same_site: SameSite,
) -> Self {
Self {
enabled,
rx_passphrase,
control_passphrase,
tx_access_control_enabled,
users_file,
bootstrap_admin_username,
bootstrap_admin_password,
session_ttl,
cookie_secure,
cookie_same_site,
}
}
}
/// Check passphrase and return the corresponding role
pub fn check_passphrase(&self, passphrase: &str) -> Option<AuthRole> {
// Use constant-time comparison to reduce timing attacks
if let Some(ctrl_pass) = &self.control_passphrase {
if constant_time_eq(passphrase, ctrl_pass) {
return Some(AuthRole::Control);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserRecord {
pub username: String,
pub password_hash: String,
pub role: AuthRole,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct UserDatabase {
users: Vec<UserRecord>,
}
/// Persistent user database. Passwords are Argon2id hashes; writes replace the
/// database atomically so an interrupted update cannot truncate it.
pub struct UserStore {
path: PathBuf,
users: RwLock<HashMap<String, UserRecord>>,
}
impl UserStore {
fn open(config: &AuthConfig) -> Result<Self, String> {
if !config.enabled {
return Ok(Self {
path: config.users_file.clone(),
users: RwLock::new(HashMap::new()),
});
}
let records = if config.users_file.exists() {
let bytes = fs::read(&config.users_file)
.map_err(|e| format!("read {}: {e}", config.users_file.display()))?;
let db: UserDatabase = serde_json::from_slice(&bytes)
.map_err(|e| format!("parse {}: {e}", config.users_file.display()))?;
db.users
} else {
let username = config.bootstrap_admin_username.as_deref().ok_or_else(|| {
format!(
"user database {} does not exist and no bootstrap administrator was configured",
config.users_file.display()
)
})?;
let password = config
.bootstrap_admin_password
.as_deref()
.ok_or_else(|| "bootstrap administrator password is missing".to_string())?;
vec![UserRecord {
username: validate_username(username)?.to_string(),
password_hash: hash_password(password)?,
role: AuthRole::Admin,
}]
};
let mut users = HashMap::new();
for record in records {
validate_username(&record.username)?;
if users.insert(record.username.clone(), record).is_some() {
return Err("user database contains duplicate usernames".to_string());
}
}
if let Some(rx_pass) = &self.rx_passphrase {
if constant_time_eq(passphrase, rx_pass) {
return Some(AuthRole::Rx);
}
if !users.values().any(|u| u.role == AuthRole::Admin) {
return Err("user database must contain at least one administrator".to_string());
}
None
let store = Self {
path: config.users_file.clone(),
users: RwLock::new(users),
};
if !store.path.exists() {
store.persist()?;
}
Ok(store)
}
fn authenticate(&self, username: &str, password: &str) -> Option<AuthRole> {
let users = self.users.read().unwrap_or_else(|e| e.into_inner());
let record = users.get(username)?;
let parsed = PasswordHash::new(&record.password_hash).ok()?;
Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.ok()?;
Some(record.role)
}
fn list(&self) -> Vec<ManagedUser> {
let users = self.users.read().unwrap_or_else(|e| e.into_inner());
let mut result: Vec<_> = users
.values()
.map(|u| ManagedUser {
username: u.username.clone(),
role: u.role,
})
.collect();
result.sort_by(|a, b| a.username.cmp(&b.username));
result
}
fn add(&self, username: &str, password: &str, role: AuthRole) -> Result<(), String> {
let username = validate_username(username)?.to_string();
validate_password(password)?;
let record = UserRecord {
username: username.clone(),
password_hash: hash_password(password)?,
role,
};
let mut users = self.users.write().unwrap_or_else(|e| e.into_inner());
if users.contains_key(&username) {
return Err("user already exists".to_string());
}
users.insert(username.clone(), record);
if let Err(error) = persist_users(&self.path, &users) {
users.remove(&username);
return Err(error);
}
Ok(())
}
fn update(
&self,
username: &str,
password: Option<&str>,
role: Option<AuthRole>,
) -> Result<(), String> {
if let Some(value) = password {
validate_password(value)?;
}
let new_hash = password.map(hash_password).transpose()?;
let mut users = self.users.write().unwrap_or_else(|e| e.into_inner());
let old = users
.get(username)
.cloned()
.ok_or_else(|| "user not found".to_string())?;
if old.role == AuthRole::Admin
&& role == Some(AuthRole::User)
&& users.values().filter(|u| u.role == AuthRole::Admin).count() == 1
{
return Err("cannot demote the last administrator".to_string());
}
let record = users.get_mut(username).expect("checked above");
if let Some(hash) = new_hash {
record.password_hash = hash;
}
if let Some(value) = role {
record.role = value;
}
if let Err(error) = persist_users(&self.path, &users) {
users.insert(username.to_string(), old);
return Err(error);
}
Ok(())
}
fn remove(&self, username: &str) -> Result<(), String> {
let mut users = self.users.write().unwrap_or_else(|e| e.into_inner());
let old = users
.get(username)
.cloned()
.ok_or_else(|| "user not found".to_string())?;
if old.role == AuthRole::Admin
&& users.values().filter(|u| u.role == AuthRole::Admin).count() == 1
{
return Err("cannot remove the last administrator".to_string());
}
users.remove(username);
if let Err(error) = persist_users(&self.path, &users) {
users.insert(username.to_string(), old);
return Err(error);
}
Ok(())
}
fn persist(&self) -> Result<(), String> {
let users = self.users.read().unwrap_or_else(|e| e.into_inner());
persist_users(&self.path, &users)
}
}
fn persist_users(path: &Path, users: &HashMap<String, UserRecord>) -> Result<(), String> {
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?;
}
let mut records: Vec<_> = users.values().cloned().collect();
records.sort_by(|a, b| a.username.cmp(&b.username));
let bytes =
serde_json::to_vec_pretty(&UserDatabase { users: records }).map_err(|e| e.to_string())?;
let temporary = path.with_extension("tmp");
fs::write(&temporary, bytes).map_err(|e| format!("write {}: {e}", temporary.display()))?;
fs::rename(&temporary, path).map_err(|e| format!("replace {}: {e}", path.display()))
}
fn hash_password(password: &str) -> Result<String, String> {
validate_password(password)?;
Argon2::default()
.hash_password(password.as_bytes(), &SaltString::generate(&mut OsRng))
.map(|hash| hash.to_string())
.map_err(|e| format!("password hashing failed: {e}"))
}
fn validate_username(username: &str) -> Result<&str, String> {
if username.is_empty()
|| username.len() > 64
|| !username
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
{
return Err("username must be 1-64 ASCII letters, digits, '.', '-' or '_'".to_string());
}
Ok(username)
}
fn validate_password(password: &str) -> Result<(), String> {
if password.len() < 8 {
return Err("password must contain at least 8 characters".to_string());
}
Ok(())
}
/// Simple per-IP rate limiter for login attempts.
@@ -283,39 +494,27 @@ impl Default for LoginRateLimiter {
pub struct AuthState {
pub config: AuthConfig,
pub store: SessionStore,
pub users: UserStore,
pub rate_limiter: LoginRateLimiter,
}
impl AuthState {
pub fn new(config: AuthConfig) -> Self {
Self {
pub fn new(config: AuthConfig) -> Result<Self, String> {
let users = UserStore::open(&config)?;
Ok(Self {
config,
store: SessionStore::new(),
users,
rate_limiter: LoginRateLimiter::default(),
}
})
}
}
/// Constant-time string comparison to mitigate timing attacks
fn constant_time_eq(a: &str, b: &str) -> bool {
let a_bytes = a.as_bytes();
let b_bytes = b.as_bytes();
if a_bytes.len() != b_bytes.len() {
return false;
}
let mut result = 0u8;
for (x, y) in a_bytes.iter().zip(b_bytes.iter()) {
result |= x ^ y;
}
result == 0
}
/// Login request body
#[derive(Debug, Deserialize)]
pub struct LoginRequest {
pub passphrase: String,
pub username: String,
pub password: String,
}
/// Session status response
@@ -323,6 +522,8 @@ pub struct LoginRequest {
pub struct SessionStatus {
pub authenticated: bool,
pub role: Option<String>,
pub username: Option<String>,
pub auth_disabled: bool,
}
/// Login response
@@ -330,6 +531,26 @@ pub struct SessionStatus {
pub struct LoginResponse {
pub authenticated: bool,
pub role: String,
pub username: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct ManagedUser {
pub username: String,
pub role: AuthRole,
}
#[derive(Debug, Deserialize)]
pub struct CreateUserRequest {
pub username: String,
pub password: String,
pub role: AuthRole,
}
#[derive(Debug, Deserialize)]
pub struct UpdateUserRequest {
pub password: Option<String>,
pub role: Option<AuthRole>,
}
/// Extract session from cookie
@@ -345,6 +566,20 @@ pub fn get_session_role(req: &HttpRequest, auth_state: &AuthState) -> Option<Aut
Some(record.role)
}
fn require_admin(req: &HttpRequest, auth_state: &AuthState) -> Result<SessionRecord, HttpResponse> {
let session = extract_session_id(req)
.and_then(|id| auth_state.store.get(&id))
.ok_or_else(|| {
HttpResponse::Unauthorized()
.json(serde_json::json!({"error":"authentication required"}))
})?;
if session.role != AuthRole::Admin {
return Err(HttpResponse::Forbidden()
.json(serde_json::json!({"error":"administrator role required"})));
}
Ok(session)
}
// ============================================================================
// Endpoints
// ============================================================================
@@ -371,8 +606,10 @@ pub async fn login(
})));
}
// Check passphrase
let role = match auth_state.config.check_passphrase(&body.passphrase) {
let role = match auth_state
.users
.authenticate(&body.username, &body.password)
{
Some(r) => r,
None => {
return Ok(HttpResponse::Unauthorized().json(serde_json::json!({
@@ -385,7 +622,10 @@ pub async fn login(
auth_state.rate_limiter.reset(&peer_ip);
// Create session
let session_id = auth_state.store.create(role, auth_state.config.session_ttl);
let session_id =
auth_state
.store
.create(body.username.clone(), role, auth_state.config.session_ttl);
let mut cookie = Cookie::new("trx_http_sid", session_id);
cookie.set_path("/");
@@ -406,6 +646,7 @@ pub async fn login(
Ok(HttpResponse::Ok().cookie(cookie).json(LoginResponse {
authenticated: true,
role: role.as_str().to_string(),
username: body.username.clone(),
}))
}
@@ -445,7 +686,9 @@ pub async fn session_status(
if !auth_state.config.enabled {
return Ok(HttpResponse::Ok().json(SessionStatus {
authenticated: true,
role: Some("control".to_string()),
role: Some("admin".to_string()),
username: None,
auth_disabled: true,
}));
}
@@ -455,15 +698,8 @@ pub async fn session_status(
return Ok(HttpResponse::Ok().json(SessionStatus {
authenticated: true,
role: Some(session_record.role.as_str().to_string()),
}));
}
// No session - check if rx access is unrestricted
if auth_state.config.rx_passphrase.is_none() {
// No rx passphrase required - grant rx role to unauthenticated users
return Ok(HttpResponse::Ok().json(SessionStatus {
authenticated: false,
role: Some("rx".to_string()),
username: Some(session_record.username),
auth_disabled: false,
}));
}
@@ -471,9 +707,93 @@ pub async fn session_status(
Ok(HttpResponse::Ok().json(SessionStatus {
authenticated: false,
role: None,
username: None,
auth_disabled: false,
}))
}
/// GET /auth/users
#[get("/auth/users")]
pub async fn list_users(req: HttpRequest, auth_state: web::Data<AuthState>) -> impl Responder {
if let Err(response) = require_admin(&req, &auth_state) {
return response;
}
HttpResponse::Ok().json(auth_state.users.list())
}
/// POST /auth/users
#[post("/auth/users")]
pub async fn create_user(
req: HttpRequest,
body: web::Json<CreateUserRequest>,
auth_state: web::Data<AuthState>,
) -> impl Responder {
if let Err(response) = require_admin(&req, &auth_state) {
return response;
}
match auth_state
.users
.add(&body.username, &body.password, body.role)
{
Ok(()) => HttpResponse::Created().json(ManagedUser {
username: body.username.clone(),
role: body.role,
}),
Err(error) => HttpResponse::BadRequest().json(serde_json::json!({"error": error})),
}
}
/// PATCH /auth/users/{username}
#[patch("/auth/users/{username}")]
pub async fn update_user(
req: HttpRequest,
username: web::Path<String>,
body: web::Json<UpdateUserRequest>,
auth_state: web::Data<AuthState>,
) -> impl Responder {
if let Err(response) = require_admin(&req, &auth_state) {
return response;
}
if body.password.is_none() && body.role.is_none() {
return HttpResponse::BadRequest()
.json(serde_json::json!({"error":"password or role is required"}));
}
match auth_state
.users
.update(&username, body.password.as_deref(), body.role)
{
Ok(()) => {
auth_state.store.remove_user(&username);
HttpResponse::Ok().json(serde_json::json!({"updated": true}))
}
Err(error) => HttpResponse::BadRequest().json(serde_json::json!({"error": error})),
}
}
/// DELETE /auth/users/{username}
#[delete("/auth/users/{username}")]
pub async fn delete_user(
req: HttpRequest,
username: web::Path<String>,
auth_state: web::Data<AuthState>,
) -> impl Responder {
let admin = match require_admin(&req, &auth_state) {
Ok(value) => value,
Err(response) => return response,
};
if admin.username == *username {
return HttpResponse::BadRequest()
.json(serde_json::json!({"error":"administrators cannot remove their own account"}));
}
match auth_state.users.remove(&username) {
Ok(()) => {
auth_state.store.remove_user(&username);
HttpResponse::Ok().json(serde_json::json!({"deleted": true}))
}
Err(error) => HttpResponse::BadRequest().json(serde_json::json!({"error": error})),
}
}
// ============================================================================
// Middleware
// ============================================================================
@@ -483,9 +803,9 @@ pub async fn session_status(
enum RouteAccess {
/// Publicly accessible (no auth required)
Public,
/// Read-only (rx or control role required)
/// Read-only (user or admin role required)
Read,
/// Control only (control role required)
/// Control only (admin role required)
Control,
}
@@ -499,7 +819,9 @@ impl RouteAccess {
|| path == "/digital-modes"
|| path == "/settings"
|| path == "/about"
|| path.starts_with("/auth/")
|| path == "/auth/login"
|| path == "/auth/logout"
|| path == "/auth/session"
{
return Self::Public;
}
@@ -561,7 +883,7 @@ impl RouteAccess {
match self {
Self::Public => true,
Self::Read => role.is_some(),
Self::Control => matches!(role, Some(AuthRole::Control)),
Self::Control => matches!(role, Some(AuthRole::Admin)),
}
}
}
@@ -631,19 +953,6 @@ where
// Auth enabled - check role
let role = get_session_role(req.request(), &auth_state);
// If rx_passphrase is not set, allow unauthenticated read access
let allow_unrestricted_read = auth_state.config.rx_passphrase.is_none();
let is_read_route = access == RouteAccess::Read;
if is_read_route && allow_unrestricted_read {
// No rx authentication required - allow read access without role
let fut = self.service.call(req);
return Box::pin(async move {
let res = fut.await?;
Ok(res)
});
}
if !access.allows(role) {
// Access denied
return Box::pin(async move {
@@ -652,12 +961,6 @@ where
Err(actix_web::error::ErrorForbidden(
"Insufficient permissions".to_string(),
))
} else if allow_unrestricted_read {
// No session but rx access is unrestricted - 403 Forbidden
// (user has implicit rx role from unrestricted access)
Err(actix_web::error::ErrorForbidden(
"Insufficient permissions".to_string(),
))
} else {
// No session and no unrestricted access - 401 Unauthorized
Err(actix_web::error::ErrorUnauthorized(
@@ -676,18 +979,10 @@ where
}
}
/// Check if a path is a TX/PTT endpoint (used for TX access control).
pub fn is_tx_endpoint(path: &str) -> bool {
path.contains("ptt")
|| path.contains("set_ptt")
|| path.contains("toggle_ptt")
|| path.contains("set_tx")
|| path.contains("toggle_tx")
}
#[cfg(test)]
mod tests {
use super::*;
use actix_web::{test as aw_test, App};
#[test]
fn test_route_access_public_paths() {
@@ -701,6 +996,7 @@ mod tests {
assert_eq!(RouteAccess::from_path("/about"), RouteAccess::Public);
assert_eq!(RouteAccess::from_path("/auth/login"), RouteAccess::Public);
assert_eq!(RouteAccess::from_path("/auth/logout"), RouteAccess::Public);
assert_eq!(RouteAccess::from_path("/auth/users"), RouteAccess::Control);
assert_eq!(RouteAccess::from_path("/style.css"), RouteAccess::Public);
assert_eq!(RouteAccess::from_path("/app.js"), RouteAccess::Public);
// Static reference data, served to every role: ".json" is not in the
@@ -735,28 +1031,29 @@ mod tests {
#[test]
fn test_route_access_allows() {
assert!(RouteAccess::Public.allows(None));
assert!(RouteAccess::Public.allows(Some(AuthRole::Rx)));
assert!(RouteAccess::Public.allows(Some(AuthRole::Control)));
assert!(RouteAccess::Public.allows(Some(AuthRole::User)));
assert!(RouteAccess::Public.allows(Some(AuthRole::Admin)));
assert!(!RouteAccess::Read.allows(None));
assert!(RouteAccess::Read.allows(Some(AuthRole::Rx)));
assert!(RouteAccess::Read.allows(Some(AuthRole::Control)));
assert!(RouteAccess::Read.allows(Some(AuthRole::User)));
assert!(RouteAccess::Read.allows(Some(AuthRole::Admin)));
assert!(!RouteAccess::Control.allows(None));
assert!(!RouteAccess::Control.allows(Some(AuthRole::Rx)));
assert!(RouteAccess::Control.allows(Some(AuthRole::Control)));
assert!(!RouteAccess::Control.allows(Some(AuthRole::User)));
assert!(RouteAccess::Control.allows(Some(AuthRole::Admin)));
}
#[test]
fn test_session_store_create_and_get() {
let store = SessionStore::new();
let ttl = Duration::from_secs(3600);
let session_id = store.create(AuthRole::Rx, ttl);
let session_id = store.create("alice".to_string(), AuthRole::User, ttl);
let record = store.get(&session_id);
assert!(record.is_some());
let record = record.unwrap();
assert_eq!(record.role, AuthRole::Rx);
assert_eq!(record.username, "alice");
assert_eq!(record.role, AuthRole::User);
assert!(!record.is_expired());
}
@@ -764,81 +1061,151 @@ mod tests {
fn test_session_store_remove() {
let store = SessionStore::new();
let ttl = Duration::from_secs(3600);
let session_id = store.create(AuthRole::Rx, ttl);
let session_id = store.create("alice".to_string(), AuthRole::User, ttl);
store.remove(&session_id);
assert!(store.get(&session_id).is_none());
}
#[test]
fn test_constant_time_eq() {
assert!(constant_time_eq("test", "test"));
assert!(!constant_time_eq("test", "fail"));
assert!(!constant_time_eq("test", "test2"));
assert!(!constant_time_eq("", "test"));
}
#[test]
fn test_auth_config_check_passphrase_control() {
let config = AuthConfig {
enabled: true,
rx_passphrase: None,
control_passphrase: Some("ctrl-pass".to_string()),
tx_access_control_enabled: true,
session_ttl: Duration::from_secs(3600),
cookie_secure: false,
cookie_same_site: SameSite::Lax,
};
assert_eq!(
config.check_passphrase("ctrl-pass"),
Some(AuthRole::Control)
fn user_store_bootstraps_and_manages_users() {
let directory = tempfile::tempdir().unwrap();
let config = AuthConfig::new(
true,
directory.path().join("users.json"),
Some("admin".to_string()),
Some("password123".to_string()),
Duration::from_secs(3600),
false,
SameSite::Lax,
);
assert_eq!(config.check_passphrase("wrong"), None);
}
#[test]
fn test_auth_config_check_passphrase_rx() {
let config = AuthConfig {
enabled: true,
rx_passphrase: Some("rx-pass".to_string()),
control_passphrase: None,
tx_access_control_enabled: true,
session_ttl: Duration::from_secs(3600),
cookie_secure: false,
cookie_same_site: SameSite::Lax,
};
assert_eq!(config.check_passphrase("rx-pass"), Some(AuthRole::Rx));
assert_eq!(config.check_passphrase("wrong"), None);
}
#[test]
fn test_auth_config_check_passphrase_both() {
let config = AuthConfig {
enabled: true,
rx_passphrase: Some("rx-pass".to_string()),
control_passphrase: Some("ctrl-pass".to_string()),
tx_access_control_enabled: true,
session_ttl: Duration::from_secs(3600),
cookie_secure: false,
cookie_same_site: SameSite::Lax,
};
// Control is checked first
let users = UserStore::open(&config).unwrap();
assert_eq!(
config.check_passphrase("ctrl-pass"),
Some(AuthRole::Control)
users.authenticate("admin", "password123"),
Some(AuthRole::Admin)
);
assert_eq!(config.check_passphrase("rx-pass"), Some(AuthRole::Rx));
assert_eq!(config.check_passphrase("wrong"), None);
assert_eq!(users.authenticate("admin", "wrong"), None);
users.add("alice", "password456", AuthRole::User).unwrap();
assert_eq!(
users.authenticate("alice", "password456"),
Some(AuthRole::User)
);
users
.update("alice", Some("password789"), Some(AuthRole::Admin))
.unwrap();
assert_eq!(
users.authenticate("alice", "password789"),
Some(AuthRole::Admin)
);
users.remove("admin").unwrap();
assert_eq!(users.list().len(), 1);
drop(users);
let mut reopen_config = config.clone();
reopen_config.bootstrap_admin_username = None;
reopen_config.bootstrap_admin_password = None;
let reopened = UserStore::open(&reopen_config).unwrap();
assert_eq!(
reopened.authenticate("alice", "password789"),
Some(AuthRole::Admin)
);
let database = fs::read_to_string(&reopen_config.users_file).unwrap();
assert!(database.contains("$argon2"));
assert!(!database.contains("password789"));
}
#[test]
fn test_is_tx_endpoint() {
assert!(is_tx_endpoint("/set_ptt"));
assert!(is_tx_endpoint("/toggle_ptt"));
assert!(is_tx_endpoint("/set_tx"));
assert!(!is_tx_endpoint("/status"));
fn user_store_protects_last_admin() {
let directory = tempfile::tempdir().unwrap();
let config = AuthConfig::new(
true,
directory.path().join("users.json"),
Some("admin".to_string()),
Some("password123".to_string()),
Duration::from_secs(3600),
false,
SameSite::Lax,
);
let users = UserStore::open(&config).unwrap();
assert!(users.remove("admin").is_err());
assert!(users.update("admin", None, Some(AuthRole::User)).is_err());
}
#[actix_web::test]
async fn admin_endpoints_manage_multiple_users_and_reject_regular_users() {
let directory = tempfile::tempdir().unwrap();
let config = AuthConfig::new(
true,
directory.path().join("users.json"),
Some("admin".to_string()),
Some("password123".to_string()),
Duration::from_secs(3600),
false,
SameSite::Lax,
);
let state = web::Data::new(AuthState::new(config).unwrap());
let app = aw_test::init_service(
App::new()
.app_data(state)
.service(login)
.service(list_users)
.service(create_user)
.service(update_user)
.service(delete_user),
)
.await;
let request = aw_test::TestRequest::post()
.uri("/auth/login")
.set_json(serde_json::json!({"username":"admin","password":"password123"}))
.to_request();
let response = aw_test::call_service(&app, request).await;
assert_eq!(response.status(), actix_web::http::StatusCode::OK);
let admin_cookie = response.response().cookies().next().unwrap().to_string();
let request = aw_test::TestRequest::post()
.uri("/auth/users")
.insert_header((actix_web::http::header::COOKIE, admin_cookie.clone()))
.set_json(
serde_json::json!({"username":"alice","password":"password456","role":"user"}),
)
.to_request();
assert_eq!(
aw_test::call_service(&app, request).await.status(),
actix_web::http::StatusCode::CREATED
);
let request = aw_test::TestRequest::post()
.uri("/auth/login")
.set_json(serde_json::json!({"username":"alice","password":"password456"}))
.to_request();
let response = aw_test::call_service(&app, request).await;
let user_cookie = response.response().cookies().next().unwrap().to_string();
let request = aw_test::TestRequest::get()
.uri("/auth/users")
.insert_header((actix_web::http::header::COOKIE, user_cookie))
.to_request();
assert_eq!(
aw_test::call_service(&app, request).await.status(),
actix_web::http::StatusCode::FORBIDDEN
);
let request = aw_test::TestRequest::patch()
.uri("/auth/users/alice")
.insert_header((actix_web::http::header::COOKIE, admin_cookie.clone()))
.set_json(serde_json::json!({"password":"new-password","role":"admin"}))
.to_request();
assert_eq!(
aw_test::call_service(&app, request).await.status(),
actix_web::http::StatusCode::OK
);
let request = aw_test::TestRequest::delete()
.uri("/auth/users/alice")
.insert_header((actix_web::http::header::COOKIE, admin_cookie))
.to_request();
assert_eq!(
aw_test::call_service(&app, request).await.status(),
actix_web::http::StatusCode::OK
);
}
}
@@ -254,9 +254,9 @@ fn build_server(
};
let auth_config = AuthConfig::new(
context.http_auth.enabled,
context.http_auth.rx_passphrase.clone(),
context.http_auth.control_passphrase.clone(),
context.http_auth.tx_access_control_enabled,
context.http_auth.users_file.clone().into(),
context.http_auth.bootstrap_admin_username.clone(),
context.http_auth.bootstrap_admin_password.clone(),
Duration::from_secs(context.http_auth.session_ttl_secs),
context.http_auth.cookie_secure,
same_site,
@@ -273,7 +273,9 @@ fn build_server(
}
let context_data = web::Data::new(context);
let auth_state = web::Data::new(AuthState::new(auth_config.clone()));
let auth_state = web::Data::new(
AuthState::new(auth_config.clone()).map_err(actix_web::error::ErrorInternalServerError)?,
);
// Spawn session cleanup task if auth is enabled
if auth_config.enabled {
+77 -89
View File
@@ -268,18 +268,15 @@ impl AsRef<str> for CookieSameSite {
pub struct HttpAuthConfig {
/// Enable HTTP frontend authentication
pub enabled: bool,
/// Passphrase for read-only access (rx role)
pub rx_passphrase: Option<String>,
/// Read the rx passphrase from this file instead.
/// JSON file containing the managed user database.
pub users_file: String,
/// Username used to create the first administrator when the database is absent.
pub bootstrap_admin_username: Option<String>,
/// Password used to create the first administrator when the database is absent.
pub bootstrap_admin_password: Option<String>,
/// Read the bootstrap administrator password from this file instead.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rx_passphrase_file: Option<String>,
/// Passphrase for full control access (control role)
pub control_passphrase: Option<String>,
/// Read the control passphrase from this file instead.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub control_passphrase_file: Option<String>,
/// Enforce TX/PTT access control (hide from unauthenticated/rx users)
pub tx_access_control_enabled: bool,
pub bootstrap_admin_password_file: Option<String>,
/// Session time-to-live in minutes
pub session_ttl_min: u64,
/// Set Secure flag on session cookie (required for HTTPS)
@@ -292,11 +289,10 @@ impl Default for HttpAuthConfig {
fn default() -> Self {
Self {
enabled: false,
rx_passphrase: None,
rx_passphrase_file: None,
control_passphrase: None,
control_passphrase_file: None,
tx_access_control_enabled: true,
users_file: "trx-http-users.json".to_string(),
bootstrap_admin_username: None,
bootstrap_admin_password: None,
bootstrap_admin_password_file: None,
session_ttl_min: 480,
cookie_secure: false,
cookie_same_site: CookieSameSite::Lax,
@@ -802,14 +798,9 @@ impl ClientConfig {
)?;
}
resolve_secret(
&mut self.frontends.http.auth.rx_passphrase,
&self.frontends.http.auth.rx_passphrase_file,
"[frontends.http.auth].rx_passphrase",
)?;
resolve_secret(
&mut self.frontends.http.auth.control_passphrase,
&self.frontends.http.auth.control_passphrase_file,
"[frontends.http.auth].control_passphrase",
&mut self.frontends.http.auth.bootstrap_admin_password,
&self.frontends.http.auth.bootstrap_admin_password_file,
"[frontends.http.auth].bootstrap_admin_password",
)?;
resolve_secret_list(
&mut self.frontends.http_json.auth.tokens,
@@ -819,7 +810,7 @@ impl ClientConfig {
if let Some(path) = config_path {
if self.has_inline_secrets() {
crate::secrets::warn_if_group_readable(path, "tokens/passphrases");
crate::secrets::warn_if_group_readable(path, "tokens/passwords");
}
}
Ok(())
@@ -832,10 +823,13 @@ impl ClientConfig {
.remotes
.iter()
.any(|r| r.auth.token_file.is_none() && r.auth.token.is_some())
|| self.frontends.http.auth.rx_passphrase_file.is_none()
&& self.frontends.http.auth.rx_passphrase.is_some()
|| self.frontends.http.auth.control_passphrase_file.is_none()
&& self.frontends.http.auth.control_passphrase.is_some()
|| self
.frontends
.http
.auth
.bootstrap_admin_password_file
.is_none()
&& self.frontends.http.auth.bootstrap_admin_password.is_some()
|| self.frontends.http_json.auth.tokens_file.is_none()
&& !self.frontends.http_json.auth.tokens.is_empty()
}
@@ -905,11 +899,10 @@ impl ClientConfig {
decode_history_retention_min_by_rig: HashMap::new(),
auth: HttpAuthConfig {
enabled: false,
rx_passphrase: Some("rx-passphrase-example".to_string()),
rx_passphrase_file: None,
control_passphrase: Some("control-passphrase-example".to_string()),
control_passphrase_file: None,
tx_access_control_enabled: true,
users_file: "trx-http-users.json".to_string(),
bootstrap_admin_username: Some("admin".to_string()),
bootstrap_admin_password: Some("change-this-password".to_string()),
bootstrap_admin_password_file: None,
session_ttl_min: 480,
cookie_secure: false,
cookie_same_site: CookieSameSite::Lax,
@@ -948,29 +941,27 @@ fn validate_http_auth(auth: &HttpAuthConfig) -> Result<(), String> {
return Ok(());
}
// If enabled, require at least one passphrase
if auth.rx_passphrase.is_none() && auth.control_passphrase.is_none() {
if auth.users_file.trim().is_empty() {
return Err("[frontends.http.auth].users_file must not be empty".to_string());
}
if auth.bootstrap_admin_username.is_some() != auth.bootstrap_admin_password.is_some() {
return Err("[frontends.http.auth] bootstrap_admin_username and bootstrap_admin_password must be set together".to_string());
}
if auth
.bootstrap_admin_username
.as_deref()
.is_some_and(|v| v.trim().is_empty())
|| auth
.bootstrap_admin_password
.as_deref()
.is_some_and(|v| v.is_empty())
{
return Err(
"[frontends.http.auth] enabled=true requires at least one passphrase \
(rx_passphrase and/or control_passphrase)"
"[frontends.http.auth] bootstrap administrator credentials must not be empty"
.to_string(),
);
}
// Validate passphrases are not empty strings
if let Some(rx) = &auth.rx_passphrase {
if rx.trim().is_empty() {
return Err("[frontends.http.auth].rx_passphrase must not be empty if set".to_string());
}
}
if let Some(ctrl) = &auth.control_passphrase {
if ctrl.trim().is_empty() {
return Err(
"[frontends.http.auth].control_passphrase must not be empty if set".to_string(),
);
}
}
// Session TTL must be > 0
if auth.session_ttl_min == 0 {
return Err("[frontends.http.auth].session_ttl_min must be > 0".to_string());
@@ -1238,42 +1229,34 @@ home-hf = "audio://10.0.0.5:4600"
}
#[test]
fn test_validate_rejects_http_auth_enabled_without_passphrases() {
fn test_validate_accepts_http_auth_with_user_database() {
let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = true;
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_accepts_bootstrap_admin_pair() {
let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = true;
config.frontends.http.auth.bootstrap_admin_username = Some("admin".to_string());
config.frontends.http.auth.bootstrap_admin_password = Some("secret-password".to_string());
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_rejects_incomplete_bootstrap_admin_pair() {
let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = true;
config.frontends.http.auth.bootstrap_admin_username = Some("admin".to_string());
assert!(config.validate().is_err());
}
#[test]
fn test_validate_accepts_http_auth_with_rx_passphrase() {
fn test_validate_rejects_empty_users_file() {
let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = true;
config.frontends.http.auth.rx_passphrase = Some("rx-secret".to_string());
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_accepts_http_auth_with_control_passphrase() {
let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = true;
config.frontends.http.auth.control_passphrase = Some("control-secret".to_string());
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_accepts_http_auth_with_both_passphrases() {
let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = true;
config.frontends.http.auth.rx_passphrase = Some("rx-secret".to_string());
config.frontends.http.auth.control_passphrase = Some("control-secret".to_string());
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_rejects_empty_rx_passphrase() {
let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = true;
config.frontends.http.auth.rx_passphrase = Some("".to_string());
config.frontends.http.auth.users_file.clear();
assert!(config.validate().is_err());
}
@@ -1281,16 +1264,15 @@ home-hf = "audio://10.0.0.5:4600"
fn test_validate_rejects_zero_session_ttl() {
let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = true;
config.frontends.http.auth.rx_passphrase = Some("rx-secret".to_string());
config.frontends.http.auth.session_ttl_min = 0;
assert!(config.validate().is_err());
}
#[test]
fn test_validate_auth_disabled_ignores_passphrases() {
fn test_validate_auth_disabled_ignores_user_settings() {
let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = false;
config.frontends.http.auth.rx_passphrase = Some("".to_string());
config.frontends.http.auth.users_file.clear();
assert!(config.validate().is_ok());
}
@@ -1298,9 +1280,9 @@ home-hf = "audio://10.0.0.5:4600"
fn test_http_auth_config_default() {
let auth = HttpAuthConfig::default();
assert!(!auth.enabled);
assert!(auth.rx_passphrase.is_none());
assert!(auth.control_passphrase.is_none());
assert!(auth.tx_access_control_enabled);
assert_eq!(auth.users_file, "trx-http-users.json");
assert!(auth.bootstrap_admin_username.is_none());
assert!(auth.bootstrap_admin_password.is_none());
assert_eq!(auth.session_ttl_min, 480);
assert!(!auth.cookie_secure);
assert!(matches!(auth.cookie_same_site, CookieSameSite::Lax));
@@ -1602,15 +1584,21 @@ spectrum_interval_ms = 0
}
#[test]
fn test_passphrase_file_fills_passphrase() {
fn test_bootstrap_password_file_fills_password() {
let f = secret_file("hunter2\n");
let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = true;
config.frontends.http.auth.control_passphrase_file =
config.frontends.http.auth.bootstrap_admin_username = Some("admin".to_string());
config.frontends.http.auth.bootstrap_admin_password_file =
Some(f.path().to_str().unwrap().to_string());
config.resolve_secrets(None).unwrap();
assert_eq!(
config.frontends.http.auth.control_passphrase.as_deref(),
config
.frontends
.http
.auth
.bootstrap_admin_password
.as_deref(),
Some("hunter2")
);
assert!(config.validate().is_ok());
+2 -2
View File
@@ -111,8 +111,8 @@ const SECTION_COMMENTS: &[(&str, &str)] = &[
),
(
"trx-client.frontends.http.auth",
"Passphrase login for the web UI. rx_passphrase_file and\n\
control_passphrase_file keep the secrets out of this file.",
"Optional user/password ACL for the web UI. Administrators manage\n\
accounts stored in users_file.",
),
(
"trx-client.frontends.rigctl",
+5 -5
View File
@@ -203,13 +203,13 @@ decode_history_retention_min = 1440
[trx-client.frontends.http.decode_history_retention_min_by_rig]
# Passphrase login for the web UI. rx_passphrase_file and
# control_passphrase_file keep the secrets out of this file.
# Optional user/password ACL for the web UI. Administrators manage
# accounts stored in users_file.
[trx-client.frontends.http.auth]
enabled = false
rx_passphrase = "rx-passphrase-example"
control_passphrase = "control-passphrase-example"
tx_access_control_enabled = true
users_file = "trx-http-users.json"
bootstrap_admin_username = "admin"
bootstrap_admin_password = "change-this-password"
session_ttl_min = 480
cookie_secure = false
cookie_same_site = "Lax"