Compare commits

..
14 Commits
Author SHA1 Message Date
sjgandClaude Opus 5 4727233d8e [feat](trx-rs): make spectrum affordable over a slow link
CI / lint (pull_request) Successful in 2m20s
CI / test (pull_request) Successful in 8m0s
CI / frontend (pull_request) Successful in 3m32s
CI / reuse (pull_request) Successful in 5s
Spectrum dominates the server↔client connection, and all three things that
govern its cost were working against a poor link.

**It was polled, one round trip per frame.** The client asked for a frame every
50 ms on a dedicated connection and waited for the reply, so the frame rate was
capped at 1/RTT — on a 200 ms link, five frames a second no matter what was
configured.  Add SubscribeSpectrum alongside the existing SubscribeMeter: the
server pushes frames from a per-rig broadcast that rig_task fills only while
somebody is subscribed.  A server too old to know the command answers with an
error and leaves the connection usable, so the client falls back to polling on
the same connection without reconnecting.

**Bins were JSON floats.** 1024 bins spelled out as decimal text is around
10 KB a frame, ~200 KB/s at full rate — while the very next hop, client to
browser, already sends the same information as base64 i8 in about 1.4 KB.  Bins
now travel base64-encoded whole dBFS, the resolution the display draws at
anyway.  Decoding still accepts the old array form.

**Nothing was tunable.** [sdr].spectrum_fft_size and [sdr].spectrum_interval_ms
replace the compile-time FFT size and cadence; [[remotes]].spectrum_interval_ms
lets the client ask for less.  512 bins at 5 frames/s is roughly 3.5 KB/s
against roughly 200 KB/s before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-07 00:13:25 +02:00
sjgandClaude Opus 5 46c9827e8a [fix](trx-config): keep the generated example off the machine that made it
CI / lint (pull_request) Successful in 2m17s
CI / frontend (pull_request) Successful in 3m28s
CI / reuse (pull_request) Successful in 3s
CI / lint (push) Successful in 2m16s
CI / test (pull_request) Successful in 7m50s
CI / frontend (push) Successful in 4m11s
CI / reuse (push) Successful in 9s
CI / test (push) Failing after 17m15s
The example is generated from the config defaults, and [decode_logs].dir
defaults to the running user's cache directory.  So the file rendered
/Users/sjg/Library/Caches/trx-rs/decoders on the machine that generated it and
/root/.cache/trx-rs/decoders in CI, and the up-to-date test failed for everyone
but its author.

Pin dir to an illustrative /var/lib/trx-rs/decoders in the example config;
omitting the key still falls back to the per-user directory.  A new test asserts
the rendered example contains none of this machine's home, cache or config
directories, so the next environment-derived default cannot slip through the
same way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 22:08:06 +02:00
sjgandClaude Opus 5 0fc977f19f [style](trx-config): apply rustfmt
CI / test (pull_request) Failing after 6m0s
CI / lint (pull_request) Successful in 2m18s
CI / frontend (pull_request) Successful in 3m28s
CI / reuse (pull_request) Successful in 2s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 21:44:17 +02:00
sjgandClaude Opus 5 084f629b5b [docs](trx-rs): record the trx-config crate and its commands
CI / lint (pull_request) Failing after 1s
CI / test (pull_request) Failing after 6m6s
CI / reuse (pull_request) Has been cancelled
CI / frontend (pull_request) Has been cancelled
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 21:39:09 +02:00
sjgandClaude Opus 5 bc63ded583 [feat](trx-config): warn about deprecated configuration keys
Several keys quietly stopped doing what they look like they do, and nothing
said so: [remote] and the flat per-rig sections are ignored outright once
[[remotes]] / [[rigs]] exist, [frontends.rigctl].port and --rigctl-port have
been dead since rig_ports replaced them, [frontends.audio].rig_ports is
superseded by rig_urls, and default_rig_id was renamed to default_rig_name.

Warn once at load, naming the replacement.  Defaults are indistinguishable from
explicit values after deserialization, so the loader now records which key paths
the file actually set and the checks work off that — no warning for a setting
the user never wrote.

The single-rig flat layout is not deprecated: it is the documented simple form,
and only draws a warning when [[rigs]] is silently shadowing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 21:35:52 +02:00
sjgandClaude Opus 5 cfaeb6ee15 [docs](trx-rs): generate the example config and correct the manual
trx-rs.toml.example was maintained by hand and had fallen well behind: no
[[rigs]], no [[remotes]], no [timeouts], no bandplan or decode-history
settings, and a [frontends.http].default_rig_id that had been renamed.

Generate it from the config structs instead, so a new field shows up the moment
it exists, and add a test that fails when the checked-in copy drifts:

    cargo run -p trx-config --example generate_example

Section comments come from a small table; a section without an entry is still
emitted, so forgetting a comment can never drop a setting from the example.

The manual was wrong about the basics.  It listed five config search paths, none
of which the loader has ever looked at (the real order is ./trx-rs.toml → XDG →
/etc), called --print-config output "fully commented" when it carries no
comments at all, and documented a TRX_PLUGIN_DIRS variable no code reads.  It
also still described [frontends.rigctl].port as the bind port years after
rig_ports replaced it.  Fixed, and the new configuration features are written
up alongside.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 21:32:46 +02:00
sjgandClaude Opus 5 76bcce8c54 [feat](trx-config): let secrets live outside the config file
Tokens and passphrases had exactly one representation: plain text in
trx-rs.toml.  That is awkward for config-management tools, for a config kept in
a private repo, and for anything shared between machines.

Two alternatives:

- ${VAR} anywhere in a config string, expanded from the environment at load.
  An unset variable is an error rather than an empty string — a silently blank
  passphrase is how authentication gets disabled by accident.
- A *_file sibling for every credential: [listen.auth].tokens_file,
  [[remotes]].auth.token_file, [frontends.http.auth].rx_passphrase_file and
  .control_passphrase_file, [frontends.http_json.auth].tokens_file.  Setting
  both forms is an error rather than a guess about which wins.

Plus a nudge: a config file that holds credentials inline and is readable by
group or others gets a warning naming the chmod that fixes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 21:27:18 +02:00
sjgandClaude Opus 5 88ed3da6cc [feat](trx-server): make the decoder set configurable per rig
Every rig started nine decoders — APRS, HF APRS, CW, FT8, FT4, WSPR, LRPT,
WEFAX, SSTV — whether or not anyone ever looked at the results.  Two rigs on a
Pi meant eighteen decoder tasks chewing CPU for modes the operator does not
run.  Only the SDR virtual channels had a decoder list; the analog path had no
say at all.

Add [decoders] per rig:

    [decoders]
    enabled = ["cw", "ft8", "wspr"]
    output_dir = "/var/lib/trx-rs"

using the same decoder names as [sdr.channels].decoders, so there is one
vocabulary.  enabled defaults to every decoder, so upgrading changes nothing.
An unknown name is a config error rather than a silently ignored entry.

output_dir also replaces the hard-coded cache paths for the decoders that write
images, so SSTV, WEFAX and LRPT output can live somewhere the operator chooses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 21:22:03 +02:00
sjgandClaude Opus 5 7c69e0de08 [feat](trx-rs): add --check-config to the server and client
Validating a config meant starting the daemon and reading the first error it
died on, fixing that, and repeating.  Add --check-config, which loads the
config through the real loader, reports every problem at once and exits 0/1:

    $ trx-server --check-config --config trx-rs.toml
    trx-rs.toml
      warning: unknown config key 'listen.prot' (did you mean 'listen.port'?)
      error: [general].log_level 'verbose' is invalid (expected one of: ...)
      error: [rig.access].baud must be > 0 for serial access
      error: [audio].frame_duration_ms must be one of: 3, 5, 10, 20, 40, 60
      error: [listen] and rig "default" [audio] would both bind 127.0.0.1:4530

Validation grows validate_all()/validate_resolved_all() alongside the existing
first-error entry points; validate() is now the first element of validate_all().
Sockets are built by one helper shared by startup and the check, so the two
cannot disagree about what --listen overrides.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 21:08:20 +02:00
sjgandClaude Opus 5 fbc4f6e398 [feat](trx-config): add a resolved-config validation phase
Some things can only be checked once CLI overrides have been folded in and the
rig/remote lists are final, so nothing checked them at all:

- The client's per-rig maps (rigctl.rig_ports, audio.rig_urls, audio.rig_ports,
  decode_history_retention_min_by_rig, http.default_rig_name) are keyed by a
  remote's short name.  A typo used to spawn a rigctl listener that injected a
  rig_id no remote answered to, without a word in the log.
- Nothing noticed two listeners claiming one socket.  [listen].port and a rig's
  [audio].port could both be 4530; on the client, http, http_json and each
  rigctl rig port could collide freely.

Add validate_resolved() to both configs, run after argument parsing, plus a
shared socket-conflict check that treats a wildcard address as conflicting with
any address on the same port and ignores port 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 21:02:26 +02:00
sjgandClaude Opus 5 d42ca4f030 [fix](trx-config): validate every rig, not just the legacy flat one
ServerConfig::validate() checked the flat [rig]/[audio]/[behavior] fields and
gave [[rigs]] entries only an id/audio-port uniqueness pass, and
validate_sdr() returned early unless the *flat* access type was "sdr".  A
multi-rig SDR station therefore got no Nyquist, stream_opus, duplicate-decoder
or tx_enabled checking at all, and a rig entry with frame_duration_ms = 7 or a
missing baud rate started and failed at runtime.

Move the per-rig rules into validate_rig_instance() and validate_sdr_instance()
and run them over resolved_rigs(), which already synthesises the flat layout as
a single entry.  Both layouts now go through the same code, and multi-rig
messages name the rig they came from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 20:57:59 +02:00
sjgandClaude Opus 5 335922fecc [feat](trx-config): report unknown configuration keys
Every config struct is #[serde(default)], so a misspelled key was dropped in
silence and the setting kept its default.  Writing `prot = 9999` under
[listen] started the server on 4530 without a word.

Collect the ignored key paths with serde_ignored and pair each with the
closest known key at the same level:

    WARN unknown config key 'listen.prot' (did you mean 'listen.port'?)

Warnings by default, so a config written for a newer version still runs on an
older binary; --strict-config makes them fatal for CI.  Logging now starts
before validation so these warnings are actually visible.

trx-configurator --check drops its hand-maintained key lists and re-implemented
range checks in favour of the real loader and validators, so it no longer
passes configs the binaries reject.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 20:55:01 +02:00
sjgandClaude Opus 5 bede2e34fe [fix](trx-config): accept both sectioned and bare config files
trx-configurator wrote standalone configs with [general]/[rig] at the root
while the loader required a [trx-server] section header, so every config the
wizard generated with --type server or --type client was rejected by the
binary it was generated for:

    $ trx-server --config trx-server.toml
    Error: ParseError("trx-server.toml", "missing [trx-server] section")

Teach the loader to fall back to the document root when no section header is
present, so hand-written standalone files keep working, and have the wizard
emit the same sectioned shape --print-config does.  A file carrying only the
*other* component's section still reports the missing section rather than
silently loading defaults.

Round-trip tests now load every document the wizard can generate through the
real loader and validator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 20:46:59 +02:00
sjgandClaude Opus 5 da58a004fe [refactor](trx-config): extract client/server config into a shared crate
The setup wizard, the server and the client each carried their own idea of
what a valid config looks like: trx-configurator validated with hand-written
toml_edit key lists while the binaries validated with serde plus their own
validate().  Nothing kept the three in sync.

