APRS tracking with overlays
  • Go 72.1%
  • Svelte 14.5%
  • TypeScript 11.9%
  • CSS 0.9%
  • Dockerfile 0.4%
  • Other 0.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Elisamuel Resto 115a190ecf
All checks were successful
Release (dev) / 🔍 Lint (push) Successful in 1m34s
Release (dev) / 🚀 Build and Publish (push) Successful in 3m1s
fix(release): stop the dev build depending on a semver release tag
The nightly version was `{{ incpatch .Version }}-dev-{{ .ShortCommit }}`,
and incpatch requires the newest git tag to be valid semver. That coupled
the dev pipeline to release tagging it has no business depending on: it
fails outright in a repo that has never been released, and fails again
the moment any non-release tag happens to be the newest one -- which is
exactly what the basis-2026-09-02 revert marker did.

Semver is a release concern. A dev build only has to be identifiable, so
the version is now a fixed 0.0.0 base plus the short commit. 0.0.0 keeps
it valid semver so nothing downstream -- Docker tags, OCI labels, the
version string sent to APRS-IS in the login line -- has to special-case
it.

Verified locally with the real Pro binary: `goreleaser release --clean
--nightly` now runs the full pipeline, builds linux/amd64 and
linux/arm64, and produces hamtrak_0.0.0-dev-c084cfe_*.tar.gz. The run
also exercises the internal/web/embedded rename through the actual
before-hooks, and the resulting binary serves the embedded SPA.

Docker and SBOM were skipped locally -- multi-arch needs a registry to
push a manifest to, so those still first execute on the runner.
2026-09-19 00:16:20 -05:00
.forgejo/workflows build: name the embed directory something gitignore won't swallow 2026-09-18 23:58:56 -05:00
cmd/hamtrak refactor: remove the URL proxy, salvage its SSRF guard 2026-09-19 00:00:17 -05:00
internal refactor: remove the URL proxy, salvage its SSRF guard 2026-09-19 00:00:17 -05:00
web feat(web): viewer-only frontend with breadcrumb trails 2026-09-19 00:00:17 -05:00
.dockerignore Bulk initial commit 2026-09-01 23:39:50 -05:00
.gitignore build: name the embed directory something gitignore won't swallow 2026-09-18 23:58:56 -05:00
.golangci.yml Bulk initial commit 2026-09-01 23:39:50 -05:00
.goreleaser.yaml fix(release): stop the dev build depending on a semver release tag 2026-09-19 00:16:20 -05:00
compose.yaml refactor: remove the URL proxy, salvage its SSRF guard 2026-09-19 00:00:17 -05:00
Dockerfile refactor: remove the URL proxy, salvage its SSRF guard 2026-09-19 00:00:17 -05:00
go.mod Bulk initial commit 2026-09-01 23:39:50 -05:00
LICENSE Bulk initial commit 2026-09-01 23:39:50 -05:00
README.md refactor: remove the URL proxy, salvage its SSRF guard 2026-09-19 00:00:17 -05:00

HamTrak

A single-binary APRS vehicle tracker for one fixed site, with a live map.

HamTrak holds one shared connection to APRS-IS and fans it out to every browser over Server-Sent Events. The whole SvelteKit frontend is compiled into the Go binary, so deploying it is one file (or one container) with no assets to serve alongside it and no database to run.

The daemon owns the configuration; the browser is a viewer. There is no callsign prompt, no login, and nothing for a visitor to set up.

  • Fixed coverage — one site, one 100-mile circle, configured on the server. 43°57.15′N 91°36.10′W (EN43EW). Every viewer sees the same feed.
  • Moving vehicles only — a station appears once it has reported movement within the last hour, and stays until its last moving fix ages out. The digipeaters, iGates and weather stations that dominate the band never clutter the map.
  • Breadcrumb trails — 60 minutes of history per vehicle, drawn behind it.
  • Decodes the awkward parts — compressed and uncompressed positions, weather, PHG, telemetry, and status packets.
  • KML/KMZ overlays — drop files in a directory; they appear within 30 seconds and vanish when removed.

