rtnetmon had no version. main.Version is now set at link time and logged in the first startup line. The Dockerfile takes it from the VERSION build argument when one is given, otherwise from `git describe --tags --always` of the .git the build context now carries, and fails the build if the context carries .git and no version comes out. .dockerignore follows the canonical copy: .git is sent, .git/config, which can hold a credential, is not. Model: opus-5-5
rtnetmon
rtnetmon is a WTFPL-licensed Go terminal (CLI/TUI) network monitor by @sneak that shows real-time network health, packet loss, and latency across one or two network interfaces on Linux and macOS.
Overview
rtnetmon is a terminal-based network monitoring tool that provides real-time visibility into network health, packet loss, and latency across one or two network interfaces simultaneously. It runs on Linux and macOS and uses a terminal dashboard interface.
Features
- Interface Monitoring: Monitor one or two network interfaces simultaneously
- Real-time Updates: Live dashboard with sub-second updates
- Comprehensive Metrics:
- ICMP reachability tests
- Packet loss percentage
- TCP connection latency
- Interface health status
- Visual Indicators: Color-coded status, spinners, and meters for quick
status assessment
- Packet loss meter uses reverse coloring (empty/green = good, full/red = bad)
- Detailed Logging: Optional logging to file for debugging and analysis
Requirements
- Linux or macOS
- Go 1.21 or later
- Root/sudo access (for raw ICMP packets)
pingcommand available in PATH- On Linux:
ip(with/proc/net/routeas a fallback) - On macOS:
netstat
Platform support and interface detection
rtnetmon monitors either one or two interfaces, chosen automatically for the platform it runs on. When only one interface is detected, the dashboard shows a single pane.
Linux. The two named interfaces (--ifaceA/--ifaceB, default
gu0/backhaul0) are used when both exist — this is the original dual-bridge
setup, unchanged. When neither exists, the single default-route interface is
monitored instead.
macOS. The physical internet interface is found from the default route. When
a VPN client is running (Mullvad and similar clients create a utun tunnel that
carries a default route or holds a routable address), that tunnel is monitored
as the primary pane alongside the physical interface. With no VPN running, only
the physical interface is monitored. Interface names are detected on macOS; the
--ifaceA/--ifaceB flags are not used there, but --labelA/--labelB still
set the pane labels.
Supported matrix
| OS | Interfaces monitored |
|---|---|
| Linux | gu0 + backhaul0 when both exist (two panes) |
| Linux | the single default-route interface otherwise (one pane) |
| macOS | VPN tunnel + physical default-route interface (two panes) |
| macOS | the physical default-route interface with no VPN (one pane) |
Anything outside this matrix — on Linux, only one of the named pair present, or no/multiple default routes when neither is present; on macOS, no default route or more than one physical default route — exits with a clear error.
Starlink status
When the non-VPN physical gateway is a Starlink dish, two extra lines appear under that pane: dish state, uptime, obstruction and alert count on the first, and pop-ping latency and drop rate with downlink/uplink throughput on the second. They turn red when the dish is not connected or an alert is active.
Detection is a TCP connect to the dish's fixed local endpoint,
192.168.100.1:9200, made over the physical interface (the same binding the
latency probes use). The dish sits behind the Starlink router, so this is not a
gateway-address check. rtnetmon probes once a minute until a dish answers and
then reads its status every few seconds; when no dish answers, nothing is drawn
and no status is fetched. The status comes from the dish's local get_status
gRPC call.
Installation
git clone https://git.eeqj.de/sneak/rtnetmon.git
cd rtnetmon
make build # produces ./bin/rtnetmon
Usage
sudo ./bin/rtnetmon --ifaceA eth0 --labelA "Primary WAN" --ifaceB wlan0 --labelB "Backup WiFi"
Command Line Options
--ifaceA: Primary network interface (default: "gu0")--labelA: Label for primary interface (default: "gu LAN - VPN outbound")--ifaceB: Secondary network interface (default: "backhaul0")--labelB: Label for secondary interface (default: "Cox cable direct")--hosts: Comma-separated list of hosts to monitor--logfile: Path to log file (default: "/tmp/rtnetmon.log")
Keyboard Controls
qorCtrl+C: Quit the application
Project Structure
rtnetmon/
├── cmd/rtnetmon/ # main entry point (thin: calls internal/cli)
│ └── main.go
├── internal/
│ ├── cli/ # command-line interface using Cobra
│ │ └── root.go
│ ├── netdetect/ # per-platform interface/route detection and selection
│ │ ├── netdetect.go # selection logic and route parsers (pure)
│ │ ├── routes_linux.go # Linux default-route query (build-tagged)
│ │ └── routes_darwin.go # macOS default-route query (build-tagged)
│ ├── starlink/ # Starlink dish detection and status
│ │ ├── starlink.go # Status, Client interface, pure Render
│ │ ├── client.go # real gRPC client (get_status)
│ │ └── pb/ # generated bindings for the get_status RPC
│ └── monitor/ # core monitoring functionality
│ ├── monitor.go # monitor types, probes, logging
│ ├── loops.go # monitoring loops (reachability, loss, TCP)
│ ├── styles.go # terminal color styles
│ ├── ui.go # user interface rendering
│ ├── dial_linux.go # TCP source binding (build-tagged)
│ └── dial_darwin.go # TCP IP_BOUND_IF binding (build-tagged)
├── script/ # Scripts to Rule Them All entrypoints
├── .gitea/workflows/ # CI (runs script/cibuild)
├── Dockerfile # lint + test gate phases and the build
├── Makefile # thin shims over script/
├── .golangci.yml # vendored linter config
├── go.mod / go.sum
└── README.md
Development
make check # run test, lint, and fmt-check (the default target)
make build # build ./bin/rtnetmon
make run # build, then run ./bin/rtnetmon locally
make dev # go run ./cmd/rtnetmon
make test # run the test suite (test phase of the Dockerfile)
make lint # run golangci-lint (lint phase of the Dockerfile)
make fmt # format Go code (writes)
make fmt-check # verify formatting (read-only)
make deps # go mod download + go mod tidy
make docker # build the Docker image
make cibuild # bootstrap, check, and build the image (run by CI)
make bootstrap # install dependencies idempotently (git, make, go)
make setup # bootstrap plus install the git pre-commit hook
make hooks # install the git pre-commit hook
make clean # remove build artifacts
Linting and testing run in Docker so results do not depend on host tooling;
make lint, make test, make docker, and make cibuild therefore require
a Docker daemon. make check must be green before committing, and the
pre-commit hook (make hooks) runs it.
Entrypoints
This repository adheres to the
Scripts to Rule Them All
standard: normalized scripts in script/ are the entrypoints for the
development workflow, and the Makefile targets are thin shims that call them.
script/bootstrap— install dependencies idempotently (git, make, go).script/setup— bootstrap plus install the git pre-commit hook.script/projectname— output the project name.script/test— run the test suite as the Dockerfiletestphase.script/lint— run golangci-lint as the Dockerfilelintphase.script/fmt— format Go code (host).script/fmt-check— verify formatting (host, read-only).script/check— run test, lint, and fmt-check.script/docker— build the Docker image.script/cibuild— bootstrap, check, and build the image; run by CI.script/precommit— run by the pre-commit hook (go mod tidy check + check).script/install-precommit— install the pre-commit hook.
Using the Monitor API
The monitor package provides an object-oriented API for programmatic use:
import "git.eeqj.de/sneak/rtnetmon/internal/monitor"
// Create a new monitor for one or two interfaces
mon := monitor.NewMonitor([]monitor.IfaceSpec{
{Name: "eth0", Label: "Primary"},
{Name: "wlan0", Label: "Backup"},
}, "/tmp/monitor.log")
// Configure timing parameters (optional - defaults are sensible)
mon.ICMPTimeout = 1 * time.Second
mon.PacketLossPings = 10
mon.PacketLossPeriod = 10 * time.Second
// Add hosts to monitor
mon.AddReachabilityHost("8.8.8.8")
mon.AddReachabilityHost("google.com")
mon.AddPacketLossHost("github.com")
mon.AddPacketLossHost("8.8.8.8")
mon.AddTCPHost("google.com:443")
mon.AddTCPHost("github.com:443")
// Run the monitor
ctx := context.Background()
if err := mon.Run(ctx); err != nil {
log.Fatal(err)
}
License
WTFPL - Do What The Fuck You Want To Public License