Move ServerConfig, ClientConfig, the section loader, the shared validators and
the endpoint-URL parsing into a new trx-config crate that all three depend on,
so there is one definition of the config to drift from.  The binaries keep a
thin crate::config re-export so their internal paths are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 20:44:42 +02:00
31 changed files with 6321 additions and 3615 deletions
+8 -1
View File
@@ -24,6 +24,12 @@ cargo test -p trx-core
./target/release/trx-server --print-config > trx-server.toml ./target/release/trx-server --print-config > trx-server.toml
./target/release/trx-client --print-config > trx-client.toml ./target/release/trx-client --print-config > trx-client.toml
# Validate a config without starting anything (reports every problem)
./target/release/trx-server --check-config --config trx-rs.toml
# Regenerate trx-rs.toml.example after changing a config struct
cargo run -p trx-config --example generate_example
# Run server # Run server
./target/release/trx-server --config trx-server.toml ./target/release/trx-server --config trx-server.toml
# or via CLI args: # or via CLI args:
@@ -41,7 +47,8 @@ This is a Cargo workspace. All crates live under `src/`:
src/ src/
trx-core/ # Core types, traits, state machine, controller (~3,500 LOC) trx-core/ # Core types, traits, state machine, controller (~3,500 LOC)
trx-protocol/ # Client↔server protocol DTOs, auth, codec, mapping (~1,100 LOC) trx-protocol/ # Client↔server protocol DTOs, auth, codec, mapping (~1,100 LOC)
trx-app/ # Shared application helpers (config paths, logging init) trx-app/ # Shared application helpers (logging init, name normalization)
trx-config/ # Client + server config structs, loader, validators (~2,500 LOC)
trx-reporting/ # PSKReporter UDP uplink + APRS-IS TCP uplink (~1,150 LOC) trx-reporting/ # PSKReporter UDP uplink + APRS-IS TCP uplink (~1,150 LOC)
trx-server/ # Server binary: rig_task, audio pipeline, listener (~3,700 LOC) trx-server/ # Server binary: rig_task, audio pipeline, listener (~3,700 LOC)
trx-backend/ # Backend abstraction trait + factory + dummy trx-backend/ # Backend abstraction trait + factory + dummy
Generated
+31 -4
View File
@@ -2412,6 +2412,16 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "serde_ignored"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798"
dependencies = [
"serde",
"serde_core",
]
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.149" version = "1.0.149"
@@ -3031,10 +3041,6 @@ dependencies = [
name = "trx-app" name = "trx-app"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"dirs",
"serde",
"thiserror 2.0.18",
"toml",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
] ]
@@ -3115,6 +3121,7 @@ dependencies = [
"toml", "toml",
"tracing", "tracing",
"trx-app", "trx-app",
"trx-config",
"trx-core", "trx-core",
"trx-frontend", "trx-frontend",
"trx-frontend-http", "trx-frontend-http",
@@ -3124,6 +3131,23 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "trx-config"
version = "0.1.0"
dependencies = [
"dirs",
"serde",
"serde_ignored",
"tempfile",
"thiserror 2.0.18",
"toml",
"toml_edit 0.22.27",
"tracing",
"trx-core",
"trx-decode-log",
"trx-reporting",
]
[[package]] [[package]]
name = "trx-configurator" name = "trx-configurator"
version = "0.1.0" version = "0.1.0"
@@ -3132,7 +3156,9 @@ dependencies = [
"dialoguer", "dialoguer",
"tempfile", "tempfile",
"tokio-serial", "tokio-serial",
"toml",
"toml_edit 0.22.27", "toml_edit 0.22.27",
"trx-config",
] ]
[[package]] [[package]]
@@ -3295,6 +3321,7 @@ dependencies = [
"trx-app", "trx-app",
"trx-aprs", "trx-aprs",
"trx-backend", "trx-backend",
"trx-config",
"trx-core", "trx-core",
"trx-cw", "trx-cw",
"trx-decode-log", "trx-decode-log",
+1
View File
@@ -18,6 +18,7 @@ members = [
"src/trx-core", "src/trx-core",
"src/trx-protocol", "src/trx-protocol",
"src/trx-app", "src/trx-app",
"src/trx-config",
"src/trx-reporting", "src/trx-reporting",
"src/trx-server", "src/trx-server",
"src/trx-server/trx-backend", "src/trx-server/trx-backend",
+10 -3
View File
@@ -93,13 +93,17 @@ The wizard walks you through rig selection, serial port detection, audio
settings, and frontend options, then writes `trx-server.toml` and settings, and frontend options, then writes `trx-server.toml` and
`trx-client.toml`. `trx-client.toml`.
Alternatively, generate example configs and edit them by hand: Alternatively, copy `trx-rs.toml.example` — a commented example covering every
setting — and edit it by hand:
```bash ```bash
./target/release/trx-server --print-config > trx-server.toml cp trx-rs.toml.example trx-rs.toml
./target/release/trx-client --print-config > trx-client.toml ./target/release/trx-server --check-config --config trx-rs.toml
``` ```
`--check-config` reports everything wrong with a config without starting
anything. `--print-config` prints the same settings without comments.
### 4. Run ### 4. Run
```bash ```bash
@@ -107,6 +111,9 @@ Alternatively, generate example configs and edit them by hand:
./target/release/trx-client --config trx-client.toml ./target/release/trx-client --config trx-client.toml
``` ```
A single `trx-rs.toml` can configure both: the server reads its `[trx-server]`
section and the client reads `[trx-client]`.
Open the configured HTTP frontend address in a browser (default `http://localhost:8080`). Open the configured HTTP frontend address in a browser (default `http://localhost:8080`).
## How It Works ## How It Works
+133 -22
View File
@@ -17,30 +17,61 @@ frontends.
## Configuration ## Configuration
Both `trx-server` and `trx-client` use TOML configuration files. Use Both `trx-server` and `trx-client` read TOML. The server takes its settings
`--print-config` to generate a fully commented example. from the `[trx-server]` section and the client from `[trx-client]`, so one
`trx-rs.toml` can configure both — or each may live in its own file with the
section header left off.
`trx-rs.toml.example` in the repository root is a complete, commented example
generated from the config definitions themselves. `--print-config` prints the
same settings without the comments.
### File Locations ### File Locations
**trx-server** lookup order: Both binaries use the same lookup order:
1. `--config <FILE>`
2. `./trx-server.toml`
3. `~/.trx-server.toml`
4. `~/.config/trx-rs/server.toml`
5. `/etc/trx-rs/server.toml`
**trx-client** lookup order:
1. `--config <FILE>` 1. `--config <FILE>`
2. `./trx-client.toml` 2. `./trx-rs.toml`
3. `~/.config/trx-rs/client.toml` 3. `~/.config/trx-rs/trx-rs.toml`
4. `/etc/trx-rs/client.toml` 4. `/etc/trx-rs/trx-rs.toml`
CLI arguments override config file values. CLI arguments override config file values.
### Environment Variables ### Checking a Config
- `TRX_PLUGIN_DIRS`: additional plugin directories (path-separated), used by `--check-config` loads the file, reports every problem it finds — unknown keys,
both server and client. invalid values, listeners fighting over a port — and exits without starting
anything:
```bash
trx-server --check-config --config trx-rs.toml
trx-client --check-config --config trx-rs.toml
```
Unknown keys are warnings by default, so a config written for a newer version
still runs on an older binary. `--strict-config` makes them fatal.
`trx-configurator --check <FILE>` runs the same checks.
### Environment Variables and Secrets
Any string in the config may reference an environment variable as `${VAR}`;
an unset variable is an error rather than an empty value.
Credentials can be kept out of the config entirely by pointing at a file
instead. Every secret has a `*_file` sibling — set one or the other, never
both:
| Inline key | File key | Contents |
|------------|----------|----------|
| `[listen.auth].tokens` | `tokens_file` | one token per line |
| `[[remotes]].auth.token` | `token_file` | the token |
| `[frontends.http.auth].rx_passphrase` | `rx_passphrase_file` | the passphrase |
| `[frontends.http.auth].control_passphrase` | `control_passphrase_file` | the passphrase |
| `[frontends.http_json.auth].tokens` | `tokens_file` | one token per line |
Blank lines and `#` comments are ignored in the list files. A config that holds
credentials inline and is readable by group or others is flagged at startup.
### Server Options ### Server Options
@@ -96,6 +127,7 @@ CLI arguments override config file values.
| Field | Type | Default | Description | | Field | Type | Default | Description |
|-------|------|---------|-------------| |-------|------|---------|-------------|
| `tokens` | string[] | `[]` | Allowed auth tokens (empty = no auth) | | `tokens` | string[] | `[]` | Allowed auth tokens (empty = no auth) |
| `tokens_file` | string | — | Read tokens from this file, one per line |
#### `[audio]` #### `[audio]`
@@ -204,6 +236,29 @@ Notes:
Files are appended in JSON Lines format. Supported date tokens: `%YYYY%`, Files are appended in JSON Lines format. Supported date tokens: `%YYYY%`,
`%MM%`, `%DD%` (UTC). `%MM%`, `%DD%` (UTC).
#### `[decoders]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | string[] | all decoders | Decoders to run for this rig |
| `output_dir` | string | `"$XDG_CACHE_HOME/trx-rs"` | Base directory for decoders that write images |
Valid decoder names: `aprs`, `aprs_hf`, `ais`, `cw`, `ft2`, `ft4`, `ft8`,
`lrpt`, `sstv`, `vdes`, `wefax`, `wspr` — the same names `[[sdr.channels]]`
uses. An unrecognised name is a config error.
Every decoder runs by default, which costs real CPU on a small machine. On a
station that only works digital modes, listing just what you use is worth it:
```toml
[decoders]
enabled = ["ft8", "ft4", "wspr"]
```
`sstv`, `wefax` and `lrpt` write images into a subdirectory of `output_dir`
named after the decoder. `ais` and `vdes` additionally require an SDR channel
configured to feed them.
#### Multi-Rig Configuration #### Multi-Rig Configuration
Use `[[rigs]]` arrays instead of the flat `[rig]` section for multi-rig setups: Use `[[rigs]]` arrays instead of the flat `[rig]` section for multi-rig setups:
@@ -254,6 +309,25 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
| Field | Type | Default | Description | | Field | Type | Default | Description |
|-------|------|---------|-------------| |-------|------|---------|-------------|
| `token` | string | — | Auth token (must not be empty if set) | | `token` | string | — | Auth token (must not be empty if set) |
| `token_file` | string | — | Read the token from this file instead |
#### `[[remotes]]`
Preferred over the single `[remote]` section: one entry per rig, each mapping a
short name to a server and an optional server-side rig id.
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `name` | string | — | Short name used everywhere in the client |
| `url` | string | — | Server address (`host:port`) |
| `rig_id` | string | — | Rig id on a multi-rig server |
| `auth.token` | string | — | Auth token |
| `auth.token_file` | string | — | Read the token from this file instead |
| `poll_interval_ms` | u64 | `750` | State poll interval |
The `name` is the key used by `default_rig_name`, `rigctl.rig_ports`,
`audio.rig_urls`, `audio.rig_ports` and `decode_history_retention_min_by_rig`.
A name in any of those maps that no remote answers to is a config error.
#### `[frontends.http]` #### `[frontends.http]`
@@ -262,6 +336,31 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
| `enabled` | bool | `true` | Enable web UI | | `enabled` | bool | `true` | Enable web UI |
| `listen` | ip | `127.0.0.1` | Bind address | | `listen` | ip | `127.0.0.1` | Bind address |
| `port` | u16 | `8080` | Bind port | | `port` | u16 | `8080` | Bind port |
| `default_rig_name` | string | — | Remote selected on startup |
| `initial_map_zoom` | u8 | `10` | Starting zoom for the APRS map |
| `show_sdr_gain_control` | bool | `true` | Expose the RF gain control |
| `bandplan_enabled` | bool | `true` | Show the bandplan strip |
| `bandplan_region` | string | `"iaru_r1"` | `iaru_r1`, `iaru_r2`, or `iaru_r3` |
| `decode_history_retention_min` | u64 | `1440` | Decode history retention |
| `decode_history_retention_min_by_rig` | table | `{}` | Per-remote retention override |
| `spectrum_coverage_margin_hz` | u32 | `50000` | Centre-retune guard margin |
| `spectrum_usable_span_ratio` | f32 | `0.92` | Usable fraction of the sampled span |
#### `[frontends.http.auth]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `false` | Require a passphrase |
| `rx_passphrase` | string | — | Passphrase granting receive-only access |
| `rx_passphrase_file` | string | — | Read it from this file instead |
| `control_passphrase` | string | — | Passphrase granting full control |
| `control_passphrase_file` | string | — | Read it from this file instead |
| `tx_access_control_enabled` | bool | `true` | Hide TX from unauthenticated users |
| `session_ttl_min` | u64 | `480` | Session lifetime |
| `cookie_secure` | bool | `false` | Set Secure on the session cookie (needs HTTPS) |
| `cookie_same_site` | string | `"Lax"` | `Strict`, `Lax`, or `None` |
With `enabled = true`, at least one passphrase must be set.
#### `[frontends.rigctl]` #### `[frontends.rigctl]`
@@ -269,7 +368,11 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
|-------|------|---------|-------------| |-------|------|---------|-------------|
| `enabled` | bool | `false` | Enable Hamlib rigctl | | `enabled` | bool | `false` | Enable Hamlib rigctl |
| `listen` | ip | `127.0.0.1` | Bind address | | `listen` | ip | `127.0.0.1` | Bind address |
| `port` | u16 | `4532` | Bind port | | `rig_ports` | table | `{}` | Remote name → local port; one listener each |
One listener is started per `rig_ports` entry, each routing to its rig, so
`rig_ports` must name at least one remote when the frontend is enabled. The
older single `port` key and `--rigctl-port` are ignored.
#### `[frontends.http_json]` #### `[frontends.http_json]`
@@ -279,13 +382,17 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
| `listen` | ip | `127.0.0.1` | Bind address | | `listen` | ip | `127.0.0.1` | Bind address |
| `port` | u16 | `0` | Bind port (0 = ephemeral) | | `port` | u16 | `0` | Bind port (0 = ephemeral) |
| `auth.tokens` | string[] | `[]` | Allowed auth tokens | | `auth.tokens` | string[] | `[]` | Allowed auth tokens |
| `auth.tokens_file` | string | — | Read tokens from this file, one per line |
#### `[frontends.audio]` #### `[frontends.audio]`
| Field | Type | Default | Description | | Field | Type | Default | Description |
|-------|------|---------|-------------| |-------|------|---------|-------------|
| `enabled` | bool | `true` | Enable audio client | | `enabled` | bool | `true` | Enable audio client |
| `server_port` | u16 | `4531` | Server audio port | | `server_url` | string | — | Audio endpoint for every remote |
| `rig_urls` | table | `{}` | Remote name → audio URL (wins over `server_url`) |
| `server_port` | u16 | `4531` | Fallback port when no URL is configured |
| `rig_ports` | table | `{}` | Remote name → port; superseded by `rig_urls` |
| `bridge.enabled` | bool | `false` | Enable local CPAL audio bridge | | `bridge.enabled` | bool | `false` | Enable local CPAL audio bridge |
| `bridge.rx_output_device` | string | — | Local playback device | | `bridge.rx_output_device` | string | — | Local playback device |
| `bridge.tx_input_device` | string | — | Local capture device | | `bridge.tx_input_device` | string | — | Local capture device |
@@ -333,13 +440,17 @@ roughly 200 KB/s for 1024 float bins at 20 frames/s.
### CLI Override Summary ### CLI Override Summary
**trx-server:** **trx-server:**
`--config`, `--print-config`, `--rig`, `--access`, `--callsign`, `--listen`, `--config`, `--print-config`, `--check-config`, `--strict-config`, `--rig`,
`--port`. SDR options are file-only. `--access`, `--callsign`, `--listen`, `--port`. SDR options are file-only.
**trx-client:** **trx-client:**
`--config`, `--print-config`, `--url`, `--token`, `--poll-interval`, `--config`, `--print-config`, `--check-config`, `--strict-config`, `--url`,
`--frontend`, `--http-listen`, `--http-port`, `--rigctl-listen`, `--token`, `--poll-interval`, `--rig-id`, `--frontend`, `--http-listen`,
`--rigctl-port`, `--http-json-listen`, `--http-json-port`, `--callsign`. `--http-port`, `--rigctl-listen`, `--http-json-listen`, `--http-json-port`,
`--callsign`.
`--listen` on the server overrides the bind address of both the control
listener and every rig's audio listener.
--- ---
-4
View File
@@ -9,9 +9,5 @@ edition = "2021"
license = "GPL-2.0-or-later" license = "GPL-2.0-or-later"
[dependencies] [dependencies]
serde = { workspace = true }
toml = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
tracing-subscriber = { workspace = true } tracing-subscriber = { workspace = true }
dirs = "6"
thiserror = "2"
-88
View File
@@ -1,88 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
use serde::de::DeserializeOwned;
use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("Failed to read config file {0}: {1}")]
ReadError(PathBuf, String),
#[error("Failed to parse config file {0}: {1}")]
ParseError(PathBuf, String),
}
/// Returns the default search paths for `trx-rs.toml`
/// (current directory → XDG config → /etc).
fn config_search_paths() -> Vec<PathBuf> {
let mut paths = vec![PathBuf::from("trx-rs.toml")];
if let Some(config_dir) = dirs::config_dir() {
paths.push(config_dir.join("trx-rs").join("trx-rs.toml"));
}
paths.push(PathBuf::from("/etc/trx-rs/trx-rs.toml"));
paths
}
/// Extract and deserialize a named section from a TOML file.
///
/// Returns `Ok(Some(cfg))` when the section is present and parses cleanly,
/// `Ok(None)` when the section is absent, or `Err` on I/O / parse failure.
fn load_section_from_file<T: DeserializeOwned>(
path: &Path,
key: &str,
) -> Result<Option<T>, ConfigError> {
let content = std::fs::read_to_string(path)
.map_err(|e| ConfigError::ReadError(path.to_path_buf(), e.to_string()))?;
let table: toml::Table = toml::from_str(&content)
.map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?;
let Some(section) = table.get(key) else {
return Ok(None);
};
// Re-serialize the section then parse as T so all serde defaults apply.
let section_toml = toml::to_string(section)
.map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?;
let cfg = toml::from_str::<T>(&section_toml)
.map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?;
Ok(Some(cfg))
}
/// Trait for loading configuration from a `trx-rs.toml` section.
pub trait ConfigFile: Sized + Default + DeserializeOwned {
/// Section key in `trx-rs.toml` (e.g. `"trx-server"` or `"trx-client"`).
fn section_key() -> &'static str;
/// Load the section from a specific file path.
///
/// Returns an error if the file cannot be read, is not valid TOML, or
/// does not contain the expected `[<section_key>]` header.
fn load_from_file(path: &Path) -> Result<Self, ConfigError> {
load_section_from_file::<Self>(path, Self::section_key())?.ok_or_else(|| {
ConfigError::ParseError(
path.to_path_buf(),
format!("missing [{}] section", Self::section_key()),
)
})
}
/// Search default paths (`trx-rs.toml` in CWD → XDG → /etc) and load
/// the first file that contains the expected section.
///
/// Returns `(config, path_where_found)` or `(Default::default(), None)`
/// when no config file is found.
fn load_from_default_paths() -> Result<(Self, Option<PathBuf>), ConfigError> {
for path in config_search_paths() {
if path.exists() {
if let Some(cfg) = load_section_from_file::<Self>(&path, Self::section_key())? {
return Ok((cfg, Some(path)));
}
}
}
Ok((Self::default(), None))
}
}
+4 -4
View File
@@ -2,12 +2,12 @@
// //
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
pub mod config; //! Shared application helpers.
//!
//! Configuration types and their loader live in the `trx-config` crate.
pub mod logging; pub mod logging;
pub mod shared_config;
pub mod util; pub mod util;
pub use config::{ConfigError, ConfigFile};
pub use logging::init_logging; pub use logging::init_logging;
pub use shared_config::{validate_log_level, validate_tokens};
pub use util::normalize_name; pub use util::normalize_name;
+1
View File
@@ -21,6 +21,7 @@ uuid = { workspace = true }
cpal = "0.15" cpal = "0.15"
opus = "0.3" opus = "0.3"
trx-app = { path = "../trx-app" } trx-app = { path = "../trx-app" }
trx-config = { path = "../trx-config" }
trx-core = { path = "../trx-core" } trx-core = { path = "../trx-core" }
trx-protocol = { path = "../trx-protocol" } trx-protocol = { path = "../trx-protocol" }
trx-frontend = { path = "trx-frontend" } trx-frontend = { path = "trx-frontend" }
File diff suppressed because it is too large Load Diff
+125 -8
View File
@@ -20,6 +20,7 @@ use tokio::task::JoinHandle;
use tracing::{error, info}; use tracing::{error, info};
use trx_app::{init_logging, normalize_name}; use trx_app::{init_logging, normalize_name};
use trx_config::ConfigFile;
use trx_core::audio::AudioStreamInfo; use trx_core::audio::AudioStreamInfo;
use trx_core::decode::DecodedMessage; use trx_core::decode::DecodedMessage;
@@ -50,6 +51,12 @@ struct Cli {
/// Print example configuration and exit /// Print example configuration and exit
#[arg(long = "print-config")] #[arg(long = "print-config")]
print_config: bool, print_config: bool,
/// Treat unknown configuration keys as a fatal error
#[arg(long = "strict-config")]
strict_config: bool,
/// Validate the configuration and exit without starting anything
#[arg(long = "check-config")]
check_config: bool,
/// Remote server URL (host:port) /// Remote server URL (host:port)
#[arg(short = 'u', long = "url")] #[arg(short = 'u', long = "url")]
url: Option<String>, url: Option<String>,
@@ -74,7 +81,7 @@ struct Cli {
/// rigctl frontend listen address /// rigctl frontend listen address
#[arg(long = "rigctl-listen")] #[arg(long = "rigctl-listen")]
rigctl_listen: Option<IpAddr>, rigctl_listen: Option<IpAddr>,
/// rigctl frontend listen port /// Deprecated: ignored, use [frontends.rigctl].rig_ports
#[arg(long = "rigctl-port")] #[arg(long = "rigctl-port")]
rigctl_port: Option<u16>, rigctl_port: Option<u16>,
/// JSON TCP frontend listen address /// JSON TCP frontend listen address
@@ -109,6 +116,65 @@ async fn main() -> DynResult<()> {
Ok(()) Ok(())
} }
/// `--check-config`: report everything wrong with the configuration and exit.
///
/// Unlike startup, this reports every problem it finds rather than stopping at
/// the first, so a config can be fixed in one pass. The file is checked as
/// written, without CLI overrides.
fn check_config(loaded: &trx_config::ConfigLoad<ClientConfig>) -> DynResult<()> {
match &loaded.path {
Some(path) => println!("{}", path.display()),
None => println!("(no config file found; checking built-in defaults)"),
}
let mut warnings: Vec<String> = loaded.unknown_keys.iter().map(|k| k.to_string()).collect();
warnings.extend(ClientConfig::deprecations(&loaded.present_keys));
let mut cfg = loaded.config.clone();
let mut errors = Vec::new();
if let Err(e) = cfg.resolve_secrets(loaded.path.as_deref()) {
errors.push(e);
}
let cfg = &cfg;
let remotes = cfg.resolved_remotes();
if remotes.is_empty() {
warnings.push(
"no remotes configured; --url will be required at startup (add [[remotes]] entries)"
.to_string(),
);
}
errors.extend(cfg.validate_all());
if !remotes.is_empty() {
errors.extend(cfg.validate_resolved_all(&remotes));
}
for w in &warnings {
println!(" warning: {}", w);
}
for e in &errors {
println!(" error: {}", e);
}
if errors.is_empty() {
println!(
" OK: {} remote(s) configured: {}",
remotes.len(),
remotes
.iter()
.map(|r| r.name.as_str())
.collect::<Vec<_>>()
.join(", ")
);
if !warnings.is_empty() {
println!(" {} warning(s)", warnings.len());
}
Ok(())
} else {
Err(format!("{} error(s), {} warning(s)", errors.len(), warnings.len()).into())
}
}
/// Holds the state needed after async initialization completes. /// Holds the state needed after async initialization completes.
struct AppState { struct AppState {
shutdown_tx: watch::Sender<bool>, shutdown_tx: watch::Sender<bool>,
@@ -135,20 +201,44 @@ async fn async_init() -> DynResult<AppState> {
std::process::exit(0); std::process::exit(0);
} }
let (cfg, config_path) = if let Some(ref path) = cli.config { let loaded = if let Some(ref path) = cli.config {
let cfg = ClientConfig::load_from_file(path)?; ClientConfig::load_from_file(path)?
(cfg, Some(path.clone()))
} else { } else {
ClientConfig::load_from_default_paths()? ClientConfig::load_from_default_paths()?
}; };
cfg.validate() let config_path = loaded.path.clone();
.map_err(|e| format!("Invalid client configuration: {}", e))?;
init_logging(cfg.general.log_level.as_deref()); if cli.check_config {
match check_config(&loaded) {
Ok(()) => std::process::exit(0),
Err(e) => {
eprintln!("{}", e);
std::process::exit(1);
}
}
}
// Logging comes up before any config complaint so the warnings are visible.
init_logging(loaded.config.general.log_level.as_deref());
if let Some(ref path) = config_path { if let Some(ref path) = config_path {
info!("Loaded configuration from {}", path.display()); info!("Loaded configuration from {}", path.display());
} }
loaded.report_unknown_keys(cli.strict_config)?;
loaded.report_deprecations();
if cli.rigctl_port.is_some() {
tracing::warn!(
"--rigctl-port is ignored; give each rig its own listener via \
[frontends.rigctl].rig_ports"
);
}
let mut cfg = loaded.config;
// Secrets configured as *_file are read before validation, so everything
// downstream sees resolved values.
cfg.resolve_secrets(config_path.as_deref())?;
cfg.validate()
.map_err(|e| format!("Invalid client configuration: {}", e))?;
frontend_runtime.http_auth.tokens = cfg frontend_runtime.http_auth.tokens = cfg
.frontends .frontends
@@ -202,7 +292,10 @@ async fn async_init() -> DynResult<AppState> {
name, name,
url: url.clone(), url: url.clone(),
rig_id, rig_id,
auth: config::RemoteAuthConfig { token }, auth: config::RemoteAuthConfig {
token,
token_file: None,
},
poll_interval_ms, poll_interval_ms,
}] }]
} else { } else {
@@ -263,6 +356,30 @@ async fn async_init() -> DynResult<AppState> {
.http_json_listen .http_json_listen
.unwrap_or(cfg.frontends.http_json.listen); .unwrap_or(cfg.frontends.http_json.listen);
let http_json_port = cli.http_json_port.unwrap_or(cfg.frontends.http_json.port); let http_json_port = cli.http_json_port.unwrap_or(cfg.frontends.http_json.port);
// Fold the CLI overrides back into the config so validation and the
// frontends agree on what is about to be bound.
cfg.frontends.http.enabled = frontends.iter().any(|f| f == "http");
cfg.frontends.rigctl.enabled = frontends.iter().any(|f| f == "rigctl");
cfg.frontends.http_json.enabled = frontends.iter().any(|f| f == "httpjson");
cfg.frontends.http.listen = http_listen;
cfg.frontends.http.port = http_port;
cfg.frontends.rigctl.listen = rigctl_listen;
cfg.frontends.http_json.listen = http_json_listen;
cfg.frontends.http_json.port = http_json_port;
// Second validation phase: the per-rig maps are keyed by remote short name,
// so they can only be checked once the remote list is final.
if cli.url.is_none() {
cfg.validate_resolved(&resolved_remotes)
.map_err(|e| format!("Invalid client configuration: {}", e))?;
} else {
// --url replaces the configured remotes outright, so only the socket
// checks still apply.
trx_config::shared::check_socket_conflicts(&cfg.bound_sockets())
.map_err(|e| format!("Invalid client configuration: {}", e))?;
}
let callsign = cli let callsign = cli
.callsign .callsign
.clone() .clone()
+4 -98
View File
@@ -22,30 +22,16 @@ use trx_protocol::rig_command_to_client;
use trx_protocol::types::RigEntry; use trx_protocol::types::RigEntry;
use trx_protocol::{ClientCommand, ClientEnvelope, ClientResponse, MeterUpdate, SpectrumFrame}; use trx_protocol::{ClientCommand, ClientEnvelope, ClientResponse, MeterUpdate, SpectrumFrame};
const DEFAULT_REMOTE_PORT: u16 = 4530; // Endpoint parsing lives in `trx-config` so config validation and the
const DEFAULT_AUDIO_PORT: u16 = 4531; // connection code agree on what a URL means.
pub use trx_config::url::{parse_audio_url, parse_remote_url, RemoteEndpoint};
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
const IO_TIMEOUT: Duration = Duration::from_secs(15); const IO_TIMEOUT: Duration = Duration::from_secs(15);
const SPECTRUM_IO_TIMEOUT: Duration = Duration::from_secs(3); const SPECTRUM_IO_TIMEOUT: Duration = Duration::from_secs(3);
const MAX_JSON_LINE_BYTES: usize = 256 * 1024; const MAX_JSON_LINE_BYTES: usize = 256 * 1024;
const MAX_CONSECUTIVE_POLL_FAILURES: u32 = 3; const MAX_CONSECUTIVE_POLL_FAILURES: u32 = 3;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RemoteEndpoint {
pub host: String,
pub port: u16,
}
impl RemoteEndpoint {
pub fn connect_addr(&self) -> String {
if self.host.contains(':') && !self.host.starts_with('[') {
format!("[{}]:{}", self.host, self.port)
} else {
format!("{}:{}", self.host, self.port)
}
}
}
// Default spectrum cadence when a config does not specify one. Both the push // Default spectrum cadence when a config does not specify one. Both the push
// stream and the poll fallback run at the configured rate; see // stream and the poll fallback run at the configured rate; see
// `[[remotes]].spectrum_interval_ms`. // `[[remotes]].spectrum_interval_ms`.
@@ -1322,86 +1308,6 @@ async fn read_limited_line<R: AsyncBufRead + Unpin>(
} }
} }
pub fn parse_remote_url(url: &str) -> Result<RemoteEndpoint, String> {
parse_endpoint_url(url, DEFAULT_REMOTE_PORT, "remote")
}
pub fn parse_audio_url(url: &str) -> Result<RemoteEndpoint, String> {
parse_endpoint_url(url, DEFAULT_AUDIO_PORT, "audio")
}
fn parse_endpoint_url(url: &str, default_port: u16, kind: &str) -> Result<RemoteEndpoint, String> {
let trimmed = url.trim();
if trimmed.is_empty() {
return Err(format!("{kind} url is empty"));
}
let addr = trimmed
.strip_prefix("tcp://")
.or_else(|| trimmed.strip_prefix("http-json://"))
.or_else(|| trimmed.strip_prefix("audio://"))
.unwrap_or(trimmed);
parse_host_port(addr, default_port, kind)
}
fn parse_host_port(input: &str, default_port: u16, kind: &str) -> Result<RemoteEndpoint, String> {
if let Some(rest) = input.strip_prefix('[') {
let closing = rest
.find(']')
.ok_or_else(|| format!("invalid {kind} url: missing closing ']' for IPv6 host"))?;
let host = &rest[..closing];
let remainder = &rest[closing + 1..];
if host.is_empty() {
return Err(format!("invalid {kind} url: host is empty"));
}
let port = if remainder.is_empty() {
default_port
} else if let Some(port_str) = remainder.strip_prefix(':') {
parse_port(port_str, kind)?
} else {
return Err(format!("invalid {kind} url: expected ':<port>' after ']'"));
};
return Ok(RemoteEndpoint {
host: host.to_string(),
port,
});
}
if input.contains(':') {
if input.matches(':').count() > 1 {
return Err(format!(
"invalid {kind} url: IPv6 host must be bracketed like [::1]:4532"
));
}
let (host, port_str) = input
.rsplit_once(':')
.ok_or_else(|| format!("invalid {kind} url: expected host:port"))?;
if host.is_empty() {
return Err(format!("invalid {kind} url: host is empty"));
}
return Ok(RemoteEndpoint {
host: host.to_string(),
port: parse_port(port_str, kind)?,
});
}
Ok(RemoteEndpoint {
host: input.to_string(),
port: default_port,
})
}
fn parse_port(port_str: &str, kind: &str) -> Result<u16, String> {
let port: u16 = port_str
.parse()
.map_err(|_| format!("invalid {kind} port: '{port_str}'"))?;
if port == 0 {
return Err(format!("invalid {kind} port: 0"));
}
Ok(port)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[allow(unused_imports)] #[allow(unused_imports)]
+24
View File
@@ -0,0 +1,24 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
[package]
name = "trx-config"
version.workspace = true
edition = "2021"
license = "GPL-2.0-or-later"
[dependencies]
serde = { workspace = true, features = ["derive"] }
toml = { workspace = true }
tracing = { workspace = true }
dirs = "6"
thiserror = "2"
trx-core = { path = "../trx-core" }
trx-decode-log = { path = "../decoders/trx-decode-log" }
trx-reporting = { path = "../trx-reporting" }
serde_ignored = "0.1"
toml_edit = "0.22"
[dev-dependencies]
tempfile = "3"
@@ -0,0 +1,29 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Regenerate `trx-rs.toml.example` from the config structs.
//!
//! Run from anywhere in the workspace:
//!
//! ```text
//! cargo run -p trx-config --example generate_example
//! ```
//!
//! A test in `trx_config::example` fails when the checked-in file no longer
//! matches, which is the reminder to run this.
use std::path::PathBuf;
fn main() -> std::io::Result<()> {
let target: PathBuf = std::env::args()
.nth(1)
.map(PathBuf::from)
.unwrap_or_else(|| {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../trx-rs.toml.example")
});
std::fs::write(&target, trx_config::example::combined_example())?;
println!("Wrote {}", target.display());
Ok(())
}
File diff suppressed because it is too large Load Diff
+295
View File
@@ -0,0 +1,295 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Generating the example configuration from the config structs.
//!
//! `trx-rs.toml.example` used to be maintained by hand and had fallen years
//! behind the code — no `[[rigs]]`, no `[[remotes]]`, no `[timeouts]`, no
//! bandplan settings. It is now produced from the structs themselves, so a new
//! field appears in the example the moment it exists, and a test fails if the
//! checked-in copy drifts.
//!
//! Section comments come from the table below. A section without an entry is
//! still emitted — only its explanatory text is missing — so forgetting to add
//! one can never drop a setting from the example.
use toml_edit::{DocumentMut, Item};
use crate::{ClientConfig, ServerConfig};
const HEADER: &str = "\
# trx-rs example configuration
#
# Generated from the config structs; regenerate with:
# cargo run -p trx-config --example generate_example
#
# Both sections are optional: trx-server reads [trx-server], trx-client reads
# [trx-client], and either may live in its own file with the section header
# omitted. Any string may use ${ENV_VAR}, and credentials may be moved out of
# this file with the matching *_file keys.
#
# Check a config without starting anything:
# trx-server --check-config --config trx-rs.toml
# trx-client --check-config --config trx-rs.toml
";
/// Explanatory comments for config sections, keyed by dotted path.
const SECTION_COMMENTS: &[(&str, &str)] = &[
("trx-server", "Server: drives the radio hardware."),
(
"trx-server.general",
"Station identity. Coordinates feed PSKReporter and the map.",
),
(
"trx-server.rig",
"Single-rig layout. For several radios, delete this and use [[rigs]].",
),
(
"trx-server.rig.access",
"How to reach the radio: serial, tcp, or sdr.",
),
("trx-server.behavior", "CAT polling and retry behaviour."),
(
"trx-server.listen",
"JSON control listener that trx-client connects to.",
),
(
"trx-server.listen.auth",
"Tokens clients must present. Empty means no authentication.\n\
Use tokens_file = \"/etc/trx-rs/tokens\" to keep them out of this file.",
),
("trx-server.audio", "Opus audio stream for trx-client."),
(
"trx-server.decoders",
"Which decoders run. Trimming this list saves real CPU on small boxes.\n\
Valid names: aprs, aprs_hf, ais, cw, ft2, ft4, ft8, lrpt, sstv, vdes, wefax, wspr.\n\
output_dir sets where sstv/wefax/lrpt write images (default: user cache dir).",
),
(
"trx-server.pskreporter",
"Report FT8/FT4/WSPR spots to pskreporter.info.",
),
(
"trx-server.aprsfi",
"Forward received APRS frames to APRS-IS.",
),
(
"trx-server.decode_logs",
"Write decodes to JSON Lines files.",
),
(
"trx-server.sdr",
"SoapySDR pipeline; used when [rig.access] type = \"sdr\".",
),
(
"trx-server.sdr.gain",
"\"auto\" for hardware AGC, or \"manual\".",
),
(
"trx-server.sdr.squelch",
"Software squelch on demodulated audio.",
),
(
"trx-server.sdr.noise_blanker",
"Impulse-noise suppression on the IQ stream.",
),
(
"trx-server.timeouts",
"Timeout and buffer tuning. The defaults suit most setups.",
),
("trx-client", "Client: exposes the radio to users."),
("trx-client.general", "Labels shown in the web UI."),
(
"trx-client.remote",
"Legacy single-remote form; prefer [[remotes]] below.",
),
(
"trx-client.frontends.http",
"Web UI. default_rig_name and the per-rig maps are keyed by the\n\
[[remotes]] name, not the server-side rig id.",
),
(
"trx-client.frontends.http.auth",
"Passphrase login for the web UI. rx_passphrase_file and\n\
control_passphrase_file keep the secrets out of this file.",
),
(
"trx-client.frontends.rigctl",
"Hamlib-compatible TCP interface, one listener per rig.",
),
(
"trx-client.frontends.http_json",
"JSON-over-TCP control interface.",
),
(
"trx-client.frontends.audio",
"Where to fetch the audio stream from.",
),
(
"trx-client.frontends.audio.bridge",
"Play RX audio on a local sound device and capture TX from one.",
),
];
/// Render the combined `trx-rs.toml.example` contents.
pub fn combined_example() -> String {
let mut doc = DocumentMut::new();
doc.decor_mut().set_prefix(HEADER);
doc.insert("trx-server", section_item(&ServerConfig::example_config()));
doc.insert("trx-client", section_item(&ClientConfig::example_config()));
// Each section was serialized on its own, so both carry table positions
// starting at zero and would otherwise render interleaved.
renumber_tables(&mut doc);
for (path, comment) in SECTION_COMMENTS {
annotate(&mut doc, path, comment);
}
doc.to_string()
}
/// Renumber every table so the document renders in tree order.
fn renumber_tables(doc: &mut DocumentMut) {
fn walk(item: &mut Item, next: &mut usize) {
match item {
Item::Table(table) => {
table.set_position(*next);
*next += 1;
for (_, child) in table.iter_mut() {
walk(child, next);
}
}
Item::ArrayOfTables(array) => {
for table in array.iter_mut() {
table.set_position(*next);
*next += 1;
for (_, child) in table.iter_mut() {
walk(child, next);
}
}
}
_ => {}
}
}
let mut next = 0;
for (_, item) in doc.as_table_mut().iter_mut() {
walk(item, &mut next);
}
}
/// Serialize one config into a toml_edit table.
fn section_item<T: serde::Serialize>(config: &T) -> Item {
let rendered = toml::to_string_pretty(config).unwrap_or_default();
let doc: DocumentMut = rendered.parse().expect("serialized config must re-parse");
Item::Table(doc.as_table().clone())
}
/// Attach a comment above the table at `path`, if it exists.
fn annotate(doc: &mut DocumentMut, path: &str, comment: &str) {
let mut item: Option<&mut Item> = None;
for segment in path.split('.') {
let next = match item {
None => doc.get_mut(segment),
Some(current) => current.as_table_mut().and_then(|t| t.get_mut(segment)),
};
match next {
Some(found) => item = Some(found),
None => return,
}
}
let Some(table) = item.and_then(|i| i.as_table_mut()) else {
return;
};
let body: String = comment
.lines()
.map(|line| format!("# {}\n", line.trim_start()))
.collect();
table.decor_mut().set_prefix(format!("\n{body}"));
}
#[cfg(test)]
mod tests {
use super::*;
/// The checked-in example must match what the structs produce, so a new
/// config field cannot land without showing up in the example.
#[test]
fn test_checked_in_example_is_up_to_date() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../trx-rs.toml.example")
.canonicalize()
.expect("example file must exist");
let on_disk = std::fs::read_to_string(&path).expect("example file must be readable");
assert_eq!(
on_disk,
combined_example(),
"trx-rs.toml.example is out of date; regenerate with \
`cargo run -p trx-config --example generate_example`"
);
}
/// Nothing in the example may be derived from the machine that generated
/// it: [decode_logs].dir defaults to the running user's cache directory,
/// which made the generated file differ between a developer's laptop and
/// CI, and the up-to-date test fail for everyone but its author.
#[test]
fn test_example_has_no_machine_specific_paths() {
let example = combined_example();
for dir in [dirs::home_dir(), dirs::cache_dir(), dirs::config_dir()]
.into_iter()
.flatten()
{
let dir = dir.to_string_lossy().into_owned();
assert!(
!example.contains(&dir),
"the example contains this machine's {dir}; pin the value in example_config()"
);
}
}
#[test]
fn test_example_loads_and_validates() {
let mut file = tempfile::NamedTempFile::new().unwrap();
std::io::Write::write_all(&mut file, combined_example().as_bytes()).unwrap();
let server = ServerConfig::load_from_file(file.path()).expect("server section loads");
assert!(
server.unknown_keys.is_empty(),
"the generated example must not contain unknown keys: {:?}",
server.unknown_keys
);
server.config.validate().expect("server section validates");
let client = ClientConfig::load_from_file(file.path()).expect("client section loads");
assert!(
client.unknown_keys.is_empty(),
"the generated example must not contain unknown keys: {:?}",
client.unknown_keys
);
client.config.validate().expect("client section validates");
}
/// Every section that gained a comment must still exist under that path.
#[test]
fn test_section_comments_match_real_sections() {
let doc: DocumentMut = combined_example().parse().unwrap();
for (path, _) in SECTION_COMMENTS {
let mut item = None;
for segment in path.split('.') {
item = match item {
None => doc.get(segment),
Some(current) => current.as_table().and_then(|t| t.get(segment)),
};
assert!(
item.is_some(),
"commented section [{path}] no longer exists"
);
}
}
}
}
+252
View File
@@ -0,0 +1,252 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Loading a config section out of a TOML file.
//!
//! Two file shapes are accepted:
//!
//! - **Sectioned** — a combined `trx-rs.toml` with `[trx-server]` and/or
//! `[trx-client]` tables. This is what `--print-config` and
//! `trx-configurator` emit.
//! - **Bare** — a standalone file whose root *is* the section, i.e. `[general]`
//! and `[rig]` at the top level. Hand-written per-binary configs use this.
//!
//! A file that carries some other component's section but not ours is treated
//! as "section absent" rather than as a bare file, so loading a client-only
//! config with the server reports the missing section instead of silently
//! falling back to defaults.
use serde::de::DeserializeOwned;
use serde::Serialize;
use std::path::{Path, PathBuf};
use thiserror::Error;
use std::collections::BTreeSet;
use crate::unknown::{describe, flatten_paths, UnknownKey};
/// Every section key that may appear at the root of a combined config file.
pub const SECTION_KEYS: &[&str] = &["trx-server", "trx-client"];
/// A loaded config plus what the loader noticed on the way in.
#[derive(Debug, Clone)]
pub struct ConfigLoad<T> {
/// The deserialized configuration.
pub config: T,
/// File the config came from; `None` when nothing was found and defaults
/// were used.
pub path: Option<PathBuf>,
/// Keys present in the file that no config field claimed.
pub unknown_keys: Vec<UnknownKey>,
/// Every key path the file actually set. Defaults are indistinguishable
/// from explicit values once deserialized, so deprecation checks need this.
pub present_keys: BTreeSet<String>,
}
impl<T: ConfigFile> ConfigLoad<T> {
/// Log a warning for every deprecated key the file sets.
pub fn report_deprecations(&self) {
for message in T::deprecations(&self.present_keys) {
tracing::warn!("{}", message);
}
}
}
impl<T> ConfigLoad<T> {
/// Log every unknown key as a warning. With `strict`, also return an error
/// so the caller can refuse to start.
pub fn report_unknown_keys(&self, strict: bool) -> Result<(), String> {
for key in &self.unknown_keys {
tracing::warn!("{}", key);
}
if strict && !self.unknown_keys.is_empty() {
return Err(format!(
"{} unknown config key(s); refusing to start because --strict-config is set",
self.unknown_keys.len()
));
}
Ok(())
}
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("Failed to read config file {0}: {1}")]
ReadError(PathBuf, String),
#[error("Failed to parse config file {0}: {1}")]
ParseError(PathBuf, String),
}
/// Returns the default search paths for `trx-rs.toml`
/// (current directory → XDG config → /etc).
fn config_search_paths() -> Vec<PathBuf> {
let mut paths = vec![PathBuf::from("trx-rs.toml")];
if let Some(config_dir) = dirs::config_dir() {
paths.push(config_dir.join("trx-rs").join("trx-rs.toml"));
}
paths.push(PathBuf::from("/etc/trx-rs/trx-rs.toml"));
paths
}
/// Pick the table holding `key`'s settings out of a parsed document.
///
/// Returns the named section when present, the whole document when it carries
/// no section headers at all (a bare standalone file), or `None` when the file
/// is sectioned but has no section for `key`.
fn select_section(table: &toml::Table, key: &str) -> Option<toml::Value> {
if let Some(section) = table.get(key) {
return Some(section.clone());
}
let is_sectioned = SECTION_KEYS.iter().any(|k| table.contains_key(*k));
if is_sectioned {
return None;
}
Some(toml::Value::Table(table.clone()))
}
/// Extract and deserialize a named section from a TOML file.
///
/// Returns `Ok(Some((cfg, unknown_keys)))` when the section is present and
/// parses cleanly, `Ok(None)` when the section is absent, or `Err` on I/O /
/// parse failure.
type LoadedSection<T> = (T, Vec<UnknownKey>, BTreeSet<String>);
fn load_section_from_file<T: ConfigFile>(
path: &Path,
key: &str,
) -> Result<Option<LoadedSection<T>>, ConfigError> {
let content = std::fs::read_to_string(path)
.map_err(|e| ConfigError::ReadError(path.to_path_buf(), e.to_string()))?;
let table: toml::Table = toml::from_str(&content)
.map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?;
let Some(mut section) = select_section(&table, key) else {
return Ok(None);
};
// ${VAR} references are expanded before deserializing, so any string in the
// file can come from the environment.
crate::secrets::expand_env_vars(&mut section)
.map_err(|e| ConfigError::ParseError(path.to_path_buf(), e))?;
let present_keys = flatten_paths(&section);
// Deserialize straight from the TOML value so serde applies every default,
// recording any key no field claimed.
let mut ignored: Vec<String> = Vec::new();
let cfg: T = serde_ignored::deserialize(section, |path| ignored.push(path.to_string()))
.map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?;
Ok(Some((
cfg,
describe(&ignored, &T::reference_value()),
present_keys,
)))
}
/// Trait for loading configuration from a `trx-rs.toml` section.
pub trait ConfigFile: Sized + Default + DeserializeOwned + Serialize {
/// Section key in `trx-rs.toml` (e.g. `"trx-server"` or `"trx-client"`).
fn section_key() -> &'static str;
/// Warnings for deprecated keys the file sets, given every key path present
/// in it. Defaults to none.
fn deprecations(_present_keys: &BTreeSet<String>) -> Vec<String> {
Vec::new()
}
/// A TOML rendering of a populated config, used to suggest corrections for
/// unknown keys. Implementations should fill in list-valued sections such
/// as `[[rigs]]` so keys nested inside them can be suggested too.
fn reference_value() -> toml::Value {
toml::Value::try_from(Self::default())
.unwrap_or_else(|_| toml::Value::Table(toml::Table::new()))
}
/// Load the section from a specific file path.
///
/// Accepts both a sectioned file (`[<section_key>]` at the root) and a bare
/// file whose root is the section itself. Returns an error if the file
/// cannot be read, is not valid TOML, or is sectioned for some other
/// component only.
fn load_from_file(path: &Path) -> Result<ConfigLoad<Self>, ConfigError> {
let (config, unknown_keys, present_keys) =
load_section_from_file::<Self>(path, Self::section_key())?.ok_or_else(|| {
ConfigError::ParseError(
path.to_path_buf(),
format!("missing [{}] section", Self::section_key()),
)
})?;
Ok(ConfigLoad {
config,
path: Some(path.to_path_buf()),
unknown_keys,
present_keys,
})
}
/// Search default paths (`trx-rs.toml` in CWD → XDG → /etc) and load
/// the first file that contains the expected section.
///
/// Falls back to `Self::default()` with no path when nothing is found.
fn load_from_default_paths() -> Result<ConfigLoad<Self>, ConfigError> {
for path in config_search_paths() {
if path.exists() {
if let Some((config, unknown_keys, present_keys)) =
load_section_from_file::<Self>(&path, Self::section_key())?
{
return Ok(ConfigLoad {
config,
path: Some(path),
unknown_keys,
present_keys,
});
}
}
}
Ok(ConfigLoad {
config: Self::default(),
path: None,
unknown_keys: Vec::new(),
present_keys: BTreeSet::new(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn table(s: &str) -> toml::Table {
toml::from_str(s).unwrap()
}
#[test]
fn test_select_section_prefers_named_section() {
let t = table("[trx-server]\n[trx-server.general]\ncallsign = \"W1AW\"\n");
let section = select_section(&t, "trx-server").unwrap();
assert!(section.get("general").is_some());
}
#[test]
fn test_select_section_falls_back_to_root_for_bare_file() {
let t = table("[general]\ncallsign = \"W1AW\"\n");
let section = select_section(&t, "trx-server").unwrap();
assert!(section.get("general").is_some());
}
#[test]
fn test_select_section_absent_when_other_section_present() {
let t = table("[trx-client]\n[trx-client.general]\ncallsign = \"W1AW\"\n");
assert!(select_section(&t, "trx-server").is_none());
}
#[test]
fn test_select_section_empty_file_is_bare() {
let t = table("");
assert!(select_section(&t, "trx-server").is_some());
}
}
+26
View File
@@ -0,0 +1,26 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Configuration types shared by `trx-server`, `trx-client` and
//! `trx-configurator`.
//!
//! Keeping the structs, the loader and the validators in one crate means the
//! setup wizard checks a config with exactly the same code the binaries load
//! it with, so the two can never drift apart.
pub mod client;
pub mod example;
pub mod file;
pub mod secrets;
pub mod server;
pub mod shared;
pub mod unknown;
pub mod url;
pub use client::ClientConfig;
pub use file::{ConfigError, ConfigFile, ConfigLoad};
pub use server::ServerConfig;
pub use shared::{validate_log_level, validate_tokens};
pub use unknown::UnknownKey;
pub use url::{parse_audio_url, parse_remote_url, RemoteEndpoint};
+297
View File
@@ -0,0 +1,297 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Keeping credentials out of the config file.
//!
//! Tokens and passphrases used to have exactly one representation: written in
//! plain text in `trx-rs.toml`, which is awkward when the config is deployed by
//! a config-management tool, committed to a private repo, or shared between
//! machines. Two alternatives are offered:
//!
//! - `${VAR}` anywhere in a config string, expanded from the environment.
//! - A `*_file` sibling of any secret key, read from disk at startup.
//!
//! Plus a nudge: a config that holds secrets and is readable by group or others
//! gets a warning.
use std::path::Path;
/// Expand `${VAR}` references in every string in a TOML value.
///
/// An unset variable is an error rather than an empty string — a silently blank
/// passphrase is the kind of thing that disables authentication by accident.
pub fn expand_env_vars(value: &mut toml::Value) -> Result<(), String> {
match value {
toml::Value::String(s) => {
if let Some(expanded) = expand_str(s)? {
*s = expanded;
}
}
toml::Value::Table(table) => {
for (_, child) in table.iter_mut() {
expand_env_vars(child)?;
}
}
toml::Value::Array(items) => {
for item in items.iter_mut() {
expand_env_vars(item)?;
}
}
_ => {}
}
Ok(())
}
/// Expand `${VAR}` in one string; `None` when there was nothing to expand.
fn expand_str(input: &str) -> Result<Option<String>, String> {
if !input.contains("${") {
return Ok(None);
}
let mut out = String::with_capacity(input.len());
let mut rest = input;
while let Some(start) = rest.find("${") {
out.push_str(&rest[..start]);
let after = &rest[start + 2..];
let Some(end) = after.find('}') else {
// Unterminated: leave the rest exactly as written.
out.push_str(&rest[start..]);
return Ok(Some(out));
};
let name = &after[..end];
if name.is_empty() || !name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') {
// Not a variable reference; pass it through untouched.
out.push_str(&rest[start..start + 2 + end + 1]);
} else {
let value = std::env::var(name)
.map_err(|_| format!("config references unset environment variable ${{{name}}}"))?;
out.push_str(&value);
}
rest = &after[end + 1..];
}
out.push_str(rest);
Ok(Some(out))
}
/// Read a single secret from a file: the whole file, trimmed.
pub fn read_secret_file(path: &str, what: &str) -> Result<String, String> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("{what}: cannot read secret file {path}: {e}"))?;
let secret = content.trim().to_string();
if secret.is_empty() {
return Err(format!("{what}: secret file {path} is empty"));
}
warn_if_group_readable(Path::new(path), what);
Ok(secret)
}
/// Read a list of secrets, one per line. Blank lines and `#` comments are
/// skipped.
pub fn read_secret_list_file(path: &str, what: &str) -> Result<Vec<String>, String> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("{what}: cannot read secret file {path}: {e}"))?;
let secrets: Vec<String> = content
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.map(str::to_string)
.collect();
if secrets.is_empty() {
return Err(format!("{what}: secret file {path} contains no entries"));
}
warn_if_group_readable(Path::new(path), what);
Ok(secrets)
}
/// Fill `inline` from `file` when the config used the `*_file` form.
///
/// Setting both is an error: which one wins would be a guess.
pub fn resolve_secret(
inline: &mut Option<String>,
file: &Option<String>,
what: &str,
) -> Result<(), String> {
let Some(path) = file else {
return Ok(());
};
if inline.is_some() {
return Err(format!(
"{what}: set either the value or its _file form, not both"
));
}
*inline = Some(read_secret_file(path, what)?);
Ok(())
}
/// Fill a token list from `file` when the config used the `*_file` form.
pub fn resolve_secret_list(
inline: &mut Vec<String>,
file: &Option<String>,
what: &str,
) -> Result<(), String> {
let Some(path) = file else {
return Ok(());
};
if !inline.is_empty() {
return Err(format!(
"{what}: set either the value or its _file form, not both"
));
}
*inline = read_secret_list_file(path, what)?;
Ok(())
}
/// Warn when a file holding secrets is readable beyond its owner.
///
/// Advisory only: plenty of valid setups (a dedicated service user, an
/// immutable image) are fine, so this never fails the load.
pub fn warn_if_group_readable(path: &Path, what: &str) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let Ok(meta) = std::fs::metadata(path) else {
return;
};
let mode = meta.permissions().mode() & 0o077;
if mode != 0 {
tracing::warn!(
"{} is readable by group or others (mode {:o}); it holds secrets ({}). \
Consider: chmod 600 {}",
path.display(),
meta.permissions().mode() & 0o777,
what,
path.display()
);
}
}
#[cfg(not(unix))]
{
let _ = (path, what);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn test_expand_leaves_plain_strings_alone() {
assert_eq!(expand_str("plain").unwrap(), None);
assert_eq!(expand_str("TRXRS-%YYYY%.log").unwrap(), None);
}
#[test]
fn test_expand_substitutes_variable() {
std::env::set_var("TRX_TEST_TOKEN", "s3cret");
assert_eq!(
expand_str("Bearer ${TRX_TEST_TOKEN}!").unwrap().as_deref(),
Some("Bearer s3cret!")
);
}
#[test]
fn test_expand_errors_on_unset_variable() {
let err = expand_str("${TRX_DEFINITELY_NOT_SET_12345}").unwrap_err();
assert!(err.contains("unset environment variable"), "{err}");
}
#[test]
fn test_expand_passes_through_non_variables() {
assert_eq!(
expand_str("${not a var}").unwrap().as_deref(),
Some("${not a var}")
);
assert_eq!(
expand_str("unterminated ${VAR").unwrap().as_deref(),
Some("unterminated ${VAR")
);
}
#[test]
fn test_expand_walks_nested_tables_and_arrays() {
std::env::set_var("TRX_TEST_HOST", "radio.example.com");
let mut value: toml::Value = toml::from_str(
r#"
[remote]
url = "${TRX_TEST_HOST}:4530"
hosts = ["${TRX_TEST_HOST}"]
"#,
)
.unwrap();
expand_env_vars(&mut value).unwrap();
assert_eq!(
value["remote"]["url"].as_str(),
Some("radio.example.com:4530")
);
assert_eq!(
value["remote"]["hosts"][0].as_str(),
Some("radio.example.com")
);
}
fn temp_file(content: &str) -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(content.as_bytes()).unwrap();
f.flush().unwrap();
f
}
#[test]
fn test_read_secret_file_trims() {
let f = temp_file(" hunter2\n");
assert_eq!(read_secret_file(path_of(&f), "test").unwrap(), "hunter2");
}
#[test]
fn test_read_secret_file_rejects_empty() {
let f = temp_file(" \n");
assert!(read_secret_file(path_of(&f), "test").is_err());
}
#[test]
fn test_read_secret_list_skips_blanks_and_comments() {
let f = temp_file("# tokens\nalpha\n\n beta \n");
assert_eq!(
read_secret_list_file(path_of(&f), "test").unwrap(),
vec!["alpha".to_string(), "beta".to_string()]
);
}
#[test]
fn test_resolve_secret_fills_from_file() {
let f = temp_file("from-file");
let mut inline = None;
resolve_secret(&mut inline, &Some(path_of(&f).to_string()), "test").unwrap();
assert_eq!(inline.as_deref(), Some("from-file"));
}
#[test]
fn test_resolve_secret_rejects_both_forms() {
let f = temp_file("from-file");
let mut inline = Some("inline".to_string());
let err = resolve_secret(&mut inline, &Some(path_of(&f).to_string()), "test").unwrap_err();
assert!(err.contains("not both"), "{err}");
}
#[test]
fn test_resolve_secret_is_a_no_op_without_file() {
let mut inline = Some("inline".to_string());
resolve_secret(&mut inline, &None, "test").unwrap();
assert_eq!(inline.as_deref(), Some("inline"));
}
#[test]
fn test_resolve_secret_list_rejects_both_forms() {
let f = temp_file("alpha");
let mut inline = vec!["inline".to_string()];
let err =
resolve_secret_list(&mut inline, &Some(path_of(&f).to_string()), "test").unwrap_err();
assert!(err.contains("not both"), "{err}");
}
fn path_of(f: &tempfile::NamedTempFile) -> &str {
f.path().to_str().unwrap()
}
}
File diff suppressed because it is too large Load Diff
@@ -19,6 +19,52 @@
//! would either bloat both binaries with unused fields or require a trait //! would either bloat both binaries with unused fields or require a trait
//! abstraction that adds complexity without clear benefit. //! abstraction that adds complexity without clear benefit.
use std::net::IpAddr;
/// A socket a component intends to bind, and what it is for.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BoundSocket {
pub addr: IpAddr,
pub port: u16,
/// Human-readable owner, e.g. `[listen]` or `[frontends.http]`.
pub label: String,
}
impl BoundSocket {
pub fn new(addr: IpAddr, port: u16, label: impl Into<String>) -> Self {
Self {
addr,
port,
label: label.into(),
}
}
}
/// Reject two components trying to bind the same socket.
///
/// A wildcard address (`0.0.0.0` / `::`) conflicts with any other address on
/// the same port, since binding it claims every interface. Port 0 means "pick
/// an ephemeral port" and never conflicts.
pub fn check_socket_conflicts(sockets: &[BoundSocket]) -> Result<(), String> {
for (i, a) in sockets.iter().enumerate() {
if a.port == 0 {
continue;
}
for b in &sockets[i + 1..] {
if b.port != a.port {
continue;
}
if a.addr == b.addr || a.addr.is_unspecified() || b.addr.is_unspecified() {
return Err(format!(
"{} and {} would both bind {}:{}",
a.label, b.label, a.addr, a.port
));
}
}
}
Ok(())
}
/// Validate that a log level string is one of the accepted values. /// Validate that a log level string is one of the accepted values.
/// ///
/// Returns `Ok(())` when `level` is `None` (defaulting is handled elsewhere) /// Returns `Ok(())` when `level` is `None` (defaulting is handled elsewhere)
@@ -53,6 +99,47 @@ pub fn validate_tokens(path: &str, tokens: &[String]) -> Result<(), String> {
mod tests { mod tests {
use super::*; use super::*;
fn sock(addr: &str, port: u16, label: &str) -> BoundSocket {
BoundSocket::new(addr.parse().unwrap(), port, label)
}
#[test]
fn test_socket_conflicts_detects_exact_duplicate() {
let err = check_socket_conflicts(&[
sock("127.0.0.1", 8080, "[frontends.http]"),
sock("127.0.0.1", 8080, "[frontends.http_json]"),
])
.unwrap_err();
assert!(err.contains("127.0.0.1:8080"), "unexpected error: {err}");
}
#[test]
fn test_socket_conflicts_detects_wildcard_overlap() {
assert!(check_socket_conflicts(&[
sock("0.0.0.0", 4530, "[listen]"),
sock("127.0.0.1", 4530, "[audio]"),
])
.is_err());
}
#[test]
fn test_socket_conflicts_allows_distinct_addresses() {
assert!(check_socket_conflicts(&[
sock("127.0.0.1", 4530, "[listen]"),
sock("192.168.1.5", 4530, "[audio]"),
])
.is_ok());
}
#[test]
fn test_socket_conflicts_ignores_ephemeral_ports() {
assert!(check_socket_conflicts(&[
sock("127.0.0.1", 0, "[frontends.http_json]"),
sock("127.0.0.1", 0, "[frontends.http]"),
])
.is_ok());
}
#[test] #[test]
fn test_validate_log_level_none() { fn test_validate_log_level_none() {
assert!(validate_log_level(None).is_ok()); assert!(validate_log_level(None).is_ok());
+263
View File
@@ -0,0 +1,263 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Reporting for config keys the deserializer ignored.
//!
//! Every config struct is `#[serde(default)]`, so a misspelled key used to be
//! dropped without a word and the setting silently kept its default. The
//! loader now collects the ignored key paths and pairs each with the closest
//! known key at the same level, so `prot = 9999` reads as a typo instead of
//! looking like it worked.
use std::collections::BTreeSet;
use std::fmt;
/// A config key that the deserializer did not recognise.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnknownKey {
/// Dotted path of the key, e.g. `listen.prot` or `rigs.0.audio.prot`.
pub path: String,
/// Closest known key at the same level, when one is near enough to suggest.
pub suggestion: Option<String>,
}
impl fmt::Display for UnknownKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.suggestion {
Some(s) => write!(
f,
"unknown config key '{}' (did you mean '{}'?)",
self.path, s
),
None => write!(f, "unknown config key '{}'", self.path),
}
}
}
/// Flatten a TOML value into the set of dotted key paths it contains.
///
/// Array indices are normalised to `0` so a path inside `[[rigs]]` matches
/// whichever entry it came from.
pub fn flatten_paths(value: &toml::Value) -> BTreeSet<String> {
let mut paths = BTreeSet::new();
walk(value, "", &mut paths);
paths
}
fn walk(value: &toml::Value, prefix: &str, paths: &mut BTreeSet<String>) {
let join = |seg: &str| {
if prefix.is_empty() {
seg.to_string()
} else {
format!("{prefix}.{seg}")
}
};
match value {
toml::Value::Table(table) => {
for (key, child) in table {
let path = join(key);
paths.insert(path.clone());
walk(child, &path, paths);
}
}
toml::Value::Array(items) => {
// Every entry of an array of tables has the same shape, so collapse
// them onto index 0 and let one entry stand for all.
for item in items {
let path = join("0");
walk(item, &path, paths);
}
}
_ => {}
}
}
/// Replace numeric path segments with `0` so array entries compare equal.
fn normalize(path: &str) -> String {
path.split('.')
.map(|seg| {
if !seg.is_empty() && seg.bytes().all(|b| b.is_ascii_digit()) {
"0"
} else {
seg
}
})
.collect::<Vec<_>>()
.join(".")
}
/// Suggest the closest known key that sits at the same level as `path`.
///
/// Returns `None` when nothing is close enough to be worth printing.
pub fn suggest(path: &str, known: &BTreeSet<String>) -> Option<String> {
let normalized = normalize(path);
let (parent, leaf) = match normalized.rsplit_once('.') {
Some((parent, leaf)) => (parent, leaf),
None => ("", normalized.as_str()),
};
// Anything longer than this is a different word, not a typo.
let limit = (leaf.chars().count() / 3).clamp(1, 3);
let mut best: Option<(usize, &str)> = None;
for candidate in known {
let (cand_parent, cand_leaf) = match candidate.rsplit_once('.') {
Some((p, l)) => (p, l),
None => ("", candidate.as_str()),
};
if cand_parent != parent || cand_leaf == leaf {
continue;
}
let distance = edit_distance(leaf, cand_leaf);
if distance <= limit && best.is_none_or(|(best_d, _)| distance < best_d) {
best = Some((distance, cand_leaf));
}
}
best.map(|(_, leaf)| {
if parent.is_empty() {
leaf.to_string()
} else {
format!("{parent}.{leaf}")
}
})
}
/// Pair each ignored path with a suggestion drawn from `reference`.
pub fn describe(paths: &[String], reference: &toml::Value) -> Vec<UnknownKey> {
let known = flatten_paths(reference);
paths
.iter()
.map(|path| UnknownKey {
path: path.clone(),
suggestion: suggest(path, &known),
})
.collect()
}
/// Optimal string alignment distance: Levenshtein plus transpositions, so the
/// common `port` → `prot` slip counts as one mistake rather than two.
fn edit_distance(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
if a.is_empty() {
return b.len();
}
if b.is_empty() {
return a.len();
}
let mut rows = vec![vec![0usize; b.len() + 1]; a.len() + 1];
for (i, row) in rows.iter_mut().enumerate() {
row[0] = i;
}
for (j, cell) in rows[0].iter_mut().enumerate() {
*cell = j;
}
for i in 1..=a.len() {
for j in 1..=b.len() {
let cost = usize::from(a[i - 1] != b[j - 1]);
let mut best = (rows[i - 1][j] + 1)
.min(rows[i][j - 1] + 1)
.min(rows[i - 1][j - 1] + cost);
if i > 1 && j > 1 && a[i - 1] == b[j - 2] && a[i - 2] == b[j - 1] {
best = best.min(rows[i - 2][j - 2] + 1);
}
rows[i][j] = best;
}
}
rows[a.len()][b.len()]
}
#[cfg(test)]
mod tests {
use super::*;
fn reference() -> toml::Value {
toml::from_str(
r#"
[general]
callsign = "N0CALL"
log_level = "info"
[listen]
enabled = true
port = 4530
[[rigs]]
id = "hf"
[rigs.audio]
port = 4531
sample_rate = 48000
"#,
)
.unwrap()
}
#[test]
fn test_flatten_collects_nested_paths() {
let paths = flatten_paths(&reference());
assert!(paths.contains("general.callsign"));
assert!(paths.contains("listen.port"));
assert!(paths.contains("rigs.0.audio.sample_rate"));
}
#[test]
fn test_suggest_finds_close_sibling() {
let known = flatten_paths(&reference());
assert_eq!(
suggest("listen.prot", &known).as_deref(),
Some("listen.port")
);
}
#[test]
fn test_suggest_inside_array_entry() {
let known = flatten_paths(&reference());
assert_eq!(
suggest("rigs.1.audio.prot", &known).as_deref(),
Some("rigs.0.audio.port")
);
}
#[test]
fn test_suggest_ignores_distant_names() {
let known = flatten_paths(&reference());
assert_eq!(suggest("listen.bananas", &known), None);
}
#[test]
fn test_suggest_does_not_cross_levels() {
let known = flatten_paths(&reference());
// `port` exists under [listen], but not under [general].
assert_eq!(suggest("general.port", &known), None);
}
#[test]
fn test_describe_formats_message() {
let described = describe(&["listen.prot".to_string()], &reference());
assert_eq!(
described[0].to_string(),
"unknown config key 'listen.prot' (did you mean 'listen.port'?)"
);
}
#[test]
fn test_describe_without_suggestion() {
let described = describe(&["listen.bananas".to_string()], &reference());
assert_eq!(
described[0].to_string(),
"unknown config key 'listen.bananas'"
);
}
#[test]
fn test_edit_distance_counts_transposition_once() {
assert_eq!(edit_distance("port", "prot"), 1);
assert_eq!(edit_distance("port", "port"), 0);
assert_eq!(edit_distance("", "port"), 4);
assert_eq!(edit_distance("sample_rat", "sample_rate"), 1);
}
}
+173
View File
@@ -0,0 +1,173 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Parsing for the `host:port` endpoint URLs used by the client's `[[remotes]]`
//! and `[frontends.audio]` settings.
//!
//! These live next to the config structs because validation needs them; the
//! client re-exports them for its connection code.
/// Default port for the server's JSON control listener.
pub const DEFAULT_REMOTE_PORT: u16 = 4530;
/// Default port for the server's Opus audio listener.
pub const DEFAULT_AUDIO_PORT: u16 = 4531;
/// A resolved `host:port` pair.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RemoteEndpoint {
pub host: String,
pub port: u16,
}
impl RemoteEndpoint {
/// Format as a connect string, bracketing bare IPv6 hosts.
pub fn connect_addr(&self) -> String {
if self.host.contains(':') && !self.host.starts_with('[') {
format!("[{}]:{}", self.host, self.port)
} else {
format!("{}:{}", self.host, self.port)
}
}
}
/// Parse a remote control URL, defaulting to port 4530.
pub fn parse_remote_url(url: &str) -> Result<RemoteEndpoint, String> {
parse_endpoint_url(url, DEFAULT_REMOTE_PORT, "remote")
}
/// Parse an audio stream URL, defaulting to port 4531.
pub fn parse_audio_url(url: &str) -> Result<RemoteEndpoint, String> {
parse_endpoint_url(url, DEFAULT_AUDIO_PORT, "audio")
}
fn parse_endpoint_url(url: &str, default_port: u16, kind: &str) -> Result<RemoteEndpoint, String> {
let trimmed = url.trim();
if trimmed.is_empty() {
return Err(format!("{kind} url is empty"));
}
let addr = trimmed
.strip_prefix("tcp://")
.or_else(|| trimmed.strip_prefix("http-json://"))
.or_else(|| trimmed.strip_prefix("audio://"))
.unwrap_or(trimmed);
parse_host_port(addr, default_port, kind)
}
fn parse_host_port(input: &str, default_port: u16, kind: &str) -> Result<RemoteEndpoint, String> {
if let Some(rest) = input.strip_prefix('[') {
let closing = rest
.find(']')
.ok_or_else(|| format!("invalid {kind} url: missing closing ']' for IPv6 host"))?;
let host = &rest[..closing];
let remainder = &rest[closing + 1..];
if host.is_empty() {
return Err(format!("invalid {kind} url: host is empty"));
}
let port = if remainder.is_empty() {
default_port
} else if let Some(port_str) = remainder.strip_prefix(':') {
parse_port(port_str, kind)?
} else {
return Err(format!("invalid {kind} url: expected ':<port>' after ']'"));
};
return Ok(RemoteEndpoint {
host: host.to_string(),
port,
});
}
if input.contains(':') {
if input.matches(':').count() > 1 {
return Err(format!(
"invalid {kind} url: IPv6 host must be bracketed like [::1]:4532"
));
}
let (host, port_str) = input
.rsplit_once(':')
.ok_or_else(|| format!("invalid {kind} url: expected host:port"))?;
if host.is_empty() {
return Err(format!("invalid {kind} url: host is empty"));
}
return Ok(RemoteEndpoint {
host: host.to_string(),
port: parse_port(port_str, kind)?,
});
}
Ok(RemoteEndpoint {
host: input.to_string(),
port: default_port,
})
}
fn parse_port(port_str: &str, kind: &str) -> Result<u16, String> {
let port: u16 = port_str
.parse()
.map_err(|_| format!("invalid {kind} port: '{port_str}'"))?;
if port == 0 {
return Err(format!("invalid {kind} port: 0"));
}
Ok(port)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_remote_url_defaults_port() {
let ep = parse_remote_url("example.com").unwrap();
assert_eq!(ep.host, "example.com");
assert_eq!(ep.port, DEFAULT_REMOTE_PORT);
}
#[test]
fn test_parse_audio_url_defaults_port() {
let ep = parse_audio_url("example.com").unwrap();
assert_eq!(ep.port, DEFAULT_AUDIO_PORT);
}
#[test]
fn test_parse_strips_schemes() {
for url in &[
"tcp://host:9000",
"http-json://host:9000",
"audio://host:9000",
] {
let ep = parse_remote_url(url).unwrap();
assert_eq!(ep.host, "host");
assert_eq!(ep.port, 9000);
}
}
#[test]
fn test_parse_bracketed_ipv6() {
let ep = parse_remote_url("[::1]:4532").unwrap();
assert_eq!(ep.host, "::1");
assert_eq!(ep.port, 4532);
assert_eq!(ep.connect_addr(), "[::1]:4532");
}
#[test]
fn test_parse_rejects_unbracketed_ipv6() {
assert!(parse_remote_url("::1:4532").is_err());
}
#[test]
fn test_parse_rejects_empty_host() {
assert!(parse_remote_url(":4530").is_err());
}
#[test]
fn test_parse_rejects_zero_port() {
assert!(parse_remote_url("host:0").is_err());
}
#[test]
fn test_parse_rejects_empty_url() {
assert!(parse_remote_url(" ").is_err());
}
}
+2
View File
@@ -15,7 +15,9 @@ path = "src/main.rs"
clap = { workspace = true, features = ["derive"] } clap = { workspace = true, features = ["derive"] }
dialoguer = "0.11" dialoguer = "0.11"
tokio-serial = { workspace = true } tokio-serial = { workspace = true }
toml = "0.8"
toml_edit = "0.22" toml_edit = "0.22"
trx-config = { path = "../trx-config" }
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"
+136 -319
View File
@@ -2,80 +2,37 @@
// //
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
//! `trx-configurator --check`: run a config file through the same loader and
//! validators the binaries use.
//!
//! This used to be a second, hand-maintained implementation — lists of known
//! keys and a handful of re-implemented range checks — which drifted out of
//! date as soon as a field was added. It now defers entirely to `trx-config`,
//! so a config that checks clean here is one the binaries will accept.
use std::fmt::Write as _; use std::fmt::Write as _;
use std::path::Path; use std::path::Path;
use toml_edit::DocumentMut; use trx_config::{ClientConfig, ServerConfig};
/// Known top-level keys for a standalone server config. /// Top-level keys that only appear in a server config. Used solely to guess
const SERVER_KEYS: &[&str] = &[ /// what a section-less file is meant to be; the real key checking is done by
"general", /// the loader.
const SERVER_MARKERS: &[&str] = &[
"rig", "rig",
"rigs", "rigs",
"behavior",
"listen", "listen",
"audio", "behavior",
"sdr", "sdr",
"pskreporter", "pskreporter",
"aprsfi", "aprsfi",
"decode_logs", "decode_logs",
"timeouts",
"audio",
]; ];
/// Known top-level keys for a standalone client config. /// Top-level keys that only appear in a client config.
const CLIENT_KEYS: &[&str] = &["general", "remote", "remotes", "frontends"]; const CLIENT_MARKERS: &[&str] = &["remote", "remotes", "frontends"];
/// Known top-level keys for a combined trx-rs.toml.
const COMBINED_KEYS: &[&str] = &["trx-server", "trx-client"];
/// Known sub-keys within [general] (server).
const SERVER_GENERAL_KEYS: &[&str] = &["callsign", "log_level", "latitude", "longitude"];
/// Known sub-keys within [general] (client).
const CLIENT_GENERAL_KEYS: &[&str] = &[
"callsign",
"log_level",
"website_url",
"website_name",
"ais_vessel_url_base",
];
/// Known sub-keys within [rig].
const RIG_KEYS: &[&str] = &["model", "initial_freq_hz", "initial_mode", "access"];
/// Known sub-keys within [rig.access].
const ACCESS_KEYS: &[&str] = &["type", "port", "baud", "host", "tcp_port", "args"];
/// Known sub-keys within [listen].
const LISTEN_KEYS: &[&str] = &["enabled", "listen", "port", "auth"];
/// Known sub-keys within [audio] (server).
const AUDIO_KEYS: &[&str] = &[
"enabled",
"listen",
"port",
"rx_enabled",
"tx_enabled",
"device",
"sample_rate",
"channels",
"frame_duration_ms",
"bitrate_bps",
];
/// Known sub-keys within [behavior].
const BEHAVIOR_KEYS: &[&str] = &[
"poll_interval_ms",
"poll_interval_tx_ms",
"max_retries",
"retry_base_delay_ms",
"vfo_prime",
];
/// Known sub-keys within [remote].
const REMOTE_KEYS: &[&str] = &["url", "rig_id", "auth", "poll_interval_ms"];
/// Known sub-keys within [frontends].
const FRONTENDS_KEYS: &[&str] = &["http", "rigctl", "http_json", "audio"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DetectedType { enum DetectedType {
@@ -100,49 +57,38 @@ pub fn check_file(path: &Path) -> Result<String, String> {
let content = std::fs::read_to_string(path) let content = std::fs::read_to_string(path)
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?; .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
// Step 1: TOML syntax check let table: toml::Table = toml::from_str(&content)
let doc: DocumentMut = content
.parse()
.map_err(|e| format!("{}: TOML syntax error: {}", path.display(), e))?; .map_err(|e| format!("{}: TOML syntax error: {}", path.display(), e))?;
let detected = detect_type(&table);
let mut report = String::new(); let mut report = String::new();
let mut warnings: Vec<String> = Vec::new(); let mut warnings: Vec<String> = Vec::new();
let mut errors: Vec<String> = Vec::new(); let mut errors: Vec<String> = Vec::new();
let table = doc.as_table();
// Step 2: Detect config type
let detected = detect_type(table);
writeln!(report, "{}: valid TOML", path.display()).unwrap(); writeln!(report, "{}: valid TOML", path.display()).unwrap();
writeln!(report, " Detected type: {}", detected).unwrap(); writeln!(report, " Detected type: {}", detected).unwrap();
// Step 3: Structural validation
match detected { match detected {
DetectedType::Server => { DetectedType::Server => check_server(path, &mut warnings, &mut errors),
check_unknown_keys(table, SERVER_KEYS, "", &mut warnings); DetectedType::Client => check_client(path, &mut warnings, &mut errors),
check_server_sections(table, "", &mut warnings, &mut errors);
}
DetectedType::Client => {
check_unknown_keys(table, CLIENT_KEYS, "", &mut warnings);
check_client_sections(table, "", &mut warnings, &mut errors);
}
DetectedType::Combined => { DetectedType::Combined => {
check_unknown_keys(table, COMBINED_KEYS, "", &mut warnings); if table.contains_key("trx-server") {
if let Some(server) = table.get("trx-server").and_then(|v| v.as_table()) { check_server(path, &mut warnings, &mut errors);
check_unknown_keys(server, SERVER_KEYS, "[trx-server].", &mut warnings);
check_server_sections(server, "[trx-server].", &mut warnings, &mut errors);
} }
if let Some(client) = table.get("trx-client").and_then(|v| v.as_table()) { if table.contains_key("trx-client") {
check_unknown_keys(client, CLIENT_KEYS, "[trx-client].", &mut warnings); check_client(path, &mut warnings, &mut errors);
check_client_sections(client, "[trx-client].", &mut warnings, &mut errors);
} }
} }
DetectedType::Unknown => { DetectedType::Unknown => {
warnings.push("Could not detect config type. Expected server, client, or combined (trx-rs.toml) layout.".to_string()); warnings.push(
"Could not detect config type. Expected server, client, or combined \
(trx-rs.toml) layout."
.to_string(),
);
} }
} }
// Step 4: Format report
for w in &warnings { for w in &warnings {
writeln!(report, " warning: {}", w).unwrap(); writeln!(report, " warning: {}", w).unwrap();
} }
@@ -169,23 +115,54 @@ pub fn check_file(path: &Path) -> Result<String, String> {
} }
} }
fn detect_type(table: &toml_edit::Table) -> DetectedType { fn check_server(path: &Path, warnings: &mut Vec<String>, errors: &mut Vec<String>) {
match ServerConfig::load_from_file(path) {
Ok(loaded) => {
warnings.extend(loaded.unknown_keys.iter().map(|k| k.to_string()));
if let Err(e) = loaded.config.validate() {
errors.push(format!("[trx-server] {}", e));
}
errors.extend(
loaded
.config
.validate_sdr()
.into_iter()
.map(|e| format!("[trx-server] {}", e)),
);
}
Err(e) => errors.push(e.to_string()),
}
}
fn check_client(path: &Path, warnings: &mut Vec<String>, errors: &mut Vec<String>) {
match ClientConfig::load_from_file(path) {
Ok(loaded) => {
warnings.extend(loaded.unknown_keys.iter().map(|k| k.to_string()));
if let Err(e) = loaded.config.validate() {
errors.push(format!("[trx-client] {}", e));
}
}
Err(e) => errors.push(e.to_string()),
}
}
fn detect_type(table: &toml::Table) -> DetectedType {
if table.contains_key("trx-server") || table.contains_key("trx-client") { if table.contains_key("trx-server") || table.contains_key("trx-client") {
return DetectedType::Combined; return DetectedType::Combined;
} }
let keys: Vec<&str> = table.iter().map(|(k, _)| k).collect(); let keys: Vec<&str> = table.keys().map(|k| k.as_str()).collect();
let server_score = keys.iter().filter(|k| SERVER_KEYS.contains(k)).count(); // Distinguishing keys first, then a simple majority.
let client_score = keys.iter().filter(|k| CLIENT_KEYS.contains(k)).count(); if keys.iter().any(|k| ["rig", "rigs", "listen"].contains(k)) {
// Use distinguishing keys to break ties
if keys.contains(&"rig") || keys.contains(&"rigs") || keys.contains(&"listen") {
return DetectedType::Server; return DetectedType::Server;
} }
if keys.contains(&"remote") || keys.contains(&"remotes") || keys.contains(&"frontends") { if keys.iter().any(|k| CLIENT_MARKERS.contains(k)) {
return DetectedType::Client; return DetectedType::Client;
} }
let server_score = keys.iter().filter(|k| SERVER_MARKERS.contains(k)).count();
let client_score = keys.iter().filter(|k| CLIENT_MARKERS.contains(k)).count();
if server_score > client_score { if server_score > client_score {
DetectedType::Server DetectedType::Server
} else if client_score > server_score { } else if client_score > server_score {
@@ -197,194 +174,6 @@ fn detect_type(table: &toml_edit::Table) -> DetectedType {
} }
} }
fn check_unknown_keys(
table: &toml_edit::Table,
known: &[&str],
prefix: &str,
warnings: &mut Vec<String>,
) {
for (key, _) in table.iter() {
if !known.contains(&key) {
warnings.push(format!("{}unknown key '{}'", prefix, key));
}
}
}
fn check_server_sections(
table: &toml_edit::Table,
prefix: &str,
warnings: &mut Vec<String>,
errors: &mut Vec<String>,
) {
if let Some(general) = table.get("general").and_then(|v| v.as_table()) {
check_unknown_keys(
general,
SERVER_GENERAL_KEYS,
&format!("{}[general].", prefix),
warnings,
);
validate_log_level(general, &format!("{}[general]", prefix), errors);
validate_coordinates(general, &format!("{}[general]", prefix), errors);
}
if let Some(rig) = table.get("rig").and_then(|v| v.as_table()) {
check_unknown_keys(rig, RIG_KEYS, &format!("{}[rig].", prefix), warnings);
if let Some(access) = rig.get("access").and_then(|v| v.as_table()) {
check_unknown_keys(
access,
ACCESS_KEYS,
&format!("{}[rig.access].", prefix),
warnings,
);
validate_access(access, &format!("{}[rig.access]", prefix), errors);
}
}
if let Some(listen) = table.get("listen").and_then(|v| v.as_table()) {
check_unknown_keys(
listen,
LISTEN_KEYS,
&format!("{}[listen].", prefix),
warnings,
);
validate_port(listen, "port", &format!("{}[listen]", prefix), errors);
}
if let Some(audio) = table.get("audio").and_then(|v| v.as_table()) {
check_unknown_keys(audio, AUDIO_KEYS, &format!("{}[audio].", prefix), warnings);
validate_port(audio, "port", &format!("{}[audio]", prefix), errors);
}
if let Some(behavior) = table.get("behavior").and_then(|v| v.as_table()) {
check_unknown_keys(
behavior,
BEHAVIOR_KEYS,
&format!("{}[behavior].", prefix),
warnings,
);
}
}
fn check_client_sections(
table: &toml_edit::Table,
prefix: &str,
warnings: &mut Vec<String>,
errors: &mut Vec<String>,
) {
if let Some(general) = table.get("general").and_then(|v| v.as_table()) {
check_unknown_keys(
general,
CLIENT_GENERAL_KEYS,
&format!("{}[general].", prefix),
warnings,
);
validate_log_level(general, &format!("{}[general]", prefix), errors);
}
if let Some(remote) = table.get("remote").and_then(|v| v.as_table()) {
check_unknown_keys(
remote,
REMOTE_KEYS,
&format!("{}[remote].", prefix),
warnings,
);
}
if let Some(frontends) = table.get("frontends").and_then(|v| v.as_table()) {
check_unknown_keys(
frontends,
FRONTENDS_KEYS,
&format!("{}[frontends].", prefix),
warnings,
);
if let Some(http) = frontends.get("http").and_then(|v| v.as_table()) {
validate_port(http, "port", &format!("{}[frontends.http]", prefix), errors);
}
if let Some(rigctl) = frontends.get("rigctl").and_then(|v| v.as_table()) {
validate_port(
rigctl,
"port",
&format!("{}[frontends.rigctl]", prefix),
errors,
);
}
}
}
// ── Value validators ────────────────────────────────────────────────────
fn validate_log_level(table: &toml_edit::Table, context: &str, errors: &mut Vec<String>) {
if let Some(level) = table.get("log_level").and_then(|v| v.as_str()) {
if !["trace", "debug", "info", "warn", "error"].contains(&level) {
errors.push(format!(
"{}.log_level '{}' is invalid (expected: trace, debug, info, warn, error)",
context, level
));
}
}
}
fn validate_coordinates(table: &toml_edit::Table, context: &str, errors: &mut Vec<String>) {
if let Some(lat) = table
.get("latitude")
.and_then(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64)))
{
if !(-90.0..=90.0).contains(&lat) {
errors.push(format!(
"{}.latitude {} is out of range (-90..90)",
context, lat
));
}
}
if let Some(lon) = table
.get("longitude")
.and_then(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64)))
{
if !(-180.0..=180.0).contains(&lon) {
errors.push(format!(
"{}.longitude {} is out of range (-180..180)",
context, lon
));
}
}
let has_lat = table.contains_key("latitude");
let has_lon = table.contains_key("longitude");
if has_lat != has_lon {
errors.push(format!(
"{}: latitude and longitude must be set together or both omitted",
context
));
}
}
fn validate_port(table: &toml_edit::Table, key: &str, context: &str, errors: &mut Vec<String>) {
if let Some(port) = table.get(key).and_then(|v| v.as_integer()) {
if let Some(enabled) = table.get("enabled").and_then(|v| v.as_bool()) {
if enabled && port <= 0 {
errors.push(format!("{}.{} must be > 0 when enabled", context, key));
}
}
if !(0..=65535).contains(&port) {
errors.push(format!(
"{}.{} {} is out of range (0..65535)",
context, key, port
));
}
}
}
fn validate_access(table: &toml_edit::Table, context: &str, errors: &mut Vec<String>) {
if let Some(access_type) = table.get("type").and_then(|v| v.as_str()) {
if !["serial", "tcp", "sdr"].contains(&access_type) {
errors.push(format!(
"{}.type '{}' is invalid (expected: serial, tcp, sdr)",
context, access_type
));
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -418,8 +207,7 @@ enabled = true
port = 4530 port = 4530
"#, "#,
); );
assert!(result.is_ok()); let report = result.expect("expected a clean report");
let report = result.unwrap();
assert!(report.contains("Detected type: server")); assert!(report.contains("Detected type: server"));
assert!(report.contains("No issues found")); assert!(report.contains("No issues found"));
} }
@@ -432,41 +220,40 @@ port = 4530
callsign = "W1AW" callsign = "W1AW"
[remote] [remote]
url = "localhost:4530" url = "192.168.1.10:4530"
[frontends.http] [frontends.http]
enabled = true enabled = true
port = 8080 port = 8080
"#, "#,
); );
assert!(result.is_ok()); let report = result.expect("expected a clean report");
let report = result.unwrap();
assert!(report.contains("Detected type: client")); assert!(report.contains("Detected type: client"));
assert!(report.contains("No issues found"));
} }
#[test] #[test]
fn test_valid_combined_config() { fn test_valid_combined_config() {
let result = check_toml( let result = check_toml(
r#" r#"
[trx-server.general] [trx-server.rig]
callsign = "W1AW" model = "ft817"
[trx-server.rig.access]
[trx-client.general] type = "serial"
callsign = "W1AW" port = "/dev/ttyUSB0"
baud = 9600
[trx-client.remote] [trx-client.remote]
url = "localhost:4530" url = "127.0.0.1:4530"
"#, "#,
); );
assert!(result.is_ok()); let report = result.expect("expected a clean report");
let report = result.unwrap();
assert!(report.contains("Detected type: combined")); assert!(report.contains("Detected type: combined"));
} }
#[test] #[test]
fn test_invalid_toml_syntax() { fn test_invalid_toml_syntax() {
let result = check_toml("this is not [valid toml"); let result = check_toml("[general\ncallsign = \"W1AW\"\n");
assert!(result.is_err());
assert!(result.unwrap_err().contains("TOML syntax error")); assert!(result.unwrap_err().contains("TOML syntax error"));
} }
@@ -474,19 +261,22 @@ url = "localhost:4530"
fn test_unknown_key_warning() { fn test_unknown_key_warning() {
let result = check_toml( let result = check_toml(
r#" r#"
[general]
callsign = "W1AW"
[rig] [rig]
model = "ft817" model = "ft817"
[rig.access]
type = "serial"
port = "/dev/ttyUSB0"
baud = 9600
[bogus_section] [listen]
foo = "bar" prot = 4530
"#, "#,
); );
assert!(result.is_ok()); let report = result.expect("unknown keys are warnings, not errors");
let report = result.unwrap(); assert!(
assert!(report.contains("unknown key 'bogus_section'")); report.contains("unknown config key 'listen.prot' (did you mean 'listen.port'?)"),
"unexpected report: {report}"
);
} }
#[test] #[test]
@@ -498,11 +288,13 @@ log_level = "verbose"
[rig] [rig]
model = "ft817" model = "ft817"
[rig.access]
type = "serial"
port = "/dev/ttyUSB0"
baud = 9600
"#, "#,
); );
assert!(result.is_err()); assert!(result.unwrap_err().contains("log_level"));
let report = result.unwrap_err();
assert!(report.contains("log_level 'verbose' is invalid"));
} }
#[test] #[test]
@@ -510,15 +302,17 @@ model = "ft817"
let result = check_toml( let result = check_toml(
r#" r#"
[general] [general]
latitude = 45.0 latitude = 52.0
[rig] [rig]
model = "ft817" model = "ft817"
[rig.access]
type = "serial"
port = "/dev/ttyUSB0"
baud = 9600
"#, "#,
); );
assert!(result.is_err()); assert!(result.unwrap_err().contains("longitude"));
let report = result.unwrap_err();
assert!(report.contains("latitude and longitude must be set together"));
} }
#[test] #[test]
@@ -526,16 +320,18 @@ model = "ft817"
let result = check_toml( let result = check_toml(
r#" r#"
[general] [general]
latitude = 95.0 latitude = 120.0
longitude = 10.0 longitude = 10.0
[rig] [rig]
model = "ft817" model = "ft817"
[rig.access]
type = "serial"
port = "/dev/ttyUSB0"
baud = 9600
"#, "#,
); );
assert!(result.is_err()); assert!(result.unwrap_err().contains("latitude"));
let report = result.unwrap_err();
assert!(report.contains("latitude 95 is out of range"));
} }
#[test] #[test]
@@ -547,10 +343,31 @@ model = "ft817"
[rig.access] [rig.access]
type = "usb" type = "usb"
port = "/dev/ttyUSB0"
baud = 9600
"#, "#,
); );
assert!(result.is_err()); assert!(result.unwrap_err().contains("access"));
let report = result.unwrap_err(); }
assert!(report.contains("type 'usb' is invalid"));
/// The old checker only knew a fixed list of top-level keys and a few range
/// rules, so it passed configs the server rejects at startup.
#[test]
fn test_catches_errors_the_key_list_checker_missed() {
let result = check_toml(
r#"
[rig]
model = "ft817"
[rig.access]
type = "serial"
port = "/dev/ttyUSB0"
baud = 9600
[audio]
enabled = true
frame_duration_ms = 7
"#,
);
assert!(result.unwrap_err().contains("frame_duration_ms"));
} }
} }
+68 -8
View File
@@ -211,10 +211,13 @@ pub fn build_server(general: ServerGeneral, rig: RigSetup, listen: ListenSetup)
let mut doc = DocumentMut::new(); let mut doc = DocumentMut::new();
doc.decor_mut() doc.decor_mut()
.set_prefix("# trx-server configuration\n# Generated by trx-configurator\n"); .set_prefix("# trx-server configuration\n# Generated by trx-configurator\n");
let tables = build_server_tables(general, rig, listen); // Emit the sectioned shape (`[trx-server]`) that trx-server writes with
for (key, item) in tables.iter() { // --print-config, so a generated file can be dropped into a combined
doc.insert(key, item.clone()); // trx-rs.toml unchanged.
} doc.insert(
"trx-server",
Item::Table(build_server_tables(general, rig, listen)),
);
doc doc
} }
@@ -350,10 +353,10 @@ pub fn build_client(
let mut doc = DocumentMut::new(); let mut doc = DocumentMut::new();
doc.decor_mut() doc.decor_mut()
.set_prefix("# trx-client configuration\n# Generated by trx-configurator\n"); .set_prefix("# trx-client configuration\n# Generated by trx-configurator\n");
let tables = build_client_tables(general, remote, frontends); doc.insert(
for (key, item) in tables.iter() { "trx-client",
doc.insert(key, item.clone()); Item::Table(build_client_tables(general, remote, frontends)),
} );
doc doc
} }
@@ -453,3 +456,60 @@ pub fn write_file(doc: &DocumentMut, path: &Path) -> Result<(), String> {
println!("Wrote {}", path.display()); println!("Wrote {}", path.display());
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::*;
use trx_config::{ClientConfig, ServerConfig};
fn write_temp(doc: &DocumentMut) -> tempfile::NamedTempFile {
let file = tempfile::Builder::new().suffix(".toml").tempfile().unwrap();
std::fs::write(file.path(), doc.to_string()).unwrap();
file
}
/// The wizard used to emit root-level `[general]` / `[rig]` tables while the
/// loader demanded a `[trx-server]` section, so every generated standalone
/// config was rejected by the binary it was generated for.
#[test]
fn test_generated_server_config_loads_and_validates() {
let file = write_temp(&build_default(ConfigType::Server));
let cfg = ServerConfig::load_from_file(file.path())
.expect("generated config must load")
.config;
cfg.validate().expect("generated config must validate");
assert_eq!(cfg.rig.model.as_deref(), Some("ft817"));
assert_eq!(cfg.listen.port, 4530);
}
#[test]
fn test_generated_client_config_loads_and_validates() {
let file = write_temp(&build_default(ConfigType::Client));
let cfg = ClientConfig::load_from_file(file.path())
.expect("generated config must load")
.config;
cfg.validate().expect("generated config must validate");
assert_eq!(cfg.remote.url.as_deref(), Some("localhost:4530"));
}
#[test]
fn test_generated_combined_config_loads_both_sections() {
let file = write_temp(&build_default(ConfigType::Combined));
let server = ServerConfig::load_from_file(file.path())
.expect("server section must load")
.config;
server.validate().expect("server section must validate");
let client = ClientConfig::load_from_file(file.path())
.expect("client section must load")
.config;
client.validate().expect("client section must validate");
}
#[test]
fn test_generated_docs_are_sectioned() {
let doc = build_default(ConfigType::Server);
assert!(doc.as_table().contains_key("trx-server"));
let doc = build_default(ConfigType::Client);
assert!(doc.as_table().contains_key("trx-client"));
}
}
+1
View File
@@ -32,6 +32,7 @@ cpal = "0.15"
num-complex = "0.4" num-complex = "0.4"
opus = "0.3" opus = "0.3"
trx-app = { path = "../trx-app" } trx-app = { path = "../trx-app" }
trx-config = { path = "../trx-config" }
trx-backend = { path = "trx-backend", features = ["soapysdr"] } trx-backend = { path = "trx-backend", features = ["soapysdr"] }
trx-ais = { path = "../decoders/trx-ais" } trx-ais = { path = "../decoders/trx-ais" }
trx-vdes = { path = "../decoders/trx-vdes" } trx-vdes = { path = "../decoders/trx-vdes" }
+1 -4
View File
@@ -2291,15 +2291,12 @@ pub async fn run_wefax_decoder(
mut state_rx: watch::Receiver<RigState>, mut state_rx: watch::Receiver<RigState>,
decode_tx: broadcast::Sender<DecodedMessage>, decode_tx: broadcast::Sender<DecodedMessage>,
histories: Arc<DecoderHistories>, histories: Arc<DecoderHistories>,
wefax_output_dir: std::path::PathBuf,
) { ) {
use trx_wefax::{WefaxConfig, WefaxDecoder, WefaxEvent}; use trx_wefax::{WefaxConfig, WefaxDecoder, WefaxEvent};
info!("WEFAX decoder started ({}Hz, {} ch)", sample_rate, channels); info!("WEFAX decoder started ({}Hz, {} ch)", sample_rate, channels);
let wefax_output_dir = dirs::cache_dir()
.unwrap_or_else(|| std::path::PathBuf::from(".cache"))
.join("trx-rs")
.join("wefax");
let config = WefaxConfig { let config = WefaxConfig {
output_dir: Some(wefax_output_dir.to_string_lossy().into_owned()), output_dir: Some(wefax_output_dir.to_string_lossy().into_owned()),
..WefaxConfig::default() ..WefaxConfig::default()
File diff suppressed because it is too large Load Diff
+138 -21
View File
@@ -30,6 +30,8 @@ use trx_core::audio::AudioStreamInfo;
use trx_app::{init_logging, normalize_name}; use trx_app::{init_logging, normalize_name};
use trx_backend::{register_builtin_backends_on, RegistrationContext, RigAccess}; use trx_backend::{register_builtin_backends_on, RegistrationContext, RigAccess};
use trx_config::shared::BoundSocket;
use trx_config::ConfigFile;
use trx_core::rig::controller::{AdaptivePolling, ExponentialBackoff}; use trx_core::rig::controller::{AdaptivePolling, ExponentialBackoff};
use trx_core::rig::request::RigRequest; use trx_core::rig::request::RigRequest;
use trx_core::rig::state::RigState; use trx_core::rig::state::RigState;
@@ -56,6 +58,12 @@ struct Cli {
/// Print example configuration and exit /// Print example configuration and exit
#[arg(long = "print-config")] #[arg(long = "print-config")]
print_config: bool, print_config: bool,
/// Treat unknown configuration keys as a fatal error
#[arg(long = "strict-config")]
strict_config: bool,
/// Validate the configuration and exit without starting anything
#[arg(long = "check-config")]
check_config: bool,
/// Rig backend to use (e.g. ft817, ft450d) /// Rig backend to use (e.g. ft817, ft450d)
#[arg(short = 'r', long = "rig")] #[arg(short = 'r', long = "rig")]
rig: Option<String>, rig: Option<String>,
@@ -655,6 +663,7 @@ fn spawn_rig_audio_stack(
} }
// Spawn APRS decoder task // Spawn APRS decoder task
if rig_cfg.decoders.is_enabled("aprs") {
let aprs_pcm_rx = pcm_tx.subscribe(); let aprs_pcm_rx = pcm_tx.subscribe();
let aprs_state_rx = state_rx.clone(); let aprs_state_rx = state_rx.clone();
let aprs_decode_tx = decode_tx.clone(); let aprs_decode_tx = decode_tx.clone();
@@ -669,8 +678,10 @@ fn spawn_rig_audio_stack(
_ = wait_for_shutdown(aprs_shutdown_rx) => {} _ = wait_for_shutdown(aprs_shutdown_rx) => {}
} }
})); }));
}
// Spawn HF APRS decoder task // Spawn HF APRS decoder task
if rig_cfg.decoders.is_enabled("aprs_hf") {
let hf_aprs_pcm_rx = pcm_tx.subscribe(); let hf_aprs_pcm_rx = pcm_tx.subscribe();
let hf_aprs_state_rx = state_rx.clone(); let hf_aprs_state_rx = state_rx.clone();
let hf_aprs_decode_tx = decode_tx.clone(); let hf_aprs_decode_tx = decode_tx.clone();
@@ -685,8 +696,11 @@ fn spawn_rig_audio_stack(
_ = wait_for_shutdown(hf_aprs_shutdown_rx) => {} _ = wait_for_shutdown(hf_aprs_shutdown_rx) => {}
} }
})); }));
}
if let Some((ais_a_pcm_rx, ais_b_pcm_rx)) = sdr_ais_pcm_rx { if let Some((ais_a_pcm_rx, ais_b_pcm_rx)) =
sdr_ais_pcm_rx.filter(|_| rig_cfg.decoders.is_enabled("ais"))
{
let ais_state_rx = state_rx.clone(); let ais_state_rx = state_rx.clone();
let ais_decode_tx = decode_tx.clone(); let ais_decode_tx = decode_tx.clone();
let ais_shutdown_rx = shutdown_rx.clone(); let ais_shutdown_rx = shutdown_rx.clone();
@@ -701,7 +715,7 @@ fn spawn_rig_audio_stack(
})); }));
} }
if let Some(vdes_iq_rx) = sdr_vdes_iq_rx { if let Some(vdes_iq_rx) = sdr_vdes_iq_rx.filter(|_| rig_cfg.decoders.is_enabled("vdes")) {
let vdes_state_rx = state_rx.clone(); let vdes_state_rx = state_rx.clone();
let vdes_decode_tx = decode_tx.clone(); let vdes_decode_tx = decode_tx.clone();
let vdes_shutdown_rx = shutdown_rx.clone(); let vdes_shutdown_rx = shutdown_rx.clone();
@@ -723,6 +737,7 @@ fn spawn_rig_audio_stack(
} }
// Spawn CW decoder task // Spawn CW decoder task
if rig_cfg.decoders.is_enabled("cw") {
let cw_pcm_rx = pcm_tx.subscribe(); let cw_pcm_rx = pcm_tx.subscribe();
let cw_state_rx = state_rx.clone(); let cw_state_rx = state_rx.clone();
let cw_decode_tx = decode_tx.clone(); let cw_decode_tx = decode_tx.clone();
@@ -737,8 +752,10 @@ fn spawn_rig_audio_stack(
_ = wait_for_shutdown(cw_shutdown_rx) => {} _ = wait_for_shutdown(cw_shutdown_rx) => {}
} }
})); }));
}
// Spawn FT8 decoder task // Spawn FT8 decoder task
if rig_cfg.decoders.is_enabled("ft8") {
let ft8_pcm_rx = pcm_tx.subscribe(); let ft8_pcm_rx = pcm_tx.subscribe();
let ft8_state_rx = state_rx.clone(); let ft8_state_rx = state_rx.clone();
let ft8_decode_tx = decode_tx.clone(); let ft8_decode_tx = decode_tx.clone();
@@ -753,8 +770,10 @@ fn spawn_rig_audio_stack(
_ = wait_for_shutdown(ft8_shutdown_rx) => {} _ = wait_for_shutdown(ft8_shutdown_rx) => {}
} }
})); }));
}
// Spawn FT4 decoder task // Spawn FT4 decoder task
if rig_cfg.decoders.is_enabled("ft4") {
let ft4_pcm_rx = pcm_tx.subscribe(); let ft4_pcm_rx = pcm_tx.subscribe();
let ft4_state_rx = state_rx.clone(); let ft4_state_rx = state_rx.clone();
let ft4_decode_tx = decode_tx.clone(); let ft4_decode_tx = decode_tx.clone();
@@ -768,10 +787,11 @@ fn spawn_rig_audio_stack(
_ = wait_for_shutdown(ft4_shutdown_rx) => {} _ = wait_for_shutdown(ft4_shutdown_rx) => {}
} }
})); }));
}
// Spawn FT2 decoder task // Spawn FT2 decoder task
#[cfg(feature = "ft2")] #[cfg(feature = "ft2")]
{ if rig_cfg.decoders.is_enabled("ft2") {
let ft2_pcm_rx = pcm_tx.subscribe(); let ft2_pcm_rx = pcm_tx.subscribe();
let ft2_state_rx = state_rx.clone(); let ft2_state_rx = state_rx.clone();
let ft2_decode_tx = decode_tx.clone(); let ft2_decode_tx = decode_tx.clone();
@@ -788,6 +808,7 @@ fn spawn_rig_audio_stack(
} }
// Spawn WSPR decoder task // Spawn WSPR decoder task
if rig_cfg.decoders.is_enabled("wspr") {
let wspr_pcm_rx = pcm_tx.subscribe(); let wspr_pcm_rx = pcm_tx.subscribe();
let wspr_state_rx = state_rx.clone(); let wspr_state_rx = state_rx.clone();
let wspr_decode_tx = decode_tx.clone(); let wspr_decode_tx = decode_tx.clone();
@@ -802,8 +823,10 @@ fn spawn_rig_audio_stack(
_ = wait_for_shutdown(wspr_shutdown_rx) => {} _ = wait_for_shutdown(wspr_shutdown_rx) => {}
} }
})); }));
}
// Spawn Meteor-M LRPT decoder task // Spawn Meteor-M LRPT decoder task
if rig_cfg.decoders.is_enabled("lrpt") {
let lrpt_pcm_rx = pcm_tx.subscribe(); let lrpt_pcm_rx = pcm_tx.subscribe();
let lrpt_state_rx = state_rx.clone(); let lrpt_state_rx = state_rx.clone();
let lrpt_decode_tx = decode_tx.clone(); let lrpt_decode_tx = decode_tx.clone();
@@ -811,18 +834,17 @@ fn spawn_rig_audio_stack(
let lrpt_ch = rig_cfg.audio.channels; let lrpt_ch = rig_cfg.audio.channels;
let lrpt_shutdown_rx = shutdown_rx.clone(); let lrpt_shutdown_rx = shutdown_rx.clone();
let lrpt_histories = histories.clone(); let lrpt_histories = histories.clone();
let lrpt_output_dir = dirs::cache_dir() let lrpt_output_dir = rig_cfg.decoders.output_dir_for("lrpt");
.unwrap_or_else(|| std::path::PathBuf::from(".cache"))
.join("trx-rs")
.join("lrpt");
handles.push(tokio::spawn(async move { handles.push(tokio::spawn(async move {
tokio::select! { tokio::select! {
_ = audio::run_lrpt_decoder(lrpt_sr, lrpt_ch as u16, lrpt_pcm_rx, lrpt_state_rx, lrpt_decode_tx, lrpt_histories, lrpt_output_dir) => {} _ = audio::run_lrpt_decoder(lrpt_sr, lrpt_ch as u16, lrpt_pcm_rx, lrpt_state_rx, lrpt_decode_tx, lrpt_histories, lrpt_output_dir) => {}
_ = wait_for_shutdown(lrpt_shutdown_rx) => {} _ = wait_for_shutdown(lrpt_shutdown_rx) => {}
} }
})); }));
}
// Spawn WEFAX decoder task // Spawn WEFAX decoder task
if rig_cfg.decoders.is_enabled("wefax") {
let wefax_pcm_rx = pcm_tx.subscribe(); let wefax_pcm_rx = pcm_tx.subscribe();
let wefax_state_rx = state_rx.clone(); let wefax_state_rx = state_rx.clone();
let wefax_decode_tx = decode_tx.clone(); let wefax_decode_tx = decode_tx.clone();
@@ -830,14 +852,17 @@ fn spawn_rig_audio_stack(
let wefax_ch = rig_cfg.audio.channels; let wefax_ch = rig_cfg.audio.channels;
let wefax_shutdown_rx = shutdown_rx.clone(); let wefax_shutdown_rx = shutdown_rx.clone();
let wefax_histories = histories.clone(); let wefax_histories = histories.clone();
let wefax_output_dir = rig_cfg.decoders.output_dir_for("wefax");
handles.push(tokio::spawn(async move { handles.push(tokio::spawn(async move {
tokio::select! { tokio::select! {
_ = audio::run_wefax_decoder(wefax_sr, wefax_ch as u16, wefax_pcm_rx, wefax_state_rx, wefax_decode_tx, wefax_histories) => {} _ = audio::run_wefax_decoder(wefax_sr, wefax_ch as u16, wefax_pcm_rx, wefax_state_rx, wefax_decode_tx, wefax_histories, wefax_output_dir) => {}
_ = wait_for_shutdown(wefax_shutdown_rx) => {} _ = wait_for_shutdown(wefax_shutdown_rx) => {}
} }
})); }));
}
// Spawn SSTV decoder task // Spawn SSTV decoder task
if rig_cfg.decoders.is_enabled("sstv") {
let sstv_pcm_rx = pcm_tx.subscribe(); let sstv_pcm_rx = pcm_tx.subscribe();
let sstv_state_rx = state_rx.clone(); let sstv_state_rx = state_rx.clone();
let sstv_decode_tx = decode_tx.clone(); let sstv_decode_tx = decode_tx.clone();
@@ -845,10 +870,7 @@ fn spawn_rig_audio_stack(
let sstv_ch = rig_cfg.audio.channels; let sstv_ch = rig_cfg.audio.channels;
let sstv_shutdown_rx = shutdown_rx.clone(); let sstv_shutdown_rx = shutdown_rx.clone();
let sstv_histories = histories.clone(); let sstv_histories = histories.clone();
let sstv_output_dir = dirs::cache_dir() let sstv_output_dir = rig_cfg.decoders.output_dir_for("sstv");
.unwrap_or_else(|| std::path::PathBuf::from(".cache"))
.join("trx-rs")
.join("sstv");
handles.push(tokio::spawn(async move { handles.push(tokio::spawn(async move {
tokio::select! { tokio::select! {
_ = audio::run_sstv_decoder(sstv_sr, sstv_ch as u16, sstv_pcm_rx, sstv_state_rx, sstv_decode_tx, sstv_histories, sstv_output_dir) => {} _ = audio::run_sstv_decoder(sstv_sr, sstv_ch as u16, sstv_pcm_rx, sstv_state_rx, sstv_decode_tx, sstv_histories, sstv_output_dir) => {}
@@ -856,6 +878,7 @@ fn spawn_rig_audio_stack(
} }
})); }));
} }
}
if rig_cfg.audio.tx_enabled { if rig_cfg.audio.tx_enabled {
let _playback_thread = let _playback_thread =
@@ -884,6 +907,82 @@ fn spawn_rig_audio_stack(
handles handles
} }
/// Sockets this process will bind, given the config and the CLI overrides.
///
/// `--listen` overrides the bind address of both the control listener and every
/// rig's audio listener, so the two callers of this must agree on the rules.
fn bound_sockets(cli: &Cli, cfg: &ServerConfig, rigs: &[RigInstanceConfig]) -> Vec<BoundSocket> {
let mut sockets = Vec::new();
if cfg.listen.enabled {
sockets.push(BoundSocket::new(
cli.listen.unwrap_or(cfg.listen.listen),
cli.port.unwrap_or(cfg.listen.port),
"[listen]",
));
}
let audio_ip = cli.listen.unwrap_or(cfg.audio.listen);
for rig in rigs {
if rig.audio.enabled {
sockets.push(BoundSocket::new(
audio_ip,
rig.audio.port,
format!("rig \"{}\" [audio]", rig.id),
));
}
}
sockets
}
/// `--check-config`: report everything wrong with the configuration and exit.
///
/// Unlike startup, this reports every problem it finds rather than stopping at
/// the first, so a config can be fixed in one pass.
fn check_config(cli: &Cli, loaded: &trx_config::ConfigLoad<ServerConfig>) -> DynResult<()> {
match &loaded.path {
Some(path) => println!("{}", path.display()),
None => println!("(no config file found; checking built-in defaults)"),
}
let mut warnings: Vec<String> = loaded.unknown_keys.iter().map(|k| k.to_string()).collect();
warnings.extend(ServerConfig::deprecations(&loaded.present_keys));
for warning in &warnings {
println!(" warning: {}", warning);
}
let mut cfg = loaded.config.clone();
let mut errors = Vec::new();
if let Err(e) = cfg.resolve_secrets(loaded.path.as_deref()) {
errors.push(e);
}
let cfg = &cfg;
let rigs = cfg.resolved_rigs();
errors.extend(cfg.validate_all());
errors.extend(cfg.validate_sdr());
errors.extend(cfg.validate_resolved_all(&rigs, &bound_sockets(cli, cfg, &rigs)));
for e in &errors {
println!(" error: {}", e);
}
if errors.is_empty() {
println!(
" OK: {} rig(s) configured: {}",
rigs.len(),
rigs.iter()
.map(|r| r.id.as_str())
.collect::<Vec<_>>()
.join(", ")
);
if !warnings.is_empty() {
println!(" {} warning(s)", warnings.len());
}
Ok(())
} else {
Err(format!("{} error(s), {} warning(s)", errors.len(), warnings.len()).into())
}
}
#[tokio::main] #[tokio::main]
async fn main() -> DynResult<()> { async fn main() -> DynResult<()> {
let mut bootstrap_ctx = RegistrationContext::new(); let mut bootstrap_ctx = RegistrationContext::new();
@@ -896,12 +995,30 @@ async fn main() -> DynResult<()> {
return Ok(()); return Ok(());
} }
let (cfg, config_path) = if let Some(ref path) = cli.config { let loaded = if let Some(ref path) = cli.config {
let cfg = ServerConfig::load_from_file(path)?; ServerConfig::load_from_file(path)?
(cfg, Some(path.clone()))
} else { } else {
ServerConfig::load_from_default_paths()? ServerConfig::load_from_default_paths()?
}; };
let config_path = loaded.path.clone();
if cli.check_config {
return check_config(&cli, &loaded);
}
// Logging comes up before any config complaint so the warnings are visible.
init_logging(loaded.config.general.log_level.as_deref());
if let Some(ref path) = config_path {
info!("Loaded configuration from {}", path.display());
}
loaded.report_unknown_keys(cli.strict_config)?;
loaded.report_deprecations();
let mut cfg = loaded.config;
// Secrets configured as *_file are read before validation, so everything
// downstream sees resolved values.
cfg.resolve_secrets(config_path.as_deref())?;
cfg.validate() cfg.validate()
.map_err(|e| format!("Invalid server configuration: {}", e))?; .map_err(|e| format!("Invalid server configuration: {}", e))?;
@@ -914,12 +1031,6 @@ async fn main() -> DynResult<()> {
std::process::exit(1); std::process::exit(1);
} }
init_logging(cfg.general.log_level.as_deref());
if let Some(ref path) = config_path {
info!("Loaded configuration from {}", path.display());
}
let registry = Arc::new(bootstrap_ctx); let registry = Arc::new(bootstrap_ctx);
// --- Resolve the effective rig list --- // --- Resolve the effective rig list ---
@@ -981,6 +1092,12 @@ async fn main() -> DynResult<()> {
(callsign, cfg.general.latitude, cfg.general.longitude) (callsign, cfg.general.latitude, cfg.general.longitude)
}; };
// Second validation phase: now that CLI overrides have been folded in, check
// the things that need the final rig list — chiefly that no two listeners
// claim the same socket.
cfg.validate_resolved(&resolved_rigs, &bound_sockets(&cli, &cfg, &resolved_rigs))
.map_err(|e| format!("Invalid server configuration: {}", e))?;
info!( info!(
"Starting trx-server with {} rig(s): {}", "Starting trx-server with {} rig(s): {}",
resolved_rigs.len(), resolved_rigs.len(),
+110 -6
View File
@@ -1,36 +1,60 @@
# trx-rs example configuration
#
# Generated from the config structs; regenerate with:
# cargo run -p trx-config --example generate_example
#
# Both sections are optional: trx-server reads [trx-server], trx-client reads
# [trx-client], and either may live in its own file with the section header
# omitted. Any string may use ${ENV_VAR}, and credentials may be moved out of
# this file with the matching *_file keys.
#
# Check a config without starting anything:
# trx-server --check-config --config trx-rs.toml
# trx-client --check-config --config trx-rs.toml
# Server: drives the radio hardware.
[trx-server] [trx-server]
rigs = [] rigs = []
# Station identity. Coordinates feed PSKReporter and the map.
[trx-server.general] [trx-server.general]
callsign = "N0CALL" callsign = "N0CALL"
log_level = "info" log_level = "info"
latitude = 52.2297 latitude = 52.2297
longitude = 21.0122 longitude = 21.0122
# Single-rig layout. For several radios, delete this and use [[rigs]].
[trx-server.rig] [trx-server.rig]
model = "ft817" model = "ft817"
initial_freq_hz = 144300000 initial_freq_hz = 144300000
initial_mode = "USB" initial_mode = "USB"
# How to reach the radio: serial, tcp, or sdr.
[trx-server.rig.access] [trx-server.rig.access]
type = "serial" type = "serial"
port = "/dev/ttyUSB0" port = "/dev/ttyUSB0"
baud = 9600 baud = 9600
# CAT polling and retry behaviour.
[trx-server.behavior] [trx-server.behavior]
poll_interval_ms = 500 poll_interval_ms = 500
poll_interval_tx_ms = 100 poll_interval_tx_ms = 100
max_retries = 3 max_retries = 3
retry_base_delay_ms = 100 retry_base_delay_ms = 100
vfo_prime = true
# JSON control listener that trx-client connects to.
[trx-server.listen] [trx-server.listen]
enabled = true enabled = true
listen = "127.0.0.1" listen = "127.0.0.1"
port = 4530 port = 4530
# Tokens clients must present. Empty means no authentication.
# Use tokens_file = "/etc/trx-rs/tokens" to keep them out of this file.
[trx-server.listen.auth] [trx-server.listen.auth]
tokens = [] tokens = []
# Opus audio stream for trx-client.
[trx-server.audio] [trx-server.audio]
enabled = true enabled = true
listen = "127.0.0.1" listen = "127.0.0.1"
@@ -42,45 +66,92 @@ channels = 2
frame_duration_ms = 20 frame_duration_ms = 20
bitrate_bps = 256000 bitrate_bps = 256000
# Report FT8/FT4/WSPR spots to pskreporter.info.
[trx-server.pskreporter] [trx-server.pskreporter]
enabled = false enabled = false
host = "report.pskreporter.info" host = "report.pskreporter.info"
port = 4739 port = 4739
# Forward received APRS frames to APRS-IS.
[trx-server.aprsfi] [trx-server.aprsfi]
enabled = false enabled = false
host = "rotate.aprs.net" host = "rotate.aprs.net"
port = 14580 port = 14580
passcode = -1 passcode = -1
beacon = false
beacon_interval_secs = 1200
beacon_symbol_table = "/"
beacon_symbol_code = "-"
# Write decodes to JSON Lines files.
[trx-server.decode_logs] [trx-server.decode_logs]
enabled = false enabled = false
dir = "/path/to/log/dir" dir = "/var/lib/trx-rs/decoders"
aprs_file = "TRXRS-APRS-%YYYY%-%MM%-%DD%.log" aprs_file = "TRXRS-APRS-%YYYY%-%MM%-%DD%.log"
cw_file = "TRXRS-CW-%YYYY%-%MM%-%DD%.log" cw_file = "TRXRS-CW-%YYYY%-%MM%-%DD%.log"
ft8_file = "TRXRS-FT8-%YYYY%-%MM%-%DD%.log" ft8_file = "TRXRS-FT8-%YYYY%-%MM%-%DD%.log"
wspr_file = "TRXRS-WSPR-%YYYY%-%MM%-%DD%.log" wspr_file = "TRXRS-WSPR-%YYYY%-%MM%-%DD%.log"
wefax_file = "TRXRS-WEFAX-%YYYY%-%MM%-%DD%.log"
# Which decoders run. Trimming this list saves real CPU on small boxes.
# Valid names: aprs, aprs_hf, ais, cw, ft2, ft4, ft8, lrpt, sstv, vdes, wefax, wspr.
# output_dir sets where sstv/wefax/lrpt write images (default: user cache dir).
[trx-server.decoders]
enabled = [
"aprs",
"aprs_hf",
"ais",
"cw",
"ft2",
"ft4",
"ft8",
"lrpt",
"sstv",
"vdes",
"wefax",
"wspr",
]
# SoapySDR pipeline; used when [rig.access] type = "sdr".
[trx-server.sdr] [trx-server.sdr]
sample_rate = 1920000 sample_rate = 1920000
spectrum_fft_size = 1024
spectrum_interval_ms = 50
bandwidth = 1500000 bandwidth = 1500000
wfm_deemphasis_us = 50 wfm_deemphasis_us = 50
center_offset_hz = 100000 center_offset_hz = 100000
channels = [] channels = []
max_virtual_channels = 4 max_virtual_channels = 4
spectrum_fft_size = 1024
spectrum_interval_ms = 50
# "auto" for hardware AGC, or "manual".
[trx-server.sdr.gain] [trx-server.sdr.gain]
mode = "auto" mode = "auto"
value = 30.0 value = 30.0
# Software squelch on demodulated audio.
[trx-server.sdr.squelch] [trx-server.sdr.squelch]
enabled = false enabled = false
threshold_db = -65.0 threshold_db = -65.0
hysteresis_db = 3.0 hysteresis_db = 3.0
tail_ms = 180 tail_ms = 180
# Impulse-noise suppression on the IQ stream.
[trx-server.sdr.noise_blanker]
enabled = false
threshold = 10.0
# Timeout and buffer tuning. The defaults suit most setups.
[trx-server.timeouts]
command_exec_timeout_ms = 10000
poll_refresh_timeout_ms = 8000
io_timeout_ms = 10000
request_timeout_ms = 12000
rig_task_channel_buffer = 32
# Client: exposes the radio to users.
[trx-client]
# Labels shown in the web UI.
[trx-client.general] [trx-client.general]
callsign = "N0CALL" callsign = "N0CALL"
website_url = "https://haxx.space" website_url = "https://haxx.space"
@@ -88,25 +159,52 @@ website_name = "haxx.space"
ais_vessel_url_base = "https://www.vesselfinder.com/?mmsi=" ais_vessel_url_base = "https://www.vesselfinder.com/?mmsi="
log_level = "info" log_level = "info"
# Legacy single-remote form; prefer [[remotes]] below.
[trx-client.remote] [trx-client.remote]
url = "192.168.1.100:9000"
rig_id = "hf"
poll_interval_ms = 750 poll_interval_ms = 750
spectrum_interval_ms = 50 spectrum_interval_ms = 50
[trx-client.remote.auth] [trx-client.remote.auth]
[[trx-client.remotes]]
name = "home-hf"
url = "192.168.1.100:4530"
rig_id = "hf"
poll_interval_ms = 750
spectrum_interval_ms = 50
[trx-client.remotes.auth]
token = "my-token" token = "my-token"
[[trx-client.remotes]]
name = "home-vhf"
url = "192.168.1.100:4530"
rig_id = "vhf"
poll_interval_ms = 750
spectrum_interval_ms = 50
[trx-client.remotes.auth]
token = "my-token"
# Web UI. default_rig_name and the per-rig maps are keyed by the
# [[remotes]] name, not the server-side rig id.
[trx-client.frontends.http] [trx-client.frontends.http]
enabled = true enabled = true
listen = "127.0.0.1" listen = "127.0.0.1"
port = 8080 port = 8080
default_rig_id = "hf" default_rig_name = "home-hf"
initial_map_zoom = 10 initial_map_zoom = 10
spectrum_coverage_margin_hz = 50000 spectrum_coverage_margin_hz = 50000
spectrum_usable_span_ratio = 0.9200000166893005 spectrum_usable_span_ratio = 0.9200000166893005
show_sdr_gain_control = true show_sdr_gain_control = true
bandplan_enabled = true
bandplan_region = "iaru_r1"
decode_history_retention_min = 1440
[trx-client.frontends.http.decode_history_retention_min_by_rig]
# Passphrase login for the web UI. rx_passphrase_file and
# control_passphrase_file keep the secrets out of this file.
[trx-client.frontends.http.auth] [trx-client.frontends.http.auth]
enabled = false enabled = false
rx_passphrase = "rx-passphrase-example" rx_passphrase = "rx-passphrase-example"
@@ -116,6 +214,7 @@ session_ttl_min = 480
cookie_secure = false cookie_secure = false
cookie_same_site = "Lax" cookie_same_site = "Lax"
# Hamlib-compatible TCP interface, one listener per rig.
[trx-client.frontends.rigctl] [trx-client.frontends.rigctl]
enabled = false enabled = false
listen = "127.0.0.1" listen = "127.0.0.1"
@@ -123,6 +222,7 @@ port = 4532
[trx-client.frontends.rigctl.rig_ports] [trx-client.frontends.rigctl.rig_ports]
# JSON-over-TCP control interface.
[trx-client.frontends.http_json] [trx-client.frontends.http_json]
enabled = true enabled = true
listen = "127.0.0.1" listen = "127.0.0.1"
@@ -131,12 +231,16 @@ port = 0
[trx-client.frontends.http_json.auth] [trx-client.frontends.http_json.auth]
tokens = [] tokens = []
# Where to fetch the audio stream from.
[trx-client.frontends.audio] [trx-client.frontends.audio]
enabled = true enabled = true
server_port = 4531 server_port = 4531
[trx-client.frontends.audio.rig_urls]
[trx-client.frontends.audio.rig_ports] [trx-client.frontends.audio.rig_ports]
# Play RX audio on a local sound device and capture TX from one.
[trx-client.frontends.audio.bridge] [trx-client.frontends.audio.bridge]
enabled = false enabled = false
bitrate_bps = 192000 bitrate_bps = 192000