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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" 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]] [[package]]
name = "atomic-waker" name = "atomic-waker"
version = "1.1.2" version = "1.1.2"
@@ -345,6 +357,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]] [[package]]
name = "bindgen" name = "bindgen"
version = "0.66.1" version = "0.66.1"
@@ -395,6 +413,24 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" 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]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.12.0" version = "0.12.0"
@@ -525,7 +561,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures 0.3.0",
"rand_core 0.10.1", "rand_core 0.10.1",
] ]
@@ -721,6 +757,15 @@ dependencies = [
"windows", "windows",
] ]
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.3.0" version = "0.3.0"
@@ -739,6 +784,16 @@ dependencies = [
"cfg-if", "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]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.2.1" version = "0.2.1"
@@ -799,15 +854,26 @@ dependencies = [
"zeroize", "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]] [[package]]
name = "digest" name = "digest"
version = "0.11.2" version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c"
dependencies = [ dependencies = [
"block-buffer", "block-buffer 0.12.0",
"const-oid", "const-oid",
"crypto-common", "crypto-common 0.2.1",
] ]
[[package]] [[package]]
@@ -1019,6 +1085,16 @@ dependencies = [
"slab", "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]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.17" version = "0.2.17"
@@ -1890,6 +1966,17 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "peeking_take_while" name = "peeking_take_while"
version = "0.1.2" version = "0.1.2"
@@ -2492,8 +2579,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures 0.3.0",
"digest", "digest 0.11.2",
] ]
[[package]] [[package]]
@@ -3215,6 +3302,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"actix-web", "actix-web",
"actix-ws", "actix-ws",
"argon2",
"base64", "base64",
"brotli 7.0.0", "brotli 7.0.0",
"bytes", "bytes",
+9 -4
View File
@@ -925,9 +925,14 @@ main
### HTTP Frontend Auth ### HTTP Frontend Auth
- Optional token or HTTP Basic Auth middleware - Optional Argon2id-backed managed accounts with HttpOnly session cookies
- Configured in `[frontends.http.auth]` - An exclusive Guest role plus composable Read, Control, Transmit, Write, and Administrator roles, with policy shared by middleware and handlers
- Rate limiting supported - 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 ### 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` | | `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` | | `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_*` | | `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` | | `RigRoutingContext` | Remote rig state & routing | `active_rig_id`, `remote_rigs`, `rig_states`, `server_connected` |
| `OwnerInfo` | Station metadata | `callsign`, `website_url`, `ais_vessel_url_base` | | `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/now` | The server's UTC clock, for checking the browser's |
| `GET` | `/api/logbook/prefill` | The six fields an entry opens with | | `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 ### Frontend
@@ -516,7 +516,7 @@ All five are implemented.
| Phase | Lands | | Phase | Lands |
|-------|-------| |-------|-------|
| 1 | `trx-logbook`: `Qso`, the ADI reader and writer, round-trip tests against files from other loggers | | 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 | | 3 | Logbook tab: entry, table, filters, import, export |
| 4 | Ham layout, pre-filled entry from a decode row or the map, worked-before | | 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 | | 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. 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 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, and changed on the station line at the top of the panel where it sticks for the session.
It cannot be taken from the session's identity: the auth roles are `control` and `rx`, with no It cannot be inferred from the session's identity: an account username need not be an operator
notion of who is logged in. 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 **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 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: When auth is enabled, an **auth gate** blocks the UI with:
- Title: "Access Required" - Title: "Access Required"
- Subtitle: "Enter passphrase to continue" - Subtitle: "Sign in to continue"
- Password input + Login button (green accent, full-width) - Username and password inputs + Login button (green accent, full-width)
- Optional "Continue as Guest" button (shown when RX passphrase is not set)
- Error message area (red `#ff6b6b`) - Error message area (red `#ff6b6b`)
- Role badge display - 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. 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 ### 7.1 Route Access Classification
Routes are classified into three tiers: Routes are classified into access tiers:
| Tier | Examples | Requirement | | Tier | Examples | Requirement |
|---|---|---| |---|---|---|
| **Public** | `/`, `/index.html`, `/map`, `/auth/*`, static assets | None | | **Public** | `/`, `/index.html`, `/map`, login/session endpoints, static assets | None |
| **Read** | `/status`, `/events`, `/audio`, `/decode`, `/spectrum`, `/bookmarks` | Rx or Control role | | **Read** | `/status`, `/events`, `/audio`, `/decode`, `/spectrum`, `/bookmarks` | Guest, Read, Control, Transmit, or Administrator role |
| **Control** | `/set_freq`, `/set_mode`, `/set_ptt`, `/toggle_power`, all other POST | Control role only | | **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 ### 7.2 Session Management
- Sessions are 128-bit random hex tokens stored in HttpOnly cookies - Sessions are 128-bit random hex tokens stored in HttpOnly cookies
- Configurable TTL (default from TOML config) - Configurable TTL (default from TOML config)
- Expired sessions auto-pruned on access - 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 Every authenticated non-Guest account gets a Settings > Account tab for changing
for Control-role users, providing an extra safety layer. 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 | | `[listen.auth].tokens` | `tokens_file` | one token per line |
| `[[remotes]].auth.token` | `token_file` | the token | | `[[remotes]].auth.token` | `token_file` | the token |
| `[frontends.http.auth].rx_passphrase` | `rx_passphrase_file` | the passphrase | | `[frontends.http.auth].bootstrap_admin_password` | `bootstrap_admin_password_file` | the initial administrator password |
| `[frontends.http.auth].control_passphrase` | `control_passphrase_file` | the passphrase |
| `[frontends.http_json.auth].tokens` | `tokens_file` | one token per line | | `[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 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 | | Field | Type | Default | Description |
|-------|------|---------|-------------| |-------|------|---------|-------------|
| `enabled` | bool | `false` | Require a passphrase | | `enabled` | bool | `false` | Enable the user/password ACL |
| `rx_passphrase` | string | — | Passphrase granting receive-only access | | `users_file` | string | `"trx-http-users.json"` | Persistent managed user database |
| `rx_passphrase_file` | string | — | Read it from this file instead | | `bootstrap_admin_username` | string | — | First administrator, used only if the database is absent |
| `control_passphrase` | string | — | Passphrase granting full control | | `bootstrap_admin_password` | string | — | First administrator password |
| `control_passphrase_file` | string | — | Read it from this file instead | | `bootstrap_admin_password_file` | string | — | Read the bootstrap password from this file instead |
| `tx_access_control_enabled` | bool | `true` | Hide TX from unauthenticated users | | `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 | | `session_ttl_min` | u64 | `480` | Session lifetime |
| `cookie_secure` | bool | `false` | Set Secure on the session cookie (needs HTTPS) | | `cookie_secure` | bool | `false` | Set Secure on the session cookie (needs HTTPS) |
| `cookie_same_site` | string | `"Lax"` | `Strict`, `Lax`, or `None` | | `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]` #### `[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 address bar itself is updated as you tune, using `replaceState`, so sweeping
the dial does not fill the browser's history. 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 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 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. 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 ## Authentication
The HTTP frontend supports optional passphrase-based authentication with two The HTTP frontend supports an optional user/password ACL:
roles:
- **rx** — read-only access (monitoring, audio, decode streams) - **Guest** — read-only station access with no Account or Users controls; Guest cannot be combined with another role
- **control** — full access (frequency, mode, PTT, and all settings) - **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 ### Configuration
```toml ```toml
[frontends.http.auth] [frontends.http.auth]
enabled = false enabled = false
rx_passphrase = "rx-only-passphrase" users_file = "trx-http-users.json"
control_passphrase = "full-control-passphrase" bootstrap_admin_username = "admin"
tx_access_control_enabled = true bootstrap_admin_password = "change-this-password"
bootstrap_read_enabled = true
bootstrap_read_username = "guest"
bootstrap_read_password = "guest"
session_ttl_min = 480 session_ttl_min = 480
cookie_secure = false # true if served via HTTPS cookie_secure = false # true if served via HTTPS
cookie_same_site = "Lax" # Strict|Lax|None cookie_same_site = "Lax" # Strict|Lax|None
``` ```
When `enabled = false` (the default), all auth is bypassed and the UI behaves 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 ### Behaviour
- On login, the server issues an `HttpOnly` session cookie. - On login, the server issues an `HttpOnly` session cookie.
- Sessions are in-memory; a server restart invalidates all sessions. - Sessions are in-memory; a server restart invalidates all sessions.
- Rate limiting is applied per IP to mitigate brute-force attempts. - Rate limiting is applied per IP to mitigate brute-force attempts.
- When `tx_access_control_enabled = true`, TX/PTT controls are hidden and - User records persist in `users_file`; passwords are stored as salted Argon2id hashes.
rejected for unauthenticated or `rx`-role users. - 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 ### Routes
| Endpoint | Method | Description | | Endpoint | Method | Description |
|----------|--------|-------------| |----------|--------|-------------|
| `/auth/login` | POST | Submit `{ "passphrase": "..." }` | | `/auth/login` | POST | Submit `{ "username": "...", "password": "..." }` |
| `/auth/logout` | POST | Clear session | | `/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, Read routes accept Guest or require Read. Tuning and receive-side radio mutations
mode, PTT, etc.) require `control` role. require Control. PTT, transmitted audio, and TX limit changes require Transmit.
Logbook access and bookmark mutations require Write. Administrator grants every permission.
### Frontend Flow ### Frontend Flow
1. On load, the UI calls `/auth/session`. 1. On load, the UI calls `/auth/session`.
2. If unauthenticated, a login screen is shown. 2. If unauthenticated, a login screen is shown.
3. On successful login, the normal UI loads. 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. 5. If a session expires mid-use, streams stop and the login screen returns.
### Transport Security ### Transport Security
+13 -1
View File
@@ -45,8 +45,20 @@ impl WefaxConfig {
60.0 / lpm as f32 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 { pub fn samples_per_line(lpm: u16, sample_rate: u32) -> usize {
(Self::line_duration_s(lpm) * sample_rate as f32).round() as 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. /// Real WEFAX content typically shows r > 0.5 between adjacent lines.
const LINE_CORR_NOISE_THRESHOLD: f32 = 0.2; const LINE_CORR_NOISE_THRESHOLD: f32 = 0.2;
/// Number of consecutive uncorrelated scan lines that trigger auto-finalize /// Number of consecutive uncorrelated scan lines that mark the end of a
/// while receiving. At 120 LPM this is 15 s; at 60 LPM it's 30 s. Modelled on /// transmission (carrier truly gone) and trigger auto-finalize. This must be
/// fldigi's line-to-line correlation check for automatic stop. /// long enough to ride through ordinary HF fading (QSB), which routinely
const LINE_CORR_NOISE_LINES: u32 = 30; /// 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 /// Maximum number of scan-line-equivalent sample windows to wait for phasing
/// lock before falling through to Receiving. Typical WEFAX phasing lasts /// 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 /// the decoder falls through to Receiving so a noisy or partial
/// phasing signal doesn't wedge the state machine. /// phasing signal doesn't wedge the state machine.
phasing_samples: u64, 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). /// Current rig dial frequency in Hz (for image filenames).
freq_hz: u64, freq_hz: u64,
/// Current rig mode name (for image filenames). /// Current rig mode name (for image filenames).
@@ -135,6 +155,7 @@ impl WefaxDecoder {
signal_detect_buf: Vec::with_capacity(INTERNAL_RATE as usize / 2), signal_detect_buf: Vec::with_capacity(INTERNAL_RATE as usize / 2),
low_corr_lines: 0, low_corr_lines: 0,
phasing_samples: 0, phasing_samples: 0,
auto_start_used: false,
freq_hz: 0, freq_hz: 0,
mode: String::new(), mode: String::new(),
} }
@@ -204,7 +225,13 @@ impl WefaxDecoder {
// Fallback: detect active WEFAX signal by luminance variance. // Fallback: detect active WEFAX signal by luminance variance.
// Like fldigi's "strong image signal" detection — if we see // Like fldigi's "strong image signal" detection — if we see
// sustained modulated signal, auto-start receiving with defaults. // 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); self.signal_detect_buf.extend_from_slice(&luminance);
let window_size = INTERNAL_RATE as usize / 2; let window_size = INTERNAL_RATE as usize / 2;
while self.signal_detect_buf.len() >= window_size { while self.signal_detect_buf.len() >= window_size {
@@ -340,6 +367,14 @@ impl WefaxDecoder {
break; 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. // Emit progress event.
if self.config.emit_progress && count % PROGRESS_INTERVAL == 0 { if self.config.emit_progress && count % PROGRESS_INTERVAL == 0 {
let line_data = let line_data =
@@ -402,6 +437,7 @@ impl WefaxDecoder {
self.signal_detect_buf.clear(); self.signal_detect_buf.clear();
self.low_corr_lines = 0; self.low_corr_lines = 0;
self.phasing_samples = 0; self.phasing_samples = 0;
self.auto_start_used = false;
events events
} }
@@ -434,6 +470,7 @@ impl WefaxDecoder {
fn transition_to_start_detected(&mut self, ioc: u16) -> WefaxEvent { fn transition_to_start_detected(&mut self, ioc: u16) -> WefaxEvent {
let ioc = self.config.ioc.unwrap_or(ioc); let ioc = self.config.ioc.unwrap_or(ioc);
debug!(ioc, "WEFAX: APT start detected"); debug!(ioc, "WEFAX: APT start detected");
self.auto_start_used = true;
self.state = State::StartDetected { ioc }; self.state = State::StartDetected { ioc };
self.reception_start_ms = Some( self.reception_start_ms = Some(
std::time::SystemTime::now() std::time::SystemTime::now()
@@ -463,6 +500,7 @@ impl WefaxDecoder {
self.image = Some(ImageAssembler::new(ppl)); self.image = Some(ImageAssembler::new(ppl));
self.tone_detector.reset(); self.tone_detector.reset();
self.low_corr_lines = 0; self.low_corr_lines = 0;
self.auto_start_used = true;
self.state = State::Receiving { ioc, lpm }; self.state = State::Receiving { ioc, lpm };
self.state_event("Receiving", ioc, lpm) self.state_event("Receiving", ioc, lpm)
} }
@@ -596,4 +634,47 @@ mod tests {
dec.reset(); dec.reset();
assert_eq!(dec.state, State::Idle); 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. /// Line slicer for WEFAX image assembly.
pub struct LineSlicer { pub struct LineSlicer {
/// Samples per line at the internal sample rate. /// Exact (fractional) samples per line at the internal sample rate.
samples_per_line: usize, samples_per_line: f64,
/// Pixels per line (IOC × π). /// Pixels per line (IOC × π).
pixels_per_line: usize, pixels_per_line: usize,
/// Phase offset in samples from the phasing detector. /// Phase offset in samples from the phasing detector.
@@ -22,22 +22,37 @@ pub struct LineSlicer {
buffer: Vec<f32>, buffer: Vec<f32>,
/// Whether we have aligned to the phase offset yet. /// Whether we have aligned to the phase offset yet.
aligned: bool, 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 { impl LineSlicer {
pub fn new(lpm: u16, ioc: u16, sample_rate: u32, phase_offset: usize) -> Self { 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; let pixels_per_line = WefaxConfig::pixels_per_line(ioc) as usize;
Self { Self {
samples_per_line, samples_per_line,
pixels_per_line, pixels_per_line,
phase_offset, phase_offset,
buffer: Vec::with_capacity(samples_per_line * 2), buffer: Vec::with_capacity(samples_per_line as usize * 2),
aligned: false, 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. /// Feed luminance samples and extract complete image lines.
/// ///
/// Returns a vector of completed lines, each as a `Vec<u8>` of /// 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²)). // 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; let mut offset = 0;
while offset + self.samples_per_line <= self.buffer.len() { loop {
let line_samples = &self.buffer[offset..offset + self.samples_per_line]; let len = self.line_len(self.line_index);
let pixels = self.resample_line(line_samples); if offset + len > self.buffer.len() {
lines.push(pixels); break;
offset += self.samples_per_line; }
let line_samples = &self.buffer[offset..offset + len];
lines.push(self.resample_line(line_samples));
offset += len;
self.line_index += 1;
} }
if offset > 0 { if offset > 0 {
self.buffer.drain(..offset); self.buffer.drain(..offset);
@@ -77,6 +98,7 @@ impl LineSlicer {
pub fn reset(&mut self) { pub fn reset(&mut self) {
self.buffer.clear(); self.buffer.clear();
self.aligned = false; self.aligned = false;
self.line_index = 0;
} }
/// Resample a line's worth of luminance samples to the target pixel count /// 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)); 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] #[test]
fn slicer_linear_interpolation() { fn slicer_linear_interpolation() {
let lpm = 120; let lpm = 120;
+5 -1
View File
@@ -78,7 +78,11 @@ pub struct ToneDetector {
impl ToneDetector { impl ToneDetector {
pub fn new(sample_rate: u32) -> Self { pub fn new(sample_rate: u32) -> Self {
let window_size = (sample_rate / 2) as usize; // ~0.5 s window 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 window_duration_s = window_size as f32 / sample_rate as f32;
let min_sustain_windows = (min_sustain_s / window_duration_s).ceil() as u32; 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 // Set HTTP frontend authentication config
frontend_runtime.http_auth.enabled = cfg.frontends.http.auth.enabled; 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.users_file = cfg.frontends.http.auth.users_file.clone();
frontend_runtime.http_auth.control_passphrase = frontend_runtime.http_auth.bootstrap_admin_username =
cfg.frontends.http.auth.control_passphrase.clone(); cfg.frontends.http.auth.bootstrap_admin_username.clone();
frontend_runtime.http_auth.tx_access_control_enabled = frontend_runtime.http_auth.bootstrap_admin_password =
cfg.frontends.http.auth.tx_access_control_enabled; 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.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_secure = cfg.frontends.http.auth.cookie_secure;
frontend_runtime.http_auth.cookie_same_site = match cfg.frontends.http.auth.cookie_same_site { 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. /// HTTP authentication configuration.
pub struct HttpAuthConfig { pub struct HttpAuthConfig {
pub enabled: bool, pub enabled: bool,
pub rx_passphrase: Option<String>, pub users_file: String,
pub control_passphrase: Option<String>, pub bootstrap_admin_username: Option<String>,
pub tx_access_control_enabled: bool, 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 session_ttl_secs: u64,
pub cookie_secure: bool, pub cookie_secure: bool,
pub cookie_same_site: String, pub cookie_same_site: String,
@@ -271,9 +274,12 @@ impl Default for HttpAuthConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
enabled: false, enabled: false,
rx_passphrase: None, users_file: "trx-http-users.json".to_string(),
control_passphrase: None, bootstrap_admin_username: None,
tx_access_control_enabled: true, 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, session_ttl_secs: 480 * 60,
cookie_secure: false, cookie_secure: false,
cookie_same_site: "Lax".to_string(), cookie_same_site: "Lax".to_string(),
@@ -28,6 +28,7 @@ flate2 = { workspace = true }
brotli = "7" brotli = "7"
rand = "0.8" rand = "0.8"
hex = "0.4" hex = "0.4"
argon2 = "0.5"
pickledb = "0.5" pickledb = "0.5"
dirs = "6" dirs = "6"
uuid = { workspace = true } 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 // src/webgl-renderer.ts
(function initTrxWebGl(global) { (function initTrxWebGl(global) {
"use strict"; "use strict";
@@ -1314,60 +1331,6 @@ async function loadDecoderRegistry(onLoaded) {
bridge.decoderRegistry = decoderRegistry; bridge.decoderRegistry = decoderRegistry;
bridge.onDecoderRegistryReady = onDecoderRegistryReady; 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 // src/core/format.ts
function formatDuration(milliseconds) { function formatDuration(milliseconds) {
const seconds = Math.floor(milliseconds / 1e3); 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"); 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); void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
var authRole = null; var authRoles = [];
var authUsername = null;
var authEnabled = true; 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() { async function checkAuthStatus() {
return fetchAuthSession(); return fetchAuthSession();
} }
async function authLogin(passphrase) { async function authLogin(username, password) {
return login(passphrase); return login(username, password);
} }
async function authLogout() { async function authLogout() {
try { try {
await logout(); await logout();
authRole = null; setAuthRoles([]);
authUsername = null;
disconnect(); disconnect();
setDecodeHistoryOverlayVisible(false); setDecodeHistoryOverlayVisible(false);
requiredElement("content").style.display = "none"; requiredElement("content").style.display = "none";
requiredElement("loading").style.display = "none"; requiredElement("loading").style.display = "none";
requiredElement("auth-passphrase").value = ""; requiredElement("auth-password").value = "";
updateAuthUI(); updateAuthUI();
const authStatus = await checkAuthStatus(); showAuthGate();
const allowGuest = authStatus.role === "rx";
showAuthGate(allowGuest);
} catch (e) { } catch (e) {
console.error("Logout failed:", e); console.error("Logout failed:", e);
showAuthError("Logout failed"); showAuthError("Logout failed");
} }
} }
function showAuthGate(allowGuest = false) { function showAuthGate() {
if (!authEnabled) return; if (!authEnabled) return;
setDecodeHistoryOverlayVisible(false); setDecodeHistoryOverlayVisible(false);
requiredElement("loading").style.display = "none"; requiredElement("loading").style.display = "none";
@@ -1905,10 +1902,6 @@ function showAuthGate(allowGuest = false) {
document.querySelectorAll(".tab-panel").forEach((panel) => { document.querySelectorAll(".tab-panel").forEach((panel) => {
panel.style.display = "none"; 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) => { document.querySelectorAll(".tab-bar .tab").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.tab === "main"); btn.classList.toggle("active", btn.dataset.tab === "main");
}); });
@@ -1944,20 +1937,30 @@ function updateAuthUI() {
const badge = document.getElementById("auth-badge"); const badge = document.getElementById("auth-badge");
const badgeRole = document.getElementById("auth-role-badge"); const badgeRole = document.getElementById("auth-role-badge");
const headerAuthBtn2 = document.getElementById("header-auth-btn"); const headerAuthBtn2 = document.getElementById("header-auth-btn");
const accountTab = document.getElementById("settings-account-tab");
if (!authEnabled) { if (!authEnabled) {
if (badge) badge.style.display = "none"; if (badge) badge.style.display = "none";
if (headerAuthBtn2) headerAuthBtn2.style.display = "none"; if (headerAuthBtn2) headerAuthBtn2.style.display = "none";
if (accountTab) accountTab.style.display = "none";
syncTopBarAccess(); syncTopBarAccess();
return; 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 (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) { if (headerAuthBtn2) {
headerAuthBtn2.textContent = "Logout"; headerAuthBtn2.textContent = "Logout";
headerAuthBtn2.style.display = "block"; headerAuthBtn2.style.display = "block";
} }
} else { } else {
if (accountTab) accountTab.style.display = "none";
if (badge) badge.style.display = "none"; if (badge) badge.style.display = "none";
if (headerAuthBtn2) { if (headerAuthBtn2) {
headerAuthBtn2.textContent = "Login"; headerAuthBtn2.textContent = "Login";
@@ -1967,31 +1970,34 @@ function updateAuthUI() {
syncTopBarAccess(); syncTopBarAccess();
} }
function applyAuthRestrictions() { function applyAuthRestrictions() {
if (!authRole) return; if (authRoles.length === 0) return;
if (authRole === "rx") { if (!hasAuthRole2("transmit")) {
const pttBtn2 = document.getElementById("ptt-btn"); 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 powerBtn2 = document.getElementById("power-btn");
const lockBtn2 = document.getElementById("lock-btn"); const lockBtn2 = document.getElementById("lock-btn");
const freqInput = document.getElementById("freq"); const freqInput = document.getElementById("freq");
const centerFreqInput = document.getElementById("center-freq"); const centerFreqInput = document.getElementById("center-freq");
const modeSelect = document.getElementById("mode"); 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 jogUp = document.getElementById("jog-up");
const jogDown = document.getElementById("jog-down"); const jogDown = document.getElementById("jog-down");
const jogButtons = document.querySelectorAll(".jog-step button"); const jogButtons = document.querySelectorAll(".jog-step button");
const vfoButtons = document.querySelectorAll("#vfo-picker button"); const vfoButtons = document.querySelectorAll("#vfo-picker button");
if (pttBtn2) pttBtn2.disabled = true;
if (powerBtn2) powerBtn2.disabled = true; if (powerBtn2) powerBtn2.disabled = true;
if (lockBtn2) lockBtn2.disabled = true; if (lockBtn2) lockBtn2.disabled = true;
if (txAudioBtn2) txAudioBtn2.disabled = true;
if (txLimitBtn2) txLimitBtn2.disabled = true;
if (freqInput) freqInput.disabled = true; if (freqInput) freqInput.disabled = true;
if (centerFreqInput) centerFreqInput.disabled = true; if (centerFreqInput) centerFreqInput.disabled = true;
if (modeSelect) modeSelect.disabled = true; if (modeSelect) modeSelect.disabled = true;
if (txLimitInput2) txLimitInput2.disabled = true;
vfoButtons.forEach((btn) => btn.disabled = true); vfoButtons.forEach((btn) => btn.disabled = true);
const jogWheel2 = document.getElementById("jog-wheel"); const jogWheel2 = document.getElementById("jog-wheel");
if (jogUp) jogUp.disabled = true; if (jogUp) jogUp.disabled = true;
@@ -2028,7 +2034,6 @@ function applyAuthRestrictions() {
btn.disabled = true; btn.disabled = true;
} }
}); });
if (txLimitRow2) txLimitRow2.style.opacity = "0.5";
} }
} }
function applyCapabilities(caps) { function applyCapabilities(caps) {
@@ -2258,20 +2263,21 @@ window.applyDecodeHistoryRetention = function() {
} }
}; };
function syncTopBarAccess() { function syncTopBarAccess() {
const loggedOut = authEnabled && !authRole; const loggedOut = authEnabled && authRoles.length === 0;
const tabBar = document.getElementById("tab-bar"); const tabBar = document.getElementById("tab-bar");
const rigSwitch = document.querySelector(".header-rig-switch"); const rigSwitch = document.querySelector(".header-rig-switch");
if (tabBar) tabBar.style.display = ""; if (tabBar) tabBar.style.display = "";
document.querySelectorAll(".tab-bar .tab").forEach((btn) => { document.querySelectorAll(".tab-bar .tab").forEach((btn) => {
const isMain = btn.dataset.tab === "main"; 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; btn.disabled = false;
}); });
if (rigSwitch) { if (rigSwitch) {
rigSwitch.style.display = loggedOut ? "none" : ""; rigSwitch.style.display = loggedOut ? "none" : "";
} }
if (headerRigSwitchSelect) { if (headerRigSwitchSelect) {
headerRigSwitchSelect.disabled = loggedOut || authRole === "rx" || lastRigIds.length === 0; headerRigSwitchSelect.disabled = loggedOut || !hasAuthRole2("control") || lastRigIds.length === 0;
} }
} }
var overviewDrawPending = false; var overviewDrawPending = false;
@@ -2907,7 +2913,7 @@ function applyRigList(activeRigId, rigIds, displayNames) {
} }
const nextKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || ""); const nextKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || "");
const rigListChanged = prevKey !== nextKey; const rigListChanged = prevKey !== nextKey;
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx"; const disableSwitch = lastRigIds.length === 0 || !hasAuthRole2("control");
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch); populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
updateRigSubtitle(lastActiveRigId); updateRigSubtitle(lastActiveRigId);
window.trxUi?.setActiveRig(lastActiveRigId); window.trxUi?.setActiveRig(lastActiveRigId);
@@ -4245,9 +4251,16 @@ function formatSignal(sUnits) {
return overDb === 0 ? `${sigUnit("S")}9` : `${sigUnit("S")}9+${overDb}${sigUnit("dB")}`; return overDb === 0 ? `${sigUnit("S")}9` : `${sigUnit("S")}9+${overDb}${sigUnit("dB")}`;
} }
function setDisabled(disabled) { function setDisabled(disabled) {
[freqEl, centerFreqEl, modeEl, pttBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => { const controlDisabled = disabled || authEnabled && !hasAuthRole2("control");
if (el) el.disabled = disabled; 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(); syncModePicker();
} }
var serverVersion = null; var serverVersion = null;
@@ -4510,7 +4523,7 @@ function scheduleTuneLinkSync() {
async function applyTuneLink(link) { async function applyTuneLink(link) {
const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null; const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null;
if (!wanted) return; if (!wanted) return;
if (authRole === "rx") { if (!hasAuthRole2("control")) {
showHint("Read-only session — link not applied", 2500); showHint("Read-only session — link not applied", 2500);
return; 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 (typeof update.show_sdr_gain_control === "boolean") {
if (sdrSettingsRowEl) sdrSettingsRowEl.style.display = update.show_sdr_gain_control ? "" : "none"; 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" }); const resp = await fetch(path, { method: "POST" });
if (authEnabled && resp.status === 401) { if (authEnabled && resp.status === 401) {
authRole = null; setAuthRoles([]);
if (es) es.close(); if (es) es.close();
showAuthGate(); showAuthGate();
throw new Error("Authentication required"); throw new Error("Authentication required");
@@ -5253,7 +5273,7 @@ async function switchRigFromSelect(selectEl) {
showHint("No rig selected", 1500); showHint("No rig selected", 1500);
return; return;
} }
if (authRole === "rx") { if (!hasAuthRole2("control")) {
showHint("Control role required", 1500); showHint("Control role required", 1500);
return; return;
} }
@@ -5867,8 +5887,13 @@ function navigateToTab(name, options = {}) {
window.trxUi?.closeMobileOverlays?.(); window.trxUi?.closeMobileOverlays?.();
const leavingSatellites = _activeTab === "satellites" && name !== "satellites"; const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
const { updateHistory = true, replaceHistory = false } = options; const { updateHistory = true, replaceHistory = false } = options;
if (authEnabled && !authRole && name !== "main") { if (authEnabled && authRoles.length === 0 && name !== "main") {
showAuthGate(false); showAuthGate();
return;
}
if (authEnabled && name === "logbook" && !hasAuthRole2("write")) {
showHint("Write role required for logbook access", 2500);
navigateToTab("main", options);
return; return;
} }
const btn = document.querySelector(`.tab-bar .tab[data-tab="${name}"]`); const btn = document.querySelector(`.tab-bar .tab[data-tab="${name}"]`);
@@ -5981,11 +6006,11 @@ window.addEventListener("resize", () => {
scheduleSpectrumLayout(); scheduleSpectrumLayout();
}); });
async function initializeApp() { async function initializeApp() {
showAuthGate(false); showAuthGate();
const authStatus = await checkAuthStatus(); const authStatus = await checkAuthStatus();
authEnabled = !authStatus.auth_disabled; authEnabled = !authStatus.auth_disabled;
if (!authEnabled) { if (!authEnabled) {
authRole = "control"; setAuthRoles(AUTH_ADMIN_ROLES);
hideAuthGate(); hideAuthGate();
updateAuthUI(); updateAuthUI();
connect(); connect();
@@ -5996,7 +6021,8 @@ async function initializeApp() {
return; return;
} }
if (authStatus.authenticated) { if (authStatus.authenticated) {
authRole = authStatus.role ?? null; setAuthRoles(authStatus.roles);
authUsername = authStatus.username ?? null;
hideAuthGate(); hideAuthGate();
updateAuthUI(); updateAuthUI();
applyAuthRestrictions(); applyAuthRestrictions();
@@ -6006,32 +6032,191 @@ async function initializeApp() {
resizeHeaderSignalCanvas(); resizeHeaderSignalCanvas();
startHeaderSignalSampling(); startHeaderSignalSampling();
} else { } else {
const allowGuest = authStatus.role === "rx"; showAuthGate();
showAuthGate(allowGuest);
} }
} }
var settingsUiReady = false; var settingsUiReady = false;
function initSettingsUI() { function initSettingsUI() {
settingsUiReady = true; settingsUiReady = true;
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRole); window.trx.modules.scheduler?.initialize(lastActiveRigId, authRoles);
window.trx.modules.scheduler?.wireEvents(); window.trx.modules.scheduler?.wireEvents();
if (window.trx.modules.backgroundDecode) { if (window.trx.modules.backgroundDecode) {
window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRole); window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRoles);
window.trx.modules.backgroundDecode.wireEvents(); 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) => { requiredElement("auth-form").addEventListener("submit", async (e) => {
e.preventDefault(); e.preventDefault();
const passphraseEl = requiredElement("auth-passphrase"); const usernameEl = requiredElement("auth-username");
const passphrase = passphraseEl.value; const passwordEl = requiredElement("auth-password");
const btn = requiredElement("auth-form").querySelector("button[type=submit]"); const btn = requiredElement("auth-form").querySelector("button[type=submit]");
if (!btn) return; if (!btn) return;
btn.disabled = true; btn.disabled = true;
btn.textContent = "Logging in..."; btn.textContent = "Logging in...";
try { try {
const result = await authLogin(passphrase); const result = await authLogin(usernameEl.value, passwordEl.value);
authRole = result.role ?? null; setAuthRoles(result.roles);
passphraseEl.value = ""; authUsername = result.username ?? usernameEl.value;
passwordEl.value = "";
hideAuthGate(); hideAuthGate();
updateAuthUI(); updateAuthUI();
applyAuthRestrictions(); applyAuthRestrictions();
@@ -6041,37 +6226,22 @@ requiredElement("auth-form").addEventListener("submit", async (e) => {
resizeHeaderSignalCanvas(); resizeHeaderSignalCanvas();
startHeaderSignalSampling(); startHeaderSignalSampling();
} catch (err) { } catch (err) {
showAuthError("Invalid passphrase"); showAuthError("Invalid username or password");
console.error("Login error:", err); console.error("Login error:", err);
} finally { } finally {
btn.disabled = false; btn.disabled = false;
btn.textContent = "Login"; 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"); var headerAuthBtn = document.getElementById("header-auth-btn");
if (headerAuthBtn) { if (headerAuthBtn) {
headerAuthBtn.addEventListener("click", async () => { 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 })) { 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(); await authLogout();
} }
} else { } else {
showAuthGate(false); showAuthGate();
} }
}); });
} }
@@ -6109,8 +6279,8 @@ Object.defineProperties(trxState, {
authEnabled: { get() { authEnabled: { get() {
return authEnabled; return authEnabled;
} }, } },
authRole: { get() { authRoles: { get() {
return authRole; return authRoles;
} }, } },
decoderRegistry: { get() { decoderRegistry: { get() {
return decoderRegistry; return decoderRegistry;
@@ -6461,6 +6631,9 @@ var sdrNbThresholdControlsEl = document.getElementById("sdr-nb-threshold-control
var sdrNbThresholdEl = document.getElementById("sdr-nb-threshold"); var sdrNbThresholdEl = document.getElementById("sdr-nb-threshold");
var sdrNbThresholdSetBtn = document.getElementById("sdr-nb-threshold-set"); var sdrNbThresholdSetBtn = document.getElementById("sdr-nb-threshold-set");
var sdrNbSupported = false; 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) => { fetch("/audio", { method: "GET" }).then((r) => {
if (r.status === 404) audioRow.style.display = "none"; if (r.status === 404) audioRow.style.display = "none";
}).catch(() => { }).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() { function updateWfmControls() {
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase(); const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
if (wfmControlsCol) wfmControlsCol.style.display = mode === "WFM" ? "" : "none"; if (wfmControlsCol) wfmControlsCol.style.display = mode === "WFM" ? "" : "none";
if (samControlsCol) samControlsCol.style.display = mode === "SAM" ? "" : "none"; if (samControlsCol) samControlsCol.style.display = mode === "SAM" ? "" : "none";
if (modeControlsRow) modeControlsRow.style.display = mode === "WFM" || mode === "SAM" ? "" : "none"; if (modeControlsRow) modeControlsRow.style.display = mode === "WFM" || mode === "SAM" ? "" : "none";
if (sdrDigSidebandWrapEl) {
sdrDigSidebandWrapEl.style.display = sdrDigSidebandSupported && mode === "DIG" ? "" : "none";
}
} }
if (!hasWebCodecs) { if (!hasWebCodecs) {
rxAudioBtn.disabled = true; rxAudioBtn.disabled = true;
@@ -7182,6 +7370,10 @@ function startTxAudio() {
void stopTxAudio(); void stopTxAudio();
return; return;
} }
if (authEnabled && !hasAuthRole2("transmit")) {
audioStatus.textContent = "Transmit role required";
return;
}
if (!hasWebCodecs) { if (!hasWebCodecs) {
audioStatus.textContent = "Audio requires Chrome/Edge"; audioStatus.textContent = "Audio requires Chrome/Edge";
return; return;
@@ -1,3 +1,6 @@
import {
hasAuthRole
} from "./chunk-PISLBJGN.js";
import { import {
hostState hostState
} from "./chunk-KL66PICH.js"; } from "./chunk-KL66PICH.js";
@@ -13,7 +16,7 @@ var bgdWindow = window;
return d.id; return d.id;
}); });
} }
let backgroundDecodeRole = null; let backgroundDecodeRoles = [];
let currentRigId = null; let currentRigId = null;
let currentConfig = null; let currentConfig = null;
let bookmarkList = []; let bookmarkList = [];
@@ -21,8 +24,8 @@ var bgdWindow = window;
let bgdDirty = false; let bgdDirty = false;
let statusByBookmark = /* @__PURE__ */ new Map(); let statusByBookmark = /* @__PURE__ */ new Map();
let lastStatus = null; let lastStatus = null;
function initBackgroundDecode(rigId, role) { function initBackgroundDecode(rigId, roles) {
backgroundDecodeRole = role; backgroundDecodeRoles = roles;
currentRigId = rigId || hostState.lastActiveRigId || null; currentRigId = rigId || hostState.lastActiveRigId || null;
if (currentRigId) loadBackgroundDecode(); if (currentRigId) loadBackgroundDecode();
startStatusPolling(); startStatusPolling();
@@ -358,7 +361,7 @@ var bgdWindow = window;
btn.title = bgdDirty ? "Apply these bookmarks to the background decoder" : "No changes to save"; btn.title = bgdDirty ? "Apply these bookmarks to the background decoder" : "No changes to save";
} }
function isControlRole() { function isControlRole() {
return backgroundDecodeRole === "control" || hostState.authEnabled === false; return hasAuthRole(backgroundDecodeRoles, "control") || hostState.authEnabled === false;
} }
function showToast(msg, isError) { function showToast(msg, isError) {
const el = document.getElementById("background-decode-toast"); const el = document.getElementById("background-decode-toast");
@@ -1,3 +1,6 @@
import {
hasAuthRole
} from "./chunk-PISLBJGN.js";
import { import {
hostCore, hostCore,
hostState hostState
@@ -42,7 +45,7 @@ function bmEsc(str) {
return d.innerHTML; return d.innerHTML;
} }
function bmCanControl() { function bmCanControl() {
return !hostState.authEnabled || hostState.authRole === "control"; return !hostState.authEnabled || hasAuthRole(hostState.authRoles, "write");
} }
function bmSyncAccess() { function bmSyncAccess() {
const canCtrl = bmCanControl(); const canCtrl = bmCanControl();
@@ -211,6 +214,12 @@ function bmBuildDecoderCheckboxes() {
container.appendChild(label); 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) { function bmOpenForm(bm) {
const wrap = bmEl("bm-form-wrap"); const wrap = bmEl("bm-form-wrap");
if (!wrap) return; if (!wrap) return;
@@ -224,7 +233,9 @@ function bmOpenForm(bm) {
bmEl("bm-locator").value = bm ? bm.locator || "" : ""; bmEl("bm-locator").value = bm ? bm.locator || "" : "";
bmEl("bm-category-input").value = bm ? bm.category || "" : ""; bmEl("bm-category-input").value = bm ? bm.category || "" : "";
bmEl("bm-comment").value = bm ? bm.comment || "" : ""; bmEl("bm-comment").value = bm ? bm.comment || "" : "";
bmEl("bm-dig-sideband").value = bm ? bm.dig_sideband || "" : "";
bmWriteDecoders(bm?.decoders ?? []); bmWriteDecoders(bm?.decoders ?? []);
bmSyncDigSidebandVisibility();
bmEl("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark"; bmEl("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark";
wrap.style.display = "flex"; wrap.style.display = "flex";
bmEl("bm-name").focus(); bmEl("bm-name").focus();
@@ -260,6 +271,7 @@ async function bmSave(e) {
const category = bmEl("bm-category-input").value.trim(); const category = bmEl("bm-category-input").value.trim();
const comment = bmEl("bm-comment").value.trim(); const comment = bmEl("bm-comment").value.trim();
const decoders = bmReadDecoders(); const decoders = bmReadDecoders();
const dig_sideband = mode.toUpperCase() === "DIG" ? bmEl("bm-dig-sideband").value || null : null;
const formError = bmEl("bm-form-error"); const formError = bmEl("bm-form-error");
if (formError) formError.textContent = ""; if (formError) formError.textContent = "";
if (!name || !Number.isFinite(freq_hz) || !mode) { if (!name || !Number.isFinite(freq_hz) || !mode) {
@@ -276,7 +288,8 @@ async function bmSave(e) {
locator: locator || null, locator: locator || null,
category, category,
comment, comment,
decoders decoders,
dig_sideband
}; };
try { try {
let resp; let resp;
@@ -341,6 +354,12 @@ function bmApply(bm) {
} }
const tunePromise = (async () => { const tunePromise = (async () => {
await bridge.trx.modules.vchan?.takeSchedulerControl(); 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; const onVirtual = await bridge.trx.modules.vchan?.interceptMode(bm.mode) ?? false;
if (!onVirtual) { if (!onVirtual) {
await hostCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode)); await hostCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
@@ -593,6 +612,7 @@ function bmPopulateScopePicker() {
bmEl("bm-form").addEventListener("submit", (event) => { bmEl("bm-form").addEventListener("submit", (event) => {
void bmSave(event); void bmSave(event);
}); });
bmEl("bm-mode").addEventListener("input", bmSyncDigSidebandVisibility);
bmEl("bm-form-cancel").addEventListener("click", bmCloseForm); bmEl("bm-form-cancel").addEventListener("click", bmCloseForm);
const formWrap = bmEl("bm-form-wrap"); const formWrap = bmEl("bm-form-wrap");
if (formWrap) { 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 { import {
hostCore, hostCore,
hostState hostState
@@ -47,6 +50,9 @@ var entryRigName = null;
var entryGrid = null; var entryGrid = null;
var qsos = []; var qsos = [];
var workedRequest = 0; var workedRequest = 0;
function canWriteLogbook() {
return !hostState.authEnabled || hasAuthRole(hostState.authRoles, "write");
}
function notify(message, kind) { function notify(message, kind) {
if (bridge.trxUi.notify) bridge.trxUi.notify(message, kind ? { kind } : void 0); if (bridge.trxUi.notify) bridge.trxUi.notify(message, kind ? { kind } : void 0);
else hostCore.showHint(message, 2e3); else hostCore.showHint(message, 2e3);
@@ -300,6 +306,11 @@ function renderRows() {
row.appendChild(cell); row.appendChild(cell);
} }
const actions = document.createElement("td"); const actions = document.createElement("td");
if (!canWriteLogbook()) {
row.appendChild(actions);
fragment.appendChild(row);
continue;
}
const confirm = document.createElement("button"); const confirm = document.createElement("button");
confirm.type = "button"; confirm.type = "button";
confirm.className = "log-row-btn"; confirm.className = "log-row-btn";
@@ -420,14 +431,19 @@ importFile?.addEventListener("change", () => {
if (file) void importAdif(file); if (file) void importAdif(file);
importFile.value = ""; importFile.value = "";
}); });
bridge.logContact = (seed) => { if (canWriteLogbook()) {
bridge.logContact = (seed) => {
bridge.navigateToTab?.("logbook"); bridge.navigateToTab?.("logbook");
void openEntry(seed).then(() => callInput?.focus()); void openEntry(seed).then(() => callInput?.focus());
}; };
} else {
if (form) form.style.display = "none";
if (importBtn) importBtn.style.display = "none";
}
renderStation(); renderStation();
if (cabrilloCallsign && !cabrilloCallsign.value) { if (cabrilloCallsign && !cabrilloCallsign.value) {
cabrilloCallsign.value = stationCallEl?.textContent?.trim() ?? ""; cabrilloCallsign.value = stationCallEl?.textContent?.trim() ?? "";
} }
syncCabrilloLink(); syncCabrilloLink();
void openEntry(); if (canWriteLogbook()) void openEntry();
void refreshLog(); void refreshLog();
@@ -1,3 +1,6 @@
import {
hasAuthRole
} from "./chunk-PISLBJGN.js";
import { import {
hostState hostState
} from "./chunk-KL66PICH.js"; } from "./chunk-KL66PICH.js";
@@ -15,7 +18,7 @@ function schedulerOptionalEl(id) {
} }
(function() { (function() {
"use strict"; "use strict";
let schedulerRole = null; let schedulerRoles = [];
let currentRigId = null; let currentRigId = null;
let currentConfig = null; let currentConfig = null;
let currentSchedulerStatus = null; let currentSchedulerStatus = null;
@@ -25,8 +28,8 @@ function schedulerOptionalEl(id) {
let schedulerStepPending = false; let schedulerStepPending = false;
let schEntryEditIdx = null; let schEntryEditIdx = null;
let schedulerDirty = false; let schedulerDirty = false;
function initScheduler(rigId, role) { function initScheduler(rigId, roles) {
schedulerRole = role; schedulerRoles = roles;
currentRigId = rigId || null; currentRigId = rigId || null;
if (currentRigId) loadScheduler(); if (currentRigId) loadScheduler();
startStatusPolling(); startStatusPolling();
@@ -272,7 +275,7 @@ function schedulerOptionalEl(id) {
const nextBtn = schedulerEl("scheduler-next-btn"); const nextBtn = schedulerEl("scheduler-next-btn");
if (!prevBtn || !nextBtn) return; if (!prevBtn || !nextBtn) return;
const state = schedulerInterleaveState(currentConfig); 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; prevBtn.disabled = !enabled;
nextBtn.disabled = !enabled; nextBtn.disabled = !enabled;
const hint = enabled ? "Select a different active scheduler entry" : "Available only when multiple scheduler entries are active"; 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"); const panel = schedulerEl("scheduler-panel");
if (!panel) return; if (!panel) return;
const mode = currentConfig && currentConfig.mode || "disabled"; const mode = currentConfig && currentConfig.mode || "disabled";
const isControl = schedulerRole === "control"; const isControl = hasAuthRole(schedulerRoles, "control");
setSelected("scheduler-mode-select", mode); setSelected("scheduler-mode-select", mode);
const satEnabled = currentConfig && currentConfig.satellites && currentConfig.satellites.enabled; const satEnabled = currentConfig && currentConfig.satellites && currentConfig.satellites.enabled;
const controlRow = document.querySelector(".scheduler-control-row"); const controlRow = document.querySelector(".scheduler-control-row");
@@ -1220,8 +1223,8 @@ function schedulerOptionalEl(id) {
markDirty: markSchedulerDirty markDirty: markSchedulerDirty
}; };
schedulerWindow.trx.modules.scheduler = schedulerService; schedulerWindow.trx.modules.scheduler = schedulerService;
if (hostState.authRole != null) { if (!hostState.authEnabled || hostState.authRoles.length > 0) {
initScheduler(hostState.lastActiveRigId, hostState.authRole); initScheduler(hostState.lastActiveRigId, hostState.authRoles);
wireSchedulerEvents(); wireSchedulerEvents();
} }
})(); })();
@@ -123,13 +123,13 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div id="auth-gate" class="auth-gate" style="display:none;"> <div id="auth-gate" class="auth-gate" style="display:none;">
<div class="auth-gate-head"> <div class="auth-gate-head">
<div class="auth-gate-title">Access Required</div> <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> </div>
<form id="auth-form" class="auth-form"> <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> <button type="submit" class="auth-submit">Login</button>
</form> </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-error" class="auth-error" style="display: none;"></div>
<div id="auth-role" class="auth-role" style="display: none;"></div> <div id="auth-role" class="auth-role" style="display: none;"></div>
</div> </div>
@@ -385,6 +385,14 @@ SPDX-License-Identifier: GPL-2.0-or-later
</label> </label>
<button id="sdr-nb-threshold-set" type="button" class="wfm-inline-btn">Set</button> <button id="sdr-nb-threshold-set" type="button" class="wfm-inline-btn">Set</button>
</div> </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> </div>
<div class="full-row label-below-row" id="vchan-row"> <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) <label class="bm-label">Bandwidth (Hz)
<input type="number" id="bm-bw" class="status-input" min="0" placeholder="optional" /> <input type="number" id="bm-bw" class="status-input" min="0" placeholder="optional" />
</label> </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 <label class="bm-label">Locator
<input type="text" id="bm-locator" class="status-input" maxlength="6" placeholder="e.g. JO93" /> <input type="text" id="bm-locator" class="status-input" maxlength="6" placeholder="e.g. JO93" />
</label> </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-background-decode">Background Decode</button>
<button class="sub-tab" data-subtab="settings-bandplan">Bandplan</button> <button class="sub-tab" data-subtab="settings-bandplan">Bandplan</button>
<button class="sub-tab" data-subtab="settings-history">History</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>
<div id="subtab-settings-scheduler" class="sub-tab-panel"> <div id="subtab-settings-scheduler" class="sub-tab-panel">
<div id="scheduler-panel" class="sch-panel"> <div id="scheduler-panel" class="sch-panel">
@@ -1750,6 +1768,34 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div> </div>
</div> </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>
<div id="tab-about" class="tab-panel" style="display:none;"> <div id="tab-about" class="tab-panel" style="display:none;">
<h2 class="section-heading">About</h2> <h2 class="section-heading">About</h2>
@@ -196,8 +196,7 @@ body {
font-size: var(--fs-base); font-size: var(--fs-base);
box-sizing: border-box; box-sizing: border-box;
} }
.auth-submit, .auth-submit {
.auth-guest {
width: 100%; width: 100%;
padding: 0.65rem 0.75rem; padding: 0.65rem 0.75rem;
border-radius: var(--radius-md); border-radius: var(--radius-md);
@@ -213,16 +212,14 @@ body {
font-weight: 700; font-weight: 700;
} }
.auth-submit:hover:not(:disabled) { background: var(--accent-green-hover); } .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-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); } .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; } .label { color: var(--text-muted); font-size: 0.9rem; margin-bottom: 6px; display: block; }
#tab-main .label > span { #tab-main .label > span {
@@ -12,9 +12,12 @@ use trx_core::rig::{
RigAccessMethod, RigCapabilities, RigInfo, RigRxStatus, RigStatus, RigTxStatus, RigVfo, RigAccessMethod, RigCapabilities, RigInfo, RigRxStatus, RigStatus, RigTxStatus, RigVfo,
RigVfoEntry, 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::rig::{RigListItem, RigListResponse};
use trx_frontend_http::server::api::FrontendMeta; use trx_frontend_http::server::api::FrontendMeta;
use trx_frontend_http::server::auth::AuthRole;
use trx_protocol::{DecoderActivation, DecoderDescriptor}; use trx_protocol::{DecoderActivation, DecoderDescriptor};
use ts_rs::{Config, TS}; use ts_rs::{Config, TS};
@@ -48,6 +51,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
export!(RigStatus); export!(RigStatus);
export!(DecoderConfig); export!(DecoderConfig);
export!(WfmDenoiseLevel); export!(WfmDenoiseLevel);
export!(DigSidebandPolicy);
export!(RigFilterState); export!(RigFilterState);
export!(RdsData); export!(RdsData);
export!(SpectrumData); export!(SpectrumData);
@@ -56,6 +60,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
export!(RigListItem); export!(RigListItem);
export!(RigListResponse); export!(RigListResponse);
export!(FrontendMeta); export!(FrontendMeta);
export!(AuthRole);
export!(DecoderActivation); export!(DecoderActivation);
export!(DecoderDescriptor); export!(DecoderDescriptor);
@@ -12,7 +12,7 @@
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json", "typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern", "lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
"test": "node --test tests/*.test.mjs", "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" "verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
}, },
"devDependencies": { "devDependencies": {
@@ -2,11 +2,51 @@
// //
// SPDX-License-Identifier: GPL-2.0-or-later // 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 { export interface AuthSession {
authenticated: boolean; authenticated: boolean;
role?: AuthRole; roles: AuthRole[];
username?: string;
auth_disabled?: boolean; auth_disabled?: boolean;
} }
@@ -18,21 +58,24 @@ function decodeAuthSession(value: unknown): AuthSession {
if (typeof session.authenticated !== "boolean") { if (typeof session.authenticated !== "boolean") {
throw new TypeError("The authentication response has no authenticated flag"); 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") { if (session.auth_disabled !== undefined && typeof session.auth_disabled !== "boolean") {
throw new TypeError("The authentication response has an invalid auth_disabled flag"); throw new TypeError("The authentication response has an invalid auth_disabled flag");
} }
const decoded: AuthSession = { authenticated: session.authenticated }; const decoded: AuthSession = {
if (session.role !== undefined) decoded.role = session.role; 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; if (session.auth_disabled !== undefined) decoded.auth_disabled = session.auth_disabled;
return decoded; return decoded;
} }
const authDisabledSession: AuthSession = { const authDisabledSession: AuthSession = {
authenticated: true, authenticated: true,
role: "control", roles: [...AUTH_ADMIN_ROLES],
auth_disabled: true, auth_disabled: true,
}; };
@@ -40,19 +83,19 @@ export async function fetchAuthSession(): Promise<AuthSession> {
try { try {
const response = await fetch("/auth/session"); const response = await fetch("/auth/session");
if (response.status === 404) return authDisabledSession; if (response.status === 404) return authDisabledSession;
if (!response.ok) return { authenticated: false }; if (!response.ok) return { authenticated: false, roles: [] };
return decodeAuthSession(await response.json()); return decodeAuthSession(await response.json());
} catch (error: unknown) { } catch (error: unknown) {
console.error("Auth check failed:", error); 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", { const response = await fetch("/auth/login", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ passphrase }), body: JSON.stringify({ username, password }),
}); });
if (response.status === 404) return authDisabledSession; if (response.status === 404) return authDisabledSession;
if (!response.ok) { if (!response.ok) {
@@ -62,6 +105,52 @@ export async function login(passphrase: string): Promise<AuthSession> {
return decodeAuthSession(await response.json()); 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> { export async function logout(): Promise<void> {
const response = await fetch("/auth/logout", { method: "POST" }); const response = await fetch("/auth/logout", { method: "POST" });
if (response.status !== 404 && !response.ok) throw new Error("Logout failed"); 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 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). * 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 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 DecoderActivation = "mode_bound" | "toggle";
export type DecoderDescriptor = { export type DecoderDescriptor = {
@@ -21,6 +21,17 @@ import {
fetchAuthSession, fetchAuthSession,
login, login,
logout, logout,
listUsers,
createUser,
updateUser,
deleteUser,
changeOwnPassword,
AUTH_ADMIN_ROLES,
AUTH_ROLES,
AUTH_ROLE_LABELS,
hasAccountControls,
hasAuthRole as rolesInclude,
normalizeAuthRoles,
} from "./api/auth.js"; } from "./api/auth.js";
import { import {
formatByteSize as recorderFormatSize, formatByteSize as recorderFormatSize,
@@ -189,8 +200,8 @@ interface TrxModules {
reverseGeocodeLocation(lat: number, lon: number, grid: string): void; reverseGeocodeLocation(lat: number, lon: number, grid: string): void;
bandForHz(frequencyHz: number): unknown; bandForHz(frequencyHz: number): unknown;
}; };
scheduler?: { 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, role: AuthRole | null): 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?: { bookmarks?: {
readonly overlayList: readonly Bookmark[]; readonly overlayList: readonly Bookmark[];
readonly overlayRevision: number; readonly overlayRevision: number;
@@ -226,7 +237,7 @@ interface TrxState {
readonly initialMapZoom: number; readonly initialMapZoom: number;
readonly decodeHistoryRetentionMin: number; readonly decodeHistoryRetentionMin: number;
readonly authEnabled: boolean; readonly authEnabled: boolean;
readonly authRole: AuthRole | null; readonly authRoles: readonly AuthRole[];
readonly decoderRegistry: typeof decoderRegistry; readonly decoderRegistry: typeof decoderRegistry;
readonly sseSessionId: string | null; readonly sseSessionId: string | null;
readonly primaryRds: RdsData | null; readonly primaryRds: RdsData | null;
@@ -406,40 +417,76 @@ declare global {
void loadDecoderRegistry(refreshOperatorLayoutCapabilities); void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
// --- Authentication --- // --- Authentication ---
let authRole: AuthRole | null = null; let authRoles: AuthRole[] = [];
let authUsername: string | null = null;
let authEnabled = true; 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() { async function checkAuthStatus() {
return fetchAuthSession(); return fetchAuthSession();
} }
async function authLogin(passphrase: string) { async function authLogin(username: string, password: string) {
return login(passphrase); return login(username, password);
} }
async function authLogout() { async function authLogout() {
try { try {
await logout(); await logout();
authRole = null; setAuthRoles([]);
authUsername = null;
// Disconnect and show auth gate without page reload // Disconnect and show auth gate without page reload
disconnect(); disconnect();
setDecodeHistoryOverlayVisible(false); setDecodeHistoryOverlayVisible(false);
requiredElement("content").style.display = "none"; requiredElement("content").style.display = "none";
requiredElement("loading").style.display = "none"; requiredElement("loading").style.display = "none";
requiredElement<HTMLInputElement>("auth-passphrase").value = ""; requiredElement<HTMLInputElement>("auth-password").value = "";
updateAuthUI(); updateAuthUI();
// Check if guest mode is available after logout showAuthGate();
const authStatus = await checkAuthStatus();
const allowGuest = authStatus.role === "rx";
showAuthGate(allowGuest);
} catch (e) { } catch (e) {
console.error("Logout failed:", e); console.error("Logout failed:", e);
showAuthError("Logout failed"); showAuthError("Logout failed");
} }
} }
function showAuthGate(allowGuest = false) { function showAuthGate() {
if (!authEnabled) return; if (!authEnabled) return;
setDecodeHistoryOverlayVisible(false); setDecodeHistoryOverlayVisible(false);
requiredElement("loading").style.display = "none"; requiredElement("loading").style.display = "none";
@@ -459,12 +506,6 @@ function showAuthGate(allowGuest = false) {
panel.style.display = "none"; 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) => { document.querySelectorAll<HTMLElement>(".tab-bar .tab").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.tab === "main"); btn.classList.toggle("active", btn.dataset.tab === "main");
}); });
@@ -506,22 +547,32 @@ function updateAuthUI() {
const badge = document.getElementById("auth-badge"); const badge = document.getElementById("auth-badge");
const badgeRole = document.getElementById("auth-role-badge"); const badgeRole = document.getElementById("auth-role-badge");
const headerAuthBtn = document.getElementById("header-auth-btn"); const headerAuthBtn = document.getElementById("header-auth-btn");
const accountTab = document.getElementById("settings-account-tab");
if (!authEnabled) { if (!authEnabled) {
if (badge) badge.style.display = "none"; if (badge) badge.style.display = "none";
if (headerAuthBtn) headerAuthBtn.style.display = "none"; if (headerAuthBtn) headerAuthBtn.style.display = "none";
if (accountTab) accountTab.style.display = "none";
syncTopBarAccess(); syncTopBarAccess();
return; 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 (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) { if (headerAuthBtn) {
headerAuthBtn.textContent = "Logout"; headerAuthBtn.textContent = "Logout";
headerAuthBtn.style.display = "block"; headerAuthBtn.style.display = "block";
} }
} else { } else {
if (accountTab) accountTab.style.display = "none";
if (badge) badge.style.display = "none"; if (badge) badge.style.display = "none";
if (headerAuthBtn) { if (headerAuthBtn) {
headerAuthBtn.textContent = "Login"; headerAuthBtn.textContent = "Login";
@@ -532,37 +583,40 @@ function updateAuthUI() {
} }
function applyAuthRestrictions() { function applyAuthRestrictions() {
if (!authRole) return; if (authRoles.length === 0) return;
// Disable TX/PTT/frequency/mode/VFO controls for rx role if (!hasAuthRole("transmit")) {
if (authRole === "rx") {
const pttBtn = document.getElementById("ptt-btn") as HTMLButtonElement | null; 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 powerBtn = document.getElementById("power-btn") as HTMLButtonElement | null;
const lockBtn = document.getElementById("lock-btn") as HTMLButtonElement | null; const lockBtn = document.getElementById("lock-btn") as HTMLButtonElement | null;
const freqInput = document.getElementById("freq") as HTMLInputElement | null; const freqInput = document.getElementById("freq") as HTMLInputElement | null;
const centerFreqInput = document.getElementById("center-freq") as HTMLInputElement | null; const centerFreqInput = document.getElementById("center-freq") as HTMLInputElement | null;
const modeSelect = document.getElementById("mode") as HTMLSelectElement | 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 jogUp = document.getElementById("jog-up") as HTMLButtonElement | null;
const jogDown = document.getElementById("jog-down") as HTMLButtonElement | null; const jogDown = document.getElementById("jog-down") as HTMLButtonElement | null;
const jogButtons = document.querySelectorAll<HTMLButtonElement>(".jog-step button"); const jogButtons = document.querySelectorAll<HTMLButtonElement>(".jog-step button");
const vfoButtons = document.querySelectorAll<HTMLButtonElement>("#vfo-picker button"); const vfoButtons = document.querySelectorAll<HTMLButtonElement>("#vfo-picker button");
// Disable TX buttons
if (pttBtn) pttBtn.disabled = true;
if (powerBtn) powerBtn.disabled = true; if (powerBtn) powerBtn.disabled = true;
if (lockBtn) lockBtn.disabled = true; if (lockBtn) lockBtn.disabled = true;
if (txAudioBtn) txAudioBtn.disabled = true;
if (txLimitBtn) txLimitBtn.disabled = true;
// Disable frequency/mode inputs // Disable frequency/mode inputs
if (freqInput) freqInput.disabled = true; if (freqInput) freqInput.disabled = true;
if (centerFreqInput) centerFreqInput.disabled = true; if (centerFreqInput) centerFreqInput.disabled = true;
if (modeSelect) modeSelect.disabled = true; if (modeSelect) modeSelect.disabled = true;
if (txLimitInput) txLimitInput.disabled = true;
// Disable VFO selector // Disable VFO selector
vfoButtons.forEach(btn => btn.disabled = true); vfoButtons.forEach(btn => btn.disabled = true);
@@ -606,9 +660,6 @@ function applyAuthRestrictions() {
btn.disabled = true; 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() { function syncTopBarAccess() {
const loggedOut = authEnabled && !authRole; const loggedOut = authEnabled && authRoles.length === 0;
const tabBar = document.getElementById("tab-bar"); const tabBar = document.getElementById("tab-bar");
const rigSwitch = document.querySelector<HTMLElement>(".header-rig-switch"); const rigSwitch = document.querySelector<HTMLElement>(".header-rig-switch");
if (tabBar) tabBar.style.display = ""; if (tabBar) tabBar.style.display = "";
document.querySelectorAll<HTMLButtonElement>(".tab-bar .tab").forEach((btn) => { document.querySelectorAll<HTMLButtonElement>(".tab-bar .tab").forEach((btn) => {
const isMain = btn.dataset.tab === "main"; 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; btn.disabled = false;
}); });
@@ -883,7 +935,7 @@ function syncTopBarAccess() {
} }
if (headerRigSwitchSelect) { 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 nextKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || "");
const rigListChanged = prevKey !== nextKey; const rigListChanged = prevKey !== nextKey;
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx"; const disableSwitch = lastRigIds.length === 0 || !hasAuthRole("control");
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch); populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
updateRigSubtitle(lastActiveRigId); updateRigSubtitle(lastActiveRigId);
window.trxUi?.setActiveRig(lastActiveRigId); window.trxUi?.setActiveRig(lastActiveRigId);
@@ -3029,9 +3081,16 @@ function formatSignal(sUnits: number) {
} }
function setDisabled(disabled: boolean) { function setDisabled(disabled: boolean) {
[freqEl, centerFreqEl, modeEl, pttBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => { const controlDisabled = disabled || (authEnabled && !hasAuthRole("control"));
if (el) el.disabled = disabled; 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(); syncModePicker();
} }
@@ -3352,7 +3411,7 @@ function scheduleTuneLinkSync() {
async function applyTuneLink(link: TuneLink) { async function applyTuneLink(link: TuneLink) {
const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null; const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null;
if (!wanted) return; if (!wanted) return;
if (authRole === "rx") { if (!hasAuthRole("control")) {
showHint("Read-only session — link not applied", 2500); showHint("Read-only session — link not applied", 2500);
return; 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 (typeof update.show_sdr_gain_control === "boolean") {
if (sdrSettingsRowEl) sdrSettingsRowEl.style.display = update.show_sdr_gain_control ? "" : "none"; 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" }); const resp = await fetch(path, { method: "POST" });
if (authEnabled && resp.status === 401) { if (authEnabled && resp.status === 401) {
// Not authenticated - return to login // Not authenticated - return to login
authRole = null; setAuthRoles([]);
if (es) es.close(); if (es) es.close();
showAuthGate(); showAuthGate();
throw new Error("Authentication required"); throw new Error("Authentication required");
@@ -4191,7 +4257,7 @@ async function switchRigFromSelect(selectEl: HTMLSelectElement) {
showHint("No rig selected", 1500); showHint("No rig selected", 1500);
return; return;
} }
if (authRole === "rx") { if (!hasAuthRole("control")) {
showHint("Control role required", 1500); showHint("Control role required", 1500);
return; return;
} }
@@ -4874,8 +4940,13 @@ function navigateToTab(name: TabName, options: { updateHistory?: boolean; replac
window.trxUi?.closeMobileOverlays?.(); window.trxUi?.closeMobileOverlays?.();
const leavingSatellites = _activeTab === "satellites" && name !== "satellites"; const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
const { updateHistory = true, replaceHistory = false } = options; const { updateHistory = true, replaceHistory = false } = options;
if (authEnabled && !authRole && name !== "main") { if (authEnabled && authRoles.length === 0 && name !== "main") {
showAuthGate(false); showAuthGate();
return;
}
if (authEnabled && name === "logbook" && !hasAuthRole("write")) {
showHint("Write role required for logbook access", 2500);
navigateToTab("main", options);
return; return;
} }
const btn = document.querySelector<HTMLElement>(`.tab-bar .tab[data-tab="${name}"]`); const btn = document.querySelector<HTMLElement>(`.tab-bar .tab[data-tab="${name}"]`);
@@ -5008,12 +5079,12 @@ window.addEventListener("resize", () => { scheduleSpectrumLayout(); });
// --- Auth startup sequence --- // --- Auth startup sequence ---
async function initializeApp() { async function initializeApp() {
showAuthGate(false); showAuthGate();
const authStatus = await checkAuthStatus(); const authStatus = await checkAuthStatus();
authEnabled = !authStatus.auth_disabled; authEnabled = !authStatus.auth_disabled;
if (!authEnabled) { if (!authEnabled) {
authRole = "control"; setAuthRoles(AUTH_ADMIN_ROLES);
hideAuthGate(); hideAuthGate();
updateAuthUI(); updateAuthUI();
connect(); connect();
@@ -5026,7 +5097,8 @@ async function initializeApp() {
if (authStatus.authenticated) { if (authStatus.authenticated) {
// User has valid session // User has valid session
authRole = authStatus.role ?? null; setAuthRoles(authStatus.roles);
authUsername = authStatus.username ?? null;
hideAuthGate(); hideAuthGate();
updateAuthUI(); updateAuthUI();
applyAuthRestrictions(); applyAuthRestrictions();
@@ -5036,10 +5108,7 @@ async function initializeApp() {
resizeHeaderSignalCanvas(); resizeHeaderSignalCanvas();
startHeaderSignalSampling(); startHeaderSignalSampling();
} else { } else {
// No valid session - show auth gate showAuthGate();
// Guest button is shown if guest mode is available (role granted without auth)
const allowGuest = authStatus.role === "rx";
showAuthGate(allowGuest);
} }
} }
@@ -5049,28 +5118,186 @@ let settingsUiReady = false;
function initSettingsUI() { function initSettingsUI() {
settingsUiReady = true; settingsUiReady = true;
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRole); window.trx.modules.scheduler?.initialize(lastActiveRigId, authRoles);
window.trx.modules.scheduler?.wireEvents(); window.trx.modules.scheduler?.wireEvents();
if (window.trx.modules.backgroundDecode) { if (window.trx.modules.backgroundDecode) {
window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRole); window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRoles);
window.trx.modules.backgroundDecode.wireEvents(); 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 // Setup auth form
requiredElement<HTMLFormElement>("auth-form").addEventListener("submit", async (e) => { requiredElement<HTMLFormElement>("auth-form").addEventListener("submit", async (e) => {
e.preventDefault(); e.preventDefault();
const passphraseEl = requiredElement<HTMLInputElement>("auth-passphrase"); const usernameEl = requiredElement<HTMLInputElement>("auth-username");
const passphrase = passphraseEl.value; const passwordEl = requiredElement<HTMLInputElement>("auth-password");
const btn = requiredElement<HTMLFormElement>("auth-form").querySelector<HTMLButtonElement>("button[type=submit]"); const btn = requiredElement<HTMLFormElement>("auth-form").querySelector<HTMLButtonElement>("button[type=submit]");
if (!btn) return; if (!btn) return;
btn.disabled = true; btn.disabled = true;
btn.textContent = "Logging in..."; btn.textContent = "Logging in...";
try { try {
const result = await authLogin(passphrase); const result = await authLogin(usernameEl.value, passwordEl.value);
authRole = result.role ?? null; setAuthRoles(result.roles);
passphraseEl.value = ""; authUsername = result.username ?? usernameEl.value;
passwordEl.value = "";
hideAuthGate(); hideAuthGate();
updateAuthUI(); updateAuthUI();
applyAuthRestrictions(); applyAuthRestrictions();
@@ -5080,7 +5307,7 @@ requiredElement<HTMLFormElement>("auth-form").addEventListener("submit", async (
resizeHeaderSignalCanvas(); resizeHeaderSignalCanvas();
startHeaderSignalSampling(); startHeaderSignalSampling();
} catch (err) { } catch (err) {
showAuthError("Invalid passphrase"); showAuthError("Invalid username or password");
console.error("Login error:", err); console.error("Login error:", err);
} finally { } finally {
btn.disabled = false; 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) // Setup header auth button (Login/Logout)
const headerAuthBtn = document.getElementById("header-auth-btn") as HTMLButtonElement | null; const headerAuthBtn = document.getElementById("header-auth-btn") as HTMLButtonElement | null;
if (headerAuthBtn) { if (headerAuthBtn) {
headerAuthBtn.addEventListener("click", async () => { headerAuthBtn.addEventListener("click", async () => {
if (authRole) { if (authRoles.length > 0) {
// Logged in - show logout confirmation // 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 })) { 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(); await authLogout();
} }
} else { } else {
// Not logged in - show auth gate // Not logged in - show auth gate
showAuthGate(false); showAuthGate();
} }
}); });
} }
@@ -5138,7 +5348,7 @@ Object.defineProperties(trxState, {
initialMapZoom: { get() { return initialMapZoom; } }, initialMapZoom: { get() { return initialMapZoom; } },
decodeHistoryRetentionMin: { get() { return decodeHistoryRetentionMin; } }, decodeHistoryRetentionMin: { get() { return decodeHistoryRetentionMin; } },
authEnabled: { get() { return authEnabled; } }, authEnabled: { get() { return authEnabled; } },
authRole: { get() { return authRole; } }, authRoles: { get() { return authRoles; } },
decoderRegistry: { get() { return decoderRegistry; } }, decoderRegistry: { get() { return decoderRegistry; } },
sseSessionId: { get() { return sseSessionId; } }, sseSessionId: { get() { return sseSessionId; } },
primaryRds: { get() { return primaryRds; } }, 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 sdrNbThresholdEl = document.getElementById("sdr-nb-threshold") as HTMLInputElement | null;
const sdrNbThresholdSetBtn = document.getElementById("sdr-nb-threshold-set") as HTMLButtonElement | null; const sdrNbThresholdSetBtn = document.getElementById("sdr-nb-threshold-set") as HTMLButtonElement | null;
let sdrNbSupported = false; 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 // Hide audio row if audio is not configured on the server
fetch("/audio", { method: "GET" }).then((r) => { 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() { function updateWfmControls() {
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase(); const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
if (wfmControlsCol) wfmControlsCol.style.display = mode === "WFM" ? "" : "none"; 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 // 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. // still take a track and a gap in the tray, and draw its divider.
if (modeControlsRow) modeControlsRow.style.display = (mode === "WFM" || mode === "SAM") ? "" : "none"; 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 // Show compatibility warning for non-Chromium browsers
@@ -6183,6 +6412,10 @@ function stopRxAudio() {
function startTxAudio() { function startTxAudio() {
if (txActive) { void stopTxAudio(); return; } if (txActive) { void stopTxAudio(); return; }
if (authEnabled && !hasAuthRole("transmit")) {
audioStatus.textContent = "Transmit role required";
return;
}
if (!hasWebCodecs) { if (!hasWebCodecs) {
audioStatus.textContent = "Audio requires Chrome/Edge"; audioStatus.textContent = "Audio requires Chrome/Edge";
return; return;
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
import { hostState } from "./host.js"; import { hostState } from "./host.js";
import { hasAuthRole, type AuthRole } from "../api/auth.js";
export {}; export {};
@@ -44,7 +45,7 @@ interface BackgroundBridge {
trx?: { modules?: { backgroundDecode?: BackgroundDecodeService } }; trx?: { modules?: { backgroundDecode?: BackgroundDecodeService } };
} }
interface BackgroundDecodeService { interface BackgroundDecodeService {
initialize(rigId: string | null, role: string | null): void; initialize(rigId: string | null, roles: readonly AuthRole[]): void;
wireEvents(): void; wireEvents(): void;
setRig(rigId: string | null): void; setRig(rigId: string | null): void;
} }
@@ -60,7 +61,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
.map(function (d) { return d.id; }); .map(function (d) { return d.id; });
} }
let backgroundDecodeRole: string | null = null; let backgroundDecodeRoles: readonly AuthRole[] = [];
let currentRigId: string | null = null; let currentRigId: string | null = null;
let currentConfig: BackgroundDecodeConfig | null = null; let currentConfig: BackgroundDecodeConfig | null = null;
let bookmarkList: Bookmark[] = []; let bookmarkList: Bookmark[] = [];
@@ -70,8 +71,8 @@ const bgdWindow = window as unknown as BackgroundBridge;
let statusByBookmark = new Map<string, BackgroundStatusEntry>(); let statusByBookmark = new Map<string, BackgroundStatusEntry>();
let lastStatus: BackgroundDecodeStatus | null = null; let lastStatus: BackgroundDecodeStatus | null = null;
function initBackgroundDecode(rigId: string | null, role: string | null): void { function initBackgroundDecode(rigId: string | null, roles: readonly AuthRole[]): void {
backgroundDecodeRole = role; backgroundDecodeRoles = roles;
// The panel used to take whatever rig it was handed at load and wait to be // 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 // 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 // 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 { function isControlRole(): boolean {
return backgroundDecodeRole === "control" || hostState.authEnabled === false; return hasAuthRole(backgroundDecodeRoles, "control") || hostState.authEnabled === false;
} }
function showToast(msg: string, isError: boolean): void { function showToast(msg: string, isError: boolean): void {
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
import { hostCore, hostState } from "./host.js"; import { hostCore, hostState } from "./host.js";
import { hasAuthRole } from "../api/auth.js";
export {}; export {};
@@ -18,6 +19,8 @@ interface Bookmark {
category?: string | null; category?: string | null;
comment?: string | null; comment?: string | null;
decoders?: string[]; decoders?: string[];
/** DIG sideband override: "auto" | "usb" | "lsb". Empty/absent = global. */
dig_sideband?: string | null;
scope?: string; scope?: string;
} }
@@ -100,7 +103,8 @@ function bmEsc(str: unknown): string {
} }
function bmCanControl() { 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. // 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 { function bmOpenForm(bm: Bookmark | null): void {
const wrap = bmEl("bm-form-wrap"); const wrap = bmEl("bm-form-wrap");
if (!wrap) return; if (!wrap) return;
@@ -336,7 +348,9 @@ function bmOpenForm(bm: Bookmark | null): void {
bmEl("bm-locator").value = bm ? (bm.locator || "") : ""; bmEl("bm-locator").value = bm ? (bm.locator || "") : "";
bmEl("bm-category-input").value = bm ? (bm.category || "") : ""; bmEl("bm-category-input").value = bm ? (bm.category || "") : "";
bmEl("bm-comment").value = bm ? (bm.comment || "") : ""; bmEl("bm-comment").value = bm ? (bm.comment || "") : "";
bmEl("bm-dig-sideband").value = bm ? (bm.dig_sideband || "") : "";
bmWriteDecoders(bm?.decoders ?? []); bmWriteDecoders(bm?.decoders ?? []);
bmSyncDigSidebandVisibility();
bmEl("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark"; bmEl("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark";
wrap.style.display = "flex"; wrap.style.display = "flex";
@@ -381,6 +395,9 @@ async function bmSave(e: Event): Promise<void> {
const category = bmEl("bm-category-input").value.trim(); const category = bmEl("bm-category-input").value.trim();
const comment = bmEl("bm-comment").value.trim(); const comment = bmEl("bm-comment").value.trim();
const decoders = bmReadDecoders(); 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"); const formError = bmEl("bm-form-error");
if (formError) formError.textContent = ""; if (formError) formError.textContent = "";
@@ -401,6 +418,7 @@ async function bmSave(e: Event): Promise<void> {
category, category,
comment, comment,
decoders, decoders,
dig_sideband,
}; };
try { try {
@@ -475,6 +493,16 @@ function bmApply(bm: Bookmark): void {
const tunePromise = (async () => { const tunePromise = (async () => {
await bridge.trx.modules.vchan?.takeSchedulerControl(); 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; const onVirtual = await bridge.trx.modules.vchan?.interceptMode(bm.mode) ?? false;
if (!onVirtual) { if (!onVirtual) {
await hostCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode)); await hostCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
@@ -768,6 +796,9 @@ function bmPopulateScopePicker() {
// Form submit // Form submit
bmEl("bm-form").addEventListener("submit", (event) => { void bmSave(event); }); 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 // Form cancel
bmEl("bm-form-cancel").addEventListener("click", bmCloseForm); bmEl("bm-form-cancel").addEventListener("click", bmCloseForm);
@@ -11,6 +11,8 @@
// feature bundles from re-deriving it — and from drifting back to bare `window` // feature bundles from re-deriving it — and from drifting back to bare `window`
// properties, which the module graph no longer publishes. // properties, which the module graph no longer publishes.
import type { AuthRole } from "../api/auth.js";
export interface HostDecoderDescriptor { export interface HostDecoderDescriptor {
id: string; id: string;
label: string; label: string;
@@ -25,7 +27,7 @@ export interface HostState {
/** The callsign this station is on the air with, from the client config. */ /** The callsign this station is on the air with, from the client config. */
readonly ownerCallsign: string | null; readonly ownerCallsign: string | null;
readonly authEnabled: boolean; readonly authEnabled: boolean;
readonly authRole: string | null; readonly authRoles: readonly AuthRole[];
readonly lastActiveRigId: string | null; readonly lastActiveRigId: string | null;
readonly lastRigIds: string[]; readonly lastRigIds: string[];
readonly lastRigDisplayNames: Record<string, string>; readonly lastRigDisplayNames: Record<string, string>;
@@ -10,6 +10,7 @@
// keeping if the times in it are the radio's. // keeping if the times in it are the radio's.
import { hostCore, hostState } from "./host.js"; import { hostCore, hostState } from "./host.js";
import { hasAuthRole } from "../api/auth.js";
export {}; export {};
@@ -120,6 +121,11 @@ let entryGrid: string | null = null;
let qsos: Qso[] = []; let qsos: Qso[] = [];
let workedRequest = 0; let workedRequest = 0;
function canWriteLogbook(): boolean {
return !hostState.authEnabled
|| hasAuthRole(hostState.authRoles, "write");
}
function notify(message: string, kind?: string): void { function notify(message: string, kind?: string): void {
if (bridge.trxUi.notify) bridge.trxUi.notify(message, kind ? { kind } : undefined); if (bridge.trxUi.notify) bridge.trxUi.notify(message, kind ? { kind } : undefined);
else hostCore.showHint(message, 2000); else hostCore.showHint(message, 2000);
@@ -419,6 +425,11 @@ function renderRows(): void {
row.appendChild(cell); row.appendChild(cell);
} }
const actions = document.createElement("td"); 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 // 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. // than a form: a card arrives, and the contact counts towards an award.
const confirm = document.createElement("button"); 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. */ /** Start an entry from a decode, and show the operator where it went. */
bridge.logContact = (seed) => { if (canWriteLogbook()) {
bridge.logContact = (seed) => {
bridge.navigateToTab?.("logbook"); bridge.navigateToTab?.("logbook");
void openEntry(seed).then(() => callInput?.focus()); void openEntry(seed).then(() => callInput?.focus());
}; };
} else {
if (form) form.style.display = "none";
if (importBtn) importBtn.style.display = "none";
}
renderStation(); renderStation();
if (cabrilloCallsign && !cabrilloCallsign.value) { if (cabrilloCallsign && !cabrilloCallsign.value) {
cabrilloCallsign.value = stationCallEl?.textContent?.trim() ?? ""; cabrilloCallsign.value = stationCallEl?.textContent?.trim() ?? "";
} }
syncCabrilloLink(); syncCabrilloLink();
void openEntry(); if (canWriteLogbook()) void openEntry();
void refreshLog(); void refreshLog();
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
import type { SatelliteScheduleConfig, SatelliteSchedulerApi } from "./satellite-types.js"; import type { SatelliteScheduleConfig, SatelliteSchedulerApi } from "./satellite-types.js";
import type { AuthRole } from "../api/auth.js";
export type SchedulerMode = "disabled" | "grayline" | "time_span"; export type SchedulerMode = "disabled" | "grayline" | "time_span";
@@ -60,7 +61,7 @@ export interface SchedulerStatus {
} }
export interface SchedulerService { export interface SchedulerService {
initialize(rigId: string | null, role: string | null): void; initialize(rigId: string | null, roles: readonly AuthRole[]): void;
destroy(): void; destroy(): void;
setRig(rigId: string | null): void; setRig(rigId: string | null): void;
wireEvents(): void; wireEvents(): void;
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
import { hostState } from "./host.js"; import { hostState } from "./host.js";
import { hasAuthRole, type AuthRole } from "../api/auth.js";
import type { import type {
ScheduleEntry, ScheduleEntry,
@@ -43,7 +44,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// State // State
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
let schedulerRole: string | null = null; let schedulerRoles: readonly AuthRole[] = [];
let currentRigId: string | null = null; let currentRigId: string | null = null;
let currentConfig: SchedulerConfig | null = null; let currentConfig: SchedulerConfig | null = null;
let currentSchedulerStatus: SchedulerStatus | null = null; let currentSchedulerStatus: SchedulerStatus | null = null;
@@ -58,8 +59,8 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Init // Init
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
function initScheduler(rigId: string | null, role: string | null): void { function initScheduler(rigId: string | null, roles: readonly AuthRole[]): void {
schedulerRole = role; schedulerRoles = roles;
currentRigId = rigId || null; currentRigId = rigId || null;
if (currentRigId) loadScheduler(); if (currentRigId) loadScheduler();
startStatusPolling(); startStatusPolling();
@@ -356,7 +357,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
if (!prevBtn || !nextBtn) return; if (!prevBtn || !nextBtn) return;
const state = schedulerInterleaveState(currentConfig); const state = schedulerInterleaveState(currentConfig);
const enabled = const enabled =
schedulerRole === "control" && hasAuthRole(schedulerRoles, "control") &&
!!currentRigId && !!currentRigId &&
!schedulerStepPending && !schedulerStepPending &&
state.activeEntries.length > 1; state.activeEntries.length > 1;
@@ -466,7 +467,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
if (!panel) return; if (!panel) return;
const mode = (currentConfig && currentConfig.mode) || "disabled"; const mode = (currentConfig && currentConfig.mode) || "disabled";
const isControl = schedulerRole === "control"; const isControl = hasAuthRole(schedulerRoles, "control");
// Mode selector // Mode selector
setSelected("scheduler-mode-select", mode); 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 eagerly, initSettingsUI() in app.js calls initScheduler();
// when loaded lazily (e.g. settings tab click after boot), the app has // when loaded lazily (e.g. settings tab click after boot), the app has
// already passed that point, so we must self-initialize here. // already passed that point, so we must self-initialize here.
if (hostState.authRole != null) { if (!hostState.authEnabled || hostState.authRoles.length > 0) {
initScheduler(hostState.lastActiveRigId, hostState.authRole); initScheduler(hostState.lastActiveRigId, hostState.authRoles);
wireSchedulerEvents(); 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)); const source = await bundleEntry(new URL("../src/plugins/background-decode.ts", import.meta.url));
new vm.Script(source).runInContext(context); 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)); await new Promise((resolve) => setTimeout(resolve, 0));
assert.ok(requested.includes("/background-decode/rig%2Fa")); assert.ok(requested.includes("/background-decode/rig%2Fa"));
assert.ok(requested.includes("/bookmarks")); assert.ok(requested.includes("/bookmarks"));
@@ -32,7 +32,7 @@ function hostFixture(overrides = {}) {
const calls = { postPath: [], setRigFrequency: [], armOptimisticFrequency: [], applyLocalTunedFrequency: [], syncBandwidthInput: [], scheduleSpectrumDraw: 0, syncModePicker: 0 }; const calls = { postPath: [], setRigFrequency: [], armOptimisticFrequency: [], applyLocalTunedFrequency: [], syncBandwidthInput: [], scheduleSpectrumDraw: 0, syncModePicker: 0 };
const state = { const state = {
authEnabled: false, authEnabled: false,
authRole: "control", authRoles: ["read", "control", "transmit", "write", "administrator"],
lastActiveRigId: null, lastActiveRigId: null,
lastRigIds: [], lastRigIds: [],
lastRigDisplayNames: {}, lastRigDisplayNames: {},
@@ -157,7 +157,7 @@ test("bookmark controls follow the host authentication state", async () => {
if (!elements.has(id)) elements.set(id, new ElementFixture()); if (!elements.has(id)) elements.set(id, new ElementFixture());
return elements.get(id); return elements.get(id);
}; };
const { window } = hostFixture({ authEnabled: true, authRole: "rx" }); const { window } = hostFixture({ authEnabled: true, authRoles: ["read"] });
const context = vm.createContext({ const context = vm.createContext({
window, window,
document: documentFixture(element), 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"); 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(""); await window.trx.modules.bookmarks.fetch("");
assert.equal(element("bm-add-btn").style.display, ""); assert.equal(element("bm-add-btn").style.display, "");
}); });
@@ -19,7 +19,7 @@ export function createHost({ state = {}, core = {}, modules = {} } = {}) {
serverLat: null, serverLat: null,
serverLon: null, serverLon: null,
authEnabled: false, authEnabled: false,
authRole: "control", authRoles: ["read", "control", "transmit", "write", "administrator"],
lastActiveRigId: null, lastActiveRigId: null,
lastRigIds: [], lastRigIds: [],
lastRigDisplayNames: {}, lastRigDisplayNames: {},
@@ -11,7 +11,7 @@ import { bundleEntry } from "./bundle-entry.mjs";
test("scheduler registers a typed module service without lifecycle globals", async () => { test("scheduler registers a typed module service without lifecycle globals", async () => {
// No role known yet: the entry registers its service and waits for the // No role known yet: the entry registers its service and waits for the
// application to drive initialization. // 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({ const context = vm.createContext({
window, window,
document: { document: {
@@ -76,7 +76,7 @@ test("scheduler self-initializes for the active rig when a role is already known
return elements.get(id); return elements.get(id);
}; };
const window = { const window = {
...createHost({ state: { authRole: "control", lastActiveRigId: "sdr" } }), ...createHost({ state: { authRoles: ["administrator"], lastActiveRigId: "sdr" } }),
trxUi: { confirm: async () => true }, trxUi: { confirm: async () => true },
}; };
const context = vm.createContext({ 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 indexPath = new URL("../../assets/web/index.html", import.meta.url);
const pluginLoaderPath = new URL("../src/plugin-loader.ts", 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 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 () => { test("index loads one first-party application bootstrap", async () => {
const html = await readFile(indexPath, "utf8"); 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 () => { test("lazy frontend features use modules and local map symbols", async () => {
const [loader, map] = await Promise.all([ const [loader, map] = await Promise.all([
readFile(pluginLoaderPath, "utf8"), 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, bandplanEnabled = false,
bandplanUnauthorizedFirst = false, bandplanUnauthorizedFirst = false,
satPasses = null, satPasses = null,
authSession = { authenticated: true, roles: ["read", "control", "transmit", "write", "administrator"], auth_disabled: true },
users = [],
} = {}) { } = {}) {
const rigItems = ["rig-a", "rig-b"].map((remote) => ({ const rigItems = ["rig-a", "rig-b"].map((remote) => ({
remote, remote,
@@ -232,7 +234,8 @@ export async function startWebFixture({
}; };
const jsonRoutes = new Map([ const jsonRoutes = new Map([
["/auth/session", { authenticated: true, role: "control", auth_disabled: true }], ["/auth/session", authSession],
["/auth/users", users],
["/decoders", DECODER_REGISTRY], ["/decoders", DECODER_REGISTRY],
["/rigs", rigsResponse], ["/rigs", rigsResponse],
["/status", status], ["/status", status],
@@ -9,7 +9,7 @@ use std::sync::Arc;
use actix_web::Error; use actix_web::Error;
use actix_web::{delete, get, post, put, web, HttpRequest, HttpResponse}; 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; use crate::server::status;
// ============================================================================ // ============================================================================
@@ -37,6 +37,7 @@ pub struct BookmarkInput {
pub comment: Option<String>, pub comment: Option<String>,
pub category: Option<String>, pub category: Option<String>,
pub decoders: Option<Vec<String>>, pub decoders: Option<Vec<String>>,
pub dig_sideband: Option<String>,
} }
/// A bookmark with its owning scope tag for the list response. /// 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 // Endpoints
// ============================================================================ // ============================================================================
@@ -165,7 +177,7 @@ pub async fn create_bookmark(
body: web::Json<BookmarkInput>, body: web::Json<BookmarkInput>,
auth_state: web::Data<crate::server::auth::AuthState>, auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> { ) -> 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 store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
if store.freq_taken(body.freq_hz, None) { if store.freq_taken(body.freq_hz, None) {
return Err(actix_web::error::ErrorConflict( return Err(actix_web::error::ErrorConflict(
@@ -182,6 +194,7 @@ pub async fn create_bookmark(
comment: body.comment.clone().unwrap_or_default(), comment: body.comment.clone().unwrap_or_default(),
category: body.category.clone().unwrap_or_default(), category: body.category.clone().unwrap_or_default(),
decoders: body.decoders.clone().unwrap_or_default(), decoders: body.decoders.clone().unwrap_or_default(),
dig_sideband: normalize_dig_sideband(body.dig_sideband.clone()),
}; };
if store.insert(&bm) { if store.insert(&bm) {
Ok(HttpResponse::Created().json(bm)) Ok(HttpResponse::Created().json(bm))
@@ -201,7 +214,7 @@ pub async fn update_bookmark(
body: web::Json<BookmarkInput>, body: web::Json<BookmarkInput>,
auth_state: web::Data<crate::server::auth::AuthState>, auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> { ) -> 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 store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
let id = path.into_inner(); let id = path.into_inner();
if store.freq_taken(body.freq_hz, Some(&id)) { 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(), comment: body.comment.clone().unwrap_or_default(),
category: body.category.clone().unwrap_or_default(), category: body.category.clone().unwrap_or_default(),
decoders: body.decoders.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) { if store.upsert(&id, &bm) {
Ok(HttpResponse::Ok().json(bm)) Ok(HttpResponse::Ok().json(bm))
@@ -235,7 +249,7 @@ pub async fn delete_bookmark(
query: web::Query<BookmarkScopeQuery>, query: web::Query<BookmarkScopeQuery>,
auth_state: web::Data<crate::server::auth::AuthState>, auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> { ) -> 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 store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
let id = path.into_inner(); let id = path.into_inner();
if store.remove(&id) { if store.remove(&id) {
@@ -253,7 +267,7 @@ pub async fn batch_delete_bookmarks(
query: web::Query<BookmarkScopeQuery>, query: web::Query<BookmarkScopeQuery>,
auth_state: web::Data<crate::server::auth::AuthState>, auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> { ) -> 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 store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
let mut deleted = 0usize; let mut deleted = 0usize;
for id in &body.ids { for id in &body.ids {
@@ -272,7 +286,7 @@ pub async fn batch_move_bookmarks(
query: web::Query<BookmarkScopeQuery>, query: web::Query<BookmarkScopeQuery>,
auth_state: web::Data<crate::server::auth::AuthState>, auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> { ) -> 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 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 to_store = resolve_bookmark_store(Some(body.to.as_str()), store_map.get_ref());
let mut moved = 0usize; 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::qso::{adif_mode_for_rig_mode, band_for_hz, mode_for_decoder};
use trx_logbook::{LogQuery, Logbook, Qso}; 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; use crate::server::auth::AuthState;
/// What a contact looks like on the wire. /// What a contact looks like on the wire.
@@ -225,7 +225,7 @@ pub async fn add_qso(
logbook: web::Data<Arc<Logbook>>, logbook: web::Data<Arc<Logbook>>,
auth_state: web::Data<AuthState>, auth_state: web::Data<AuthState>,
) -> Result<HttpResponse, actix_web::Error> { ) -> 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) { let qso = match input.into_inner().into_qso(None) {
Ok(qso) => qso, Ok(qso) => qso,
Err(reason) => { Err(reason) => {
@@ -248,7 +248,7 @@ pub async fn edit_qso(
logbook: web::Data<Arc<Logbook>>, logbook: web::Data<Arc<Logbook>>,
auth_state: web::Data<AuthState>, auth_state: web::Data<AuthState>,
) -> Result<HttpResponse, actix_web::Error> { ) -> 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 id = path.into_inner();
let Some(existing) = book(&logbook).get(&id) else { let Some(existing) = book(&logbook).get(&id) else {
return Ok(HttpResponse::NotFound().json(serde_json::json!({ "error": "no such contact" }))); 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>>, logbook: web::Data<Arc<Logbook>>,
auth_state: web::Data<AuthState>, auth_state: web::Data<AuthState>,
) -> Result<HttpResponse, actix_web::Error> { ) -> 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()) { match book(&logbook).delete(&path.into_inner()) {
Ok(true) => Ok(HttpResponse::Ok().json(serde_json::json!({ "deleted": true }))), Ok(true) => Ok(HttpResponse::Ok().json(serde_json::json!({ "deleted": true }))),
Ok(false) => { Ok(false) => {
@@ -312,7 +312,7 @@ pub async fn import_adi(
logbook: web::Data<Arc<Logbook>>, logbook: web::Data<Arc<Logbook>>,
auth_state: web::Data<AuthState>, auth_state: web::Data<AuthState>,
) -> Result<HttpResponse, actix_web::Error> { ) -> 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) { match book(&logbook).import_adi(&body) {
Ok(outcome) => Ok(HttpResponse::Ok().json(outcome)), Ok(outcome) => Ok(HttpResponse::Ok().json(outcome)),
Err(err) => Ok(HttpResponse::InternalServerError() Err(err) => Ok(HttpResponse::InternalServerError()
@@ -391,16 +391,20 @@ fn gz_cache_entry(src: &[u8], name: &str) -> GzCacheEntry {
GzCacheEntry { gz, br, etag } GzCacheEntry { gz, br, etag }
} }
fn require_control( fn require_write(
req: &HttpRequest, req: &HttpRequest,
auth_state: &crate::server::auth::AuthState, auth_state: &crate::server::auth::AuthState,
) -> Result<(), actix_web::Error> { ) -> Result<(), actix_web::Error> {
if !auth_state.config.enabled { if !auth_state.config.enabled
return Ok(()); || crate::server::auth::session_grants(
} req,
match crate::server::auth::get_session_role(req, auth_state) { auth_state,
Some(crate::server::auth::AuthRole::Control) => Ok(()), crate::server::auth::AuthRole::Write,
_ => Err(actix_web::error::ErrorForbidden("control role required")), )
{
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_agc)
.service(rig::set_sdr_squelch) .service(rig::set_sdr_squelch)
.service(rig::set_sdr_noise_blanker) .service(rig::set_sdr_noise_blanker)
.service(rig::set_sdr_dig_sideband)
.service(rig::set_wfm_deemphasis) .service(rig::set_wfm_deemphasis)
.service(rig::set_wfm_stereo) .service(rig::set_wfm_stereo)
.service(rig::set_wfm_denoise) .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::login)
.service(crate::server::auth::logout) .service(crate::server::auth::logout)
.service(crate::server::auth::session_status) .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 // Logbook
.service(logbook::list_qsos) .service(logbook::list_qsos)
.service(logbook::add_qso) .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 { fn auth_state_disabled() -> crate::server::auth::AuthState {
crate::server::auth::AuthState::new(crate::server::auth::AuthConfig::new( crate::server::auth::AuthState::new(crate::server::auth::AuthConfig::new(
false, false,
std::path::PathBuf::from("unused-users.json"),
None, None,
None, None,
false,
std::time::Duration::from_secs(3600), std::time::Duration::from_secs(3600),
false, false,
crate::server::auth::SameSite::Lax, crate::server::auth::SameSite::Lax,
)) ))
.unwrap()
} }
/// Auth on with no session presented, which is what a listener is. /// Auth on with no session presented, which is what a listener is.
fn auth_state_locked() -> crate::server::auth::AuthState { fn auth_state_locked() -> crate::server::auth::AuthState {
let directory = tempfile::tempdir().unwrap();
crate::server::auth::AuthState::new(crate::server::auth::AuthConfig::new( crate::server::auth::AuthState::new(crate::server::auth::AuthConfig::new(
true, true,
Some("listen".to_string()), directory.path().join("users.json"),
Some("control".to_string()), Some(crate::server::auth::BootstrapAccount::new(
false, "admin".to_string(),
"password123".to_string(),
)),
None,
std::time::Duration::from_secs(3600), std::time::Duration::from_secs(3600),
false, false,
crate::server::auth::SameSite::Lax, crate::server::auth::SameSite::Lax,
)) ))
.unwrap()
} }
/// A contact written over HTTP comes back out of the log, and out of an /// 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( let logbook = std::sync::Arc::new(
trx_logbook::Logbook::open(&dir.path().join("logbook.jsonl")).expect("open"), 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( let app = actix_test::init_service(
App::new() App::new()
.app_data(web::Data::new(logbook)) .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::add_qso)
.service(logbook::list_qsos), .service(logbook::list_qsos),
) )
@@ -1177,6 +1199,10 @@ mod tests {
&app, &app,
actix_test::TestRequest::post() actix_test::TestRequest::post()
.uri("/api/logbook") .uri("/api/logbook")
.cookie(actix_web::cookie::Cookie::new(
"trx_http_sid",
session_id.clone(),
))
.set_json(serde_json::json!({ .set_json(serde_json::json!({
"call": "SP2SJG", "freq_hz": 14_074_000_u64, "mode": "FT8", "call": "SP2SJG", "freq_hz": 14_074_000_u64, "mode": "FT8",
})) }))
@@ -1192,12 +1218,47 @@ mod tests {
&app, &app,
actix_test::TestRequest::get() actix_test::TestRequest::get()
.uri("/api/logbook") .uri("/api/logbook")
.cookie(actix_web::cookie::Cookie::new("trx_http_sid", session_id))
.to_request(), .to_request(),
) )
.await; .await;
assert_eq!(listed["total"], 0); 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 /// A contact needs a callsign; the panel is not the only thing that has to
/// insist on it. /// insist on it.
#[actix_web::test] #[actix_web::test]
@@ -14,7 +14,7 @@ use uuid::Uuid;
use trx_core::radio::freq::Freq; use trx_core::radio::freq::Freq;
use trx_core::rig::state::WfmDenoiseLevel; 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_frontend::{FrontendRuntimeContext, RemoteRigEntry};
use trx_protocol::parse_mode; use trx_protocol::parse_mode;
@@ -299,6 +299,23 @@ pub async fn set_sdr_noise_blanker(
.await .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 // WFM / SAM settings
// ============================================================================ // ============================================================================
@@ -737,6 +737,7 @@ pub async fn audio_ws(
body: web::Payload, body: web::Payload,
query: web::Query<AudioQuery>, query: web::Query<AudioQuery>,
context: web::Data<Arc<FrontendRuntimeContext>>, context: web::Data<Arc<FrontendRuntimeContext>>,
auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> { ) -> Result<HttpResponse, Error> {
let Some(tx_sender) = context.audio.tx.as_ref().cloned() else { let Some(tx_sender) = context.audio.tx.as_ref().cloned() else {
return Ok(HttpResponse::NotFound().body("audio not enabled")); return Ok(HttpResponse::NotFound().body("audio not enabled"));
@@ -746,6 +747,7 @@ pub async fn audio_ws(
if !req.headers().contains_key("upgrade") { if !req.headers().contains_key("upgrade") {
return Ok(HttpResponse::NoContent().finish()); 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. // If a channel_id is specified, subscribe to the per-channel broadcaster.
// The entry is created asynchronously when AUDIO_MSG_VCHAN_ALLOCATED arrives // The entry is created asynchronously when AUDIO_MSG_VCHAN_ALLOCATED arrives
@@ -880,6 +882,16 @@ pub async fn audio_ws(
msg = msg_stream.recv() => { msg = msg_stream.recv() => {
match msg { match msg {
Some(Ok(Message::Binary(data))) => { 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; let _ = tx_sender.send(Bytes::from(data.to_vec())).await;
} }
Some(Ok(Message::Close(_))) => break, 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 comment: String,
pub category: String, pub category: String,
pub decoders: Vec<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 { pub struct BookmarkStore {
@@ -592,6 +592,21 @@ async fn apply_scheduler_target(
) )
.await?; .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( scheduler_send(
rig_tx, rig_tx,
RigCommand::SetMode(trx_protocol::parse_mode(&bookmark.mode)), RigCommand::SetMode(trx_protocol::parse_mode(&bookmark.mode)),
@@ -252,11 +252,31 @@ fn build_server(
"None" => SameSite::None, "None" => SameSite::None,
_ => SameSite::Lax, // default _ => 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( let auth_config = AuthConfig::new(
context.http_auth.enabled, context.http_auth.enabled,
context.http_auth.rx_passphrase.clone(), context.http_auth.users_file.clone().into(),
context.http_auth.control_passphrase.clone(), bootstrap_admin,
context.http_auth.tx_access_control_enabled, bootstrap_read,
Duration::from_secs(context.http_auth.session_ttl_secs), Duration::from_secs(context.http_auth.session_ttl_secs),
context.http_auth.cookie_secure, context.http_auth.cookie_secure,
same_site, same_site,
@@ -273,7 +293,9 @@ fn build_server(
} }
let context_data = web::Data::new(context); 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 // Spawn session cleanup task if auth is enabled
if auth_config.enabled { if auth_config.enabled {
+121 -88
View File
@@ -268,18 +268,21 @@ impl AsRef<str> for CookieSameSite {
pub struct HttpAuthConfig { pub struct HttpAuthConfig {
/// Enable HTTP frontend authentication /// Enable HTTP frontend authentication
pub enabled: bool, pub enabled: bool,
/// Passphrase for read-only access (rx role) /// JSON file containing the managed user database.
pub rx_passphrase: Option<String>, pub users_file: String,
/// Read the rx passphrase from this file instead. /// 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")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub rx_passphrase_file: Option<String>, pub bootstrap_admin_password_file: Option<String>,
/// Passphrase for full control access (control role) /// Create a Guest account when bootstrapping a new database.
pub control_passphrase: Option<String>, pub bootstrap_read_enabled: bool,
/// Read the control passphrase from this file instead. /// Username for the Guest bootstrap account.
#[serde(default, skip_serializing_if = "Option::is_none")] pub bootstrap_read_username: String,
pub control_passphrase_file: Option<String>, /// Password for the Guest bootstrap account.
/// Enforce TX/PTT access control (hide from unauthenticated/rx users) pub bootstrap_read_password: Option<String>,
pub tx_access_control_enabled: bool,
/// Session time-to-live in minutes /// Session time-to-live in minutes
pub session_ttl_min: u64, pub session_ttl_min: u64,
/// Set Secure flag on session cookie (required for HTTPS) /// Set Secure flag on session cookie (required for HTTPS)
@@ -292,11 +295,13 @@ impl Default for HttpAuthConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
enabled: false, enabled: false,
rx_passphrase: None, users_file: "trx-http-users.json".to_string(),
rx_passphrase_file: None, bootstrap_admin_username: None,
control_passphrase: None, bootstrap_admin_password: None,
control_passphrase_file: None, bootstrap_admin_password_file: None,
tx_access_control_enabled: true, bootstrap_read_enabled: true,
bootstrap_read_username: "guest".to_string(),
bootstrap_read_password: Some("guest".to_string()),
session_ttl_min: 480, session_ttl_min: 480,
cookie_secure: false, cookie_secure: false,
cookie_same_site: CookieSameSite::Lax, cookie_same_site: CookieSameSite::Lax,
@@ -802,14 +807,9 @@ impl ClientConfig {
)?; )?;
} }
resolve_secret( resolve_secret(
&mut self.frontends.http.auth.rx_passphrase, &mut self.frontends.http.auth.bootstrap_admin_password,
&self.frontends.http.auth.rx_passphrase_file, &self.frontends.http.auth.bootstrap_admin_password_file,
"[frontends.http.auth].rx_passphrase", "[frontends.http.auth].bootstrap_admin_password",
)?;
resolve_secret(
&mut self.frontends.http.auth.control_passphrase,
&self.frontends.http.auth.control_passphrase_file,
"[frontends.http.auth].control_passphrase",
)?; )?;
resolve_secret_list( resolve_secret_list(
&mut self.frontends.http_json.auth.tokens, &mut self.frontends.http_json.auth.tokens,
@@ -819,7 +819,7 @@ impl ClientConfig {
if let Some(path) = config_path { if let Some(path) = config_path {
if self.has_inline_secrets() { if self.has_inline_secrets() {
crate::secrets::warn_if_group_readable(path, "tokens/passphrases"); crate::secrets::warn_if_group_readable(path, "tokens/passwords");
} }
} }
Ok(()) Ok(())
@@ -832,10 +832,22 @@ impl ClientConfig {
.remotes .remotes
.iter() .iter()
.any(|r| r.auth.token_file.is_none() && r.auth.token.is_some()) .any(|r| r.auth.token_file.is_none() && r.auth.token.is_some())
|| self.frontends.http.auth.rx_passphrase_file.is_none() || self
&& self.frontends.http.auth.rx_passphrase.is_some() .frontends
|| self.frontends.http.auth.control_passphrase_file.is_none() .http
&& self.frontends.http.auth.control_passphrase.is_some() .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_file.is_none()
&& !self.frontends.http_json.auth.tokens.is_empty() && !self.frontends.http_json.auth.tokens.is_empty()
} }
@@ -905,11 +917,13 @@ impl ClientConfig {
decode_history_retention_min_by_rig: HashMap::new(), decode_history_retention_min_by_rig: HashMap::new(),
auth: HttpAuthConfig { auth: HttpAuthConfig {
enabled: false, enabled: false,
rx_passphrase: Some("rx-passphrase-example".to_string()), users_file: "trx-http-users.json".to_string(),
rx_passphrase_file: None, bootstrap_admin_username: Some("admin".to_string()),
control_passphrase: Some("control-passphrase-example".to_string()), bootstrap_admin_password: Some("change-this-password".to_string()),
control_passphrase_file: None, bootstrap_admin_password_file: None,
tx_access_control_enabled: true, bootstrap_read_enabled: true,
bootstrap_read_username: "guest".to_string(),
bootstrap_read_password: Some("guest".to_string()),
session_ttl_min: 480, session_ttl_min: 480,
cookie_secure: false, cookie_secure: false,
cookie_same_site: CookieSameSite::Lax, cookie_same_site: CookieSameSite::Lax,
@@ -948,27 +962,34 @@ fn validate_http_auth(auth: &HttpAuthConfig) -> Result<(), String> {
return Ok(()); return Ok(());
} }
// If enabled, require at least one passphrase if auth.users_file.trim().is_empty() {
if auth.rx_passphrase.is_none() && auth.control_passphrase.is_none() { 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( return Err(
"[frontends.http.auth] enabled=true requires at least one passphrase \ "[frontends.http.auth] bootstrap administrator credentials must not be empty"
(rx_passphrase and/or control_passphrase)"
.to_string(), .to_string(),
); );
} }
if auth.bootstrap_read_enabled
// Validate passphrases are not empty strings && (auth.bootstrap_read_username.trim().is_empty()
if let Some(rx) = &auth.rx_passphrase { || auth
if rx.trim().is_empty() { .bootstrap_read_password
return Err("[frontends.http.auth].rx_passphrase must not be empty if set".to_string()); .as_deref()
} .is_none_or(str::is_empty))
} {
if let Some(ctrl) = &auth.control_passphrase { return Err("[frontends.http.auth] enabled bootstrap Guest account requires a non-empty username and password".to_string());
if ctrl.trim().is_empty() {
return Err(
"[frontends.http.auth].control_passphrase must not be empty if set".to_string(),
);
}
} }
// Session TTL must be > 0 // Session TTL must be > 0
@@ -1238,42 +1259,34 @@ home-hf = "audio://10.0.0.5:4600"
} }
#[test] #[test]
fn test_validate_rejects_http_auth_enabled_without_passphrases() { fn test_validate_accepts_http_auth_with_user_database() {
let mut config = ClientConfig::default(); let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = true; 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()); assert!(config.validate().is_err());
} }
#[test] #[test]
fn test_validate_accepts_http_auth_with_rx_passphrase() { fn test_validate_rejects_empty_users_file() {
let mut config = ClientConfig::default(); let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = true; config.frontends.http.auth.enabled = true;
config.frontends.http.auth.rx_passphrase = Some("rx-secret".to_string()); config.frontends.http.auth.users_file.clear();
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());
assert!(config.validate().is_err()); assert!(config.validate().is_err());
} }
@@ -1281,16 +1294,27 @@ home-hf = "audio://10.0.0.5:4600"
fn test_validate_rejects_zero_session_ttl() { fn test_validate_rejects_zero_session_ttl() {
let mut config = ClientConfig::default(); let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = true; 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; config.frontends.http.auth.session_ttl_min = 0;
assert!(config.validate().is_err()); assert!(config.validate().is_err());
} }
#[test] #[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(); let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = false; 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()); assert!(config.validate().is_ok());
} }
@@ -1298,9 +1322,12 @@ home-hf = "audio://10.0.0.5:4600"
fn test_http_auth_config_default() { fn test_http_auth_config_default() {
let auth = HttpAuthConfig::default(); let auth = HttpAuthConfig::default();
assert!(!auth.enabled); assert!(!auth.enabled);
assert!(auth.rx_passphrase.is_none()); assert_eq!(auth.users_file, "trx-http-users.json");
assert!(auth.control_passphrase.is_none()); assert!(auth.bootstrap_admin_username.is_none());
assert!(auth.tx_access_control_enabled); 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_eq!(auth.session_ttl_min, 480);
assert!(!auth.cookie_secure); assert!(!auth.cookie_secure);
assert!(matches!(auth.cookie_same_site, CookieSameSite::Lax)); assert!(matches!(auth.cookie_same_site, CookieSameSite::Lax));
@@ -1602,15 +1629,21 @@ spectrum_interval_ms = 0
} }
#[test] #[test]
fn test_passphrase_file_fills_passphrase() { fn test_bootstrap_password_file_fills_password() {
let f = secret_file("hunter2\n"); let f = secret_file("hunter2\n");
let mut config = ClientConfig::default(); let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = true; 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()); Some(f.path().to_str().unwrap().to_string());
config.resolve_secrets(None).unwrap(); config.resolve_secrets(None).unwrap();
assert_eq!( assert_eq!(
config.frontends.http.auth.control_passphrase.as_deref(), config
.frontends
.http
.auth
.bootstrap_admin_password
.as_deref(),
Some("hunter2") Some("hunter2")
); );
assert!(config.validate().is_ok()); assert!(config.validate().is_ok());
+2 -2
View File
@@ -111,8 +111,8 @@ const SECTION_COMMENTS: &[(&str, &str)] = &[
), ),
( (
"trx-client.frontends.http.auth", "trx-client.frontends.http.auth",
"Passphrase login for the web UI. rx_passphrase_file and\n\ "Optional user/password ACL for the web UI. Administrators manage\n\
control_passphrase_file keep the secrets out of this file.", accounts stored in users_file.",
), ),
( (
"trx-client.frontends.rigctl", "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}; use serde::{Deserialize, Serialize};
pub use trx_decode_log::DecodeLogsConfig; 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. /// Every decoder the server knows how to run, by config name.
/// ///
@@ -393,6 +393,10 @@ pub struct SdrConfig {
pub squelch: SdrSquelchConfig, pub squelch: SdrSquelchConfig,
/// Noise blanker for impulse noise suppression on IQ samples. /// Noise blanker for impulse noise suppression on IQ samples.
pub noise_blanker: SdrNoiseBlankerConfig, 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). /// Virtual receiver channels (at least one required when SDR backend is active).
pub channels: Vec<SdrChannelConfig>, pub channels: Vec<SdrChannelConfig>,
/// Maximum number of simultaneous virtual channels (including the primary). /// Maximum number of simultaneous virtual channels (including the primary).
@@ -433,6 +437,7 @@ impl Default for SdrConfig {
gain: SdrGainConfig::default(), gain: SdrGainConfig::default(),
squelch: SdrSquelchConfig::default(), squelch: SdrSquelchConfig::default(),
noise_blanker: SdrNoiseBlankerConfig::default(), noise_blanker: SdrNoiseBlankerConfig::default(),
dig_sideband: DigSidebandPolicy::default(),
channels: Vec::new(), channels: Vec::new(),
max_virtual_channels: default_max_virtual_channels(), max_virtual_channels: default_max_virtual_channels(),
spectrum_fft_size: default_spectrum_fft_size(), 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::response::{RigError, RigResult};
pub use rig::state::{ pub use rig::state::{
DecoderConfig, DecoderResetSeqs, RdsData, RigFilterState, RigMode, RigSnapshot, RigState, DecoderConfig, DecoderResetSeqs, RdsData, RigFilterState, RigMode, RigSnapshot, RigState,
WfmDenoiseLevel, DigSidebandPolicy, WfmDenoiseLevel,
}; };
pub use rig::AudioSource; pub use rig::AudioSource;
+3 -1
View File
@@ -3,7 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
use crate::radio::freq::Freq; use crate::radio::freq::Freq;
use crate::rig::state::WfmDenoiseLevel; use crate::rig::state::{DigSidebandPolicy, WfmDenoiseLevel};
use crate::RigMode; use crate::RigMode;
/// Internal command handled by the rig task. /// Internal command handled by the rig task.
@@ -50,6 +50,8 @@ pub enum RigCommand {
SetSdrAgc(bool), SetSdrAgc(bool),
SetSdrSquelch { enabled: bool, threshold_db: f64 }, SetSdrSquelch { enabled: bool, threshold_db: f64 },
SetSdrNoiseBlanker { enabled: bool, threshold: f64 }, SetSdrNoiseBlanker { enabled: bool, threshold: f64 },
/// Set how the SDR backend resolves DIG mode to a sideband (SDR only).
SetSdrDigSideband(DigSidebandPolicy),
SetWfmDeemphasis(u32), SetWfmDeemphasis(u32),
SetWfmStereo(bool), SetWfmStereo(bool),
SetWfmDenoise(WfmDenoiseLevel), SetWfmDenoise(WfmDenoiseLevel),
@@ -470,6 +470,7 @@ pub fn command_from_rig_command(cmd: RigCommand) -> Box<dyn RigCommandHandler> {
| RigCommand::SetSdrAgc(_) | RigCommand::SetSdrAgc(_)
| RigCommand::SetSdrSquelch { .. } | RigCommand::SetSdrSquelch { .. }
| RigCommand::SetSdrNoiseBlanker { .. } | RigCommand::SetSdrNoiseBlanker { .. }
| RigCommand::SetSdrDigSideband(_)
| RigCommand::SetWfmDeemphasis(_) | RigCommand::SetWfmDeemphasis(_)
| RigCommand::SetWfmStereo(_) | RigCommand::SetWfmStereo(_)
| RigCommand::SetWfmDenoise(_) | 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>( fn set_wfm_stereo<'a>(
&'a mut self, &'a mut self,
_enabled: bool, _enabled: bool,
+145
View File
@@ -338,6 +338,10 @@ pub struct RigFilterState {
pub sdr_nb_enabled: Option<bool>, pub sdr_nb_enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub sdr_nb_threshold: Option<f64>, 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")] #[serde(default = "default_wfm_deemphasis_us")]
pub wfm_deemphasis_us: u32, pub wfm_deemphasis_us: u32,
#[serde(default = "default_wfm_stereo")] #[serde(default = "default_wfm_stereo")]
@@ -370,6 +374,89 @@ pub enum WfmDenoiseLevel {
High, 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 { fn default_wfm_deemphasis_us() -> u32 {
75 75
} }
@@ -506,3 +593,61 @@ pub struct RigSnapshot {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub vchan_rds: Option<Vec<VchanRdsEntry>>, 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_squelch_threshold_db: None,
sdr_nb_enabled: None, sdr_nb_enabled: None,
sdr_nb_threshold: None, sdr_nb_threshold: None,
sdr_dig_sideband: None,
wfm_deemphasis_us: 75, wfm_deemphasis_us: 75,
wfm_stereo: true, wfm_stereo: true,
wfm_stereo_detected: false, wfm_stereo_detected: false,
@@ -391,6 +392,7 @@ mod tests {
sdr_squelch_threshold_db: None, sdr_squelch_threshold_db: None,
sdr_nb_enabled: None, sdr_nb_enabled: None,
sdr_nb_threshold: None, sdr_nb_threshold: None,
sdr_dig_sideband: None,
wfm_deemphasis_us: 50, wfm_deemphasis_us: 50,
wfm_stereo: true, wfm_stereo: true,
wfm_stereo_detected: true, wfm_stereo_detected: true,
+1
View File
@@ -146,6 +146,7 @@ define_command_mapping! {
SetSdrGain { gain_db } <=> SetSdrGain, SetSdrGain { gain_db } <=> SetSdrGain,
SetSdrLnaGain { gain_db } <=> SetSdrLnaGain, SetSdrLnaGain { gain_db } <=> SetSdrLnaGain,
SetSdrAgc { enabled } <=> SetSdrAgc, SetSdrAgc { enabled } <=> SetSdrAgc,
SetSdrDigSideband { policy } <=> SetSdrDigSideband,
SetWfmDeemphasis { deemphasis_us } <=> SetWfmDeemphasis, SetWfmDeemphasis { deemphasis_us } <=> SetWfmDeemphasis,
SetWfmStereo { enabled } <=> SetWfmStereo, SetWfmStereo { enabled } <=> SetWfmStereo,
SetWfmDenoise { level } <=> SetWfmDenoise, SetWfmDenoise { level } <=> SetWfmDenoise,
+4 -1
View File
@@ -7,7 +7,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use trx_core::rig::state::RigSnapshot; use trx_core::rig::state::RigSnapshot;
use trx_core::WfmDenoiseLevel; use trx_core::{DigSidebandPolicy, WfmDenoiseLevel};
/// Command received from network clients (JSON). /// Command received from network clients (JSON).
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
@@ -106,6 +106,9 @@ pub enum ClientCommand {
enabled: bool, enabled: bool,
threshold: f64, threshold: f64,
}, },
SetSdrDigSideband {
policy: DigSidebandPolicy,
},
SetWfmDeemphasis { SetWfmDeemphasis {
deemphasis_us: u32, 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, max_virtual_channels: rig_cfg.sdr.max_virtual_channels,
nb_enabled: rig_cfg.sdr.noise_blanker.enabled, nb_enabled: rig_cfg.sdr.noise_blanker.enabled,
nb_threshold: rig_cfg.sdr.noise_blanker.threshold, nb_threshold: rig_cfg.sdr.noise_blanker.threshold,
dig_sideband: rig_cfg.sdr.dig_sideband,
spectrum_fft_size: rig_cfg.sdr.spectrum_fft_size, 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()); let _ = ctx.state_tx.send(ctx.state.clone());
return snapshot_from(ctx.state); 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) => { RigCommand::SetWfmDeemphasis(deemphasis_us) => {
if let Some(sdr) = ctx.rig.as_sdr() { if let Some(sdr) = ctx.rig.as_sdr() {
if let Err(e) = sdr.set_wfm_deemphasis(deemphasis_us).await { 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 std::sync::{Arc, Mutex};
use trx_core::radio::freq::{Band, Freq}; use trx_core::radio::freq::{Band, Freq};
use trx_core::rig::response::RigError; 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::{ use trx_core::rig::{
AudioSource, Rig, RigAccessMethod, RigCapabilities, RigCat, RigInfo, RigSdr, RigStatusFuture, AudioSource, Rig, RigAccessMethod, RigCapabilities, RigCat, RigInfo, RigSdr, RigStatusFuture,
}; };
@@ -73,6 +76,8 @@ pub struct SoapySdrConfig {
pub nb_enabled: bool, pub nb_enabled: bool,
/// Noise blanker impulse threshold multiplier. /// Noise blanker impulse threshold multiplier.
pub nb_threshold: f64, 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. /// 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, /// 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, max_virtual_channels: 4,
nb_enabled: false, nb_enabled: false,
nb_threshold: 10.0, nb_threshold: 10.0,
dig_sideband: DigSidebandPolicy::Auto,
spectrum_fft_size: 1024, spectrum_fft_size: 1024,
} }
} }
@@ -157,7 +163,12 @@ pub struct SoapySdrRig {
/// Hidden AIS decoder channels (A and B) when available. /// Hidden AIS decoder channels (A and B) when available.
ais_channel_indices: Option<(usize, usize)>, ais_channel_indices: Option<(usize, usize)>,
/// Virtual channel manager shared with external consumers (e.g. RigHandle). /// Virtual channel manager shared with external consumers (e.g. RigHandle).
/// Also owns the shared DIG-sideband policy.
channel_manager: Arc<vchan_impl::SdrVirtualChannelManager>, 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 { impl SoapySdrRig {
@@ -200,6 +211,7 @@ impl SoapySdrRig {
let max_virtual_channels = config.max_virtual_channels; let max_virtual_channels = config.max_virtual_channels;
let nb_enabled = config.nb_enabled; let nb_enabled = config.nb_enabled;
let nb_threshold = config.nb_threshold; let nb_threshold = config.nb_threshold;
let dig_sideband = config.dig_sideband;
let spectrum_fft_size = config.spectrum_fft_size; let spectrum_fft_size = config.spectrum_fft_size;
tracing::info!( tracing::info!(
"initialising SoapySDR backend (args={:?}, gain_mode={:?}, gain_db={}, max_gain_db={:?})", "initialising SoapySDR backend (args={:?}, gain_mode={:?}, gain_db={}, max_gain_db={:?})",
@@ -364,8 +376,15 @@ impl SoapySdrRig {
pipeline.clone(), pipeline.clone(),
fixed_slot_count, fixed_slot_count,
max_virtual_channels, 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 { let rig = Self {
info, info,
freq: initial_freq, freq: initial_freq,
@@ -392,7 +411,17 @@ impl SoapySdrRig {
nb_threshold, nb_threshold,
ais_channel_indices: Some((primary_channel_count, primary_channel_count + 1)), ais_channel_indices: Some((primary_channel_count, primary_channel_count + 1)),
channel_manager, 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(); rig.apply_ais_channel_activity();
Ok(rig) Ok(rig)
} }
@@ -448,6 +477,7 @@ impl SoapySdrRig {
max_virtual_channels, max_virtual_channels,
nb_enabled, nb_enabled,
nb_threshold, 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(); self.update_ais_channel_offsets();
Ok(()) Ok(())
}) })
@@ -632,12 +677,17 @@ impl RigCat for SoapySdrRig {
tracing::debug!("SoapySdrRig: set_mode -> {:?}", mode); tracing::debug!("SoapySdrRig: set_mode -> {:?}", mode);
self.mode = mode.clone(); self.mode = mode.clone();
self.bandwidth_hz = Self::default_bandwidth_for_mode(&mode); 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. // Update the primary channel's demodulator in the live pipeline.
{ {
let dsps = self.pipeline.channel_dsps.read().unwrap(); let dsps = self.pipeline.channel_dsps.read().unwrap();
if let Some(dsp_arc) = dsps.get(self.primary_channel_idx) { if let Some(dsp_arc) = dsps.get(self.primary_channel_idx) {
let mut dsp = dsp_arc.lock().unwrap(); let mut dsp = dsp_arc.lock().unwrap();
dsp.set_mode(&mode); dsp.set_mode(&effective);
dsp.set_filter(self.bandwidth_hz); 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>( fn set_wfm_stereo<'a>(
&'a mut self, &'a mut self,
enabled: bool, enabled: bool,
@@ -1049,6 +1123,7 @@ impl RigSdr for SoapySdrRig {
sdr_squelch_threshold_db: Some(self.squelch_threshold_db as f64), sdr_squelch_threshold_db: Some(self.squelch_threshold_db as f64),
sdr_nb_enabled: Some(self.nb_enabled), sdr_nb_enabled: Some(self.nb_enabled),
sdr_nb_threshold: Some(self.nb_threshold), sdr_nb_threshold: Some(self.nb_threshold),
sdr_dig_sideband: Some(self.channel_manager.dig_policy()),
wfm_deemphasis_us: self.wfm_deemphasis_us, wfm_deemphasis_us: self.wfm_deemphasis_us,
wfm_stereo: self.wfm_stereo, wfm_stereo: self.wfm_stereo,
wfm_stereo_detected, wfm_stereo_detected,
@@ -22,12 +22,12 @@
//! updates every `ChannelDsp` in place and pauses out-of-span channels instead //! updates every `ChannelDsp` in place and pauses out-of-span channels instead
//! of destroying them. //! of destroying them.
use std::sync::atomic::{AtomicI64, Ordering}; use std::sync::atomic::{AtomicI64, AtomicU8, Ordering};
use std::sync::{Arc, RwLock}; use std::sync::{Arc, RwLock};
use num_complex::Complex; use num_complex::Complex;
use tokio::sync::broadcast; 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 uuid::Uuid;
use crate::dsp::SdrPipeline; use crate::dsp::SdrPipeline;
@@ -59,7 +59,12 @@ fn default_bandwidth_hz(mode: &RigMode) -> u32 {
struct ManagedChannel { struct ManagedChannel {
id: Uuid, id: Uuid,
freq_hz: u64, freq_hz: u64,
/// Logical mode requested by the user (e.g. `DIG`), shown in listings.
mode: RigMode, 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. /// `broadcast::Sender` kept alive so new subscribers can join at any time.
pcm_tx: broadcast::Sender<Vec<f32>>, pcm_tx: broadcast::Sender<Vec<f32>>,
/// IQ tap sender (kept alive; external consumers may subscribe). /// IQ tap sender (kept alive; external consumers may subscribe).
@@ -91,6 +96,9 @@ pub struct SdrVirtualChannelManager {
channels: RwLock<Vec<ManagedChannel>>, channels: RwLock<Vec<ManagedChannel>>,
/// Fires whenever a channel is explicitly destroyed. /// Fires whenever a channel is explicitly destroyed.
destroyed_tx: broadcast::Sender<Uuid>, 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 { impl SdrVirtualChannelManager {
@@ -100,7 +108,12 @@ impl SdrVirtualChannelManager {
/// - `fixed_slot_count`: number of fixed pipeline slots (primary + AIS), /// - `fixed_slot_count`: number of fixed pipeline slots (primary + AIS),
/// i.e. the index of the first slot available for virtual channels. /// i.e. the index of the first slot available for virtual channels.
/// - `max_total`: maximum total channels including primary (e.g. 4). /// - `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. // Seed the channel list with a synthetic primary-channel entry.
// We use the first PCM sender from the pipeline (index 0). // We use the first PCM sender from the pipeline (index 0).
let primary_pcm_tx = pipeline let primary_pcm_tx = pipeline
@@ -118,6 +131,7 @@ impl SdrVirtualChannelManager {
id: Uuid::new_v4(), id: Uuid::new_v4(),
freq_hz: 0, // actual freq kept by SoapySdrRig; manager treats ch-0 as opaque freq_hz: 0, // actual freq kept by SoapySdrRig; manager treats ch-0 as opaque
mode: RigMode::USB, mode: RigMode::USB,
applied_mode: RigMode::USB,
pcm_tx: primary_pcm_tx, pcm_tx: primary_pcm_tx,
iq_tx: primary_iq_tx, iq_tx: primary_iq_tx,
pipeline_slot: 0, pipeline_slot: 0,
@@ -134,6 +148,7 @@ impl SdrVirtualChannelManager {
max_total: max_total.max(1), max_total: max_total.max(1),
channels: RwLock::new(vec![primary]), channels: RwLock::new(vec![primary]),
destroyed_tx, destroyed_tx,
dig_sideband: AtomicU8::new(dig_sideband.to_u8()),
} }
} }
@@ -141,6 +156,36 @@ impl SdrVirtualChannelManager {
self.destroyed_tx.clone() 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 { fn half_span_hz(&self) -> i64 {
i64::from(self.pipeline.sdr_sample_rate) / 2 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 bandwidth_hz = default_bandwidth_hz(mode);
let (pcm_tx, iq_tx) = self let applied_mode = self.resolve_mode(mode, freq_hz);
.pipeline let (pcm_tx, iq_tx) =
.add_virtual_channel(if_hz as f64, mode, bandwidth_hz); self.pipeline
.add_virtual_channel(if_hz as f64, &applied_mode, bandwidth_hz);
let pipeline_slot = self let pipeline_slot = self
.pipeline .pipeline
@@ -190,6 +238,7 @@ impl SdrVirtualChannelManager {
id, id,
freq_hz, freq_hz,
mode: mode.clone(), mode: mode.clone(),
applied_mode,
pcm_tx, pcm_tx,
iq_tx, iq_tx,
pipeline_slot, pipeline_slot,
@@ -314,9 +363,18 @@ impl VirtualChannelManager for SdrVirtualChannelManager {
.ok_or(VChanError::NotFound)?; .ok_or(VChanError::NotFound)?;
ch.freq_hz = freq_hz; 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(); let dsps = self.pipeline.channel_dsps.read().unwrap();
if let Some(dsp_arc) = dsps.get(ch.pipeline_slot) { 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(()) Ok(())
} }
@@ -329,9 +387,11 @@ impl VirtualChannelManager for SdrVirtualChannelManager {
.ok_or(VChanError::NotFound)?; .ok_or(VChanError::NotFound)?;
ch.mode = mode.clone(); 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(); let dsps = self.pipeline.channel_dsps.read().unwrap();
if let Some(dsp_arc) = dsps.get(ch.pipeline_slot) { 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(()) Ok(())
} }
@@ -425,6 +485,7 @@ impl VirtualChannelManager for SdrVirtualChannelManager {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::demod::Demodulator;
use crate::dsp::{MockIqSource, NoiseBlankerConfig, SdrPipeline}; use crate::dsp::{MockIqSource, NoiseBlankerConfig, SdrPipeline};
fn make_pipeline() -> Arc<SdrPipeline> { fn make_pipeline() -> Arc<SdrPipeline> {
@@ -446,7 +507,7 @@ mod tests {
#[test] #[test]
fn add_and_list() { fn add_and_list() {
let p = make_pipeline(); 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. // Set center to 14.1 MHz so that 14.074 MHz is within ±960 kHz.
mgr.update_center_hz(14_100_000); mgr.update_center_hz(14_100_000);
assert_eq!(mgr.channels().len(), 1); // primary only assert_eq!(mgr.channels().len(), 1); // primary only
@@ -462,7 +523,7 @@ mod tests {
#[test] #[test]
fn remove_virtual_channel() { fn remove_virtual_channel() {
let p = make_pipeline(); 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); mgr.update_center_hz(14_100_000);
let (id, _) = mgr.add_channel(14_074_000, &RigMode::USB).unwrap(); let (id, _) = mgr.add_channel(14_074_000, &RigMode::USB).unwrap();
mgr.remove_channel(id).unwrap(); mgr.remove_channel(id).unwrap();
@@ -472,7 +533,7 @@ mod tests {
#[test] #[test]
fn cannot_remove_primary() { fn cannot_remove_primary() {
let p = make_pipeline(); 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 primary_id = mgr.channels()[0].id;
let err = mgr.remove_channel(primary_id).unwrap_err(); let err = mgr.remove_channel(primary_id).unwrap_err();
assert!(matches!(err, VChanError::Permanent)); assert!(matches!(err, VChanError::Permanent));
@@ -481,7 +542,7 @@ mod tests {
#[test] #[test]
fn cap_enforced() { fn cap_enforced() {
let p = make_pipeline(); 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.update_center_hz(14_100_000);
mgr.add_channel(14_074_000, &RigMode::USB).unwrap(); mgr.add_channel(14_074_000, &RigMode::USB).unwrap();
let err = mgr.add_channel(14_075_000, &RigMode::USB).unwrap_err(); let err = mgr.add_channel(14_075_000, &RigMode::USB).unwrap_err();
@@ -491,7 +552,7 @@ mod tests {
#[test] #[test]
fn out_of_bandwidth() { fn out_of_bandwidth() {
let p = make_pipeline(); 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 // 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(); let err = mgr.add_channel(10_000_000, &RigMode::USB).unwrap_err();
assert!(matches!(err, VChanError::OutOfBandwidth { .. })); assert!(matches!(err, VChanError::OutOfBandwidth { .. }));
@@ -500,7 +561,7 @@ mod tests {
#[test] #[test]
fn hidden_background_channels_are_outside_visible_cap() { fn hidden_background_channels_are_outside_visible_cap() {
let p = make_pipeline(); 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.update_center_hz(14_100_000);
mgr.add_channel(14_074_000, &RigMode::USB).unwrap(); mgr.add_channel(14_074_000, &RigMode::USB).unwrap();
@@ -513,10 +574,46 @@ mod tests {
assert!(visible.iter().all(|channel| channel.id != hidden_id)); 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] #[test]
fn retune_keeps_virtual_channel_allocated() { fn retune_keeps_virtual_channel_allocated() {
let p = make_pipeline(); 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); mgr.update_center_hz(14_100_000);
let mut destroyed_rx = mgr.subscribe_destroyed(); let mut destroyed_rx = mgr.subscribe_destroyed();
+9 -5
View File
@@ -118,6 +118,7 @@ sample_rate = 1920000
bandwidth = 1500000 bandwidth = 1500000
wfm_deemphasis_us = 50 wfm_deemphasis_us = 50
center_offset_hz = 100000 center_offset_hz = 100000
dig_sideband = "auto"
channels = [] channels = []
max_virtual_channels = 4 max_virtual_channels = 4
spectrum_fft_size = 1024 spectrum_fft_size = 1024
@@ -203,13 +204,16 @@ decode_history_retention_min = 1440
[trx-client.frontends.http.decode_history_retention_min_by_rig] [trx-client.frontends.http.decode_history_retention_min_by_rig]
# Passphrase login for the web UI. rx_passphrase_file and # Optional user/password ACL for the web UI. Administrators manage
# control_passphrase_file keep the secrets out of this file. # accounts stored in users_file.
[trx-client.frontends.http.auth] [trx-client.frontends.http.auth]
enabled = false enabled = false
rx_passphrase = "rx-passphrase-example" users_file = "trx-http-users.json"
control_passphrase = "control-passphrase-example" bootstrap_admin_username = "admin"
tx_access_control_enabled = true bootstrap_admin_password = "change-this-password"
bootstrap_read_enabled = true
bootstrap_read_username = "guest"
bootstrap_read_password = "guest"
session_ttl_min = 480 session_ttl_min = 480
cookie_secure = false cookie_secure = false
cookie_same_site = "Lax" cookie_same_site = "Lax"