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:
- 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.
- 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).
- 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
-10or 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
rmsgwfrom 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):
- 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. - Boot it, SSH in, and update:
sudo apt update && sudo apt full-upgrade -y sudo raspi-config # set locale/timezone, expand filesystem if needed - Plug in the TNC and confirm it enumerates:
If you have other USB-serial devices, consider a udev rule to pin the TNC to a stable device name.
ls /dev/ttyACM* dmesg | tail -20
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-devax25-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 VHFReplace 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 %UAgain, 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 -l7. 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 rmsgwTwo 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:
-
Enable hooks. Add
-DHOOKS -DHOOKDIR='"/usr/local/etc/rmsgw/hooks"'to theCPPFLAGSin threeMakefile.amfiles, alongside the existing-DGWMONITOR:lib/Makefile.am(librms_a_CPPFLAGS), this is where the realrunhook()implementation lives.rmsgw/Makefile.am(AM_CPPFLAGS),rmsgw/session.chas its own#ifdef HOOKSblock affecting thepost-rmsgw-sessionhook’s argument list, it must be built consistently with the library.rmsgw_aci/Makefile.am(AM_CPPFLAGS), same consistency requirement forrmsgw_aci.
-
Fix a real SIGCHLD bug.
runhook()forks a child to run each hook script, then reaps it with a barewhile (pid != wait(&status)) ;loop. That assumeswait()blocks. Butax25d(which spawnsrmsgwper incoming connection) setsSIGCHLDtoSIG_IGN, and that disposition survivesexecve()intormsgw. WithSIGCHLDignored, the kernel auto-reaps children beforerunhook()’s ownwait()ever runs, sowait()returns immediately withECHILD, and the loop spins forever, pegging a CPU core and hanging the connection. A plain interactive SSH shell hasSIGCHLDatSIG_DFL, so this only shows up on a real radio connection throughax25d, manual testing from a shell won’t catch it. Add this line inrunhook(), right before thefork()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 -j4Takes 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 scriptsThen 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_aciNo 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, setGWCALLandGRIDSQUAREfor your station.LOGFACILITY=LOCAL0/LOGMASK=INFOroute 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 packagedchannels.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 theupdatesysop.pycron 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 alogger -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.targetsudo systemctl daemon-reload
sudo systemctl enable --now ax25.serviceCron, 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>&1updatesysop.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 presentThen 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 succeededNotes 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
-DHOOKSwithout theSIGCHLDfix, you will get a hang on the first real over-the-air connection, not in any manual/SSH testing, becauseax25dsetsSIGCHLDtoSIG_IGNand interactive shells don’t. Budget time to hit this if you go down the hooks path. channels.xmlholds 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.
rmsgwruns once per incoming AX.25 session, spawned byax25d. 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.