Compare commits

..
3 Commits
Author SHA1 Message Date
sjg be9d5c301b [docs](trx-rs): add safe deployment guide
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / reuse (push) Canceled after 0s
Document deployment with a dedicated service account, restricted device access, authenticated network listeners, systemd user services, reverse proxying, verification, upgrades, and rollback.

Assisted-By: OpenAI Codex (GPT-5)
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-18 22:53:00 +02:00
sjg b73c97dd5b [fix](trx-server): honor per-rig audio bind addresses
Use each rig's configured audio listener unless --listen explicitly overrides all bind addresses. Keep preflight socket validation consistent with runtime behavior.

Assisted-By: OpenAI Codex (GPT-5)
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-18 22:45:33 +02:00
sjg 6276c3feea [fix](trx-rs): allow systemd hardware discovery
Do not restrict socket families in the generic user units because SoapySDR and libusb require netlink sockets to enumerate radio hardware.

Assisted-By: OpenAI Codex (GPT-5)
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-18 22:44:58 +02:00
5 changed files with 330 additions and 11 deletions
+5
View File
@@ -149,6 +149,11 @@ Serial (`/dev/ttyUSB*`) and audio access require your user to be in the
`dialout` and `audio` groups. Remove everything with `script/uninstall.sh`
(add `--purge` to also delete the config).
For an unattended or remotely accessible installation, follow the
[safe deployment guide](docs/Deployment.md). It covers a dedicated service
account, device permissions, authentication, firewall and reverse-proxy
boundaries, verification, upgrades, and rollback.
## How It Works
```mermaid
+287
View File
@@ -0,0 +1,287 @@
# Safe deployment
This guide deploys trx-rs on Linux as a dedicated, unprivileged user with
systemd user services. It keeps radio-device access, configuration, and runtime
data separate from an administrator's account.
The examples use `trx-rs` as the account name and `/opt/trx-rs/bin` for
root-owned executables. Adapt group names and firewall commands to your Linux
distribution.
## 1. Decide what must be reachable
Only expose listeners that another machine actually needs:
| Listener | Typical port | Recommended exposure |
| --- | ---: | --- |
| Server control | TCP 4530 | Loopback or trusted radio LAN only |
| Server audio | TCP 4531 and per-rig ports | Trusted radio LAN only |
| Client web UI | TCP 8080 or a chosen port | Loopback behind HTTPS proxy |
| Client rigctl | Per-rig TCP ports | Loopback or trusted LAN only |
| Client JSON | Configured TCP port | Loopback or trusted LAN only |
`127.0.0.1` accepts connections only from the same host. Use a specific LAN
address when possible, or `0.0.0.0` when the listener must accept connections
on every IPv4 interface. Binding a socket does not configure the host firewall.
Do not expose unauthenticated control, audio, rigctl, or JSON listeners to the
public Internet. Prefer a VPN for links between radio sites. Put the web UI
behind an HTTPS reverse proxy when it is remotely accessible.
## 2. Create the service account
Create a non-login service account with a home directory:
```bash
sudo useradd --create-home --shell /usr/sbin/nologin trx-rs
sudo chmod 0750 /home/trx-rs
```
Add only the hardware groups required on this host. Common group names are
`dialout` for serial devices and `audio` for sound devices:
```bash
sudo usermod -aG dialout,audio trx-rs
```
SDR USB access is distribution- and device-specific. Install the vendor's udev
rules or add a narrowly scoped rule for the device's USB vendor/product IDs.
Avoid making all USB devices world-writable. After reconnecting the device,
verify access as the service account:
```bash
sudo -u trx-rs test -r /dev/ttyUSB0
sudo -u trx-rs test -w /dev/ttyUSB0
sudo -u trx-rs SoapySDRUtil --find
```
Run only the checks relevant to the configured hardware. Group membership and
udev-rule changes normally require reconnecting the device or restarting the
service.
## 3. Build and install immutable binaries
Build from a reviewed revision as a normal development user, not as root:
```bash
git clone https://github.com/stanislawgrams/trx-rs.git
cd trx-rs
git switch --detach <reviewed-tag-or-commit>
cargo build --release -p trx-server -p trx-client -p trx-configurator
```
Install root-owned binaries into a directory the service user cannot modify:
```bash
sudo install -d -o root -g root -m 0755 /opt/trx-rs/bin
sudo install -o root -g root -m 0755 \
target/release/trx-server \
target/release/trx-client \
target/release/trx-configurator \
/opt/trx-rs/bin/
```
If SDR support is not needed, build `trx-server` with
`--no-default-features`. Keep the source revision and Rust toolchain used for
the build in deployment records.
## 4. Install and validate configuration
Create private configuration and state directories, then seed the example:
```bash
sudo install -d -o trx-rs -g trx-rs -m 0700 \
/home/trx-rs/.config/trx-rs \
/home/trx-rs/.config/systemd/user
sudo install -o trx-rs -g trx-rs -m 0600 \
trx-rs.toml.example /home/trx-rs/.config/trx-rs/trx-rs.toml
sudoedit /home/trx-rs/.config/trx-rs/trx-rs.toml
```
At minimum:
- remove unused example rigs and remotes;
- select the correct serial, TCP, or SDR device;
- give every enabled rig a unique ID and audio port;
- use `127.0.0.1` for same-host connections;
- use a LAN address or `0.0.0.0` only for deliberately remote listeners;
- enable authentication before exposing server control or the web UI;
- replace every example password and token;
- keep credential files mode `0600` and owned by `trx-rs`;
- set `cookie_secure = true` when the web UI is served through HTTPS.
For a remote server, control and each per-rig audio listener need an explicit
non-loopback address:
```toml
[trx-server.listen]
enabled = true
listen = "0.0.0.0"
port = 4530
[trx-server.listen.auth]
tokens_file = "/home/trx-rs/.config/trx-rs/server-tokens"
[[trx-server.rigs]]
id = "station-hf"
[trx-server.rigs.audio]
enabled = true
listen = "0.0.0.0"
port = 4531
```
When using several `[[trx-server.rigs]]` entries, configure audio under each
`[trx-server.rigs.audio]` section. Do not rely on the legacy flat
`[trx-server.audio]` section.
For a web UI behind a reverse proxy, keep the backend on loopback and choose an
unused port. The proxy upstream must use the same address and port:
```toml
[trx-client.frontends.http]
enabled = true
listen = "127.0.0.1"
port = 7345
[trx-client.frontends.http.auth]
enabled = true
users_file = "/home/trx-rs/.config/trx-rs/http-users.json"
cookie_secure = true
```
Validate the configuration before starting either daemon:
```bash
sudo -u trx-rs /opt/trx-rs/bin/trx-server \
--check-config --config /home/trx-rs/.config/trx-rs/trx-rs.toml
sudo -u trx-rs /opt/trx-rs/bin/trx-client \
--check-config --config /home/trx-rs/.config/trx-rs/trx-rs.toml
```
Treat unknown-key and deprecated-section warnings as deployment errors. They
often mean a setting is not applied where expected.
## 5. Install the systemd user services
Render the packaged units with the immutable binary directory:
```bash
sed 's|@BINDIR@|/opt/trx-rs/bin|g' packaging/systemd/trx-server.service \
| sudo tee /home/trx-rs/.config/systemd/user/trx-server.service >/dev/null
sed 's|@BINDIR@|/opt/trx-rs/bin|g' packaging/systemd/trx-client.service \
| sudo tee /home/trx-rs/.config/systemd/user/trx-client.service >/dev/null
sudo chown trx-rs:trx-rs \
/home/trx-rs/.config/systemd/user/trx-server.service \
/home/trx-rs/.config/systemd/user/trx-client.service
sudo chmod 0644 \
/home/trx-rs/.config/systemd/user/trx-server.service \
/home/trx-rs/.config/systemd/user/trx-client.service
```
Enable lingering so the user manager runs without an interactive login, then
start it:
```bash
sudo loginctl enable-linger trx-rs
trx_uid=$(id -u trx-rs)
sudo systemctl start "user@${trx_uid}.service"
```
Manage the user services through that user's runtime directory:
```bash
trx_uid=$(id -u trx-rs)
sudo -u trx-rs XDG_RUNTIME_DIR="/run/user/${trx_uid}" \
systemctl --user daemon-reload
sudo -u trx-rs XDG_RUNTIME_DIR="/run/user/${trx_uid}" \
systemctl --user enable --now trx-server.service trx-client.service
```
The client unit has ordering, but not activation, on `trx-server.service`. If
both are enabled locally, the client starts after the server process is
started. If the local server is disabled because all remotes are external, the
client does not start it. The client retries remote connections; systemd cannot
order startup against a service on another host.
## 6. Firewall and reverse proxy
Allow only required ports and sources. For example, permit server control and
audio only from the trusted radio subnet, not from every interface. Exact
commands differ between nftables, firewalld, and ufw.
For the web UI, terminate TLS in a maintained reverse proxy and send traffic to
the loopback backend. Preserve WebSocket upgrade headers if the proxy requires
them. Example nginx location:
```nginx
location / {
proxy_pass http://127.0.0.1:7345;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
```
Test the backend directly before debugging a `502 Bad Gateway` response:
```bash
curl --fail --show-error http://127.0.0.1:7345/
```
A refused connection means nothing is listening at the proxy's configured
address and port. Check for port conflicts with `ss -ltnp` and confirm the
application log reports the same bind address as the proxy upstream.
## 7. Verify the deployment
```bash
trx_uid=$(id -u trx-rs)
sudo -u trx-rs XDG_RUNTIME_DIR="/run/user/${trx_uid}" \
systemctl --user status trx-server.service trx-client.service
sudo journalctl _UID="${trx_uid}" \
-u trx-server.service -u trx-client.service --since today
sudo ss -ltnp
```
Verify all of the following:
- both required services remain active without a restart loop;
- radio hardware opens successfully;
- logs show the intended control, audio, and frontend bind addresses;
- the client connects to every configured control and audio endpoint;
- only intended interfaces expose listeners;
- authentication works and anonymous access is rejected where configured;
- the HTTPS proxy returns the UI and supports live updates;
- the service account cannot write `/opt/trx-rs/bin`.
Do not rely only on `systemctl` reporting `active`: individual frontend tasks
can fail after the main client process starts. Always inspect startup logs and
probe each required endpoint.
## 8. Upgrade and roll back
Build and validate a new revision before replacing the installed executables.
Back up configuration and state first. Stop the services, install all binaries
from the same build, and restart:
```bash
trx_uid=$(id -u trx-rs)
sudo -u trx-rs XDG_RUNTIME_DIR="/run/user/${trx_uid}" \
systemctl --user stop trx-client.service trx-server.service
sudo install -o root -g root -m 0755 \
target/release/trx-server \
target/release/trx-client \
target/release/trx-configurator \
/opt/trx-rs/bin/
sudo -u trx-rs XDG_RUNTIME_DIR="/run/user/${trx_uid}" \
systemctl --user start trx-server.service trx-client.service
```
Keep the previous binaries or package revision available for rollback. If the
new version fails, stop both services, restore the complete previous binary
set, restore configuration only if its format changed, and start the services
again.
-3
View File
@@ -33,9 +33,6 @@ RestartSec=2
KillSignal=SIGINT
TimeoutStopSec=15
NoNewPrivileges=true
# The client opens outbound TCP connections to remote servers and binds TCP
# listeners for its configured HTTP, JSON, rigctl, and audio frontends.
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
[Install]
WantedBy=default.target
-3
View File
@@ -33,9 +33,6 @@ RestartSec=2
KillSignal=SIGINT
TimeoutStopSec=15
NoNewPrivileges=true
# Permit local IPC plus IPv4/IPv6 listening and outbound connections. Ports
# are configuration-defined, so SocketBindAllow= cannot safely narrow them.
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
[Install]
WantedBy=default.target
+38 -5
View File
@@ -922,11 +922,10 @@ fn bound_sockets(cli: &Cli, cfg: &ServerConfig, rigs: &[RigInstanceConfig]) -> V
"[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,
cli.listen.unwrap_or(rig.audio.listen),
rig.audio.port,
format!("rig \"{}\" [audio]", rig.id),
));
@@ -1276,9 +1275,9 @@ async fn main() -> DynResult<()> {
}
}));
// Spawn audio stack.
// listen_override priority: --listen CLI flag > global [audio].listen > per-rig default.
let audio_listen_override = cli.listen.or(Some(cfg.audio.listen));
// Spawn audio stack. --listen overrides every configured bind address;
// otherwise each rig keeps its own [rigs.audio].listen value.
let audio_listen_override = cli.listen;
#[cfg(feature = "soapysdr")]
let audio_vchan_manager = sdr_vchan_manager.clone();
#[cfg(not(feature = "soapysdr"))]
@@ -1417,6 +1416,40 @@ mod tests {
assert!(parse_serial_addr(" ").is_err());
}
#[test]
fn bound_sockets_preserves_per_rig_audio_addresses() {
let cli = Cli::parse_from(["trx-server"]);
let cfg = ServerConfig::default();
let mut local = RigInstanceConfig::default();
local.id = "local".into();
local.audio.listen = "127.0.0.1".parse().unwrap();
local.audio.port = 4531;
let mut remote = RigInstanceConfig::default();
remote.id = "remote".into();
remote.audio.listen = "0.0.0.0".parse().unwrap();
remote.audio.port = 4532;
let sockets = bound_sockets(&cli, &cfg, &[local, remote]);
let local = sockets.iter().find(|s| s.port == 4531).unwrap();
let remote = sockets.iter().find(|s| s.port == 4532).unwrap();
assert_eq!(local.addr, "127.0.0.1".parse::<IpAddr>().unwrap());
assert_eq!(remote.addr, "0.0.0.0".parse::<IpAddr>().unwrap());
}
#[test]
fn bound_sockets_applies_cli_listen_to_every_audio_listener() {
let cli = Cli::parse_from(["trx-server", "--listen", "192.0.2.10"]);
let cfg = ServerConfig::default();
let mut rig = RigInstanceConfig::default();
rig.id = "remote".into();
rig.audio.listen = "127.0.0.1".parse().unwrap();
rig.audio.port = 4532;
let sockets = bound_sockets(&cli, &cfg, &[rig]);
let audio = sockets.iter().find(|s| s.port == 4532).unwrap();
assert_eq!(audio.addr, "192.0.2.10".parse::<IpAddr>().unwrap());
}
#[test]
fn default_audio_bandwidth_for_mode_table() {
assert_eq!(default_audio_bandwidth_for_mode(&RigMode::USB), 3_000);