Skip to content
Building a Winlink RMS Gateway on a Raspberry Pi

Winlink lets you send and receive email over radio, with no internet required on the client end. Somewhere between you and the internet, though, there has to be a gateway: an RMS (Radio Mail Server) station that bridges the AX.25 packet network to Winlink’s CMS (Common Message Server). This post documents everything needed to run one of those gateways yourself, end to end: getting a Winlink account, requesting sysop permissions for a new RMS, picking and installing the OS on a Raspberry Pi, wiring up the AX.25 stack and rmsgw, and a source rebuild needed to fix a real bug in upstream’s lifecycle hooks. It runs my own station, DL7EDU-10, on 144.900 MHz / 1200 baud AFSK, grid square JO62rn.

This is not a “5 minute quickstart.” An RMS gateway is a small radio station that other hams will connect through, treat the steps below as a full build, not a checklist to rubber-stamp.

1. Create a Winlink account

If you don’t already have one:

  1. Go to winlink.org and register with your callsign. You’ll need a valid amateur radio license, Winlink verifies callsigns against the FCC/national regulator databases where possible.
  2. Confirm the account by email and log in to the Winlink web client at least once, this activates full account access (some features are gated until first login).
  3. From the web client, open Settings → Compose → Sysop actions, or go straight to the RMS sysop tools page. This is where you’ll later register your gateway’s channel.

You don’t need any special sysop flag to use Winlink; running an RMS is an additional step layered on top of a normal account.

2. Ask Winlink admins for gateway permission

Winlink is a shared, cooperative system: anyone can technically stand up an RMS Gateway, but the network’s development team gates official propagation of your station’s channel into the Winlink system (so other users can actually route mail through you) and expects some coordination first, especially on VHF/UHF where you might overlap with an existing gateway’s coverage.

Before building anything, email the Winlink development/sysop team (find current contact details on the Winlink RMS Gateway wiki page rather than hardcoding an address here, it does change). A short, direct email works well. Cover:

  • Your callsign and the callsign/SSID you intend to use for the gateway (typically your own callsign with an -10 or similar SSID, e.g. DL7EDU-10).
  • Grid square / approximate location.
  • Band, frequency, and mode (e.g. 144.900 MHz, 1200 baud AFSK packet).
  • That you intend to run linux-rmsgw (the Linux RMS Gateway software) and are requesting a channel registration / access to the sysop tools for it.
  • Whether there’s already RMS coverage in your area you’re aware of, and whether you’re filling a gap or adding redundancy.

Example email:

Subject: New RMS Gateway registration request, DL7EDU-10

Hello,

I’d like to register a new Winlink RMS Gateway. Details:

  • Callsign/SSID: DL7EDU-10
  • Grid square: JO62rn
  • Frequency/mode: 144.900 MHz, 1200 baud AFSK packet
  • Software: linux-rmsgw on a Raspberry Pi, hardware KISS TNC

I don’t believe there’s existing RMS coverage on VHF packet in my immediate area. Please let me know if there’s anything else you need from me before I can register the channel and get a channel password.

73, [your callsign]

You’ll get back guidance and, eventually, the ability to add your channel through the sysop tools, which is also where the channel password for channels.xml (step 6 below) comes from. Do not skip this step and just start transmitting into the Winlink CMS, an unregistered channel won’t relay mail properly and you’ll be operating outside the network’s coordination.

3. Hardware

What this build uses:

  • Raspberry Pi 3 (any model with a real USB port and enough grunt to compile rmsgw from source works; the Pi 3 does a full build in under 10 minutes).
  • A hardware KISS TNC, connected over USB. I use a Nino TNC, which enumerates as /dev/ttyACM0. A soundcard + Direwolf (software TNC) setup also works but isn’t what’s documented here, the Pi’s config below assumes a real KISS-speaking TNC.
  • A 2m (or appropriate band) radio with the TNC wired into its mic/data and speaker/data lines, set to your chosen packet frequency (144.900 MHz / 1200 baud AFSK in this build).

4. Install the Raspberry Pi OS

Use Raspberry Pi OS (Raspbian), 32-bit, Lite (no desktop needed, this is a headless appliance):

  1. Flash it with Raspberry Pi Imager. In the imager’s advanced options (gear icon / Ctrl+Shift+X), set the hostname (e.g. winlink), enable SSH, and set a username/password ahead of time, this avoids ever needing a monitor/keyboard on the Pi.
  2. Boot it, SSH in, and update:
    sudo apt update && sudo apt full-upgrade -y
    sudo raspi-config   # set locale/timezone, expand filesystem if needed
  3. Plug in the TNC and confirm it enumerates:
    ls /dev/ttyACM*
    dmesg | tail -20
    If you have other USB-serial devices, consider a udev rule to pin the TNC to a stable device name.

This build ran on kernel 6.12.25+rpt-rpi-v7, armv7l. Any reasonably current Raspberry Pi OS release works, the AX.25 tooling and rmsgw haven’t changed API in years.

5. Install packages

sudo apt install ax25-tools ax25-apps libax25 gcc make autoconf automake libtool git pkg-config libxml2-dev

