Milestone: repo standards and macOS support #6

Open
clawbot wants to merge 2 commits from next into main
43 changed files with 3229 additions and 942 deletions
+51
View File
@@ -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-*
+12
View File
@@ -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
+16
View File
@@ -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
+28 -1
View File
@@ -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
+78 -14
View File
@@ -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
+37
View File
@@ -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/
+13
View File
@@ -0,0 +1,13 @@
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
Version 2, December 2004
Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>
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.
+42 -30
View File
@@ -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/
+113 -38
View File
@@ -1,46 +1,84 @@
# 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 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
```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 +98,75 @@ 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
── 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
│ ├── dial_linux.go # TCP source binding (build-tagged)
│ └── dial_darwin.go # TCP IP_BOUND_IF binding (build-tagged)
├── script/ # Scripts to Rule Them All entrypoints
├── .gitea/workflows/ # CI (runs script/cibuild)
├── Dockerfile # lint + test gate phases and the build
├── Makefile # thin shims over script/
├── .golangci.yml # vendored linter config
├── go.mod / go.sum
└── README.md
```
## Development
### 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
@@ -103,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
+603
View File
@@ -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 `@<commit-sha>`). 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@<version> --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/<name>`. 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<tab>`, 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=<phase> /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:
@<test-command> || \
{ echo "--- Rerunning with -v for details ---"; \
<test-command-with-v>; 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 <tool>; 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/<name>`. 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/<path>`.
- 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`
+42
View File
@@ -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.
+4 -8
View File
@@ -1,20 +1,16 @@
//go:build linux
// +build linux
// netmon dual-interface network dashboard (curses)
// WTFPL 2025-05-16 sneak@sneak.berlin
// Command rtnetmon is a real-time network monitoring dashboard for Linux
// and macOS. 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)
}
}
+1 -1
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
package cli
import "github.com/spf13/cobra"
// NewRootCmd exposes newRootCmd for external tests.
func NewRootCmd() *cobra.Command { return newRootCmd() }
+106 -54
View File
@@ -1,22 +1,22 @@
//go:build linux
// +build linux
// Package cli wires command-line flags to the network monitor and runs it.
package cli
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
type Config struct {
// config holds the application configuration.
type config struct {
IfaceA string
LabelA string
IfaceB string
@@ -25,94 +25,146 @@ 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(cmd *cobra.Command, _ []string) error {
return runMonitor(cmd, 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 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)
// Create the monitor
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)
// 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
// 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 rootCmd.Execute()
return newRootCmd().Execute()
}
+13 -26
View File
@@ -1,36 +1,23 @@
//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)
}
}
}
+36
View File
@@ -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
}
}
+12
View File
@@ -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
}
+23
View File
@@ -0,0 +1,23 @@
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 }
// Interfaces returns the monitored interfaces.
func (m *Monitor) Interfaces() []*InterfaceStatus { return m.interfaces }
// 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)
}
+264 -225
View File
@@ -1,278 +1,317 @@
//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
}
+249 -138
View File
@@ -1,16 +1,18 @@
//go:build linux
// +build linux
// 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 (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"net"
"os"
"os/exec"
"runtime"
"strconv"
"strings"
"sync"
@@ -19,16 +21,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
@@ -53,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
@@ -63,95 +96,105 @@ 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
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,
// IfaceSpec names one interface to monitor and its display label.
type IfaceSpec struct {
Name string
Label string
}
// Default display configuration
MeterWidth: 7,
MeterFillWidth: 5,
MaxMeterValue: 10,
HostWidth: 30,
NumWidth: 7,
StdWidth: 8,
NWidth: 6,
LostWidth: 7,
// 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,
PacketLossPeriod: defaultPacketLossPeriod,
StatsHistory: defaultStatsHistory,
ScreenRefresh: defaultScreenRefresh,
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),
}
for _, spec := range ifaces {
m.interfaces = append(m.interfaces, NewInterfaceStatus(spec.Name, spec.Label))
}
return m
}
// 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...)
@@ -159,14 +202,12 @@ 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)
}
// Run UI loop
m.logf("Starting UI loop")
m.uiLoop(ctx)
m.logf("UI loop exited, monitor ending")
@@ -174,36 +215,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 +272,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 +286,188 @@ 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)
defer cancel()
return exec.CommandContext(ctx, "ping", "-I", iface, "-c1", "-W1", host).Run() == nil
// 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}
}
// lossPercent calculates packet loss percentage
func (m *Monitor) lossPercent(iface, host string) float64 {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
// 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()
out, err := exec.CommandContext(ctx, "ping", "-q", "-i", "0.05",
"-c", fmt.Sprint(m.PacketLossPings), "-W1", "-I", iface, host).CombinedOutput()
args := pingArgs(runtime.GOOS, iface, host)
cmd := exec.CommandContext( //nolint:gosec // G204: fixed argv, operator CLI input
ctx, "ping", args...)
return cmd.Run() == nil
}
// 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()
args := lossArgs(runtime.GOOS, iface, host, m.PacketLossPings)
cmd := exec.CommandContext( //nolint:gosec // G204: fixed argv, operator CLI input
ctx, "ping", args...)
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}
d := net.Dialer{Timeout: m.TCPTimeout, LocalAddr: la, Control: bindControl(iface)}
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 +477,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...)
}
+74 -65
View File
@@ -1,125 +1,134 @@
//go:build linux
// +build linux
package monitor
package monitor_test
import (
"testing"
"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"
)
// 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([]monitor.IfaceSpec{
{Name: ifaceTest0, Label: "Test Interface A"},
{Name: ifaceTest1, Label: "Test Interface B"},
}, "/tmp/test.log")
ifaces := mon.Interfaces()
if len(ifaces) != 2 {
t.Fatalf("interfaces = %d, want 2", len(ifaces))
}
// Check interfaces
if mon.interfaceA == nil {
t.Fatal("interfaceA is nil")
if ifaces[0].Name != ifaceTest0 {
t.Errorf("interfaces[0].Name = %q, want %q", ifaces[0].Name, ifaceTest0)
}
if mon.interfaceB == nil {
t.Fatal("interfaceB is nil")
if ifaces[1].Name != ifaceTest1 {
t.Errorf("interfaces[1].Name = %q, want %q", ifaces[1].Name, ifaceTest1)
}
if mon.interfaceA.Name != "test0" {
t.Errorf("Expected interfaceA.Name to be 'test0', got '%s'", mon.interfaceA.Name)
}
if mon.interfaceB.Name != "test1" {
t.Errorf("Expected interfaceB.Name to be 'test1', got '%s'", mon.interfaceB.Name)
}
// 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", "")
func TestNewMonitorSingle(t *testing.T) {
t.Parallel()
// Test adding reachability hosts
mon.AddReachabilityHost("8.8.8.8")
mon.AddReachabilityHost("google.com")
mon := monitor.NewMonitor(
[]monitor.IfaceSpec{{Name: ifaceEth0, Label: "default route"}}, "")
if len(mon.reachabilityHosts) != 2 {
t.Errorf("Expected 2 reachability hosts, got %d", len(mon.reachabilityHosts))
ifaces := mon.Interfaces()
if len(ifaces) != 1 {
t.Fatalf("interfaces = %d, want 1", len(ifaces))
}
// Test adding packet loss hosts
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([]monitor.IfaceSpec{
{Name: ifaceTest0, Label: "Test A"},
{Name: ifaceTest1, Label: "Test B"},
}, "")
mon.AddReachabilityHost(host8888)
mon.AddReachabilityHost("google.com")
if len(mon.ReachabilityHosts()) != 2 {
t.Errorf("reachability hosts = %d, want 2", len(mon.ReachabilityHosts()))
}
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)
}
})
}
+58
View File
@@ -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)
}
})
}
}
+82 -53
View File
@@ -1,23 +1,60 @@
//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 +62,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()
}
}
+29 -87
View File
@@ -1,104 +1,46 @@
//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)
}
})
}
+234 -202
View File
@@ -1,6 +1,3 @@
//go:build linux
// +build linux
package monitor
import (
@@ -13,283 +10,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 +343,55 @@ 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 one pane per interface; a single interface draws one pane.
y := rowFirstIface
for _, st := range m.interfaces {
y = m.DrawInterface(m.screen, y, w, st)
}
// 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 = 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()
}
}
+12
View File
@@ -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) }
+375
View File
@@ -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()
}
+310
View File
@@ -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)
}
}
+27
View File
@@ -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
}
+36
View File
@@ -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
}
+70
View File
@@ -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 <nix-attr> <apt-pkg> <brew-formula> <apk-pkg>
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 "$@"
Executable
+14
View File
@@ -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 "$@"
Executable
+19
View File
@@ -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 "$@"
Executable
+14
View File
@@ -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 "$@"
Executable
+16
View File
@@ -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 "$@"
+18
View File
@@ -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 "$@"
+15
View File
@@ -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 "$@"
Executable
+16
View File
@@ -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 "$@"
+21
View File
@@ -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 "$@"
+11
View File
@@ -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 "$@"
Executable
+13
View File
@@ -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 "$@"
Executable
+16
View File
@@ -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 "$@"