Running it

Set a real callsign and bring it up, then open http://localhost:4576.

docker compose up -d

A complete compose.yaml is in this repo:

services:
  hamtrak:
    image: scm.nullnetwork.cc/erestodo/hamtrak:latest
    container_name: hamtrak
    restart: unless-stopped
    ports:
      - "4576:4576"
    environment:
      APP_CALLSIGN: N0CALL       # <- must be a real callsign, see below
      KML_DIR: /overlays
    volumes:
      - ./overlays:/overlays:ro
    security_opt:
      - no-new-privileges:true
    read_only: true
    cap_drop:
      - ALL

APP_CALLSIGN must be a real amateur callsign. APRS-IS servers answer the N0CALL placeholder with # Login by user not allowed and hang up, so the daemon will reconnect forever and the map will stay empty. It is a receive-only session (pass -1) — nothing is transmitted on your behalf.

The image is a Docker Hardened Image: distroless, no shell, no package manager, running as nonroot 65532. The 60-minute window of positions lives in memory and refills from the feed after a restart, so the only volume is the read-only overlay directory and the root filesystem stays read-only. It must be readable by uid 65532; the container has no shell to fix permissions at startup.

Tags: :latest and :vX.Y.Z for releases, :dev built from master.

Or run the binary straight from a release archive:

./hamtrak --addr :4576

Configuration

Every setting can come from the environment or a flag. Precedence is flag > environment > default, and --help prints the value that would actually be used.

Flag Environment Default Purpose
--callsign APP_CALLSIGN N0CALL APRS-IS login. Must be real — see above.
--kml-dir KML_DIR (unset) Directory scanned for .kml/.kmz. Unset disables overlays.
--addr HAMTRAK_ADDR :4576 HTTP listen address
--aprs-server HAMTRAK_APRS_SERVER rotate.aprs2.net:14580 APRS-IS host:port (filtered feed)
--log-level HAMTRAK_LOG_LEVEL info debug, info, warn, or error

The coverage centre, its 100-mile radius, and the 60-minute retention window are compile-time constants in internal/hub, not settings — this build tracks one site.

Why is the map empty?

Usually because nothing is moving. Only stations that have reported a speed above zero in the last hour are published, and outside of busy periods a 100-mile circle can legitimately contain none. A 90-second sample at 21:00 local had 15 positioned packets and not one in motion.

To check what the feed actually contains, capture some raw lines and run them through the parser:

HAMTRAK_CAPTURE=/path/to/raw.txt go test ./internal/aprsis -run TestParseLiveCapture -v

It reports how many packets carried a non-zero speed, which is exactly the predicate the map publishes on.

Building

Releases are cut by GoReleaser, which builds the frontend and embeds it before compiling — see .goreleaser.yaml.

goreleaser build --clean --snapshot --single-target

To work on it, run the two halves separately. Vite proxies /api/* to the Go server:

go run ./cmd/hamtrak --addr :8080   # one terminal
cd web && npm run dev -- --port 5173  # another

Note: internal/web/embedded/ is generated. Only .gitkeep is committed, so //go:embed all:embedded resolves on a fresh clone and Go tooling runs without building the frontend first. A go build that has not had the frontend built produces a working server that serves an empty site.

The directory is deliberately not called dist: global and inherited gitignore files almost always carry a blanket dist/, which swallows the directory, drops the placeholder, and turns a fresh clone into a confusing Go build error. Worth copying into any other project that embeds a frontend.

A note on how this was built

Made with the assistance of AI LLM technology, verified and tested by humans. The APRS parsing in particular is covered by tests written against real packet captures; nothing here ships on the strength of a model's say-so alone.

License

MIT © 2026 Elisamuel Resto Donate, KF0ACN <sam@samresto.dev>