Part 13 of Running It For Real. Part 12 isolated the daemon in a container. The two native-OS deployments — a Linux systemd service and a Windows install — want the same result by different means. On Linux, systemd can sandbox a native process nearly as tightly as a container: give it one USB device, one writable directory, and nothing else. On Windows, the work is different in kind — an installer that separates the program from the operator’s irreplaceable captures, seeds a working config, bundles the driver, and — the part that separates a good installer from a hostile one — never eats your recordings on uninstall. This post is both, because “runs natively” is two very different engineering problems that share one goal: least surprise, least privilege.
TL;DR: The shipped systemd unit is
Type=execwithRestart=on-failure, wrapped in a sandbox:DynamicUser=true,ProtectSystem=strict,ProtectHome=true, aStateDirectoryfor the only writable path, and the fullNoNewPrivileges/ProtectKernel*/Restrict*set — punctured by exactly one hole,DeviceAllow=char-usb_device rwmplusSupplementaryGroups=plugdev, so the pure-Go USBDEVFS backend can reach the dongle. The Windows side is an Inno Setup script that installs the program to Program Files but keeps all operator files in a separate data root, seeds a starterconfig.yamlthat survives reinstalls, bundles the Zadig WinUSB driver, and on uninstall preserves recordings, iq, and exports while offering to clear the rest.
Key takeaways
- systemd sandboxes a native process like a container does.
ProtectSystem,ProtectHome, and aStateDirectoryleave the daemon one writable path and a read-only rest-of-system. - One deliberate hole.
DeviceAllow=char-usb_device rwmis the systemd analogue of--device— the single capability the whole sandbox exists to grant safely. - The Windows installer separates program from data. The
.exelands in Program Files; config, recordings, IQ, and exports live under a user-chosen data root that reinstalls don’t touch. - Uninstall never destroys captures. Recordings, IQ, and exports are preserved unconditionally; only config/data/logs/web are offered for deletion, defaulting to keep.
Cheat sheet
| Piece | Where it lives | What it does |
|---|---|---|
| Service unit | docs/gophertrunk.service |
Type=exec, restart-on-failure |
| Filesystem sandbox | ProtectSystem=strict, StateDirectory=gophertrunk |
one writable path, read-only rest |
| The USB hole | DeviceAllow=char-usb_device rwm + SupplementaryGroups=plugdev |
reach /dev/bus/usb/* |
| Secret injection | EnvironmentFile=-/etc/gophertrunk/env |
bearer token out of config.yaml |
| Windows installer | installer/windows/gophertrunk.iss |
Inno Setup, admin, x64 |
| Data root | {code:DataDir} (default Documents\GopherTrunk) |
config/recordings/iq/exports/… |
| Driver | bundled zadig.exe |
one-time WinUSB bind per dongle |
In this post
- The unit, minus the sandbox — restart policy and secret injection.
- The sandbox — what systemd takes away, and the one thing it leaves.
- The USB hole — the single
DeviceAllowthe whole unit is built around. - The Windows installer — program vs data, and the config that survives.
- A humane uninstall — why your captures outlive the software.
The unit, minus the sandbox
Strip the hardening away and the systemd unit is unremarkable — which is the
point. It runs the daemon, restarts it if it dies, and pulls a secret out of an
environment file instead of config.yaml:
# docs/gophertrunk.service (shape)
[Service]
Type=exec
ExecStart=/usr/local/bin/gophertrunk run -config /etc/gophertrunk/config.yaml
Restart=on-failure
RestartSec=5
# Pull a bearer token out of an env file so it's not committed in config.yaml;
# reference it from config.yaml as api.auth.token_file.
# EnvironmentFile=-/etc/gophertrunk/env
Type=exec means systemd considers the service started once the binary has
exec‘d — simpler and more honest than forking, since the daemon never
daemonizes itself. Restart=on-failure with RestartSec=5 is the base-level
supervisor: a crash gets a fresh process five seconds later, the native analogue
of the container’s restart: unless-stopped. And the commented EnvironmentFile
is the pattern we’ve pointed at all series for every credential — the Broadcastify
key, the Icecast password, the API bearer token belong in an env file
0640-owned by root, not in a config that gets read and copied everywhere.
The sandbox: what systemd takes away
The rest of the [Service] block is a container-grade sandbox built from systemd
directives. The design is deny by default, permit one path:
# docs/gophertrunk.service (shape) — the sandbox
DynamicUser=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/gophertrunk /etc/gophertrunk/last_cc_cache.json
StateDirectory=gophertrunk
StateDirectoryMode=0750
NoNewPrivileges=true
ProtectKernelModules=true
ProtectKernelTunables=true
ProtectControlGroups=true
RestrictRealtime=true
RestrictNamespaces=true
RestrictSUIDSGID=true
SystemCallArchitectures=native
DynamicUser=true runs the daemon as an ephemeral, unprivileged user that exists
only for the service’s lifetime — no account to manage, no home to leak into.
ProtectSystem=strict mounts the entire filesystem read-only, and ProtectHome=true
hides /home, /root, and /run/user entirely. Against that read-only backdrop,
StateDirectory=gophertrunk creates and owns exactly one writable path
(/var/lib/gophertrunk) at mode 0750, and ReadWritePaths adds the one extra
file the daemon needs to persist (the last-control-channel cache). Everything else
— NoNewPrivileges, the three ProtectKernel*/ProtectControlGroups lines, the
Restrict* set, native-only syscalls — closes escalation and lateral-movement
paths a compromised process might otherwise reach. The result is a native daemon
that can write to one directory, read a read-only system, and touch one class of
device. That last clause is the interesting one.
The USB hole the whole unit is built around
A fully-locked sandbox that can’t see the radio is useless, so the unit punches exactly one deliberate hole:
# docs/gophertrunk.service (shape) — the one device permission
# The pure-Go USBDEVFS backend needs read+write on /dev/bus/usb/<bus>/<dev>;
# libsystemd routes this through cgroup device controllers.
DeviceAllow=char-usb_device rwm
SupplementaryGroups=plugdev
This is the systemd analogue of Part 12’s --device plus group_add.
DeviceAllow=char-usb_device rwm grants the sandbox read/write/mknod on USB
character devices through the cgroup device controller; SupplementaryGroups=plugdev
puts the DynamicUser in the group the host’s udev rule granted, so the node is
actually openable. The comment names the reason precisely: the pure-Go USBDEVFS
backend talks to /dev/bus/usb/<bus>/<dev> directly, so it needs read+write on the
node, and this is the one line that lets it through the otherwise-total device
lockdown. Operators who want tighter scope replace this with an explicit
DeviceAllow per enumerated dongle; the class-wide form is the trusted-host
default. There’s a parallel, commented pair for audio (char-alsa +
SupplementaryGroups=audio) that stays off unless audio.enabled is set — the
same opt-in posture as Part 8. The unit even documents its own sharp edge:
PrivateUsers=false is required today because the USB transport uses udev’s
netlink monitor, a thing to tighten once that dependency is gone.
The Windows installer: program vs data
Windows has no systemd, and the operating problem shifts from sandboxing a process to installing software a non-technical operator won’t regret. The Inno Setup script’s central decision is to split the program from the operator’s files:
{ installer/windows/gophertrunk.iss (shape) — two roots }
[Setup]
DefaultDirName={autopf}\GopherTrunk ; program → Program Files
PrivilegesRequired=admin
ArchitecturesAllowed=x64compatible
[Dirs] ; data → user-chosen root
Name: "{code:DataDir}\config"
Name: "{code:DataDir}\recordings"
Name: "{code:DataDir}\iq"
Name: "{code:DataDir}\exports"
Name: "{code:DataDir}\data"
Name: "{code:DataDir}\logs"
Name: "{code:DataDir}\web"
The .exe installs to Program Files ({autopf}), where it belongs. But every file
the operator creates or cares about — config, voice recordings, raw IQ, CSV/PDF
exports, the SQLite database, logs, and the bundled web consoles — lives under a
separate data root the installer prompts for, defaulting to
Documents\GopherTrunk because that’s the spot a non-admin user can always write
to. The shipped config.example.yaml uses config-relative paths (../recordings,
../iq, …), so once the seeded config.yaml lands in <DataRoot>\config the
daemon writes everything into the sibling folders automatically.
Two registry values make the daemon discoverable without a -config flag:
GOPHERTRUNK_CONFIG points at the seeded config, and GOPHERTRUNK_HOME at the
data root — and because the installer sets ChangesEnvironment=yes, Inno
broadcasts WM_SETTINGCHANGE so a freshly-opened shell sees them. The config
itself is seeded with onlyifdoesntexist uninsneveruninstall, so a reinstall
never overwrites operator edits and an uninstall never deletes the config file.
The installer also bundles zadig.exe — the WinUSB driver tool — so the operator
binds their RTL-SDR’s driver in one click rather than chasing a download, and drops
Start-menu shortcuts for the three web consoles (standard, Signal Lab, Config
Builder).
How that principle shaped the installer
- Config-relative paths make one data-root prompt enough. Because every default
path in
config.yamlis relative to the config file, the installer asks for one folder and every other file follows — no per-folder prompts, no absolute paths baked into a machine-specific config. - The data root is bridged into the uninstaller via the registry. Inno’s
[Code]state from the install run doesn’t survive into the uninstall run, so the data root is persisted toHKLM\Software\GopherTrunk\Install\DataDir— the only durable way the uninstaller can find the operator’s files to (optionally) clean them. - Every env var and PATH edit is reversible.
GOPHERTRUNK_CONFIG/GOPHERTRUNK_HOMEcarryuninsdeletevalue, and the PATH entry is stripped by an explicit uninstall step that sandwiches the match in semicolons so it never chops a path that’s a prefix of another.
A humane uninstall
The single most operator-respecting line in the whole installer is what it won’t delete. On uninstall, the script strips its PATH entry, then asks whether to remove config, database, logs, and web console — and no matter what the operator answers, it never touches recordings, IQ, or exports:
{ installer/windows/gophertrunk.iss (shape) — WipeManagedData }
{ Deletes only Setup-managed folders; the operator's irreplaceable
captures (recordings, iq, exports) are DELIBERATELY preserved. }
DelTree(Root + 'config', True, True, True);
DelTree(Root + 'data', True, True, True);
DelTree(Root + 'logs', True, True, True);
DelTree(Root + 'web', True, True, True);
The uninstall prompt defaults to No (MB_DEFBUTTON2), and even a Yes leaves
the capture folders standing. This is the desktop equivalent of the container’s
bind-mounted volumes: the software is disposable, the data is not. An operator who
ran GopherTrunk for six months and then uninstalls it still has every recording —
which is exactly the promise a 24/7 receiver should keep.
Where this goes next
We’ve now hardened where the daemon runs on every surface — container, systemd, Windows. The finale, Part 14, is about staying up: the health endpoints an orchestrator or a monitor polls, the heartbeat that makes a stop never silent, the USB watchdog that reacquires a dropped dongle without a restart, and what months of uninterrupted uptime actually look like — the operational payoff the whole series has been building toward.
FAQ
Do I have to use all the systemd hardening directives?
No — the unit’s comment says most operators leave them alone but to remove any that
block your specifics. The load-bearing ones are ProtectSystem=strict +
StateDirectory (the writable-path sandbox) and the DeviceAllow + SupplementaryGroups
pair (USB access). Drop a Restrict* line if it conflicts with your host, but keep the
device permission or the daemon can’t see the radio.
Why DeviceAllow=char-usb_device rwm instead of a specific device?
It’s the trusted-host default — a class-wide grant that works regardless of which bus
the dongle enumerates on. For tighter scope, replace it with an explicit DeviceAllow
line per enumerated RTL-SDR. Either way SupplementaryGroups=plugdev is needed so the
DynamicUser is in the group your udev rule granted.
Where does the Windows installer put my recordings?
Under the data root you choose at install time (default Documents\GopherTrunk), in a
recordings subfolder — separate from the program in Program Files. Config, IQ, exports,
database, and logs are siblings under the same root, so everything you care about lives
in one place you pick and can back up.
Will reinstalling or upgrading overwrite my config?
No. The seeded config.yaml uses onlyifdoesntexist, so an existing config is never
overwritten by a reinstall, and uninsneveruninstall means an uninstall leaves it in
place too. Your edits survive both.
What does uninstall delete? Only Setup-managed folders — config, data, logs, web — and only if you say yes to the prompt (which defaults to No). Your recordings, IQ captures, and exports are preserved unconditionally. The uninstaller also strips its PATH entry and its environment variables, but never your captures.
Series navigation
Part 13 of 14 · ← Part 12: Docker & RTL-SDR USB Pass-Through · Next → Part 14: Staying Up — Health, Watchdogs & the Ops Payoff