From 7dd4ac798d3877a0a82ea37ab364dc795810f171 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Mon, 21 Sep 2026 09:22:28 +0200 Subject: [PATCH 1/9] Adopt repo standards: scaffold, policies, lint-clean (closes #1) Standard scaffold: script/ entrypoints with the Makefile as thin shims, a Dockerfile whose lint and test phases gate the build, a Gitea CI workflow running script/cibuild, REPO_POLICIES.md, TODO.md, .editorconfig, .dockerignore, LICENSE, wider .gitignore. .golangci.yml is byte-identical to the canonical copy in the prompts repo. 211 lint findings fixed in code: package globals became functions/fields and a cobra command constructor, magic numbers became named constants, ctx is threaded into the probes, monitor loops split. Tests moved to external _test packages with export_test.go. Behavior unchanged. Readers will trip over: make lint/test/check now need a Docker daemon; the personal rsync copy/run targets are gone and make run runs locally. Disclosure: four //nolint:gosec remain on the fixed-argv ping/curl calls and the operator-chosen log file, as the reference repo annotates the same class. Disclosure: module path left as-is. Model: opus-4-8 (implementation); fable-5-1 (merge) --- .dockerignore | 51 +++ .editorconfig | 12 + .gitea/workflows/check.yml | 16 + .gitignore | 29 +- .golangci.yml | 92 ++++- Dockerfile | 37 ++ LICENSE | 13 + Makefile | 72 ++-- README.md | 80 ++-- REPO_POLICIES.md | 603 +++++++++++++++++++++++++++++++ TODO.md | 42 +++ cmd/rtnetmon/main.go | 10 +- internal/cli/export_test.go | 8 + internal/cli/root.go | 114 +++--- internal/cli/root_test.go | 37 +- internal/monitor/export_test.go | 20 + internal/monitor/loops.go | 487 +++++++++++++------------ internal/monitor/monitor.go | 320 ++++++++++------ internal/monitor/monitor_test.go | 95 ++--- internal/monitor/styles.go | 133 ++++--- internal/monitor/styles_test.go | 114 ++---- internal/monitor/ui.go | 428 ++++++++++++---------- script/bootstrap | 70 ++++ script/check | 14 + script/cibuild | 19 + script/docker | 14 + script/fmt | 16 + script/fmt-check | 18 + script/install-precommit | 15 + script/lint | 16 + script/precommit | 21 ++ script/projectname | 11 + script/setup | 13 + script/test | 16 + 34 files changed, 2172 insertions(+), 884 deletions(-) create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .gitea/workflows/check.yml create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 REPO_POLICIES.md create mode 100644 TODO.md create mode 100644 internal/cli/export_test.go create mode 100644 internal/monitor/export_test.go create mode 100755 script/bootstrap create mode 100755 script/check create mode 100755 script/cibuild create mode 100755 script/docker create mode 100755 script/fmt create mode 100755 script/fmt-check create mode 100755 script/install-precommit create mode 100755 script/lint create mode 100755 script/precommit create mode 100755 script/projectname create mode 100755 script/setup create mode 100755 script/test diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b04a35f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,51 @@ +# .dockerignore does NOT use .gitignore semantics. Docker matches with +# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross +# `/` and an unprefixed pattern is anchored at the context root. Every +# depth-independent pattern therefore needs `**/`; only genuinely +# root-anchored entries go unprefixed. Never transplant these into +# .gitignore, where `**/` is wrong. +# +# Matching is case-sensitive, so secrets use character ranges rather +# than an ALL-CAPS twin, which would still miss `Server.Key`. + +# Excluding .git means `git describe` cannot run in any build stage and +# fails quietly there; rtnetmon embeds no version, so this is safe. +.git + +# Agent scratch: one full checkout of the repo per in-flight agent. +# Anchored because agents run at the repo root here. +.claude + +# This repo's own host-built artifacts, root-anchored so `bin/` is not +# also matched inside package directories. +/bin +/rtnetmon +main.go.old + +# Environment files. +**/*.[eE][nN][vV] +**/.[eE][nN][vV].* +**/.[eE][nN][vV][rR][cC] + +# Private keys and the bundles carrying them. +**/*.[pP][eE][mM] +**/*.[kK][eE][yY] +**/*.[pP]12 +**/*.[pP][fF][xX] +**/[iI][dD]_[rR][sS][aA] +**/[iI][dD]_[dD][sS][aA] +**/[iI][dD]_[eE][cC][dD][sS][aA] +**/[iI][dD]_[eE][dD]25519 + +# OS metadata. +**/.DS_Store +**/Thumbs.db + +# Editor state. +**/*.swp +**/*.swo +**/*~ +**/*.bak +**/.idea +**/.vscode +**/*.sublime-* diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..2fe0ce0 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[Makefile] +indent_style = tab diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml new file mode 100644 index 0000000..7fb17b1 --- /dev/null +++ b/.gitea/workflows/check.yml @@ -0,0 +1,16 @@ +name: check + +on: + push: + branches: + - '**' + +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 2024-10-23 + + - name: Build (runs bootstrap, checks, and the image build) + run: script/cibuild diff --git a/.gitignore b/.gitignore index 5bdb44c..56c9747 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,31 @@ +# OS +.DS_Store +Thumbs.db + +# Editors +*.swp +*.swo +*~ +*.bak +.idea/ +.vscode/ +*.sublime-* + +# Agent scratch (worktrees of this repo and per-agent instruction files, +# created and destroyed by in-flight tooling). Unanchored: .gitignore +# patterns already match at every depth. Not a .dockerignore entry. +.claude/ .aider* -.env AGENTS.md CLAUDE.md + +# Environment / secrets +.env +.env.* +*.pem +*.key + +# Build artifacts +/bin/ +/rtnetmon +main.go.old diff --git a/.golangci.yml b/.golangci.yml index 1eadd9c..a7a74c2 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,5 +1,9 @@ version: "2" +# Config schema uses the golangci-lint v2 layout (settings live under +# linters.settings, not top-level linters-settings) so that the +# thresholds below are actually applied by golangci-lint >= v2. + run: timeout: 5m modules-download-mode: readonly @@ -7,28 +11,88 @@ run: linters: default: all enable: + # Successor to the deprecated gomodguard. Named explicitly, rather than + # left to `default: all`, because it carries the module policy below. - gomodguard_v2 disable: # Genuinely incompatible with project patterns - exhaustruct # Requires all struct fields - - depguard # Dependency allow/block lists - godot # Requires comments to end with periods - - wsl # Deprecated, replaced by wsl_v5 - wrapcheck # Too verbose for internal packages - varnamelen # Short names like db, id are idiomatic Go - -linters-settings: - lll: - line-length: 88 - funlen: - lines: 80 - statements: 50 - cyclop: - max-complexity: 15 - dupl: - threshold: 100 + # Deprecated: the warning is attached to the old name, so it is + # silenced by disabling that name, not by enabling the successor. + - wsl # Deprecated, replaced by wsl_v5 + - gomodguard # Deprecated, replaced by gomodguard_v2 + settings: + lll: + line-length: 88 + funlen: + lines: 80 + statements: 50 + cyclop: + max-complexity: 15 + dupl: + threshold: 100 + depguard: + # Test-support code must not be compiled into the shipped binary. A + # test-support package exists to hand a test privileges the program + # itself must never have, so a file that is not a test must not import + # one. Test files, and the files inside a package whose directory name + # ends in `test`, are where that code belongs, and are exempt. + # + # The deny list below is the one part of this file a repository is + # expected to extend, and the only part it may. depguard matches an + # import path against a list of prefixes, so it cannot be told "any path + # whose last segment ends in test"; a repository's own test-support + # packages have to be named here one at a time, by full import path, + # under a module path that differs from repository to repository. Add + # them; change nothing else. + rules: + test-support: + list-mode: lax + files: + - "$all" + - "!$test" + - "!**/*test/**" + deny: + - pkg: net/http/httptest + desc: >- + Test-support code belongs in test files and in packages whose + directory name ends in test, not in the shipped binary. + # Only decisions already recorded in the Go package defaults are + # listed here. Every entry matches the module path exactly. + gomodguard_v2: + blocked: + - module: github.com/rs/zerolog + recommendations: + - log/slog + reason: "Structured logging is stdlib log/slog." + # One entry per pre-fork module path, because the later releases + # are separate paths. A prefix match would be shorter but would + # also reach github.com/go-redis/redismock, the test double for + # the successor these entries recommend. + - module: github.com/go-redis/redis + recommendations: + - github.com/redis/go-redis/v9 + reason: "Pre-fork module; use the maintained go-redis v9." + - module: github.com/go-redis/redis/v7 + recommendations: + - github.com/redis/go-redis/v9 + reason: "Pre-fork module; use the maintained go-redis v9." + - module: github.com/go-redis/redis/v8 + recommendations: + - github.com/redis/go-redis/v9 + reason: "Pre-fork module; use the maintained go-redis v9." + - module: github.com/sergi/go-diff + recommendations: + - github.com/aymanbagabas/go-udiff + reason: "No unified diff output; use go-udiff." + - module: github.com/hexops/gotextdiff + recommendations: + - github.com/aymanbagabas/go-udiff + reason: "Unmaintained fork; use go-udiff." issues: - exclude-use-default: false max-issues-per-linter: 0 max-same-issues: 0 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..60b93dd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# Lint phase. golangci-lint runs here, in the pinned linter image, never on +# the host. script/lint builds this stage by name with --no-cache. +# golangci/golangci-lint:v2.12.2, 2026-05-06 +FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN golangci-lint config verify --config .golangci.yml +RUN golangci-lint run --config .golangci.yml ./... + +# Test phase. script/test builds this stage by name with --no-cache. +# golang:1.26.1-bookworm, 2026-03-17 +FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS test +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN go test -count=1 -race -cover -timeout 90s ./... || \ + { echo "--- Rerunning with -v for details ---"; \ + go test -count=1 -race -v -timeout 90s ./...; exit 1; } + +# Build stage, and the last one. Nothing is wanted from the phases above; +# the two copies are the ordering edges that make BuildKit build them first, +# so this stage cannot build unless lint and test passed. For this +# non-server tool the final stage is the build/development environment +# carrying the compiled binary; rtnetmon runs on a host with the privileges +# to open raw ICMP sockets, not as a container service. +# golang:1.26.1-bookworm, 2026-03-17 +FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS builder +COPY --from=lint /src/go.sum /dev/null +COPY --from=test /src/go.sum /dev/null +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -trimpath -o /rtnetmon ./cmd/rtnetmon/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5c93f45 --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + Version 2, December 2004 + + Copyright (C) 2004 Sam Hocevar + + Everyone is permitted to copy and distribute verbatim or modified + copies of this license document, and changing it is allowed as long + as the name is changed. + + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. You just DO WHAT THE FUCK YOU WANT TO. diff --git a/Makefile b/Makefile index 4f31c61..cf34572 100644 --- a/Makefile +++ b/Makefile @@ -1,40 +1,52 @@ -.PHONY: test lint fmt build run-local clean all copy run +.PHONY: bootstrap setup test lint fmt fmt-check check build run dev deps \ + docker cibuild clean hooks -# Default target -default: test +# Target bodies live in script/ (Scripts to Rule Them All); the targets +# below are thin shims that call them. +.DEFAULT_GOAL := check -# Build the binary -build: - go build -o rtnetmon ./cmd/rtnetmon +bootstrap: + @script/bootstrap -# Run tests -test: lint - go test -v ./... +setup: + @script/setup + +test: + @script/test -# Run linter lint: - golangci-lint run --config .golangci.yml ./... + @script/lint -# Format code fmt: - go fmt ./... + @script/fmt -# Run the application locally -run-local: build - ./rtnetmon +fmt-check: + @script/fmt-check + +check: + @script/check + +docker: + @script/docker + +cibuild: + @script/cibuild + +hooks: + @script/install-precommit + +build: + CGO_ENABLED=0 go build -trimpath -o bin/rtnetmon ./cmd/rtnetmon + +run: build + ./bin/rtnetmon + +dev: + go run ./cmd/rtnetmon + +deps: + go mod download + go mod tidy -# Clean build artifacts clean: - rm -f rtnetmon - rm -f main.go.old - -# Build and test everything -all: fmt lint test build - -# Remote deployment targets -copy: - ssh root@las1stor1 -v "mkdir -p /tmp/x" - rsync -av --exclude='.git' --exclude='*.test' --exclude='/rtnetmon' --exclude='main.go.old' ./ root@las1stor1:/tmp/x/ - -run: copy - ssh -t root@las1stor1 -v "cd /tmp/x && go run ./cmd/rtnetmon" + rm -rf bin/ diff --git a/README.md b/README.md index b8fc7ef..dd9badb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # rtnetmon -Real-time network monitoring dashboard for Linux systems with dual-interface support. +rtnetmon is a WTFPL-licensed Go terminal (CLI/TUI) network monitor by +[@sneak](https://sneak.berlin) that shows real-time network health, packet +loss, and latency across two Linux network interfaces at once. ## Overview @@ -34,13 +36,13 @@ for a clean, real-time dashboard interface. ```bash git clone https://git.eeqj.de/sneak/rtnetmon.git cd rtnetmon -make build +make build # produces ./bin/rtnetmon ``` ## Usage ```bash -sudo ./rtnetmon --ifaceA eth0 --labelA "Primary WAN" --ifaceB wlan0 --labelB "Backup WiFi" +sudo ./bin/rtnetmon --ifaceA eth0 --labelA "Primary WAN" --ifaceB wlan0 --labelB "Backup WiFi" ``` ### Command Line Options @@ -60,41 +62,69 @@ sudo ./rtnetmon --ifaceA eth0 --labelA "Primary WAN" --ifaceB wlan0 --labelB "Ba ``` rtnetmon/ -├── cmd/rtnetmon/ # Main entry point +├── cmd/rtnetmon/ # main entry point (thin: calls internal/cli) │ └── main.go ├── internal/ -│ ├── cli/ # Command-line interface using Cobra +│ ├── cli/ # command-line interface using Cobra │ │ └── root.go -│ └── monitor/ # Core monitoring functionality -│ ├── monitor.go # Main monitoring types and functions -│ ├── loops.go # Monitoring loops (reachability, loss, TCP) -│ ├── styles.go # Terminal color styles -│ └── ui.go # User interface rendering -├── go.mod -├── go.sum -├── Makefile +│ └── 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 +├── 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 -### Building - ```bash -make build # Build the binary -make test # Run tests -make lint # Run linter -make fmt # Format code -make all # Format, lint, test, and build +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 ``` -### Testing +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. -The project includes unit tests for core functionality. Run tests with: +## Entrypoints -```bash -make test -``` +This repository adheres to the +[Scripts to Rule Them All](https://github.com/github/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 diff --git a/REPO_POLICIES.md b/REPO_POLICIES.md new file mode 100644 index 0000000..2256291 --- /dev/null +++ b/REPO_POLICIES.md @@ -0,0 +1,603 @@ +--- +title: Repository Policies +last_modified: 2026-09-08 +--- + +This document covers repository structure, tooling, and workflow standards. Code +style conventions are in separate documents: + +- [Code Styleguide](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE.md) + (general, bash, Docker) +- [Go](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_GO.md) +- [JavaScript](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md) +- [Python](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md) +- [Go HTTP Server Conventions](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md) + +--- + +- Cross-project documentation (such as this file) must include + `last_modified: YYYY-MM-DD` in the YAML front matter so it can be kept in sync + with the authoritative source as policies evolve. + +- **ALL external references must be pinned by cryptographic hash.** This + includes Docker base images, Go modules, npm packages, GitHub Actions, and + anything else fetched from a remote source. Version tags (`@v4`, `@latest`, + `:3.21`, etc.) are server-mutable and therefore remote code execution + vulnerabilities. The ONLY acceptable way to reference an external dependency + is by its content hash (Docker `@sha256:...`, Go module hash in `go.sum`, npm + integrity hash in lockfile, GitHub Actions `@`). No exceptions. + This also means never `curl | bash` to install tools like pyenv, nvm, rustup, + etc. Instead, download a specific release archive from GitHub, verify its hash + (hardcoded in the Dockerfile or script), and only then install. Unverified + install scripts are arbitrary remote code execution. This is the single most + important rule in this document. Double-check every external reference in + every file before committing. There are zero exceptions to this rule. + +- Every repo with software must have a root `Makefile` with these targets: + `make bootstrap`, `make setup`, `make test`, `make lint`, `make fmt` (writes), + `make fmt-check` (read-only), `make check` (runs `test`, `lint`, `fmt-check`), + `make docker`, and `make hooks` (installs pre-commit hook). A model Makefile + is at `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`. + +- Repos follow the + [Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) + pattern: the implementation of each Makefile target lives in an executable + script in `script/` (`script/bootstrap`, `script/setup`, `script/test`, + `script/lint`, `script/fmt`, `script/fmt-check`, `script/check`, + `script/docker`), and the Makefile targets are thin shims that call them. The + scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they run in + minimal containers (e.g. alpine images have no bash); locate the repo root + with `$(cd "$(dirname "$0")/.." && pwd -P)` and `cd` there before acting. From + the standard's canonical set we use `bootstrap`, `setup` (make the repo ready + for development after a fresh clone: runs `bootstrap`, then + `install-precommit`, plus any repo-specific initialization), `test`, and + `cibuild`. `script/bootstrap` installs all dependencies idempotently and + assumes nothing is present: base tools come from nix, apt, brew, or apk + (detected in that order; apt runs noninteractive). For node it uses the + installed node if present; otherwise it installs a PINNED node version via + nvm, first installing nvm itself if missing — from a hash-verified GitHub + release archive (never `curl | sh`), with bash installed as an explicit + prerequisite since nvm requires bash. yarn is then pinned via + `corepack prepare yarn@ --activate`. Never install "latest" or "lts"; + always exact versions. `script/cibuild` runs the CI build: it changes to the + repo root, runs `script/bootstrap`, runs `script/check`, and builds the image + with the version; the Gitea workflow calls it. **`script/cibuild` runs + `script/bootstrap` first**, because the workflow checks out the repo and runs + nothing else, while `script/fmt-check` runs the formatter on the host: on a + pristine checkout with nothing installed the run dies there, after the + containerised gates have passed. **The bootstrap alone is not enough**: + `script/bootstrap` installs node and yarn under nvm and leaves neither on the + `PATH` of the shell that called it, so a bare `yarn` still exits 127. The host + entrypoints that need yarn — `script/fmt` and `script/fmt-check` — therefore + source nvm for the pinned node version before invoking it, exactly as + `script/bootstrap`'s own install step does. A runner carrying nothing but + docker and git then gets through `script/check`. Four further scripts are our + own extensions to the standard: `script/check` runs `script/test`, + `script/lint` and `script/fmt-check`; `script/precommit` is what the git + pre-commit hook runs, and it calls `script/check`; `script/install-precommit` + installs the git pre-commit hook (the `make hooks` target shims to it); and + `script/projectname` (literally that filename) simply outputs the project's + name. Scripts that need the name call `script/projectname` — e.g. + `script/docker` assembles its image tag from it — so those scripts stay + byte-identical across all repos. Repo-type-specific pre-commit extras (e.g. + `go mod tidy` verification in Go repos) belong in `script/precommit`, not in + the hook itself. Model scripts are at + `https://git.eeqj.de/sneak/prompts/raw/branch/main/script/`. The README + must document the provided scripts in an **Entrypoints** section (see the + README requirements below). + +- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.) + instead of invoking the underlying tools directly. The Makefile is the single + source of truth for how these operations are run. + +- The Makefile is authoritative documentation for how the repo is used. Beyond + the required targets above, it should have targets for every common operation: + running a local development server (`make run`, `make dev`), re-initializing + or migrating the database (`make db-reset`, `make migrate`), building + artifacts (`make build`), generating code, seeding data, or anything else a + developer would do regularly. If someone checks out the repo and types + `make`, they should see every meaningful operation available. A new + contributor should be able to understand the entire development workflow by + reading the Makefile. + +- Every repo should have a `Dockerfile`, and it carries the repo's gates: a + `lint` phase and a `test` phase, with the final stage depending on both so the + image cannot be built unless they pass. For non-server repos the final stage + brings up a development environment; for server repos it is the runtime image. + Dockerfiles install development prerequisites by running `script/bootstrap` + rather than duplicating installs inline; COPY `script/` and the dependency + manifests (`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before + running it. + +- **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is + no separate lint file. `script/lint` and `script/test` each build one phase + and nothing else: + + ```sh + docker build --no-cache --target lint -t "$(script/projectname)-lint" . + docker build --no-cache --target test -t "$(script/projectname)-test" . + ``` + + **A stage that is not the last one in the file is built only when the final + stage's chain depends on it, or when `--target` names it.** That is why the + two gates are always invoked by name here, and why the final stage carries a + `COPY --from=` of a harmless file from each of them: without that edge a + plain `docker build .` builds the last stage alone and exits 0 having linted + and tested nothing. + + **Every `docker build` in `script/` is tagged**, here and in + `script/cibuild` and `script/docker`. An untagged build leaves a dangling + image behind on every invocation, on every developer host and every CI + runner; a tagged one replaces the previous image. + + Inside a phase the tool is invoked directly — `golangci-lint`, `go test`, + `eslint`, `prettier` — never through `make lint` or `script/test`, which are + themselves a `docker build` and would recurse into a daemon that does not + exist in a build step. Formatting is the exception and stays on the host: + `script/fmt` writes the working tree, and `script/fmt-check` is its + read-only twin. + + **No lint verdict may come from a host invocation of the linter.** On a + shared host golangci-lint reads a result cache keyed on file content rather + than location, so a second checkout of the same content is served the first + one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs + exit non-zero with `parallel golangci-lint is running` — a status a caller + cannot tell from real findings. Both have produced wrong verdicts in this + org, in both directions. A container has its own cache, its own `TMPDIR` and + a digest-pinned binary, so neither is reachable. + +- **Any build that runs checks is built with `--no-cache`.** Docker invalidates + a `COPY` layer only when the copied content changes, so on an unchanged tree + the check `RUN` is served from cache, nothing executes, and the build still + exits 0. Every `docker build` in `script/` therefore passes `--no-cache`: + `script/lint`, `script/test`, `script/cibuild` and `script/docker` are the + four, and there is no fifth — `script/check` runs the two gate phases and + `script/fmt-check`, and builds no image of its own. A bare `docker build .` is + not evidence that anything ran: a sub-second build reporting success is a + cache hit, not a result. Never invalidate by pruning — `docker builder prune` + and friends destroy a build cache shared with every other build on the host. + +- **The gate phases are separate stages, and the build stage depends on both.** + The lint phase is based on the `golangci/golangci-lint` image (pinned by + hash), so lint failures surface in seconds rather than after a full compile, + and the test phase is based on the Go image. The canonical Go repo + `Dockerfile`: + + ```dockerfile + # Lint phase + # golangci/golangci-lint:v2.x.x, YYYY-MM-DD + FROM golangci/golangci-lint@sha256:... AS lint + WORKDIR /src + COPY go.mod go.sum ./ + RUN go mod download + COPY . . + RUN golangci-lint run --config .golangci.yml ./... + + # Test phase + # golang:1.x-alpine, YYYY-MM-DD + FROM golang@sha256:... AS test + WORKDIR /src + COPY go.mod go.sum ./ + RUN go mod download + COPY . . + RUN go test -timeout 90s -race -cover ./... || \ + { echo "--- Rerunning with -v for details ---"; \ + go test -timeout 90s -race -v ./...; exit 1; } + + # Build stage. Nothing is wanted from either phase above; the copies + # are what make BuildKit build them first, so this stage cannot run + # unless lint and test passed. + # golang:1.x-alpine, YYYY-MM-DD + FROM golang@sha256:... AS builder + COPY --from=lint /src/go.sum /dev/null + COPY --from=test /src/go.sum /dev/null + WORKDIR /src + COPY go.mod go.sum ./ + RUN go mod download + COPY . . + + ARG VERSION=dev + RUN CGO_ENABLED=0 go build -trimpath \ + -ldflags="-s -w -X main.Version=${VERSION}" \ + -o /app ./cmd/app/ + + # Runtime stage, and the last one + FROM alpine@sha256:... + COPY --from=builder /app /usr/local/bin/app + ENTRYPOINT ["app"] + ``` + + Key points: + - The lint phase uses the `golangci/golangci-lint` image directly (it has + both Go and the linter), so nothing needs installing. + - `COPY --from= /src/go.sum /dev/null` is a no-op copy whose only + purpose is the ordering edge. BuildKit runs stages in parallel by default, + and a stage nothing depends on is not built at all, so without these two + lines a red gate would not fail the build. + - Keep the runtime stage last, and if you add a stage after it, give it the + same two copies. A plain `docker build .` builds the last stage's chain + and nothing else. + - If the project uses `//go:embed` directives that reference build artifacts + (e.g. a web frontend compiled in a separate stage), the lint phase must + create placeholder files so the embed directives resolve. Example: + `RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`. + - If the project requires CGO or system libraries for linting (e.g. + `vips-dev`), install them in the lint phase with `apk add`. + - `ARG VERSION=dev` is declared in the stage that compiles and supplied by + `script/docker` and `script/cibuild`; no stage may call `git describe`. + +- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that + runs `script/cibuild` on push, and checks out the repo as its only other step. + That script bootstraps, runs the gate phases, and then builds the image, so a + successful run means every check passed; a bare `docker build .` does not + carry the same guarantee, because its gate phases may come from the cache. The + image build is uncached and so runs the gate phases a second time. That is the + price of the rule above, and it is worth paying: the image that ships is built + from a run of its own gates rather than from a cache entry. + +- Use platform-standard formatters: `black` for Python, `prettier` for + JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with + two exceptions: four-space indents (except Go), and `proseWrap: always` for + Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown, + HTML, CSS) should also have `.prettierrc` and `.prettierignore`. + +- Pre-commit hook: runs `script/precommit`, which calls `script/check`. If local + testing is not possible in the repo, `script/precommit` may skip `script/test` + and run only `script/lint` and `script/fmt-check`. The hook is installed by + `script/install-precommit`; the Makefile must provide a `make hooks` target + that shims to it. + +- All repos with software must have tests that run via the platform-standard + test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful + tests exist yet, add the most minimal test possible — e.g. importing the + module under test to verify it compiles/parses. There is no excuse for + `make test` to be a no-op. + +- `make test` must complete in under 60 seconds. That is the hard cap, and a + suite that exceeds it fails. Under 20 seconds is the target. A suite between + 20 and 60 seconds is still green, but the overage must be filed as an + improvement bug against that repo. Add a 90-second timeout to the test + invocation (`go test -timeout 90s`). The backstop deliberately sits above the + hard cap so that it catches a genuinely hung test rather than a merely slow + one. + +- **The test command should use the conditional verbose rerun pattern.** Run + tests without `-v` (verbose) first. If tests fail, automatically rerun with + `-v` to show full output. This keeps CI logs and `docker build` output clean + on success (just package/suite summaries) while providing full diagnostic + detail on failure (every test case, every assertion). The command lives in the + `test` phase of the `Dockerfile`, since `script/test` builds that phase; the + Makefile form below is the same pattern for any repo-local invocation: + + ```makefile + test: + @ || \ + { echo "--- Rerunning with -v for details ---"; \ + ; exit 1; } + ``` + + Go example: + + ```makefile + test: + @go test -count=1 -timeout 90s -race -cover ./... || \ + { echo "--- Rerunning with -v for details ---"; \ + go test -count=1 -timeout 90s -race -v ./...; exit 1; } + ``` + + `-count=1` is required on both invocations: it defeats Go's test _result_ + cache, so the target cannot report a pass it did not earn, and the rerun + reproduces a failure instead of replaying it. It leaves the build cache + alone, so it costs the runtime of the suite and no recompilation. + + Note that this is a second, independent cache, stacked below the Docker + layer cache that [issue #26](https://git.eeqj.de/sneak/prompts/issues/26) + addresses. `CHECK_EPOCH` guarantees the `RUN make test` _step_ re-executes; + it does not guarantee `go test` inside that step does any work, because the + `GOCACHE` baked into earlier image layers survives into the re-executed + step. They are two separate defects requiring two separate fixes, and a fix + for one must not be recorded as covering the other. + + Python example: + + ```makefile + test: + @python -m pytest || \ + { echo "--- Rerunning with -v for details ---"; \ + python -m pytest -v; exit 1; } + ``` + + The `exit 1` ensures the target always fails after a rerun — the first run + already proved the tests are broken, so the build must not pass even if a + flaky test happens to succeed on the second attempt. The rerun exists solely + for diagnostic output. + +- Docker builds must complete in under 5 minutes. + +- `make check` must not modify any files in the repo. Tests may use temporary + directories. + +- `main` must always pass `make check`, no exceptions. + +- Never commit secrets. `.env` files, credentials, API keys, and private keys + must be in `.gitignore`. No exceptions. + +- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`), + editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`), + language build artifacts, and `node_modules/`. Fetch the standard `.gitignore` + from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when + setting up a new repo. These patterns are written to `.gitignore`'s own + semantics, in which an unanchored pattern already matches at every depth; they + are not a `.dockerignore` and must not be transplanted into one unmodified. + +- **`.dockerignore` does not use `.gitignore` semantics, and copying patterns + across unmodified leaves secrets in the build context.** Docker matches with + `moby/patternmatcher`: `filepath.Match` semantics plus a `**` extension, so + `*` does not cross `/` and a pattern without a leading `**/` is anchored at + the build-context root. A `.dockerignore` listing `.env`, `*.pem` and `*.key` + therefore excludes only the copies at the repository root, while `config/.env` + and `certs/server.key` still reach the context and can land in an image layer + — which is more dangerous than a short file with no secret patterns at all, + because it reads as solved and stops anyone looking. Give every + depth-independent pattern the `**/` prefix and leave only genuinely + root-anchored entries unprefixed: `.git`, and the repo's own host-built + binary, written `/myapp` and never `**/myapp`, which would also match + `cmd/myapp/` and delete the package directory from the context. Matching is + case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so + secret names use character ranges — `**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`, + and likewise for `.envrc` and the extensionless SSH keys. Where such a pattern + also catches something the build needs, re-include it with a negation + (`!docs/example.env`); deleting the pattern reopens the exposure for every + other file it covers. Fetch the standard `.dockerignore` from + `https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` and extend + it with the repo's own artifacts. + +- **In-repo agent scratch belongs in both files, written to each file's own + semantics.** `.claude/` holds one worktree per in-flight agent — an entire + additional checkout of the repo — so under `COPY . .` the build context + inflates by a multiple of the repo and another session's unreviewed work can + be copied into an image layer. In `.gitignore` the entry is `.claude/`, + unanchored. In `.dockerignore` it is `.claude`, anchored and with **no** `**/` + prefix, because the prefixed form would also delete any nested directory of + that name from the build. Anchoring carries a known gap that the canonical + `.dockerignore` states in its own comment, since consuming repos receive the + file and not the tracker: the directory is created in the agent's working + directory, so a repo running agents in subdirectories still ships + `services/api/.claude/` and must add its own anchored entry there. + +- **Excluding `.git` means `git describe` cannot run inside any build stage, and + it fails quietly there.** In a build stage there is no repository, so + `git describe` writes nothing to stdout, `-X main.Version=` comes out empty, + the binary reports no version at all, and the build still exits 0. Compute the + version on the host and thread it in as a build arg. `script/docker` and + `script/cibuild` do this, byte-identically across repos: + + ```sh + # Own line: a failing command substitution inside an argument does not + # trip `set -e`, so the inline form degrades to an empty constant. + version="$(git describe --tags --always --dirty 2>/dev/null || true)" + [ -n "$version" ] || version="unknown" + docker build --no-cache \ + --build-arg VERSION="$version" \ + -t "$(script/projectname)" . + ``` + + `--always` makes an untagged repo yield an abbreviated commit hash rather + than failing, and the `[ -n "$version" ]` line is the single place the + fallback is applied — a live check that fires on a build from an export with + no `.git` and on a repository with no commits yet. Do not fold it into the + substitution as `|| echo unknown`, which makes the guard unreachable. The + Dockerfile's side is `ARG VERSION=dev` in the stage that compiles, declared + there because `ARG` is stage-scoped; passing `VERSION` to a repo whose + Dockerfile declares no such `ARG` is ignored and costs nothing, which is why + the scripts stay byte-identical. One consequence for CI: the standard + checkout action clones shallow and fetches no tags, so a repo that embeds a + tag-derived version must set `fetch-depth: 0` on its checkout step. + +- **Verify `.dockerignore` by enumerating the image, not by reading the + patterns.** Plant files at the root _and_ at least two directories deep, build + a probe image that does `COPY . .`, and list what actually landed + (`docker run --rm --entrypoint find IMAGE /app`). The `transferring context` + size is not a substitute: a nested secret is a few bytes, and BuildKit + transfers only the delta from the previous build. + +- **No build artifacts in version control.** Code-derived data (compiled + bundles, minified output, generated assets) must never be committed to the + repository if it can be avoided. The build process (e.g. Dockerfile, Makefile) + should generate these at build time. Notable exception: Go protobuf generated + files (`.pb.go`) ARE committed because repos need to work with `go get`, which + downloads code but does not execute code generation. + +- Never use `git add -A` or `git add .`. Always stage files explicitly by name. + +- Never force-push to `main`. + +- Make all changes on a feature branch. You can do whatever you want on a + feature branch. + +- `.golangci.yml` is standardized. The vendored copy in a consuming repo must + _NEVER_ be modified by an agent: fetch it from + `https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and keep it + byte-identical, so that no repo can quietly loosen its own linting. Linter + configuration changes are made to the canonical copy in the `prompts` repo and + reach consuming repos by re-vendoring; an agent may open a PR against + canonical, which only the user merges. One list is exempt from byte-identity, + because it cannot be written once for every repo: the `deny` list of the + `test-support` depguard rule, where a repo names its own test-support packages + by full import path. A repo adds entries there and changes nothing else, and a + re-vendor carries its entries forward. The canonical golangci-lint version is + v2.12.2 (released 2026-05-06), pinned as the digest of the lint phase's base + image + (`golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240`, + which reports `2.12.2 built with go1.26.2 from c0d3ddc9`). That digest is the + only pin, since no repo installs golangci-lint on the host: bumping the + version means changing it and nothing else. + +- **`script/bootstrap` installs a pinned tool by comparing versions, never by + testing presence.** An `if ! command -v ; then install; fi` guard tests + `PATH` only, so on an already-provisioned machine the pin is inert and a + version bump is a silent no-op — while the Dockerfile, installing into a clean + image, gets the pinned version, so a local `make check` and `make docker` can + disagree about what the tool even is. The canonical form: + - compares the installed version against the pin over the **whole** version + token; a parser that stops at the first `-` reports `2.12.2` for a host + running `2.12.2-rc1` and skips the install; + - treats absent, non-zero, empty or unrecognised `--version` output as a + mismatch, so the failure direction is a redundant install and never a + skipped one; + - after installing, re-resolves the binary the way callers do — `hash -r`, + then through `PATH`, not through the directory the installer wrote to — + and fails naming the resolved path, since an install that a shadowing + binary hides succeeds while changing nothing any caller sees; + - is actually called, and prints the version on both success paths: a + function defined and never invoked has the same exit status and the same + empty output as one that worked. + + Keep it POSIX sh: no arrays, no `[[`, no `grep -P`. + +- When pinning images or packages by hash, add a comment above the reference + with the version and date (YYYY-MM-DD). + +- Use `yarn`, not `npm`. + +- Write all dates as YYYY-MM-DD (ISO 8601). + +- Simple projects should be configured with environment variables. + +- Dockerized web services listen on port 8080 by default, overridable with + `PORT`. + +- **HTTP/web services must be hardened for production internet exposure before + tagging 1.0.** This means full compliance with security best practices + including, without limitation, all of the following: + - **Security headers** on every response: + - `Strict-Transport-Security` (HSTS) with `max-age` of at least one year + and `includeSubDomains`. + - `Content-Security-Policy` (CSP) with a restrictive default policy + (`default-src 'self'` as a baseline, tightened per-resource as + needed). Never use `unsafe-inline` or `unsafe-eval` unless + unavoidable, and document the reason. + - `X-Frame-Options: DENY` (or `SAMEORIGIN` if framing is required). + Prefer the `frame-ancestors` CSP directive as the primary control. + - `X-Content-Type-Options: nosniff`. + - `Referrer-Policy: strict-origin-when-cross-origin` (or stricter). + - `Permissions-Policy` restricting access to browser features the + application does not use (camera, microphone, geolocation, etc.). + - **Request and response limits:** + - Maximum request body size enforced on all endpoints (e.g. Go + `http.MaxBytesReader`). Choose a sane default per-route; never accept + unbounded input. + - Maximum response body size where applicable (e.g. paginated APIs). + - `ReadTimeout` and `ReadHeaderTimeout` on the `http.Server` to defend + against slowloris attacks. + - `WriteTimeout` on the `http.Server`. + - `IdleTimeout` on the `http.Server`. + - Per-handler execution time limits via `context.WithTimeout` or + chi/stdlib `middleware.Timeout`. + - **Authentication and session security:** + - Rate limiting on password-based authentication endpoints. API keys are + high-entropy and not susceptible to brute force, so they are exempt. + - CSRF tokens on all state-mutating HTML forms. API endpoints + authenticated via `Authorization` header (Bearer token, API key) are + exempt because the browser does not attach these automatically. + - Passwords stored using bcrypt, scrypt, or argon2 — never plain-text, + MD5, or SHA. + - Session cookies set with `HttpOnly`, `Secure`, and `SameSite=Lax` (or + `Strict`) attributes. + - **Reverse proxy awareness:** + - True client IP detection when behind a reverse proxy + (`X-Forwarded-For`, `X-Real-IP`). The application must accept + forwarded headers only from a configured set of trusted proxy + addresses — never trust `X-Forwarded-For` unconditionally. + - **CORS:** + - Authenticated endpoints must restrict `Access-Control-Allow-Origin` to + an explicit allowlist of known origins. Wildcard (`*`) is acceptable + only for public, unauthenticated read-only APIs. + - **Error handling:** + - Internal errors must never leak stack traces, SQL queries, file paths, + or other implementation details to the client. Return generic error + messages in production; detailed errors only when `DEBUG` is enabled. + - **TLS:** + - Services never terminate TLS directly. They are always deployed behind + a TLS-terminating reverse proxy. The service itself listens on plain + HTTP. However, HSTS headers and `Secure` cookie flags must still be + set by the application so that the browser enforces HTTPS end-to-end. + + This list is non-exhaustive. Apply defense-in-depth: if a standard security + hardening measure exists for HTTP services and is not listed here, it is + still expected. When in doubt, harden. + +- `README.md` is the primary documentation. Required sections: + - **Description**: First line must include the project name, purpose, + category (web server, SPA, CLI tool, etc.), license, and author. Example: + "µPaaS is an MIT-licensed Go web application by @sneak that receives + git-frontend webhooks and deploys applications via Docker in realtime." + - **Getting Started**: Copy-pasteable install/usage code block. + - **Entrypoints**: Opens by stating that the repo adheres to the + [Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) + standard (with that link), then documents each provided `script/` + entrypoint and its purpose. + - **Rationale**: Why does this exist? + - **Design**: How is the program structured? + - **TODO**: Update meticulously, even between commits. When planning, put + the todo list in the README so a new agent can pick up where the last one + left off. + - **License**: MIT, GPL, or WTFPL. Ask the user for new projects. Include a + `LICENSE` file in the repo root and a License section in the README. + - **Author**: [@sneak](https://sneak.berlin). + +- First commit of a new repo should contain only `README.md`. + +- Go module root: `sneak.berlin/go/`. Always run `go mod tidy` before + committing. + +- Use SemVer. + +- Database migrations live in `internal/db/migrations/` and must be embedded in + the binary. + - `000_migration.sql` — contains ONLY the creation of the migrations + tracking table itself. Nothing else. + - `001_schema.sql` — the full application schema. + - **Pre-1.0.0:** never add additional migration files (002, 003, etc.). + There is no installed base to migrate. Edit `001_schema.sql` directly. + - **Post-1.0.0:** add new numbered migration files for each schema change. + Never edit existing migrations after release. + +- All repos should have an `.editorconfig` enforcing the project's indentation + settings. + +- Avoid putting files in the repo root unless necessary. Root should contain + only project-level config files (`README.md`, `Makefile`, `Dockerfile`, + `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and + language-specific config). Everything else goes in a subdirectory. Canonical + subdirectory names: + - `bin/` — executable scripts and tools + - `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose + body is a single call into `internal/` or `pkg/`, no project logic in + `cmd/` + - `configs/` — configuration templates and examples + - `deploy/` — deployment manifests (k8s, compose, terraform) + - `docs/` — documentation and markdown (README.md stays in root) + - `internal/` — Go internal packages + - `internal/db/migrations/` — database migrations + - `pkg/` — Go library packages + - `share/` — systemd units, data files + - `static/` — static assets (images, fonts, etc.) + - `web/` — web frontend source + +- When setting up a new repo, files from the `prompts` repo may be used as + templates. Fetch them from + `https://git.eeqj.de/sneak/prompts/raw/branch/main/`. + +- New repos must contain at minimum: + - `README.md`, `.git`, `.gitignore`, `.editorconfig` + - `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo) + - `Makefile` + - `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`, + `lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`, + `install-precommit`) + - `Dockerfile`, `.dockerignore` + - `.gitea/workflows/check.yml` + - Go: `go.mod`, `go.sum`, `.golangci.yml` + - JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore` + - Python: `pyproject.toml` diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..00222ad --- /dev/null +++ b/TODO.md @@ -0,0 +1,42 @@ +# Workflow + +One issue per unit of work, one branch and one PR per issue: + +- ensure a tracked issue exists with a definition of done +- branch from `next` (never from `main`) +- do the work; open a PR based on `next` (never on `main`) +- pass an independent review, then the change is squash-merged into `next` +- push; nothing stays local-only + +`next` is the branch for the next milestone and must stay green and +mergeable to `main` without notice. Only `sneak` merges `next` into +`main`, and for now only `sneak` merges into `next`. No commits land +directly on `main` or `next` — only merges via PRs. + +Issue branches do NOT touch this file — it is maintained on `next`. +Every branch editing `TODO.md` conflicts with every other. + +# Status + +The repository has been brought up to current repo standards: `script/` +Scripts to Rule Them All entrypoints with the `Makefile` reduced to thin +shims, a `Dockerfile` whose `lint` and `test` phases gate the build, a +`.gitea/workflows/` CI workflow running `script/cibuild`, the vendored +`.golangci.yml`, `REPO_POLICIES.md`, `.editorconfig`, `.dockerignore`, a +`LICENSE` file, and a comprehensive `.gitignore`. + +Lint is clean under the standard `default: all` configuration, with the +findings fixed rather than suppressed. The only annotations are +justified `//nolint:gosec` on the `ping`/`curl` subprocess calls (G204) +and on opening the operator-chosen log file (G304): fixed argv with no +shell, so these are false positives, annotated as the reference repos do. + +# Next Step + +Feature work, each on its own branch and PR from `next`: + +- https://git.eeqj.de/sneak/rtnetmon/issues/2 — macOS support: + VPN-aware interface detection and a single-interface UI when only one + interface exists. +- https://git.eeqj.de/sneak/rtnetmon/issues/3 — show Starlink status + lines when Starlink is the non-VPN gateway. diff --git a/cmd/rtnetmon/main.go b/cmd/rtnetmon/main.go index 3334586..9ab1c90 100644 --- a/cmd/rtnetmon/main.go +++ b/cmd/rtnetmon/main.go @@ -1,20 +1,18 @@ //go:build linux -// +build linux -// netmon – dual-interface network dashboard (curses) -// WTFPL – 2025-05-16 sneak@sneak.berlin +// Command rtnetmon is a dual-interface real-time network monitoring +// dashboard for Linux. WTFPL, sneak@sneak.berlin. package main import ( "log" - "os" "git.eeqj.de/sneak/rtnetmon/internal/cli" ) func main() { - if err := cli.Execute(); err != nil { + err := cli.Execute() + if err != nil { log.Fatal(err) - os.Exit(1) } } diff --git a/internal/cli/export_test.go b/internal/cli/export_test.go new file mode 100644 index 0000000..316dfe7 --- /dev/null +++ b/internal/cli/export_test.go @@ -0,0 +1,8 @@ +//go:build linux + +package cli + +import "github.com/spf13/cobra" + +// NewRootCmd exposes newRootCmd for external tests. +func NewRootCmd() *cobra.Command { return newRootCmd() } diff --git a/internal/cli/root.go b/internal/cli/root.go index bd45ef9..456cff5 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -1,6 +1,6 @@ //go:build linux -// +build linux +// Package cli wires command-line flags to the network monitor and runs it. package cli import ( @@ -15,8 +15,8 @@ import ( "git.eeqj.de/sneak/rtnetmon/internal/monitor" ) -// Config holds the application configuration -type Config struct { +// config holds the application configuration. +type config struct { IfaceA string LabelA string IfaceB string @@ -25,94 +25,104 @@ type Config struct { LogFile string } -var ( - cfg Config - rootCmd = &cobra.Command{ - Use: "rtnetmon", - Short: "Real-time network monitoring dashboard", - Long: `rtnetmon is a dual-interface network monitoring dashboard that provides -real-time visibility into network health, packet loss, and latency.`, - RunE: runMonitor, - } -) - -// Default hosts for monitoring -var ( - defaultReachabilityHosts = []string{ +// defaultReachabilityHosts is the default reachability host list. +func defaultReachabilityHosts() []string { + return []string{ "8.8.8.8", "8.8.4.4", "google.com", "github.com", "console.aws.amazon.com", "console.cloud.google.com", "fast.com", "datavi.be", "captive.apple.com", } +} - defaultPacketLossHosts = []string{ - "github.com", "google.com", "8.8.8.8", "captive.apple.com", "62.115.190.68", +// defaultPacketLossHosts is the default packet-loss host list. +func defaultPacketLossHosts() []string { + return []string{ + "github.com", "google.com", "8.8.8.8", + "captive.apple.com", "62.115.190.68", } +} - defaultTCPHosts = []string{ +// defaultTCPHosts is the default TCP connect host:port list. +func defaultTCPHosts() []string { + return []string{ "datavi.be:443", "fast.com:443", "console.aws.amazon.com:443", "console.cloud.google.com:443", "captive.apple.com:80", "google.com:443", } -) - -func init() { - // Define flags - rootCmd.Flags().StringVar(&cfg.IfaceA, "ifaceA", "gu0", "primary network interface") - rootCmd.Flags().StringVar(&cfg.LabelA, "labelA", "gu LAN - VPN outbound", "label for ifaceA") - rootCmd.Flags().StringVar(&cfg.IfaceB, "ifaceB", "backhaul0", "secondary network interface") - rootCmd.Flags().StringVar(&cfg.LabelB, "labelB", "Cox cable direct", "label for ifaceB") - rootCmd.Flags().StringSliceVar(&cfg.Hosts, "hosts", defaultReachabilityHosts, "comma-separated reachability hosts") - rootCmd.Flags().StringVar(&cfg.LogFile, "logfile", "/tmp/rtnetmon.log", "path to log file") - - // Bind flags to viper - _ = viper.BindPFlag("ifaceA", rootCmd.Flags().Lookup("ifaceA")) - _ = viper.BindPFlag("labelA", rootCmd.Flags().Lookup("labelA")) - _ = viper.BindPFlag("ifaceB", rootCmd.Flags().Lookup("ifaceB")) - _ = viper.BindPFlag("labelB", rootCmd.Flags().Lookup("labelB")) - _ = viper.BindPFlag("hosts", rootCmd.Flags().Lookup("hosts")) - _ = viper.BindPFlag("logfile", rootCmd.Flags().Lookup("logfile")) } -func runMonitor(cmd *cobra.Command, args []string) error { +// newRootCmd builds the cobra root command with its flags bound. +func newRootCmd() *cobra.Command { + cfg := &config{} + cmd := &cobra.Command{ + Use: "rtnetmon", + Short: "Real-time network monitoring dashboard", + Long: `rtnetmon is a dual-interface network monitoring dashboard that provides +real-time visibility into network health, packet loss, and latency.`, + RunE: func(_ *cobra.Command, _ []string) error { + return runMonitor(cfg) + }, + } + registerFlags(cmd, cfg) + + return cmd +} + +// registerFlags defines and viper-binds the command's flags. +func registerFlags(cmd *cobra.Command, cfg *config) { + f := cmd.Flags() + f.StringVar(&cfg.IfaceA, "ifaceA", "gu0", "primary network interface") + f.StringVar(&cfg.LabelA, "labelA", "gu LAN - VPN outbound", "label for ifaceA") + f.StringVar(&cfg.IfaceB, "ifaceB", "backhaul0", "secondary network interface") + f.StringVar(&cfg.LabelB, "labelB", "Cox cable direct", "label for ifaceB") + f.StringSliceVar(&cfg.Hosts, "hosts", defaultReachabilityHosts(), + "comma-separated reachability hosts") + f.StringVar(&cfg.LogFile, "logfile", "/tmp/rtnetmon.log", "path to log file") + + for _, name := range []string{ + "ifaceA", "labelA", "ifaceB", "labelB", "hosts", "logfile", + } { + _ = viper.BindPFlag(name, f.Lookup(name)) + } +} + +// runMonitor constructs and runs the monitor from cfg. +func runMonitor(cfg *config) error { monitor.Logf(cfg.LogFile, "Starting rtnetmon") - monitor.Logf(cfg.LogFile, "Monitoring interfaces %s and %s", cfg.IfaceA, cfg.IfaceB) + monitor.Logf(cfg.LogFile, "Monitoring interfaces %s and %s", + cfg.IfaceA, cfg.IfaceB) - // Create the monitor - mon := monitor.NewMonitor(cfg.IfaceA, cfg.LabelA, cfg.IfaceB, cfg.LabelB, cfg.LogFile) + mon := monitor.NewMonitor(cfg.IfaceA, cfg.LabelA, cfg.IfaceB, cfg.LabelB, + cfg.LogFile) - // Add reachability hosts for _, host := range cfg.Hosts { mon.AddReachabilityHost(host) } - // Add packet loss hosts - for _, host := range defaultPacketLossHosts { + for _, host := range defaultPacketLossHosts() { mon.AddPacketLossHost(host) } - // Add TCP hosts - for _, host := range defaultTCPHosts { + for _, host := range defaultTCPHosts() { mon.AddTCPHost(host) } - // Create context with signal handling ctx, cancel := context.WithCancel(context.Background()) defer cancel() - // Handle signals sig := make(chan os.Signal, 1) signal.Notify(sig, os.Interrupt, syscall.SIGTERM) + go func() { s := <-sig monitor.Logf(cfg.LogFile, "Signal received: %v", s) cancel() }() - // Run the monitor return mon.Run(ctx) } -// Execute runs the root command +// Execute builds the root command and runs it. func Execute() error { - return rootCmd.Execute() + return newRootCmd().Execute() } diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 6f0d373..4e6ade9 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -1,36 +1,25 @@ //go:build linux -// +build linux -package cli +package cli_test import ( "testing" + + "git.eeqj.de/sneak/rtnetmon/internal/cli" ) -// TestExecute tests that the CLI can be initialized -func TestExecute(t *testing.T) { - // This is a simple compilation test to ensure the CLI package compiles - // We can't easily test the full Execute() function as it starts the UI +func TestNewRootCmd(t *testing.T) { + t.Parallel() - // Test that rootCmd is properly initialized - if rootCmd == nil { - t.Fatal("rootCmd is nil") + cmd := cli.NewRootCmd() + if cmd.Use != "rtnetmon" { + t.Errorf("Use = %q, want %q", cmd.Use, "rtnetmon") } - if rootCmd.Use != "rtnetmon" { - t.Errorf("Expected rootCmd.Use to be 'rtnetmon', got '%s'", rootCmd.Use) - } - - // Test that default configuration is set - if len(defaultReachabilityHosts) == 0 { - t.Error("defaultReachabilityHosts is empty") - } - - if len(defaultPacketLossHosts) == 0 { - t.Error("defaultPacketLossHosts is empty") - } - - if len(defaultTCPHosts) == 0 { - t.Error("defaultTCPHosts is empty") + names := []string{"ifaceA", "labelA", "ifaceB", "labelB", "hosts", "logfile"} + for _, name := range names { + if cmd.Flags().Lookup(name) == nil { + t.Errorf("flag %q not registered", name) + } } } diff --git a/internal/monitor/export_test.go b/internal/monitor/export_test.go new file mode 100644 index 0000000..aff1b4c --- /dev/null +++ b/internal/monitor/export_test.go @@ -0,0 +1,20 @@ +//go:build linux + +package monitor + +// Test-only accessors exposing unexported state for white-box assertions. + +// ReachabilityHosts returns the configured reachability hosts. +func (m *Monitor) ReachabilityHosts() []string { return m.reachabilityHosts } + +// PacketLossHosts returns the configured packet-loss hosts. +func (m *Monitor) PacketLossHosts() []string { return m.packetLossHosts } + +// TCPHosts returns the configured TCP hosts. +func (m *Monitor) TCPHosts() []string { return m.tcpHosts } + +// InterfaceA returns the first monitored interface. +func (m *Monitor) InterfaceA() *InterfaceStatus { return m.interfaceA } + +// InterfaceB returns the second monitored interface. +func (m *Monitor) InterfaceB() *InterfaceStatus { return m.interfaceB } diff --git a/internal/monitor/loops.go b/internal/monitor/loops.go index d624c11..9671d62 100644 --- a/internal/monitor/loops.go +++ b/internal/monitor/loops.go @@ -1,278 +1,319 @@ //go:build linux -// +build linux package monitor import ( "context" + crand "crypto/rand" "math" - "math/rand" + "math/big" "strings" "sync" "time" ) -// UIUpdateChan is used to signal UI updates -var UIUpdateChan = make(chan struct{}, 100) +// Probe scheduling and change-detection thresholds. +const ( + jitterBaseMillis = 100 + jitterRangeMillis = 800 + lossChangeThreshold = 0.01 + tcpChangeThreshold = 20 // milliseconds +) -// reachLoop monitors reachability for hosts +// randomOffset returns a startup jitter to avoid clustering probes at the +// same instant across loops. crypto/rand is used so no weak PRNG is linked. +func randomOffset() time.Duration { + n, err := crand.Int(crand.Reader, big.NewInt(jitterRangeMillis)) + if err != nil { + return jitterBaseMillis * time.Millisecond + } + + return time.Duration(jitterBaseMillis+n.Int64()) * time.Millisecond +} + +// probeAll runs probe for each host concurrently and returns the results +// keyed by host. +func probeAll[T any](hosts []string, probe func(host string) T) map[string]T { + var ( + wg sync.WaitGroup + mu sync.Mutex + res = make(map[string]T, len(hosts)) + ) + + for _, h := range hosts { + wg.Add(1) + + go func(host string) { + defer wg.Done() + + v := probe(host) + + mu.Lock() + res[host] = v + mu.Unlock() + }(h) + } + + wg.Wait() + + return res +} + +// reachLoop monitors reachability for hosts on one interface. func (m *Monitor) reachLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { - m.logf("Starting reachability monitoring for %s with %d hosts", st.Name, len(hosts)) - - // Add random offset to avoid clustering at 1-second intervals - randomOffset := time.Duration(100+rand.Intn(800)) * time.Millisecond - m.logf("Reachability monitoring for %s will start after %v offset", st.Name, randomOffset) - time.Sleep(randomOffset) + m.logf("Starting reachability monitoring for %s with %d hosts", + st.Name, len(hosts)) + time.Sleep(randomOffset()) tk := time.NewTicker(time.Second) defer tk.Stop() + for { select { case <-ctx.Done(): m.logf("Stopping reachability monitoring for %s", st.Name) + return case <-tk.C: - var wg sync.WaitGroup - res := make(map[string]bool, len(hosts)) - mu := sync.Mutex{} - for _, h := range hosts { - wg.Add(1) - go func(host string) { - defer wg.Done() - st.mu.Lock() - st.TotalICMPReq++ - // Increase meter value when a packet is sent - st.MeterValue++ - if st.MeterValue > m.MaxMeterValue { - st.MeterValue = m.MaxMeterValue - } - st.mu.Unlock() - - ok := m.pingOnce(st.Name, host) - mu.Lock() - res[host] = ok - mu.Unlock() - - st.mu.Lock() - if ok { - st.TotalICMPRep++ - // Decrease meter value when a packet is successfully received - st.MeterValue-- - if st.MeterValue < 0 { - st.MeterValue = 0 - } - // Only update spinner when packets are successfully received - st.Spin() - } else { - st.DroppedCount++ - st.LastDrop = time.Now() - - // Track lost packets per host - st.LostPackets[host]++ - - // Trigger UI update on ping failure - select { - case UIUpdateChan <- struct{}{}: - default: - } - } - st.mu.Unlock() - }(h) - } - wg.Wait() - - // Check if reachability status changed - statusChanged := false - st.mu.Lock() - for host, newStatus := range res { - if oldStatus, ok := st.Reachable[host]; !ok || oldStatus != newStatus { - statusChanged = true - break - } - } - - st.Reachable = res - st.LastPing = time.Now() - - // Check if all hosts are reachable - allReachable := true - for _, ok := range res { - if !ok { - allReachable = false - break - } - } - - // If all hosts are reachable, gradually decay the meter value - if allReachable && st.MeterValue > 0 { - st.MeterValue-- - } - - st.mu.Unlock() - - // Always trigger UI update when reachability status changes - if statusChanged { - select { - case UIUpdateChan <- struct{}{}: - default: - } - } + m.reachTick(ctx, st, hosts) } } } -// lossLoop monitors packet loss for hosts -func (m *Monitor) lossLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { - m.logf("Starting packet loss monitoring for %s with %d hosts", st.Name, len(hosts)) +// reachTick pings every host concurrently and applies the results. +func (m *Monitor) reachTick(ctx context.Context, st *InterfaceStatus, hosts []string) { + res := probeAll(hosts, func(host string) bool { + return m.reachProbe(ctx, st, host) + }) + m.reachApply(st, res) +} - // Add random offset to avoid clustering at periodic intervals - randomOffset := time.Duration(100+rand.Intn(800)) * time.Millisecond - m.logf("Packet loss monitoring for %s will start after %v offset", st.Name, randomOffset) - time.Sleep(randomOffset) +// reachProbe pings a single host and updates per-host counters. +func (m *Monitor) reachProbe( + ctx context.Context, st *InterfaceStatus, host string, +) bool { + st.mu.Lock() + st.TotalICMPReq++ + st.MeterValue = min(st.MeterValue+1, m.MaxMeterValue) + st.mu.Unlock() + + ok := m.pingOnce(ctx, st.Name, host) + + st.mu.Lock() + defer st.mu.Unlock() + + if ok { + st.TotalICMPRep++ + st.MeterValue = max(st.MeterValue-1, 0) + st.Spin() + + return true + } + + st.DroppedCount++ + st.LastDrop = time.Now() + st.LostPackets[host]++ + + m.notifyUI() + + return false +} + +// reachApply stores the round's results and decays the meter when clean. +func (m *Monitor) reachApply(st *InterfaceStatus, res map[string]bool) { + st.mu.Lock() + changed := reachChanged(st.Reachable, res) + st.Reachable = res + st.LastPing = time.Now() + + if allReachable(res) && st.MeterValue > 0 { + st.MeterValue-- + } + st.mu.Unlock() + + if changed { + m.notifyUI() + } +} + +// reachChanged reports whether any host's reachability differs from before. +func reachChanged(old, cur map[string]bool) bool { + for host, newStatus := range cur { + if oldStatus, ok := old[host]; !ok || oldStatus != newStatus { + return true + } + } + + return false +} + +// allReachable reports whether every host in the map is reachable. +func allReachable(res map[string]bool) bool { + for _, ok := range res { + if !ok { + return false + } + } + + return true +} + +// lossLoop monitors packet loss for hosts on one interface. +func (m *Monitor) lossLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { + m.logf("Starting packet loss monitoring for %s with %d hosts", + st.Name, len(hosts)) + time.Sleep(randomOffset()) tk := time.NewTicker(m.PacketLossPeriod) defer tk.Stop() + for { select { case <-ctx.Done(): m.logf("Stopping packet loss monitoring for %s", st.Name) + return case <-tk.C: - var wg sync.WaitGroup - res := make(map[string]float64, len(hosts)) - mu := sync.Mutex{} - for _, h := range hosts { - wg.Add(1) - go func(host string) { - defer wg.Done() - lp := m.lossPercent(st.Name, host) - mu.Lock() - res[host] = lp - mu.Unlock() - - st.mu.Lock() - if lp == 0 { - // Only update spinner when there's 0% packet loss - st.Spin() - } else { - // Calculate approximate number of lost packets based on loss percentage - lostPackets := int(math.Ceil(float64(m.PacketLossPings) * lp)) - - // Update dropped count with the number of lost packets - st.DroppedCount += lostPackets - - // Update last drop time if packets were lost - if lostPackets > 0 { - st.LastDrop = time.Now() - } - - // Track lost packets per host - st.LostPackets[host] += lostPackets - - // Trigger UI update on packet loss - select { - case UIUpdateChan <- struct{}{}: - default: - } - } - st.mu.Unlock() - }(h) - } - wg.Wait() - - // Check if loss status changed - statusChanged := false - st.mu.Lock() - for host, newLoss := range res { - if oldLoss, ok := st.Loss[host]; !ok || math.Abs(oldLoss-newLoss) > 0.01 { - statusChanged = true - break - } - } - - for k, v := range res { - st.Loss[k] = v - } - st.mu.Unlock() - - // Always trigger UI update when loss status changes - if statusChanged { - select { - case UIUpdateChan <- struct{}{}: - default: - } - } + m.lossTick(ctx, st, hosts) } } } -// tcpLoop monitors TCP connectivity for hosts +// lossTick measures loss for every host concurrently and applies results. +func (m *Monitor) lossTick(ctx context.Context, st *InterfaceStatus, hosts []string) { + res := probeAll(hosts, func(host string) float64 { + return m.lossProbe(ctx, st, host) + }) + m.lossApply(st, res) +} + +// lossProbe measures loss for a host and updates per-host counters. +func (m *Monitor) lossProbe( + ctx context.Context, st *InterfaceStatus, host string, +) float64 { + lp := m.lossPercent(ctx, st.Name, host) + + st.mu.Lock() + defer st.mu.Unlock() + + if lp == 0 { + st.Spin() + + return lp + } + + lostPackets := int(math.Ceil(float64(m.PacketLossPings) * lp)) + st.DroppedCount += lostPackets + + if lostPackets > 0 { + st.LastDrop = time.Now() + } + + st.LostPackets[host] += lostPackets + + m.notifyUI() + + return lp +} + +// lossApply stores the round's loss results. +func (m *Monitor) lossApply(st *InterfaceStatus, res map[string]float64) { + st.mu.Lock() + changed := lossChanged(st.Loss, res) + + for k, v := range res { + st.Loss[k] = v + } + st.mu.Unlock() + + if changed { + m.notifyUI() + } +} + +// lossChanged reports whether any host's loss moved beyond the threshold. +func lossChanged(old, cur map[string]float64) bool { + for host, newLoss := range cur { + if oldLoss, ok := old[host]; !ok || math.Abs(oldLoss-newLoss) > lossChangeThreshold { + return true + } + } + + return false +} + +// tcpLoop monitors TCP connectivity for hosts on one interface. func (m *Monitor) tcpLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { m.logf("Starting TCP monitoring for %s with %d hosts", st.Name, len(hosts)) - - // Add random offset to avoid clustering at 1-second intervals - randomOffset := time.Duration(100+rand.Intn(800)) * time.Millisecond - m.logf("TCP monitoring for %s will start after %v offset", st.Name, randomOffset) - time.Sleep(randomOffset) + time.Sleep(randomOffset()) tk := time.NewTicker(time.Second) defer tk.Stop() + for { select { case <-ctx.Done(): m.logf("Stopping TCP monitoring for %s", st.Name) + return case <-tk.C: - statusChanged := false - - for _, hp := range hosts { - ms := float64(m.tcpDuration(st.Name, hp).Milliseconds()) - st.mu.Lock() - - // Check if TCP latency significantly changed - hist := st.TCP[hp] - if len(hist) > 0 { - lastMs := hist[len(hist)-1] - if math.Abs(lastMs-ms) > 20 { // 20ms threshold for significant change - statusChanged = true - } - } else { - // First measurement - statusChanged = true - } - - if ms < float64(m.TCPTimeout.Milliseconds()) { - // Only update spinner on successful TCP connections - st.Spin() - } else { - // Trigger UI update on TCP timeout - select { - case UIUpdateChan <- struct{}{}: - default: - } - } - - if len(hist) >= m.StatsHistory { - hist = hist[1:] - } - st.TCP[hp] = append(hist, ms) - - // Update lost packets for the host (without port) - hostName := strings.Split(hp, ":")[0] - if ms >= float64(m.TCPTimeout.Milliseconds()) { - st.LostPackets[hostName]++ - } - - st.mu.Unlock() - } - - // Always trigger UI update when TCP status changes significantly - if statusChanged { - select { - case UIUpdateChan <- struct{}{}: - default: - } - } + m.tcpTick(st, hosts) } } } + +// tcpTick measures TCP latency for each host and redraws on change. +func (m *Monitor) tcpTick(st *InterfaceStatus, hosts []string) { + changed := false + + for _, hp := range hosts { + if m.tcpProbe(st, hp) { + changed = true + } + } + + if changed { + m.notifyUI() + } +} + +// tcpProbe measures one host's latency, records it, and reports whether the +// latency changed significantly. +func (m *Monitor) tcpProbe(st *InterfaceStatus, hp string) bool { + ms := float64(m.tcpDuration(st.Name, hp).Milliseconds()) + + st.mu.Lock() + defer st.mu.Unlock() + + hist := st.TCP[hp] + changed := tcpSignificant(hist, ms) + + if ms < float64(m.TCPTimeout.Milliseconds()) { + st.Spin() + } else { + m.notifyUI() + } + + if len(hist) >= m.StatsHistory { + hist = hist[1:] + } + + st.TCP[hp] = append(hist, ms) + + host := strings.Split(hp, ":")[0] + if ms >= float64(m.TCPTimeout.Milliseconds()) { + st.LostPackets[host]++ + } + + return changed +} + +// tcpSignificant reports whether ms differs meaningfully from the last +// sample (or there is no prior sample). +func tcpSignificant(hist []float64, ms float64) bool { + if len(hist) == 0 { + return true + } + + return math.Abs(hist[len(hist)-1]-ms) > tcpChangeThreshold +} diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index 2f97fff..0d85de8 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -1,11 +1,14 @@ //go:build linux -// +build linux +// Package monitor implements the dual-interface real-time network +// monitoring dashboard: ICMP reachability, packet loss, TCP latency, and +// the terminal UI that renders them. package monitor import ( "context" "encoding/json" + "errors" "fmt" "math" "net" @@ -19,16 +22,48 @@ import ( tcell "github.com/gdamore/tcell/v2" ) -// Non-configurable constants (meter characters) +// Meter glyphs used to render the ASCII loss meter. const ( - // ASCII characters for the meter MeterStart = '[' MeterEnd = ']' MeterFill = '=' MeterEmpty = ' ' ) -// Monitor represents the network monitoring system +// Default monitor timing configuration. +const ( + defaultICMPTimeout = 500 * time.Millisecond + defaultTCPTimeout = 500 * time.Millisecond + defaultPacketLossPings = 20 + defaultPacketLossPeriod = 5 * time.Second + defaultStatsHistory = 300 + defaultScreenRefresh = 500 * time.Millisecond +) + +// Default display geometry (column widths and meter sizing). +const ( + defaultMeterWidth = 7 + defaultMeterFillWidth = 5 + defaultMaxMeterValue = 10 + defaultHostWidth = 30 + defaultNumWidth = 7 + defaultStdWidth = 8 + defaultNWidth = 6 + defaultLostWidth = 7 +) + +// Miscellaneous timing constants. +const ( + ipInfoTimeout = 2 * time.Second + lossQueryTimeout = 3 * time.Second + uiUpdateBuffer = 100 + logFileMode = 0o644 +) + +// errNoIPv4 is returned when an interface has no usable IPv4 address. +var errNoIPv4 = errors.New("no IPv4 address on interface") + +// Monitor represents the network monitoring system. type Monitor struct { // Configuration ICMPTimeout time.Duration @@ -63,95 +98,95 @@ type Monitor struct { // Runtime state screen tcell.Screen startTime time.Time + uiUpdate chan struct{} // Synchronization mu sync.RWMutex } -// NewMonitor creates a new Monitor instance with default settings +// NewMonitor creates a new Monitor instance with default settings. func NewMonitor(ifaceA, labelA, ifaceB, labelB, logFile string) *Monitor { return &Monitor{ - // Default timing configuration - ICMPTimeout: 500 * time.Millisecond, - TCPTimeout: 500 * time.Millisecond, - PacketLossPings: 20, - PacketLossPeriod: 5 * time.Second, - StatsHistory: 300, - ScreenRefresh: 500 * time.Millisecond, + ICMPTimeout: defaultICMPTimeout, + TCPTimeout: defaultTCPTimeout, + PacketLossPings: defaultPacketLossPings, + PacketLossPeriod: defaultPacketLossPeriod, + StatsHistory: defaultStatsHistory, + ScreenRefresh: defaultScreenRefresh, - // Default display configuration - MeterWidth: 7, - MeterFillWidth: 5, - MaxMeterValue: 10, - HostWidth: 30, - NumWidth: 7, - StdWidth: 8, - NWidth: 6, - LostWidth: 7, + MeterWidth: defaultMeterWidth, + MeterFillWidth: defaultMeterFillWidth, + MaxMeterValue: defaultMaxMeterValue, + HostWidth: defaultHostWidth, + NumWidth: defaultNumWidth, + StdWidth: defaultStdWidth, + NWidth: defaultNWidth, + LostWidth: defaultLostWidth, - // Initialize host lists reachabilityHosts: []string{}, packetLossHosts: []string{}, tcpHosts: []string{}, - // Initialize interfaces interfaceA: NewInterfaceStatus(ifaceA, labelA), interfaceB: NewInterfaceStatus(ifaceB, labelB), - // Logging - logFile: logFile, - + logFile: logFile, startTime: time.Now(), + uiUpdate: make(chan struct{}, uiUpdateBuffer), } } -// AddReachabilityHost adds a host for reachability monitoring +// AddReachabilityHost adds a host for reachability monitoring. func (m *Monitor) AddReachabilityHost(host string) { m.mu.Lock() defer m.mu.Unlock() + m.reachabilityHosts = append(m.reachabilityHosts, host) } -// AddPacketLossHost adds a host for packet loss monitoring +// AddPacketLossHost adds a host for packet loss monitoring. func (m *Monitor) AddPacketLossHost(host string) { m.mu.Lock() defer m.mu.Unlock() + m.packetLossHosts = append(m.packetLossHosts, host) } -// AddTCPHost adds a host:port for TCP connectivity monitoring +// AddTCPHost adds a host:port for TCP connectivity monitoring. func (m *Monitor) AddTCPHost(hostPort string) { m.mu.Lock() defer m.mu.Unlock() + m.tcpHosts = append(m.tcpHosts, hostPort) } -// Run starts the monitoring system +// Run starts the monitoring system. func (m *Monitor) Run(ctx context.Context) error { m.logf("Starting monitor run") - - // Initialize screen m.logf("Initializing screen") + scr, err := tcell.NewScreen() if err != nil { m.logf("Error creating screen: %v", err) + return fmt.Errorf("error creating screen: %w", err) } - if err = scr.Init(); err != nil { + + err = scr.Init() + if err != nil { m.logf("Error initializing screen: %v", err) + return fmt.Errorf("error initializing screen: %w", err) } + m.screen = scr m.logf("Screen initialized") - // Create cancellable context ctx, cancel := context.WithCancel(ctx) defer cancel() - // Handle keyboard events go m.keyboardEventLoop(ctx, cancel) - // Start monitoring goroutines m.logf("Starting monitoring goroutines") m.mu.RLock() reachHosts := append([]string{}, m.reachabilityHosts...) @@ -166,7 +201,6 @@ func (m *Monitor) Run(ctx context.Context) error { go m.tcpLoop(ctx, m.interfaceA, tcpHosts) go m.tcpLoop(ctx, m.interfaceB, tcpHosts) - // Run UI loop m.logf("Starting UI loop") m.uiLoop(ctx) m.logf("UI loop exited, monitor ending") @@ -174,36 +208,49 @@ func (m *Monitor) Run(ctx context.Context) error { return nil } -// keyboardEventLoop handles keyboard input -func (m *Monitor) keyboardEventLoop(ctx context.Context, cancel context.CancelFunc) { - m.logf("Starting keyboard event loop") - for { - if ev := m.screen.PollEvent(); ev != nil { - m.logf("Event received: %T", ev) - if ke, ok := ev.(*tcell.EventKey); ok { - m.logf("Key event: %v, rune: %c", ke.Key(), ke.Rune()) - if ke.Key() == tcell.KeyCtrlC || ke.Rune() == 'q' { - m.logf("Quit key detected") - cancel() - return - } - } - - // Trigger UI update on any event - select { - case UIUpdateChan <- struct{}{}: - default: - } - } +// notifyUI requests a screen redraw without blocking if one is pending. +func (m *Monitor) notifyUI() { + select { + case m.uiUpdate <- struct{}{}: + default: } } -// logf logs a formatted message -func (m *Monitor) logf(format string, v ...interface{}) { +// keyboardEventLoop handles keyboard input. The context is accepted for a +// uniform loop signature; shutdown is driven by cancel on the quit key and +// by the screen being finalized (PollEvent then returns nil). +func (m *Monitor) keyboardEventLoop(_ context.Context, cancel context.CancelFunc) { + m.logf("Starting keyboard event loop") + + for { + ev := m.screen.PollEvent() + if ev == nil { + continue + } + + m.logf("Event received: %T", ev) + + if ke, ok := ev.(*tcell.EventKey); ok { + m.logf("Key event: %v, rune: %c", ke.Key(), ke.Rune()) + + if ke.Key() == tcell.KeyCtrlC || ke.Rune() == 'q' { + m.logf("Quit key detected") + cancel() + + return + } + } + + m.notifyUI() + } +} + +// logf logs a formatted message. +func (m *Monitor) logf(format string, v ...any) { Logf(m.logFile, format, v...) } -// InterfaceStatus holds the runtime status for a network interface +// InterfaceStatus holds the runtime status for a network interface. type InterfaceStatus struct { Name, Label, IPInfo string Reachable map[string]bool @@ -218,7 +265,7 @@ type InterfaceStatus struct { mu sync.RWMutex } -// NewInterfaceStatus creates a new interface status +// NewInterfaceStatus creates a new interface status. func NewInterfaceStatus(name, label string) *InterfaceStatus { return &InterfaceStatus{ Name: name, @@ -232,137 +279,164 @@ func NewInterfaceStatus(name, label string) *InterfaceStatus { } } -// ipInfoResp holds the IP info response +// ipInfoResp holds the ipinfo.io response. type ipInfoResp struct { IP string `json:"ip"` Hostname string `json:"hostname"` Org string `json:"org"` } -// fetchIPInfo fetches IP information for an interface +// fetchIPInfo fetches public IP information as seen from an interface. func fetchIPInfo(iface string) string { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), ipInfoTimeout) defer cancel() - out, _ := exec.CommandContext(ctx, "curl", "-s", "--interface", iface, "--max-time", "2", "ipinfo.io").Output() + + cmd := exec.CommandContext( //nolint:gosec // G204: fixed argv, operator CLI input + ctx, "curl", "-s", "--interface", iface, "--max-time", "2", "ipinfo.io") + + out, _ := cmd.Output() + var r ipInfoResp + _ = json.Unmarshal(out, &r) if r.IP == "" { return "(ipinfo error)" } + return fmt.Sprintf("%s [%s] %s", r.IP, r.Hostname, r.Org) } -// pingOnce performs a single ping -func (m *Monitor) pingOnce(iface, host string) bool { - ctx, cancel := context.WithTimeout(context.Background(), m.ICMPTimeout) +// pingOnce performs a single ping over the named interface. +func (m *Monitor) pingOnce(ctx context.Context, iface, host string) bool { + ctx, cancel := context.WithTimeout(ctx, m.ICMPTimeout) defer cancel() - return exec.CommandContext(ctx, "ping", "-I", iface, "-c1", "-W1", host).Run() == nil + + cmd := exec.CommandContext( //nolint:gosec // G204: fixed argv, operator CLI input + ctx, "ping", "-I", iface, "-c1", "-W1", host) + + return cmd.Run() == nil } -// lossPercent calculates packet loss percentage -func (m *Monitor) lossPercent(iface, host string) float64 { - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) +// lossPercent measures the packet loss fraction (0..1) for a host. +func (m *Monitor) lossPercent(ctx context.Context, iface, host string) float64 { + ctx, cancel := context.WithTimeout(ctx, lossQueryTimeout) defer cancel() - out, err := exec.CommandContext(ctx, "ping", "-q", "-i", "0.05", - "-c", fmt.Sprint(m.PacketLossPings), "-W1", "-I", iface, host).CombinedOutput() + + cmd := exec.CommandContext( //nolint:gosec // G204: fixed argv, operator CLI input + ctx, "ping", "-q", "-i", "0.05", + "-c", strconv.Itoa(m.PacketLossPings), "-W1", "-I", iface, host) + + out, err := cmd.CombinedOutput() if err != nil { return 1.0 } + for _, ln := range strings.Split(string(out), "\n") { - if strings.Contains(ln, "packet loss") { - for _, f := range strings.Fields(ln) { - if strings.HasSuffix(f, "%") { - p, _ := strconv.ParseFloat(strings.TrimSuffix(f, "%"), 64) - return p / 100.0 - } + if !strings.Contains(ln, "packet loss") { + continue + } + + for _, f := range strings.Fields(ln) { + before, ok := strings.CutSuffix(f, "%") + if !ok { + continue } + + p, _ := strconv.ParseFloat(before, 64) + + return p / percentFull } } + return 1.0 } -// localAddr gets the local address for an interface +// localAddr returns the first IPv4 address bound to an interface. func localAddr(iface string) (net.Addr, error) { ifi, err := net.InterfaceByName(iface) if err != nil { - return nil, err + return nil, fmt.Errorf("interface %s: %w", iface, err) } + add, _ := ifi.Addrs() for _, a := range add { if ipnet, ok := a.(*net.IPNet); ok && ipnet.IP.To4() != nil { return &net.TCPAddr{IP: ipnet.IP}, nil } } - return nil, fmt.Errorf("no IPv4 on %s", iface) + + return nil, fmt.Errorf("%w: %s", errNoIPv4, iface) } -// tcpDuration measures TCP connection duration +// tcpDuration measures how long a TCP connection to hp takes over iface. func (m *Monitor) tcpDuration(iface, hp string) time.Duration { la, err := localAddr(iface) if err != nil { return m.TCPTimeout } + d := net.Dialer{Timeout: m.TCPTimeout, LocalAddr: la} st := time.Now() + c, err := d.Dial("tcp", hp) if err != nil { return m.TCPTimeout } - c.Close() + + _ = c.Close() + return time.Since(st) } -// MinMaxAvgStd calculates min, max, average and standard deviation -func MinMaxAvgStd(xs []float64) (min, max, avg, std float64) { +// MinMaxAvgStd returns the minimum, maximum, mean and standard deviation. +func MinMaxAvgStd(xs []float64) (float64, float64, float64, float64) { if len(xs) == 0 { - return + return 0, 0, 0, 0 } - min, max = xs[0], xs[0] + + mn, mx := xs[0], xs[0] + var sum float64 + for _, v := range xs { - if v < min { - min = v - } - if v > max { - max = v - } + mn = min(mn, v) + mx = max(mx, v) sum += v } - avg = sum / float64(len(xs)) - var vs float64 + + avg := sum / float64(len(xs)) + + var variance float64 + for _, v := range xs { - d := v - avg - vs += d * d + variance += (v - avg) * (v - avg) } - std = math.Sqrt(vs / float64(len(xs))) - return + + return mn, mx, avg, math.Sqrt(variance / float64(len(xs))) } -// Spin advances the spinner frame +// Spin advances the spinner frame. func (st *InterfaceStatus) Spin() { - st.SpinFrame = (st.SpinFrame + 1) % len(Spins) + st.SpinFrame = (st.SpinFrame + 1) % len(spins()) } -// IsHealthy checks if the interface is healthy +// IsHealthy reports whether the interface currently looks healthy. func (st *InterfaceStatus) IsHealthy(tcpTimeout time.Duration) bool { st.mu.RLock() defer st.mu.RUnlock() - // Check if there are any currently unreachable hosts for _, ok := range st.Reachable { if !ok { return false } } - // Check if there's any current packet loss for _, lp := range st.Loss { if lp > 0 { return false } } - // Check if there are any TCP timeouts for _, hist := range st.TCP { if len(hist) == 0 || hist[len(hist)-1] >= float64(tcpTimeout.Milliseconds()) { return false @@ -372,23 +446,29 @@ func (st *InterfaceStatus) IsHealthy(tcpTimeout time.Duration) bool { return true } -// Spinners and braille characters -var ( - Spins = []rune{'|', '/', '-', '\\'} - BrailleSpins = []rune{ - '⠉', '⠘', '⠰', '⠠', '⠄', '⠆', '⠇', '⠋', - } -) +// spins returns the ASCII spinner frames. +func spins() []rune { + return []rune{'|', '/', '-', '\\'} +} -// Logf is a simple logging function -func Logf(logFile string, format string, v ...interface{}) { +// brailleSpins returns the braille clock spinner frames. +func brailleSpins() []rune { + return []rune{'⠉', '⠘', '⠰', '⠠', '⠄', '⠆', '⠇', '⠋'} +} + +// Logf appends a timestamped formatted message to logFile, if set. +func Logf(logFile, format string, v ...any) { if logFile == "" { return } - f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + + f, err := os.OpenFile( //nolint:gosec // G304: operator --logfile path + logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, logFileMode) if err != nil { return } - defer f.Close() - fmt.Fprintf(f, time.Now().Format("2006-01-02 15:04:05.000 ")+format+"\n", v...) + defer func() { _ = f.Close() }() + + _, _ = fmt.Fprintf(f, + time.Now().Format("2006-01-02 15:04:05.000 ")+format+"\n", v...) } diff --git a/internal/monitor/monitor_test.go b/internal/monitor/monitor_test.go index 0c6cc8f..a0e92c7 100644 --- a/internal/monitor/monitor_test.go +++ b/internal/monitor/monitor_test.go @@ -1,125 +1,110 @@ //go:build linux -// +build linux -package monitor +package monitor_test import ( "testing" + + "git.eeqj.de/sneak/rtnetmon/internal/monitor" ) -// TestNewMonitor tests the creation of a new Monitor func TestNewMonitor(t *testing.T) { - // Test that we can create a monitor without errors - mon := NewMonitor("test0", "Test Interface A", "test1", "Test Interface B", "/tmp/test.log") + t.Parallel() - if mon == nil { - t.Fatal("NewMonitor returned nil") - } + mon := monitor.NewMonitor("test0", "Test Interface A", "test1", + "Test Interface B", "/tmp/test.log") - // Check interfaces - if mon.interfaceA == nil { + if mon.InterfaceA() == nil { t.Fatal("interfaceA is nil") } - if mon.interfaceB == nil { + if mon.InterfaceB() == nil { t.Fatal("interfaceB is nil") } - if mon.interfaceA.Name != "test0" { - t.Errorf("Expected interfaceA.Name to be 'test0', got '%s'", mon.interfaceA.Name) + if mon.InterfaceA().Name != "test0" { + t.Errorf("interfaceA.Name = %q, want %q", mon.InterfaceA().Name, "test0") } - if mon.interfaceB.Name != "test1" { - t.Errorf("Expected interfaceB.Name to be 'test1', got '%s'", mon.interfaceB.Name) + if mon.InterfaceB().Name != "test1" { + t.Errorf("interfaceB.Name = %q, want %q", mon.InterfaceB().Name, "test1") } - // Check default configuration if mon.ICMPTimeout.Milliseconds() != 500 { - t.Errorf("Expected ICMPTimeout to be 500ms, got %dms", mon.ICMPTimeout.Milliseconds()) + t.Errorf("ICMPTimeout = %dms, want 500ms", mon.ICMPTimeout.Milliseconds()) } if mon.PacketLossPings != 20 { - t.Errorf("Expected PacketLossPings to be 20, got %d", mon.PacketLossPings) + t.Errorf("PacketLossPings = %d, want 20", mon.PacketLossPings) } - // Check that host lists are initialized - if mon.reachabilityHosts == nil { + if mon.ReachabilityHosts() == nil { t.Error("reachabilityHosts is nil") } - if mon.packetLossHosts == nil { + if mon.PacketLossHosts() == nil { t.Error("packetLossHosts is nil") } - if mon.tcpHosts == nil { + if mon.TCPHosts() == nil { t.Error("tcpHosts is nil") } } -// TestAddHosts tests adding hosts to the monitor func TestAddHosts(t *testing.T) { - mon := NewMonitor("test0", "Test A", "test1", "Test B", "") + t.Parallel() + + mon := monitor.NewMonitor("test0", "Test A", "test1", "Test B", "") - // Test adding reachability hosts mon.AddReachabilityHost("8.8.8.8") mon.AddReachabilityHost("google.com") - if len(mon.reachabilityHosts) != 2 { - t.Errorf("Expected 2 reachability hosts, got %d", len(mon.reachabilityHosts)) + if len(mon.ReachabilityHosts()) != 2 { + t.Errorf("reachability hosts = %d, want 2", len(mon.ReachabilityHosts())) } - // Test adding packet loss hosts mon.AddPacketLossHost("github.com") - if len(mon.packetLossHosts) != 1 { - t.Errorf("Expected 1 packet loss host, got %d", len(mon.packetLossHosts)) + if len(mon.PacketLossHosts()) != 1 { + t.Errorf("packet loss hosts = %d, want 1", len(mon.PacketLossHosts())) } - // Test adding TCP hosts mon.AddTCPHost("google.com:443") mon.AddTCPHost("github.com:443") - if len(mon.tcpHosts) != 2 { - t.Errorf("Expected 2 TCP hosts, got %d", len(mon.tcpHosts)) + if len(mon.TCPHosts()) != 2 { + t.Errorf("tcp hosts = %d, want 2", len(mon.TCPHosts())) } } -// TestMinMaxAvgStd tests the statistics calculation function func TestMinMaxAvgStd(t *testing.T) { + t.Parallel() + tests := []struct { name string data []float64 wantMin, wantMax, wantAvg float64 }{ - { - name: "empty slice", - data: []float64{}, - wantMin: 0, wantMax: 0, wantAvg: 0, - }, - { - name: "single value", - data: []float64{5.0}, - wantMin: 5.0, wantMax: 5.0, wantAvg: 5.0, - }, - { - name: "multiple values", - data: []float64{1.0, 2.0, 3.0, 4.0, 5.0}, - wantMin: 1.0, wantMax: 5.0, wantAvg: 3.0, - }, + {"empty slice", []float64{}, 0, 0, 0}, + {"single value", []float64{5.0}, 5.0, 5.0, 5.0}, + {"multiple values", []float64{1.0, 2.0, 3.0, 4.0, 5.0}, 1.0, 5.0, 3.0}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - min, max, avg, _ := MinMaxAvgStd(tt.data) + t.Parallel() - if min != tt.wantMin { - t.Errorf("MinMaxAvgStd() min = %v, want %v", min, tt.wantMin) + mn, mx, avg, _ := monitor.MinMaxAvgStd(tt.data) + if mn != tt.wantMin { + t.Errorf("min = %v, want %v", mn, tt.wantMin) } - if max != tt.wantMax { - t.Errorf("MinMaxAvgStd() max = %v, want %v", max, tt.wantMax) + + if mx != tt.wantMax { + t.Errorf("max = %v, want %v", mx, tt.wantMax) } + if avg != tt.wantAvg { - t.Errorf("MinMaxAvgStd() avg = %v, want %v", avg, tt.wantAvg) + t.Errorf("avg = %v, want %v", avg, tt.wantAvg) } }) } diff --git a/internal/monitor/styles.go b/internal/monitor/styles.go index 6417cf9..423db33 100644 --- a/internal/monitor/styles.go +++ b/internal/monitor/styles.go @@ -1,23 +1,62 @@ //go:build linux -// +build linux package monitor import tcell "github.com/gdamore/tcell/v2" -// Color styles -var ( - CBrightGreen = tcell.StyleDefault.Foreground(tcell.ColorGreen).Bold(true) - CGreen = tcell.StyleDefault.Foreground(tcell.ColorGreen) - CDimGreen = tcell.StyleDefault.Foreground(tcell.ColorGreen).Dim(true) - CYellow = tcell.StyleDefault.Foreground(tcell.ColorYellow) - COrange = tcell.StyleDefault.Foreground(tcell.ColorOrange) - CRed = tcell.StyleDefault.Foreground(tcell.ColorRed) - CBrightRed = tcell.StyleDefault.Foreground(tcell.ColorRed).Bold(true) - CDefault = tcell.StyleDefault +// percentFull is the top of the 0..100 percentage scale. +const percentFull = 100.0 - // Rainbow colors for the spinner - CRainbow = []tcell.Style{ +// Meter fill thresholds, expressed as percent-good (100 = best, 0 = worst). +const ( + meterExcellent = 90 + meterGood = 75 + meterFair = 60 + meterMediocre = 40 + meterPoor = 20 +) + +// TCP latency thresholds, in milliseconds. +const ( + latencyGood = 50 + latencyModerate = 100 + latencyHigh = 200 +) + +// lossWarnPercent is the packet-loss level above which the display warns. +const lossWarnPercent = 5 + +func styleBrightGreen() tcell.Style { + return tcell.StyleDefault.Foreground(tcell.ColorGreen).Bold(true) +} + +func styleGreen() tcell.Style { + return tcell.StyleDefault.Foreground(tcell.ColorGreen) +} + +func styleDimGreen() tcell.Style { + return tcell.StyleDefault.Foreground(tcell.ColorGreen).Dim(true) +} + +func styleYellow() tcell.Style { + return tcell.StyleDefault.Foreground(tcell.ColorYellow) +} + +func styleOrange() tcell.Style { + return tcell.StyleDefault.Foreground(tcell.ColorOrange) +} + +func styleRed() tcell.Style { + return tcell.StyleDefault.Foreground(tcell.ColorRed) +} + +func styleBrightRed() tcell.Style { + return tcell.StyleDefault.Foreground(tcell.ColorRed).Bold(true) +} + +// rainbow returns the color cycle used for spinners and the clock. +func rainbow() []tcell.Style { + return []tcell.Style{ tcell.StyleDefault.Foreground(tcell.ColorRed), tcell.StyleDefault.Foreground(tcell.ColorOrange), tcell.StyleDefault.Foreground(tcell.ColorYellow), @@ -25,63 +64,55 @@ var ( tcell.StyleDefault.Foreground(tcell.ColorBlue), tcell.StyleDefault.Foreground(tcell.ColorPurple), } -) +} -// MeterColorForValue returns the appropriate color style for a meter value -// value: current value of the meter -// maxValue: maximum value of the meter -// reverse: if true, low values are good (green) and high values are bad (red) -// -// if false, high values are good (green) and low values are bad (red) +// MeterColorForValue returns the color style for a meter value. When reverse +// is true, low values are good (green) and high values bad (red); otherwise +// high values are good. func MeterColorForValue(value, maxValue int, reverse bool) tcell.Style { - // Calculate percentage - percentage := float64(value) / float64(maxValue) * 100 - - // For reverse mode (low is good), invert the percentage + percentage := float64(value) / float64(maxValue) * percentFull if reverse { - percentage = 100 - percentage + percentage = percentFull - percentage } - // Return color based on percentage (after any reversal) - // Now high percentage always means "good" and low percentage means "bad" switch { - case percentage >= 90: - return CBrightGreen - case percentage >= 75: - return CGreen - case percentage >= 60: - return CDimGreen - case percentage >= 40: - return CYellow - case percentage >= 20: - return COrange + case percentage >= meterExcellent: + return styleBrightGreen() + case percentage >= meterGood: + return styleGreen() + case percentage >= meterFair: + return styleDimGreen() + case percentage >= meterMediocre: + return styleYellow() + case percentage >= meterPoor: + return styleOrange() default: - return CRed + return styleRed() } } -// StyleLatency returns the appropriate style for latency values +// StyleLatency returns the style for a TCP latency in milliseconds. func StyleLatency(ms float64) tcell.Style { switch { - case ms < 50: - return CBrightGreen - case ms < 100: - return CGreen - case ms < 200: - return CYellow + case ms < latencyGood: + return styleBrightGreen() + case ms < latencyModerate: + return styleGreen() + case ms < latencyHigh: + return styleYellow() default: - return CRed + return styleRed() } } -// StyleLoss returns the appropriate style for packet loss percentages +// StyleLoss returns the style for a packet-loss percentage. func StyleLoss(p float64) tcell.Style { switch { case p == 0: - return CBrightGreen - case p < 5: - return CYellow + return styleBrightGreen() + case p < lossWarnPercent: + return styleYellow() default: - return CBrightRed + return styleBrightRed() } } diff --git a/internal/monitor/styles_test.go b/internal/monitor/styles_test.go index 325082a..a3da137 100644 --- a/internal/monitor/styles_test.go +++ b/internal/monitor/styles_test.go @@ -1,104 +1,48 @@ //go:build linux -// +build linux -package monitor +package monitor_test import ( "testing" tcell "github.com/gdamore/tcell/v2" + + "git.eeqj.de/sneak/rtnetmon/internal/monitor" ) -// TestMeterColorForValue tests the MeterColorForValue function func TestMeterColorForValue(t *testing.T) { - tests := []struct { - name string - value int - maxValue int - reverse bool - wantColor tcell.Style - }{ - // Reverse mode tests (low is good, high is bad) - { - name: "reverse mode: 0% (best)", - value: 0, - maxValue: 10, - reverse: true, - wantColor: CBrightGreen, - }, - { - name: "reverse mode: 10% (good)", - value: 1, - maxValue: 10, - reverse: true, - wantColor: CBrightGreen, - }, - { - name: "reverse mode: 50% (medium)", - value: 5, - maxValue: 10, - reverse: true, - wantColor: CYellow, - }, - { - name: "reverse mode: 90% (bad)", - value: 9, - maxValue: 10, - reverse: true, - wantColor: CRed, - }, - { - name: "reverse mode: 100% (worst)", - value: 10, - maxValue: 10, - reverse: true, - wantColor: CRed, - }, + t.Parallel() - // Normal mode tests (high is good, low is bad) - { - name: "normal mode: 0% (worst)", - value: 0, - maxValue: 10, - reverse: false, - wantColor: CRed, - }, - { - name: "normal mode: 10% (bad)", - value: 1, - maxValue: 10, - reverse: false, - wantColor: CRed, - }, - { - name: "normal mode: 50% (medium)", - value: 5, - maxValue: 10, - reverse: false, - wantColor: CYellow, - }, - { - name: "normal mode: 90% (good)", - value: 9, - maxValue: 10, - reverse: false, - wantColor: CBrightGreen, - }, - { - name: "normal mode: 100% (best)", - value: 10, - maxValue: 10, - reverse: false, - wantColor: CBrightGreen, - }, + green := tcell.StyleDefault.Foreground(tcell.ColorGreen).Bold(true) + yellow := tcell.StyleDefault.Foreground(tcell.ColorYellow) + red := tcell.StyleDefault.Foreground(tcell.ColorRed) + + tests := []struct { + name string + value, maxValue int + reverse bool + want tcell.Style + }{ + {"reverse 0% best", 0, 10, true, green}, + {"reverse 10% good", 1, 10, true, green}, + {"reverse 50% medium", 5, 10, true, yellow}, + {"reverse 90% bad", 9, 10, true, red}, + {"reverse 100% worst", 10, 10, true, red}, + {"normal 0% worst", 0, 10, false, red}, + {"normal 10% bad", 1, 10, false, red}, + {"normal 50% medium", 5, 10, false, yellow}, + {"normal 90% good", 9, 10, false, green}, + {"normal 100% best", 10, 10, false, green}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := MeterColorForValue(tt.value, tt.maxValue, tt.reverse) - if got != tt.wantColor { + t.Parallel() + + got := monitor.MeterColorForValue(tt.value, tt.maxValue, tt.reverse) + if got != tt.want { t.Errorf("MeterColorForValue(%d, %d, %v) = %v, want %v", - tt.value, tt.maxValue, tt.reverse, got, tt.wantColor) + tt.value, tt.maxValue, tt.reverse, got, tt.want) } }) } diff --git a/internal/monitor/ui.go b/internal/monitor/ui.go index 2734f8b..660160a 100644 --- a/internal/monitor/ui.go +++ b/internal/monitor/ui.go @@ -1,5 +1,4 @@ //go:build linux -// +build linux package monitor @@ -13,283 +12,331 @@ import ( tcell "github.com/gdamore/tcell/v2" ) -// Frame counter for the timestamp spinner (ticks once per second) -var ( - timestampSpinFrame = 0 - lastTimestampSpinUpdate = time.Now() +// Fixed rows at the top of the display. +const ( + rowTopRule = 0 + rowClock = 1 + rowBottomRule = 2 + rowRuntime = 3 + rowFirstIface = 5 ) -// Put writes text to the screen at the specified position with style +// Column offsets and spacing within the drawn output. +const ( + colLeft = 0 + colSpinner = 3 // after the "== " prefix + colMeter = 5 // after the spinner + colGap = 1 // single-space gap between fields + clockGap = 5 // space between the two clocks + lineStep = 1 + blockGap = 2 // blank line plus the following line + headerAdvance = 4 // rule + content + rule + blank line +) + +// ICMP summary table geometry. +const ( + icmpLabelWidth = 8 + icmpValWidth = 9 + icmpColGap = 4 +) + +// Put writes text to the screen at the specified position with style. func Put(scr tcell.Screen, x, y int, txt string, st tcell.Style) { for i, r := range txt { scr.SetContent(x+i, y, r, nil, st) } } -// HLine creates a horizontal line of the specified width +// HLine returns a horizontal line of the specified width. func HLine(w int) string { return strings.Repeat("=", w) } -// DrawRainbowText draws text with rainbow colors +// DrawRainbowText draws text cycling through the given color styles. func DrawRainbowText(scr tcell.Screen, x, y int, text string, colors []tcell.Style) { for i, char := range text { - // Cycle through the rainbow colors - colorIndex := i % len(colors) - style := colors[colorIndex] - // Draw the character with the current rainbow color + style := colors[i%len(colors)] scr.SetContent(x+i, y, char, nil, style) } } -// CreateMeter creates a visual meter using ASCII characters +// msStr formats a millisecond value as a compact "%.0fms" string. +func msStr(v float64) string { + return fmt.Sprintf("%.0fms", v) +} + +// CreateMeter creates a visual meter using ASCII characters. func (m *Monitor) CreateMeter(value int) (string, tcell.Style) { - if value > m.MaxMeterValue { - value = m.MaxMeterValue - } - if value < 0 { - value = 0 - } + value = max(min(value, m.MaxMeterValue), 0) - // Calculate the number of fill characters to show - fillCount := value * m.MeterFillWidth / m.MaxMeterValue - if fillCount > m.MeterFillWidth { - fillCount = m.MeterFillWidth - } + fillCount := min(value*m.MeterFillWidth/m.MaxMeterValue, m.MeterFillWidth) - // Build the meter string var b strings.Builder + b.WriteRune(MeterStart) - // Add fill characters - for i := 0; i < fillCount; i++ { + for range fillCount { b.WriteRune(MeterFill) } - // Add empty spaces - for i := 0; i < m.MeterFillWidth-fillCount; i++ { + for range m.MeterFillWidth - fillCount { b.WriteRune(MeterEmpty) } b.WriteRune(MeterEnd) - // Get the appropriate style for this meter value - // Using reverse=true because for packet loss, low values are good + // reverse=true: for packet loss, low values are good. style := MeterColorForValue(value, m.MaxMeterValue, true) return b.String(), style } -// DrawInterface draws the interface status on the screen +// DrawInterface draws the interface status on the screen, returning the next +// free row. func (m *Monitor) DrawInterface(scr tcell.Screen, y, w int, st *InterfaceStatus) int { - Put(scr, 0, y, HLine(w), CDefault) + Put(scr, colLeft, y, HLine(w), tcell.StyleDefault) - // header line healthy := st.IsHealthy(m.TCPTimeout) - style := CBrightGreen + y = m.drawHeader(scr, y, w, st, healthy) + + m.mu.RLock() + tcpHosts := append([]string{}, m.tcpHosts...) + m.mu.RUnlock() + + st.mu.RLock() + defer st.mu.RUnlock() + + y = drawReachability(scr, y, st) + y = drawPacketLoss(scr, y, st) + y = m.drawTCPTable(scr, y, st, tcpHosts) + y = drawICMPStats(scr, y, st) + + return y +} + +// drawHeader renders the interface title, spinner and meter line. +func (m *Monitor) drawHeader( + scr tcell.Screen, y, w int, st *InterfaceStatus, healthy bool, +) int { + style := styleBrightGreen() if !healthy { - style = CBrightRed + style = styleBrightRed() } + st.mu.RLock() header := fmt.Sprintf("%s: %s — %s", st.Name, st.Label, st.IPInfo) - spinChar := Spins[st.SpinFrame] + spinChar := spins()[st.SpinFrame] spinnerFrame := st.SpinFrame meterValue := st.MeterValue st.mu.RUnlock() - // Create the meter with appropriate style meter, meterStyle := m.CreateMeter(meterValue) + colors := rainbow() + spinnerStyle := colors[spinnerFrame%len(colors)] - // Get rainbow color for spinner (cycle through colors) - spinnerStyle := CRainbow[spinnerFrame%len(CRainbow)] + Put(scr, colLeft, y+lineStep, "== ", tcell.StyleDefault) + Put(scr, colSpinner, y+lineStep, string(spinChar)+" ", spinnerStyle) + Put(scr, colMeter, y+lineStep, meter+" ", meterStyle) + Put(scr, colMeter+m.MeterWidth+colGap, y+lineStep, header, style) + Put(scr, colLeft, y+blockGap, HLine(w), tcell.StyleDefault) - Put(scr, 0, y+1, "== ", CDefault) - Put(scr, 3, y+1, string(spinChar)+" ", spinnerStyle) // Colorful spinner - Put(scr, 5, y+1, meter+" ", meterStyle) // Colored meter - Put(scr, 5+m.MeterWidth+1, y+1, header, style) // Move the header after the meter - Put(scr, 0, y+2, HLine(w), CDefault) - y += 4 + return y + headerAdvance +} - /* reachability */ - st.mu.RLock() +// drawReachability renders the reachability summary. The caller holds +// st.mu.RLock. +func drawReachability(scr tcell.Screen, y int, st *InterfaceStatus) int { total := len(st.Reachable) good := 0 + for _, ok := range st.Reachable { if ok { good++ } } + age := time.Since(st.LastPing).Round(time.Second) - reachStyle := CBrightGreen + + reachStyle := styleBrightGreen() if good != total { - reachStyle = CBrightRed + reachStyle = styleBrightRed() } - // Get last drop time and age dropTimeStr := "never" dropAgeStr := "N/A" + if !st.LastDrop.IsZero() { dropTimeStr = st.LastDrop.Format("15:04:05") dropAgeStr = time.Since(st.LastDrop).Round(time.Second).String() } - // Simplified reachability line without drop info - Put(scr, 0, y, fmt.Sprintf("Reachable: %d/%d (at %s, age %s)", + Put(scr, colLeft, y, fmt.Sprintf("Reachable: %d/%d (at %s, age %s)", good, total, st.LastPing.Format("15:04:05"), age), reachStyle) - y++ + y += lineStep if good == total { - Put(scr, 0, y, "Unreachable: none", CDefault) + Put(scr, colLeft, y, "Unreachable: none", tcell.StyleDefault) } else { - var down []string + down := make([]string, 0, len(st.Reachable)) + for h, ok := range st.Reachable { if !ok { down = append(down, h) } } - // Sort for consistent display + sort.Strings(down) - // Add drop info to the end of the unreachable line - Put(scr, 0, y, fmt.Sprintf("Unreachable: %s (last drop at %s, age %s)", - strings.Join(down, ", "), dropTimeStr, dropAgeStr), CBrightRed) + Put(scr, colLeft, y, fmt.Sprintf("Unreachable: %s (last drop at %s, age %s)", + strings.Join(down, ", "), dropTimeStr, dropAgeStr), styleBrightRed()) } - y += 2 - /* packet loss */ - Put(scr, 0, y, "Packet Loss:", CDefault) - y++ + return y + blockGap +} - // Get all hosts and sort them for consistent display order - var lossHosts []string +// drawPacketLoss renders the per-host packet-loss list. The caller holds +// st.mu.RLock. +func drawPacketLoss(scr tcell.Screen, y int, st *InterfaceStatus) int { + Put(scr, colLeft, y, "Packet Loss:", tcell.StyleDefault) + y += lineStep + + lossHosts := make([]string, 0, len(st.Loss)) for host := range st.Loss { lossHosts = append(lossHosts, host) } + sort.Strings(lossHosts) - // Find the maximum length of host names for alignment maxHostLen := 0 for _, host := range lossHosts { - if len(host) > maxHostLen { - maxHostLen = len(host) - } + maxHostLen = max(maxHostLen, len(host)) } - // Add 1 for the colon - maxHostLen += 1 + maxHostLen++ // room for the colon for _, host := range lossHosts { - p := st.Loss[host] * 100 - // Use the maxHostLen for consistent alignment - Put(scr, 0, y, fmt.Sprintf("%-*s %5.0f%%", maxHostLen, host+":", p), StyleLoss(p)) - y++ + p := st.Loss[host] * percentFull + Put(scr, colLeft, y, fmt.Sprintf("%-*s %5.0f%%", maxHostLen, host+":", p), + StyleLoss(p)) + y += lineStep } + dAge := "N/A" if !st.LastDrop.IsZero() { dAge = time.Since(st.LastDrop).Round(time.Second).String() } - Put(scr, 0, y, fmt.Sprintf("Dropped: %d (last at %s, age %s)", - st.DroppedCount, st.LastDrop.Format("15:04:05"), dAge), CDefault) - y += 2 - /* TCP table */ - Put(scr, 0, y, "TCP Connect Stats:", CDefault) - y++ - // header row + Put(scr, colLeft, y, fmt.Sprintf("Dropped: %d (last at %s, age %s)", + st.DroppedCount, st.LastDrop.Format("15:04:05"), dAge), tcell.StyleDefault) + + return y + blockGap +} + +// drawTCPTable renders the TCP connect-stats table. The caller holds +// st.mu.RLock. +func (m *Monitor) drawTCPTable( + scr tcell.Screen, y int, st *InterfaceStatus, tcpHosts []string, +) int { + Put(scr, colLeft, y, "TCP Connect Stats:", tcell.StyleDefault) + y += lineStep + headerRow := fmt.Sprintf("%-*s %*s %*s %*s %*s %*s %*s %*s", m.HostWidth, "Host", m.NumWidth, "last", m.NumWidth, "min", m.NumWidth, "avg", m.NumWidth, "max", m.StdWidth, "stddev", m.NWidth, "n", m.LostWidth, "lost") - Put(scr, 0, y, headerRow, CDefault) - y++ - - m.mu.RLock() - tcpHosts := append([]string{}, m.tcpHosts...) - m.mu.RUnlock() + Put(scr, colLeft, y, headerRow, tcell.StyleDefault) + y += lineStep for _, hp := range tcpHosts { hist := st.TCP[hp] if len(hist) == 0 { continue } - last := hist[len(hist)-1] - mi, ma, av, sd := MinMaxAvgStd(hist) - // Get host name without port for lost packets lookup - hostName := strings.Split(hp, ":")[0] - lost := st.LostPackets[hostName] - - // Add lost column - row := fmt.Sprintf("%-*s %*s %*s %*s %*s %*s %*d %*d", - m.HostWidth, hp, - m.NumWidth, fmt.Sprintf("%.0fms", last), - m.NumWidth, fmt.Sprintf("%.0fms", mi), - m.NumWidth, fmt.Sprintf("%.0fms", av), - m.NumWidth, fmt.Sprintf("%.0fms", ma), - m.StdWidth, fmt.Sprintf("%.0fms", sd), - m.NWidth, len(hist), - m.LostWidth, lost, - ) - Put(scr, 0, y, row, CDefault) - // colourise individual numbers - Put(scr, m.HostWidth+1, y, fmt.Sprintf("%*s", m.NumWidth, fmt.Sprintf("%.0fms", last)), StyleLatency(last)) - Put(scr, m.HostWidth+1+m.NumWidth+1, y, fmt.Sprintf("%*s", m.NumWidth, fmt.Sprintf("%.0fms", mi)), StyleLatency(mi)) - Put(scr, m.HostWidth+1+m.NumWidth*2+2, y, fmt.Sprintf("%*s", m.NumWidth, fmt.Sprintf("%.0fms", av)), StyleLatency(av)) - Put(scr, m.HostWidth+1+m.NumWidth*3+3, y, fmt.Sprintf("%*s", m.NumWidth, fmt.Sprintf("%.0fms", ma)), StyleLatency(ma)) - Put(scr, m.HostWidth+1+m.NumWidth*4+4, y, fmt.Sprintf("%*s", m.StdWidth, fmt.Sprintf("%.0fms", sd)), CDefault) - // Colorize the lost packets column - lostStyle := CDefault - if lost > 0 { - lostStyle = CRed - } - Put(scr, m.HostWidth+1+m.NumWidth*4+4+m.StdWidth+1+m.NWidth+1, y, fmt.Sprintf("%*d", m.LostWidth, lost), lostStyle) - y++ - } - y++ - - /* ICMP stats - combined into a single line with headers */ - // Calculate total lost packets - lost := st.TotalICMPReq - st.TotalICMPRep - if lost < 0 { - lost = 0 + m.drawTCPRow(scr, y, st, hp, hist) + y += lineStep } - // Create styles based on values - lostStyle := CDefault - if lost > 0 { - lostStyle = CRed - } - - // Define column widths and positions - const valWidth = 9 - - // Format the values with right alignment - reqVal := fmt.Sprintf("%9d", st.TotalICMPReq) - repVal := fmt.Sprintf("%9d", st.TotalICMPRep) - lostVal := fmt.Sprintf("%9d", lost) - - // Header texts with the same width as values for right alignment - reqHeader := fmt.Sprintf("%9s", "Requests") - repHeader := fmt.Sprintf("%9s", "Replies") - lostHeader := fmt.Sprintf("%9s", "Lost") - - // Draw the header line - Put(scr, 0, y, " ", CDefault) - Put(scr, 8, y, reqHeader, CDefault) - Put(scr, 8+valWidth+4, y, repHeader, CDefault) - Put(scr, 8+2*(valWidth+4), y, lostHeader, CDefault) - y++ - - // Draw the values line - Put(scr, 0, y, "ICMP: ", CDefault) - Put(scr, 8, y, reqVal, CDefault) - Put(scr, 8+valWidth+4, y, repVal, CDefault) - Put(scr, 8+2*(valWidth+4), y, lostVal, lostStyle) - - y += 2 - st.mu.RUnlock() - return y + return y + lineStep } -// uiLoop runs the UI event loop +// drawTCPRow renders one TCP host's statistics row. The caller holds +// st.mu.RLock. +func (m *Monitor) drawTCPRow( + scr tcell.Screen, y int, st *InterfaceStatus, hp string, hist []float64, +) { + last := hist[len(hist)-1] + mi, ma, av, sd := MinMaxAvgStd(hist) + + host := strings.Split(hp, ":")[0] + lost := st.LostPackets[host] + + row := fmt.Sprintf("%-*s %*s %*s %*s %*s %*s %*d %*d", + m.HostWidth, hp, + m.NumWidth, msStr(last), + m.NumWidth, msStr(mi), + m.NumWidth, msStr(av), + m.NumWidth, msStr(ma), + m.StdWidth, msStr(sd), + m.NWidth, len(hist), + m.LostWidth, lost, + ) + Put(scr, colLeft, y, row, tcell.StyleDefault) + + // Overlay the numeric columns colored by value, at the same offsets the + // base row above laid them out. + x := m.HostWidth + colGap + Put(scr, x, y, fmt.Sprintf("%*s", m.NumWidth, msStr(last)), StyleLatency(last)) + x += m.NumWidth + colGap + Put(scr, x, y, fmt.Sprintf("%*s", m.NumWidth, msStr(mi)), StyleLatency(mi)) + x += m.NumWidth + colGap + Put(scr, x, y, fmt.Sprintf("%*s", m.NumWidth, msStr(av)), StyleLatency(av)) + x += m.NumWidth + colGap + Put(scr, x, y, fmt.Sprintf("%*s", m.NumWidth, msStr(ma)), StyleLatency(ma)) + x += m.NumWidth + colGap + Put(scr, x, y, fmt.Sprintf("%*s", m.StdWidth, msStr(sd)), tcell.StyleDefault) + x += m.StdWidth + colGap + x += m.NWidth + colGap // n column already drawn by the base row + + lostStyle := tcell.StyleDefault + if lost > 0 { + lostStyle = styleRed() + } + + Put(scr, x, y, fmt.Sprintf("%*d", m.LostWidth, lost), lostStyle) +} + +// drawICMPStats renders the ICMP request/reply/lost summary. The caller +// holds st.mu.RLock. +func drawICMPStats(scr tcell.Screen, y int, st *InterfaceStatus) int { + lost := max(st.TotalICMPReq-st.TotalICMPRep, 0) + + lostStyle := tcell.StyleDefault + if lost > 0 { + lostStyle = styleRed() + } + + reqCol := icmpLabelWidth + repCol := reqCol + icmpValWidth + icmpColGap + lostCol := repCol + icmpValWidth + icmpColGap + + Put(scr, reqCol, y, fmt.Sprintf("%*s", icmpValWidth, "Requests"), tcell.StyleDefault) + Put(scr, repCol, y, fmt.Sprintf("%*s", icmpValWidth, "Replies"), tcell.StyleDefault) + Put(scr, lostCol, y, fmt.Sprintf("%*s", icmpValWidth, "Lost"), tcell.StyleDefault) + y += lineStep + + reqStr := fmt.Sprintf("%*d", icmpValWidth, st.TotalICMPReq) + repStr := fmt.Sprintf("%*d", icmpValWidth, st.TotalICMPRep) + lostStr := fmt.Sprintf("%*d", icmpValWidth, lost) + + Put(scr, colLeft, y, "ICMP: ", tcell.StyleDefault) + Put(scr, reqCol, y, reqStr, tcell.StyleDefault) + Put(scr, repCol, y, repStr, tcell.StyleDefault) + Put(scr, lostCol, y, lostStr, lostStyle) + + return y + blockGap +} + +// uiLoop runs the UI event loop. func (m *Monitor) uiLoop(ctx context.Context) { m.logf("UI loop started") + defer func() { m.logf("UI loop cleanup") m.screen.Clear() @@ -298,67 +345,53 @@ func (m *Monitor) uiLoop(ctx context.Context) { m.logf("Screen finalized") }() - // Load PST location pstLoc, err := time.LoadLocation("America/Los_Angeles") if err != nil { m.logf("Error loading PST location: %v", err) + pstLoc = time.UTC } - // Function to draw the screen + timestampSpinFrame := 0 + lastTimestampSpinUpdate := time.Now() + drawScreen := func() { w, _ := m.screen.Size() m.screen.Clear() - // Draw the top horizontal line - Put(m.screen, 0, 0, HLine(w), CDefault) + Put(m.screen, colLeft, rowTopRule, HLine(w), tcell.StyleDefault) - // Get current time and format it now := time.Now() timeStr := now.Format(time.RFC1123Z) - - // Format time in PST pstTimeStr := now.In(pstLoc).Format(time.RFC1123Z) - // Update the timestamp spinner once per second if time.Since(lastTimestampSpinUpdate) >= time.Second { - timestampSpinFrame = (timestampSpinFrame + 1) % len(BrailleSpins) + timestampSpinFrame = (timestampSpinFrame + 1) % len(brailleSpins()) lastTimestampSpinUpdate = now } - // Get braille spinner character - brailleChar := BrailleSpins[timestampSpinFrame] + brailleChar := brailleSpins()[timestampSpinFrame] + colors := rainbow() - // Draw the header with timestamp and spinner - Put(m.screen, 0, 1, "== ", CDefault) - Put(m.screen, 3, 1, string(brailleChar)+" ", CDefault) + Put(m.screen, colLeft, rowClock, "== ", tcell.StyleDefault) + Put(m.screen, colSpinner, rowClock, string(brailleChar)+" ", tcell.StyleDefault) + DrawRainbowText(m.screen, colMeter, rowClock, timeStr, colors) + DrawRainbowText(m.screen, colMeter+len(timeStr)+clockGap, rowClock, + pstTimeStr, colors) + Put(m.screen, colLeft, rowBottomRule, HLine(w), tcell.StyleDefault) - // Draw the timestamp with rainbow colors - DrawRainbowText(m.screen, 5, 1, timeStr, CRainbow) + runtime := time.Since(m.startTime).Round(time.Second).String() + Put(m.screen, colLeft, rowRuntime, "Runtime: "+runtime, tcell.StyleDefault) - // Add 5 space gap and PST time with rainbow colors - DrawRainbowText(m.screen, 5+len(timeStr)+5, 1, pstTimeStr, CRainbow) - - // Draw the bottom horizontal line - Put(m.screen, 0, 2, HLine(w), CDefault) - - // Draw the runtime - Put(m.screen, 0, 3, "Runtime: "+time.Since(m.startTime).Round(time.Second).String(), CDefault) - - // Draw interfaces - y := 5 + y := rowFirstIface y = m.DrawInterface(m.screen, y, w, m.interfaceA) _ = m.DrawInterface(m.screen, y, w, m.interfaceB) - // Show the screen m.screen.Show() } - // Initial draw drawScreen() - // Even without a ticker, ensure we update at least every second - // This is a backup in case there are no spinner updates backupTicker := time.NewTicker(time.Second) defer backupTicker.Stop() @@ -366,12 +399,11 @@ func (m *Monitor) uiLoop(ctx context.Context) { select { case <-ctx.Done(): m.logf("Context cancelled, exiting UI loop") + return - case <-UIUpdateChan: - // Update on spinner ticks (no rate limiting) + case <-m.uiUpdate: drawScreen() case <-backupTicker.C: - // Fallback to ensure we update at least once per second drawScreen() } } diff --git a/script/bootstrap b/script/bootstrap new file mode 100755 index 0000000..7a3adbd --- /dev/null +++ b/script/bootstrap @@ -0,0 +1,70 @@ +#!/bin/sh +# script/bootstrap: install all dependencies needed to build and develop +# this repo, idempotently. Assumes nothing is present (not git, make, or +# go). Base tooling comes from nix, apt, brew, or apk (detected in that +# order; apt runs noninteractive). golangci-lint is deliberately not +# installed: linting runs only in docker, via script/lint and the +# Dockerfile lint phase. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +PKGMGR="" +SUDO="" + +detect_pkgmgr() { + [ -n "$PKGMGR" ] && return 0 + if command -v nix-env >/dev/null 2>&1; then + PKGMGR="nix" + elif command -v apt-get >/dev/null 2>&1; then + PKGMGR="apt" + elif command -v brew >/dev/null 2>&1; then + PKGMGR="brew" + elif command -v apk >/dev/null 2>&1; then + PKGMGR="apk" + else + echo "bootstrap: no supported package manager (nix, apt, brew, apk)" >&2 + exit 1 + fi + if [ "$PKGMGR" = "apt" ]; then + export DEBIAN_FRONTEND=noninteractive + if [ "$(id -u)" != "0" ]; then + SUDO="sudo" + fi + fi +} + +# pkg_install +pkg_install() { + detect_pkgmgr + case "$PKGMGR" in + nix) nix-env -iA "nixpkgs.$1" ;; + apt) $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2" ;; + brew) brew install "$3" ;; + apk) apk add --no-cache "$4" ;; + esac +} + +missing() { + ! command -v "$1" >/dev/null 2>&1 +} + +main() { + cd "$ROOT" + + if missing git; then pkg_install git git git git; fi + if missing make; then pkg_install gnumake make make make; fi + if missing go; then pkg_install go golang go go; fi + + # docker is platform-specific and out of scope for a package-manager + # bootstrap, but script/lint and script/test need it. + if missing docker; then + echo "bootstrap: docker not found; script/lint and script/test require it" >&2 + fi + + go mod download + + echo "bootstrap complete" +} + +main "$@" diff --git a/script/check b/script/check new file mode 100755 index 0000000..68e22d1 --- /dev/null +++ b/script/check @@ -0,0 +1,14 @@ +#!/bin/sh +# script/check: run all checks (test, lint, fmt-check). Our extension to +# scripts-to-rule-them-all. Must not modify any files. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" + +main() { + "$SCRIPT_DIR/test" + "$SCRIPT_DIR/lint" + "$SCRIPT_DIR/fmt-check" +} + +main "$@" diff --git a/script/cibuild b/script/cibuild new file mode 100755 index 0000000..94b1a35 --- /dev/null +++ b/script/cibuild @@ -0,0 +1,19 @@ +#!/bin/sh +# script/cibuild: run the CI build. Runs script/bootstrap first (a pristine +# checkout has nothing installed, and script/fmt-check runs gofmt on the +# host), then script/check, then builds the image with --no-cache so the +# shipped image is built from a fresh run of its own gate phases. The Gitea +# workflow runs this on push. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" + +main() { + cd "$ROOT" + "$SCRIPT_DIR/bootstrap" + "$SCRIPT_DIR/check" + docker build --no-cache -t "$("$SCRIPT_DIR/projectname")" . +} + +main "$@" diff --git a/script/docker b/script/docker new file mode 100755 index 0000000..c643893 --- /dev/null +++ b/script/docker @@ -0,0 +1,14 @@ +#!/bin/sh +# script/docker: build the Docker image tagged with the project name. +# --no-cache so the lint and test gate phases actually run. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" + +main() { + cd "$ROOT" + docker build --no-cache -t "$("$SCRIPT_DIR/projectname")" . +} + +main "$@" diff --git a/script/fmt b/script/fmt new file mode 100755 index 0000000..709b790 --- /dev/null +++ b/script/fmt @@ -0,0 +1,16 @@ +#!/bin/sh +# script/fmt: format all Go code (writes). Formatting is the one gate that +# runs on the host rather than in docker. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +main() { + cd "$ROOT" + gofmt -s -w . + if command -v goimports >/dev/null 2>&1; then + goimports -w . + fi +} + +main "$@" diff --git a/script/fmt-check b/script/fmt-check new file mode 100755 index 0000000..d25106f --- /dev/null +++ b/script/fmt-check @@ -0,0 +1,18 @@ +#!/bin/sh +# script/fmt-check: check Go formatting (read-only). Same scope as +# script/fmt, but fails instead of writing. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +main() { + cd "$ROOT" + out="$(gofmt -s -l .)" + if [ -n "$out" ]; then + echo "gofmt needed on:" + echo "$out" + exit 1 + fi +} + +main "$@" diff --git a/script/install-precommit b/script/install-precommit new file mode 100755 index 0000000..7b119e9 --- /dev/null +++ b/script/install-precommit @@ -0,0 +1,15 @@ +#!/bin/sh +# script/install-precommit: install the git pre-commit hook that runs +# script/precommit. Our extension to scripts-to-rule-them-all. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +main() { + cd "$ROOT" + printf '#!/bin/sh\nset -e\nscript/precommit\n' > .git/hooks/pre-commit + chmod +x .git/hooks/pre-commit + echo "pre-commit hook installed: runs script/precommit" +} + +main "$@" diff --git a/script/lint b/script/lint new file mode 100755 index 0000000..8996765 --- /dev/null +++ b/script/lint @@ -0,0 +1,16 @@ +#!/bin/sh +# script/lint: run the linter. golangci-lint is never installed on the host; +# it runs only in docker, as the `lint` phase of the Dockerfile. --no-cache +# forces the phase to re-execute, so a cached layer cannot report a pass it +# did not earn. The build is tagged so no dangling image is left behind. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +main() { + cd "$ROOT" + docker build --no-cache --target lint \ + -t "$(script/projectname)-lint" . +} + +main "$@" diff --git a/script/precommit b/script/precommit new file mode 100755 index 0000000..95033c9 --- /dev/null +++ b/script/precommit @@ -0,0 +1,21 @@ +#!/bin/sh +# script/precommit: run by the git pre-commit hook; fails the commit if +# checks fail. Our extension to scripts-to-rule-them-all. Go repo extra: +# `go mod tidy` must not change go.mod/go.sum. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" + +main() { + cd "$ROOT" + go mod tidy + git diff --exit-code -- go.mod go.sum || { + echo "precommit: go mod tidy changed go.mod/go.sum;" \ + "stage the changes and retry" >&2 + exit 1 + } + "$SCRIPT_DIR/check" +} + +main "$@" diff --git a/script/projectname b/script/projectname new file mode 100755 index 0000000..399f7dd --- /dev/null +++ b/script/projectname @@ -0,0 +1,11 @@ +#!/bin/sh +# script/projectname: output the name of this project. Our extension to +# scripts-to-rule-them-all. Scripts that need the name (e.g. script/docker) +# call this so they stay identical across repos. +set -eu + +main() { + echo "rtnetmon" +} + +main "$@" diff --git a/script/setup b/script/setup new file mode 100755 index 0000000..662b750 --- /dev/null +++ b/script/setup @@ -0,0 +1,13 @@ +#!/bin/sh +# script/setup: make a fresh clone ready for development: install +# dependencies (script/bootstrap) and the git pre-commit hook. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" + +main() { + "$SCRIPT_DIR/bootstrap" + "$SCRIPT_DIR/install-precommit" +} + +main "$@" diff --git a/script/test b/script/test new file mode 100755 index 0000000..08a5bc5 --- /dev/null +++ b/script/test @@ -0,0 +1,16 @@ +#!/bin/sh +# script/test: run the test suite as the `test` phase of the Dockerfile, +# with --no-cache so the tests actually re-run. The build is tagged so no +# dangling image is left behind. The 90s per-package timeout matches the +# org-wide backstop in REPO_POLICIES.md. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +main() { + cd "$ROOT" + docker build --no-cache --target test \ + -t "$(script/projectname)-test" . +} + +main "$@" From 486a47397cae4e6a41319ae3ee7f8cd62e4ca7af Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Mon, 21 Sep 2026 15:01:59 +0200 Subject: [PATCH 2/9] Detect interfaces per platform; add macOS and single-interface support (closes #2) Linux runs exactly as before when both bridge interfaces exist. When they do not, the lone default-route interface is monitored, and a single interface draws a single UI pane instead of an empty second one. macOS is newly supported: a running VPN tunnel (utun) is monitored as the primary pane alongside the physical default-route interface, or the physical interface alone when no VPN is up. Detection lives in internal/netdetect: interface/route data types, pure selection logic keyed on OS name, and route parsers, all unit-tested on Linux for both platforms. Only the real route query and the per-platform TCP dial binding are build-tagged. NewMonitor now takes a list of interfaces. Not verified on a real mac: live netstat parsing, IP_BOUND_IF dialing, Mullvad leak protection. Model: opus-4-8 (implementation); fable-5-1 (landing commit) --- README.md | 81 ++++-- cmd/rtnetmon/main.go | 6 +- go.mod | 2 +- internal/cli/export_test.go | 2 - internal/cli/root.go | 62 ++++- internal/cli/root_test.go | 2 - internal/monitor/dial_darwin.go | 36 +++ internal/monitor/dial_linux.go | 12 + internal/monitor/export_test.go | 15 +- internal/monitor/loops.go | 2 - internal/monitor/monitor.go | 79 ++++-- internal/monitor/monitor_test.go | 56 ++-- internal/monitor/ping_test.go | 58 +++++ internal/monitor/styles.go | 2 - internal/monitor/styles_test.go | 2 - internal/monitor/ui.go | 8 +- internal/netdetect/export_test.go | 12 + internal/netdetect/netdetect.go | 375 +++++++++++++++++++++++++++ internal/netdetect/netdetect_test.go | 310 ++++++++++++++++++++++ internal/netdetect/routes_darwin.go | 27 ++ internal/netdetect/routes_linux.go | 36 +++ 21 files changed, 1092 insertions(+), 93 deletions(-) create mode 100644 internal/monitor/dial_darwin.go create mode 100644 internal/monitor/dial_linux.go create mode 100644 internal/monitor/ping_test.go create mode 100644 internal/netdetect/export_test.go create mode 100644 internal/netdetect/netdetect.go create mode 100644 internal/netdetect/netdetect_test.go create mode 100644 internal/netdetect/routes_darwin.go create mode 100644 internal/netdetect/routes_linux.go diff --git a/README.md b/README.md index dd9badb..783b65f 100644 --- a/README.md +++ b/README.md @@ -2,34 +2,70 @@ rtnetmon is a WTFPL-licensed Go terminal (CLI/TUI) network monitor by [@sneak](https://sneak.berlin) that shows real-time network health, packet -loss, and latency across two Linux network interfaces at once. +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 two network -interfaces simultaneously. It's designed for Linux systems and uses ncurses -for a clean, real-time dashboard interface. +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 -- **Dual Interface Monitoring**: Monitor two network interfaces simultaneously +- **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) + - 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 operating system +- 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. 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. ## Installation @@ -67,11 +103,17 @@ rtnetmon/ ├── 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) │ └── 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 +│ ├── 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 @@ -133,8 +175,11 @@ The monitor package provides an object-oriented API for programmatic use: ```go import "git.eeqj.de/sneak/rtnetmon/internal/monitor" -// Create a new monitor -mon := monitor.NewMonitor("eth0", "Primary", "wlan0", "Backup", "/tmp/monitor.log") +// 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 diff --git a/cmd/rtnetmon/main.go b/cmd/rtnetmon/main.go index 9ab1c90..eac0fed 100644 --- a/cmd/rtnetmon/main.go +++ b/cmd/rtnetmon/main.go @@ -1,7 +1,5 @@ -//go:build linux - -// Command rtnetmon is a dual-interface real-time network monitoring -// dashboard for Linux. WTFPL, sneak@sneak.berlin. +// Command rtnetmon is a real-time network monitoring dashboard for Linux +// and macOS. WTFPL, sneak@sneak.berlin. package main import ( diff --git a/go.mod b/go.mod index 88cbd4c..135c5bb 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/gdamore/tcell/v2 v2.8.1 github.com/spf13/cobra v1.8.0 github.com/spf13/viper v1.18.2 + golang.org/x/sys v0.29.0 ) require ( @@ -29,7 +30,6 @@ require ( go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect - golang.org/x/sys v0.29.0 // indirect golang.org/x/term v0.28.0 // indirect golang.org/x/text v0.21.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect diff --git a/internal/cli/export_test.go b/internal/cli/export_test.go index 316dfe7..e768ab2 100644 --- a/internal/cli/export_test.go +++ b/internal/cli/export_test.go @@ -1,5 +1,3 @@ -//go:build linux - package cli import "github.com/spf13/cobra" diff --git a/internal/cli/root.go b/internal/cli/root.go index 456cff5..68d70d4 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -1,5 +1,3 @@ -//go:build linux - // Package cli wires command-line flags to the network monitor and runs it. package cli @@ -7,12 +5,14 @@ import ( "context" "os" "os/signal" + "runtime" "syscall" "github.com/spf13/cobra" "github.com/spf13/viper" "git.eeqj.de/sneak/rtnetmon/internal/monitor" + "git.eeqj.de/sneak/rtnetmon/internal/netdetect" ) // config holds the application configuration. @@ -59,8 +59,8 @@ func newRootCmd() *cobra.Command { Short: "Real-time network monitoring dashboard", Long: `rtnetmon is a dual-interface network monitoring dashboard that provides real-time visibility into network health, packet loss, and latency.`, - RunE: func(_ *cobra.Command, _ []string) error { - return runMonitor(cfg) + RunE: func(cmd *cobra.Command, _ []string) error { + return runMonitor(cmd, cfg) }, } registerFlags(cmd, cfg) @@ -86,14 +86,17 @@ func registerFlags(cmd *cobra.Command, cfg *config) { } } -// runMonitor constructs and runs the monitor from cfg. -func runMonitor(cfg *config) error { +// runMonitor detects the interfaces to monitor, then constructs and runs the +// monitor from cfg. +func runMonitor(cmd *cobra.Command, cfg *config) error { monitor.Logf(cfg.LogFile, "Starting rtnetmon") - monitor.Logf(cfg.LogFile, "Monitoring interfaces %s and %s", - cfg.IfaceA, cfg.IfaceB) - mon := monitor.NewMonitor(cfg.IfaceA, cfg.LabelA, cfg.IfaceB, cfg.LabelB, - cfg.LogFile) + specs, err := detectInterfaces(cmd, cfg) + if err != nil { + return err + } + + mon := monitor.NewMonitor(specs, cfg.LogFile) for _, host := range cfg.Hosts { mon.AddReachabilityHost(host) @@ -122,6 +125,45 @@ func runMonitor(cfg *config) error { return mon.Run(ctx) } +// detectInterfaces enumerates the host, reads its default routes, and applies +// the platform selection rules to decide which interfaces to monitor. +func detectInterfaces( + cmd *cobra.Command, cfg *config, +) ([]monitor.IfaceSpec, error) { + ifaces, err := netdetect.Interfaces() + if err != nil { + return nil, err + } + + routes, err := netdetect.DefaultRoutes() + if err != nil { + return nil, err + } + + flags := netdetect.Flags{ + IfaceA: cfg.IfaceA, + LabelA: cfg.LabelA, + IfaceB: cfg.IfaceB, + LabelB: cfg.LabelB, + LabelASet: cmd.Flags().Changed("labelA"), + LabelBSet: cmd.Flags().Changed("labelB"), + } + + panes, err := netdetect.Select(runtime.GOOS, ifaces, routes, flags) + if err != nil { + return nil, err + } + + specs := make([]monitor.IfaceSpec, len(panes)) + for i, p := range panes { + specs[i] = monitor.IfaceSpec{Name: p.Name, Label: p.Label} + } + + monitor.Logf(cfg.LogFile, "Monitoring %d interface(s)", len(specs)) + + return specs, nil +} + // Execute builds the root command and runs it. func Execute() error { return newRootCmd().Execute() diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 4e6ade9..04daa0a 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -1,5 +1,3 @@ -//go:build linux - package cli_test import ( diff --git a/internal/monitor/dial_darwin.go b/internal/monitor/dial_darwin.go new file mode 100644 index 0000000..e290e0b --- /dev/null +++ b/internal/monitor/dial_darwin.go @@ -0,0 +1,36 @@ +//go:build darwin + +package monitor + +import ( + "net" + "syscall" + + "golang.org/x/sys/unix" +) + +// bindControl returns a socket control hook that pins the connection to the +// given interface with IP_BOUND_IF. On macOS a bound source address is not +// enough: without this the kernel still routes the packets over the VPN's +// default route, so traffic would not leave the interface we are measuring. +func bindControl(iface string) func(network, address string, c syscall.RawConn) error { + ifi, err := net.InterfaceByName(iface) + if err != nil { + return nil + } + + idx := ifi.Index + + return func(_, _ string, c syscall.RawConn) error { + var setErr error + + ctrlErr := c.Control(func(fd uintptr) { + setErr = unix.SetsockoptInt(int(fd), unix.IPPROTO_IP, unix.IP_BOUND_IF, idx) + }) + if ctrlErr != nil { + return ctrlErr + } + + return setErr + } +} diff --git a/internal/monitor/dial_linux.go b/internal/monitor/dial_linux.go new file mode 100644 index 0000000..fc47b93 --- /dev/null +++ b/internal/monitor/dial_linux.go @@ -0,0 +1,12 @@ +//go:build linux + +package monitor + +import "syscall" + +// bindControl returns no socket control hook on Linux: binding the dialer's +// local source address (as tcpDuration already does) is enough to send +// traffic out of the chosen interface. +func bindControl(_ string) func(network, address string, c syscall.RawConn) error { + return nil +} diff --git a/internal/monitor/export_test.go b/internal/monitor/export_test.go index aff1b4c..5fd3294 100644 --- a/internal/monitor/export_test.go +++ b/internal/monitor/export_test.go @@ -1,5 +1,3 @@ -//go:build linux - package monitor // Test-only accessors exposing unexported state for white-box assertions. @@ -13,8 +11,13 @@ func (m *Monitor) PacketLossHosts() []string { return m.packetLossHosts } // TCPHosts returns the configured TCP hosts. func (m *Monitor) TCPHosts() []string { return m.tcpHosts } -// InterfaceA returns the first monitored interface. -func (m *Monitor) InterfaceA() *InterfaceStatus { return m.interfaceA } +// Interfaces returns the monitored interfaces. +func (m *Monitor) Interfaces() []*InterfaceStatus { return m.interfaces } -// InterfaceB returns the second monitored interface. -func (m *Monitor) InterfaceB() *InterfaceStatus { return m.interfaceB } +// PingArgs exposes pingArgs for external tests. +func PingArgs(goos, iface, host string) []string { return pingArgs(goos, iface, host) } + +// LossArgs exposes lossArgs for external tests. +func LossArgs(goos, iface, host string, count int) []string { + return lossArgs(goos, iface, host, count) +} diff --git a/internal/monitor/loops.go b/internal/monitor/loops.go index 9671d62..1420eb8 100644 --- a/internal/monitor/loops.go +++ b/internal/monitor/loops.go @@ -1,5 +1,3 @@ -//go:build linux - package monitor import ( diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index 0d85de8..83fe1e5 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -1,8 +1,6 @@ -//go:build linux - -// Package monitor implements the dual-interface real-time network -// monitoring dashboard: ICMP reachability, packet loss, TCP latency, and -// the terminal UI that renders them. +// Package monitor implements the real-time network monitoring dashboard: +// ICMP reachability, packet loss, TCP latency, and the terminal UI that +// renders them. It monitors one or two interfaces. package monitor import ( @@ -14,6 +12,7 @@ import ( "net" "os" "os/exec" + "runtime" "strconv" "strings" "sync" @@ -88,9 +87,8 @@ type Monitor struct { packetLossHosts []string tcpHosts []string - // Interfaces - interfaceA *InterfaceStatus - interfaceB *InterfaceStatus + // Interfaces to monitor (one or two) + interfaces []*InterfaceStatus // Logging logFile string @@ -104,9 +102,16 @@ type Monitor struct { mu sync.RWMutex } -// NewMonitor creates a new Monitor instance with default settings. -func NewMonitor(ifaceA, labelA, ifaceB, labelB, logFile string) *Monitor { - return &Monitor{ +// IfaceSpec names one interface to monitor and its display label. +type IfaceSpec struct { + Name string + Label string +} + +// NewMonitor creates a new Monitor instance with default settings, +// monitoring the given interfaces (one or two). +func NewMonitor(ifaces []IfaceSpec, logFile string) *Monitor { + m := &Monitor{ ICMPTimeout: defaultICMPTimeout, TCPTimeout: defaultTCPTimeout, PacketLossPings: defaultPacketLossPings, @@ -127,13 +132,16 @@ func NewMonitor(ifaceA, labelA, ifaceB, labelB, logFile string) *Monitor { packetLossHosts: []string{}, tcpHosts: []string{}, - interfaceA: NewInterfaceStatus(ifaceA, labelA), - interfaceB: NewInterfaceStatus(ifaceB, labelB), - logFile: logFile, startTime: time.Now(), uiUpdate: make(chan struct{}, uiUpdateBuffer), } + + for _, spec := range ifaces { + m.interfaces = append(m.interfaces, NewInterfaceStatus(spec.Name, spec.Label)) + } + + return m } // AddReachabilityHost adds a host for reachability monitoring. @@ -194,12 +202,11 @@ func (m *Monitor) Run(ctx context.Context) error { tcpHosts := append([]string{}, m.tcpHosts...) m.mu.RUnlock() - go m.reachLoop(ctx, m.interfaceA, reachHosts) - go m.reachLoop(ctx, m.interfaceB, reachHosts) - go m.lossLoop(ctx, m.interfaceA, lossHosts) - go m.lossLoop(ctx, m.interfaceB, lossHosts) - go m.tcpLoop(ctx, m.interfaceA, tcpHosts) - go m.tcpLoop(ctx, m.interfaceB, tcpHosts) + for _, st := range m.interfaces { + go m.reachLoop(ctx, st, reachHosts) + go m.lossLoop(ctx, st, lossHosts) + go m.tcpLoop(ctx, st, tcpHosts) + } m.logf("Starting UI loop") m.uiLoop(ctx) @@ -306,13 +313,36 @@ func fetchIPInfo(iface string) string { return fmt.Sprintf("%s [%s] %s", r.IP, r.Hostname, r.Org) } +// pingArgs builds the arguments for a single reachability ping on the given +// OS. Linux binds the interface with -I and takes -W in seconds; macOS binds +// with -b and takes -W in milliseconds. +func pingArgs(goos, iface, host string) []string { + if goos == "darwin" { + return []string{"-b", iface, "-c1", "-W1000", host} + } + + return []string{"-I", iface, "-c1", "-W1", host} +} + +// lossArgs builds the arguments for a packet-loss ping burst of count pings. +func lossArgs(goos, iface, host string, count int) []string { + c := strconv.Itoa(count) + if goos == "darwin" { + return []string{"-q", "-i", "0.05", "-c", c, "-W1000", "-b", iface, host} + } + + return []string{"-q", "-i", "0.05", "-c", c, "-W1", "-I", iface, host} +} + // pingOnce performs a single ping over the named interface. func (m *Monitor) pingOnce(ctx context.Context, iface, host string) bool { ctx, cancel := context.WithTimeout(ctx, m.ICMPTimeout) defer cancel() + args := pingArgs(runtime.GOOS, iface, host) + cmd := exec.CommandContext( //nolint:gosec // G204: fixed argv, operator CLI input - ctx, "ping", "-I", iface, "-c1", "-W1", host) + ctx, "ping", args...) return cmd.Run() == nil } @@ -322,9 +352,10 @@ func (m *Monitor) lossPercent(ctx context.Context, iface, host string) float64 { ctx, cancel := context.WithTimeout(ctx, lossQueryTimeout) defer cancel() + args := lossArgs(runtime.GOOS, iface, host, m.PacketLossPings) + cmd := exec.CommandContext( //nolint:gosec // G204: fixed argv, operator CLI input - ctx, "ping", "-q", "-i", "0.05", - "-c", strconv.Itoa(m.PacketLossPings), "-W1", "-I", iface, host) + ctx, "ping", args...) out, err := cmd.CombinedOutput() if err != nil { @@ -375,7 +406,7 @@ func (m *Monitor) tcpDuration(iface, hp string) time.Duration { return m.TCPTimeout } - d := net.Dialer{Timeout: m.TCPTimeout, LocalAddr: la} + d := net.Dialer{Timeout: m.TCPTimeout, LocalAddr: la, Control: bindControl(iface)} st := time.Now() c, err := d.Dial("tcp", hp) diff --git a/internal/monitor/monitor_test.go b/internal/monitor/monitor_test.go index a0e92c7..133f829 100644 --- a/internal/monitor/monitor_test.go +++ b/internal/monitor/monitor_test.go @@ -1,5 +1,3 @@ -//go:build linux - package monitor_test import ( @@ -8,26 +6,33 @@ import ( "git.eeqj.de/sneak/rtnetmon/internal/monitor" ) +// Interface names and hosts reused across the monitor package tests. +const ( + ifaceTest0 = "test0" + ifaceTest1 = "test1" + ifaceEth0 = "eth0" + host8888 = "8.8.8.8" +) + func TestNewMonitor(t *testing.T) { t.Parallel() - mon := monitor.NewMonitor("test0", "Test Interface A", "test1", - "Test Interface B", "/tmp/test.log") + mon := monitor.NewMonitor([]monitor.IfaceSpec{ + {Name: ifaceTest0, Label: "Test Interface A"}, + {Name: ifaceTest1, Label: "Test Interface B"}, + }, "/tmp/test.log") - if mon.InterfaceA() == nil { - t.Fatal("interfaceA is nil") + ifaces := mon.Interfaces() + if len(ifaces) != 2 { + t.Fatalf("interfaces = %d, want 2", len(ifaces)) } - if mon.InterfaceB() == nil { - t.Fatal("interfaceB is nil") + if ifaces[0].Name != ifaceTest0 { + t.Errorf("interfaces[0].Name = %q, want %q", ifaces[0].Name, ifaceTest0) } - if mon.InterfaceA().Name != "test0" { - t.Errorf("interfaceA.Name = %q, want %q", mon.InterfaceA().Name, "test0") - } - - if mon.InterfaceB().Name != "test1" { - t.Errorf("interfaceB.Name = %q, want %q", mon.InterfaceB().Name, "test1") + if ifaces[1].Name != ifaceTest1 { + t.Errorf("interfaces[1].Name = %q, want %q", ifaces[1].Name, ifaceTest1) } if mon.ICMPTimeout.Milliseconds() != 500 { @@ -51,12 +56,31 @@ func TestNewMonitor(t *testing.T) { } } +func TestNewMonitorSingle(t *testing.T) { + t.Parallel() + + mon := monitor.NewMonitor( + []monitor.IfaceSpec{{Name: ifaceEth0, Label: "default route"}}, "") + + ifaces := mon.Interfaces() + if len(ifaces) != 1 { + t.Fatalf("interfaces = %d, want 1", len(ifaces)) + } + + if ifaces[0].Name != ifaceEth0 { + t.Errorf("interfaces[0].Name = %q, want %q", ifaces[0].Name, ifaceEth0) + } +} + func TestAddHosts(t *testing.T) { t.Parallel() - mon := monitor.NewMonitor("test0", "Test A", "test1", "Test B", "") + mon := monitor.NewMonitor([]monitor.IfaceSpec{ + {Name: ifaceTest0, Label: "Test A"}, + {Name: ifaceTest1, Label: "Test B"}, + }, "") - mon.AddReachabilityHost("8.8.8.8") + mon.AddReachabilityHost(host8888) mon.AddReachabilityHost("google.com") if len(mon.ReachabilityHosts()) != 2 { diff --git a/internal/monitor/ping_test.go b/internal/monitor/ping_test.go new file mode 100644 index 0000000..b79cbc2 --- /dev/null +++ b/internal/monitor/ping_test.go @@ -0,0 +1,58 @@ +package monitor_test + +import ( + "reflect" + "testing" + + "git.eeqj.de/sneak/rtnetmon/internal/monitor" +) + +func TestPingArgs(t *testing.T) { + t.Parallel() + + tests := []struct { + goos string + want []string + }{ + {"linux", []string{"-I", ifaceEth0, "-c1", "-W1", host8888}}, + {"darwin", []string{"-b", ifaceEth0, "-c1", "-W1000", host8888}}, + } + + for _, tt := range tests { + t.Run(tt.goos, func(t *testing.T) { + t.Parallel() + + got := monitor.PingArgs(tt.goos, ifaceEth0, host8888) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("PingArgs(%q) = %v, want %v", tt.goos, got, tt.want) + } + }) + } +} + +func TestLossArgs(t *testing.T) { + t.Parallel() + + tests := []struct { + goos string + want []string + }{ + {"linux", []string{ + "-q", "-i", "0.05", "-c", "20", "-W1", "-I", ifaceEth0, host8888, + }}, + {"darwin", []string{ + "-q", "-i", "0.05", "-c", "20", "-W1000", "-b", ifaceEth0, host8888, + }}, + } + + for _, tt := range tests { + t.Run(tt.goos, func(t *testing.T) { + t.Parallel() + + got := monitor.LossArgs(tt.goos, ifaceEth0, host8888, 20) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("LossArgs(%q) = %v, want %v", tt.goos, got, tt.want) + } + }) + } +} diff --git a/internal/monitor/styles.go b/internal/monitor/styles.go index 423db33..9bdc7d8 100644 --- a/internal/monitor/styles.go +++ b/internal/monitor/styles.go @@ -1,5 +1,3 @@ -//go:build linux - package monitor import tcell "github.com/gdamore/tcell/v2" diff --git a/internal/monitor/styles_test.go b/internal/monitor/styles_test.go index a3da137..b9b8b14 100644 --- a/internal/monitor/styles_test.go +++ b/internal/monitor/styles_test.go @@ -1,5 +1,3 @@ -//go:build linux - package monitor_test import ( diff --git a/internal/monitor/ui.go b/internal/monitor/ui.go index 660160a..088bb77 100644 --- a/internal/monitor/ui.go +++ b/internal/monitor/ui.go @@ -1,5 +1,3 @@ -//go:build linux - package monitor import ( @@ -383,9 +381,11 @@ func (m *Monitor) uiLoop(ctx context.Context) { runtime := time.Since(m.startTime).Round(time.Second).String() Put(m.screen, colLeft, rowRuntime, "Runtime: "+runtime, tcell.StyleDefault) + // Draw one pane per interface; a single interface draws one pane. y := rowFirstIface - y = m.DrawInterface(m.screen, y, w, m.interfaceA) - _ = m.DrawInterface(m.screen, y, w, m.interfaceB) + for _, st := range m.interfaces { + y = m.DrawInterface(m.screen, y, w, st) + } m.screen.Show() } diff --git a/internal/netdetect/export_test.go b/internal/netdetect/export_test.go new file mode 100644 index 0000000..79ef40e --- /dev/null +++ b/internal/netdetect/export_test.go @@ -0,0 +1,12 @@ +package netdetect + +// Test-only accessors exposing the unexported route parsers. + +// ParseIPRoute exposes parseIPRoute for external tests. +func ParseIPRoute(out string) []Route { return parseIPRoute(out) } + +// ParseProcNetRoute exposes parseProcNetRoute for external tests. +func ParseProcNetRoute(out string) []Route { return parseProcNetRoute(out) } + +// ParseNetstat exposes parseNetstat for external tests. +func ParseNetstat(out string) []Route { return parseNetstat(out) } diff --git a/internal/netdetect/netdetect.go b/internal/netdetect/netdetect.go new file mode 100644 index 0000000..061c043 --- /dev/null +++ b/internal/netdetect/netdetect.go @@ -0,0 +1,375 @@ +// Package netdetect chooses which network interfaces rtnetmon should monitor. +// +// The host-specific queries (enumerating interfaces, reading the routing +// table) live behind small data types so the selection logic and the route +// parsers are pure functions that can be unit-tested on any OS with fake +// data. Only the real routing-table query is build-tagged per platform. +package netdetect + +import ( + "encoding/hex" + "errors" + "fmt" + "net" + "sort" + "strings" +) + +// Selection failures. Those needing the offending interface names are +// wrapped with %w at the call site. +var ( + errUnsupportedOS = errors.New("unsupported operating system") + errOneOfPair = errors.New( + "found only one of the configured interfaces; " + + "rtnetmon needs both, or neither (default route only)") + errNoDefaultRoute = errors.New( + "no default route found; rtnetmon needs one internet interface") + errManyDefaultRoutes = errors.New( + "multiple default-route interfaces found; rtnetmon supports only one") + errNoPhysRoute = errors.New( + "no physical default-route interface found; " + + "rtnetmon needs one internet interface") + errManyPhysRoutes = errors.New( + "multiple physical default-route interfaces found; " + + "rtnetmon supports only one") +) + +// Interface is a network interface reduced to what detection needs. +type Interface struct { + Name string + Up bool + IPv4 []string +} + +// Route is one routing-table entry reduced to what detection needs. +type Route struct { + Iface string + Gateway string + Default bool +} + +// Pane names one interface to display, with its label. +type Pane struct { + Name string + Label string +} + +// Flags carries the user's --iface/--label choices and whether each label was +// set explicitly on the command line. +type Flags struct { + IfaceA string + LabelA string + IfaceB string + LabelB string + LabelASet bool + LabelBSet bool +} + +// Interfaces enumerates the host's interfaces and their IPv4 addresses. This +// uses the standard library and is the same on every platform. +func Interfaces() ([]Interface, error) { + ifs, err := net.Interfaces() + if err != nil { + return nil, fmt.Errorf("listing interfaces: %w", err) + } + + out := make([]Interface, 0, len(ifs)) + for _, ifi := range ifs { + var v4 []string + + addrs, _ := ifi.Addrs() + for _, a := range addrs { + if n, ok := a.(*net.IPNet); ok && n.IP.To4() != nil { + v4 = append(v4, n.IP.String()) + } + } + + out = append(out, Interface{ + Name: ifi.Name, + Up: ifi.Flags&net.FlagUp != 0, + IPv4: v4, + }) + } + + return out, nil +} + +// Select decides which one or two interfaces to monitor for the given OS. +// See the README's supported matrix for the exact rules. +func Select(goos string, ifaces []Interface, routes []Route, f Flags) ([]Pane, error) { + switch goos { + case "linux": + return selectLinux(ifaces, routes, f) + case "darwin": + return selectDarwin(ifaces, routes, f) + default: + return nil, fmt.Errorf("%w: %q", errUnsupportedOS, goos) + } +} + +// selectLinux keeps today's behavior: if both configured interfaces exist, +// monitor them as two panes; if neither exists, monitor the single +// default-route interface; anything else is an error. +func selectLinux(ifaces []Interface, routes []Route, f Flags) ([]Pane, error) { + haveA := hasInterface(ifaces, f.IfaceA) + haveB := hasInterface(ifaces, f.IfaceB) + + switch { + case haveA && haveB: + return []Pane{ + {Name: f.IfaceA, Label: f.LabelA}, + {Name: f.IfaceB, Label: f.LabelB}, + }, nil + case !haveA && !haveB: + name, err := onlyDefaultRoute(routes) + if err != nil { + return nil, err + } + + return []Pane{{Name: name, Label: singleLabel(f)}}, nil + default: + return nil, fmt.Errorf("%w (%q, %q)", errOneOfPair, f.IfaceA, f.IfaceB) + } +} + +// selectDarwin monitors the physical default-route interface, plus a VPN +// tunnel as the primary pane when one is running. +func selectDarwin(ifaces []Interface, routes []Route, f Flags) ([]Pane, error) { + vpn := findVPN(ifaces, routes) + + phys, err := onlyPhysicalDefaultRoute(routes, vpn) + if err != nil { + return nil, err + } + + if vpn == "" { + return []Pane{{Name: phys, Label: singleLabel(f)}}, nil + } + + return []Pane{ + {Name: vpn, Label: labelOr(f.LabelA, f.LabelASet, "VPN")}, + {Name: phys, Label: labelOr(f.LabelB, f.LabelBSet, "default route")}, + }, nil +} + +// findVPN returns the name of a VPN tunnel interface, or "" if none is +// running. A tunnel counts as a running VPN when it carries a default route, +// or when it is up with a routable (non-link-local) IPv4 address. Idle system +// tunnels have only a link-local IPv6 address and are skipped. +func findVPN(ifaces []Interface, routes []Route) string { + routeIfaces := map[string]bool{} + + for _, r := range routes { + if r.Default { + routeIfaces[r.Iface] = true + } + } + + sorted := append([]Interface(nil), ifaces...) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name }) + + for _, ifi := range sorted { + if !isTunnel(ifi.Name) { + continue + } + + if routeIfaces[ifi.Name] { + return ifi.Name + } + + if ifi.Up && hasRoutableIPv4(ifi) { + return ifi.Name + } + } + + return "" +} + +// onlyDefaultRoute returns the single default-route interface, or an error if +// there is not exactly one. +func onlyDefaultRoute(routes []Route) (string, error) { + names := defaultRouteIfaces(routes, "") + + switch len(names) { + case 1: + return names[0], nil + case 0: + return "", errNoDefaultRoute + default: + return "", fmt.Errorf("%w (%s)", + errManyDefaultRoutes, strings.Join(names, ", ")) + } +} + +// onlyPhysicalDefaultRoute returns the single non-tunnel default-route +// interface (ignoring the VPN), or an error if there is not exactly one. +func onlyPhysicalDefaultRoute(routes []Route, vpn string) (string, error) { + names := defaultRouteIfaces(routes, vpn) + + switch len(names) { + case 1: + return names[0], nil + case 0: + return "", errNoPhysRoute + default: + return "", fmt.Errorf("%w (%s)", + errManyPhysRoutes, strings.Join(names, ", ")) + } +} + +// defaultRouteIfaces returns the sorted, unique interface names that carry a +// default route, excluding the named VPN interface and any other tunnel. +func defaultRouteIfaces(routes []Route, vpn string) []string { + seen := map[string]bool{} + + var names []string + + for _, r := range routes { + if !r.Default || r.Iface == vpn || isTunnel(r.Iface) || seen[r.Iface] { + continue + } + + seen[r.Iface] = true + names = append(names, r.Iface) + } + + sort.Strings(names) + + return names +} + +// hasInterface reports whether an interface with the given name exists. +func hasInterface(ifaces []Interface, name string) bool { + for _, ifi := range ifaces { + if ifi.Name == name { + return true + } + } + + return false +} + +// isTunnel reports whether the interface name is a macOS userspace tunnel +// (utunN), which is what Mullvad and other WireGuard/OpenVPN clients use. +func isTunnel(name string) bool { + return strings.HasPrefix(name, "utun") +} + +// hasRoutableIPv4 reports whether the interface has an IPv4 address that is +// neither loopback nor link-local. +func hasRoutableIPv4(ifi Interface) bool { + for _, s := range ifi.IPv4 { + ip := net.ParseIP(s) + if ip == nil || ip.IsLoopback() || ip.IsLinkLocalUnicast() { + continue + } + + return true + } + + return false +} + +// singleLabel is the label for a lone pane: the explicit --labelA if given, +// otherwise a plain description. +func singleLabel(f Flags) string { + return labelOr(f.LabelA, f.LabelASet, "default route") +} + +// labelOr returns value when it was set explicitly, otherwise fallback. +func labelOr(value string, set bool, fallback string) string { + if set { + return value + } + + return fallback +} + +// parseIPRoute reads `ip -4 route show default` output. Every printed line is +// a default route. +func parseIPRoute(out string) []Route { + var routes []Route + + for _, line := range strings.Split(out, "\n") { + fields := strings.Fields(line) + if len(fields) == 0 || fields[0] != "default" { + continue + } + + r := Route{Default: true} + + for i := range len(fields) - 1 { + switch fields[i] { + case "dev": + r.Iface = fields[i+1] + case "via": + r.Gateway = fields[i+1] + } + } + + if r.Iface != "" { + routes = append(routes, r) + } + } + + return routes +} + +// parseProcNetRoute reads /proc/net/route. A default route has destination +// 00000000; the gateway is a little-endian hex IPv4 address. +func parseProcNetRoute(out string) []Route { + var routes []Route + + for i, line := range strings.Split(out, "\n") { + if i == 0 { // column header + continue + } + + fields := strings.Fields(line) + if len(fields) < 3 || fields[1] != "00000000" { + continue + } + + routes = append(routes, Route{ + Iface: fields[0], + Gateway: hexToIP(fields[2]), + Default: true, + }) + } + + return routes +} + +// parseNetstat reads `netstat -rn -f inet` output (macOS). Rows whose +// destination is "default" are default routes; the Netif column (field 4) +// names the interface. +func parseNetstat(out string) []Route { + var routes []Route + + for _, line := range strings.Split(out, "\n") { + fields := strings.Fields(line) + if len(fields) < 4 || fields[0] != "default" { + continue + } + + routes = append(routes, Route{ + Iface: fields[3], + Gateway: fields[1], + Default: true, + }) + } + + return routes +} + +// hexToIP converts a little-endian hex IPv4 address (as found in +// /proc/net/route) to dotted-quad form. +func hexToIP(h string) string { + b, err := hex.DecodeString(h) + if err != nil || len(b) != net.IPv4len { + return "" + } + + // /proc/net/route stores the address little-endian. + return net.IPv4(b[3], b[2], b[1], b[0]).String() +} diff --git a/internal/netdetect/netdetect_test.go b/internal/netdetect/netdetect_test.go new file mode 100644 index 0000000..83d421e --- /dev/null +++ b/internal/netdetect/netdetect_test.go @@ -0,0 +1,310 @@ +package netdetect_test + +import ( + "reflect" + "testing" + + "git.eeqj.de/sneak/rtnetmon/internal/netdetect" +) + +// Values reused across the selection tests. +const ( + osLinux = "linux" + osDarwin = "darwin" + + ifaceGu0 = "gu0" + ifaceBackhaul = "backhaul0" + ifaceEth0 = "eth0" + ifaceWlan0 = "wlan0" + ifaceEn0 = "en0" + ifaceUtun4 = "utun4" + + labelGu = "gu LAN - VPN outbound" + labelCox = "Cox cable direct" + labelDefault = "default route" + + addrEn0 = "192.168.1.20" +) + +// linuxFlags describes the Linux bridge interfaces rtnetmon was built for. +func linuxFlags() netdetect.Flags { + return netdetect.Flags{ + IfaceA: ifaceGu0, LabelA: labelGu, + IfaceB: ifaceBackhaul, LabelB: labelCox, + } +} + +// selectCase is one Select scenario with fake interfaces and routes. +type selectCase struct { + name string + goos string + ifaces []netdetect.Interface + routes []netdetect.Route + flags netdetect.Flags + want []netdetect.Pane + wantErr bool +} + +// runSelectCases runs each case as a parallel subtest. +func runSelectCases(t *testing.T, cases []selectCase) { + t.Helper() + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := netdetect.Select(tt.goos, tt.ifaces, tt.routes, tt.flags) + if tt.wantErr { + if err == nil { + t.Fatalf("Select() expected error, got panes %v", got) + } + + return + } + + if err != nil { + t.Fatalf("Select() unexpected error: %v", err) + } + + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Select() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSelectLinuxPanes(t *testing.T) { + t.Parallel() + + runSelectCases(t, []selectCase{ + { + name: "both bridge interfaces present", + goos: osLinux, + ifaces: []netdetect.Interface{ + {Name: ifaceGu0, Up: true}, + {Name: ifaceBackhaul, Up: true}, + {Name: ifaceEth0, Up: true}, + }, + routes: []netdetect.Route{{Iface: ifaceEth0, Default: true}}, + flags: linuxFlags(), + want: []netdetect.Pane{ + {Name: ifaceGu0, Label: labelGu}, + {Name: ifaceBackhaul, Label: labelCox}, + }, + }, + { + name: "no bridge interfaces, single default route", + goos: osLinux, + ifaces: []netdetect.Interface{ + {Name: ifaceEth0, Up: true}, + {Name: "lo", Up: true}, + }, + routes: []netdetect.Route{{Iface: ifaceEth0, Default: true}}, + flags: linuxFlags(), + want: []netdetect.Pane{{Name: ifaceEth0, Label: labelDefault}}, + }, + { + name: "single default route keeps explicit label", + goos: osLinux, + ifaces: []netdetect.Interface{{Name: ifaceWlan0, Up: true}}, + routes: []netdetect.Route{{Iface: ifaceWlan0, Default: true}}, + flags: netdetect.Flags{ + IfaceA: ifaceGu0, LabelA: "Home WiFi", LabelASet: true, + IfaceB: ifaceBackhaul, LabelB: labelCox, + }, + want: []netdetect.Pane{{Name: ifaceWlan0, Label: "Home WiFi"}}, + }, + }) +} + +func TestSelectLinuxErrors(t *testing.T) { + t.Parallel() + + runSelectCases(t, []selectCase{ + { + name: "only one bridge interface present", + goos: osLinux, + ifaces: []netdetect.Interface{ + {Name: ifaceGu0, Up: true}, + {Name: ifaceEth0, Up: true}, + }, + routes: []netdetect.Route{{Iface: ifaceEth0, Default: true}}, + flags: linuxFlags(), + wantErr: true, + }, + { + name: "no bridge, no default route", + goos: osLinux, + ifaces: []netdetect.Interface{{Name: ifaceEth0, Up: true}}, + routes: nil, + flags: linuxFlags(), + wantErr: true, + }, + { + name: "no bridge, multiple default routes", + goos: osLinux, + ifaces: []netdetect.Interface{ + {Name: ifaceEth0, Up: true}, + {Name: "eth1", Up: true}, + }, + routes: []netdetect.Route{ + {Iface: ifaceEth0, Default: true}, + {Iface: "eth1", Default: true}, + }, + flags: linuxFlags(), + wantErr: true, + }, + }) +} + +func TestSelectDarwinPanes(t *testing.T) { + t.Parallel() + + runSelectCases(t, []selectCase{ + { + name: "vpn tunnel plus physical default route", + goos: osDarwin, + ifaces: []netdetect.Interface{ + {Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}}, + {Name: ifaceUtun4, Up: true, IPv4: []string{"10.64.0.2"}}, + {Name: "utun0", Up: true, IPv4: nil}, + }, + routes: []netdetect.Route{ + {Iface: ifaceUtun4, Default: true}, + {Iface: ifaceEn0, Default: true}, + }, + flags: linuxFlags(), + want: []netdetect.Pane{ + {Name: ifaceUtun4, Label: "VPN"}, + {Name: ifaceEn0, Label: labelDefault}, + }, + }, + { + name: "vpn detected by routable address without its own route", + goos: osDarwin, + ifaces: []netdetect.Interface{ + {Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}}, + {Name: "utun6", Up: true, IPv4: []string{"10.2.0.2"}}, + }, + routes: []netdetect.Route{{Iface: ifaceEn0, Default: true}}, + flags: linuxFlags(), + want: []netdetect.Pane{ + {Name: "utun6", Label: "VPN"}, + {Name: ifaceEn0, Label: labelDefault}, + }, + }, + { + name: "vpn pane honors explicit labels", + goos: osDarwin, + ifaces: []netdetect.Interface{ + {Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}}, + {Name: ifaceUtun4, Up: true, IPv4: []string{"10.64.0.2"}}, + }, + routes: []netdetect.Route{ + {Iface: ifaceUtun4, Default: true}, + {Iface: ifaceEn0, Default: true}, + }, + flags: netdetect.Flags{ + LabelA: "Mullvad", LabelASet: true, + LabelB: "Fiber", LabelBSet: true, + }, + want: []netdetect.Pane{ + {Name: ifaceUtun4, Label: "Mullvad"}, + {Name: ifaceEn0, Label: "Fiber"}, + }, + }, + }) +} + +func TestSelectDarwinSingleAndErrors(t *testing.T) { + t.Parallel() + + runSelectCases(t, []selectCase{ + { + name: "no vpn, single physical default route", + goos: osDarwin, + ifaces: []netdetect.Interface{ + {Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}}, + {Name: "utun0", Up: true, IPv4: nil}, + {Name: "utun1", Up: true, IPv4: []string{"169.254.1.1"}}, + }, + routes: []netdetect.Route{{Iface: ifaceEn0, Default: true}}, + flags: linuxFlags(), + want: []netdetect.Pane{{Name: ifaceEn0, Label: labelDefault}}, + }, + { + name: "no default route", + goos: osDarwin, + ifaces: []netdetect.Interface{ + {Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}}, + }, + routes: nil, + flags: linuxFlags(), + wantErr: true, + }, + { + name: "two physical default routes", + goos: osDarwin, + ifaces: []netdetect.Interface{ + {Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}}, + {Name: "en1", Up: true, IPv4: []string{"192.168.2.20"}}, + }, + routes: []netdetect.Route{ + {Iface: ifaceEn0, Default: true}, + {Iface: "en1", Default: true}, + }, + flags: linuxFlags(), + wantErr: true, + }, + { + name: "unsupported operating system", + goos: "windows", + wantErr: true, + }, + }) +} + +func TestParseIPRoute(t *testing.T) { + t.Parallel() + + out := "default via 192.168.1.1 dev eth0 proto dhcp metric 100\n" + got := netdetect.ParseIPRoute(out) + want := []netdetect.Route{{Iface: ifaceEth0, Gateway: "192.168.1.1", Default: true}} + + if !reflect.DeepEqual(got, want) { + t.Errorf("ParseIPRoute() = %v, want %v", got, want) + } +} + +func TestParseProcNetRoute(t *testing.T) { + t.Parallel() + + out := "Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\n" + + "eth0\t00000000\t0102A8C0\t0003\t0\t0\t100\t00000000\n" + + "eth0\t0002A8C0\t00000000\t0001\t0\t0\t0\t00FFFFFF\n" + got := netdetect.ParseProcNetRoute(out) + want := []netdetect.Route{{Iface: ifaceEth0, Gateway: "192.168.2.1", Default: true}} + + if !reflect.DeepEqual(got, want) { + t.Errorf("ParseProcNetRoute() = %v, want %v", got, want) + } +} + +func TestParseNetstat(t *testing.T) { + t.Parallel() + + out := "Routing tables\n\nInternet:\n" + + "Destination Gateway Flags Netif Expire\n" + + "default 10.0.0.1 UGScg en0\n" + + "default link#15 UCSg utun4\n" + + "127.0.0.1 127.0.0.1 UH lo0\n" + got := netdetect.ParseNetstat(out) + want := []netdetect.Route{ + {Iface: ifaceEn0, Gateway: "10.0.0.1", Default: true}, + {Iface: ifaceUtun4, Gateway: "link#15", Default: true}, + } + + if !reflect.DeepEqual(got, want) { + t.Errorf("ParseNetstat() = %v, want %v", got, want) + } +} diff --git a/internal/netdetect/routes_darwin.go b/internal/netdetect/routes_darwin.go new file mode 100644 index 0000000..5027129 --- /dev/null +++ b/internal/netdetect/routes_darwin.go @@ -0,0 +1,27 @@ +//go:build darwin + +package netdetect + +import ( + "context" + "fmt" + "os/exec" + "time" +) + +// routeQueryTimeout bounds the external route-table query. +const routeQueryTimeout = 2 * time.Second + +// DefaultRoutes returns the host's IPv4 default routes, read from the macOS +// routing table. +func DefaultRoutes() ([]Route, error) { + ctx, cancel := context.WithTimeout(context.Background(), routeQueryTimeout) + defer cancel() + + out, err := exec.CommandContext(ctx, "netstat", "-rn", "-f", "inet").Output() + if err != nil { + return nil, fmt.Errorf("running netstat: %w", err) + } + + return parseNetstat(string(out)), nil +} diff --git a/internal/netdetect/routes_linux.go b/internal/netdetect/routes_linux.go new file mode 100644 index 0000000..b1b35bb --- /dev/null +++ b/internal/netdetect/routes_linux.go @@ -0,0 +1,36 @@ +//go:build linux + +package netdetect + +import ( + "context" + "fmt" + "os" + "os/exec" + "time" +) + +// routeQueryTimeout bounds the external route-table query. +const routeQueryTimeout = 2 * time.Second + +// DefaultRoutes returns the host's IPv4 default routes. It prefers the `ip` +// command and falls back to reading /proc/net/route. +func DefaultRoutes() ([]Route, error) { + ctx, cancel := context.WithTimeout(context.Background(), routeQueryTimeout) + defer cancel() + + out, err := exec.CommandContext(ctx, + "ip", "-4", "route", "show", "default").Output() + if err == nil { + if routes := parseIPRoute(string(out)); len(routes) > 0 { + return routes, nil + } + } + + data, err := os.ReadFile("/proc/net/route") + if err != nil { + return nil, fmt.Errorf("reading /proc/net/route: %w", err) + } + + return parseProcNetRoute(string(data)), nil +} From 769511dec26ea01f93a88018cffb2c0da266978e Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 22 Sep 2026 20:56:01 +0200 Subject: [PATCH 3/9] Add Starlink status lines for the physical gateway pane (closes #3) (#7) Detection is a TCP connect to the dish endpoint bound to the physical interface; once a dish answers, get_status is read every 5s and two status lines render under the physical pane, red on disconnect or alert. No dish: nothing drawn, no RPC. Minimal vendored proto bindings for get_status only. Independent review passed (PR 7 comment 100012). Model: opus-4-8 (implementation and review); model: claude-fable-5 (merge) --- README.md | 19 + go.mod | 4 + go.sum | 8 + internal/cli/root.go | 5 + internal/monitor/export_test.go | 45 ++ internal/monitor/monitor.go | 10 + internal/monitor/starlink.go | 166 ++++++ internal/monitor/starlink_test.go | 141 +++++ internal/monitor/ui.go | 5 + internal/starlink/client.go | 129 +++++ internal/starlink/pb/starlink.pb.go | 629 +++++++++++++++++++++++ internal/starlink/pb/starlink.proto | 74 +++ internal/starlink/pb/starlink_grpc.pb.go | 140 +++++ internal/starlink/starlink.go | 81 +++ internal/starlink/starlink_test.go | 79 +++ 15 files changed, 1535 insertions(+) create mode 100644 internal/monitor/starlink.go create mode 100644 internal/monitor/starlink_test.go create mode 100644 internal/starlink/client.go create mode 100644 internal/starlink/pb/starlink.pb.go create mode 100644 internal/starlink/pb/starlink.proto create mode 100644 internal/starlink/pb/starlink_grpc.pb.go create mode 100644 internal/starlink/starlink.go create mode 100644 internal/starlink/starlink_test.go diff --git a/README.md b/README.md index 783b65f..bf24aa7 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,21 @@ Anything outside this matrix — on Linux, only one of the named pair present, o 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 ```bash @@ -107,6 +122,10 @@ rtnetmon/ │ │ ├── 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) diff --git a/go.mod b/go.mod index 135c5bb..b4f8747 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,8 @@ require ( github.com/spf13/cobra v1.8.0 github.com/spf13/viper v1.18.2 golang.org/x/sys v0.29.0 + google.golang.org/grpc v1.67.1 + google.golang.org/protobuf v1.36.6 ) require ( @@ -30,8 +32,10 @@ require ( go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/net v0.28.0 // indirect golang.org/x/term v0.28.0 // indirect golang.org/x/text v0.21.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index ea7093a..2312c7a 100644 --- a/go.sum +++ b/go.sum @@ -91,6 +91,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= +golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -137,6 +139,12 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 h1:e7S5W7MGGLaSu8j3YjdezkZ+m1/Nm0uRVRMEMGk26Xs= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= +google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= +google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/cli/root.go b/internal/cli/root.go index 68d70d4..3a08014 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -98,6 +98,11 @@ func runMonitor(cmd *cobra.Command, cfg *config) error { mon := monitor.NewMonitor(specs, cfg.LogFile) + // The non-VPN physical gateway is the last pane: pane B when there are + // two, the only pane when there is one. That is where a Starlink dish, + // if present upstream, is detected and its status shown. + mon.EnableStarlink(specs[len(specs)-1].Name) + for _, host := range cfg.Hosts { mon.AddReachabilityHost(host) } diff --git a/internal/monitor/export_test.go b/internal/monitor/export_test.go index 5fd3294..f330e5a 100644 --- a/internal/monitor/export_test.go +++ b/internal/monitor/export_test.go @@ -1,7 +1,52 @@ package monitor +import ( + "context" + + "git.eeqj.de/sneak/rtnetmon/internal/starlink" +) + // Test-only accessors exposing unexported state for white-box assertions. +// SetStarlinkClient injects a Starlink client for iface, bypassing the real +// gRPC dialer so the loop can be driven with a fake. +func (m *Monitor) SetStarlinkClient(iface string, c starlink.Client) { + for _, st := range m.interfaces { + if st.Name == iface { + m.slPane = st + m.slState = &starlinkState{} + m.slClient = c + + return + } + } +} + +// StarlinkEnabled reports whether a Starlink client is configured. +func (m *Monitor) StarlinkEnabled() bool { return m.slClient != nil } + +// StarlinkPaneName returns the name of the pane the Starlink lines attach +// to, or "" if none. +func (m *Monitor) StarlinkPaneName() string { + if m.slPane == nil { + return "" + } + + return m.slPane.Name +} + +// StarlinkProbeStep runs one detection step. +func (m *Monitor) StarlinkProbeStep(ctx context.Context) { m.starlinkProbe(ctx) } + +// StarlinkRefreshStep runs one status-refresh step. +func (m *Monitor) StarlinkRefreshStep(ctx context.Context) { m.starlinkRefresh(ctx) } + +// StarlinkSnapshot returns the current Starlink display state: detected, +// have-status, and the latest status. +func (m *Monitor) StarlinkSnapshot() (bool, bool, starlink.Status) { + return m.slState.snapshot() +} + // ReachabilityHosts returns the configured reachability hosts. func (m *Monitor) ReachabilityHosts() []string { return m.reachabilityHosts } diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index 83fe1e5..841d759 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -19,6 +19,8 @@ import ( "time" tcell "github.com/gdamore/tcell/v2" + + "git.eeqj.de/sneak/rtnetmon/internal/starlink" ) // Meter glyphs used to render the ASCII loss meter. @@ -90,6 +92,12 @@ type Monitor struct { // Interfaces to monitor (one or two) interfaces []*InterfaceStatus + // Starlink status for the physical (non-VPN gateway) pane. All three + // are nil unless EnableStarlink was called for a monitored interface. + slClient starlink.Client + slState *starlinkState + slPane *InterfaceStatus + // Logging logFile string @@ -208,6 +216,8 @@ func (m *Monitor) Run(ctx context.Context) error { go m.tcpLoop(ctx, st, tcpHosts) } + go m.starlinkLoop(ctx) + m.logf("Starting UI loop") m.uiLoop(ctx) m.logf("UI loop exited, monitor ending") diff --git a/internal/monitor/starlink.go b/internal/monitor/starlink.go new file mode 100644 index 0000000..f25596a --- /dev/null +++ b/internal/monitor/starlink.go @@ -0,0 +1,166 @@ +package monitor + +import ( + "context" + "net" + "sync" + "time" + + tcell "github.com/gdamore/tcell/v2" + + "git.eeqj.de/sneak/rtnetmon/internal/starlink" +) + +// Starlink probe and status cadence: detect once a minute until a dish +// answers, then refresh the status every few seconds. +const ( + starlinkProbePeriod = time.Minute + starlinkStatusPeriod = 5 * time.Second +) + +// starlinkState holds the latest dish status for the physical pane. Nothing +// is drawn until a dish first answers, so an absent dish adds no lines. +type starlinkState struct { + mu sync.RWMutex + detected bool + haveStatus bool + status starlink.Status +} + +// snapshot returns whether a dish was detected, whether a status has been +// read, and the latest status, all under the read lock. +func (s *starlinkState) snapshot() (bool, bool, starlink.Status) { + s.mu.RLock() + defer s.mu.RUnlock() + + return s.detected, s.haveStatus, s.status +} + +// EnableStarlink turns on Starlink detection for the named interface, which +// must be the non-VPN physical gateway pane. It is a no-op if no monitored +// interface has that name. +func (m *Monitor) EnableStarlink(iface string) { + for _, st := range m.interfaces { + if st.Name == iface { + m.slPane = st + m.slState = &starlinkState{} + m.slClient = starlink.NewClient(m.starlinkDialer(iface)) + + return + } + } +} + +// starlinkDialer returns a dialer that binds to iface exactly as the TCP +// latency probes do, so the dish is reached over the physical interface. +func (m *Monitor) starlinkDialer(iface string) starlink.DialFunc { + return func(ctx context.Context, addr string) (net.Conn, error) { + la, err := localAddr(iface) + if err != nil { + return nil, err + } + + d := net.Dialer{LocalAddr: la, Control: bindControl(iface)} + + return d.DialContext(ctx, "tcp", addr) + } +} + +// starlinkLoop probes for a dish and refreshes its status until the context +// is cancelled. It does nothing when Starlink was not enabled. +func (m *Monitor) starlinkLoop(ctx context.Context) { + if m.slClient == nil { + return + } + + m.logf("Starting Starlink monitoring for %s", m.slPane.Name) + m.starlinkProbe(ctx) + + probe := time.NewTicker(starlinkProbePeriod) + status := time.NewTicker(starlinkStatusPeriod) + + defer probe.Stop() + defer status.Stop() + + for { + select { + case <-ctx.Done(): + m.logf("Stopping Starlink monitoring for %s", m.slPane.Name) + + return + case <-probe.C: + m.starlinkProbe(ctx) + case <-status.C: + m.starlinkRefresh(ctx) + } + } +} + +// starlinkProbe detects the dish; once detected it stays detected, and a +// first status is fetched immediately. +func (m *Monitor) starlinkProbe(ctx context.Context) { + if detected, _, _ := m.slState.snapshot(); detected { + return + } + + if !m.slClient.Probe(ctx) { + return + } + + m.slState.mu.Lock() + m.slState.detected = true + m.slState.mu.Unlock() + + m.notifyUI() + m.starlinkRefresh(ctx) +} + +// starlinkRefresh fetches status only once a dish has been detected, so an +// absent dish produces no status traffic. +func (m *Monitor) starlinkRefresh(ctx context.Context) { + if detected, _, _ := m.slState.snapshot(); !detected { + return + } + + st, err := m.slClient.Status(ctx) + if err != nil { + m.logf("Starlink status error: %v", err) + + return + } + + m.slState.mu.Lock() + m.slState.status = st + m.slState.haveStatus = true + m.slState.mu.Unlock() + + m.notifyUI() +} + +// drawStarlink draws the two dish status lines under the physical pane. It +// draws nothing until a dish has answered. +func (m *Monitor) drawStarlink(scr tcell.Screen, y int) int { + detected, haveStatus, st := m.slState.snapshot() + if !detected { + return y + } + + if !haveStatus { + Put(scr, colLeft, y, "Starlink: detected, status unavailable", styleBrightRed()) + + return y + lineStep + } + + line1, line2, alert := starlink.Render(st) + + style := tcell.StyleDefault + if alert { + style = styleBrightRed() + } + + Put(scr, colLeft, y, line1, style) + y += lineStep + Put(scr, colLeft, y, line2, style) + + return y + lineStep +} diff --git a/internal/monitor/starlink_test.go b/internal/monitor/starlink_test.go new file mode 100644 index 0000000..e3f8145 --- /dev/null +++ b/internal/monitor/starlink_test.go @@ -0,0 +1,141 @@ +package monitor_test + +import ( + "context" + "sync" + "testing" + "time" + + "git.eeqj.de/sneak/rtnetmon/internal/monitor" + "git.eeqj.de/sneak/rtnetmon/internal/starlink" +) + +// fakeDish is a Starlink client for tests: no dish, no network. It records +// how often each method is called and returns programmed results. +type fakeDish struct { + mu sync.Mutex + probeOK bool + status starlink.Status + statusErr error + probeCalls int + statusCalls int +} + +func (f *fakeDish) Probe(_ context.Context) bool { + f.mu.Lock() + defer f.mu.Unlock() + + f.probeCalls++ + + return f.probeOK +} + +func (f *fakeDish) Status(_ context.Context) (starlink.Status, error) { + f.mu.Lock() + defer f.mu.Unlock() + + f.statusCalls++ + + return f.status, f.statusErr +} + +func (f *fakeDish) counts() (int, int) { + f.mu.Lock() + defer f.mu.Unlock() + + return f.probeCalls, f.statusCalls +} + +func newTwoPaneMonitor() *monitor.Monitor { + return monitor.NewMonitor([]monitor.IfaceSpec{ + {Name: ifaceTest0, Label: "A"}, + {Name: ifaceTest1, Label: "B"}, + }, "") +} + +func TestEnableStarlinkSelectsPane(t *testing.T) { + t.Parallel() + + mon := newTwoPaneMonitor() + mon.EnableStarlink(ifaceTest1) + + if !mon.StarlinkEnabled() { + t.Fatal("StarlinkEnabled = false, want true") + } + + if got := mon.StarlinkPaneName(); got != ifaceTest1 { + t.Errorf("StarlinkPaneName = %q, want %q", got, ifaceTest1) + } +} + +func TestEnableStarlinkUnknownInterface(t *testing.T) { + t.Parallel() + + mon := newTwoPaneMonitor() + mon.EnableStarlink("does-not-exist") + + if mon.StarlinkEnabled() { + t.Error("StarlinkEnabled = true for an unknown interface, want false") + } +} + +// TestStarlinkNoDishNoStatus verifies that when no dish answers, no status +// is ever fetched and nothing is marked detected. +func TestStarlinkNoDishNoStatus(t *testing.T) { + t.Parallel() + + mon := newTwoPaneMonitor() + fake := &fakeDish{probeOK: false} + mon.SetStarlinkClient(ifaceTest1, fake) + + ctx := context.Background() + mon.StarlinkProbeStep(ctx) + mon.StarlinkRefreshStep(ctx) + + detected, haveStatus, _ := mon.StarlinkSnapshot() + if detected || haveStatus { + t.Errorf("detected=%v haveStatus=%v, want both false", detected, haveStatus) + } + + if _, status := fake.counts(); status != 0 { + t.Errorf("status calls = %d, want 0 (no probing noise)", status) + } +} + +// TestStarlinkDetectedFetchesStatus verifies that once a dish answers, +// detection triggers a status fetch and later refreshes fetch again. +func TestStarlinkDetectedFetchesStatus(t *testing.T) { + t.Parallel() + + mon := newTwoPaneMonitor() + want := starlink.Status{ + State: "CONNECTED", + Uptime: 2 * time.Hour, + DownlinkMbps: 100, + } + fake := &fakeDish{probeOK: true, status: want} + mon.SetStarlinkClient(ifaceTest1, fake) + + ctx := context.Background() + mon.StarlinkProbeStep(ctx) + + detected, haveStatus, got := mon.StarlinkSnapshot() + if !detected || !haveStatus { + t.Fatalf("detected=%v haveStatus=%v, want both true", detected, haveStatus) + } + + if got != want { + t.Errorf("status = %+v, want %+v", got, want) + } + + mon.StarlinkRefreshStep(ctx) + + probe, status := fake.counts() + if probe != 1 { + t.Errorf("probe calls = %d, want 1", probe) + } + + if status != 2 { + t.Errorf("status calls = %d, want 2", status) + } +} diff --git a/internal/monitor/ui.go b/internal/monitor/ui.go index 088bb77..185fcbe 100644 --- a/internal/monitor/ui.go +++ b/internal/monitor/ui.go @@ -107,6 +107,11 @@ func (m *Monitor) DrawInterface(scr tcell.Screen, y, w int, st *InterfaceStatus) y = m.drawTCPTable(scr, y, st, tcpHosts) y = drawICMPStats(scr, y, st) + // The Starlink lines belong to the physical (non-VPN gateway) pane only. + if st == m.slPane { + y = m.drawStarlink(scr, y) + } + return y } diff --git a/internal/starlink/client.go b/internal/starlink/client.go new file mode 100644 index 0000000..2a5eeaa --- /dev/null +++ b/internal/starlink/client.go @@ -0,0 +1,129 @@ +package starlink + +import ( + "context" + "errors" + "fmt" + "net" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + + pb "git.eeqj.de/sneak/rtnetmon/internal/starlink/pb" +) + +// errNoDishStatus is returned when the dish answers but the response +// carries no dish status. +var errNoDishStatus = errors.New("no dish status in response") + +// Per-call deadlines so a silent dish never wedges the monitor loop. +const ( + probeTimeout = 2 * time.Second + statusTimeout = 3 * time.Second +) + +// DialFunc opens a TCP connection to addr bound to a chosen interface. The +// monitor supplies one that binds the same way its TCP latency probes do, +// so the dish is reached over the physical (non-VPN) interface rather than +// a VPN tunnel that may hold the default route. +type DialFunc func(ctx context.Context, addr string) (net.Conn, error) + +// GRPCClient is the real Client: it reaches a dish at Address over whatever +// interface dial binds to. +type GRPCClient struct { + dial DialFunc +} + +// NewClient returns a client that talks to a dish at Address, dialing with +// dial so the connection leaves the intended interface. +func NewClient(dial DialFunc) *GRPCClient { + return &GRPCClient{dial: dial} +} + +// Probe connects to the dish's endpoint and closes it. A successful TCP +// connect over the bound interface is the detection signal. +func (c *GRPCClient) Probe(ctx context.Context) bool { + ctx, cancel := context.WithTimeout(ctx, probeTimeout) + defer cancel() + + conn, err := c.dial(ctx, Address) + if err != nil { + return false + } + + _ = conn.Close() + + return true +} + +// Status calls the dish's get_status RPC and reduces the response to the +// fields rtnetmon shows. +func (c *GRPCClient) Status(ctx context.Context) (Status, error) { + ctx, cancel := context.WithTimeout(ctx, statusTimeout) + defer cancel() + + // passthrough:/// hands Address straight to our dialer, with no name + // resolution, so the connection is made over the bound interface. + conn, err := grpc.NewClient("passthrough:///"+Address, + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) { + return c.dial(ctx, addr) + }), + ) + if err != nil { + return Status{}, fmt.Errorf("starlink dial: %w", err) + } + defer func() { _ = conn.Close() }() + + resp, err := pb.NewDeviceClient(conn).Handle(ctx, &pb.Request{ + Request: &pb.Request_GetStatus{GetStatus: &pb.GetStatusRequest{}}, + }) + if err != nil { + return Status{}, fmt.Errorf("starlink get_status: %w", err) + } + + dish := resp.GetDishGetStatus() + if dish == nil { + return Status{}, errNoDishStatus + } + + return statusFromDish(dish), nil +} + +// statusFromDish reduces the dish's status message to a Status. The +// generated getters are nil-safe, so absent sub-messages read as zero. +func statusFromDish(d *pb.DishGetStatusResponse) Status { + obstruction := float64(d.GetObstructionStats().GetFractionObstructed()) + //nolint:gosec // G115: dish uptime in seconds never approaches int64 max + uptime := time.Duration(d.GetDeviceState().GetUptimeS()) * time.Second + + return Status{ + State: d.GetState().String(), + Uptime: uptime, + ObstructionPct: obstruction * percentScale, + Alerts: countAlerts(d.GetAlerts()), + PopPingMs: float64(d.GetPopPingLatencyMs()), + PopPingDropPct: float64(d.GetPopPingDropRate()) * percentScale, + DownlinkMbps: float64(d.GetDownlinkThroughputBps()) / bitsPerMegabit, + UplinkMbps: float64(d.GetUplinkThroughputBps()) / bitsPerMegabit, + } +} + +// countAlerts counts the active dish alert flags. +func countAlerts(a *pb.DishAlerts) int { + n := 0 + + for _, on := range []bool{ + a.GetMotorsStuck(), + a.GetThermalShutdown(), + a.GetThermalThrottle(), + a.GetUnexpectedLocation(), + } { + if on { + n++ + } + } + + return n +} diff --git a/internal/starlink/pb/starlink.pb.go b/internal/starlink/pb/starlink.pb.go new file mode 100644 index 0000000..b711a7f --- /dev/null +++ b/internal/starlink/pb/starlink.pb.go @@ -0,0 +1,629 @@ +// A minimal slice of the Starlink dish's local gRPC API: just the one +// unary RPC (SpaceX.API.Device.Device/Handle) and the get_status request +// and dish status response, reduced to the handful of fields rtnetmon +// shows. The proto package name and field numbers must match the dish +// exactly for the wire format to line up; every other field the dish sends +// is ignored as an unknown field. +// +// Field numbers, message names and the DishState enum are transcribed from +// the community-maintained pre-generated bindings at +// github.com/starlink-community/starlink-grpc-go, commit +// 2e89f3d7e3092adecbcc04e9e003e61ed5f7c6a3 (get_status = 1004, +// dish_get_status = 2004). They are not verified against a live dish here. +// +// Regenerate with: +// protoc --go_out=. --go_opt=paths=source_relative \ +// --go-grpc_out=. --go-grpc_opt=paths=source_relative \ +// internal/starlink/pb/starlink.proto + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.6 +// protoc v7.36.2 +// source: internal/starlink/pb/starlink.proto + +package starlinkpb + +import ( + reflect "reflect" + sync "sync" + unsafe "unsafe" + + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type DishState int32 + +const ( + DishState_UNKNOWN DishState = 0 + DishState_CONNECTED DishState = 1 + DishState_SEARCHING DishState = 2 + DishState_BOOTING DishState = 3 +) + +// Enum value maps for DishState. +var ( + DishState_name = map[int32]string{ + 0: "UNKNOWN", + 1: "CONNECTED", + 2: "SEARCHING", + 3: "BOOTING", + } + DishState_value = map[string]int32{ + "UNKNOWN": 0, + "CONNECTED": 1, + "SEARCHING": 2, + "BOOTING": 3, + } +) + +func (x DishState) Enum() *DishState { + p := new(DishState) + *p = x + return p +} + +func (x DishState) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (DishState) Descriptor() protoreflect.EnumDescriptor { + return file_internal_starlink_pb_starlink_proto_enumTypes[0].Descriptor() +} + +func (DishState) Type() protoreflect.EnumType { + return &file_internal_starlink_pb_starlink_proto_enumTypes[0] +} + +func (x DishState) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use DishState.Descriptor instead. +func (DishState) EnumDescriptor() ([]byte, []int) { + return file_internal_starlink_pb_starlink_proto_rawDescGZIP(), []int{0} +} + +type Request struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Request: + // + // *Request_GetStatus + Request isRequest_Request `protobuf_oneof:"request"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Request) Reset() { + *x = Request{} + mi := &file_internal_starlink_pb_starlink_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Request) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Request) ProtoMessage() {} + +func (x *Request) ProtoReflect() protoreflect.Message { + mi := &file_internal_starlink_pb_starlink_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Request.ProtoReflect.Descriptor instead. +func (*Request) Descriptor() ([]byte, []int) { + return file_internal_starlink_pb_starlink_proto_rawDescGZIP(), []int{0} +} + +func (x *Request) GetRequest() isRequest_Request { + if x != nil { + return x.Request + } + return nil +} + +func (x *Request) GetGetStatus() *GetStatusRequest { + if x != nil { + if x, ok := x.Request.(*Request_GetStatus); ok { + return x.GetStatus + } + } + return nil +} + +type isRequest_Request interface { + isRequest_Request() +} + +type Request_GetStatus struct { + GetStatus *GetStatusRequest `protobuf:"bytes,1004,opt,name=get_status,json=getStatus,proto3,oneof"` +} + +func (*Request_GetStatus) isRequest_Request() {} + +type GetStatusRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetStatusRequest) Reset() { + *x = GetStatusRequest{} + mi := &file_internal_starlink_pb_starlink_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetStatusRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetStatusRequest) ProtoMessage() {} + +func (x *GetStatusRequest) ProtoReflect() protoreflect.Message { + mi := &file_internal_starlink_pb_starlink_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetStatusRequest.ProtoReflect.Descriptor instead. +func (*GetStatusRequest) Descriptor() ([]byte, []int) { + return file_internal_starlink_pb_starlink_proto_rawDescGZIP(), []int{1} +} + +type Response struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Response: + // + // *Response_DishGetStatus + Response isResponse_Response `protobuf_oneof:"response"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Response) Reset() { + *x = Response{} + mi := &file_internal_starlink_pb_starlink_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Response) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Response) ProtoMessage() {} + +func (x *Response) ProtoReflect() protoreflect.Message { + mi := &file_internal_starlink_pb_starlink_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Response.ProtoReflect.Descriptor instead. +func (*Response) Descriptor() ([]byte, []int) { + return file_internal_starlink_pb_starlink_proto_rawDescGZIP(), []int{2} +} + +func (x *Response) GetResponse() isResponse_Response { + if x != nil { + return x.Response + } + return nil +} + +func (x *Response) GetDishGetStatus() *DishGetStatusResponse { + if x != nil { + if x, ok := x.Response.(*Response_DishGetStatus); ok { + return x.DishGetStatus + } + } + return nil +} + +type isResponse_Response interface { + isResponse_Response() +} + +type Response_DishGetStatus struct { + DishGetStatus *DishGetStatusResponse `protobuf:"bytes,2004,opt,name=dish_get_status,json=dishGetStatus,proto3,oneof"` +} + +func (*Response_DishGetStatus) isResponse_Response() {} + +type DishGetStatusResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + DeviceState *DeviceState `protobuf:"bytes,2,opt,name=device_state,json=deviceState,proto3" json:"device_state,omitempty"` + ObstructionStats *DishObstructionStats `protobuf:"bytes,1004,opt,name=obstruction_stats,json=obstructionStats,proto3" json:"obstruction_stats,omitempty"` + Alerts *DishAlerts `protobuf:"bytes,1005,opt,name=alerts,proto3" json:"alerts,omitempty"` + State DishState `protobuf:"varint,1006,opt,name=state,proto3,enum=SpaceX.API.Device.DishState" json:"state,omitempty"` + PopPingDropRate float32 `protobuf:"fixed32,1003,opt,name=pop_ping_drop_rate,json=popPingDropRate,proto3" json:"pop_ping_drop_rate,omitempty"` + DownlinkThroughputBps float32 `protobuf:"fixed32,1007,opt,name=downlink_throughput_bps,json=downlinkThroughputBps,proto3" json:"downlink_throughput_bps,omitempty"` + UplinkThroughputBps float32 `protobuf:"fixed32,1008,opt,name=uplink_throughput_bps,json=uplinkThroughputBps,proto3" json:"uplink_throughput_bps,omitempty"` + PopPingLatencyMs float32 `protobuf:"fixed32,1009,opt,name=pop_ping_latency_ms,json=popPingLatencyMs,proto3" json:"pop_ping_latency_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DishGetStatusResponse) Reset() { + *x = DishGetStatusResponse{} + mi := &file_internal_starlink_pb_starlink_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DishGetStatusResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DishGetStatusResponse) ProtoMessage() {} + +func (x *DishGetStatusResponse) ProtoReflect() protoreflect.Message { + mi := &file_internal_starlink_pb_starlink_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DishGetStatusResponse.ProtoReflect.Descriptor instead. +func (*DishGetStatusResponse) Descriptor() ([]byte, []int) { + return file_internal_starlink_pb_starlink_proto_rawDescGZIP(), []int{3} +} + +func (x *DishGetStatusResponse) GetDeviceState() *DeviceState { + if x != nil { + return x.DeviceState + } + return nil +} + +func (x *DishGetStatusResponse) GetObstructionStats() *DishObstructionStats { + if x != nil { + return x.ObstructionStats + } + return nil +} + +func (x *DishGetStatusResponse) GetAlerts() *DishAlerts { + if x != nil { + return x.Alerts + } + return nil +} + +func (x *DishGetStatusResponse) GetState() DishState { + if x != nil { + return x.State + } + return DishState_UNKNOWN +} + +func (x *DishGetStatusResponse) GetPopPingDropRate() float32 { + if x != nil { + return x.PopPingDropRate + } + return 0 +} + +func (x *DishGetStatusResponse) GetDownlinkThroughputBps() float32 { + if x != nil { + return x.DownlinkThroughputBps + } + return 0 +} + +func (x *DishGetStatusResponse) GetUplinkThroughputBps() float32 { + if x != nil { + return x.UplinkThroughputBps + } + return 0 +} + +func (x *DishGetStatusResponse) GetPopPingLatencyMs() float32 { + if x != nil { + return x.PopPingLatencyMs + } + return 0 +} + +type DeviceState struct { + state protoimpl.MessageState `protogen:"open.v1"` + UptimeS uint64 `protobuf:"varint,1,opt,name=uptime_s,json=uptimeS,proto3" json:"uptime_s,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeviceState) Reset() { + *x = DeviceState{} + mi := &file_internal_starlink_pb_starlink_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeviceState) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeviceState) ProtoMessage() {} + +func (x *DeviceState) ProtoReflect() protoreflect.Message { + mi := &file_internal_starlink_pb_starlink_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeviceState.ProtoReflect.Descriptor instead. +func (*DeviceState) Descriptor() ([]byte, []int) { + return file_internal_starlink_pb_starlink_proto_rawDescGZIP(), []int{4} +} + +func (x *DeviceState) GetUptimeS() uint64 { + if x != nil { + return x.UptimeS + } + return 0 +} + +type DishObstructionStats struct { + state protoimpl.MessageState `protogen:"open.v1"` + FractionObstructed float32 `protobuf:"fixed32,1,opt,name=fraction_obstructed,json=fractionObstructed,proto3" json:"fraction_obstructed,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DishObstructionStats) Reset() { + *x = DishObstructionStats{} + mi := &file_internal_starlink_pb_starlink_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DishObstructionStats) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DishObstructionStats) ProtoMessage() {} + +func (x *DishObstructionStats) ProtoReflect() protoreflect.Message { + mi := &file_internal_starlink_pb_starlink_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DishObstructionStats.ProtoReflect.Descriptor instead. +func (*DishObstructionStats) Descriptor() ([]byte, []int) { + return file_internal_starlink_pb_starlink_proto_rawDescGZIP(), []int{5} +} + +func (x *DishObstructionStats) GetFractionObstructed() float32 { + if x != nil { + return x.FractionObstructed + } + return 0 +} + +type DishAlerts struct { + state protoimpl.MessageState `protogen:"open.v1"` + MotorsStuck bool `protobuf:"varint,1,opt,name=motors_stuck,json=motorsStuck,proto3" json:"motors_stuck,omitempty"` + ThermalShutdown bool `protobuf:"varint,2,opt,name=thermal_shutdown,json=thermalShutdown,proto3" json:"thermal_shutdown,omitempty"` + ThermalThrottle bool `protobuf:"varint,3,opt,name=thermal_throttle,json=thermalThrottle,proto3" json:"thermal_throttle,omitempty"` + UnexpectedLocation bool `protobuf:"varint,4,opt,name=unexpected_location,json=unexpectedLocation,proto3" json:"unexpected_location,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DishAlerts) Reset() { + *x = DishAlerts{} + mi := &file_internal_starlink_pb_starlink_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DishAlerts) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DishAlerts) ProtoMessage() {} + +func (x *DishAlerts) ProtoReflect() protoreflect.Message { + mi := &file_internal_starlink_pb_starlink_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DishAlerts.ProtoReflect.Descriptor instead. +func (*DishAlerts) Descriptor() ([]byte, []int) { + return file_internal_starlink_pb_starlink_proto_rawDescGZIP(), []int{6} +} + +func (x *DishAlerts) GetMotorsStuck() bool { + if x != nil { + return x.MotorsStuck + } + return false +} + +func (x *DishAlerts) GetThermalShutdown() bool { + if x != nil { + return x.ThermalShutdown + } + return false +} + +func (x *DishAlerts) GetThermalThrottle() bool { + if x != nil { + return x.ThermalThrottle + } + return false +} + +func (x *DishAlerts) GetUnexpectedLocation() bool { + if x != nil { + return x.UnexpectedLocation + } + return false +} + +var File_internal_starlink_pb_starlink_proto protoreflect.FileDescriptor + +const file_internal_starlink_pb_starlink_proto_rawDesc = "" + + "\n" + + "#internal/starlink/pb/starlink.proto\x12\x11SpaceX.API.Device\"[\n" + + "\aRequest\x12E\n" + + "\n" + + "get_status\x18\xec\a \x01(\v2#.SpaceX.API.Device.GetStatusRequestH\x00R\tgetStatusB\t\n" + + "\arequest\"\x12\n" + + "\x10GetStatusRequest\"k\n" + + "\bResponse\x12S\n" + + "\x0fdish_get_status\x18\xd4\x0f \x01(\v2(.SpaceX.API.Device.DishGetStatusResponseH\x00R\rdishGetStatusB\n" + + "\n" + + "\bresponse\"\xea\x03\n" + + "\x15DishGetStatusResponse\x12A\n" + + "\fdevice_state\x18\x02 \x01(\v2\x1e.SpaceX.API.Device.DeviceStateR\vdeviceState\x12U\n" + + "\x11obstruction_stats\x18\xec\a \x01(\v2'.SpaceX.API.Device.DishObstructionStatsR\x10obstructionStats\x126\n" + + "\x06alerts\x18\xed\a \x01(\v2\x1d.SpaceX.API.Device.DishAlertsR\x06alerts\x123\n" + + "\x05state\x18\xee\a \x01(\x0e2\x1c.SpaceX.API.Device.DishStateR\x05state\x12,\n" + + "\x12pop_ping_drop_rate\x18\xeb\a \x01(\x02R\x0fpopPingDropRate\x127\n" + + "\x17downlink_throughput_bps\x18\xef\a \x01(\x02R\x15downlinkThroughputBps\x123\n" + + "\x15uplink_throughput_bps\x18\xf0\a \x01(\x02R\x13uplinkThroughputBps\x12.\n" + + "\x13pop_ping_latency_ms\x18\xf1\a \x01(\x02R\x10popPingLatencyMs\"(\n" + + "\vDeviceState\x12\x19\n" + + "\buptime_s\x18\x01 \x01(\x04R\auptimeS\"G\n" + + "\x14DishObstructionStats\x12/\n" + + "\x13fraction_obstructed\x18\x01 \x01(\x02R\x12fractionObstructed\"\xb6\x01\n" + + "\n" + + "DishAlerts\x12!\n" + + "\fmotors_stuck\x18\x01 \x01(\bR\vmotorsStuck\x12)\n" + + "\x10thermal_shutdown\x18\x02 \x01(\bR\x0fthermalShutdown\x12)\n" + + "\x10thermal_throttle\x18\x03 \x01(\bR\x0fthermalThrottle\x12/\n" + + "\x13unexpected_location\x18\x04 \x01(\bR\x12unexpectedLocation*C\n" + + "\tDishState\x12\v\n" + + "\aUNKNOWN\x10\x00\x12\r\n" + + "\tCONNECTED\x10\x01\x12\r\n" + + "\tSEARCHING\x10\x02\x12\v\n" + + "\aBOOTING\x10\x032K\n" + + "\x06Device\x12A\n" + + "\x06Handle\x12\x1a.SpaceX.API.Device.Request\x1a\x1b.SpaceX.API.Device.ResponseB SpaceX.API.Device.GetStatusRequest + 4, // 1: SpaceX.API.Device.Response.dish_get_status:type_name -> SpaceX.API.Device.DishGetStatusResponse + 5, // 2: SpaceX.API.Device.DishGetStatusResponse.device_state:type_name -> SpaceX.API.Device.DeviceState + 6, // 3: SpaceX.API.Device.DishGetStatusResponse.obstruction_stats:type_name -> SpaceX.API.Device.DishObstructionStats + 7, // 4: SpaceX.API.Device.DishGetStatusResponse.alerts:type_name -> SpaceX.API.Device.DishAlerts + 0, // 5: SpaceX.API.Device.DishGetStatusResponse.state:type_name -> SpaceX.API.Device.DishState + 1, // 6: SpaceX.API.Device.Device.Handle:input_type -> SpaceX.API.Device.Request + 3, // 7: SpaceX.API.Device.Device.Handle:output_type -> SpaceX.API.Device.Response + 7, // [7:8] is the sub-list for method output_type + 6, // [6:7] is the sub-list for method input_type + 6, // [6:6] is the sub-list for extension type_name + 6, // [6:6] is the sub-list for extension extendee + 0, // [0:6] is the sub-list for field type_name +} + +func init() { file_internal_starlink_pb_starlink_proto_init() } +func file_internal_starlink_pb_starlink_proto_init() { + if File_internal_starlink_pb_starlink_proto != nil { + return + } + file_internal_starlink_pb_starlink_proto_msgTypes[0].OneofWrappers = []any{ + (*Request_GetStatus)(nil), + } + file_internal_starlink_pb_starlink_proto_msgTypes[2].OneofWrappers = []any{ + (*Response_DishGetStatus)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_internal_starlink_pb_starlink_proto_rawDesc), len(file_internal_starlink_pb_starlink_proto_rawDesc)), + NumEnums: 1, + NumMessages: 7, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_internal_starlink_pb_starlink_proto_goTypes, + DependencyIndexes: file_internal_starlink_pb_starlink_proto_depIdxs, + EnumInfos: file_internal_starlink_pb_starlink_proto_enumTypes, + MessageInfos: file_internal_starlink_pb_starlink_proto_msgTypes, + }.Build() + File_internal_starlink_pb_starlink_proto = out.File + file_internal_starlink_pb_starlink_proto_goTypes = nil + file_internal_starlink_pb_starlink_proto_depIdxs = nil +} diff --git a/internal/starlink/pb/starlink.proto b/internal/starlink/pb/starlink.proto new file mode 100644 index 0000000..ac63153 --- /dev/null +++ b/internal/starlink/pb/starlink.proto @@ -0,0 +1,74 @@ +// A minimal slice of the Starlink dish's local gRPC API: just the one +// unary RPC (SpaceX.API.Device.Device/Handle) and the get_status request +// and dish status response, reduced to the handful of fields rtnetmon +// shows. The proto package name and field numbers must match the dish +// exactly for the wire format to line up; every other field the dish sends +// is ignored as an unknown field. +// +// Field numbers, message names and the DishState enum are transcribed from +// the community-maintained pre-generated bindings at +// github.com/starlink-community/starlink-grpc-go, commit +// 2e89f3d7e3092adecbcc04e9e003e61ed5f7c6a3 (get_status = 1004, +// dish_get_status = 2004). They are not verified against a live dish here. +// +// Regenerate with: +// protoc --go_out=. --go_opt=paths=source_relative \ +// --go-grpc_out=. --go-grpc_opt=paths=source_relative \ +// internal/starlink/pb/starlink.proto + +syntax = "proto3"; + +package SpaceX.API.Device; + +option go_package = "git.eeqj.de/sneak/rtnetmon/internal/starlink/pb;starlinkpb"; + +service Device { + rpc Handle(Request) returns (Response); +} + +message Request { + oneof request { + GetStatusRequest get_status = 1004; + } +} + +message GetStatusRequest {} + +message Response { + oneof response { + DishGetStatusResponse dish_get_status = 2004; + } +} + +message DishGetStatusResponse { + DeviceState device_state = 2; + DishObstructionStats obstruction_stats = 1004; + DishAlerts alerts = 1005; + DishState state = 1006; + float pop_ping_drop_rate = 1003; + float downlink_throughput_bps = 1007; + float uplink_throughput_bps = 1008; + float pop_ping_latency_ms = 1009; +} + +message DeviceState { + uint64 uptime_s = 1; +} + +message DishObstructionStats { + float fraction_obstructed = 1; +} + +message DishAlerts { + bool motors_stuck = 1; + bool thermal_shutdown = 2; + bool thermal_throttle = 3; + bool unexpected_location = 4; +} + +enum DishState { + UNKNOWN = 0; + CONNECTED = 1; + SEARCHING = 2; + BOOTING = 3; +} diff --git a/internal/starlink/pb/starlink_grpc.pb.go b/internal/starlink/pb/starlink_grpc.pb.go new file mode 100644 index 0000000..c9614f3 --- /dev/null +++ b/internal/starlink/pb/starlink_grpc.pb.go @@ -0,0 +1,140 @@ +// A minimal slice of the Starlink dish's local gRPC API: just the one +// unary RPC (SpaceX.API.Device.Device/Handle) and the get_status request +// and dish status response, reduced to the handful of fields rtnetmon +// shows. The proto package name and field numbers must match the dish +// exactly for the wire format to line up; every other field the dish sends +// is ignored as an unknown field. +// +// Field numbers, message names and the DishState enum are transcribed from +// the community-maintained pre-generated bindings at +// github.com/starlink-community/starlink-grpc-go, commit +// 2e89f3d7e3092adecbcc04e9e003e61ed5f7c6a3 (get_status = 1004, +// dish_get_status = 2004). They are not verified against a live dish here. +// +// Regenerate with: +// protoc --go_out=. --go_opt=paths=source_relative \ +// --go-grpc_out=. --go-grpc_opt=paths=source_relative \ +// internal/starlink/pb/starlink.proto + +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.5.1 +// - protoc v7.36.2 +// source: internal/starlink/pb/starlink.proto + +package starlinkpb + +import ( + context "context" + + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + Device_Handle_FullMethodName = "/SpaceX.API.Device.Device/Handle" +) + +// DeviceClient is the client API for Device service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type DeviceClient interface { + Handle(ctx context.Context, in *Request, opts ...grpc.CallOption) (*Response, error) +} + +type deviceClient struct { + cc grpc.ClientConnInterface +} + +func NewDeviceClient(cc grpc.ClientConnInterface) DeviceClient { + return &deviceClient{cc} +} + +func (c *deviceClient) Handle(ctx context.Context, in *Request, opts ...grpc.CallOption) (*Response, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Response) + err := c.cc.Invoke(ctx, Device_Handle_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// DeviceServer is the server API for Device service. +// All implementations must embed UnimplementedDeviceServer +// for forward compatibility. +type DeviceServer interface { + Handle(context.Context, *Request) (*Response, error) + mustEmbedUnimplementedDeviceServer() +} + +// UnimplementedDeviceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedDeviceServer struct{} + +func (UnimplementedDeviceServer) Handle(context.Context, *Request) (*Response, error) { + return nil, status.Errorf(codes.Unimplemented, "method Handle not implemented") +} +func (UnimplementedDeviceServer) mustEmbedUnimplementedDeviceServer() {} +func (UnimplementedDeviceServer) testEmbeddedByValue() {} + +// UnsafeDeviceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to DeviceServer will +// result in compilation errors. +type UnsafeDeviceServer interface { + mustEmbedUnimplementedDeviceServer() +} + +func RegisterDeviceServer(s grpc.ServiceRegistrar, srv DeviceServer) { + // If the following call pancis, it indicates UnimplementedDeviceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&Device_ServiceDesc, srv) +} + +func _Device_Handle_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(Request) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DeviceServer).Handle(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Device_Handle_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DeviceServer).Handle(ctx, req.(*Request)) + } + return interceptor(ctx, in, info, handler) +} + +// Device_ServiceDesc is the grpc.ServiceDesc for Device service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var Device_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "SpaceX.API.Device.Device", + HandlerType: (*DeviceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Handle", + Handler: _Device_Handle_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "internal/starlink/pb/starlink.proto", +} diff --git a/internal/starlink/starlink.go b/internal/starlink/starlink.go new file mode 100644 index 0000000..b15e0a6 --- /dev/null +++ b/internal/starlink/starlink.go @@ -0,0 +1,81 @@ +// Package starlink detects a Starlink dish on the local network and reads a +// short status summary from the dish's local gRPC endpoint. Detection and +// the status fetch sit behind the Client interface, so the monitor is +// tested with a fake and needs no dish and no network. Render turns a +// Status into the two lines the UI draws and is a pure function. +package starlink + +import ( + "context" + "fmt" + "time" +) + +// Address is the dish's fixed local gRPC endpoint. Every dish answers here +// from behind the Starlink router, whatever the LAN's own addressing is, so +// this is not the default gateway (that is the router). +const Address = "192.168.100.1:9200" + +// StateConnected is the dish state string that means the link is up; any +// other state is treated as an alert condition by Render. +const StateConnected = "CONNECTED" + +// Units used when reducing the dish's raw values to display values. +const ( + percentScale = 100.0 + bitsPerMegabit = 1_000_000.0 + hoursPerDay = 24 + minutesPerHour = 60 +) + +// Status is the subset of the dish's get_status response that rtnetmon +// shows. Throughput is megabits per second; obstruction and drop are +// percentages on a 0..100 scale. +type Status struct { + State string + Uptime time.Duration + ObstructionPct float64 + Alerts int + PopPingMs float64 + PopPingDropPct float64 + DownlinkMbps float64 + UplinkMbps float64 +} + +// Client probes for a dish and reads its status. The real implementation +// speaks gRPC to Address over one bound interface; tests use a fake. +type Client interface { + // Probe reports whether a dish answers on Address over the bound + // interface. It is cheap: a TCP connect, no gRPC call. + Probe(ctx context.Context) bool + // Status fetches the dish's current status. + Status(ctx context.Context) (Status, error) +} + +// Render formats a Status as the two status lines drawn under the physical +// pane, and reports whether they warrant an alert (red) color: the dish is +// not connected, or an alert is active. +func Render(s Status) (string, string, bool) { + line1 := fmt.Sprintf( + "Starlink: %s uptime %s obstruction %.1f%% alerts %d", + s.State, formatUptime(s.Uptime), s.ObstructionPct, s.Alerts) + line2 := fmt.Sprintf( + " pop ping %.0fms / drop %.1f%% down %.1fMbps / up %.1fMbps", + s.PopPingMs, s.PopPingDropPct, s.DownlinkMbps, s.UplinkMbps) + alert := s.State != StateConnected || s.Alerts > 0 + + return line1, line2, alert +} + +// formatUptime renders a duration as whole days, hours and minutes. +func formatUptime(d time.Duration) string { + if d < 0 { + d = 0 + } + + days := int64(d / (hoursPerDay * time.Hour)) + hours := int64(d/time.Hour) % hoursPerDay + mins := int64(d/time.Minute) % minutesPerHour + + return fmt.Sprintf("%dd %dh %dm", days, hours, mins) +} diff --git a/internal/starlink/starlink_test.go b/internal/starlink/starlink_test.go new file mode 100644 index 0000000..f65b12a --- /dev/null +++ b/internal/starlink/starlink_test.go @@ -0,0 +1,79 @@ +package starlink_test + +import ( + "testing" + "time" + + "git.eeqj.de/sneak/rtnetmon/internal/starlink" +) + +func TestRender(t *testing.T) { + t.Parallel() + + connected := starlink.Status{ + State: "CONNECTED", + Uptime: 3*24*time.Hour + 4*time.Hour + 12*time.Minute, + ObstructionPct: 0.1, + Alerts: 0, + PopPingMs: 34, + PopPingDropPct: 0.0, + DownlinkMbps: 145.3, + UplinkMbps: 12.1, + } + + tests := []struct { + name string + status starlink.Status + wantLine1 string + wantLine2 string + wantAlert bool + }{ + { + name: "connected, no alert", + status: connected, + wantLine1: "Starlink: CONNECTED uptime 3d 4h 12m obstruction 0.1% alerts 0", + wantLine2: " pop ping 34ms / drop 0.0% down 145.3Mbps / up 12.1Mbps", + wantAlert: false, + }, + { + name: "searching is an alert", + status: starlink.Status{ + State: "SEARCHING", + Uptime: 0, + }, + wantLine1: "Starlink: SEARCHING uptime 0d 0h 0m obstruction 0.0% alerts 0", + wantLine2: " pop ping 0ms / drop 0.0% down 0.0Mbps / up 0.0Mbps", + wantAlert: true, + }, + { + name: "connected but alerts active", + status: starlink.Status{ + State: "CONNECTED", + Uptime: 90 * time.Minute, + Alerts: 2, + }, + wantLine1: "Starlink: CONNECTED uptime 0d 1h 30m obstruction 0.0% alerts 2", + wantLine2: " pop ping 0ms / drop 0.0% down 0.0Mbps / up 0.0Mbps", + wantAlert: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + line1, line2, alert := starlink.Render(tt.status) + if line1 != tt.wantLine1 { + t.Errorf("line1 = %q, want %q", line1, tt.wantLine1) + } + + if line2 != tt.wantLine2 { + t.Errorf("line2 = %q, want %q", line2, tt.wantLine2) + } + + if alert != tt.wantAlert { + t.Errorf("alert = %v, want %v", alert, tt.wantAlert) + } + }) + } +} From bce8bdbb2e3345fa2e9045b67c5d7b0d99e77f6b Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 10:21:25 +0200 Subject: [PATCH 4/9] Stamp the git tag or short commit into the binary (closes #10) rtnetmon had no version. main.Version is now set at link time and logged in the first startup line. `make build` and `make dev` set it from `git describe --tags --always` unless VERSION is given. 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 --- .dockerignore | 35 +++++++++++++++++++++++++---------- Dockerfile | 16 +++++++++++++++- Makefile | 7 +++++-- cmd/rtnetmon/main.go | 6 +++++- internal/cli/export_test.go | 2 +- internal/cli/root.go | 17 +++++++++-------- 6 files changed, 60 insertions(+), 23 deletions(-) diff --git a/.dockerignore b/.dockerignore index b04a35f..8032fd8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,19 +1,28 @@ # .dockerignore does NOT use .gitignore semantics. Docker matches with # moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross # `/` and an unprefixed pattern is anchored at the context root. Every -# depth-independent pattern therefore needs `**/`; only genuinely -# root-anchored entries go unprefixed. Never transplant these into -# .gitignore, where `**/` is wrong. +# depth-independent pattern therefore needs `**/`, or `config/.env` and +# `certs/server.key` still ship while this file reads as solved. Only +# genuinely root-anchored entries go unprefixed. Never transplant these +# into .gitignore, where `**/` is wrong. # # Matching is case-sensitive, so secrets use character ranges rather # than an ALL-CAPS twin, which would still miss `Server.Key`. +# +# Extend with this repo's own host-built artifacts, written anchored: +# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and +# deletes the package directory from the context. -# Excluding .git means `git describe` cannot run in any build stage and -# fails quietly there; rtnetmon embeds no version, so this is safe. -.git +# .git is sent without its config. Without a VERSION build argument the +# stage that compiles runs `git describe --tags --always` on .git, which +# does not need .git/config; that file can hold a credential, such as a +# password in a remote URL or the token the CI checkout step stores there. +.git/config # Agent scratch: one full checkout of the repo per in-flight agent. -# Anchored because agents run at the repo root here. +# Anchored because it occurs once where agents run at the repo root. +# KNOWN GAP: a repo running agents in subdirectories still ships +# `services/api/.claude/` and must add its own anchored entry. .claude # This repo's own host-built artifacts, root-anchored so `bin/` is not @@ -22,12 +31,15 @@ /rtnetmon main.go.old -# Environment files. +# Environment files. `*.env` covers bare `.env` and the `prod.env` +# convention. Re-include a committed template with a negation if the +# build needs one: `!docs/example.env`. **/*.[eE][nN][vV] **/.[eE][nN][vV].* **/.[eE][nN][vV][rR][cC] -# Private keys and the bundles carrying them. +# Private keys and the bundles carrying them. Public certificates +# (*.crt, *.cer) are deliberately absent: they are legitimate inputs. **/*.[pP][eE][mM] **/*.[kK][eE][yY] **/*.[pP]12 @@ -37,11 +49,14 @@ main.go.old **/[iI][dD]_[eE][cC][dD][sS][aA] **/[iI][dD]_[eE][dD]25519 +# Dependencies: restored inside the image, never copied in. +**/node_modules + # OS metadata. **/.DS_Store **/Thumbs.db -# Editor state. +# Editor state: never a build input, and it churns COPY. **/*.swp **/*.swo **/*~ diff --git a/Dockerfile b/Dockerfile index 60b93dd..0b7fd8f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,4 +34,18 @@ WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 go build -trimpath -o /rtnetmon ./cmd/rtnetmon/ +# The version stamped into the binary: the VERSION build argument when one +# is given, otherwise `git describe --tags --always` of the .git the build +# context carries: the tag on a tagged commit, tag-N-gHASH on a commit after +# one, the short commit when no tag is reachable. git ships in this base +# image. A context that carries .git and still yields no version fails the +# build. With neither, as from a source tarball, the binary reports dev. +ARG VERSION +RUN version="${VERSION:-$(git describe --tags --always)}"; \ + if [ -e .git ] && { [ -z "$version" ] || [ "$version" = dev ] || \ + [ "$version" = unknown ]; }; then \ + echo "no version could be derived although the build context carries .git" >&2; \ + exit 1; \ + fi; \ + CGO_ENABLED=0 go build -trimpath -ldflags="-X main.Version=${version:-dev}" \ + -o /rtnetmon ./cmd/rtnetmon/ diff --git a/Makefile b/Makefile index cf34572..615bb94 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,8 @@ # below are thin shims that call them. .DEFAULT_GOAL := check +VERSION ?= $(shell git describe --tags --always) + bootstrap: @script/bootstrap @@ -36,13 +38,14 @@ hooks: @script/install-precommit build: - CGO_ENABLED=0 go build -trimpath -o bin/rtnetmon ./cmd/rtnetmon + CGO_ENABLED=0 go build -trimpath -ldflags "-X main.Version=$(VERSION)" \ + -o bin/rtnetmon ./cmd/rtnetmon run: build ./bin/rtnetmon dev: - go run ./cmd/rtnetmon + go run -ldflags "-X main.Version=$(VERSION)" ./cmd/rtnetmon deps: go mod download diff --git a/cmd/rtnetmon/main.go b/cmd/rtnetmon/main.go index eac0fed..5b7383d 100644 --- a/cmd/rtnetmon/main.go +++ b/cmd/rtnetmon/main.go @@ -8,8 +8,12 @@ import ( "git.eeqj.de/sneak/rtnetmon/internal/cli" ) +// Version is the git tag or short commit, set at link time with -X by the +// Makefile and the Dockerfile. Builds that do not set it report dev. +var Version = "dev" //nolint:gochecknoglobals // set at link time with -X + func main() { - err := cli.Execute() + err := cli.Execute(Version) if err != nil { log.Fatal(err) } diff --git a/internal/cli/export_test.go b/internal/cli/export_test.go index e768ab2..eac4a40 100644 --- a/internal/cli/export_test.go +++ b/internal/cli/export_test.go @@ -3,4 +3,4 @@ package cli import "github.com/spf13/cobra" // NewRootCmd exposes newRootCmd for external tests. -func NewRootCmd() *cobra.Command { return newRootCmd() } +func NewRootCmd() *cobra.Command { return newRootCmd("dev") } diff --git a/internal/cli/root.go b/internal/cli/root.go index 3a08014..29af2d5 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -51,8 +51,9 @@ func defaultTCPHosts() []string { } } -// newRootCmd builds the cobra root command with its flags bound. -func newRootCmd() *cobra.Command { +// newRootCmd builds the cobra root command with its flags bound. version is +// logged at startup. +func newRootCmd(version string) *cobra.Command { cfg := &config{} cmd := &cobra.Command{ Use: "rtnetmon", @@ -60,7 +61,7 @@ func newRootCmd() *cobra.Command { Long: `rtnetmon is a dual-interface network monitoring dashboard that provides real-time visibility into network health, packet loss, and latency.`, RunE: func(cmd *cobra.Command, _ []string) error { - return runMonitor(cmd, cfg) + return runMonitor(cmd, cfg, version) }, } registerFlags(cmd, cfg) @@ -88,8 +89,8 @@ func registerFlags(cmd *cobra.Command, cfg *config) { // runMonitor detects the interfaces to monitor, then constructs and runs the // monitor from cfg. -func runMonitor(cmd *cobra.Command, cfg *config) error { - monitor.Logf(cfg.LogFile, "Starting rtnetmon") +func runMonitor(cmd *cobra.Command, cfg *config, version string) error { + monitor.Logf(cfg.LogFile, "Starting rtnetmon %s", version) specs, err := detectInterfaces(cmd, cfg) if err != nil { @@ -169,7 +170,7 @@ func detectInterfaces( return specs, nil } -// Execute builds the root command and runs it. -func Execute() error { - return newRootCmd().Execute() +// Execute builds the root command and runs it. version is logged at startup. +func Execute(version string) error { + return newRootCmd(version).Execute() } From 66fb8bf149f98aa5d17c58c4f8f99e2214ab1e4d Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Sat, 3 Oct 2026 15:09:39 +0200 Subject: [PATCH 5/9] Refresh apt package lists before bootstrap installs anything (closes #12) The CI job image ships with empty apt package lists, so the bootstrap's `apt-get install -y golang` could not find the package and every CI run stopped there. On the apt path the bootstrap now runs `apt-get update` once, before the first install, and not at all when nothing needs installing. Model: opus-5-5 --- script/bootstrap | 3 +++ 1 file changed, 3 insertions(+) diff --git a/script/bootstrap b/script/bootstrap index 7a3adbd..233ccb3 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -31,6 +31,9 @@ detect_pkgmgr() { if [ "$(id -u)" != "0" ]; then SUDO="sudo" fi + # Fresh images, the CI runner's among them, ship with empty + # package lists. This runs once, on the first install. + $SUDO env DEBIAN_FRONTEND=noninteractive apt-get update fi } From 3578d1877746319d70ca50a1f26863039deebad9 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Sat, 3 Oct 2026 15:26:39 +0200 Subject: [PATCH 6/9] macOS: show the VPN pane only while a VPN is connected (closes #8) A utun tunnel counted as the VPN whenever it was up with a routable IPv4 address, so idle Tailscale or a tunnel left behind by a disconnected client became the VPN pane and its probes failed. A tunnel is now the VPN only while it carries the IPv4 default route in netstat: an unscoped default row, or both 0/1 and 128.0/1 rows on it. A default row scoped to a tunnel (flag I) does not count; on the physical interface it still does, since a VPN holding the default leaves the physical default scoped. Tests feed netstat text in the macOS layout through the parser into Select. Not run on a real Mac. Model: opus-5-5 --- README.md | 27 ++-- internal/netdetect/netdetect.go | 81 ++++++----- internal/netdetect/netdetect_test.go | 206 ++++++++++++++++++++++++--- 3 files changed, 248 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index bf24aa7..3884871 100644 --- a/README.md +++ b/README.md @@ -46,22 +46,27 @@ single pane. 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 +**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 | VPN tunnel + physical default-route interface (two panes) | -| macOS | the physical default-route interface with no VPN (one pane) | +| 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 diff --git a/internal/netdetect/netdetect.go b/internal/netdetect/netdetect.go index 061c043..8aee000 100644 --- a/internal/netdetect/netdetect.go +++ b/internal/netdetect/netdetect.go @@ -41,11 +41,14 @@ type Interface struct { IPv4 []string } -// Route is one routing-table entry reduced to what detection needs. +// Route is one routing-table entry reduced to what detection needs. Scoped +// marks a macOS interface-scoped route (flag I), which only traffic bound to +// that interface uses. type Route struct { Iface string Gateway string Default bool + Scoped bool } // Pane names one interface to display, with its label. @@ -133,7 +136,7 @@ func selectLinux(ifaces []Interface, routes []Route, f Flags) ([]Pane, error) { } // selectDarwin monitors the physical default-route interface, plus a VPN -// tunnel as the primary pane when one is running. +// tunnel as the primary pane while one is connected. func selectDarwin(ifaces []Interface, routes []Route, f Flags) ([]Pane, error) { vpn := findVPN(ifaces, routes) @@ -152,15 +155,16 @@ func selectDarwin(ifaces []Interface, routes []Route, f Flags) ([]Pane, error) { }, nil } -// findVPN returns the name of a VPN tunnel interface, or "" if none is -// running. A tunnel counts as a running VPN when it carries a default route, -// or when it is up with a routable (non-link-local) IPv4 address. Idle system -// tunnels have only a link-local IPv6 address and are skipped. +// findVPN returns the name of the connected VPN tunnel, or "" if there is +// none. A tunnel is the VPN only while it carries the default route; one that +// is merely up, even with a routable address (Tailscale without an exit node, +// or a tunnel a disconnected client left behind), is not. A default route +// scoped to the tunnel does not count: only traffic bound there uses it. func findVPN(ifaces []Interface, routes []Route) string { routeIfaces := map[string]bool{} for _, r := range routes { - if r.Default { + if r.Default && !r.Scoped { routeIfaces[r.Iface] = true } } @@ -176,10 +180,6 @@ func findVPN(ifaces []Interface, routes []Route) string { if routeIfaces[ifi.Name] { return ifi.Name } - - if ifi.Up && hasRoutableIPv4(ifi) { - return ifi.Name - } } return "" @@ -219,6 +219,8 @@ func onlyPhysicalDefaultRoute(routes []Route, vpn string) (string, error) { // defaultRouteIfaces returns the sorted, unique interface names that carry a // default route, excluding the named VPN interface and any other tunnel. +// Scoped routes count: while a VPN holds the default route, the physical +// interface keeps only a default route scoped to itself. func defaultRouteIfaces(routes []Route, vpn string) []string { seen := map[string]bool{} @@ -255,21 +257,6 @@ func isTunnel(name string) bool { return strings.HasPrefix(name, "utun") } -// hasRoutableIPv4 reports whether the interface has an IPv4 address that is -// neither loopback nor link-local. -func hasRoutableIPv4(ifi Interface) bool { - for _, s := range ifi.IPv4 { - ip := net.ParseIP(s) - if ip == nil || ip.IsLoopback() || ip.IsLinkLocalUnicast() { - continue - } - - return true - } - - return false -} - // singleLabel is the label for a lone pane: the explicit --labelA if given, // otherwise a plain description. func singleLabel(f Flags) string { @@ -340,23 +327,47 @@ func parseProcNetRoute(out string) []Route { return routes } -// parseNetstat reads `netstat -rn -f inet` output (macOS). Rows whose -// destination is "default" are default routes; the Netif column (field 4) -// names the interface. +// parseNetstat reads `netstat -rn -f inet` output (macOS); the Netif column +// (field 4) names the interface. A row whose destination is "default" is a +// default route, scoped when its flags include I. A "0/1" row and a "128.0/1" +// row on one interface together also make a default route there: VPN clients +// that leave the physical default in place send all traffic through them. func parseNetstat(out string) []Route { + const columns = 4 // Destination, Gateway, Flags, Netif; Expire is optional + var routes []Route + var lowHalf []string // interfaces with a 0/1 row + + highHalf := map[string]bool{} // interfaces with a 128.0/1 row + for _, line := range strings.Split(out, "\n") { fields := strings.Fields(line) - if len(fields) < 4 || fields[0] != "default" { + if len(fields) < columns { continue } - routes = append(routes, Route{ - Iface: fields[3], - Gateway: fields[1], - Default: true, - }) + dest, gateway, flags, iface := fields[0], fields[1], fields[2], fields[3] + + switch dest { + case "default": + routes = append(routes, Route{ + Iface: iface, + Gateway: gateway, + Default: true, + Scoped: strings.Contains(flags, "I"), + }) + case "0/1": + lowHalf = append(lowHalf, iface) + case "128.0/1": + highHalf[iface] = true + } + } + + for _, iface := range lowHalf { + if highHalf[iface] { + routes = append(routes, Route{Iface: iface, Default: true}) + } } return routes diff --git a/internal/netdetect/netdetect_test.go b/internal/netdetect/netdetect_test.go index 83d421e..9d92274 100644 --- a/internal/netdetect/netdetect_test.go +++ b/internal/netdetect/netdetect_test.go @@ -17,13 +17,96 @@ const ( ifaceEth0 = "eth0" ifaceWlan0 = "wlan0" ifaceEn0 = "en0" + ifaceUtun0 = "utun0" + ifaceUtun3 = "utun3" ifaceUtun4 = "utun4" labelGu = "gu LAN - VPN outbound" labelCox = "Cox cable direct" labelDefault = "default route" + labelVPN = "VPN" - addrEn0 = "192.168.1.20" + addrEn0 = "192.168.1.20" + addrVPN = "10.64.0.2" + addrTailscale = "100.101.102.103" +) + +// netstatHeader starts `netstat -rn -f inet` output on macOS. Each output +// below adds the rows of one routing state: en0 is the physical interface, +// utun3 is Tailscale's tunnel and utun4 a VPN client's. +const netstatHeader = `Routing tables + +Internet: +Destination Gateway Flags Netif Expire` + +const ( + netstatNoTunnel = netstatHeader + ` +default 192.168.1.1 UGScg en0 +127 127.0.0.1 UCS lo0 +127.0.0.1 127.0.0.1 UH lo0 +192.168.1 link#6 UCS en0 ! +192.168.1.1 a4:2b:b0:12:34:56 UHLWIir en0 1187 +` + + // Tailscale on without an exit node: routes for its own range only. + netstatTailscaleIdle = netstatHeader + ` +default 192.168.1.1 UGScg en0 +100.64/10 link#22 UCS utun3 +100.100.100.100/32 link#22 UCS utun3 +100.101.102.103/32 link#22 UCS utun3 +127.0.0.1 127.0.0.1 UH lo0 +` + + // A tunnel a disconnected client left behind, still holding its address. + netstatTunnelLeftBehind = netstatHeader + ` +default 192.168.1.1 UGScg en0 +10.64.0.2 10.64.0.2 UH utun4 +127.0.0.1 127.0.0.1 UH lo0 +` + + // A tunnel whose only default route is scoped to it. + netstatTunnelScopedDefault = netstatHeader + ` +default 192.168.1.1 UGScg en0 +default link#22 UCSIg utun3 +100.64/10 link#22 UCS utun3 +127.0.0.1 127.0.0.1 UH lo0 +` + + // A tunnel with the lower half of the address space but not the upper. + netstatVPNOneHalf = netstatHeader + ` +0/1 utun4 USc utun4 +default 192.168.1.1 UGScg en0 +127.0.0.1 127.0.0.1 UH lo0 +` + + // A VPN holding the default route; the physical default is now scoped. + netstatVPNDefault = netstatHeader + ` +default link#15 UCSg utun4 +default 192.168.1.1 UGScIg en0 +10.64.0.2 10.64.0.2 UH utun4 +127.0.0.1 127.0.0.1 UH lo0 +` + + // A VPN on both halves, leaving the physical default in place. + netstatVPNHalves = netstatHeader + ` +0/1 utun4 USc utun4 +default 192.168.1.1 UGScg en0 +default 192.168.1.1 UGScIg en0 +10.64.0.2 10.64.0.2 UH utun4 +127.0.0.1 127.0.0.1 UH lo0 +128.0/1 utun4 USc utun4 +` + + // Idle Tailscale next to a VPN on both halves. + netstatTailscaleAndVPN = netstatHeader + ` +0/1 utun4 USc utun4 +default 192.168.1.1 UGScg en0 +10.64.0.2 10.64.0.2 UH utun4 +100.64/10 link#22 UCS utun3 +100.101.102.103/32 link#22 UCS utun3 +127.0.0.1 127.0.0.1 UH lo0 +128.0/1 utun4 USc utun4 +` ) // linuxFlags describes the Linux bridge interfaces rtnetmon was built for. @@ -34,6 +117,17 @@ func linuxFlags() netdetect.Flags { } } +// macIfaces returns the interfaces every Mac in these tests has (en0, +// loopback, and an idle system tunnel with no IPv4 address) plus the given +// tunnels. +func macIfaces(tunnels ...netdetect.Interface) []netdetect.Interface { + return append([]netdetect.Interface{ + {Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}}, + {Name: "lo0", Up: true, IPv4: []string{"127.0.0.1"}}, + {Name: ifaceUtun0, Up: true}, + }, tunnels...) +} + // selectCase is one Select scenario with fake interfaces and routes. type selectCase struct { name string @@ -166,8 +260,8 @@ func TestSelectDarwinPanes(t *testing.T) { goos: osDarwin, ifaces: []netdetect.Interface{ {Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}}, - {Name: ifaceUtun4, Up: true, IPv4: []string{"10.64.0.2"}}, - {Name: "utun0", Up: true, IPv4: nil}, + {Name: ifaceUtun4, Up: true, IPv4: []string{addrVPN}}, + {Name: ifaceUtun0, Up: true, IPv4: nil}, }, routes: []netdetect.Route{ {Iface: ifaceUtun4, Default: true}, @@ -175,21 +269,7 @@ func TestSelectDarwinPanes(t *testing.T) { }, flags: linuxFlags(), want: []netdetect.Pane{ - {Name: ifaceUtun4, Label: "VPN"}, - {Name: ifaceEn0, Label: labelDefault}, - }, - }, - { - name: "vpn detected by routable address without its own route", - goos: osDarwin, - ifaces: []netdetect.Interface{ - {Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}}, - {Name: "utun6", Up: true, IPv4: []string{"10.2.0.2"}}, - }, - routes: []netdetect.Route{{Iface: ifaceEn0, Default: true}}, - flags: linuxFlags(), - want: []netdetect.Pane{ - {Name: "utun6", Label: "VPN"}, + {Name: ifaceUtun4, Label: labelVPN}, {Name: ifaceEn0, Label: labelDefault}, }, }, @@ -198,7 +278,7 @@ func TestSelectDarwinPanes(t *testing.T) { goos: osDarwin, ifaces: []netdetect.Interface{ {Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}}, - {Name: ifaceUtun4, Up: true, IPv4: []string{"10.64.0.2"}}, + {Name: ifaceUtun4, Up: true, IPv4: []string{addrVPN}}, }, routes: []netdetect.Route{ {Iface: ifaceUtun4, Default: true}, @@ -225,13 +305,24 @@ func TestSelectDarwinSingleAndErrors(t *testing.T) { goos: osDarwin, ifaces: []netdetect.Interface{ {Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}}, - {Name: "utun0", Up: true, IPv4: nil}, + {Name: ifaceUtun0, Up: true, IPv4: nil}, {Name: "utun1", Up: true, IPv4: []string{"169.254.1.1"}}, }, routes: []netdetect.Route{{Iface: ifaceEn0, Default: true}}, flags: linuxFlags(), want: []netdetect.Pane{{Name: ifaceEn0, Label: labelDefault}}, }, + { + name: "tunnel with a routable address but no default route", + goos: osDarwin, + ifaces: []netdetect.Interface{ + {Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}}, + {Name: "utun6", Up: true, IPv4: []string{"10.2.0.2"}}, + }, + routes: []netdetect.Route{{Iface: ifaceEn0, Default: true}}, + flags: linuxFlags(), + want: []netdetect.Pane{{Name: ifaceEn0, Label: labelDefault}}, + }, { name: "no default route", goos: osDarwin, @@ -264,6 +355,81 @@ func TestSelectDarwinSingleAndErrors(t *testing.T) { }) } +// TestSelectDarwinFromNetstat runs macOS routing tables through the netstat +// parser into Select: only a tunnel carrying the default route is the VPN. +func TestSelectDarwinFromNetstat(t *testing.T) { + t.Parallel() + + tailscale := netdetect.Interface{ + Name: ifaceUtun3, Up: true, IPv4: []string{addrTailscale}, + } + vpn := netdetect.Interface{Name: ifaceUtun4, Up: true, IPv4: []string{addrVPN}} + physicalOnly := []netdetect.Pane{{Name: ifaceEn0, Label: labelDefault}} + vpnAndPhysical := []netdetect.Pane{ + {Name: ifaceUtun4, Label: labelVPN}, + {Name: ifaceEn0, Label: labelDefault}, + } + + runSelectCases(t, []selectCase{ + { + name: "no tunnel", + goos: osDarwin, + ifaces: macIfaces(), + routes: netdetect.ParseNetstat(netstatNoTunnel), + want: physicalOnly, + }, + { + name: "tailscale without an exit node", + goos: osDarwin, + ifaces: macIfaces(tailscale), + routes: netdetect.ParseNetstat(netstatTailscaleIdle), + want: physicalOnly, + }, + { + name: "tunnel left behind by a disconnected client", + goos: osDarwin, + ifaces: macIfaces(vpn), + routes: netdetect.ParseNetstat(netstatTunnelLeftBehind), + want: physicalOnly, + }, + { + name: "tunnel with only a scoped default route", + goos: osDarwin, + ifaces: macIfaces(tailscale), + routes: netdetect.ParseNetstat(netstatTunnelScopedDefault), + want: physicalOnly, + }, + { + name: "tunnel with only one half of the address space", + goos: osDarwin, + ifaces: macIfaces(vpn), + routes: netdetect.ParseNetstat(netstatVPNOneHalf), + want: physicalOnly, + }, + { + name: "vpn on the default route", + goos: osDarwin, + ifaces: macIfaces(vpn), + routes: netdetect.ParseNetstat(netstatVPNDefault), + want: vpnAndPhysical, + }, + { + name: "vpn on both halves", + goos: osDarwin, + ifaces: macIfaces(vpn), + routes: netdetect.ParseNetstat(netstatVPNHalves), + want: vpnAndPhysical, + }, + { + name: "idle tailscale next to a connected vpn", + goos: osDarwin, + ifaces: macIfaces(tailscale, vpn), + routes: netdetect.ParseNetstat(netstatTailscaleAndVPN), + want: vpnAndPhysical, + }, + }) +} + func TestParseIPRoute(t *testing.T) { t.Parallel() From 7686440108a94cddd6feab0cc9e4881e984f81f6 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Sun, 4 Oct 2026 03:43:39 +0200 Subject: [PATCH 7/9] Bring TODO.md up to date with next and the tracker (closes #13) The workflow lines now say who merges where: clawbot squash-merges reviewed issue PRs into next, only sneak merges next into main, and a deploy is a squash commit from main onto prod, never automatic. Status lists what is on next today, each item linked to its issue: the repo standards, interface detection on Linux and macOS, the macOS VPN detection fix, the Starlink status lines, the version stamp, and CI running script/cibuild. Next Step is the owner's merge of the milestone PR and a first run on a real Mac. Model: opus-5-5 --- TODO.md | 49 ++++++++++++++++++++++++------------------------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/TODO.md b/TODO.md index 00222ad..02d1fc8 100644 --- a/TODO.md +++ b/TODO.md @@ -5,38 +5,37 @@ One issue per unit of work, one branch and one PR per issue: - ensure a tracked issue exists with a definition of done - branch from `next` (never from `main`) - do the work; open a PR based on `next` (never on `main`) -- pass an independent review, then the change is squash-merged into `next` +- pass an independent review, then `clawbot` squash-merges it into `next` - push; nothing stays local-only -`next` is the branch for the next milestone and must stay green and -mergeable to `main` without notice. Only `sneak` merges `next` into -`main`, and for now only `sneak` merges into `next`. No commits land -directly on `main` or `next` — only merges via PRs. +`next` is the branch for the next milestone and must stay green and mergeable to +`main` without notice. Only `sneak` merges `next` into `main`. No commits land +directly on `main` or `next`, only merges via PRs. A deploy is a squash commit +from `main` onto `prod`; `prod` never follows `main` automatically. -Issue branches do NOT touch this file — it is maintained on `next`. -Every branch editing `TODO.md` conflicts with every other. +Other issue branches do not touch this file; it changes only in its own PR to +`next`, because every branch editing `TODO.md` conflicts with every other. # Status -The repository has been brought up to current repo standards: `script/` -Scripts to Rule Them All entrypoints with the `Makefile` reduced to thin -shims, a `Dockerfile` whose `lint` and `test` phases gate the build, a -`.gitea/workflows/` CI workflow running `script/cibuild`, the vendored -`.golangci.yml`, `REPO_POLICIES.md`, `.editorconfig`, `.dockerignore`, a -`LICENSE` file, and a comprehensive `.gitignore`. +On `next`: -Lint is clean under the standard `default: all` configuration, with the -findings fixed rather than suppressed. The only annotations are -justified `//nolint:gosec` on the `ping`/`curl` subprocess calls (G204) -and on opening the operator-chosen log file (G304): fixed argv with no -shell, so these are false positives, annotated as the reference repos do. +- Repo standards (https://git.eeqj.de/sneak/rtnetmon/issues/1): the `script/` + entrypoints, the vendored `.golangci.yml`, and `REPO_POLICIES.md`. +- Interface detection on Linux and macOS + (https://git.eeqj.de/sneak/rtnetmon/issues/2). +- The fix for macOS VPN detection (https://git.eeqj.de/sneak/rtnetmon/issues/8). +- Starlink status lines (https://git.eeqj.de/sneak/rtnetmon/issues/3). +- A version stamp: built with `make build` or a plain `docker build .`, the + binary logs its git tag or short commit at startup + (https://git.eeqj.de/sneak/rtnetmon/issues/10). +- CI that runs `script/cibuild` on every push to a branch + (https://git.eeqj.de/sneak/rtnetmon/issues/12). # Next Step -Feature work, each on its own branch and PR from `next`: - -- https://git.eeqj.de/sneak/rtnetmon/issues/2 — macOS support: - VPN-aware interface detection and a single-interface UI when only one - interface exists. -- https://git.eeqj.de/sneak/rtnetmon/issues/3 — show Starlink status - lines when Starlink is the non-VPN gateway. +- `sneak` merges the milestone PR, https://git.eeqj.de/sneak/rtnetmon/pulls/6, + carrying `next` into `main`. +- A first run on a real Mac of what is on `next` now. The fix for + https://git.eeqj.de/sneak/rtnetmon/issues/8 has not run on a Mac yet; + `sneak`'s first run on his Mac is its test. From 6dc3b9e3952614722bb5b1b460e9b50a0051042d Mon Sep 17 00:00:00 2001 From: sneak Date: Fri, 6 Nov 2026 19:08:12 -0800 Subject: [PATCH 8/9] macOS: ignore on-link default routes when picking the physical uplink A VM with NAT networking makes vmnet install a default route scoped to its bridge (bridge100) with no gateway. The detector counted it as a second physical default route and refused to start: multiple physical default-route interfaces found (bridge100, en0) Scoped routes cannot simply be dropped: while a VPN holds the default route, the physical interface keeps only a default route scoped to itself. That route still names its gateway (flag G). The on-link one does not, so a physical default route now requires a next-hop gateway. parseNetstat records a gateway only when the G flag is set; a link#N gateway column is the interface itself, not a next hop. --- README.md | 7 +- internal/netdetect/netdetect.go | 52 ++++++++++---- internal/netdetect/netdetect_test.go | 102 +++++++++++++++++++++++++-- 3 files changed, 138 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 3884871..a856e31 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,12 @@ 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 +physical interface is monitored. The physical interface is the one non-tunnel +`default` row with a next-hop gateway (flag `G`); it keeps that gateway even +while a VPN holds the default route and its own default is scoped. An on-link +`default` row with no gateway, such as the one vmnet or Internet Sharing +installs on `bridge100` for a VM's subnet, is ignored. Interface names are +detected on macOS; the `--ifaceA`/`--ifaceB` flags are not used there, but `--labelA`/`--labelB` still set the pane labels. diff --git a/internal/netdetect/netdetect.go b/internal/netdetect/netdetect.go index 8aee000..c90b1cd 100644 --- a/internal/netdetect/netdetect.go +++ b/internal/netdetect/netdetect.go @@ -41,9 +41,11 @@ type Interface struct { IPv4 []string } -// Route is one routing-table entry reduced to what detection needs. Scoped -// marks a macOS interface-scoped route (flag I), which only traffic bound to -// that interface uses. +// Route is one routing-table entry reduced to what detection needs. Gateway +// is the next-hop address, empty for an on-link route that has none: a +// tunnel, or the pseudo-default that vmnet and Internet Sharing install on a +// bridge so the hosted subnet works. Scoped marks a macOS interface-scoped +// route (flag I), which only traffic bound to that interface uses. type Route struct { Iface string Gateway string @@ -188,7 +190,9 @@ func findVPN(ifaces []Interface, routes []Route) string { // onlyDefaultRoute returns the single default-route interface, or an error if // there is not exactly one. func onlyDefaultRoute(routes []Route) (string, error) { - names := defaultRouteIfaces(routes, "") + names := defaultRouteIfaces(routes, func(r Route) bool { + return !isTunnel(r.Iface) + }) switch len(names) { case 1: @@ -202,9 +206,16 @@ func onlyDefaultRoute(routes []Route) (string, error) { } // onlyPhysicalDefaultRoute returns the single non-tunnel default-route -// interface (ignoring the VPN), or an error if there is not exactly one. +// interface (ignoring the VPN), or an error if there is not exactly one. Only +// a default route with a next-hop gateway counts: scoped routes do, since +// while a VPN holds the default route the physical interface keeps only a +// default route scoped to itself, but that route still names the gateway. An +// on-link default without one (vmnet's on bridge100) reaches nothing beyond +// its own subnet and is not an uplink. func onlyPhysicalDefaultRoute(routes []Route, vpn string) (string, error) { - names := defaultRouteIfaces(routes, vpn) + names := defaultRouteIfaces(routes, func(r Route) bool { + return r.Iface != vpn && !isTunnel(r.Iface) && r.Gateway != "" + }) switch len(names) { case 1: @@ -218,16 +229,14 @@ func onlyPhysicalDefaultRoute(routes []Route, vpn string) (string, error) { } // defaultRouteIfaces returns the sorted, unique interface names that carry a -// default route, excluding the named VPN interface and any other tunnel. -// Scoped routes count: while a VPN holds the default route, the physical -// interface keeps only a default route scoped to itself. -func defaultRouteIfaces(routes []Route, vpn string) []string { +// default route accepted by keep. +func defaultRouteIfaces(routes []Route, keep func(Route) bool) []string { seen := map[string]bool{} var names []string for _, r := range routes { - if !r.Default || r.Iface == vpn || isTunnel(r.Iface) || seen[r.Iface] { + if !r.Default || !keep(r) || seen[r.Iface] { continue } @@ -327,11 +336,20 @@ func parseProcNetRoute(out string) []Route { return routes } +// netstat route flags (see `man netstat`). +const ( + netstatFlagGateway = "G" // destination is reached through a next-hop gateway + netstatFlagScoped = "I" // route is scoped to its interface +) + // parseNetstat reads `netstat -rn -f inet` output (macOS); the Netif column // (field 4) names the interface. A row whose destination is "default" is a -// default route, scoped when its flags include I. A "0/1" row and a "128.0/1" -// row on one interface together also make a default route there: VPN clients -// that leave the physical default in place send all traffic through them. +// default route, scoped when its flags include I. Its Gateway column is the +// next hop only when the flags include G; otherwise it names the link itself +// (link#N) and the route is on-link, with no gateway. A "0/1" row and a +// "128.0/1" row on one interface together also make a default route there: +// VPN clients that leave the physical default in place send all traffic +// through them. func parseNetstat(out string) []Route { const columns = 4 // Destination, Gateway, Flags, Netif; Expire is optional @@ -351,11 +369,15 @@ func parseNetstat(out string) []Route { switch dest { case "default": + if !strings.Contains(flags, netstatFlagGateway) { + gateway = "" + } + routes = append(routes, Route{ Iface: iface, Gateway: gateway, Default: true, - Scoped: strings.Contains(flags, "I"), + Scoped: strings.Contains(flags, netstatFlagScoped), }) case "0/1": lowHalf = append(lowHalf, iface) diff --git a/internal/netdetect/netdetect_test.go b/internal/netdetect/netdetect_test.go index 9d92274..156f629 100644 --- a/internal/netdetect/netdetect_test.go +++ b/internal/netdetect/netdetect_test.go @@ -17,6 +17,7 @@ const ( ifaceEth0 = "eth0" ifaceWlan0 = "wlan0" ifaceEn0 = "en0" + ifaceBridge = "bridge100" ifaceUtun0 = "utun0" ifaceUtun3 = "utun3" ifaceUtun4 = "utun4" @@ -27,6 +28,8 @@ const ( labelVPN = "VPN" addrEn0 = "192.168.1.20" + gatewayEn0 = "192.168.1.1" + addrBridge = "192.168.64.1" addrVPN = "10.64.0.2" addrTailscale = "100.101.102.103" ) @@ -106,6 +109,32 @@ default 192.168.1.1 UGScg en0 100.101.102.103/32 link#22 UCS utun3 127.0.0.1 127.0.0.1 UH lo0 128.0/1 utun4 USc utun4 +` + + // A VM with NAT networking: vmnet installs an on-link default route, + // scoped to its bridge and with no gateway, next to the real one. + netstatVMBridge = netstatHeader + ` +default 192.168.1.1 UGScg en0 +default link#45 UCSIg bridge100 ! +127.0.0.1 127.0.0.1 UH lo0 +192.168.64 link#45 UC bridge100 ! +` + + // The same VM bridge while a VPN holds the default route. + netstatVMBridgeAndVPN = netstatHeader + ` +default link#15 UCSg utun4 +default 192.168.1.1 UGScIg en0 +default link#45 UCSIg bridge100 ! +10.64.0.2 10.64.0.2 UH utun4 +127.0.0.1 127.0.0.1 UH lo0 +192.168.64 link#45 UC bridge100 ! +` + + // Only the VM bridge's on-link default route, no uplink at all. + netstatVMBridgeOnly = netstatHeader + ` +default link#45 UCSIg bridge100 ! +127.0.0.1 127.0.0.1 UH lo0 +192.168.64 link#45 UC bridge100 ! ` ) @@ -265,7 +294,7 @@ func TestSelectDarwinPanes(t *testing.T) { }, routes: []netdetect.Route{ {Iface: ifaceUtun4, Default: true}, - {Iface: ifaceEn0, Default: true}, + {Iface: ifaceEn0, Gateway: gatewayEn0, Default: true, Scoped: true}, }, flags: linuxFlags(), want: []netdetect.Pane{ @@ -282,7 +311,7 @@ func TestSelectDarwinPanes(t *testing.T) { }, routes: []netdetect.Route{ {Iface: ifaceUtun4, Default: true}, - {Iface: ifaceEn0, Default: true}, + {Iface: ifaceEn0, Gateway: gatewayEn0, Default: true, Scoped: true}, }, flags: netdetect.Flags{ LabelA: "Mullvad", LabelASet: true, @@ -308,7 +337,7 @@ func TestSelectDarwinSingleAndErrors(t *testing.T) { {Name: ifaceUtun0, Up: true, IPv4: nil}, {Name: "utun1", Up: true, IPv4: []string{"169.254.1.1"}}, }, - routes: []netdetect.Route{{Iface: ifaceEn0, Default: true}}, + routes: []netdetect.Route{{Iface: ifaceEn0, Gateway: gatewayEn0, Default: true}}, flags: linuxFlags(), want: []netdetect.Pane{{Name: ifaceEn0, Label: labelDefault}}, }, @@ -319,7 +348,7 @@ func TestSelectDarwinSingleAndErrors(t *testing.T) { {Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}}, {Name: "utun6", Up: true, IPv4: []string{"10.2.0.2"}}, }, - routes: []netdetect.Route{{Iface: ifaceEn0, Default: true}}, + routes: []netdetect.Route{{Iface: ifaceEn0, Gateway: gatewayEn0, Default: true}}, flags: linuxFlags(), want: []netdetect.Pane{{Name: ifaceEn0, Label: labelDefault}}, }, @@ -341,8 +370,8 @@ func TestSelectDarwinSingleAndErrors(t *testing.T) { {Name: "en1", Up: true, IPv4: []string{"192.168.2.20"}}, }, routes: []netdetect.Route{ - {Iface: ifaceEn0, Default: true}, - {Iface: "en1", Default: true}, + {Iface: ifaceEn0, Gateway: gatewayEn0, Default: true}, + {Iface: "en1", Gateway: "192.168.2.1", Default: true}, }, flags: linuxFlags(), wantErr: true, @@ -430,6 +459,63 @@ func TestSelectDarwinFromNetstat(t *testing.T) { }) } +// TestSelectDarwinVMBridge covers a VM's NAT bridge: vmnet installs a default +// route scoped to the bridge with no gateway, which is not an uplink. +func TestSelectDarwinVMBridge(t *testing.T) { + t.Parallel() + + vpn := netdetect.Interface{Name: ifaceUtun4, Up: true, IPv4: []string{addrVPN}} + bridge := netdetect.Interface{Name: ifaceBridge, Up: true, IPv4: []string{addrBridge}} + physicalOnly := []netdetect.Pane{{Name: ifaceEn0, Label: labelDefault}} + vpnAndPhysical := []netdetect.Pane{ + {Name: ifaceUtun4, Label: labelVPN}, + {Name: ifaceEn0, Label: labelDefault}, + } + + runSelectCases(t, []selectCase{ + { + name: "on-link default route is not an uplink", + goos: osDarwin, + ifaces: macIfaces(bridge), + routes: []netdetect.Route{ + {Iface: ifaceEn0, Gateway: gatewayEn0, Default: true}, + {Iface: ifaceBridge, Default: true, Scoped: true}, + }, + flags: linuxFlags(), + want: physicalOnly, + }, + { + name: "only an on-link default route", + goos: osDarwin, + ifaces: macIfaces(bridge), + routes: []netdetect.Route{{Iface: ifaceBridge, Default: true, Scoped: true}}, + flags: linuxFlags(), + wantErr: true, + }, + { + name: "netstat: vm bridge next to the physical default", + goos: osDarwin, + ifaces: macIfaces(bridge), + routes: netdetect.ParseNetstat(netstatVMBridge), + want: physicalOnly, + }, + { + name: "netstat: vm bridge next to a connected vpn", + goos: osDarwin, + ifaces: macIfaces(bridge, vpn), + routes: netdetect.ParseNetstat(netstatVMBridgeAndVPN), + want: vpnAndPhysical, + }, + { + name: "netstat: vm bridge with no uplink", + goos: osDarwin, + ifaces: macIfaces(bridge), + routes: netdetect.ParseNetstat(netstatVMBridgeOnly), + wantErr: true, + }, + }) +} + func TestParseIPRoute(t *testing.T) { t.Parallel() @@ -463,11 +549,13 @@ func TestParseNetstat(t *testing.T) { "Destination Gateway Flags Netif Expire\n" + "default 10.0.0.1 UGScg en0\n" + "default link#15 UCSg utun4\n" + + "default link#45 UCSIg bridge100 !\n" + "127.0.0.1 127.0.0.1 UH lo0\n" got := netdetect.ParseNetstat(out) want := []netdetect.Route{ {Iface: ifaceEn0, Gateway: "10.0.0.1", Default: true}, - {Iface: ifaceUtun4, Gateway: "link#15", Default: true}, + {Iface: ifaceUtun4, Default: true}, + {Iface: ifaceBridge, Default: true, Scoped: true}, } if !reflect.DeepEqual(got, want) { From bbceab0b24fc06300da059a9bca4fe7efac50dd1 Mon Sep 17 00:00:00 2001 From: sneak Date: Fri, 6 Nov 2026 19:12:41 -0800 Subject: [PATCH 9/9] 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. --- internal/monitor/export_test.go | 4 ++++ internal/monitor/ui.go | 21 +++++++++++---------- internal/monitor/ui_test.go | 27 +++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 10 deletions(-) create mode 100644 internal/monitor/ui_test.go diff --git a/internal/monitor/export_test.go b/internal/monitor/export_test.go index f330e5a..4bf82c1 100644 --- a/internal/monitor/export_test.go +++ b/internal/monitor/export_test.go @@ -2,6 +2,7 @@ package monitor import ( "context" + "time" "git.eeqj.de/sneak/rtnetmon/internal/starlink" ) @@ -59,6 +60,9 @@ func (m *Monitor) TCPHosts() []string { return m.tcpHosts } // Interfaces returns the monitored interfaces. func (m *Monitor) Interfaces() []*InterfaceStatus { return m.interfaces } +// ClockStrings exposes clockStrings for external tests. +func ClockStrings(now time.Time) (string, string) { return clockStrings(now) } + // PingArgs exposes pingArgs for external tests. func PingArgs(goos, iface, host string) []string { return pingArgs(goos, iface, host) } diff --git a/internal/monitor/ui.go b/internal/monitor/ui.go index 185fcbe..9044498 100644 --- a/internal/monitor/ui.go +++ b/internal/monitor/ui.go @@ -337,6 +337,15 @@ func drawICMPStats(scr tcell.Screen, y int, st *InterfaceStatus) int { } // uiLoop runs the UI event loop. +// clockFormat is the layout of both header clocks. +const clockFormat = time.RFC1123Z + +// clockStrings renders the header clocks: UTC first, then the host's local +// time in whatever location now carries. +func clockStrings(now time.Time) (string, string) { + return now.UTC().Format(clockFormat), now.Format(clockFormat) +} + func (m *Monitor) uiLoop(ctx context.Context) { m.logf("UI loop started") @@ -348,13 +357,6 @@ func (m *Monitor) uiLoop(ctx context.Context) { m.logf("Screen finalized") }() - pstLoc, err := time.LoadLocation("America/Los_Angeles") - if err != nil { - m.logf("Error loading PST location: %v", err) - - pstLoc = time.UTC - } - timestampSpinFrame := 0 lastTimestampSpinUpdate := time.Now() @@ -365,8 +367,7 @@ func (m *Monitor) uiLoop(ctx context.Context) { Put(m.screen, colLeft, rowTopRule, HLine(w), tcell.StyleDefault) now := time.Now() - timeStr := now.Format(time.RFC1123Z) - pstTimeStr := now.In(pstLoc).Format(time.RFC1123Z) + timeStr, localTimeStr := clockStrings(now) if time.Since(lastTimestampSpinUpdate) >= time.Second { timestampSpinFrame = (timestampSpinFrame + 1) % len(brailleSpins()) @@ -380,7 +381,7 @@ func (m *Monitor) uiLoop(ctx context.Context) { Put(m.screen, colSpinner, rowClock, string(brailleChar)+" ", tcell.StyleDefault) DrawRainbowText(m.screen, colMeter, rowClock, timeStr, colors) DrawRainbowText(m.screen, colMeter+len(timeStr)+clockGap, rowClock, - pstTimeStr, colors) + localTimeStr, colors) Put(m.screen, colLeft, rowBottomRule, HLine(w), tcell.StyleDefault) runtime := time.Since(m.startTime).Round(time.Second).String() diff --git a/internal/monitor/ui_test.go b/internal/monitor/ui_test.go new file mode 100644 index 0000000..5a072be --- /dev/null +++ b/internal/monitor/ui_test.go @@ -0,0 +1,27 @@ +package monitor_test + +import ( + "testing" + "time" + + "git.eeqj.de/sneak/rtnetmon/internal/monitor" +) + +// TestClockStrings checks that the header shows UTC first and the host's +// local time second, so the two differ whenever the host is not on UTC. +func TestClockStrings(t *testing.T) { + t.Parallel() + + berlin := time.FixedZone("CET", 3600) + now := time.Date(2026, time.November, 6, 20, 30, 0, 0, berlin) + + utc, local := monitor.ClockStrings(now) + + if want := "Fri, 06 Nov 2026 19:30:00 +0000"; utc != want { + t.Errorf("utc = %q, want %q", utc, want) + } + + if want := "Fri, 06 Nov 2026 20:30:00 +0100"; local != want { + t.Errorf("local = %q, want %q", local, want) + } +}