This is the second post in a three-part mini-series:
- Part 1: Stable device paths for RTL-SDR (udev creates a predictable
/dev/sdr-<serial>) - Part 2 (this post): Run Direwolf (receive-only iGate) from an RTL-SDR using Docker, without exposing the whole USB bus
- Part 3: Display logs / status on a dedicated virtual terminal (VT) without logging in
Why this approach
The RTL-SDR tools (via libusb) typically expect the device to be reachable at the canonical usbfs path:
/dev/bus/usb/<bus>/<dev>.
The catch is that the bus/dev numbers change across reboots and re-plugs.
In Part 1 we solved stability on the host by creating a consistent udev symlink:
/dev/sdr-<serial>.
In this post we’ll keep Docker locked down by passing only that one stable node into the container,
and then using a small entrypoint script to:
- Find the SDR in
/sys/bus/usb/devicesby serial - Compute the live bus/dev numbers
- Create
/dev/bus/usb/BBB/DDDinside the container (as a symlink to/dev/sdr-<serial>) - Drop privileges and run
rtl_fm | direwolf
Result: predictable device selection, minimal device exposure, and a clean “works after reboot” startup.
Directory layout
/srv/direwolf/
├─ Dockerfile
├─ docker-compose.yml
├─ entrypoint.sh
├─ direwolf.conf
└─ .env
Notes:
- No host-specific paths in this post. Use
/srv/direwolf(or any directory you like). - We mount
direwolf.confso changes don’t require rebuilds. - We intentionally do not mount any general USB directories.
Step 1 — Create a non-interactive service user on the host
Use a dedicated host user for running this service. It should not be interactive. Prefer /usr/sbin/nologin (or similar).
# Example (adjust to your distro’s tools)
sudo useradd --system --create-home --shell /usr/sbin/nologin direwolf
# Confirm UID/GID for .env
id direwolf
If your stable RTL-SDR rule assigns a dedicated group (recommended), ensure your service user is in that group.
Step 2 — Create .env (no secrets in this template)
Create /srv/direwolf/.env. This post uses placeholders; replace with your values.
DW_UID=YOUR_UID_HERE
DW_GID=YOUR_GID_HERE
# Serial used by the stable host symlink: /dev/sdr-<serial>
RTL_SERIAL=YOUR_RTL_SERIAL_HERE
# run | diag
MODE=run
# RTL-FM / APRS defaults (optional)
APRS_FREQ=144.390M
RTL_RATE=48000
RTL_GAIN=40
RTL_PPM=0
Diagnostics mode (MODE=diag) keeps the container up without starting Direwolf, so you can run rtl_test, rtl_fm,
and other checks interactively.
Step 3 — Dockerfile (build Direwolf, disable DNS-SD, include su-exec)
This Dockerfile builds Direwolf and explicitly disables DNS-SD compilation (so no Avahi runtime dependency).
It installs su-exec so the entrypoint can drop privileges after creating the usbfs symlink.
FROM alpine:3.20
ARG DIREWOLF_BRANCH=master
# Build deps can include avahi-dev if your toolchain expects it, even though DNS-SD is disabled.
ENV BUILD_DEPS="build-base cmake git pkgconf alsa-lib-dev avahi-dev" \
RUNTIME_DEPS="alsa-lib libgcc libstdc++ rtl-sdr libusb ca-certificates tzdata su-exec"
RUN apk add --no-cache $BUILD_DEPS $RUNTIME_DEPS \
&& git clone https://github.com/wb2osz/direwolf.git /tmp/direwolf \
&& cd /tmp/direwolf \
&& git checkout $DIREWOLF_BRANCH \
&& mkdir build && cd build \
&& cmake .. -DOPTIONAL_DNSSD=OFF \
&& make -j"$(nproc)" \
&& make install \
&& make install-conf \
&& rm -rf /tmp/direwolf \
&& apk del $BUILD_DEPS
# A place for logs if you choose to enable file logging later
RUN mkdir -p /usr/local/etc/direwolf /var/log/direwolf
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
EXPOSE 8000
# Start as root so we can create /dev/bus/usb/BBB/DDD, then drop privileges in entrypoint
USER root:root
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
Step 4 — Entrypoint script (map USB node, then drop privileges)
Save this as /srv/direwolf/entrypoint.sh and make it executable (chmod +x).
It does four key things:
- Reads
DW_UID,DW_GID, andRTL_SERIALfrom.env - Waits for
/dev/sdr-<serial>to exist (the only device node we pass in) - Finds bus/dev numbers by serial from
/sys, and creates/dev/bus/usb/BBB/DDD - Runs
rtl_fm | direwolfas an unprivileged user (or sleeps in diag mode)
#!/bin/sh
set -eu
DW_UID="${DW_UID:-YOUR_UID_HERE}"
DW_GID="${DW_GID:-YOUR_GID_HERE}"
SERIAL="${RTL_SERIAL:-YOUR_RTL_SERIAL_HERE}"
MODE="${MODE:-run}"
SRC_DEV="/dev/sdr-${SERIAL}"
# Create group/user at runtime (no rebuild needed). Ignore errors if they already exist.
addgroup -g "${DW_GID}" -S direwolf 2>/dev/null || true
adduser -u "${DW_UID}" -S -D -H -G direwolf -s /sbin/nologin direwolf 2>/dev/null || true
# Wait briefly for the mapped device node to appear
i=0
while [ $i -lt 50 ]; do
if [ -c "${SRC_DEV}" ]; then
break
fi
i=$((i+1))
sleep 0.2
done
if [ ! -c "${SRC_DEV}" ]; then
echo "ERROR: ${SRC_DEV} not present in container."
echo "Check docker-compose.yml devices: - /dev/sdr-${SERIAL}:/dev/sdr-${SERIAL}"
exit 1
fi
# Find sysfs USB device with matching serial; get busnum/devnum
SYSDEV=""
for d in /sys/bus/usb/devices/*; do
[ -f "$d/serial" ] || continue
s="$(cat "$d/serial" 2>/dev/null || true)"
if [ "$s" = "$SERIAL" ]; then
SYSDEV="$d"
break
fi
done
if [ -z "$SYSDEV" ]; then
echo "ERROR: Could not find USB device with serial '$SERIAL' in /sys/bus/usb/devices."
exit 1
fi
BUSNUM="$(cat "$SYSDEV/busnum")"
DEVNUM="$(cat "$SYSDEV/devnum")"
BUS="$(printf '%03d' "$BUSNUM")"
DEV="$(printf '%03d' "$DEVNUM")"
mkdir -p "/dev/bus/usb/$BUS"
# Create canonical path libusb expects, pointing to our mapped node
ln -sf "$SRC_DEV" "/dev/bus/usb/$BUS/$DEV"
echo "RTL-SDR serial=$SERIAL mapped: $SRC_DEV -> /dev/bus/usb/$BUS/$DEV"
if [ "$MODE" = "diag" ]; then
echo "MODE=diag: container will stay up for interactive diagnostics."
exec su-exec "${DW_UID}:${DW_GID}" sh -lc "sleep infinity"
fi
# Run rtl_fm -> direwolf (Direwolf must be configured with: ADEVICE stdin null)
APRS_FREQ="${APRS_FREQ:-144.390M}"
RTL_RATE="${RTL_RATE:-48000}"
RTL_GAIN="${RTL_GAIN:-40}"
RTL_PPM="${RTL_PPM:-0}"
exec su-exec "${DW_UID}:${DW_GID}" sh -lc \
"rtl_fm -d \"$SERIAL\" -M fm -f \"$APRS_FREQ\" -s \"$RTL_RATE\" -g \"$RTL_GAIN\" -p \"$RTL_PPM\" - \
| /usr/local/bin/direwolf -c /usr/local/etc/direwolf/direwolf.conf -r \"$RTL_RATE\" -t 0"
Step 5 — Direwolf config (sanitized template)
This is a minimal receive-only configuration for stdin audio (from rtl_fm).
Replace the callsign and APRS-IS credentials with your own. Do not publish your passcode.
# Audio input from rtl_fm via stdin, and no TX audio device
ADEVICE stdin null
ACHANNELS 1
# Identify this station (replace values)
MYCALL N0CALL-10
# APRS-IS login (replace values; keep passcode private)
IGLOGIN N0CALL-10 <APRS_PASSCODE>
# APRS-IS server (regional option)
IGSERVER noam.aprs2.net
# Basic AFSK 1200
MODEM 1200
CHANNEL 0
# Optional: beacons
# If you enable beacons, DO NOT publish real coordinates in public examples.
# PBEACON delay=1 every=60 sendto=0 symbol="I&" overlay=R lat=XX^XX.XXN long=XXX^XX.XXW comment="APRS RX iGate" via=WIDE1-1 compress=0
# IBEACON SENDTO=IG delay=30 every=30
# Optional: disable file logging and rely on Docker logs (recommended for containers)
# LOGDIR /var/log/direwolf/
Step 6 — docker-compose.yml (least privilege + one-device passthrough)
This compose file:
- Builds the image locally
- Passes only the stable host node
/dev/sdr-<serial> - Mounts
direwolf.confread-only so edits don’t require rebuilds - Keeps privileges low (drops everything, then adds only what’s needed for privilege drop)
- Rotates Docker logs so nothing grows forever
services:
direwolf:
build:
context: .
dockerfile: Dockerfile
container_name: direwolf
restart: always
env_file:
- ./.env
# Edit config without rebuild
volumes:
- ./direwolf.conf:/usr/local/etc/direwolf/direwolf.conf:ro
# Pass ONLY the stable RTL-SDR node created by udev (Part 1)
devices:
- "/dev/sdr-${RTL_SERIAL}:/dev/sdr-${RTL_SERIAL}"
# Hardening: no ambient privileges; add only what's needed for su-exec to drop privileges
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- SETUID
- SETGID
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
If you see an error like su-exec: setgroups(...): Operation not permitted,
it means the container can’t change identity. The fix is exactly the cap_add block above.
Bring-up and verification
Build and start
cd /srv/direwolf
docker compose build --no-cache
docker compose up -d --force-recreate
docker logs -f direwolf
Diagnostics mode
Set MODE=diag in .env, recreate, then exec into the container:
docker compose up -d --force-recreate
docker logs -f direwolf
docker exec -it direwolf sh
# inside container
rtl_test -t
rtl_test -d <SERIAL> -t
Expected “working” logs
RTL-SDR serial=... mapped: /dev/sdr-... -> /dev/bus/usb/BBB/DDDFound 1 device(s)fromrtl_test- Direwolf stays running (no immediate
End of file on stdin)
Troubleshooting
rtl_test: “No supported devices found”
- Confirm the host has the stable node:
ls -l /dev/sdr-<serial> - Confirm Compose is passing it through (check
docker compose config) - Use diag mode and run
rtl_test -tinside the container
Direwolf exits immediately: “End of file on stdin”
- This means
rtl_fmexited (so stdin closed). Look earlier in container logs for why. - Verify the config uses
ADEVICE stdin nulland that you are invoking Direwolf with-c.
su-exec permission errors
- Keep
cap_add: [SETUID, SETGID]when you alsocap_drop: ALL. - Install
su-execin the runtime image.
Up next: a VT dashboard without logging in
Part 3 will take the now-stable service logs and display them on a dedicated virtual terminal (VT) without requiring an interactive login. This keeps the box headless-friendly while still giving you an “appliance-style” status screen when you’re physically near it.
No comments:
Post a Comment