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/2] 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 "$@" -- 2.54.0 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/2] 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 +} -- 2.54.0