ax25-tools/ax25-apps give you kissattach, kissparms, ax25d, and friends. The build dependencies (gcc, autoconf, etc.) are needed because linux-rmsgw is not packaged for Debian/Raspbian, you build it from source (step 7).

direwolf is optional, install it only if you want a software-TNC fallback path; it’s not part of the hardware-KISS-TNC setup described here.

6. Configure the AX.25 stack

Config lives at /etc/ax25/ (some installs symlink this to /usr/local/etc/ax25/ if ax25-tools was itself built from source with --prefix=/usr/local, either layout is fine as long as every file agrees).

axports, declares the port kissattach will bring up:

radio DL7EDU-10 57600 255 2 VHF

Replace DL7EDU-10 with your own callsign/SSID.

ax25d.conf, routes incoming AX.25 connections into rmsgw:

[DL7EDU-10 VIA radio]
NOCALL   * * * * * *  L
default  * * * * * *  - rmsgw /usr/local/bin/rmsgw rmsgw -l debug -P %d %U

Again, swap in your callsign.

Bring the port up manually to test before automating it:

sudo kissattach /dev/ttyACM0 radio
sudo kissparms -p radio -t 500 -l 500 -s 50 -r 50 -f n
sudo ax25d -l

7. Build and install linux-rmsgw (with working hooks)

linux-rmsgw isn’t in any Debian repo, and if you build it with the stock, unmodified Makefile.am files, you’ll get a working gateway but lifecycle hooks silently do nothing: upstream’s runhook() in lib/runhook.c is gated behind #ifdef HOOKS, and no official build ever defines HOOKS. If you want per-session/per-event hooks (for logging, notifications, automation), you need to patch the build, and you need one additional fix on top of that or the very first live connection through a hooks-enabled build will hang.

7.1 Get the source and patch it

mkdir -p ~/build && cd ~/build
curl -sL https://github.com/nwdigitalradio/rmsgw/archive/refs/heads/master.tar.gz -o rmsgw.tar.gz
tar xzf rmsgw.tar.gz && mv rmsgw-master rmsgw
cd rmsgw

Two changes on top of upstream, both are small enough to hand-patch if you don’t want to fetch a patch file from a third party:

  1. Enable hooks. Add -DHOOKS -DHOOKDIR='"/usr/local/etc/rmsgw/hooks"' to the CPPFLAGS in three Makefile.am files, alongside the existing -DGWMONITOR:

    • lib/Makefile.am (librms_a_CPPFLAGS), this is where the real runhook() implementation lives.
    • rmsgw/Makefile.am (AM_CPPFLAGS), rmsgw/session.c has its own #ifdef HOOKS block affecting the post-rmsgw-session hook’s argument list, it must be built consistently with the library.
    • rmsgw_aci/Makefile.am (AM_CPPFLAGS), same consistency requirement for rmsgw_aci.
  2. Fix a real SIGCHLD bug. runhook() forks a child to run each hook script, then reaps it with a bare while (pid != wait(&status)) ; loop. That assumes wait() blocks. But ax25d (which spawns rmsgw per incoming connection) sets SIGCHLD to SIG_IGN, and that disposition survives execve() into rmsgw. With SIGCHLD ignored, the kernel auto-reaps children before runhook()’s own wait() ever runs, so wait() returns immediately with ECHILD, and the loop spins forever, pegging a CPU core and hanging the connection. A plain interactive SSH shell has SIGCHLD at SIG_DFL, so this only shows up on a real radio connection through ax25d, manual testing from a shell won’t catch it. Add this line in runhook(), right before the fork() call:

    signal(SIGCHLD, SIG_DFL);

    This guarantees the hook-running child is always reapable regardless of what the parent inherited.

You can verify you’ve hit the same bug with a minimal repro before trusting a build:

import signal, os
signal.signal(signal.SIGCHLD, signal.SIG_IGN)
os.execvp("/usr/local/bin/rmsgw", ["/usr/local/bin/rmsgw", "-l", "debug", "-P", "radio", "TESTCALL"])

Against an unpatched hooks build, this hangs with one core at 99.9%. Against the patched build, the hook runs, logs success, and the process exits cleanly.

7.2 Build

./autogen.sh
./configure --prefix=/usr/local
make -j4

Takes roughly 9 minutes on a Pi 3. Result: rmsgw/rmsgw and rmsgw_aci/rmsgw_aci, ARM/armv7l binaries, version banner unchanged (still whatever upstream tag you built, e.g. 2.5.1), since this is a compile-flag change, not an upstream version bump.

7.3 Verify before installing over anything live

strings rmsgw/rmsgw | grep -i "running hook"       # confirms the real implementation is compiled in, not the stub
strace -f -o /tmp/trace.log ./rmsgw/rmsgw -l debug -P radio TESTCALL   # check for execve() calls into your hook scripts

Then run the SIGCHLD repro above against the freshly built binary, from ~/build/rmsgw/{rmsgw,rmsgw_aci}/, not the installed path, before touching anything live.

7.4 Install

