Skip to content

cartpauj/aprgo

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

41 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

aprgo — Self-Hosted APRS Suite

A self-hosted APRS suite in a single Go binary. iGate, digipeater, operator console, map, messaging, bulletins, outbound webhooks, and admin web UI. One process, one config directory, no sidecars.

The binary owns the TNC (serial, Bluetooth, or TCP-KISS), talks to APRS-IS, runs gating and digipeat logic, fires your beacons (from a fixed location or a live GPS receiver), stores history in SQLite, can push received packets to external services via webhooks (Home Assistant, Node-RED, Zapier, ntfy, …), and serves the whole console over HTTPS. Runs unattended on a Raspberry Pi, a Wyse thin client, or any other Linux box.

Status: Early beta. Running stably on Debian 13 and the latest Raspberry Pi OS with a Mobilinkd TNC3.

Built with AI, directed by a human. aprgo is AI-assisted — "vibe-coded," yes — but not unattended. I scope, review, and test every feature on real hardware across many late nights and weekends. AI is what makes something this complete possible in my spare time; the decisions, and the bugs, are mine. Found one? Open an issue.

New to APRS? APRS is the ham radio digital data network: position reports, short messages, weather telemetry, emergency comms over VHF. An iGate is a station that bridges radio traffic to the internet (APRS-IS) and back. A digipeater repeats packets so they reach further than a single hop. aprgo can be either, both, or neither — it also runs as a pure APRS-IS client with no radio at all.


Install

One-line installer:

curl -fsSL https://raw.githubusercontent.com/cartpauj/aprgo/main/get.sh | sudo sh

It detects your distro family and CPU arch, downloads the matching .deb or .rpm from the latest GitHub release, installs it (pulling in bluez and bluez-tools as dependencies, with direwolf and gpsd suggested for soundcard-TNC and GPS setups), and prints how to reach the console.

Supported platforms

Filename suffix .deb .rpm Typical hardware
amd64 / x86_64 x86 servers, PCs, Wyse 3040 / 5070, Intel NUC, cloud VPS
arm64 / aarch64 Pi 3 (64-bit OS), Pi 4, Pi 5, Pi Zero 2 W, AWS Graviton, ARM SBCs
armhf (ARMv7) ✓ as armv7hl Pi 2, Pi 3 / 4 on 32-bit RPi OS, BeagleBone Black
armhf-armv6 Pi 1, Pi Zero, Pi Zero W (Raspberry Pi OS only)
i386 / i686 Old 32-bit x86 thin clients (Wyse 3010-class Atom), netbooks

Both ARMv7 and ARMv6 .debs carry Architecture: armhf in their metadata, since Debian has no separate ARMv6 arch. Pick the right one by filename: Pi Zero / Pi 1 users want armhf-armv6, everyone else on 32-bit RPi OS wants plain armhf.

aprgo targets Linux with systemd. macOS, Windows, and the BSDs aren't supported: Bluetooth pairing uses BlueZ, and the installer wires up a systemd unit. For anything not in the table above, build from source.

Verified configurations

End-to-end tested (pair, gate, beacon, console) on:

Hardware OS TNC
Raspberry Pi Zero W (ARMv6) Raspberry Pi OS 32-bit (no desktop) Mobilinkd TNC3 over Bluetooth
x86_64 with Bluetooth Debian 13 Trixie (no desktop) Mobilinkd TNC3 over Bluetooth

