02 January 2026

Part 2: Running Direwolf from an RTL-SDR in Docker (RX iGate, least privilege)

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:

  1. Find the SDR in /sys/bus/usb/devices by serial
  2. Compute the live bus/dev numbers
  3. Create /dev/bus/usb/BBB/DDD inside the container (as a symlink to /dev/sdr-<serial>)
  4. 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.conf so 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, and RTL_SERIAL from .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 | direwolf as 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.conf read-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/DDD
  • Found 1 device(s) from rtl_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 -t inside the container

Direwolf exits immediately: “End of file on stdin”

  • This means rtl_fm exited (so stdin closed). Look earlier in container logs for why.
  • Verify the config uses ADEVICE stdin null and that you are invoking Direwolf with -c.

su-exec permission errors

  • Keep cap_add: [SETUID, SETGID] when you also cap_drop: ALL.
  • Install su-exec in 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