sudo install -m 755 -o root -g root ~/build/rmsgw/rmsgw/rmsgw /usr/local/bin/rmsgw
sudo install -m 755 -o root -g root ~/build/rmsgw/rmsgw_aci/rmsgw_aci /usr/local/bin/rmsgw_aci

No service restart is needed: ax25d.conf spawns a fresh rmsgw per incoming connection rather than running a long-lived daemon, and cron just executes whatever’s at /usr/local/bin/rmsgw_aci on its next scheduled run. Keep a timestamped backup of whatever was there before you overwrite it, so a rollback is a two-file cp away if something looks wrong.

8. Configure the RMS Gateway itself

Config lives at /usr/local/etc/rmsgw/ (or /etc/rmsgw/, matching whatever layout you chose for AX.25 above):

  • gateway.conf, set GWCALL and GRIDSQUARE for your station. LOGFACILITY=LOCAL0 / LOGMASK=INFO route logging to journald, useful for the verification steps below.
  • channels.xml, declares your channel: callsign, grid, frequency, baud, power, antenna height/gain, and the channel password Winlink issued you in step 2. Validate the file against the packaged channels.xsd. Never commit this file with a real password to a public repo, if you’re publishing your own config for reference the way this post’s underlying repo does, redact it first.
  • sysop.xml, your sysop callsign and contact details, kept in sync with the Winlink CMS afterward by the updatesysop.py cron job (step 9).
  • banner, gwhelp, acihelp, hosts, connect banner, help text, and the CMS telnet endpoint(s) mail gets relayed through (default: cms.winlink.org:8772:CMSTELNET).
  • hooks/, one script per lifecycle event (start-rmsgw, end-rmsgw, pre/post session, pre/post ACI update). Empty stubs are fine, or drop in a logger -t rmsgw-hook "..." line per script for structured, easy-to-grep session/ACI logging without touching the binary.

9. Boot-time init and cron

Boot script (/usr/local/bin/ax25-init.sh, run as root at boot via systemd) should, in order: run kissattach against the TNC device, run kissparms, start ax25d, and clean up any stray default route AX.25 adds. Log each step with logger -t ax25-init so journalctl -t ax25-init -b gives you a clean per-boot trace.

Systemd unit, /etc/systemd/system/ax25.service:

[Unit]
Description=AX.25 init at boot
After=network.target

[Service]
Type=oneshot
ExecStart=/usr/local/bin/ax25-init.sh
RemainAfterExit=yes

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now ax25.service

Cron, as the user rmsgw runs under (crontab -e -u radio in this build):

4 * * * *      /etc/rmsgw/updatesysop.py >/dev/null 2>&1
14,42 * * * *  /usr/local/bin/rmsgw_aci > /dev/null 2>&1

updatesysop.py keeps your sysop record in sync with Winlink CMS; rmsgw_aci keeps the channel/ACI status updated. Pipe both through logger instead of discarding output if you want cron failures visible in journald.

10. Verify the running gateway

systemctl status ax25.service                    # active (exited), no failed steps
ps aux | grep -E "kissattach|ax25d"               # both running
ip link show ax0                                  # UP
sudo journalctl -t ax25-init -b                   # clean boot sequence
sudo journalctl -t rmsgw -b | grep -i "login\|logout"   # session summaries, already logged by rmsgw -l debug
sudo journalctl -t rmsgw-hook -f                  # live hook activity during a session, if you enabled hooks
sudo crontab -l -u radio                          # both cron jobs present

Then the real test: connect from another station, or a local axcall/packet client, to YOURCALL-10 via radio and confirm you land inside rmsgw and can list or send a test message. A clean session in the log looks like:

Login DL7EDU ... CMS login succeeded → traffic → Logout DL7EDU tx:58 rx:85 19.0s 7.5 Bytes/s (0)

and, if hooks are enabled:

start-rmsgw        succeeded
pre-rmsgw-session   succeeded  gwcall=DL7EDU-10 port=radio cms=cms.winlink.org
post-rmsgw-session  succeeded  user=DL7EDU err=0 sent=58 recv=85
end-rmsgw           succeeded

Notes and gotchas

  • Registration comes before radio. Don’t skip the email in step 2, an unregistered channel won’t route mail properly and you’ll just be transmitting noise into the network.
  • The hooks bug is real and upstream, not specific to this box. If you enable -DHOOKS without the SIGCHLD fix, you will get a hang on the first real over-the-air connection, not in any manual/SSH testing, because ax25d sets SIGCHLD to SIG_IGN and interactive shells don’t. Budget time to hit this if you go down the hooks path.
  • channels.xml holds a real credential. Treat it like any other secret, keep your config repo private, or redact the password before publishing.
  • This whole setup is per-connection, not a daemon. rmsgw runs once per incoming AX.25 session, spawned by ax25d. That’s why a binary swap doesn’t need a service restart, and also why a hang in one session doesn’t necessarily kill the gateway for the next one (though it does hold the AX.25 port until killed).

With this in place you have a real Winlink RMS Gateway: hams in range of your VHF signal can send and receive Winlink email with no internet connection at all, and your Pi handles the bridge to the CMS on their behalf.

Last updated on • Eduardo Fortes