If you bring up aprgo on hardware/OS not listed and it works (or doesn't), please open an issue — happy to expand this list.

Reaching the console

aprgo auto-starts on install. The web console is HTTPS by default — start at https://<host>:14439/ and click through the self-signed cert warning once. Plain HTTP on 14473 is a restricted fallback for hosts where TLS isn't usable (e.g. a browser that refuses self-signed certs, or a captive network that strips TLS).

Port URL Use
14439 (HTTPS) https://<host>:14439/ Recommended. Full access. Self-signed cert; accept the browser warning once.
14473 (HTTP) http://<host>:14473/ Restricted fallback. Read-only views (Dashboard, Map, Stations, Stats, Logs) and the first-run setup wizard. Settings, Messages, and Bulletins redirect to HTTPS.

Default login is admin / admin. Change it on first sign-in.

Connecting a radio

aprgo speaks KISS over whatever transport your TNC offers. What you do depends on the hardware:

  • Bluetooth KISS TNCs (Mobilinkd TNC3 / TNC4): turn the TNC on, open the first-run wizard, click Scan, pair. Done.
  • USB / serial KISS TNCs (NinoTNC, Kenwood TH-D74 / TM-D710G, MFJ-1270X, VR-N76, ESP32 KISS TNCs): plug in. The wizard lists them as /dev/ttyUSB0 or /dev/ttyACM0.
  • Soundcard + radio (Digirig, SignaLink USB, DRAWS, UDRC, DMK URI, SHARI, DINAH, RA-35, RTL-SDR): aprgo does not do AFSK / FSK modulation. Install Direwolf (or another KISS soundmodem) and point aprgo at its TCP-KISS port (localhost:8001 by default).
  • Older or non-KISS TNCs, networked TNC hubs, or anything talking AGW/PE/TNC-2 command mode: bridge them through tnc-server, kissnetd, or similar.

If your TNC is Bluetooth or USB KISS, just run aprgo. Otherwise set up Direwolf or tnc-server first, then point aprgo at it via the wizard's TCP option.

Position: fixed or GPS

Every station has a position source, chosen in Settings → Position & mode (works in any operating mode):

  • Fixed — beacons transmit the latitude/longitude you set in the location wizard. The default; right for a home iGate or digipeater.
  • GPS — aprgo reads a live position from a receiver and uses it for beacons. Two source types are supported:
    • Local NMEA serial/USB — a GPS dongle or HAT on /dev/ttyACM*, /dev/ttyUSB*, or the Pi UART. Click Detect GPS receivers… and aprgo probes the serial ports, validates NMEA checksums, and lists only ports actually emitting GPS data (your configured TNC port is skipped). Talker-ID agnostic, so multi-constellation receivers that emit $GNRMC/$GNGGA work alongside GPS-only $GP… units.
    • gpsd — for a shared, networked, or otherwise-supported receiver. aprgo connects to gpsd's TCP socket (default 127.0.0.1:2947, or a custom host). Auto-detected when running locally; gpsd is a package suggests (not required).

The position is sampled at the moment each beacon transmits — it doesn't change how often you beacon, and position ambiguity / symbol / message-capable flags all still apply. A fallback setting controls what happens when there's no live fix at transmit time: reuse the last-known fix (up to a max age) then fall back to the fixed location, use the fixed location, or skip the beacon entirely (best for mobile stations that shouldn't report a stale position). Live fix state (lock, satellites, HDOP, coordinates) shows in Settings and as a status card on the Dashboard and Stats pages, next to the TNC and APRS-IS indicators.

GPS hardware needs a clear view of the sky. A USB dongle sitting next to the Pi indoors will see few satellites at low signal and may never lock — put the antenna on a windowsill or outdoors, facing up.

User documentation (operating modes, hardware deep-dive, security hardening, troubleshooting, day-2 operations) lives in the project wiki.


For developers

The rest of this README is for contributors.

Architecture

┌─── RF (KISS over serial / Bluetooth / TCP) ──┐    ┌── APRS-IS ───┐
│                                              │    │              │
│  internal/rf  (transport-agnostic KISS I/O)  │    │ internal/    │
│              │                                │    │ igate        │
│              ▼ ax25.Frame                    │    │              │
│  internal/ax25  (UI frame decode/encode)     │    │              │
│              │                                │    │              │
│              ▼                                │    │              │
│  internal/aprs  (Decode info field →         │    │              │
│                  position / weather /        │    │              │
│                  telemetry / message /       │    │              │
│                  PHG / Mic-E / 3rd-party)    │    │              │
└──────────────┬───────────────────────────────┘    └──────┬───────┘
               │                                            │
               ▼ aprs.Packet                               │
        ┌──────────────────────────────────────┐           │
        │ internal/gate  (pure functions,      │◄──────────┘
        │   Decide(packet, state) → []Action)  │
        │   • RF→IS gate                       │
        │   • IS→RF gate                       │
        │   • WIDE1-1 / WIDE2-N digipeat       │
        │   • Viscous delay                    │
        │   • Preemptive digipeat (MARK)       │
        │   • Source rate-limit                │
        └──────────────┬───────────────────────┘
                       │
        ┌──────────────┼───────────────┬─────────────┐
        ▼              ▼               ▼             ▼
      Drop          rf.TX           igate.Send    store.Insert
      (logged)      (1s spacing)    (queue)       (SQLite)

                       ▲
        ┌──────────────┴───────────────────────────┐
        │ internal/server  (HTTP routes, polling   │
        │   /api/feed every 2.5s, /api/stations,   │
        │   /api/trails — NOT SSE)                 │
        │ web/  (embed.FS templates + static)      │
        └──────────────────────────────────────────┘

        ┌──────────────────────────────────────────┐
        │ internal/webhook  (subscribes bus.Packets,│
        │   filters per endpoint, POSTs JSON out to │
        │   operator URLs — fire-and-forget+retry)  │
        └──────────────────────────────────────────┘

Package map

Package Responsibility Pure? Tests
internal/ax25 KISS framing, AX.25 UI frame encode/decode, callsign grammar
internal/aprs Info-field parser (position, Mic-E, weather, PHG/RNG, telemetry, message, third-party, path, tocall device lookup) 15
internal/gate Gating + digipeat decision tree. Pure functions; the caller executes returned actions 21
internal/bus In-memory pub/sub fanout (Frames, Packets)
internal/state Persistent JSON config plus live-reload subscribers. Atomic writes with directory fsync partial
internal/config Credentials and lockdown flags (aprgo.conf). Bcrypt password, HMAC session key, ratcheted UI lockdown switches
internal/tlscert Load-or-generate self-signed ECDSA P-256 cert under /var/lib/aprgo/tls/
internal/store SQLite store (stations, packets, messages). Pure-Go modernc.org/sqlite. Pragmas tuned for SD-card deploys. Callsigns normalized uppercase on write 2
internal/auth Cookie session (HMAC) plus bcrypt password and per-IP login rate limit
internal/igate APRS-IS client: connect, login, filter, logresp parsing, auto-reconnect
internal/rf KISS reader/writer for serial / Bluetooth / TCP behind one io.ReadWriteCloser. Includes btbind rfcomm supervisor
internal/tnc BlueZ subprocess wrappers: scan, pair, SDP, rfcomm
internal/gps Live position source: local NMEA serial reader + gpsd client, device detection/verification. NMEA decode + checksum are pure; the supervisor/transports do I/O 6
internal/beacon Per-beacon periodic scheduler with jitter; samples the GPS position provider at TX time
internal/server HTTP routes, wizard, tabbed Settings, polling feed, rate limiters, CSRF, transport gate (HTTP→HTTPS), lockdown enforcement 6
internal/webhook Outbound webhook dispatcher: subscribes to bus.Packets, filters per endpoint, POSTs JSON with retry. Match/payload logic is pure + unit-tested; delivery does HTTP 11
cmd/aprgo Binary entry plus main (handles --set-password, --regen-tls, --version)
cmd/trailcheck Auxiliary dev tool

The "Pure?" column is load-bearing. Pure packages have no I/O and are unit-testable in isolation. All decision logic affecting the station's on-air behavior lives in internal/gate/ and is exhaustively tested. Effectful packages (rf, igate, beacon, store, server) own the side effects.

Building from source

# Go 1.26+ required. Pure-Go build, no CGO, no C toolchain.
git clone https://github.com/cartpauj/aprgo
cd aprgo
CGO_ENABLED=0 go build \
  -ldflags="-s -w -X main.Version=$(git describe --tags --always)" \
  -trimpath -o aprgo ./cmd/aprgo

# Tests (gate, aprs, state, server passcode helper).
go test ./...

# Cross-compile to arm64 (Pi 3 / 4 / 5 / Zero 2 W):
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -trimpath -o aprgo-arm64 ./cmd/aprgo

No CGO anywhere. modernc.org/sqlite is pure Go, golang.org/x/crypto is pure Go. Cross-compilation needs no C toolchain. The shipped binary is one static file.

Installing a locally-built binary

sudo ./deploy/install.sh ./aprgo
sudo systemctl start aprgo

The install script creates /var/lib/aprgo/ (mode 0700), copies the binary to /usr/bin/aprgo, installs the systemd unit, and enables it.

Key invariants (please preserve)

  1. Single-process design. No IPC, no helper daemons. The one external is Direwolf, which aprgo connects to over TCP KISS like any other networked TNC. Don't add IPC mechanisms; if you want a sidecar process, find another path.
  2. internal/gate/ is pure. All on-air decisions are pure functions taking (packet, state, heardChecker callback) and returning []Action. No I/O, no timers, no logging from inside gate. The caller executes the actions. Digipeat policy changes go in gate.go with unit tests in gate_test.go.
  3. All goroutines have panic recovery. internal/server spawns long-running workers wrapped in defer recover(). Add new goroutines using the same pattern. A panic in one component must not take down the process.
  4. state.json is forward-compatible. New fields default to zero on read, so older clients still parse newer files. Atomic write via temp file, rename, and directory fsync. Same pattern for aprgo.conf and the TLS material.
  5. HTTP UI is polling, not SSE. The dashboard polls /api/feed?since=N every 2.5 s. SSE was tried and reverted (NAT/proxy timeouts). Don't reintroduce SSE without a real reason.
  6. TX inter-frame spacing. rf.writeLoop enforces a 1-second minimum gap between successive RF writes (internal/rf/rf.go). APRS channel courtesy. Leave it alone.
  7. Heard-stations table excludes our own callsign. Digipeated copies of our own beacons would otherwise pollute the list; the intake path checks for self before insert.
  8. Lockdown ratchet. UI lockdown flags in aprgo.conf can only go OFF→ON via the web UI. The handler ORs any incoming form value against the existing raw value, so a hand-crafted POST can never clear a locked flag. The only way back to off is editing aprgo.conf and restarting.

Where to make common changes

Goal Files to touch
New APRS data type in the parser internal/aprs/info.go (or a new file like weather.go). Tests in internal/aprs/parsers_test.go. Surface in templates, popup, feed.
New operating mode internal/state/state.go (Mode enum and applyModeDefaults), internal/server/wizard.go (step copy), web/templates/setup.html (radio card).
New wizard step internal/server/wizard.go (add to wizardSteps, save case, renderStep extras), web/templates/setup.html (step template plus dispatch in main switch).
New gating / digipeat rule internal/gate/gate.go (function plus state flag if user-tunable). Pair with unit tests in gate_test.go.
New HTTP endpoint internal/server/routes.go (HandleFunc plus handler). Templates in web/templates/. Add to the transport gate's isCriticalPath() allowlist if it mutates state.
New persistent setting internal/state/state.go (struct field), Settings UI in web/templates/settings.html, save case in internal/server/routes.go handleSettingsSave.
New webhook filter or payload field internal/webhook/match.go (Match + payload/buildBody), internal/state/state.go (Webhook struct), internal/server/sanitize.go (parseWebhooksForm), and the row UI in web/templates/settings.html + web/static/js/settings.js (keep the Go-rendered row and the JS rowTemplate in sync).
New TNC transport internal/state/state.go (TNCKind enum), internal/rf/rf.go (open/dial logic), web/templates/setup.html (wizard fieldset).
New GPS source/transport internal/gps/ (GPSKind session in gps.go, NMEA decode in nmea.go, detection in detect.go), internal/state/state.go (GPSKind), Settings UI in web/templates/settings.html + scan handler in internal/server/routes.go.
New beacon-style packet internal/beacon/beacon.go (build function), state schema, Settings UI.
New lockdown flag internal/config/config.go (Lockdown struct plus Effective()), 403 checks in handlers via s.requireUnlocked, UI surfaces in web/templates/settings.html.

Testing

Coverage is heaviest where it matters most:

  • internal/gate/gate_test.go — 21 tests covering WIDE-N parsing, N-capping, decrement, MARK mode (preemptive), path length, viscous flag, skip-self.
  • internal/aprs/parsers_test.go — 15 tests covering weather, PHG, RNG, tocall lookup (exact, wildcard, SSID strip), path parsing (used hops, q-construct).
  • internal/gps/nmea_test.go — 6 tests: NMEA checksum validation, talker-ID-agnostic sentence typing, ddmm→decimal coordinate decode, and fix-vs-no-fix semantics (RMC status A/V, GGA quality 0) on real captured sentences.
  • internal/state/ — config validation tests.
  • internal/webhook/ — filter matching (source / type / callsign / to-callsign / message-text), third-party originator attribution (MsgOrigSrc, not the relay), payload shape, and HTTP delivery (success, retry-then-drop).
  • internal/server/ — settings-page render (catches template breakage), webhook save round-trip, and the passcode helper.
  • internal/store/ — callsign case-folding so a conversation isn't split by case.

RF goroutines and the IS client are exercised by integration testing on a real Pi or Wyse target rather than unit tests. New code touching gate/, aprs/, ax25/, or webhook/ should always come with tests — those are the places operators can't see things go wrong.

Deployment loop (dev to real target)

Inner loop for testing on a real Pi or thin client:

# 1. Build for the target arch (arm64 example).
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -trimpath -o /tmp/aprgo-linux ./cmd/aprgo

# 2. scp to target.
scp /tmp/aprgo-linux user@host:/tmp/

# 3. Hot-swap.
ssh user@host '
  sudo systemctl stop aprgo &&
  sudo install -m 0755 /tmp/aprgo-linux /usr/bin/aprgo &&
  sudo systemctl start aprgo &&
  sudo systemctl is-active aprgo
'

# 4. Watch logs.
ssh user@host 'journalctl -u aprgo -f'

/var/lib/aprgo/ survives the swap. state.json and aprgo.conf are forward-compatible, so new fields default to zero on read.

Project layout

cmd/aprgo/         binary entry + main
cmd/trailcheck/    aux dev tool
internal/
  ax25/            KISS framing + AX.25 UI frame encode/decode
  aprs/            info-field parser (position, Mic-E, weather, PHG, telemetry,
                     message, third-party, tocall, path)
                   data/  embedded aprsorg/aprs-deviceid tocall registry
  bus/             typed pub/sub fanout
  state/           persistent operating config (state.json)
  config/          credentials + lockdown flags (aprgo.conf)
  tlscert/         self-signed cert load-or-generate
  store/           SQLite stations/packets/messages
  auth/            cookie session (HMAC + password-generation binding)
  igate/           APRS-IS client (reconnect, filter, logresp parsing)
  rf/              KISS reader/writer for serial / Bluetooth / TCP, plus btbind supervisor
  tnc/             BlueZ subprocess wrappers (scan / pair / SDP / rfcomm)
  gps/             live position: NMEA serial reader + gpsd client, device detection

  gate/            RF↔IS gating + digipeat decision engine (pure functions)
  beacon/          periodic beacon scheduler
  server/          HTTP routes, polling feed, wizard, rate limiters, CSRF, transport gate, lockdown enforcement
deploy/            systemd unit, install.sh, nfpm.yaml, postinst/prerm/postrm scripts
.github/workflows/ release.yml — builds .deb/.rpm matrix on v* tags
web/               embed.FS for templates + static assets
get.sh             one-line installer used in the README

License

MIT. See LICENSE.

Attributions

aprgo is built on a lot of other people's work.

Code ported or derived

Source License Where it lives
aprx parse_aprs.c by Matti Aarnio (OH2MQK) MIT internal/aprs/info.go. Mic-E plus position and uncompressed-position decoders, comment cleanup, character validation.

Bundled assets (web/static/)

Project Version License Files
Leaflet by Vladimir Agafonkin / CloudMade 1.9.4 BSD-2-Clause leaflet.js, leaflet.css
htmx by Big Sky Software 1.9.10 BSD-2-Clause / 0BSD htmx.min.js
hessu/aprs-symbols by Heikki Siltala (OH7LZB) MIT aprs-symbols-48-0.png, aprs-symbols-48-1.png, aprs-symbols-48-2.png
IBM Plex Sans + IBM Plex Mono by IBM SIL Open Font License 1.1 fonts/plex-*.woff2

Embedded data (internal/aprs/data/)

Project License Files
aprsorg/aprs-deviceid. APRS tocall device identification registry. CC BY-SA 2.0 tocalls.yaml, tocalls.json

Go module dependencies

Pure Go, no CGO. Pulled in by go.mod:

Module License What it does
golang.org/x/crypto BSD-3-Clause bcrypt for the admin password hash
golang.org/x/sys BSD-3-Clause Low-level syscalls (serial port termios via unix.Termios)
modernc.org/sqlite BSD-3-Clause (SQLite itself is public domain) The SQLite store. Pure-Go translation of the C source, so CGO isn't needed
modernc.org/libc, modernc.org/memory, modernc.org/mathutil BSD-3-Clause Transitive support packages for modernc.org/sqlite
github.com/dustin/go-humanize MIT Human-readable byte sizes and time deltas on the stats page
github.com/google/uuid BSD-3-Clause Random UUIDs (transitive)
github.com/mattn/go-isatty MIT TTY detection (transitive)
github.com/ncruces/go-strftime MIT strftime-style date formatting (transitive)
github.com/remyoudompheng/bigfft BSD-3-Clause Large-integer FFT (transitive, via SQLite)

Version pins and checksums are in go.mod and go.sum.

Specification sources

aprgo's protocol behavior tracks publicly available APRS documentation. None of these specs are bundled in the repo:

  • APRS Protocol Reference v1.0.1 (Bob Bruninga, WB4APR)
  • APRS aprs11 / aprs12 addenda at aprs.org: fix14439, preemptive-digipeating, RFlimits, SSIDs, mic-e-types, spec-wx, datum, replyacks
  • APRS-IS specifications at aprs-is.net: IGating, IGateDetails, q-construct rules, Connecting
  • AX.25 Link Access Protocol v2.2 (TAPR)
  • base-91 telemetry as documented at he.fi

Thanks to everyone who's reported bugs from the air. APRS still works because operators share.