Compare commits

...
Author SHA1 Message Date
sjgandClaude Opus 4.8 df7483fe30 [feat](trx-rs): configurable DIG sideband for SDR (auto/USB/LSB)
CI / frontend (push) Successful in 5m41s
CI / lint (push) Failing after 4s
CI / test (push) Successful in 10m37s
CI / reuse (push) Successful in 5s
DIG has no inherent sideband; on the SDR backend it was always demodulated
as USB. Make it resolve to USB or LSB via a policy that defaults to the
amateur SSB/data convention (USB at/above 10 MHz, LSB below) and can be
overridden globally from the advanced radio controls or per-bookmark.

Design: the logical DIG mode is kept in RigState (display, decoder gating)
while the SDR pipeline is handed a concrete USB/LSB demodulator resolved from
(policy, dial frequency). Resolution happens at the boundary — the rig for
the primary channel and the virtual-channel manager for vchans — so the hot
DSP/demod path is untouched. The resolved sideband is only re-pushed when it
actually changes (e.g. tuning DIG/Auto across 10 MHz), keeping ordinary
tuning glitch-free.

Core/protocol:
- New `DigSidebandPolicy { Auto, Usb, Lsb }` with `resolve(freq)` and an
  `effective_demod_mode()` helper (trx-core), re-exported at the crate root.
- `RigCommand::SetSdrDigSideband`, `RigSdr::set_sdr_dig_sideband`, and a
  `RigFilterState.sdr_dig_sideband` field for state sync; wired through the
  ClientCommand mapping.

Config: `[rig.sdr] dig_sideband = "auto"` (regenerated trx-rs.toml.example).

SDR backend: the vchan manager owns the shared policy (atomic); the rig
applies it to the primary channel and, on `set_sdr_dig_sideband`, re-resolves
all DIG virtual channels.

Frontend: a mode-gated "DIG sideband" selector in the SDR advanced controls
(POST /set_sdr_dig_sideband), reflecting server state; bookmarks gain an
optional `dig_sideband` field (form selector shown only for DIG) that, on
apply, sets the global policy before switching to DIG. The scheduler honours
it for automated bookmark activation too.

Tests: policy resolution / effective-mode / u8+parse round-trips (trx-core);
a vchan integration test asserting a DIG channel resolves to LSB below
10 MHz, flips with the policy, and still lists as DIG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UiK871ht2uPFBHtMbxy3wD
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-16 00:26:45 +02:00
sjgandClaude Opus 4.8 fe3b414fba [fix](trx-wefax): stop fragmenting one transmission into many images
The WEFAX decoder decoded content but chopped a single chart into many
short PNG "chunks". Two heuristics fought each other: in State::Receiving
the carrier-loss watchdog finalized the image after only 30 low-correlation
scan lines (~15 s at 120 LPM), and the Idle variance auto-start then
re-triggered on the still-present carrier ~3 s later, starting a fresh
image. Ordinary HF fading (QSB) of 5-20 s therefore split every chart into
a stream of tiny images.

Reference decoders (fldigi) keep one continuous image per APT cycle up to a
large line cap and only stop on the APT stop tone or genuine signal loss.
Align with that model:

- Raise the end-of-transmission watchdog to 120 low-correlation lines
  (~60 s at 120 LPM) so normal fades ride through within one image.
- Gate the variance-based auto-start to fire at most once per session
  (auto_start_used). After the first image a new one starts only on an APT
  start tone or an explicit reset, so trailing carrier/noise can no longer
  spawn a second image. reset() re-arms it.
- Cap a single image at 3000 lines to bound memory on an open carrier.
- Require a 2 s (was 1 s) APT tone sustain, cutting false Stop detections on
  busy image content that momentarily hits ~450 transitions/s.

Also make line slicing drift-free: 120 LPM at 11025 Hz is 5512.5 samples per
line, and slicing on the rounded integer accumulated a fractional-sample
error every line (slow horizontal slant). Boundaries are now derived from the
exact fractional line length (samples_per_line_f64) so the error never
accumulates.

