sneak bbceab0b24
check / check (push) Successful in 2m0s
Show UTC as the first header clock instead of hardcoded Los Angeles time
The header drew the host's local time and then America/Los_Angeles
time, so on a Mac in that zone both clocks read the same and neither
was UTC. The first clock is now UTC and the second the host's local
time, which also drops the hardcoded zone and its load-failure path.
2026-11-06 19:12:41 -08:00
2025-05-16 05:28:39 -07:00

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)
  • ping command available in PATH
  • On Linux: ip (with /proc/net/route as 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. The VPN pane appears only while a VPN is connected, meaning its utun tunnel carries the IPv4 default route: in netstat -rn -f inet that is a default row on the tunnel, or both a 0/1 and a 128.0/1 row on it, which some VPN clients install instead of replacing the default. That tunnel is then monitored as the primary pane alongside the physical interface. A tunnel that is up without the default route is ignored, whatever its address: Tailscale without an exit node, or a tunnel a disconnected client left behind. A default route scoped to the tunnel alone (flag I) does not count either. With no VPN connected, 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 connected VPN tunnel + physical default-route interface (two panes)
macOS the physical default-route interface with no VPN connected (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.

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

  • q or Ctrl+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 Dockerfile test phase.
  • script/lint — run golangci-lint as the Dockerfile lint phase.
  • 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

Author

sneak@sneak.berlin

S
Description
No description provided
Readme WTFPL
241 KiB
0 Stars 1 Watchers 0 Forks
Languages
Go 90.9%
Shell 5.9%
Dockerfile 2.4%
Makefile 0.8%