Adds regression tests for the single-auto-start invariant and zero slicer
drift over 1000 lines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UiK871ht2uPFBHtMbxy3wD
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-15 23:35:09 +02:00
sjg 1829e3d17b [fix](trx-frontend-http): make Guest role composable
CI / lint (pull_request) Successful in 2m25s
CI / test (pull_request) Successful in 9m16s
CI / frontend (pull_request) Successful in 5m22s
CI / reuse (pull_request) Successful in 6s
CI / lint (push) Successful in 2m23s
CI / test (push) Successful in 8m11s
CI / frontend (push) Successful in 4m24s
CI / reuse (push) Successful in 4s
2026-08-13 02:16:59 +02:00
sjg b037225a05 [fix](trx-frontend-http): block Guest password changes
CI / lint (pull_request) Successful in 2m25s
CI / test (pull_request) Successful in 9m5s
CI / frontend (pull_request) Successful in 5m23s
CI / reuse (pull_request) Successful in 5s
CI / lint (push) Successful in 2m24s
CI / test (push) Successful in 9m11s
CI / frontend (push) Successful in 5m22s
CI / reuse (push) Successful in 5s
2026-08-11 22:47:55 +02:00
sjg e978cf8a84 [feat](trx-frontend-http): separate transmit permission
CI / frontend (pull_request) Successful in 5m21s
CI / lint (pull_request) Successful in 2m24s
CI / test (pull_request) Successful in 9m11s
CI / test (push) Successful in 8m16s
CI / frontend (push) Successful in 4m24s
CI / reuse (push) Successful in 5s
CI / reuse (pull_request) Successful in 5s
CI / lint (push) Successful in 2m25s
Assisted-By: Codex (GPT-5)
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-11 18:36:33 +02:00
sjg 44870bc941 [feat](trx-frontend-http): add restricted Guest role
CI / lint (pull_request) Successful in 2m24s
CI / test (pull_request) Successful in 8m16s
CI / frontend (pull_request) Successful in 4m17s
CI / reuse (pull_request) Successful in 5s
Assisted-By: Codex (GPT-5)
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-11 16:04:51 +02:00
sjg 5e9dae02c7 Complete managed account lifecycle
CI / test (push) Successful in 8m12s
CI / frontend (push) Successful in 4m15s
CI / reuse (push) Successful in 5s
CI / lint (pull_request) Successful in 2m24s
CI / test (pull_request) Successful in 9m6s
CI / frontend (pull_request) Successful in 5m17s
CI / reuse (pull_request) Successful in 5s
CI / lint (push) Successful in 2m26s
2026-08-11 07:49:53 +02:00
sjg 34507ffa17 Add composable HTTP access roles
CI / lint (pull_request) Successful in 2m24s
CI / test (pull_request) Successful in 9m24s
CI / frontend (pull_request) Successful in 5m12s
CI / reuse (pull_request) Successful in 6s
CI / lint (push) Successful in 2m24s
CI / test (push) Successful in 8m8s
CI / frontend (push) Successful in 4m15s
CI / reuse (push) Successful in 5s
2026-08-11 01:06:57 +02:00
sjg 36c1e56efa Protect the final administrator 2026-08-11 00:39:25 +02:00
sjg e4cce9a004 Add Users settings tab 2026-08-11 00:37:59 +02:00
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
65 changed files with 3749 additions and 745 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",
+9 -4
View File
@@ -925,9 +925,14 @@ main
### HTTP Frontend Auth
- Optional token or HTTP Basic Auth middleware
- Configured in `[frontends.http.auth]`
- Rate limiting supported
- Optional Argon2id-backed managed accounts with HttpOnly session cookies
- An exclusive Guest role plus composable Read, Control, Transmit, Write, and Administrator roles, with policy shared by middleware and handlers
- Guest sessions receive read-only station access but no account-control endpoints or panels
- Transmit separately gates PTT, TX audio frames, and TX power-limit changes
- Atomic JSON persistence with migration from the legacy single-role schema
- Account enable/disable, administrator CRUD, self-service password changes, and session revocation on security changes
- A database invariant always preserves at least one enabled administrator
- Per-IP login rate limiting; configured in `[frontends.http.auth]`
### Transport Security
@@ -1043,7 +1048,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/read accounts, `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` |
+4 -4
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,8 +541,8 @@ 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
notion of who is logged in.
It cannot be inferred from the session's identity: an account username need not be an operator
callsign, and operational accounts may be shared.
**Server clock, and the log says so.** The server is the machine at the radio; the browser may
be on a phone in another timezone with a clock nobody has checked. QSO times are UTC from the
+19 -12
View File
@@ -121,13 +121,14 @@ 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).
**Guest** provides read-only station access and is exclusive. Non-Guest accounts
may combine **Read**, **Control**, **Transmit**, **Write**, and **Administrator** roles.
Administrator implies all permissions.
Session cookie: `trx_http_sid`, HttpOnly, configurable Secure and SameSite attributes.
@@ -336,25 +337,31 @@ Logo and favicon are embedded at compile time via `include_bytes!`. The logo ima
### 7.1 Route Access Classification
Routes are classified into three tiers:
Routes are classified into access 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` | Guest, Read, Control, Transmit, or Administrator role |
| **Control** | `/set_freq`, `/set_mode`, `/toggle_power`, receive-side radio-control POST routes | Control or Administrator role |
| **Transmit** | `/set_ptt`, `/set_tx_limit`, outbound `/audio` frames | Transmit or Administrator role |
| **Write** | Logbook access and bookmark mutations | Write or Administrator role |
### 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.
Every authenticated non-Guest account gets a Settings > Account tab for changing
its own password. Guest sees neither Account nor Users and both account-control
APIs deny Guest sessions. Only administrators get Settings > Users, where accounts can be
created, enabled/disabled, assigned multiple roles, given a new password, or
removed. The final enabled administrator cannot be disabled, removed, or
demoted. Account security changes revoke every active session for that account.
---
+46 -25
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,20 @@ 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 |
| `bootstrap_read_enabled` | bool | `true` | Create the default Guest account when the database is absent |
| `bootstrap_read_username` | string | `"guest"` | Initial Guest username |
| `bootstrap_read_password` | string | `"guest"` | Initial Guest password |
| `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 +580,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 `Control` role; a `Read`
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 +589,72 @@ 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
roles:
The HTTP frontend supports an optional user/password ACL:
- **rx** — read-only access (monitoring, audio, decode streams)
- **control** — full access (frequency, mode, PTT, and all settings)
- **Guest** — read-only station access with no Account or Users controls; Guest cannot be combined with another role
- **Read** — monitoring, audio, decode streams, and bookmark reads
- **Control** — tuning, mode, power, and receive-side radio controls
- **Transmit** — PTT, transmitted audio, and TX power-limit controls
- **Write** — logbook access and bookmark changes
- **Administrator** — user management and all other permissions
### 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"
bootstrap_read_enabled = true
bootstrap_read_username = "guest"
bootstrap_read_password = "guest"
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 (with every non-Guest role), the default `guest`/`guest` Guest
account, and the Argon2id-hashed user database. Change or disable the guest
credentials in configuration before first startup on an exposed deployment.
### 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.
- Non-Guest roles are independent; for example, an account may have Read and Write without Control.
- Guest accounts have no account-control panels and cannot call account-control endpoints.
- Every non-Guest signed-in user can change their own password in Settings > Account. This signs out all of their sessions.
- Administrators can add, enable/disable, or remove users and change roles/passwords in Settings > Users.
- At least one enabled administrator must always remain and cannot be disabled, removed, or demoted.
- Disabling/removing an account or changing its password/roles revokes all of its sessions.
- Existing account files migrate automatically: legacy accounts are enabled by default and legacy `user`/`admin` roles become Read/all roles.
### 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/session` | GET | Check current session/roles |
| `/auth/account/password` | PATCH | Change a non-Guest user's password after verifying the current password |
| `/auth/users` | GET/POST | List or add users (admin only) |
| `/auth/users/{username}` | PATCH/DELETE | Change enabled state/password/roles or remove user (administrator only) |
Protected routes require at least `rx` role. Control routes (set frequency,
mode, PTT, etc.) require `control` role.
Read routes accept Guest or require Read. Tuning and receive-side radio mutations
require Control. PTT, transmitted audio, and TX limit changes require Transmit.
Logbook access and bookmark mutations require Write. Administrator grants every permission.
### 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. The interface enables controls according to the account's roles.
5. If a session expires mid-use, streams stop and the login screen returns.
### Transport Security
+13 -1
View File
@@ -45,8 +45,20 @@ impl WefaxConfig {
60.0 / lpm as f32
}
/// Samples per line at the internal sample rate.
/// Samples per line at the internal sample rate (rounded to an integer;
/// use [`Self::samples_per_line_f64`] for drift-free line slicing).
pub fn samples_per_line(lpm: u16, sample_rate: u32) -> usize {
(Self::line_duration_s(lpm) * sample_rate as f32).round() as usize
}
/// Exact (fractional) samples per line at the internal sample rate.
///
/// The line period rarely lands on an integer number of samples
/// (e.g. 120 LPM at 11 025 Hz is 5512.5 samples). Slicing on the rounded
/// integer accumulates a fractional-sample error every line, which shows
/// up as a slow horizontal slant over a tall image. Line boundaries are
/// instead computed from this exact value so the error never accumulates.
pub fn samples_per_line_f64(lpm: u16, sample_rate: u32) -> f64 {
60.0 / f64::from(lpm) * f64::from(sample_rate)
}
}
+86 -5
View File
@@ -38,10 +38,24 @@ const SIGNAL_DETECT_WINDOWS: u32 = 6;
/// Real WEFAX content typically shows r > 0.5 between adjacent lines.
const LINE_CORR_NOISE_THRESHOLD: f32 = 0.2;
/// Number of consecutive uncorrelated scan lines that trigger auto-finalize
/// while receiving. At 120 LPM this is 15 s; at 60 LPM it's 30 s. Modelled on
/// fldigi's line-to-line correlation check for automatic stop.
const LINE_CORR_NOISE_LINES: u32 = 30;
/// Number of consecutive uncorrelated scan lines that mark the end of a
/// transmission (carrier truly gone) and trigger auto-finalize. This must be
/// long enough to ride through ordinary HF fading (QSB), which routinely
/// decorrelates adjacent lines for several seconds without the transmission
/// having ended. At 120 LPM this is ~60 s; at 60 LPM ~120 s.
///
/// A short window here is what previously chopped a single chart into many
/// PNG "chunks": a 15 s fade tripped the watchdog, the image was finalized,
/// and the still-present carrier immediately re-started a fresh image. Real
/// WEFAX decoders (fldigi) keep one continuous image per APT cycle up to a
/// large line cap and only stop on the APT stop tone or genuine signal loss.
const LINE_CORR_NOISE_LINES: u32 = 120;
/// Hard cap on lines in a single image. A 120 LPM chart runs ~10 min
/// (~1200 lines); this cap (≈25 min at 120 LPM) only bounds memory if a
/// session is left running on an open carrier. On reaching it the image is
/// finalized and the decoder waits for a fresh APT start.
const MAX_IMAGE_LINES: u32 = 3000;
/// Maximum number of scan-line-equivalent sample windows to wait for phasing
/// lock before falling through to Receiving. Typical WEFAX phasing lasts
@@ -107,6 +121,12 @@ pub struct WefaxDecoder {
/// the decoder falls through to Receiving so a noisy or partial
/// phasing signal doesn't wedge the state machine.
phasing_samples: u64,
/// Whether a reception has already been auto-started from bare signal
/// variance during this session. After the first image, a new one is only
/// started by an APT start tone — this stops the trailing noise / carrier
/// that follows one chart from immediately auto-starting another image
/// (the mechanism that fragmented a transmission into many chunks).
auto_start_used: bool,
/// Current rig dial frequency in Hz (for image filenames).
freq_hz: u64,
/// Current rig mode name (for image filenames).
@@ -135,6 +155,7 @@ impl WefaxDecoder {
signal_detect_buf: Vec::with_capacity(INTERNAL_RATE as usize / 2),
low_corr_lines: 0,
phasing_samples: 0,
auto_start_used: false,
freq_hz: 0,
mode: String::new(),
}
@@ -204,7 +225,13 @@ impl WefaxDecoder {
// Fallback: detect active WEFAX signal by luminance variance.
// Like fldigi's "strong image signal" detection — if we see
// sustained modulated signal, auto-start receiving with defaults.
if self.state == State::Idle {
//
// Only ever taken once per session: it exists to catch a user
// tuning in mid-image with no APT start. After the first image,
// a new reception requires an APT start tone, so the residual
// carrier / noise that trails a finished chart cannot silently
// spawn a second image (which is what produced many chunks).
if self.state == State::Idle && !self.auto_start_used {
self.signal_detect_buf.extend_from_slice(&luminance);
let window_size = INTERNAL_RATE as usize / 2;
while self.signal_detect_buf.len() >= window_size {
@@ -340,6 +367,14 @@ impl WefaxDecoder {
break;
}
// Bound memory on an open carrier: finalize and wait
// for a fresh APT start rather than growing forever.
if count >= MAX_IMAGE_LINES {
debug!(lines = count, "WEFAX: max image lines — finalizing");
carrier_lost = true;
break;
}
// Emit progress event.
if self.config.emit_progress && count % PROGRESS_INTERVAL == 0 {
let line_data =
@@ -402,6 +437,7 @@ impl WefaxDecoder {
self.signal_detect_buf.clear();
self.low_corr_lines = 0;
self.phasing_samples = 0;
self.auto_start_used = false;
events
}
@@ -434,6 +470,7 @@ impl WefaxDecoder {
fn transition_to_start_detected(&mut self, ioc: u16) -> WefaxEvent {
let ioc = self.config.ioc.unwrap_or(ioc);
debug!(ioc, "WEFAX: APT start detected");
self.auto_start_used = true;
self.state = State::StartDetected { ioc };
self.reception_start_ms = Some(
std::time::SystemTime::now()
@@ -463,6 +500,7 @@ impl WefaxDecoder {
self.image = Some(ImageAssembler::new(ppl));
self.tone_detector.reset();
self.low_corr_lines = 0;
self.auto_start_used = true;
self.state = State::Receiving { ioc, lpm };
self.state_event("Receiving", ioc, lpm)
}
@@ -596,4 +634,47 @@ mod tests {
dec.reset();
assert_eq!(dec.state, State::Idle);
}
/// Regression test for the over-chunking bug: once a session has produced
/// an image, the trailing carrier / noise must not silently auto-start a
/// second image. Only an APT start tone (or an explicit reset) may begin a
/// new reception after the first.
#[test]
fn variance_auto_start_only_fires_once_per_session() {
let mut dec = WefaxDecoder::new(11025, WefaxConfig::default());
// A transition rate that matches no APT tone → drives the variance
// based "strong signal" auto-start rather than a tone detection.
let strong = generate_apt_start(100.0, 11025, 4.0);
dec.process_samples(&strong);
assert!(
matches!(dec.state, State::Receiving { .. }),
"strong signal should auto-start one image, got {:?}",
dec.state
);
assert!(dec.auto_start_used);
// Simulate the image ending on carrier loss / stop (finalize → idle)
// WITHOUT an operator reset.
dec.transition_to_idle();
assert_eq!(dec.state, State::Idle);
// The still-present carrier must NOT spawn a second image.
dec.process_samples(&generate_apt_start(100.0, 11025, 4.0));
assert_eq!(
dec.state,
State::Idle,
"trailing signal must not re-auto-start a fresh image"
);
// An explicit reset re-arms mid-image auto-start.
dec.reset();
assert!(!dec.auto_start_used);
dec.process_samples(&generate_apt_start(100.0, 11025, 4.0));
assert!(
matches!(dec.state, State::Receiving { .. }),
"reset should re-arm variance auto-start, got {:?}",
dec.state
);
}
}
+54 -9
View File
@@ -12,8 +12,8 @@ use crate::config::WefaxConfig;
/// Line slicer for WEFAX image assembly.
pub struct LineSlicer {
/// Samples per line at the internal sample rate.
samples_per_line: usize,
/// Exact (fractional) samples per line at the internal sample rate.
samples_per_line: f64,
/// Pixels per line (IOC × π).
pixels_per_line: usize,
/// Phase offset in samples from the phasing detector.
@@ -22,22 +22,37 @@ pub struct LineSlicer {
buffer: Vec<f32>,
/// Whether we have aligned to the phase offset yet.
aligned: bool,
/// Index of the next line to emit. Boundaries are derived from this
/// against the exact fractional line length so rounding never accumulates.
line_index: u64,
}
impl LineSlicer {
pub fn new(lpm: u16, ioc: u16, sample_rate: u32, phase_offset: usize) -> Self {
let samples_per_line = WefaxConfig::samples_per_line(lpm, sample_rate);
let samples_per_line = WefaxConfig::samples_per_line_f64(lpm, sample_rate);
let pixels_per_line = WefaxConfig::pixels_per_line(ioc) as usize;
Self {
samples_per_line,
pixels_per_line,
phase_offset,
buffer: Vec::with_capacity(samples_per_line * 2),
buffer: Vec::with_capacity(samples_per_line as usize * 2),
aligned: false,
line_index: 0,
}
}
/// Number of samples in line `n`, from the exact fractional line length.
///
/// Boundaries are `round(n · spl)`; the per-line length is the difference
/// of successive boundaries, so lengths alternate (e.g. 5513/5512 for
/// 120 LPM at 11 025 Hz) with no cumulative drift.
fn line_len(&self, n: u64) -> usize {
let start = (n as f64 * self.samples_per_line).round() as u64;
let end = ((n + 1) as f64 * self.samples_per_line).round() as u64;
(end - start) as usize
}
/// Feed luminance samples and extract complete image lines.
///
/// Returns a vector of completed lines, each as a `Vec<u8>` of
@@ -56,12 +71,18 @@ impl LineSlicer {
}
// Extract complete lines (single drain at the end to avoid O(n²)).
// Line boundaries follow the exact fractional line length so the
// sample clock stays locked over a tall image (no accumulating slant).
let mut offset = 0;
while offset + self.samples_per_line <= self.buffer.len() {
let line_samples = &self.buffer[offset..offset + self.samples_per_line];
let pixels = self.resample_line(line_samples);
lines.push(pixels);
offset += self.samples_per_line;
loop {
let len = self.line_len(self.line_index);
if offset + len > self.buffer.len() {
break;
}
let line_samples = &self.buffer[offset..offset + len];
lines.push(self.resample_line(line_samples));
offset += len;
self.line_index += 1;
}
if offset > 0 {
self.buffer.drain(..offset);
@@ -77,6 +98,7 @@ impl LineSlicer {
pub fn reset(&mut self) {
self.buffer.clear();
self.aligned = false;
self.line_index = 0;
}
/// Resample a line's worth of luminance samples to the target pixel count
@@ -129,6 +151,29 @@ mod tests {
assert!(lines[0].iter().all(|&p| p == 255));
}
#[test]
fn slicer_no_cumulative_drift() {
// 120 LPM at 11 025 Hz is 5512.5 samples/line — not an integer. Slicing
// on the rounded value (5513) would lose a line every ~11 000 lines and
// slant the image; the fractional boundaries must not accumulate error.
let lpm = 120;
let ioc = 576;
let sr = 11025;
let spl = WefaxConfig::samples_per_line_f64(lpm, sr);
assert_ne!(spl.fract(), 0.0, "test premise: spl is fractional");
let mut slicer = LineSlicer::new(lpm, ioc, sr, 0);
let total = (spl * 1000.0).round() as usize;
let samples = vec![1.0f32; total];
let lines = slicer.process(&samples);
assert_eq!(
lines.len(),
1000,
"exactly 1000 lines should fit in {} samples with no drift",
total
);
}
#[test]
fn slicer_linear_interpolation() {
let lpm = 120;
+5 -1
View File
@@ -78,7 +78,11 @@ pub struct ToneDetector {
impl ToneDetector {
pub fn new(sample_rate: u32) -> Self {
let window_size = (sample_rate / 2) as usize; // ~0.5 s window
let min_sustain_s = 1.0; // fldigi uses 2 consecutive half-second windows
// APT start/stop tones are transmitted for ~5 s (WMO), so requiring a
// 2 s sustain costs no real detection latency while sharply cutting
// false positives from busy image content that momentarily produces a
// 300/450/675-transitions-per-second rate.
let min_sustain_s = 2.0;
let window_duration_s = window_size as f32 / sample_rate as f32;
let min_sustain_windows = (min_sustain_s / window_duration_s).ceil() as u32;
+11 -5
View File
@@ -252,11 +252,17 @@ 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.bootstrap_read_enabled =
cfg.frontends.http.auth.bootstrap_read_enabled;
frontend_runtime.http_auth.bootstrap_read_username =
cfg.frontends.http.auth.bootstrap_read_username.clone();
frontend_runtime.http_auth.bootstrap_read_password =
cfg.frontends.http.auth.bootstrap_read_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 {
+12 -6
View File
@@ -257,9 +257,12 @@ 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 bootstrap_read_enabled: bool,
pub bootstrap_read_username: String,
pub bootstrap_read_password: Option<String>,
pub session_ttl_secs: u64,
pub cookie_secure: bool,
pub cookie_same_site: String,
@@ -271,9 +274,12 @@ 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,
bootstrap_read_enabled: true,
bootstrap_read_username: "guest".to_string(),
bootstrap_read_password: Some("guest".to_string()),
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 }
@@ -1,3 +1,20 @@
import {
AUTH_ADMIN_ROLES,
AUTH_ROLES,
AUTH_ROLE_LABELS,
changeOwnPassword,
createUser,
deleteUser,
fetchAuthSession,
hasAccountControls,
hasAuthRole,
listUsers,
login,
logout,
normalizeAuthRoles,
updateUser
} from "./chunk-PISLBJGN.js";
// src/webgl-renderer.ts
(function initTrxWebGl(global) {
"use strict";
@@ -1314,60 +1331,6 @@ async function loadDecoderRegistry(onLoaded) {
bridge.decoderRegistry = decoderRegistry;
bridge.onDecoderRegistryReady = onDecoderRegistryReady;
// src/api/auth.ts
function decodeAuthSession(value) {
if (typeof value !== "object" || value === null) {
throw new TypeError("The authentication response is malformed");
}
const session = 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") {
throw new TypeError("The authentication response has an invalid role");
}
if (session.auth_disabled !== void 0 && typeof session.auth_disabled !== "boolean") {
throw new TypeError("The authentication response has an invalid auth_disabled flag");
}
const decoded = { authenticated: session.authenticated };
if (session.role !== void 0) decoded.role = session.role;
if (session.auth_disabled !== void 0) decoded.auth_disabled = session.auth_disabled;
return decoded;
}
var authDisabledSession = {
authenticated: true,
role: "control",
auth_disabled: true
};
async function fetchAuthSession() {
try {
const response = await fetch("/auth/session");
if (response.status === 404) return authDisabledSession;
if (!response.ok) return { authenticated: false };
return decodeAuthSession(await response.json());
} catch (error) {
console.error("Auth check failed:", error);
return { authenticated: false };
}
}
async function login(passphrase) {
const response = await fetch("/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ passphrase })
});
if (response.status === 404) return authDisabledSession;
if (!response.ok) {
const message = await response.text();
throw new Error(message || "Login failed");
}
return decodeAuthSession(await response.json());
}
async function logout() {
const response = await fetch("/auth/logout", { method: "POST" });
if (response.status !== 404 && !response.ok) throw new Error("Logout failed");
}
// src/core/format.ts
function formatDuration(milliseconds) {
const seconds = Math.floor(milliseconds / 1e3);
@@ -1862,33 +1825,67 @@ function isVchanRdsEntry(value) {
return isRecord2(value) && typeof value.id === "string" && (value.rds === void 0 || value.rds === null || isRdsData(value.rds)) && (value.signal_db === void 0 || value.signal_db === null || typeof value.signal_db === "number");
}
void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
var authRole = null;
var authRoles = [];
var authUsername = null;
var authEnabled = true;
function setAuthRoles(roles) {
authRoles = normalizeAuthRoles(roles);
}
function hasAuthRole2(role) {
return hasAuthRole(authRoles, role);
}
function buildRoleChoices(selected) {
const element = document.createElement("span");
element.className = "auth-role-choices";
const inputs = AUTH_ROLES.map((value) => {
const label = document.createElement("label");
label.className = "auth-role-choice";
const input = document.createElement("input");
input.type = "checkbox";
input.value = value;
input.checked = selected.includes(value);
label.append(input, ` ${AUTH_ROLE_LABELS[value]}`);
element.append(label);
return { input, value };
});
inputs.forEach(({ input, value }) => {
input.addEventListener("change", () => {
if (!input.checked) return;
if (value === "guest") {
const administrator = inputs.find((choice) => choice.value === "administrator");
if (administrator) administrator.input.checked = false;
} else if (value === "administrator") {
const guest = inputs.find((choice) => choice.value === "guest");
if (guest) guest.input.checked = false;
}
});
});
return { element, inputs };
}
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;
setAuthRoles([]);
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 +1902,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");
});
@@ -1944,20 +1937,30 @@ function updateAuthUI() {
const badge = document.getElementById("auth-badge");
const badgeRole = document.getElementById("auth-role-badge");
const headerAuthBtn2 = document.getElementById("header-auth-btn");
const accountTab = document.getElementById("settings-account-tab");
if (!authEnabled) {
if (badge) badge.style.display = "none";
if (headerAuthBtn2) headerAuthBtn2.style.display = "none";
if (accountTab) accountTab.style.display = "none";
syncTopBarAccess();
return;
}
if (authRole) {
if (authRoles.length > 0) {
const canManageAccount = hasAccountControls(authRoles);
if (accountTab) accountTab.style.display = canManageAccount ? "" : "none";
if (!canManageAccount && accountTab?.classList.contains("active")) {
const panel = document.getElementById("subtab-settings-account");
if (panel) panel.style.display = "none";
document.querySelector('[data-subtab="settings-scheduler"]')?.click();
}
if (badge) badge.style.display = "block";
if (badgeRole) badgeRole.textContent = authRole === "control" ? "Control (full access)" : "RX (read-only)";
if (badgeRole) badgeRole.textContent = `${authUsername || "local"}${authRoles.map((role) => AUTH_ROLE_LABELS[role]).join(", ")}`;
if (headerAuthBtn2) {
headerAuthBtn2.textContent = "Logout";
headerAuthBtn2.style.display = "block";
}
} else {
if (accountTab) accountTab.style.display = "none";
if (badge) badge.style.display = "none";
if (headerAuthBtn2) {
headerAuthBtn2.textContent = "Login";
@@ -1967,31 +1970,34 @@ function updateAuthUI() {
syncTopBarAccess();
}
function applyAuthRestrictions() {
if (!authRole) return;
if (authRole === "rx") {
if (authRoles.length === 0) return;
if (!hasAuthRole2("transmit")) {
const pttBtn2 = document.getElementById("ptt-btn");
const txLimitInput2 = document.getElementById("tx-limit");
const txLimitBtn2 = document.getElementById("tx-limit-btn");
const txAudioBtn2 = document.getElementById("tx-audio-btn");
const txLimitRow2 = document.getElementById("tx-limit-row");
if (pttBtn2) pttBtn2.disabled = true;
if (txAudioBtn2) txAudioBtn2.disabled = true;
if (txLimitBtn2) txLimitBtn2.disabled = true;
if (txLimitInput2) txLimitInput2.disabled = true;
if (txLimitRow2) txLimitRow2.style.opacity = "0.5";
}
if (!hasAuthRole2("control")) {
const powerBtn2 = document.getElementById("power-btn");
const lockBtn2 = document.getElementById("lock-btn");
const freqInput = document.getElementById("freq");
const centerFreqInput = document.getElementById("center-freq");
const modeSelect = document.getElementById("mode");
const txLimitInput2 = document.getElementById("tx-limit");
const txLimitBtn2 = document.getElementById("tx-limit-btn");
const txAudioBtn2 = document.getElementById("tx-audio-btn");
const txLimitRow2 = document.getElementById("tx-limit-row");
const jogUp = document.getElementById("jog-up");
const jogDown = document.getElementById("jog-down");
const jogButtons = document.querySelectorAll(".jog-step button");
const vfoButtons = document.querySelectorAll("#vfo-picker button");
if (pttBtn2) pttBtn2.disabled = true;
if (powerBtn2) powerBtn2.disabled = true;
if (lockBtn2) lockBtn2.disabled = true;
if (txAudioBtn2) txAudioBtn2.disabled = true;
if (txLimitBtn2) txLimitBtn2.disabled = true;
if (freqInput) freqInput.disabled = true;
if (centerFreqInput) centerFreqInput.disabled = true;
if (modeSelect) modeSelect.disabled = true;
if (txLimitInput2) txLimitInput2.disabled = true;
vfoButtons.forEach((btn) => btn.disabled = true);
const jogWheel2 = document.getElementById("jog-wheel");
if (jogUp) jogUp.disabled = true;
@@ -2028,7 +2034,6 @@ function applyAuthRestrictions() {
btn.disabled = true;
}
});
if (txLimitRow2) txLimitRow2.style.opacity = "0.5";
}
}
function applyCapabilities(caps) {
@@ -2258,20 +2263,21 @@ window.applyDecodeHistoryRetention = function() {
}
};
function syncTopBarAccess() {
const loggedOut = authEnabled && !authRole;
const loggedOut = authEnabled && authRoles.length === 0;
const tabBar = document.getElementById("tab-bar");
const rigSwitch = document.querySelector(".header-rig-switch");
if (tabBar) tabBar.style.display = "";
document.querySelectorAll(".tab-bar .tab").forEach((btn) => {
const isMain = btn.dataset.tab === "main";
btn.style.display = !loggedOut || isMain ? "" : "none";
const lacksLogbookAccess = authEnabled && btn.dataset.tab === "logbook" && !hasAuthRole2("write");
btn.style.display = (!loggedOut || isMain) && !lacksLogbookAccess ? "" : "none";
btn.disabled = false;
});
if (rigSwitch) {
rigSwitch.style.display = loggedOut ? "none" : "";
}
if (headerRigSwitchSelect) {
headerRigSwitchSelect.disabled = loggedOut || authRole === "rx" || lastRigIds.length === 0;
headerRigSwitchSelect.disabled = loggedOut || !hasAuthRole2("control") || lastRigIds.length === 0;
}
}
var overviewDrawPending = false;
@@ -2907,7 +2913,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 || !hasAuthRole2("control");
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
updateRigSubtitle(lastActiveRigId);
window.trxUi?.setActiveRig(lastActiveRigId);
@@ -4245,9 +4251,16 @@ function formatSignal(sUnits) {
return overDb === 0 ? `${sigUnit("S")}9` : `${sigUnit("S")}9+${overDb}${sigUnit("dB")}`;
}
function setDisabled(disabled) {
[freqEl, centerFreqEl, modeEl, pttBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => {
if (el) el.disabled = disabled;
const controlDisabled = disabled || authEnabled && !hasAuthRole2("control");
const transmitDisabled = disabled || authEnabled && !hasAuthRole2("transmit");
[freqEl, centerFreqEl, modeEl, powerBtn, lockBtn].forEach((el) => {
if (el) el.disabled = controlDisabled;
});
[pttBtn, txLimitInput, txLimitBtn].forEach((el) => {
if (el) el.disabled = transmitDisabled;
});
const transmitAudio = document.getElementById("tx-audio-btn");
if (transmitAudio) transmitAudio.disabled = transmitDisabled || !hasWebCodecs;
syncModePicker();
}
var serverVersion = null;
@@ -4510,7 +4523,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 (!hasAuthRole2("control")) {
showHint("Read-only session — link not applied", 2500);
return;
}
@@ -4745,6 +4758,13 @@ function render(update) {
}
}
}
if (typeof update.filter.sdr_dig_sideband === "string") {
sdrDigSidebandSupported = true;
if (sdrDigSidebandEl && document.activeElement !== sdrDigSidebandEl) {
sdrDigSidebandEl.value = update.filter.sdr_dig_sideband;
}
updateWfmControls();
}
}
if (typeof update.show_sdr_gain_control === "boolean") {
if (sdrSettingsRowEl) sdrSettingsRowEl.style.display = update.show_sdr_gain_control ? "" : "none";
@@ -5228,7 +5248,7 @@ async function postPath(path, options = {}) {
}
const resp = await fetch(path, { method: "POST" });
if (authEnabled && resp.status === 401) {
authRole = null;
setAuthRoles([]);
if (es) es.close();
showAuthGate();
throw new Error("Authentication required");
@@ -5253,7 +5273,7 @@ async function switchRigFromSelect(selectEl) {
showHint("No rig selected", 1500);
return;
}
if (authRole === "rx") {
if (!hasAuthRole2("control")) {
showHint("Control role required", 1500);
return;
}
@@ -5867,8 +5887,13 @@ function navigateToTab(name, options = {}) {
window.trxUi?.closeMobileOverlays?.();
const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
const { updateHistory = true, replaceHistory = false } = options;
if (authEnabled && !authRole && name !== "main") {
showAuthGate(false);
if (authEnabled && authRoles.length === 0 && name !== "main") {
showAuthGate();
return;
}
if (authEnabled && name === "logbook" && !hasAuthRole2("write")) {
showHint("Write role required for logbook access", 2500);
navigateToTab("main", options);
return;
}
const btn = document.querySelector(`.tab-bar .tab[data-tab="${name}"]`);
@@ -5981,11 +6006,11 @@ window.addEventListener("resize", () => {
scheduleSpectrumLayout();
});
async function initializeApp() {
showAuthGate(false);
showAuthGate();
const authStatus = await checkAuthStatus();
authEnabled = !authStatus.auth_disabled;
if (!authEnabled) {
authRole = "control";
setAuthRoles(AUTH_ADMIN_ROLES);
hideAuthGate();
updateAuthUI();
connect();
@@ -5996,7 +6021,8 @@ async function initializeApp() {
return;
}
if (authStatus.authenticated) {
authRole = authStatus.role ?? null;
setAuthRoles(authStatus.roles);
authUsername = authStatus.username ?? null;
hideAuthGate();
updateAuthUI();
applyAuthRestrictions();
@@ -6006,32 +6032,191 @@ async function initializeApp() {
resizeHeaderSignalCanvas();
startHeaderSignalSampling();
} else {
const allowGuest = authStatus.role === "rx";
showAuthGate(allowGuest);
showAuthGate();
}
}
var settingsUiReady = false;
function initSettingsUI() {
settingsUiReady = true;
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRole);
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRoles);
window.trx.modules.scheduler?.wireEvents();
if (window.trx.modules.backgroundDecode) {
window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRole);
window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRoles);
window.trx.modules.backgroundDecode.wireEvents();
}
void refreshUserManagement();
}
async function refreshUserManagement() {
const section = document.getElementById("user-management");
const tab = document.getElementById("settings-users-tab");
if (!section || !tab) return;
const canManageUsers = authEnabled && hasAuthRole2("administrator");
tab.style.display = canManageUsers ? "" : "none";
if (!canManageUsers) {
const panel = document.getElementById("subtab-settings-users");
if (panel) panel.style.display = "none";
if (tab.classList.contains("active")) {
document.querySelector('[data-subtab="settings-scheduler"]')?.click();
}
return;
}
const list = requiredElement("user-list");
try {
const users = await listUsers();
const enabledAdminCount = users.filter((user) => user.enabled && hasAuthRole(user.roles, "administrator")).length;
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";
if (!user.enabled) name.textContent += " (disabled)";
const { element: roles, inputs: roleInputs } = buildRoleChoices(user.roles);
const enabledLabel = document.createElement("label");
enabledLabel.className = "auth-role-choice";
const enabled = document.createElement("input");
enabled.type = "checkbox";
enabled.checked = user.enabled;
enabled.disabled = user.username === authUsername;
if (enabled.disabled) enabled.title = "You cannot disable your current account";
enabledLabel.append(enabled, " Enabled");
const isOnlyAdmin = user.enabled && hasAuthRole(user.roles, "administrator") && enabledAdminCount === 1;
const administratorInput = roleInputs.find((item) => item.value === "administrator")?.input;
const guestInput = roleInputs.find((item) => item.value === "guest")?.input;
if (isOnlyAdmin && administratorInput) {
administratorInput.disabled = true;
administratorInput.title = "The final administrator cannot be demoted";
}
if (isOnlyAdmin && guestInput) {
guestInput.disabled = true;
guestInput.title = "The final administrator cannot become a Guest";
}
if (isOnlyAdmin) {
enabled.disabled = true;
enabled.title = "The final enabled administrator cannot be disabled";
}
const password = document.createElement("input");
password.type = "password";
password.placeholder = "New password (8+ characters)";
password.autocomplete = "new-password";
password.className = "auth-input";
password.minLength = 8;
password.maxLength = 1024;
const syncPasswordAccess = () => {
const guestActive = guestInput?.checked === true;
password.disabled = guestActive;
password.title = guestActive ? "Password changes are unavailable while the Guest role is active" : "";
if (guestActive) password.value = "";
};
roleInputs.forEach(({ input }) => input.addEventListener("change", syncPasswordAccess));
syncPasswordAccess();
const save = document.createElement("button");
save.type = "button";
save.textContent = "Save";
save.addEventListener("click", async () => {
const changes = {
roles: roleInputs.filter(({ input }) => input.checked).map(({ value }) => value),
enabled: enabled.checked
};
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 || isOnlyAdmin;
if (isOnlyAdmin) remove.title = "The final administrator cannot be removed";
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, enabledLabel, roles, 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);
}
}
var createRoleContainer = document.getElementById("user-create-roles");
if (createRoleContainer) {
const { element } = buildRoleChoices(["read"]);
element.id = createRoleContainer.id;
createRoleContainer.replaceWith(element);
}
document.getElementById("user-create-form")?.addEventListener("submit", (event) => {
event.preventDefault();
const username = requiredElement("user-create-username");
const password = requiredElement("user-create-password");
const enabled = requiredElement("user-create-enabled");
const roles = Array.from(document.querySelectorAll("#user-create-roles input[type=checkbox]"));
void runUserOperation(async () => {
await createUser(username.value, password.value, roles.filter((input) => input.checked).map((input) => input.value), enabled.checked);
username.value = "";
password.value = "";
enabled.checked = true;
roles.forEach((input) => {
input.checked = input.value === "read";
});
});
});
document.getElementById("account-password-form")?.addEventListener("submit", (event) => {
event.preventDefault();
const form = event.currentTarget;
const currentPassword = requiredElement("account-current-password");
const newPassword = requiredElement("account-new-password");
const confirmPassword = requiredElement("account-confirm-password");
const error = requiredElement("account-password-error");
const submit = form.querySelector('button[type="submit"]');
if (newPassword.value !== confirmPassword.value) {
error.textContent = "New passwords do not match";
error.style.display = "block";
return;
}
if (submit) submit.disabled = true;
void changeOwnPassword(currentPassword.value, newPassword.value).then(async () => {
form.reset();
error.style.display = "none";
await authLogout();
showHint("Password changed. Sign in again.", 3e3);
}).catch((reason) => {
error.textContent = reason instanceof Error ? reason.message : String(reason);
error.style.display = "block";
}).finally(() => {
if (submit) submit.disabled = false;
});
});
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);
authRole = result.role ?? null;
passphraseEl.value = "";
const result = await authLogin(usernameEl.value, passwordEl.value);
setAuthRoles(result.roles);
authUsername = result.username ?? usernameEl.value;
passwordEl.value = "";
hideAuthGate();
updateAuthUI();
applyAuthRestrictions();
@@ -6041,37 +6226,22 @@ 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 () => {
if (authRole) {
if (authRoles.length > 0) {
if (await window.trxUi.confirm({ title: "Log out?", message: "Audio and control access for this browser session will end.", confirmLabel: "Log out", danger: false })) {
await authLogout();
}
} else {
showAuthGate(false);
showAuthGate();
}
});
}
@@ -6109,8 +6279,8 @@ Object.defineProperties(trxState, {
authEnabled: { get() {
return authEnabled;
} },
authRole: { get() {
return authRole;
authRoles: { get() {
return authRoles;
} },
decoderRegistry: { get() {
return decoderRegistry;
@@ -6461,6 +6631,9 @@ var sdrNbThresholdControlsEl = document.getElementById("sdr-nb-threshold-control
var sdrNbThresholdEl = document.getElementById("sdr-nb-threshold");
var sdrNbThresholdSetBtn = document.getElementById("sdr-nb-threshold-set");
var sdrNbSupported = false;
var sdrDigSidebandWrapEl = document.getElementById("sdr-dig-sideband-wrap");
var sdrDigSidebandEl = document.getElementById("sdr-dig-sideband");
var sdrDigSidebandSupported = false;
fetch("/audio", { method: "GET" }).then((r) => {
if (r.status === 404) audioRow.style.display = "none";
}).catch(() => {
@@ -6856,11 +7029,26 @@ if (sdrNbThresholdEl) {
}
});
}
function submitSdrDigSideband() {
if (!sdrDigSidebandSupported || !sdrDigSidebandEl) return;
const policy = sdrDigSidebandEl.value || "auto";
if (policy !== "auto" && policy !== "usb" && policy !== "lsb") return;
postPath(`/set_sdr_dig_sideband?policy=${encodeURIComponent(policy)}`).catch(() => {
});
}
if (sdrDigSidebandEl) {
sdrDigSidebandEl.addEventListener("change", () => {
submitSdrDigSideband();
});
}
function updateWfmControls() {
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
if (wfmControlsCol) wfmControlsCol.style.display = mode === "WFM" ? "" : "none";
if (samControlsCol) samControlsCol.style.display = mode === "SAM" ? "" : "none";
if (modeControlsRow) modeControlsRow.style.display = mode === "WFM" || mode === "SAM" ? "" : "none";
if (sdrDigSidebandWrapEl) {
sdrDigSidebandWrapEl.style.display = sdrDigSidebandSupported && mode === "DIG" ? "" : "none";
}
}
if (!hasWebCodecs) {
rxAudioBtn.disabled = true;
@@ -7182,6 +7370,10 @@ function startTxAudio() {
void stopTxAudio();
return;
}
if (authEnabled && !hasAuthRole2("transmit")) {
audioStatus.textContent = "Transmit role required";
return;
}
if (!hasWebCodecs) {
audioStatus.textContent = "Audio requires Chrome/Edge";
return;
@@ -1,3 +1,6 @@
import {
hasAuthRole
} from "./chunk-PISLBJGN.js";
import {
hostState
} from "./chunk-KL66PICH.js";
@@ -13,7 +16,7 @@ var bgdWindow = window;
return d.id;
});
}
let backgroundDecodeRole = null;
let backgroundDecodeRoles = [];
let currentRigId = null;
let currentConfig = null;
let bookmarkList = [];
@@ -21,8 +24,8 @@ var bgdWindow = window;
let bgdDirty = false;
let statusByBookmark = /* @__PURE__ */ new Map();
let lastStatus = null;
function initBackgroundDecode(rigId, role) {
backgroundDecodeRole = role;
function initBackgroundDecode(rigId, roles) {
backgroundDecodeRoles = roles;
currentRigId = rigId || hostState.lastActiveRigId || null;
if (currentRigId) loadBackgroundDecode();
startStatusPolling();
@@ -358,7 +361,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 hasAuthRole(backgroundDecodeRoles, "control") || hostState.authEnabled === false;
}
function showToast(msg, isError) {
const el = document.getElementById("background-decode-toast");
@@ -1,3 +1,6 @@
import {
hasAuthRole
} from "./chunk-PISLBJGN.js";
import {
hostCore,
hostState
@@ -42,7 +45,7 @@ function bmEsc(str) {
return d.innerHTML;
}
function bmCanControl() {
return !hostState.authEnabled || hostState.authRole === "control";
return !hostState.authEnabled || hasAuthRole(hostState.authRoles, "write");
}
function bmSyncAccess() {
const canCtrl = bmCanControl();
@@ -211,6 +214,12 @@ function bmBuildDecoderCheckboxes() {
container.appendChild(label);
});
}
function bmSyncDigSidebandVisibility() {
const label = bmOptionalEl("bm-dig-sideband-label");
if (!label) return;
const mode = (bmEl("bm-mode").value || "").trim().toUpperCase();
label.style.display = mode === "DIG" ? "" : "none";
}
function bmOpenForm(bm) {
const wrap = bmEl("bm-form-wrap");
if (!wrap) return;
@@ -224,7 +233,9 @@ function bmOpenForm(bm) {
bmEl("bm-locator").value = bm ? bm.locator || "" : "";
bmEl("bm-category-input").value = bm ? bm.category || "" : "";
bmEl("bm-comment").value = bm ? bm.comment || "" : "";
bmEl("bm-dig-sideband").value = bm ? bm.dig_sideband || "" : "";
bmWriteDecoders(bm?.decoders ?? []);
bmSyncDigSidebandVisibility();
bmEl("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark";
wrap.style.display = "flex";
bmEl("bm-name").focus();
@@ -260,6 +271,7 @@ async function bmSave(e) {
const category = bmEl("bm-category-input").value.trim();
const comment = bmEl("bm-comment").value.trim();
const decoders = bmReadDecoders();
const dig_sideband = mode.toUpperCase() === "DIG" ? bmEl("bm-dig-sideband").value || null : null;
const formError = bmEl("bm-form-error");
if (formError) formError.textContent = "";
if (!name || !Number.isFinite(freq_hz) || !mode) {
@@ -276,7 +288,8 @@ async function bmSave(e) {
locator: locator || null,
category,
comment,
decoders
decoders,
dig_sideband
};
try {
let resp;
@@ -341,6 +354,12 @@ function bmApply(bm) {
}
const tunePromise = (async () => {
await bridge.trx.modules.vchan?.takeSchedulerControl();
if ((bm.mode || "").toUpperCase() === "DIG" && bm.dig_sideband) {
const p = bm.dig_sideband.toLowerCase();
if (p === "auto" || p === "usb" || p === "lsb") {
await hostCore.postPath("/set_sdr_dig_sideband?policy=" + encodeURIComponent(p));
}
}
const onVirtual = await bridge.trx.modules.vchan?.interceptMode(bm.mode) ?? false;
if (!onVirtual) {
await hostCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
@@ -593,6 +612,7 @@ function bmPopulateScopePicker() {
bmEl("bm-form").addEventListener("submit", (event) => {
void bmSave(event);
});
bmEl("bm-mode").addEventListener("input", bmSyncDigSidebandVisibility);
bmEl("bm-form-cancel").addEventListener("click", bmCloseForm);
const formWrap = bmEl("bm-form-wrap");
if (formWrap) {
@@ -0,0 +1,136 @@
// src/api/auth.ts
var AUTH_ROLES = ["guest", "read", "control", "transmit", "write", "administrator"];
var AUTH_ADMIN_ROLES = AUTH_ROLES.filter((role) => role !== "guest");
var AUTH_ROLE_LABELS = {
guest: "Guest",
read: "Read",
control: "Control",
transmit: "Transmit",
write: "Write",
administrator: "Administrator"
};
function isAuthRole(value) {
return typeof value === "string" && AUTH_ROLES.includes(value);
}
function normalizeAuthRoles(roles) {
return AUTH_ROLES.filter((role) => roles.includes(role));
}
function hasAuthRole(roles, required) {
return roles.includes("administrator") || roles.includes(required) || required === "read" && roles.includes("guest") || required === "read" && (roles.includes("control") || roles.includes("transmit"));
}
function hasAccountControls(roles) {
return roles.length > 0 && !roles.includes("guest");
}
function decodeRoles(value, context) {
if (!Array.isArray(value) || !value.every(isAuthRole)) {
throw new TypeError(`${context} has invalid roles`);
}
return normalizeAuthRoles(value);
}
function decodeAuthSession(value) {
if (typeof value !== "object" || value === null) {
throw new TypeError("The authentication response is malformed");
}
const session = value;
if (typeof session.authenticated !== "boolean") {
throw new TypeError("The authentication response has no authenticated flag");
}
if (session.auth_disabled !== void 0 && typeof session.auth_disabled !== "boolean") {
throw new TypeError("The authentication response has an invalid auth_disabled flag");
}
const decoded = {
authenticated: session.authenticated,
roles: decodeRoles(session.roles, "The authentication response")
};
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,
roles: [...AUTH_ADMIN_ROLES],
auth_disabled: true
};
async function fetchAuthSession() {
try {
const response = await fetch("/auth/session");
if (response.status === 404) return authDisabledSession;
if (!response.ok) return { authenticated: false, roles: [] };
return decodeAuthSession(await response.json());
} catch (error) {
console.error("Auth check failed:", error);
return { authenticated: false, roles: [] };
}
}
async function login(username, password) {
const response = await fetch("/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password })
});
if (response.status === 404) return authDisabledSession;
if (!response.ok) {
const message = await response.text();
throw new Error(message || "Login failed");
}
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" && typeof record.enabled === "boolean" && Array.isArray(record.roles) && record.roles.every(isAuthRole);
})) {
throw new TypeError("The user list response is malformed");
}
return value.map((user) => ({ ...user, roles: normalizeAuthRoles(user.roles) }));
}
async function createUser(username, password, roles, enabled = true) {
await userRequest("/auth/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password, roles, enabled }) });
}
async function updateUser(username, changes) {
await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(changes) });
}
async function changeOwnPassword(currentPassword, newPassword) {
await userRequest("/auth/account/password", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword })
});
}
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");
}
export {
AUTH_ROLES,
AUTH_ADMIN_ROLES,
AUTH_ROLE_LABELS,
normalizeAuthRoles,
hasAuthRole,
hasAccountControls,
fetchAuthSession,
login,
listUsers,
createUser,
updateUser,
changeOwnPassword,
deleteUser,
logout
};
@@ -1,3 +1,6 @@
import {
hasAuthRole
} from "./chunk-PISLBJGN.js";
import {
hostCore,
hostState
@@ -47,6 +50,9 @@ var entryRigName = null;
var entryGrid = null;
var qsos = [];
var workedRequest = 0;
function canWriteLogbook() {
return !hostState.authEnabled || hasAuthRole(hostState.authRoles, "write");
}
function notify(message, kind) {
if (bridge.trxUi.notify) bridge.trxUi.notify(message, kind ? { kind } : void 0);
else hostCore.showHint(message, 2e3);
@@ -300,6 +306,11 @@ function renderRows() {
row.appendChild(cell);
}
const actions = document.createElement("td");
if (!canWriteLogbook()) {
row.appendChild(actions);
fragment.appendChild(row);
continue;
}
const confirm = document.createElement("button");
confirm.type = "button";
confirm.className = "log-row-btn";
@@ -420,14 +431,19 @@ importFile?.addEventListener("change", () => {
if (file) void importAdif(file);
importFile.value = "";
});
bridge.logContact = (seed) => {
if (canWriteLogbook()) {
bridge.logContact = (seed) => {
bridge.navigateToTab?.("logbook");
void openEntry(seed).then(() => callInput?.focus());
};
};
} else {
if (form) form.style.display = "none";
if (importBtn) importBtn.style.display = "none";
}
renderStation();
if (cabrilloCallsign && !cabrilloCallsign.value) {
cabrilloCallsign.value = stationCallEl?.textContent?.trim() ?? "";
}
syncCabrilloLink();
void openEntry();
if (canWriteLogbook()) void openEntry();
void refreshLog();
@@ -1,3 +1,6 @@
import {
hasAuthRole
} from "./chunk-PISLBJGN.js";
import {
hostState
} from "./chunk-KL66PICH.js";
@@ -15,7 +18,7 @@ function schedulerOptionalEl(id) {
}
(function() {
"use strict";
let schedulerRole = null;
let schedulerRoles = [];
let currentRigId = null;
let currentConfig = null;
let currentSchedulerStatus = null;
@@ -25,8 +28,8 @@ function schedulerOptionalEl(id) {
let schedulerStepPending = false;
let schEntryEditIdx = null;
let schedulerDirty = false;
function initScheduler(rigId, role) {
schedulerRole = role;
function initScheduler(rigId, roles) {
schedulerRoles = roles;
currentRigId = rigId || null;
if (currentRigId) loadScheduler();
startStatusPolling();
@@ -272,7 +275,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 = hasAuthRole(schedulerRoles, "control") && !!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 +357,7 @@ function schedulerOptionalEl(id) {
const panel = schedulerEl("scheduler-panel");
if (!panel) return;
const mode = currentConfig && currentConfig.mode || "disabled";
const isControl = schedulerRole === "control";
const isControl = hasAuthRole(schedulerRoles, "control");
setSelected("scheduler-mode-select", mode);
const satEnabled = currentConfig && currentConfig.satellites && currentConfig.satellites.enabled;
const controlRow = document.querySelector(".scheduler-control-row");
@@ -1220,8 +1223,8 @@ function schedulerOptionalEl(id) {
markDirty: markSchedulerDirty
};
schedulerWindow.trx.modules.scheduler = schedulerService;
if (hostState.authRole != null) {
initScheduler(hostState.lastActiveRigId, hostState.authRole);
if (!hostState.authEnabled || hostState.authRoles.length > 0) {
initScheduler(hostState.lastActiveRigId, hostState.authRoles);
wireSchedulerEvents();
}
})();
@@ -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" maxlength="1024" 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>
@@ -385,6 +385,14 @@ SPDX-License-Identifier: GPL-2.0-or-later
</label>
<button id="sdr-nb-threshold-set" type="button" class="wfm-inline-btn">Set</button>
</div>
<label class="wfm-control" id="sdr-dig-sideband-wrap" style="display:none;">
<span class="wfm-control-label" title="Sideband used to demodulate DIG. Auto = USB &ge; 10 MHz, LSB below.">DIG sideband</span>
<select id="sdr-dig-sideband" class="status-input">
<option value="auto">Auto (by band)</option>
<option value="usb">USB</option>
<option value="lsb">LSB</option>
</select>
</label>
</div>
</div>
<div class="full-row label-below-row" id="vchan-row">
@@ -721,6 +729,14 @@ SPDX-License-Identifier: GPL-2.0-or-later
<label class="bm-label">Bandwidth (Hz)
<input type="number" id="bm-bw" class="status-input" min="0" placeholder="optional" />
</label>
<label class="bm-label" id="bm-dig-sideband-label" style="display:none;">DIG sideband
<select id="bm-dig-sideband" class="status-input">
<option value="">Global setting</option>
<option value="auto">Auto (by band)</option>
<option value="usb">USB</option>
<option value="lsb">LSB</option>
</select>
</label>
<label class="bm-label">Locator
<input type="text" id="bm-locator" class="status-input" maxlength="6" placeholder="e.g. JO93" />
</label>
@@ -1461,6 +1477,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
<button class="sub-tab" data-subtab="settings-background-decode">Background Decode</button>
<button class="sub-tab" data-subtab="settings-bandplan">Bandplan</button>
<button class="sub-tab" data-subtab="settings-history">History</button>
<button id="settings-account-tab" class="sub-tab" data-subtab="settings-account" style="display:none;">Account</button>
<button id="settings-users-tab" class="sub-tab" data-subtab="settings-users" style="display:none;">Users</button>
</div>
<div id="subtab-settings-scheduler" class="sub-tab-panel">
<div id="scheduler-panel" class="sch-panel">
@@ -1750,6 +1768,34 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div>
</div>
</div>
<div id="subtab-settings-account" class="sub-tab-panel" style="display:none;">
<div class="settings-card">
<h3>Change password</h3>
<form id="account-password-form" class="sch-row" style="flex-wrap:wrap; gap:.5rem;">
<input id="account-current-password" class="auth-input" type="password" placeholder="Current password" autocomplete="current-password" maxlength="1024" required />
<input id="account-new-password" class="auth-input" type="password" placeholder="New password (8+ characters)" autocomplete="new-password" minlength="8" maxlength="1024" required />
<input id="account-confirm-password" class="auth-input" type="password" placeholder="Confirm new password" autocomplete="new-password" minlength="8" maxlength="1024" required />
<button type="submit" class="auth-submit">Change password</button>
</form>
<div id="account-password-error" class="auth-error" role="alert" aria-live="polite" style="display:none;"></div>
<p class="settings-note">Changing your password signs out every session for this account.</p>
</div>
</div>
<div id="subtab-settings-users" class="sub-tab-panel" style="display:none;">
<div id="user-management">
<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" maxlength="1024" required />
<span id="user-create-roles" class="auth-role-choices"></span>
<label class="auth-role-choice"><input id="user-create-enabled" type="checkbox" checked /> Enabled</label>
<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>
</div>
</div>
</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,14 @@ 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 .auth-role-choices { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
#user-management .auth-role-choice { display: inline-flex; align-items: center; gap: .2rem; white-space: nowrap; }
.settings-note { color: var(--text-muted); font-size: .85rem; margin: .75rem 0 0; }
#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 {
@@ -12,9 +12,12 @@ use trx_core::rig::{
RigAccessMethod, RigCapabilities, RigInfo, RigRxStatus, RigStatus, RigTxStatus, RigVfo,
RigVfoEntry,
};
use trx_core::{DecoderConfig, RdsData, RigFilterState, RigMode, RigSnapshot, WfmDenoiseLevel};
use trx_core::{
DecoderConfig, DigSidebandPolicy, RdsData, RigFilterState, RigMode, RigSnapshot, WfmDenoiseLevel,
};
use trx_frontend_http::server::api::rig::{RigListItem, RigListResponse};
use trx_frontend_http::server::api::FrontendMeta;
use trx_frontend_http::server::auth::AuthRole;
use trx_protocol::{DecoderActivation, DecoderDescriptor};
use ts_rs::{Config, TS};
@@ -48,6 +51,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
export!(RigStatus);
export!(DecoderConfig);
export!(WfmDenoiseLevel);
export!(DigSidebandPolicy);
export!(RigFilterState);
export!(RdsData);
export!(SpectrumData);
@@ -56,6 +60,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
export!(RigListItem);
export!(RigListResponse);
export!(FrontendMeta);
export!(AuthRole);
export!(DecoderActivation);
export!(DecoderDescriptor);
@@ -12,7 +12,7 @@
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
"test": "node --test tests/*.test.mjs",
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs && node tests/mobile-layout.mjs && node tests/satellite-predictions.mjs && node tests/background-decode.mjs && node tests/logbook.mjs",
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs && node tests/mobile-layout.mjs && node tests/satellite-predictions.mjs && node tests/background-decode.mjs && node tests/logbook.mjs && node tests/account-management.mjs && node tests/transmit-role.mjs",
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
},
"devDependencies": {
@@ -2,11 +2,51 @@
//
// SPDX-License-Identifier: GPL-2.0-or-later
export type AuthRole = "rx" | "control";
import type { AuthRole } from "./generated.js";
export type { AuthRole };
export const AUTH_ROLES: readonly AuthRole[] = ["guest", "read", "control", "transmit", "write", "administrator"];
export const AUTH_ADMIN_ROLES: readonly AuthRole[] = AUTH_ROLES.filter(role => role !== "guest");
export const AUTH_ROLE_LABELS: Readonly<Record<AuthRole, string>> = {
guest: "Guest",
read: "Read",
control: "Control",
transmit: "Transmit",
write: "Write",
administrator: "Administrator",
};
export function isAuthRole(value: unknown): value is AuthRole {
return typeof value === "string" && (AUTH_ROLES as readonly string[]).includes(value);
}
export function normalizeAuthRoles(roles: readonly AuthRole[]): AuthRole[] {
return AUTH_ROLES.filter(role => roles.includes(role));
}
export function hasAuthRole(roles: readonly AuthRole[], required: AuthRole): boolean {
return roles.includes("administrator")
|| roles.includes(required)
|| required === "read" && roles.includes("guest")
|| required === "read" && (roles.includes("control") || roles.includes("transmit"));
}
export function hasAccountControls(roles: readonly AuthRole[]): boolean {
return roles.length > 0 && !roles.includes("guest");
}
function decodeRoles(value: unknown, context: string): AuthRole[] {
if (!Array.isArray(value) || !value.every(isAuthRole)) {
throw new TypeError(`${context} has invalid roles`);
}
return normalizeAuthRoles(value);
}
export interface AuthSession {
authenticated: boolean;
role?: AuthRole;
roles: AuthRole[];
username?: string;
auth_disabled?: boolean;
}
@@ -18,21 +58,24 @@ 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") {
throw new TypeError("The authentication response has an invalid role");
}
if (session.auth_disabled !== undefined && typeof session.auth_disabled !== "boolean") {
throw new TypeError("The authentication response has an invalid auth_disabled flag");
}
const decoded: AuthSession = { authenticated: session.authenticated };
if (session.role !== undefined) decoded.role = session.role;
const decoded: AuthSession = {
authenticated: session.authenticated,
roles: decodeRoles(session.roles, "The authentication response"),
};
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",
roles: [...AUTH_ADMIN_ROLES],
auth_disabled: true,
};
@@ -40,19 +83,19 @@ export async function fetchAuthSession(): Promise<AuthSession> {
try {
const response = await fetch("/auth/session");
if (response.status === 404) return authDisabledSession;
if (!response.ok) return { authenticated: false };
if (!response.ok) return { authenticated: false, roles: [] };
return decodeAuthSession(await response.json());
} catch (error: unknown) {
console.error("Auth check failed:", error);
return { authenticated: false };
return { authenticated: false, roles: [] };
}
}
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 +105,52 @@ export async function login(passphrase: string): Promise<AuthSession> {
return decodeAuthSession(await response.json());
}
export interface ManagedUser { username: string; roles: AuthRole[]; enabled: boolean }
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"
&& typeof record.enabled === "boolean"
&& Array.isArray(record.roles)
&& record.roles.every(isAuthRole);
})) {
throw new TypeError("The user list response is malformed");
}
return (value as ManagedUser[]).map(user => ({ ...user, roles: normalizeAuthRoles(user.roles) }));
}
export async function createUser(username: string, password: string, roles: AuthRole[], enabled = true): Promise<void> {
await userRequest("/auth/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password, roles, enabled }) });
}
export async function updateUser(username: string, changes: { password?: string; roles?: AuthRole[]; enabled?: boolean }): Promise<void> {
await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(changes) });
}
export async function changeOwnPassword(currentPassword: string, newPassword: string): Promise<void> {
await userRequest("/auth/account/password", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
});
}
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");
@@ -55,7 +55,14 @@ export type DecoderConfig = { aprs_decode_enabled: boolean, hf_aprs_decode_enabl
export type WfmDenoiseLevel = "off" | "auto" | "low" | "medium" | "high";
export type RigFilterState = { bandwidth_hz: number, cw_center_hz: number, sdr_gain_db?: number | null, sdr_lna_gain_db?: number | null, sdr_agc_enabled?: boolean | null, sdr_squelch_enabled?: boolean | null, sdr_squelch_threshold_db?: number | null, sdr_nb_enabled?: boolean | null, sdr_nb_threshold?: number | null, wfm_deemphasis_us: number, wfm_stereo: boolean, wfm_stereo_detected: boolean, wfm_denoise: WfmDenoiseLevel,
export type DigSidebandPolicy = "auto" | "usb" | "lsb";
export type RigFilterState = { bandwidth_hz: number, cw_center_hz: number, sdr_gain_db?: number | null, sdr_lna_gain_db?: number | null, sdr_agc_enabled?: boolean | null, sdr_squelch_enabled?: boolean | null, sdr_squelch_threshold_db?: number | null, sdr_nb_enabled?: boolean | null, sdr_nb_threshold?: number | null,
/**
* Current DIG sideband policy (SDR backends only). Surfaces in the UI as
* the advanced-controls "DIG sideband" selector.
*/
sdr_dig_sideband?: DigSidebandPolicy | null, wfm_deemphasis_us: number, wfm_stereo: boolean, wfm_stereo_detected: boolean, wfm_denoise: WfmDenoiseLevel,
/**
* Co-Channel Interference level (0100 scale).
*/
@@ -123,6 +130,8 @@ export type RigListResponse = { active_remote: string | null, rigs: Array<RigLis
export type FrontendMeta = { clients: number, rigctl_clients: number, audio_clients: number, rigctl_addr: string | null, active_remote: string | null, remotes: Array<string>, owner_callsign: string | null, owner_website_url: string | null, owner_website_name: string | null, ais_vessel_url_base: string | null, show_sdr_gain_control: boolean, initial_map_zoom: number, spectrum_coverage_margin_hz: number, spectrum_usable_span_ratio: number, bandplan_enabled: boolean, bandplan_region: string, decode_history_retention_min: bigint, server_connected: boolean, };
export type AuthRole = "guest" | "read" | "control" | "transmit" | "write" | "administrator";
export type DecoderActivation = "mode_bound" | "toggle";
export type DecoderDescriptor = {
@@ -21,6 +21,17 @@ import {
fetchAuthSession,
login,
logout,
listUsers,
createUser,
updateUser,
deleteUser,
changeOwnPassword,
AUTH_ADMIN_ROLES,
AUTH_ROLES,
AUTH_ROLE_LABELS,
hasAccountControls,
hasAuthRole as rolesInclude,
normalizeAuthRoles,
} from "./api/auth.js";
import {
formatByteSize as recorderFormatSize,
@@ -189,8 +200,8 @@ interface TrxModules {
reverseGeocodeLocation(lat: number, lon: number, grid: string): void;
bandForHz(frequencyHz: number): unknown;
};
scheduler?: { initialize(rigId: string | null, role: AuthRole | null): void; setRig(rigId: string | null): void; wireEvents(): void };
backgroundDecode?: { initialize(rigId: string | null, role: AuthRole | null): void; setRig(rigId: string | null): void; wireEvents(): void };
scheduler?: { initialize(rigId: string | null, roles: readonly AuthRole[]): void; setRig(rigId: string | null): void; wireEvents(): void };
backgroundDecode?: { initialize(rigId: string | null, roles: readonly AuthRole[]): void; setRig(rigId: string | null): void; wireEvents(): void };
bookmarks?: {
readonly overlayList: readonly Bookmark[];
readonly overlayRevision: number;
@@ -226,7 +237,7 @@ interface TrxState {
readonly initialMapZoom: number;
readonly decodeHistoryRetentionMin: number;
readonly authEnabled: boolean;
readonly authRole: AuthRole | null;
readonly authRoles: readonly AuthRole[];
readonly decoderRegistry: typeof decoderRegistry;
readonly sseSessionId: string | null;
readonly primaryRds: RdsData | null;
@@ -406,40 +417,76 @@ declare global {
void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
// --- Authentication ---
let authRole: AuthRole | null = null;
let authRoles: AuthRole[] = [];
let authUsername: string | null = null;
let authEnabled = true;
function setAuthRoles(roles: readonly AuthRole[]) {
authRoles = normalizeAuthRoles(roles);
}
function hasAuthRole(role: AuthRole) {
return rolesInclude(authRoles, role);
}
function buildRoleChoices(selected: readonly AuthRole[]) {
const element = document.createElement("span");
element.className = "auth-role-choices";
const inputs = AUTH_ROLES.map((value) => {
const label = document.createElement("label");
label.className = "auth-role-choice";
const input = document.createElement("input");
input.type = "checkbox";
input.value = value;
input.checked = selected.includes(value);
label.append(input, ` ${AUTH_ROLE_LABELS[value]}`);
element.append(label);
return { input, value };
});
inputs.forEach(({ input, value }) => {
input.addEventListener("change", () => {
if (!input.checked) return;
if (value === "guest") {
const administrator = inputs.find(choice => choice.value === "administrator");
if (administrator) administrator.input.checked = false;
} else if (value === "administrator") {
const guest = inputs.find(choice => choice.value === "guest");
if (guest) guest.input.checked = false;
}
});
});
return { element, inputs };
}
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;
setAuthRoles([]);
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 +506,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");
});
@@ -506,22 +547,32 @@ function updateAuthUI() {
const badge = document.getElementById("auth-badge");
const badgeRole = document.getElementById("auth-role-badge");
const headerAuthBtn = document.getElementById("header-auth-btn");
const accountTab = document.getElementById("settings-account-tab");
if (!authEnabled) {
if (badge) badge.style.display = "none";
if (headerAuthBtn) headerAuthBtn.style.display = "none";
if (accountTab) accountTab.style.display = "none";
syncTopBarAccess();
return;
}
if (authRole) {
if (authRoles.length > 0) {
const canManageAccount = hasAccountControls(authRoles);
if (accountTab) accountTab.style.display = canManageAccount ? "" : "none";
if (!canManageAccount && accountTab?.classList.contains("active")) {
const panel = document.getElementById("subtab-settings-account");
if (panel) panel.style.display = "none";
document.querySelector<HTMLButtonElement>('[data-subtab="settings-scheduler"]')?.click();
}
if (badge) badge.style.display = "block";
if (badgeRole) badgeRole.textContent = authRole === "control" ? "Control (full access)" : "RX (read-only)";
if (badgeRole) badgeRole.textContent = `${authUsername || "local"}${authRoles.map(role => AUTH_ROLE_LABELS[role]).join(", ")}`;
if (headerAuthBtn) {
headerAuthBtn.textContent = "Logout";
headerAuthBtn.style.display = "block";
}
} else {
if (accountTab) accountTab.style.display = "none";
if (badge) badge.style.display = "none";
if (headerAuthBtn) {
headerAuthBtn.textContent = "Login";
@@ -532,37 +583,40 @@ function updateAuthUI() {
}
function applyAuthRestrictions() {
if (!authRole) return;
if (authRoles.length === 0) return;
// Disable TX/PTT/frequency/mode/VFO controls for rx role
if (authRole === "rx") {
if (!hasAuthRole("transmit")) {
const pttBtn = document.getElementById("ptt-btn") as HTMLButtonElement | null;
const txLimitInput = document.getElementById("tx-limit") as HTMLInputElement | null;
const txLimitBtn = document.getElementById("tx-limit-btn") as HTMLButtonElement | null;
const txAudioBtn = document.getElementById("tx-audio-btn") as HTMLButtonElement | null;
const txLimitRow = document.getElementById("tx-limit-row");
if (pttBtn) pttBtn.disabled = true;
if (txAudioBtn) txAudioBtn.disabled = true;
if (txLimitBtn) txLimitBtn.disabled = true;
if (txLimitInput) txLimitInput.disabled = true;
if (txLimitRow) txLimitRow.style.opacity = "0.5";
}
// General tuning and receive-side controls require Control.
if (!hasAuthRole("control")) {
const powerBtn = document.getElementById("power-btn") as HTMLButtonElement | null;
const lockBtn = document.getElementById("lock-btn") as HTMLButtonElement | null;
const freqInput = document.getElementById("freq") as HTMLInputElement | null;
const centerFreqInput = document.getElementById("center-freq") as HTMLInputElement | null;
const modeSelect = document.getElementById("mode") as HTMLSelectElement | null;
const txLimitInput = document.getElementById("tx-limit") as HTMLInputElement | null;
const txLimitBtn = document.getElementById("tx-limit-btn") as HTMLButtonElement | null;
const txAudioBtn = document.getElementById("tx-audio-btn") as HTMLButtonElement | null;
const txLimitRow = document.getElementById("tx-limit-row");
const jogUp = document.getElementById("jog-up") as HTMLButtonElement | null;
const jogDown = document.getElementById("jog-down") as HTMLButtonElement | null;
const jogButtons = document.querySelectorAll<HTMLButtonElement>(".jog-step button");
const vfoButtons = document.querySelectorAll<HTMLButtonElement>("#vfo-picker button");
// Disable TX buttons
if (pttBtn) pttBtn.disabled = true;
if (powerBtn) powerBtn.disabled = true;
if (lockBtn) lockBtn.disabled = true;
if (txAudioBtn) txAudioBtn.disabled = true;
if (txLimitBtn) txLimitBtn.disabled = true;
// Disable frequency/mode inputs
if (freqInput) freqInput.disabled = true;
if (centerFreqInput) centerFreqInput.disabled = true;
if (modeSelect) modeSelect.disabled = true;
if (txLimitInput) txLimitInput.disabled = true;
// Disable VFO selector
vfoButtons.forEach(btn => btn.disabled = true);
@@ -606,9 +660,6 @@ function applyAuthRestrictions() {
btn.disabled = true;
}
});
// Hide TX-specific UI but keep controls visible (disabled)
if (txLimitRow) txLimitRow.style.opacity = "0.5";
}
}
@@ -867,14 +918,15 @@ window.applyDecodeHistoryRetention = function() {
};
function syncTopBarAccess() {
const loggedOut = authEnabled && !authRole;
const loggedOut = authEnabled && authRoles.length === 0;
const tabBar = document.getElementById("tab-bar");
const rigSwitch = document.querySelector<HTMLElement>(".header-rig-switch");
if (tabBar) tabBar.style.display = "";
document.querySelectorAll<HTMLButtonElement>(".tab-bar .tab").forEach((btn) => {
const isMain = btn.dataset.tab === "main";
btn.style.display = !loggedOut || isMain ? "" : "none";
const lacksLogbookAccess = authEnabled && btn.dataset.tab === "logbook" && !hasAuthRole("write");
btn.style.display = (!loggedOut || isMain) && !lacksLogbookAccess ? "" : "none";
btn.disabled = false;
});
@@ -883,7 +935,7 @@ function syncTopBarAccess() {
}
if (headerRigSwitchSelect) {
headerRigSwitchSelect.disabled = loggedOut || authRole === "rx" || lastRigIds.length === 0;
headerRigSwitchSelect.disabled = loggedOut || !hasAuthRole("control") || lastRigIds.length === 0;
}
}
@@ -1464,7 +1516,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 || !hasAuthRole("control");
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
updateRigSubtitle(lastActiveRigId);
window.trxUi?.setActiveRig(lastActiveRigId);
@@ -3029,9 +3081,16 @@ function formatSignal(sUnits: number) {
}
function setDisabled(disabled: boolean) {
[freqEl, centerFreqEl, modeEl, pttBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => {
if (el) el.disabled = disabled;
const controlDisabled = disabled || (authEnabled && !hasAuthRole("control"));
const transmitDisabled = disabled || (authEnabled && !hasAuthRole("transmit"));
[freqEl, centerFreqEl, modeEl, powerBtn, lockBtn].forEach((el) => {
if (el) el.disabled = controlDisabled;
});
[pttBtn, txLimitInput, txLimitBtn].forEach((el) => {
if (el) el.disabled = transmitDisabled;
});
const transmitAudio = document.getElementById("tx-audio-btn") as HTMLButtonElement | null;
if (transmitAudio) transmitAudio.disabled = transmitDisabled || !hasWebCodecs;
syncModePicker();
}
@@ -3352,7 +3411,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 (!hasAuthRole("control")) {
showHint("Read-only session — link not applied", 2500);
return;
}
@@ -3622,6 +3681,13 @@ function render(update: AppUpdate) {
}
}
}
if (typeof update.filter.sdr_dig_sideband === "string") {
sdrDigSidebandSupported = true;
if (sdrDigSidebandEl && document.activeElement !== sdrDigSidebandEl) {
sdrDigSidebandEl.value = update.filter.sdr_dig_sideband;
}
updateWfmControls();
}
}
if (typeof update.show_sdr_gain_control === "boolean") {
if (sdrSettingsRowEl) sdrSettingsRowEl.style.display = update.show_sdr_gain_control ? "" : "none";
@@ -4162,7 +4228,7 @@ async function postPath(path: string, options: PostOptions = {}) {
const resp = await fetch(path, { method: "POST" });
if (authEnabled && resp.status === 401) {
// Not authenticated - return to login
authRole = null;
setAuthRoles([]);
if (es) es.close();
showAuthGate();
throw new Error("Authentication required");
@@ -4191,7 +4257,7 @@ async function switchRigFromSelect(selectEl: HTMLSelectElement) {
showHint("No rig selected", 1500);
return;
}
if (authRole === "rx") {
if (!hasAuthRole("control")) {
showHint("Control role required", 1500);
return;
}
@@ -4874,8 +4940,13 @@ function navigateToTab(name: TabName, options: { updateHistory?: boolean; replac
window.trxUi?.closeMobileOverlays?.();
const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
const { updateHistory = true, replaceHistory = false } = options;
if (authEnabled && !authRole && name !== "main") {
showAuthGate(false);
if (authEnabled && authRoles.length === 0 && name !== "main") {
showAuthGate();
return;
}
if (authEnabled && name === "logbook" && !hasAuthRole("write")) {
showHint("Write role required for logbook access", 2500);
navigateToTab("main", options);
return;
}
const btn = document.querySelector<HTMLElement>(`.tab-bar .tab[data-tab="${name}"]`);
@@ -5008,12 +5079,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";
setAuthRoles(AUTH_ADMIN_ROLES);
hideAuthGate();
updateAuthUI();
connect();
@@ -5026,7 +5097,8 @@ async function initializeApp() {
if (authStatus.authenticated) {
// User has valid session
authRole = authStatus.role ?? null;
setAuthRoles(authStatus.roles);
authUsername = authStatus.username ?? null;
hideAuthGate();
updateAuthUI();
applyAuthRestrictions();
@@ -5036,10 +5108,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();
}
}
@@ -5049,28 +5118,186 @@ let settingsUiReady = false;
function initSettingsUI() {
settingsUiReady = true;
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRole);
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRoles);
window.trx.modules.scheduler?.wireEvents();
if (window.trx.modules.backgroundDecode) {
window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRole);
window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRoles);
window.trx.modules.backgroundDecode.wireEvents();
}
void refreshUserManagement();
}
async function refreshUserManagement() {
const section = document.getElementById("user-management");
const tab = document.getElementById("settings-users-tab");
if (!section || !tab) return;
const canManageUsers = authEnabled && hasAuthRole("administrator");
tab.style.display = canManageUsers ? "" : "none";
if (!canManageUsers) {
const panel = document.getElementById("subtab-settings-users");
if (panel) panel.style.display = "none";
if (tab.classList.contains("active")) {
document.querySelector<HTMLButtonElement>('[data-subtab="settings-scheduler"]')?.click();
}
return;
}
const list = requiredElement("user-list");
try {
const users = await listUsers();
const enabledAdminCount = users.filter(user => user.enabled && rolesInclude(user.roles, "administrator")).length;
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";
if (!user.enabled) name.textContent += " (disabled)";
const { element: roles, inputs: roleInputs } = buildRoleChoices(user.roles);
const enabledLabel = document.createElement("label");
enabledLabel.className = "auth-role-choice";
const enabled = document.createElement("input");
enabled.type = "checkbox";
enabled.checked = user.enabled;
enabled.disabled = user.username === authUsername;
if (enabled.disabled) enabled.title = "You cannot disable your current account";
enabledLabel.append(enabled, " Enabled");
const isOnlyAdmin = user.enabled
&& rolesInclude(user.roles, "administrator")
&& enabledAdminCount === 1;
const administratorInput = roleInputs.find(item => item.value === "administrator")?.input;
const guestInput = roleInputs.find(item => item.value === "guest")?.input;
if (isOnlyAdmin && administratorInput) {
administratorInput.disabled = true;
administratorInput.title = "The final administrator cannot be demoted";
}
if (isOnlyAdmin && guestInput) {
guestInput.disabled = true;
guestInput.title = "The final administrator cannot become a Guest";
}
if (isOnlyAdmin) {
enabled.disabled = true;
enabled.title = "The final enabled administrator cannot be disabled";
}
const password = document.createElement("input");
password.type = "password"; password.placeholder = "New password (8+ characters)"; password.autocomplete = "new-password"; password.className = "auth-input"; password.minLength = 8; password.maxLength = 1024;
const syncPasswordAccess = () => {
const guestActive = guestInput?.checked === true;
password.disabled = guestActive;
password.title = guestActive ? "Password changes are unavailable while the Guest role is active" : "";
if (guestActive) password.value = "";
};
roleInputs.forEach(({ input }) => input.addEventListener("change", syncPasswordAccess));
syncPasswordAccess();
const save = document.createElement("button"); save.type = "button"; save.textContent = "Save";
save.addEventListener("click", async () => {
const changes: { roles?: AuthRole[]; password?: string; enabled?: boolean } = {
roles: roleInputs.filter(({ input }) => input.checked).map(({ value }) => value),
enabled: enabled.checked,
};
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 || isOnlyAdmin;
if (isOnlyAdmin) remove.title = "The final administrator cannot be removed";
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, enabledLabel, roles, 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);
}
}
const createRoleContainer = document.getElementById("user-create-roles");
if (createRoleContainer) {
const { element } = buildRoleChoices(["read"]);
element.id = createRoleContainer.id;
createRoleContainer.replaceWith(element);
}
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 enabled = requiredElement<HTMLInputElement>("user-create-enabled");
const roles = Array.from(document.querySelectorAll<HTMLInputElement>("#user-create-roles input[type=checkbox]"));
void runUserOperation(async () => {
await createUser(username.value, password.value, roles.filter(input => input.checked).map(input => input.value as AuthRole), enabled.checked);
username.value = ""; password.value = "";
enabled.checked = true;
roles.forEach(input => { input.checked = input.value === "read"; });
});
});
document.getElementById("account-password-form")?.addEventListener("submit", (event) => {
event.preventDefault();
const form = event.currentTarget as HTMLFormElement;
const currentPassword = requiredElement<HTMLInputElement>("account-current-password");
const newPassword = requiredElement<HTMLInputElement>("account-new-password");
const confirmPassword = requiredElement<HTMLInputElement>("account-confirm-password");
const error = requiredElement("account-password-error");
const submit = form.querySelector<HTMLButtonElement>('button[type="submit"]');
if (newPassword.value !== confirmPassword.value) {
error.textContent = "New passwords do not match";
error.style.display = "block";
return;
}
if (submit) submit.disabled = true;
void changeOwnPassword(currentPassword.value, newPassword.value)
.then(async () => {
form.reset();
error.style.display = "none";
await authLogout();
showHint("Password changed. Sign in again.", 3000);
})
.catch((reason: unknown) => {
error.textContent = reason instanceof Error ? reason.message : String(reason);
error.style.display = "block";
})
.finally(() => {
if (submit) submit.disabled = false;
});
});
// 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);
authRole = result.role ?? null;
passphraseEl.value = "";
const result = await authLogin(usernameEl.value, passwordEl.value);
setAuthRoles(result.roles);
authUsername = result.username ?? usernameEl.value;
passwordEl.value = "";
hideAuthGate();
updateAuthUI();
applyAuthRestrictions();
@@ -5080,7 +5307,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,35 +5315,18 @@ 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) {
headerAuthBtn.addEventListener("click", async () => {
if (authRole) {
if (authRoles.length > 0) {
// Logged in - show logout confirmation
if (await window.trxUi.confirm({ title: "Log out?", message: "Audio and control access for this browser session will end.", confirmLabel: "Log out", danger: false })) {
await authLogout();
}
} else {
// Not logged in - show auth gate
showAuthGate(false);
showAuthGate();
}
});
}
@@ -5138,7 +5348,7 @@ Object.defineProperties(trxState, {
initialMapZoom: { get() { return initialMapZoom; } },
decodeHistoryRetentionMin: { get() { return decodeHistoryRetentionMin; } },
authEnabled: { get() { return authEnabled; } },
authRole: { get() { return authRole; } },
authRoles: { get() { return authRoles; } },
decoderRegistry: { get() { return decoderRegistry; } },
sseSessionId: { get() { return sseSessionId; } },
primaryRds: { get() { return primaryRds; } },
@@ -5408,6 +5618,9 @@ const sdrNbThresholdControlsEl = document.getElementById("sdr-nb-threshold-contr
const sdrNbThresholdEl = document.getElementById("sdr-nb-threshold") as HTMLInputElement | null;
const sdrNbThresholdSetBtn = document.getElementById("sdr-nb-threshold-set") as HTMLButtonElement | null;
let sdrNbSupported = false;
const sdrDigSidebandWrapEl = document.getElementById("sdr-dig-sideband-wrap");
const sdrDigSidebandEl = document.getElementById("sdr-dig-sideband") as HTMLSelectElement | null;
let sdrDigSidebandSupported = false;
// Hide audio row if audio is not configured on the server
fetch("/audio", { method: "GET" }).then((r) => {
@@ -5859,6 +6072,17 @@ if (sdrNbThresholdEl) {
}
});
}
function submitSdrDigSideband() {
if (!sdrDigSidebandSupported || !sdrDigSidebandEl) return;
const policy = sdrDigSidebandEl.value || "auto";
if (policy !== "auto" && policy !== "usb" && policy !== "lsb") return;
postPath(`/set_sdr_dig_sideband?policy=${encodeURIComponent(policy)}`).catch(() => {});
}
if (sdrDigSidebandEl) {
sdrDigSidebandEl.addEventListener("change", () => {
submitSdrDigSideband();
});
}
function updateWfmControls() {
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
if (wfmControlsCol) wfmControlsCol.style.display = mode === "WFM" ? "" : "none";
@@ -5866,6 +6090,11 @@ function updateWfmControls() {
// The row holds only these two, so it goes with them — an empty one would
// still take a track and a gap in the tray, and draw its divider.
if (modeControlsRow) modeControlsRow.style.display = (mode === "WFM" || mode === "SAM") ? "" : "none";
// DIG sideband selector is only meaningful in DIG mode on an SDR backend.
if (sdrDigSidebandWrapEl) {
sdrDigSidebandWrapEl.style.display =
sdrDigSidebandSupported && mode === "DIG" ? "" : "none";
}
}
// Show compatibility warning for non-Chromium browsers
@@ -6183,6 +6412,10 @@ function stopRxAudio() {
function startTxAudio() {
if (txActive) { void stopTxAudio(); return; }
if (authEnabled && !hasAuthRole("transmit")) {
audioStatus.textContent = "Transmit role required";
return;
}
if (!hasWebCodecs) {
audioStatus.textContent = "Audio requires Chrome/Edge";
return;
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import { hostState } from "./host.js";
import { hasAuthRole, type AuthRole } from "../api/auth.js";
export {};
@@ -44,7 +45,7 @@ interface BackgroundBridge {
trx?: { modules?: { backgroundDecode?: BackgroundDecodeService } };
}
interface BackgroundDecodeService {
initialize(rigId: string | null, role: string | null): void;
initialize(rigId: string | null, roles: readonly AuthRole[]): void;
wireEvents(): void;
setRig(rigId: string | null): void;
}
@@ -60,7 +61,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
.map(function (d) { return d.id; });
}
let backgroundDecodeRole: string | null = null;
let backgroundDecodeRoles: readonly AuthRole[] = [];
let currentRigId: string | null = null;
let currentConfig: BackgroundDecodeConfig | null = null;
let bookmarkList: Bookmark[] = [];
@@ -70,8 +71,8 @@ const bgdWindow = window as unknown as BackgroundBridge;
let statusByBookmark = new Map<string, BackgroundStatusEntry>();
let lastStatus: BackgroundDecodeStatus | null = null;
function initBackgroundDecode(rigId: string | null, role: string | null): void {
backgroundDecodeRole = role;
function initBackgroundDecode(rigId: string | null, roles: readonly AuthRole[]): void {
backgroundDecodeRoles = roles;
// The panel used to take whatever rig it was handed at load and wait to be
// told again. Loading before the rig list arrives handed it null, and the
// next telling only came when the operator switched rigs, so the panel sat
@@ -468,7 +469,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
}
function isControlRole(): boolean {
return backgroundDecodeRole === "control" || hostState.authEnabled === false;
return hasAuthRole(backgroundDecodeRoles, "control") || hostState.authEnabled === false;
}
function showToast(msg: string, isError: boolean): void {
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import { hostCore, hostState } from "./host.js";
import { hasAuthRole } from "../api/auth.js";
export {};
@@ -18,6 +19,8 @@ interface Bookmark {
category?: string | null;
comment?: string | null;
decoders?: string[];
/** DIG sideband override: "auto" | "usb" | "lsb". Empty/absent = global. */
dig_sideband?: string | null;
scope?: string;
}
@@ -100,7 +103,8 @@ function bmEsc(str: unknown): string {
}
function bmCanControl() {
return !hostState.authEnabled || hostState.authRole === "control";
return !hostState.authEnabled
|| hasAuthRole(hostState.authRoles, "write");
}
// Show/hide the Add Bookmark / Select All buttons based on the current auth role.
@@ -319,6 +323,14 @@ function bmBuildDecoderCheckboxes() {
});
}
/** Show the DIG-sideband override selector only when the mode field is DIG. */
function bmSyncDigSidebandVisibility(): void {
const label = bmOptionalEl("bm-dig-sideband-label");
if (!label) return;
const mode = (bmEl("bm-mode").value || "").trim().toUpperCase();
label.style.display = mode === "DIG" ? "" : "none";
}
function bmOpenForm(bm: Bookmark | null): void {
const wrap = bmEl("bm-form-wrap");
if (!wrap) return;
@@ -336,7 +348,9 @@ function bmOpenForm(bm: Bookmark | null): void {
bmEl("bm-locator").value = bm ? (bm.locator || "") : "";
bmEl("bm-category-input").value = bm ? (bm.category || "") : "";
bmEl("bm-comment").value = bm ? (bm.comment || "") : "";
bmEl("bm-dig-sideband").value = bm ? (bm.dig_sideband || "") : "";
bmWriteDecoders(bm?.decoders ?? []);
bmSyncDigSidebandVisibility();
bmEl("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark";
wrap.style.display = "flex";
@@ -381,6 +395,9 @@ async function bmSave(e: Event): Promise<void> {
const category = bmEl("bm-category-input").value.trim();
const comment = bmEl("bm-comment").value.trim();
const decoders = bmReadDecoders();
// Only meaningful for DIG; stored empty otherwise so it never overrides.
const dig_sideband =
mode.toUpperCase() === "DIG" ? (bmEl("bm-dig-sideband").value || null) : null;
const formError = bmEl("bm-form-error");
if (formError) formError.textContent = "";
@@ -401,6 +418,7 @@ async function bmSave(e: Event): Promise<void> {
category,
comment,
decoders,
dig_sideband,
};
try {
@@ -475,6 +493,16 @@ function bmApply(bm: Bookmark): void {
const tunePromise = (async () => {
await bridge.trx.modules.vchan?.takeSchedulerControl();
// For a DIG bookmark carrying a sideband override, set the global DIG
// policy first so the backend resolves the intended sideband when DIG is
// applied. "auto" is honoured explicitly; an empty override is left alone.
if ((bm.mode || "").toUpperCase() === "DIG" && bm.dig_sideband) {
const p = bm.dig_sideband.toLowerCase();
if (p === "auto" || p === "usb" || p === "lsb") {
await hostCore.postPath("/set_sdr_dig_sideband?policy=" + encodeURIComponent(p));
}
}
const onVirtual = await bridge.trx.modules.vchan?.interceptMode(bm.mode) ?? false;
if (!onVirtual) {
await hostCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
@@ -768,6 +796,9 @@ function bmPopulateScopePicker() {
// Form submit
bmEl("bm-form").addEventListener("submit", (event) => { void bmSave(event); });
// Reveal the DIG-sideband override only while the mode field reads DIG.
bmEl("bm-mode").addEventListener("input", bmSyncDigSidebandVisibility);
// Form cancel
bmEl("bm-form-cancel").addEventListener("click", bmCloseForm);
@@ -11,6 +11,8 @@
// feature bundles from re-deriving it — and from drifting back to bare `window`
// properties, which the module graph no longer publishes.
import type { AuthRole } from "../api/auth.js";
export interface HostDecoderDescriptor {
id: string;
label: string;
@@ -25,7 +27,7 @@ export interface HostState {
/** The callsign this station is on the air with, from the client config. */
readonly ownerCallsign: string | null;
readonly authEnabled: boolean;
readonly authRole: string | null;
readonly authRoles: readonly AuthRole[];
readonly lastActiveRigId: string | null;
readonly lastRigIds: string[];
readonly lastRigDisplayNames: Record<string, string>;
@@ -10,6 +10,7 @@
// keeping if the times in it are the radio's.
import { hostCore, hostState } from "./host.js";
import { hasAuthRole } from "../api/auth.js";
export {};
@@ -120,6 +121,11 @@ let entryGrid: string | null = null;
let qsos: Qso[] = [];
let workedRequest = 0;
function canWriteLogbook(): boolean {
return !hostState.authEnabled
|| hasAuthRole(hostState.authRoles, "write");
}
function notify(message: string, kind?: string): void {
if (bridge.trxUi.notify) bridge.trxUi.notify(message, kind ? { kind } : undefined);
else hostCore.showHint(message, 2000);
@@ -419,6 +425,11 @@ function renderRows(): void {
row.appendChild(cell);
}
const actions = document.createElement("td");
if (!canWriteLogbook()) {
row.appendChild(actions);
fragment.appendChild(row);
continue;
}
// Confirming is the commonest edit a log gets, so it is a button rather
// than a form: a card arrives, and the contact counts towards an award.
const confirm = document.createElement("button");
@@ -547,15 +558,20 @@ importFile?.addEventListener("change", () => {
});
/** Start an entry from a decode, and show the operator where it went. */
bridge.logContact = (seed) => {
if (canWriteLogbook()) {
bridge.logContact = (seed) => {
bridge.navigateToTab?.("logbook");
void openEntry(seed).then(() => callInput?.focus());
};
};
} else {
if (form) form.style.display = "none";
if (importBtn) importBtn.style.display = "none";
}
renderStation();
if (cabrilloCallsign && !cabrilloCallsign.value) {
cabrilloCallsign.value = stationCallEl?.textContent?.trim() ?? "";
}
syncCabrilloLink();
void openEntry();
if (canWriteLogbook()) void openEntry();
void refreshLog();
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import type { SatelliteScheduleConfig, SatelliteSchedulerApi } from "./satellite-types.js";
import type { AuthRole } from "../api/auth.js";
export type SchedulerMode = "disabled" | "grayline" | "time_span";
@@ -60,7 +61,7 @@ export interface SchedulerStatus {
}
export interface SchedulerService {
initialize(rigId: string | null, role: string | null): void;
initialize(rigId: string | null, roles: readonly AuthRole[]): void;
destroy(): void;
setRig(rigId: string | null): void;
wireEvents(): void;
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import { hostState } from "./host.js";
import { hasAuthRole, type AuthRole } from "../api/auth.js";
import type {
ScheduleEntry,
@@ -43,7 +44,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
// -------------------------------------------------------------------------
// State
// -------------------------------------------------------------------------
let schedulerRole: string | null = null;
let schedulerRoles: readonly AuthRole[] = [];
let currentRigId: string | null = null;
let currentConfig: SchedulerConfig | null = null;
let currentSchedulerStatus: SchedulerStatus | null = null;
@@ -58,8 +59,8 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
// -------------------------------------------------------------------------
// Init
// -------------------------------------------------------------------------
function initScheduler(rigId: string | null, role: string | null): void {
schedulerRole = role;
function initScheduler(rigId: string | null, roles: readonly AuthRole[]): void {
schedulerRoles = roles;
currentRigId = rigId || null;
if (currentRigId) loadScheduler();
startStatusPolling();
@@ -356,7 +357,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
if (!prevBtn || !nextBtn) return;
const state = schedulerInterleaveState(currentConfig);
const enabled =
schedulerRole === "control" &&
hasAuthRole(schedulerRoles, "control") &&
!!currentRigId &&
!schedulerStepPending &&
state.activeEntries.length > 1;
@@ -466,7 +467,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
if (!panel) return;
const mode = (currentConfig && currentConfig.mode) || "disabled";
const isControl = schedulerRole === "control";
const isControl = hasAuthRole(schedulerRoles, "control");
// Mode selector
setSelected("scheduler-mode-select", mode);
@@ -1574,8 +1575,8 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
// When loaded eagerly, initSettingsUI() in app.js calls initScheduler();
// when loaded lazily (e.g. settings tab click after boot), the app has
// already passed that point, so we must self-initialize here.
if (hostState.authRole != null) {
initScheduler(hostState.lastActiveRigId, hostState.authRole);
if (!hostState.authEnabled || hostState.authRoles.length > 0) {
initScheduler(hostState.lastActiveRigId, hostState.authRoles);
wireSchedulerEvents();
}
})();
@@ -0,0 +1,102 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import { chromium } from "playwright-core";
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
/* global document */
const ALL_ROLES = ["read", "control", "transmit", "write", "administrator"];
const fixture = await startWebFixture({
authSession: {
authenticated: true,
username: "admin",
roles: ALL_ROLES,
auth_disabled: false,
},
users: [
{ username: "admin", roles: ALL_ROLES, enabled: true },
{ username: "guest", roles: ["guest"], enabled: true },
{ username: "listener", roles: ["read"], enabled: false },
],
});
const { browser, page, runtimeErrors } = await startBrowser(chromium);
try {
await page.goto(`${fixture.origin}/settings`, { waitUntil: "domcontentloaded" });
await page.locator("#tab-settings").waitFor({ state: "visible" });
await page.locator("#settings-account-tab").waitFor({ state: "visible" });
await page.locator("#settings-users-tab").waitFor({ state: "visible" });
await page.locator("#settings-account-tab").click();
assert.equal(await page.locator("#account-password-form").isVisible(), true);
assert.equal(await page.locator("#subtab-settings-users").isVisible(), false);
await page.locator("#settings-users-tab").click();
await page.locator("#user-list").getByText("listener (disabled)").waitFor();
assert.equal(await page.locator("#user-create-form").isVisible(), true);
const state = await page.evaluate(() => {
const rows = [...document.querySelectorAll("#user-list > .sch-row")];
const rowFor = (username) => rows.find((row) => row.querySelector("strong")?.textContent.startsWith(username));
const admin = rowFor("admin");
const guest = rowFor("guest");
const listener = rowFor("listener");
const role = (row, value) => row?.querySelector(`input[value="${value}"]`);
return {
createRoles: [...document.querySelectorAll("#user-create-roles input")].map((input) => input.value),
adminEnabledLocked: admin?.querySelector('input[type="checkbox"]')?.disabled,
adminRoleLocked: role(admin, "administrator")?.disabled,
adminGuestLocked: role(admin, "guest")?.disabled,
adminRemoveLocked: admin?.querySelector("button.danger")?.disabled,
guestPasswordLocked: guest?.querySelector('input[type="password"]')?.disabled,
listenerEnabled: listener?.querySelector('input[type="checkbox"]')?.checked,
listenerRead: role(listener, "read")?.checked,
};
});
assert.deepEqual(state.createRoles, ["guest", ...ALL_ROLES]);
assert.equal(state.adminEnabledLocked, true);
assert.equal(state.adminRoleLocked, true);
assert.equal(state.adminGuestLocked, true);
assert.equal(state.adminRemoveLocked, true);
assert.equal(state.guestPasswordLocked, true);
assert.equal(state.listenerEnabled, false);
assert.equal(state.listenerRead, true);
const listenerRow = page.locator("#user-list > .sch-row").filter({ hasText: "listener" });
const listenerPassword = listenerRow.locator('input[type="password"]');
await listenerRow.locator('input[value="guest"]').check();
assert.equal(await listenerPassword.isDisabled(), true);
assert.equal(await listenerRow.locator('input[value="read"]').isChecked(), true);
await listenerRow.locator('input[value="control"]').check();
assert.equal(await listenerRow.locator('input[value="guest"]').isChecked(), true);
assert.equal(await listenerPassword.isDisabled(), true);
await listenerRow.locator('input[value="guest"]').uncheck();
assert.equal(await listenerPassword.isDisabled(), false);
assert.deepEqual(runtimeErrors, []);
} finally {
await browser.close();
await fixture.close();
}
const guestFixture = await startWebFixture({
authSession: {
authenticated: true,
username: "guest",
roles: ["guest"],
auth_disabled: false,
},
});
const guestBrowser = await startBrowser(chromium);
try {
await guestBrowser.page.goto(`${guestFixture.origin}/settings`, { waitUntil: "domcontentloaded" });
await guestBrowser.page.locator("#tab-settings").waitFor({ state: "visible" });
assert.equal(await guestBrowser.page.locator("#settings-account-tab").isVisible(), false);
assert.equal(await guestBrowser.page.locator("#settings-users-tab").isVisible(), false);
assert.deepEqual(guestBrowser.runtimeErrors, []);
} finally {
await guestBrowser.browser.close();
await guestFixture.close();
}
@@ -0,0 +1,61 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import test from "node:test";
import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs";
const source = await bundleEntry(new URL("../src/api/auth.ts", import.meta.url), "AuthApi");
function loadAuth(fetch) {
const context = vm.createContext({ fetch, console });
new vm.Script(source).runInContext(context);
return context.AuthApi;
}
test("role policy centralizes Guest and separates Control from Transmit", () => {
const auth = loadAuth(async () => { throw new Error("unused"); });
assert.deepEqual(Array.from(auth.AUTH_ROLES), ["guest", "read", "control", "transmit", "write", "administrator"]);
assert.equal(auth.hasAuthRole(["guest"], "read"), true);
assert.equal(auth.hasAccountControls(["guest"]), false);
assert.equal(auth.hasAccountControls(["read"]), true);
assert.equal(auth.hasAuthRole(["control"], "read"), true);
assert.equal(auth.hasAuthRole(["transmit"], "read"), true);
assert.equal(auth.hasAuthRole(["control"], "transmit"), false);
assert.equal(auth.hasAuthRole(["control"], "write"), false);
assert.equal(auth.hasAuthRole(["administrator"], "write"), true);
});
test("auth responses normalize roles and require the managed-account lifecycle state", async () => {
const replies = new Map([
["/auth/session", { authenticated: true, roles: ["write", "read", "write"], username: "alice" }],
["/auth/users", [{ username: "alice", roles: ["write", "read"], enabled: false }]],
]);
const auth = loadAuth(async (url) => ({
ok: true,
status: 200,
json: async () => replies.get(String(url)),
}));
assert.deepEqual(Array.from((await auth.fetchAuthSession()).roles), ["read", "write"]);
assert.deepEqual(Array.from((await auth.listUsers())[0].roles), ["read", "write"]);
assert.equal((await auth.listUsers())[0].enabled, false);
});
test("changing a password sends current and replacement credentials", async () => {
let request;
const auth = loadAuth(async (url, init) => {
request = { url, init };
return { ok: true, status: 200, json: async () => ({}) };
});
await auth.changeOwnPassword("old-password", "new-password");
assert.equal(request.url, "/auth/account/password");
assert.equal(request.init.method, "PATCH");
assert.deepEqual(JSON.parse(request.init.body), {
current_password: "old-password",
new_password: "new-password",
});
});
@@ -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", ["administrator"]);
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",
authRoles: ["read", "control", "transmit", "write", "administrator"],
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, authRoles: ["read"] });
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.authRoles = ["read", "write"];
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",
authRoles: ["read", "control", "transmit", "write", "administrator"],
lastActiveRigId: null,
lastRigIds: [],
lastRigDisplayNames: {},
@@ -11,7 +11,7 @@ import { bundleEntry } from "./bundle-entry.mjs";
test("scheduler registers a typed module service without lifecycle globals", async () => {
// No role known yet: the entry registers its service and waits for the
// application to drive initialization.
const window = { ...createHost({ state: { authRole: null } }), trxUi: { confirm: async () => true } };
const window = { ...createHost({ state: { authEnabled: true, authRoles: [] } }), trxUi: { confirm: async () => true } };
const context = vm.createContext({
window,
document: {
@@ -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: { authRoles: ["administrator"], lastActiveRigId: "sdr" } }),
trxUi: { confirm: async () => true },
};
const context = vm.createContext({
@@ -9,6 +9,7 @@ import { readFile } from "node:fs/promises";
const indexPath = new URL("../../assets/web/index.html", import.meta.url);
const pluginLoaderPath = new URL("../src/plugin-loader.ts", import.meta.url);
const mapCorePath = new URL("../src/map-core.ts", import.meta.url);
const appPath = new URL("../src/app.ts", import.meta.url);
test("index loads one first-party application bootstrap", async () => {
const html = await readFile(indexPath, "utf8");
@@ -30,6 +31,30 @@ test("startup has no remote script or stylesheet dependencies", async () => {
);
});
test("administrator user management is a dedicated Settings sub-tab", async () => {
const [html, app] = await Promise.all([
readFile(indexPath, "utf8"),
readFile(appPath, "utf8"),
]);
assert.match(html, /data-subtab="settings-users"/);
assert.match(html, /id="subtab-settings-users" class="sub-tab-panel"/);
assert.match(app, /authEnabled && hasAuthRole\("administrator"\)/);
assert.match(app, /settings-users-tab/);
});
test("account lifecycle controls include self-service passwords and enable state", async () => {
const [html, app] = await Promise.all([
readFile(indexPath, "utf8"),
readFile(appPath, "utf8"),
]);
assert.match(html, /data-subtab="settings-account"/);
assert.match(html, /id="account-password-form"/);
assert.match(html, /id="user-create-enabled"/);
assert.match(app, /changeOwnPassword/);
assert.match(app, /hasAccountControls\(authRoles\)/);
assert.match(app, /enabledAdminCount/);
});
test("lazy frontend features use modules and local map symbols", async () => {
const [loader, map] = await Promise.all([
readFile(pluginLoaderPath, "utf8"),
@@ -0,0 +1,53 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import { chromium } from "playwright-core";
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
async function controlState(page) {
return {
frequency: await page.locator("#freq").isDisabled(),
ptt: await page.locator("#ptt-btn").isDisabled(),
txAudio: await page.locator("#tx-audio-btn").isDisabled(),
txLimit: await page.locator("#tx-limit-btn").isDisabled(),
};
}
async function inspectRole(roles) {
const fixture = await startWebFixture({
tx: true,
authSession: {
authenticated: true,
username: "operator",
roles,
auth_disabled: false,
},
});
const session = await startBrowser(chromium);
try {
await session.page.goto(`${fixture.origin}/`, { waitUntil: "domcontentloaded" });
await session.page.locator("#content").waitFor({ state: "visible" });
await session.page.waitForTimeout(1200);
assert.deepEqual(session.runtimeErrors, []);
return await controlState(session.page);
} finally {
await session.browser.close();
await fixture.close();
}
}
assert.deepEqual(await inspectRole(["control"]), {
frequency: false,
ptt: true,
txAudio: true,
txLimit: true,
});
assert.deepEqual(await inspectRole(["transmit"]), {
frequency: true,
ptt: false,
txAudio: false,
txLimit: false,
});
@@ -145,6 +145,8 @@ export async function startWebFixture({
bandplanEnabled = false,
bandplanUnauthorizedFirst = false,
satPasses = null,
authSession = { authenticated: true, roles: ["read", "control", "transmit", "write", "administrator"], auth_disabled: true },
users = [],
} = {}) {
const rigItems = ["rig-a", "rig-b"].map((remote) => ({
remote,
@@ -232,7 +234,8 @@ export async function startWebFixture({
};
const jsonRoutes = new Map([
["/auth/session", { authenticated: true, role: "control", auth_disabled: true }],
["/auth/session", authSession],
["/auth/users", users],
["/decoders", DECODER_REGISTRY],
["/rigs", rigsResponse],
["/status", status],
@@ -9,7 +9,7 @@ use std::sync::Arc;
use actix_web::Error;
use actix_web::{delete, get, post, put, web, HttpRequest, HttpResponse};
use super::{no_cache_response, request_accepts_html, require_control};
use super::{no_cache_response, request_accepts_html, require_write};
use crate::server::status;
// ============================================================================
@@ -37,6 +37,7 @@ pub struct BookmarkInput {
pub comment: Option<String>,
pub category: Option<String>,
pub decoders: Option<Vec<String>>,
pub dig_sideband: Option<String>,
}
/// A bookmark with its owning scope tag for the list response.
@@ -88,6 +89,17 @@ fn normalize_bookmark_locator(locator: Option<String>) -> Option<String> {
})
}
/// Normalize a DIG sideband override to `auto`/`usb`/`lsb`, or `None` when
/// absent or unrecognized (leaves the current policy untouched on apply).
fn normalize_dig_sideband(value: Option<String>) -> Option<String> {
value.and_then(|v| match v.trim().to_lowercase().as_str() {
"auto" => Some("auto".to_string()),
"usb" => Some("usb".to_string()),
"lsb" => Some("lsb".to_string()),
_ => None,
})
}
// ============================================================================
// Endpoints
// ============================================================================
@@ -165,7 +177,7 @@ pub async fn create_bookmark(
body: web::Json<BookmarkInput>,
auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> {
require_control(&req, &auth_state)?;
require_write(&req, &auth_state)?;
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
if store.freq_taken(body.freq_hz, None) {
return Err(actix_web::error::ErrorConflict(
@@ -182,6 +194,7 @@ pub async fn create_bookmark(
comment: body.comment.clone().unwrap_or_default(),
category: body.category.clone().unwrap_or_default(),
decoders: body.decoders.clone().unwrap_or_default(),
dig_sideband: normalize_dig_sideband(body.dig_sideband.clone()),
};
if store.insert(&bm) {
Ok(HttpResponse::Created().json(bm))
@@ -201,7 +214,7 @@ pub async fn update_bookmark(
body: web::Json<BookmarkInput>,
auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> {
require_control(&req, &auth_state)?;
require_write(&req, &auth_state)?;
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
let id = path.into_inner();
if store.freq_taken(body.freq_hz, Some(&id)) {
@@ -219,6 +232,7 @@ pub async fn update_bookmark(
comment: body.comment.clone().unwrap_or_default(),
category: body.category.clone().unwrap_or_default(),
decoders: body.decoders.clone().unwrap_or_default(),
dig_sideband: normalize_dig_sideband(body.dig_sideband.clone()),
};
if store.upsert(&id, &bm) {
Ok(HttpResponse::Ok().json(bm))
@@ -235,7 +249,7 @@ pub async fn delete_bookmark(
query: web::Query<BookmarkScopeQuery>,
auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> {
require_control(&req, &auth_state)?;
require_write(&req, &auth_state)?;
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
let id = path.into_inner();
if store.remove(&id) {
@@ -253,7 +267,7 @@ pub async fn batch_delete_bookmarks(
query: web::Query<BookmarkScopeQuery>,
auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> {
require_control(&req, &auth_state)?;
require_write(&req, &auth_state)?;
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
let mut deleted = 0usize;
for id in &body.ids {
@@ -272,7 +286,7 @@ pub async fn batch_move_bookmarks(
query: web::Query<BookmarkScopeQuery>,
auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> {
require_control(&req, &auth_state)?;
require_write(&req, &auth_state)?;
let from_store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
let to_store = resolve_bookmark_store(Some(body.to.as_str()), store_map.get_ref());
let mut moved = 0usize;
@@ -15,7 +15,7 @@ use serde::{Deserialize, Serialize};
use trx_logbook::qso::{adif_mode_for_rig_mode, band_for_hz, mode_for_decoder};
use trx_logbook::{LogQuery, Logbook, Qso};
use super::{active_rig_id_from_context, require_control};
use super::{active_rig_id_from_context, require_write};
use crate::server::auth::AuthState;
/// What a contact looks like on the wire.
@@ -225,7 +225,7 @@ pub async fn add_qso(
logbook: web::Data<Arc<Logbook>>,
auth_state: web::Data<AuthState>,
) -> Result<HttpResponse, actix_web::Error> {
require_control(&req, auth_state.get_ref())?;
require_write(&req, auth_state.get_ref())?;
let qso = match input.into_inner().into_qso(None) {
Ok(qso) => qso,
Err(reason) => {
@@ -248,7 +248,7 @@ pub async fn edit_qso(
logbook: web::Data<Arc<Logbook>>,
auth_state: web::Data<AuthState>,
) -> Result<HttpResponse, actix_web::Error> {
require_control(&req, auth_state.get_ref())?;
require_write(&req, auth_state.get_ref())?;
let id = path.into_inner();
let Some(existing) = book(&logbook).get(&id) else {
return Ok(HttpResponse::NotFound().json(serde_json::json!({ "error": "no such contact" })));
@@ -276,7 +276,7 @@ pub async fn delete_qso(
logbook: web::Data<Arc<Logbook>>,
auth_state: web::Data<AuthState>,
) -> Result<HttpResponse, actix_web::Error> {
require_control(&req, auth_state.get_ref())?;
require_write(&req, auth_state.get_ref())?;
match book(&logbook).delete(&path.into_inner()) {
Ok(true) => Ok(HttpResponse::Ok().json(serde_json::json!({ "deleted": true }))),
Ok(false) => {
@@ -312,7 +312,7 @@ pub async fn import_adi(
logbook: web::Data<Arc<Logbook>>,
auth_state: web::Data<AuthState>,
) -> Result<HttpResponse, actix_web::Error> {
require_control(&req, auth_state.get_ref())?;
require_write(&req, auth_state.get_ref())?;
match book(&logbook).import_adi(&body) {
Ok(outcome) => Ok(HttpResponse::Ok().json(outcome)),
Err(err) => Ok(HttpResponse::InternalServerError()
@@ -391,16 +391,20 @@ fn gz_cache_entry(src: &[u8], name: &str) -> GzCacheEntry {
GzCacheEntry { gz, br, etag }
}
fn require_control(
fn require_write(
req: &HttpRequest,
auth_state: &crate::server::auth::AuthState,
) -> Result<(), actix_web::Error> {
if !auth_state.config.enabled {
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")),
if !auth_state.config.enabled
|| crate::server::auth::session_grants(
req,
auth_state,
crate::server::auth::AuthRole::Write,
)
{
Ok(())
} else {
Err(actix_web::error::ErrorForbidden("write role required"))
}
}
@@ -591,6 +595,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(rig::set_sdr_agc)
.service(rig::set_sdr_squelch)
.service(rig::set_sdr_noise_blanker)
.service(rig::set_sdr_dig_sideband)
.service(rig::set_wfm_deemphasis)
.service(rig::set_wfm_stereo)
.service(rig::set_wfm_denoise)
@@ -709,6 +714,11 @@ 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::change_own_password)
.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 +959,36 @@ 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(crate::server::auth::BootstrapAccount::new(
"admin".to_string(),
"password123".to_string(),
)),
None,
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
@@ -1164,10 +1180,16 @@ mod tests {
let logbook = std::sync::Arc::new(
trx_logbook::Logbook::open(&dir.path().join("logbook.jsonl")).expect("open"),
);
let auth_state = auth_state_locked();
let session_id = auth_state.store.create(
"reader".to_string(),
[crate::server::auth::AuthRole::Read].into_iter().collect(),
std::time::Duration::from_secs(3600),
);
let app = actix_test::init_service(
App::new()
.app_data(web::Data::new(logbook))
.app_data(web::Data::new(auth_state_locked()))
.app_data(web::Data::new(auth_state))
.service(logbook::add_qso)
.service(logbook::list_qsos),
)
@@ -1177,6 +1199,10 @@ mod tests {
&app,
actix_test::TestRequest::post()
.uri("/api/logbook")
.cookie(actix_web::cookie::Cookie::new(
"trx_http_sid",
session_id.clone(),
))
.set_json(serde_json::json!({
"call": "SP2SJG", "freq_hz": 14_074_000_u64, "mode": "FT8",
}))
@@ -1192,12 +1218,47 @@ mod tests {
&app,
actix_test::TestRequest::get()
.uri("/api/logbook")
.cookie(actix_web::cookie::Cookie::new("trx_http_sid", session_id))
.to_request(),
)
.await;
assert_eq!(listed["total"], 0);
}
#[actix_web::test]
async fn a_write_session_can_write_to_the_log() {
let dir = tempfile::tempdir().expect("tempdir");
let logbook = std::sync::Arc::new(
trx_logbook::Logbook::open(&dir.path().join("logbook.jsonl")).expect("open"),
);
let auth_state = auth_state_locked();
let session_id = auth_state.store.create(
"writer".to_string(),
[crate::server::auth::AuthRole::Write].into_iter().collect(),
std::time::Duration::from_secs(3600),
);
let app = actix_test::init_service(
App::new()
.app_data(web::Data::new(logbook))
.app_data(web::Data::new(auth_state))
.service(logbook::add_qso),
)
.await;
let response = actix_test::call_service(
&app,
actix_test::TestRequest::post()
.uri("/api/logbook")
.cookie(actix_web::cookie::Cookie::new("trx_http_sid", session_id))
.set_json(serde_json::json!({
"call": "SP2SJG", "freq_hz": 14_074_000_u64, "mode": "FT8",
}))
.to_request(),
)
.await;
assert_eq!(response.status(), actix_web::http::StatusCode::OK);
}
/// A contact needs a callsign; the panel is not the only thing that has to
/// insist on it.
#[actix_web::test]
@@ -14,7 +14,7 @@ use uuid::Uuid;
use trx_core::radio::freq::Freq;
use trx_core::rig::state::WfmDenoiseLevel;
use trx_core::{RigCommand, RigRequest, RigState};
use trx_core::{DigSidebandPolicy, RigCommand, RigRequest, RigState};
use trx_frontend::{FrontendRuntimeContext, RemoteRigEntry};
use trx_protocol::parse_mode;
@@ -299,6 +299,23 @@ pub async fn set_sdr_noise_blanker(
.await
}
#[derive(serde::Deserialize)]
pub struct SdrDigSidebandQuery {
/// `auto`, `usb`, or `lsb`.
pub policy: DigSidebandPolicy,
pub remote: Option<String>,
}
/// Set how the SDR backend resolves DIG mode to a sideband.
#[post("/set_sdr_dig_sideband")]
pub async fn set_sdr_dig_sideband(
query: web::Query<SdrDigSidebandQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let q = query.into_inner();
send_command(&rig_tx, RigCommand::SetSdrDigSideband(q.policy), q.remote).await
}
// ============================================================================
// WFM / SAM settings
// ============================================================================
@@ -737,6 +737,7 @@ pub async fn audio_ws(
body: web::Payload,
query: web::Query<AudioQuery>,
context: web::Data<Arc<FrontendRuntimeContext>>,
auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> {
let Some(tx_sender) = context.audio.tx.as_ref().cloned() else {
return Ok(HttpResponse::NotFound().body("audio not enabled"));
@@ -746,6 +747,7 @@ pub async fn audio_ws(
if !req.headers().contains_key("upgrade") {
return Ok(HttpResponse::NoContent().finish());
}
let tx_session_id = crate::server::auth::extract_session_id(&req);
// If a channel_id is specified, subscribe to the per-channel broadcaster.
// The entry is created asynchronously when AUDIO_MSG_VCHAN_ALLOCATED arrives
@@ -880,6 +882,16 @@ pub async fn audio_ws(
msg = msg_stream.recv() => {
match msg {
Some(Ok(Message::Binary(data))) => {
let can_transmit = !auth_state.config.enabled
|| crate::server::auth::session_id_grants(
tx_session_id.as_ref(),
&auth_state,
crate::server::auth::AuthRole::Transmit,
);
if !can_transmit {
warn!("Audio WS: closing after unauthorized TX frame");
break;
}
let _ = tx_sender.send(Bytes::from(data.to_vec())).await;
}
Some(Ok(Message::Close(_))) => break,
File diff suppressed because it is too large Load Diff
@@ -21,6 +21,10 @@ pub struct Bookmark {
pub comment: String,
pub category: String,
pub decoders: Vec<String>,
/// For `DIG` bookmarks, the sideband policy to apply on tune: `auto`,
/// `usb`, or `lsb`. `None` (or non-DIG) leaves the current policy alone.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dig_sideband: Option<String>,
}
pub struct BookmarkStore {
@@ -592,6 +592,21 @@ async fn apply_scheduler_target(
)
.await?;
// Apply any DIG sideband override before switching to DIG so the SDR
// backend resolves the intended sideband for this bookmark.
if let Some(policy) = bookmark
.dig_sideband
.as_deref()
.and_then(trx_core::DigSidebandPolicy::parse)
{
scheduler_send(
rig_tx,
RigCommand::SetSdrDigSideband(policy),
remote.to_string(),
)
.await?;
}
scheduler_send(
rig_tx,
RigCommand::SetMode(trx_protocol::parse_mode(&bookmark.mode)),
@@ -252,11 +252,31 @@ fn build_server(
"None" => SameSite::None,
_ => SameSite::Lax, // default
};
let bootstrap_admin = auth::BootstrapAccount::from_parts(
context.http_auth.bootstrap_admin_username.clone(),
context.http_auth.bootstrap_admin_password.clone(),
);
let bootstrap_read = context
.http_auth
.bootstrap_read_enabled
.then(|| {
context
.http_auth
.bootstrap_read_password
.clone()
.map(|password| {
auth::BootstrapAccount::new(
context.http_auth.bootstrap_read_username.clone(),
password,
)
})
})
.flatten();
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(),
bootstrap_admin,
bootstrap_read,
Duration::from_secs(context.http_auth.session_ttl_secs),
context.http_auth.cookie_secure,
same_site,
@@ -273,7 +293,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 {
+121 -88
View File
@@ -268,18 +268,21 @@ 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>,
/// Create a Guest account when bootstrapping a new database.
pub bootstrap_read_enabled: bool,
/// Username for the Guest bootstrap account.
pub bootstrap_read_username: String,
/// Password for the Guest bootstrap account.
pub bootstrap_read_password: Option<String>,
/// Session time-to-live in minutes
pub session_ttl_min: u64,
/// Set Secure flag on session cookie (required for HTTPS)
@@ -292,11 +295,13 @@ 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,
bootstrap_read_enabled: true,
bootstrap_read_username: "guest".to_string(),
bootstrap_read_password: Some("guest".to_string()),
session_ttl_min: 480,
cookie_secure: false,
cookie_same_site: CookieSameSite::Lax,
@@ -802,14 +807,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 +819,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 +832,22 @@ 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.auth.enabled
&& self.frontends.http.auth.bootstrap_read_enabled
&& self
.frontends
.http
.auth
.bootstrap_read_password
.as_deref()
.is_some_and(|password| password != "guest")
|| self.frontends.http_json.auth.tokens_file.is_none()
&& !self.frontends.http_json.auth.tokens.is_empty()
}
@@ -905,11 +917,13 @@ 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,
bootstrap_read_enabled: true,
bootstrap_read_username: "guest".to_string(),
bootstrap_read_password: Some("guest".to_string()),
session_ttl_min: 480,
cookie_secure: false,
cookie_same_site: CookieSameSite::Lax,
@@ -948,27 +962,34 @@ 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(),
);
}
if auth.bootstrap_read_enabled
&& (auth.bootstrap_read_username.trim().is_empty()
|| auth
.bootstrap_read_password
.as_deref()
.is_none_or(str::is_empty))
{
return Err("[frontends.http.auth] enabled bootstrap Guest account requires a non-empty username and password".to_string());
}
// Session TTL must be > 0
@@ -1238,42 +1259,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 +1294,27 @@ 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_allows_disabling_read_bootstrap_credentials() {
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());
config.frontends.http.auth.bootstrap_read_enabled = false;
config.frontends.http.auth.bootstrap_read_username.clear();
config.frontends.http.auth.bootstrap_read_password = None;
assert!(config.validate().is_ok());
}
#[test]
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 +1322,12 @@ 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!(auth.bootstrap_read_enabled);
assert_eq!(auth.bootstrap_read_username, "guest");
assert_eq!(auth.bootstrap_read_password.as_deref(), Some("guest"));
assert_eq!(auth.session_ttl_min, 480);
assert!(!auth.cookie_secure);
assert!(matches!(auth.cookie_same_site, CookieSameSite::Lax));
@@ -1602,15 +1629,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",
+6 -1
View File
@@ -19,7 +19,7 @@ use crate::shared::{check_socket_conflicts, validate_log_level, validate_tokens,
use serde::{Deserialize, Serialize};
pub use trx_decode_log::DecodeLogsConfig;
use trx_core::rig::state::RigMode;
use trx_core::rig::state::{DigSidebandPolicy, RigMode};
/// Every decoder the server knows how to run, by config name.
///
@@ -393,6 +393,10 @@ pub struct SdrConfig {
pub squelch: SdrSquelchConfig,
/// Noise blanker for impulse noise suppression on IQ samples.
pub noise_blanker: SdrNoiseBlankerConfig,
/// How DIG mode picks a sideband: `auto` (USB ≥ 10 MHz, LSB below),
/// `usb`, or `lsb`. Overridable at runtime and per-bookmark.
#[serde(default)]
pub dig_sideband: DigSidebandPolicy,
/// Virtual receiver channels (at least one required when SDR backend is active).
pub channels: Vec<SdrChannelConfig>,
/// Maximum number of simultaneous virtual channels (including the primary).
@@ -433,6 +437,7 @@ impl Default for SdrConfig {
gain: SdrGainConfig::default(),
squelch: SdrSquelchConfig::default(),
noise_blanker: SdrNoiseBlankerConfig::default(),
dig_sideband: DigSidebandPolicy::default(),
channels: Vec::new(),
max_virtual_channels: default_max_virtual_channels(),
spectrum_fft_size: default_spectrum_fft_size(),
+1 -1
View File
@@ -17,6 +17,6 @@ pub use rig::request::RigRequest;
pub use rig::response::{RigError, RigResult};
pub use rig::state::{
DecoderConfig, DecoderResetSeqs, RdsData, RigFilterState, RigMode, RigSnapshot, RigState,
WfmDenoiseLevel,
DigSidebandPolicy, WfmDenoiseLevel,
};
pub use rig::AudioSource;
+3 -1
View File
@@ -3,7 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
use crate::radio::freq::Freq;
use crate::rig::state::WfmDenoiseLevel;
use crate::rig::state::{DigSidebandPolicy, WfmDenoiseLevel};
use crate::RigMode;
/// Internal command handled by the rig task.
@@ -50,6 +50,8 @@ pub enum RigCommand {
SetSdrAgc(bool),
SetSdrSquelch { enabled: bool, threshold_db: f64 },
SetSdrNoiseBlanker { enabled: bool, threshold: f64 },
/// Set how the SDR backend resolves DIG mode to a sideband (SDR only).
SetSdrDigSideband(DigSidebandPolicy),
SetWfmDeemphasis(u32),
SetWfmStereo(bool),
SetWfmDenoise(WfmDenoiseLevel),
@@ -470,6 +470,7 @@ pub fn command_from_rig_command(cmd: RigCommand) -> Box<dyn RigCommandHandler> {
| RigCommand::SetSdrAgc(_)
| RigCommand::SetSdrSquelch { .. }
| RigCommand::SetSdrNoiseBlanker { .. }
| RigCommand::SetSdrDigSideband(_)
| RigCommand::SetWfmDeemphasis(_)
| RigCommand::SetWfmStereo(_)
| RigCommand::SetWfmDenoise(_)
+11
View File
@@ -260,6 +260,17 @@ pub trait RigSdr: Send {
)))
}
/// Set how DIG mode resolves to a sideband (SDR backends only).
fn set_sdr_dig_sideband<'a>(
&'a mut self,
_policy: crate::rig::state::DigSidebandPolicy,
) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'a>> {
Box::pin(std::future::ready(Err(
Box::new(response::RigError::not_supported("set_sdr_dig_sideband"))
as Box<dyn std::error::Error + Send + Sync>,
)))
}
fn set_wfm_stereo<'a>(
&'a mut self,
_enabled: bool,
+145
View File
@@ -338,6 +338,10 @@ pub struct RigFilterState {
pub sdr_nb_enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sdr_nb_threshold: Option<f64>,
/// Current DIG sideband policy (SDR backends only). Surfaces in the UI as
/// the advanced-controls "DIG sideband" selector.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sdr_dig_sideband: Option<DigSidebandPolicy>,
#[serde(default = "default_wfm_deemphasis_us")]
pub wfm_deemphasis_us: u32,
#[serde(default = "default_wfm_stereo")]
@@ -370,6 +374,89 @@ pub enum WfmDenoiseLevel {
High,
}
/// How the SDR backend resolves the [`RigMode::DIG`] mode to a concrete
/// sideband when demodulating.
///
/// DIG (data / digital) has no inherent sideband; by ham-radio convention it
/// follows the SSB convention for the band. `Auto` applies that convention
/// (USB at/above 10 MHz, LSB below); `Usb`/`Lsb` force a fixed sideband. The
/// setting is a global SDR control (see advanced radio controls) and may be
/// overridden per-bookmark.
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, TS)]
#[serde(rename_all = "lowercase")]
pub enum DigSidebandPolicy {
/// Pick USB or LSB from the dial frequency (USB ≥ 10 MHz, LSB below).
#[default]
Auto,
/// Always demodulate DIG as upper sideband.
Usb,
/// Always demodulate DIG as lower sideband.
Lsb,
}
impl DigSidebandPolicy {
/// Frequency boundary used by [`DigSidebandPolicy::Auto`]: at or above this
/// dial frequency DIG is upper sideband, below it is lower sideband. This is
/// the standard amateur SSB/data convention.
pub const AUTO_THRESHOLD_HZ: u64 = 10_000_000;
/// Resolve this policy to a concrete sideband mode ([`RigMode::USB`] or
/// [`RigMode::LSB`]) for the given dial frequency.
pub fn resolve(self, freq_hz: u64) -> RigMode {
match self {
DigSidebandPolicy::Usb => RigMode::USB,
DigSidebandPolicy::Lsb => RigMode::LSB,
DigSidebandPolicy::Auto => {
if freq_hz >= Self::AUTO_THRESHOLD_HZ {
RigMode::USB
} else {
RigMode::LSB
}
}
}
}
/// Compact encoding for storage in an atomic (e.g. shared runtime state).
pub fn to_u8(self) -> u8 {
match self {
DigSidebandPolicy::Auto => 0,
DigSidebandPolicy::Usb => 1,
DigSidebandPolicy::Lsb => 2,
}
}
/// Inverse of [`DigSidebandPolicy::to_u8`]; unknown values decode to `Auto`.
pub fn from_u8(v: u8) -> Self {
match v {
1 => DigSidebandPolicy::Usb,
2 => DigSidebandPolicy::Lsb,
_ => DigSidebandPolicy::Auto,
}
}
/// Parse a case-insensitive `auto`/`usb`/`lsb` string; `None` if unknown.
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"auto" => Some(DigSidebandPolicy::Auto),
"usb" => Some(DigSidebandPolicy::Usb),
"lsb" => Some(DigSidebandPolicy::Lsb),
_ => None,
}
}
}
/// Resolve a channel's effective demodulation mode. For [`RigMode::DIG`] this
/// applies the sideband `policy` at the given dial frequency; every other mode
/// is returned unchanged. Callers hand the result to the DSP while keeping the
/// logical `DIG` mode for display and decoder gating.
pub fn effective_demod_mode(logical: &RigMode, policy: DigSidebandPolicy, freq_hz: u64) -> RigMode {
if *logical == RigMode::DIG {
policy.resolve(freq_hz)
} else {
logical.clone()
}
}
fn default_wfm_deemphasis_us() -> u32 {
75
}
@@ -506,3 +593,61 @@ pub struct RigSnapshot {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vchan_rds: Option<Vec<VchanRdsEntry>>,
}
#[cfg(test)]
mod dig_sideband_tests {
use super::{effective_demod_mode, DigSidebandPolicy, RigMode};
#[test]
fn auto_follows_ssb_band_convention() {
// Below 10 MHz → LSB; at/above 10 MHz → USB.
assert_eq!(DigSidebandPolicy::Auto.resolve(7_074_000), RigMode::LSB);
assert_eq!(DigSidebandPolicy::Auto.resolve(3_580_000), RigMode::LSB);
assert_eq!(DigSidebandPolicy::Auto.resolve(9_999_999), RigMode::LSB);
assert_eq!(DigSidebandPolicy::Auto.resolve(10_000_000), RigMode::USB);
assert_eq!(DigSidebandPolicy::Auto.resolve(14_074_000), RigMode::USB);
}
#[test]
fn forced_policies_ignore_frequency() {
assert_eq!(DigSidebandPolicy::Usb.resolve(3_580_000), RigMode::USB);
assert_eq!(DigSidebandPolicy::Lsb.resolve(14_074_000), RigMode::LSB);
}
#[test]
fn effective_mode_only_rewrites_dig() {
// Non-DIG modes pass through untouched regardless of policy/frequency.
assert_eq!(
effective_demod_mode(&RigMode::USB, DigSidebandPolicy::Lsb, 3_580_000),
RigMode::USB
);
assert_eq!(
effective_demod_mode(&RigMode::FM, DigSidebandPolicy::Usb, 3_580_000),
RigMode::FM
);
// DIG resolves by policy + frequency.
assert_eq!(
effective_demod_mode(&RigMode::DIG, DigSidebandPolicy::Auto, 7_074_000),
RigMode::LSB
);
assert_eq!(
effective_demod_mode(&RigMode::DIG, DigSidebandPolicy::Auto, 14_074_000),
RigMode::USB
);
}
#[test]
fn u8_roundtrips_and_parses() {
for p in [
DigSidebandPolicy::Auto,
DigSidebandPolicy::Usb,
DigSidebandPolicy::Lsb,
] {
assert_eq!(DigSidebandPolicy::from_u8(p.to_u8()), p);
}
assert_eq!(DigSidebandPolicy::from_u8(200), DigSidebandPolicy::Auto);
assert_eq!(DigSidebandPolicy::parse("USB"), Some(DigSidebandPolicy::Usb));
assert_eq!(DigSidebandPolicy::parse(" lsb "), Some(DigSidebandPolicy::Lsb));
assert_eq!(DigSidebandPolicy::parse("nonsense"), None);
}
}
+2
View File
@@ -343,6 +343,7 @@ mod tests {
sdr_squelch_threshold_db: None,
sdr_nb_enabled: None,
sdr_nb_threshold: None,
sdr_dig_sideband: None,
wfm_deemphasis_us: 75,
wfm_stereo: true,
wfm_stereo_detected: false,
@@ -391,6 +392,7 @@ mod tests {
sdr_squelch_threshold_db: None,
sdr_nb_enabled: None,
sdr_nb_threshold: None,
sdr_dig_sideband: None,
wfm_deemphasis_us: 50,
wfm_stereo: true,
wfm_stereo_detected: true,
+1
View File
@@ -146,6 +146,7 @@ define_command_mapping! {
SetSdrGain { gain_db } <=> SetSdrGain,
SetSdrLnaGain { gain_db } <=> SetSdrLnaGain,
SetSdrAgc { enabled } <=> SetSdrAgc,
SetSdrDigSideband { policy } <=> SetSdrDigSideband,
SetWfmDeemphasis { deemphasis_us } <=> SetWfmDeemphasis,
SetWfmStereo { enabled } <=> SetWfmStereo,
SetWfmDenoise { level } <=> SetWfmDenoise,
+4 -1
View File
@@ -7,7 +7,7 @@
use serde::{Deserialize, Serialize};
use trx_core::rig::state::RigSnapshot;
use trx_core::WfmDenoiseLevel;
use trx_core::{DigSidebandPolicy, WfmDenoiseLevel};
/// Command received from network clients (JSON).
#[derive(Debug, Serialize, Deserialize)]
@@ -106,6 +106,9 @@ pub enum ClientCommand {
enabled: bool,
threshold: f64,
},
SetSdrDigSideband {
policy: DigSidebandPolicy,
},
SetWfmDeemphasis {
deemphasis_us: u32,
},
+1
View File
@@ -360,6 +360,7 @@ fn build_sdr_rig_from_instance(rig_cfg: &RigInstanceConfig) -> SdrRigBuildResult
max_virtual_channels: rig_cfg.sdr.max_virtual_channels,
nb_enabled: rig_cfg.sdr.noise_blanker.enabled,
nb_threshold: rig_cfg.sdr.noise_blanker.threshold,
dig_sideband: rig_cfg.sdr.dig_sideband,
spectrum_fft_size: rig_cfg.sdr.spectrum_fft_size,
})?;
+12
View File
@@ -774,6 +774,18 @@ async fn process_command(
let _ = ctx.state_tx.send(ctx.state.clone());
return snapshot_from(ctx.state);
}
RigCommand::SetSdrDigSideband(policy) => {
if let Some(sdr) = ctx.rig.as_sdr() {
if let Err(e) = sdr.set_sdr_dig_sideband(policy).await {
return Err(RigError::communication(format!("set_sdr_dig_sideband: {e}")));
}
} else {
return Err(RigError::not_supported("set_sdr_dig_sideband"));
}
ctx.state.filter = ctx.rig.as_sdr_ref().and_then(|s| s.filter_state());
let _ = ctx.state_tx.send(ctx.state.clone());
return snapshot_from(ctx.state);
}
RigCommand::SetWfmDeemphasis(deemphasis_us) => {
if let Some(sdr) = ctx.rig.as_sdr() {
if let Err(e) = sdr.set_wfm_deemphasis(deemphasis_us).await {
@@ -13,7 +13,10 @@ use std::sync::atomic::Ordering;
use std::sync::{Arc, Mutex};
use trx_core::radio::freq::{Band, Freq};
use trx_core::rig::response::RigError;
use trx_core::rig::state::{RigFilterState, SpectrumData, VchanRdsEntry, WfmDenoiseLevel};
use trx_core::rig::state::{
effective_demod_mode, DigSidebandPolicy, RigFilterState, SpectrumData, VchanRdsEntry,
WfmDenoiseLevel,
};
use trx_core::rig::{
AudioSource, Rig, RigAccessMethod, RigCapabilities, RigCat, RigInfo, RigSdr, RigStatusFuture,
};
@@ -73,6 +76,8 @@ pub struct SoapySdrConfig {
pub nb_enabled: bool,
/// Noise blanker impulse threshold multiplier.
pub nb_threshold: f64,
/// How DIG mode resolves to a sideband (auto/usb/lsb).
pub dig_sideband: DigSidebandPolicy,
/// FFT bin count for the spectrum display; a power of two.
///
/// Fewer bins cost less DSP and put fewer bytes on the network per frame,
@@ -104,6 +109,7 @@ impl Default for SoapySdrConfig {
max_virtual_channels: 4,
nb_enabled: false,
nb_threshold: 10.0,
dig_sideband: DigSidebandPolicy::Auto,
spectrum_fft_size: 1024,
}
}
@@ -157,7 +163,12 @@ pub struct SoapySdrRig {
/// Hidden AIS decoder channels (A and B) when available.
ais_channel_indices: Option<(usize, usize)>,
/// Virtual channel manager shared with external consumers (e.g. RigHandle).
/// Also owns the shared DIG-sideband policy.
channel_manager: Arc<vchan_impl::SdrVirtualChannelManager>,
/// Concrete demodulation mode last pushed to the primary channel DSP. For
/// DIG this is the resolved USB/LSB; used to avoid rebuilding filters on
/// every tune step when the resolved sideband hasn't actually changed.
applied_primary_mode: RigMode,
}
impl SoapySdrRig {
@@ -200,6 +211,7 @@ impl SoapySdrRig {
let max_virtual_channels = config.max_virtual_channels;
let nb_enabled = config.nb_enabled;
let nb_threshold = config.nb_threshold;
let dig_sideband = config.dig_sideband;
let spectrum_fft_size = config.spectrum_fft_size;
tracing::info!(
"initialising SoapySDR backend (args={:?}, gain_mode={:?}, gain_db={}, max_gain_db={:?})",
@@ -364,8 +376,15 @@ impl SoapySdrRig {
pipeline.clone(),
fixed_slot_count,
max_virtual_channels,
dig_sideband,
));
// Concrete demod mode the primary channel starts in. For DIG this
// resolves the configured sideband policy against the initial dial
// frequency so the very first image/audio uses the right sideband.
let initial_primary_mode = effective_demod_mode(&initial_mode, dig_sideband, initial_freq.hz);
let initial_is_dig = initial_mode == RigMode::DIG;
let rig = Self {
info,
freq: initial_freq,
@@ -392,7 +411,17 @@ impl SoapySdrRig {
nb_threshold,
ais_channel_indices: Some((primary_channel_count, primary_channel_count + 1)),
channel_manager,
applied_primary_mode: initial_primary_mode.clone(),
};
// If the primary channel starts in DIG, its DSP was created with the
// logical DIG demodulator (upper sideband); push the resolved sideband
// so an Auto/LSB start is honoured immediately.
if initial_is_dig {
let dsps = rig.pipeline.channel_dsps.read().unwrap();
if let Some(dsp_arc) = dsps.get(rig.primary_channel_idx) {
dsp_arc.lock().unwrap().set_mode(&initial_primary_mode);
}
}
rig.apply_ais_channel_activity();
Ok(rig)
}
@@ -448,6 +477,7 @@ impl SoapySdrRig {
max_virtual_channels,
nb_enabled,
nb_threshold,
dig_sideband: DigSidebandPolicy::default(),
})
}
@@ -619,6 +649,21 @@ impl RigCat for SoapySdrRig {
}
}
}
// In DIG/Auto, tuning across the 10 MHz boundary flips the
// sideband. Rebuild the demodulator only when it actually changes,
// so ordinary tuning stays glitch-free.
if self.mode == RigMode::DIG {
let effective = self.channel_manager.dig_policy().resolve(self.freq.hz);
if effective != self.applied_primary_mode {
let dsps = self.pipeline.channel_dsps.read().unwrap();
if let Some(dsp_arc) = dsps.get(self.primary_channel_idx) {
let mut dsp = dsp_arc.lock().unwrap();
dsp.set_mode(&effective);
dsp.set_filter(self.bandwidth_hz);
}
self.applied_primary_mode = effective;
}
}
self.update_ais_channel_offsets();
Ok(())
})
@@ -632,12 +677,17 @@ impl RigCat for SoapySdrRig {
tracing::debug!("SoapySdrRig: set_mode -> {:?}", mode);
self.mode = mode.clone();
self.bandwidth_hz = Self::default_bandwidth_for_mode(&mode);
// DIG carries no inherent sideband: resolve it to a concrete
// USB/LSB demodulator from the policy + dial frequency. The logical
// DIG mode is kept in `self.mode` (and RigState) for display.
let effective = effective_demod_mode(&self.mode, self.channel_manager.dig_policy(), self.freq.hz);
self.applied_primary_mode = effective.clone();
// Update the primary channel's demodulator in the live pipeline.
{
let dsps = self.pipeline.channel_dsps.read().unwrap();
if let Some(dsp_arc) = dsps.get(self.primary_channel_idx) {
let mut dsp = dsp_arc.lock().unwrap();
dsp.set_mode(&mode);
dsp.set_mode(&effective);
dsp.set_filter(self.bandwidth_hz);
}
}
@@ -956,6 +1006,30 @@ impl RigSdr for SoapySdrRig {
})
}
fn set_sdr_dig_sideband<'a>(
&'a mut self,
policy: DigSidebandPolicy,
) -> Pin<Box<dyn std::future::Future<Output = DynResult<()>> + Send + 'a>> {
Box::pin(async move {
// Update the shared policy (also re-resolves DIG virtual channels).
self.channel_manager.set_dig_policy(policy);
// Re-apply to the primary channel when it is currently DIG.
if self.mode == RigMode::DIG {
let effective = policy.resolve(self.freq.hz);
if effective != self.applied_primary_mode {
let dsps = self.pipeline.channel_dsps.read().unwrap();
if let Some(dsp_arc) = dsps.get(self.primary_channel_idx) {
let mut dsp = dsp_arc.lock().unwrap();
dsp.set_mode(&effective);
dsp.set_filter(self.bandwidth_hz);
}
self.applied_primary_mode = effective;
}
}
Ok(())
})
}
fn set_wfm_stereo<'a>(
&'a mut self,
enabled: bool,
@@ -1049,6 +1123,7 @@ impl RigSdr for SoapySdrRig {
sdr_squelch_threshold_db: Some(self.squelch_threshold_db as f64),
sdr_nb_enabled: Some(self.nb_enabled),
sdr_nb_threshold: Some(self.nb_threshold),
sdr_dig_sideband: Some(self.channel_manager.dig_policy()),
wfm_deemphasis_us: self.wfm_deemphasis_us,
wfm_stereo: self.wfm_stereo,
wfm_stereo_detected,
@@ -22,12 +22,12 @@
//! updates every `ChannelDsp` in place and pauses out-of-span channels instead
//! of destroying them.
use std::sync::atomic::{AtomicI64, Ordering};
use std::sync::atomic::{AtomicI64, AtomicU8, Ordering};
use std::sync::{Arc, RwLock};
use num_complex::Complex;
use tokio::sync::broadcast;
use trx_core::rig::state::{RigMode, VchanRdsEntry};
use trx_core::rig::state::{effective_demod_mode, DigSidebandPolicy, RigMode, VchanRdsEntry};
use uuid::Uuid;
use crate::dsp::SdrPipeline;
@@ -59,7 +59,12 @@ fn default_bandwidth_hz(mode: &RigMode) -> u32 {
struct ManagedChannel {
id: Uuid,
freq_hz: u64,
/// Logical mode requested by the user (e.g. `DIG`), shown in listings.
mode: RigMode,
/// Concrete demod mode last pushed to the DSP. For `DIG` this is the
/// resolved USB/LSB; tracked so a frequency change only rebuilds the
/// demodulator when the resolved sideband actually flips.
applied_mode: RigMode,
/// `broadcast::Sender` kept alive so new subscribers can join at any time.
pcm_tx: broadcast::Sender<Vec<f32>>,
/// IQ tap sender (kept alive; external consumers may subscribe).
@@ -91,6 +96,9 @@ pub struct SdrVirtualChannelManager {
channels: RwLock<Vec<ManagedChannel>>,
/// Fires whenever a channel is explicitly destroyed.
destroyed_tx: broadcast::Sender<Uuid>,
/// Shared DIG-sideband policy ([`DigSidebandPolicy`] encoded via `to_u8`).
/// Read when resolving any DIG channel's demodulator.
dig_sideband: AtomicU8,
}
impl SdrVirtualChannelManager {
@@ -100,7 +108,12 @@ impl SdrVirtualChannelManager {
/// - `fixed_slot_count`: number of fixed pipeline slots (primary + AIS),
/// i.e. the index of the first slot available for virtual channels.
/// - `max_total`: maximum total channels including primary (e.g. 4).
pub fn new(pipeline: Arc<SdrPipeline>, fixed_slot_count: usize, max_total: usize) -> Self {
pub fn new(
pipeline: Arc<SdrPipeline>,
fixed_slot_count: usize,
max_total: usize,
dig_sideband: DigSidebandPolicy,
) -> Self {
// Seed the channel list with a synthetic primary-channel entry.
// We use the first PCM sender from the pipeline (index 0).
let primary_pcm_tx = pipeline
@@ -118,6 +131,7 @@ impl SdrVirtualChannelManager {
id: Uuid::new_v4(),
freq_hz: 0, // actual freq kept by SoapySdrRig; manager treats ch-0 as opaque
mode: RigMode::USB,
applied_mode: RigMode::USB,
pcm_tx: primary_pcm_tx,
iq_tx: primary_iq_tx,
pipeline_slot: 0,
@@ -134,6 +148,7 @@ impl SdrVirtualChannelManager {
max_total: max_total.max(1),
channels: RwLock::new(vec![primary]),
destroyed_tx,
dig_sideband: AtomicU8::new(dig_sideband.to_u8()),
}
}
@@ -141,6 +156,36 @@ impl SdrVirtualChannelManager {
self.destroyed_tx.clone()
}
/// Current shared DIG-sideband policy.
pub fn dig_policy(&self) -> DigSidebandPolicy {
DigSidebandPolicy::from_u8(self.dig_sideband.load(Ordering::Relaxed))
}
/// Update the shared DIG-sideband policy and re-resolve every DIG virtual
/// channel's demodulator (the primary channel is handled by `SoapySdrRig`).
pub fn set_dig_policy(&self, policy: DigSidebandPolicy) {
self.dig_sideband.store(policy.to_u8(), Ordering::Relaxed);
let mut channels = self.channels.write().unwrap();
let dsps = self.pipeline.channel_dsps.read().unwrap();
for ch in channels
.iter_mut()
.filter(|c| !c.permanent && c.mode == RigMode::DIG)
{
let effective = policy.resolve(ch.freq_hz);
if effective != ch.applied_mode {
ch.applied_mode = effective.clone();
if let Some(dsp_arc) = dsps.get(ch.pipeline_slot) {
dsp_arc.lock().unwrap().set_mode(&effective);
}
}
}
}
/// Resolve a channel's effective demod mode under the current policy.
fn resolve_mode(&self, logical: &RigMode, freq_hz: u64) -> RigMode {
effective_demod_mode(logical, self.dig_policy(), freq_hz)
}
fn half_span_hz(&self) -> i64 {
i64::from(self.pipeline.sdr_sample_rate) / 2
}
@@ -172,10 +217,13 @@ impl SdrVirtualChannelManager {
});
}
// Bandwidth follows the logical mode; the DSP demodulator follows the
// resolved sideband (DIG → USB/LSB by policy + frequency).
let bandwidth_hz = default_bandwidth_hz(mode);
let (pcm_tx, iq_tx) = self
.pipeline
.add_virtual_channel(if_hz as f64, mode, bandwidth_hz);
let applied_mode = self.resolve_mode(mode, freq_hz);
let (pcm_tx, iq_tx) =
self.pipeline
.add_virtual_channel(if_hz as f64, &applied_mode, bandwidth_hz);
let pipeline_slot = self
.pipeline
@@ -190,6 +238,7 @@ impl SdrVirtualChannelManager {
id,
freq_hz,
mode: mode.clone(),
applied_mode,
pcm_tx,
iq_tx,
pipeline_slot,
@@ -314,9 +363,18 @@ impl VirtualChannelManager for SdrVirtualChannelManager {
.ok_or(VChanError::NotFound)?;
ch.freq_hz = freq_hz;
// A DIG/Auto channel flips sideband across the 10 MHz boundary; rebuild
// the demodulator only when the resolved sideband actually changes.
let effective = self.resolve_mode(&ch.mode, freq_hz);
let mode_changed = effective != ch.applied_mode;
ch.applied_mode = effective.clone();
let dsps = self.pipeline.channel_dsps.read().unwrap();
if let Some(dsp_arc) = dsps.get(ch.pipeline_slot) {
dsp_arc.lock().unwrap().set_channel_if_hz(if_hz as f64);
let mut dsp = dsp_arc.lock().unwrap();
dsp.set_channel_if_hz(if_hz as f64);
if mode_changed {
dsp.set_mode(&effective);
}
}
Ok(())
}
@@ -329,9 +387,11 @@ impl VirtualChannelManager for SdrVirtualChannelManager {
.ok_or(VChanError::NotFound)?;
ch.mode = mode.clone();
let effective = self.resolve_mode(mode, ch.freq_hz);
ch.applied_mode = effective.clone();
let dsps = self.pipeline.channel_dsps.read().unwrap();
if let Some(dsp_arc) = dsps.get(ch.pipeline_slot) {
dsp_arc.lock().unwrap().set_mode(mode);
dsp_arc.lock().unwrap().set_mode(&effective);
}
Ok(())
}
@@ -425,6 +485,7 @@ impl VirtualChannelManager for SdrVirtualChannelManager {
#[cfg(test)]
mod tests {
use super::*;
use crate::demod::Demodulator;
use crate::dsp::{MockIqSource, NoiseBlankerConfig, SdrPipeline};
fn make_pipeline() -> Arc<SdrPipeline> {
@@ -446,7 +507,7 @@ mod tests {
#[test]
fn add_and_list() {
let p = make_pipeline();
let mgr = SdrVirtualChannelManager::new(p, 1, 4);
let mgr = SdrVirtualChannelManager::new(p, 1, 4, DigSidebandPolicy::Auto);
// Set center to 14.1 MHz so that 14.074 MHz is within ±960 kHz.
mgr.update_center_hz(14_100_000);
assert_eq!(mgr.channels().len(), 1); // primary only
@@ -462,7 +523,7 @@ mod tests {
#[test]
fn remove_virtual_channel() {
let p = make_pipeline();
let mgr = SdrVirtualChannelManager::new(p, 1, 4);
let mgr = SdrVirtualChannelManager::new(p, 1, 4, DigSidebandPolicy::Auto);
mgr.update_center_hz(14_100_000);
let (id, _) = mgr.add_channel(14_074_000, &RigMode::USB).unwrap();
mgr.remove_channel(id).unwrap();
@@ -472,7 +533,7 @@ mod tests {
#[test]
fn cannot_remove_primary() {
let p = make_pipeline();
let mgr = SdrVirtualChannelManager::new(p, 1, 4);
let mgr = SdrVirtualChannelManager::new(p, 1, 4, DigSidebandPolicy::Auto);
let primary_id = mgr.channels()[0].id;
let err = mgr.remove_channel(primary_id).unwrap_err();
assert!(matches!(err, VChanError::Permanent));
@@ -481,7 +542,7 @@ mod tests {
#[test]
fn cap_enforced() {
let p = make_pipeline();
let mgr = SdrVirtualChannelManager::new(p, 1, 2); // primary + 1 virtual max
let mgr = SdrVirtualChannelManager::new(p, 1, 2, DigSidebandPolicy::Auto); // primary + 1 virtual max
mgr.update_center_hz(14_100_000);
mgr.add_channel(14_074_000, &RigMode::USB).unwrap();
let err = mgr.add_channel(14_075_000, &RigMode::USB).unwrap_err();
@@ -491,7 +552,7 @@ mod tests {
#[test]
fn out_of_bandwidth() {
let p = make_pipeline();
let mgr = SdrVirtualChannelManager::new(p, 1, 4);
let mgr = SdrVirtualChannelManager::new(p, 1, 4, DigSidebandPolicy::Auto);
// center_hz = 0, half_span = 960_000 Hz — 10 MHz is way out
let err = mgr.add_channel(10_000_000, &RigMode::USB).unwrap_err();
assert!(matches!(err, VChanError::OutOfBandwidth { .. }));
@@ -500,7 +561,7 @@ mod tests {
#[test]
fn hidden_background_channels_are_outside_visible_cap() {
let p = make_pipeline();
let mgr = SdrVirtualChannelManager::new(p, 1, 2); // primary + 1 visible max
let mgr = SdrVirtualChannelManager::new(p, 1, 2, DigSidebandPolicy::Auto); // primary + 1 visible max
mgr.update_center_hz(14_100_000);
mgr.add_channel(14_074_000, &RigMode::USB).unwrap();
@@ -513,10 +574,46 @@ mod tests {
assert!(visible.iter().all(|channel| channel.id != hidden_id));
}
#[test]
fn dig_channel_resolves_sideband_and_tracks_policy() {
let p = make_pipeline();
let mgr = SdrVirtualChannelManager::new(p.clone(), 1, 6, DigSidebandPolicy::Auto);
mgr.update_center_hz(7_100_000);
// The virtual channel occupies pipeline slot 1 (slot 0 is the primary).
let demod_at_slot1 = || {
p.channel_dsps.read().unwrap()[1]
.lock()
.unwrap()
.demodulator
.clone()
};
// DIG below 10 MHz under Auto → lower sideband.
let (_id, _rx) = mgr.add_channel(7_074_000, &RigMode::DIG).unwrap();
assert_eq!(demod_at_slot1(), Demodulator::Lsb);
// Forcing USB re-resolves the live channel.
mgr.set_dig_policy(DigSidebandPolicy::Usb);
assert_eq!(demod_at_slot1(), Demodulator::Usb);
// Back to Auto (still below 10 MHz) → lower sideband again.
mgr.set_dig_policy(DigSidebandPolicy::Auto);
assert_eq!(demod_at_slot1(), Demodulator::Lsb);
// The listing keeps the logical DIG mode for display.
let ch = mgr
.channels()
.into_iter()
.find(|c| !c.permanent)
.expect("virtual channel");
assert_eq!(ch.mode, "DIG");
}
#[test]
fn retune_keeps_virtual_channel_allocated() {
let p = make_pipeline();
let mgr = SdrVirtualChannelManager::new(p, 1, 4);
let mgr = SdrVirtualChannelManager::new(p, 1, 4, DigSidebandPolicy::Auto);
mgr.update_center_hz(14_100_000);
let mut destroyed_rx = mgr.subscribe_destroyed();
+9 -5
View File
@@ -118,6 +118,7 @@ sample_rate = 1920000
bandwidth = 1500000
wfm_deemphasis_us = 50
center_offset_hz = 100000
dig_sideband = "auto"
channels = []
max_virtual_channels = 4
spectrum_fft_size = 1024
@@ -203,13 +204,16 @@ 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"
bootstrap_read_enabled = true
bootstrap_read_username = "guest"
bootstrap_read_password = "guest"
session_ttl_min = 480
cookie_secure = false
cookie_same_site = "Lax"