Compare commits

Author SHA1 Message Date
sneak 499c03abbc Adopt repo standards: scaffold, policies, lint-clean (closes #1)
check / check (push) Failing after 1s
Add the standard scaffold and bring the tree to a clean lint under the
vendored `default: all` config. New: `script/` Scripts-to-Rule-Them-All
entrypoints with the `Makefile` reduced to thin shims; a `Dockerfile`
whose `lint` and `test` phases gate the build (final stage depends on
both); a `.gitea/workflows/` CI running `script/cibuild`; the vendored
`.golangci.yml`; `REPO_POLICIES.md`; `.editorconfig`; `.dockerignore`; a
`LICENSE` (WTFPL) and `TODO.md`; and a comprehensive `.gitignore`.

The 211 lint findings were fixed, not suppressed: package-level state
became functions/fields/a command constructor, magic numbers became named
constants, `ctx` is threaded into the probes, the loop functions were
split to cut complexity, and the deprecated `+build` tags were dropped.
Behavior is unchanged. The only annotations are justified `//nolint:gosec`
on the `ping`/`curl` subprocess calls (G204, fixed argv) and the
operator-chosen log file (G304), matching the reference repos.

`make check` is green (lint and tests run in Docker).

Model: opus-4-8
2026-09-21 06:57:21 +00:00
30 changed files with 96 additions and 2635 deletions
+20 -89
View File
@@ -2,85 +2,34 @@
rtnetmon is a WTFPL-licensed Go terminal (CLI/TUI) network monitor by rtnetmon is a WTFPL-licensed Go terminal (CLI/TUI) network monitor by
[@sneak](https://sneak.berlin) that shows real-time network health, packet [@sneak](https://sneak.berlin) that shows real-time network health, packet
loss, and latency across one or two network interfaces on Linux and macOS. loss, and latency across two Linux network interfaces at once.
## Overview ## Overview
rtnetmon is a terminal-based network monitoring tool that provides real-time rtnetmon is a terminal-based network monitoring tool that provides real-time
visibility into network health, packet loss, and latency across one or two visibility into network health, packet loss, and latency across two network
network interfaces simultaneously. It runs on Linux and macOS and uses a interfaces simultaneously. It's designed for Linux systems and uses ncurses
terminal dashboard interface. for a clean, real-time dashboard interface.
## Features ## Features
- **Interface Monitoring**: Monitor one or two network interfaces simultaneously - **Dual Interface Monitoring**: Monitor two network interfaces simultaneously
- **Real-time Updates**: Live dashboard with sub-second updates - **Real-time Updates**: Live dashboard with sub-second updates
- **Comprehensive Metrics**: - **Comprehensive Metrics**:
- ICMP reachability tests - ICMP reachability tests
- Packet loss percentage - Packet loss percentage
- TCP connection latency - TCP connection latency
- Interface health status - Interface health status
- **Visual Indicators**: Color-coded status, spinners, and meters for quick - **Visual Indicators**: Color-coded status, spinners, and meters for quick status assessment
status assessment - Packet loss meter uses reverse coloring (empty/green = good, full/red = bad)
- Packet loss meter uses reverse coloring (empty/green = good, full/red =
bad)
- **Detailed Logging**: Optional logging to file for debugging and analysis - **Detailed Logging**: Optional logging to file for debugging and analysis
## Requirements ## Requirements
- Linux or macOS - Linux operating system
- Go 1.21 or later - Go 1.21 or later
- Root/sudo access (for raw ICMP packets) - Root/sudo access (for raw ICMP packets)
- `ping` command available in PATH - `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.
## Starlink status
When the non-VPN physical gateway is a Starlink dish, two extra lines appear
under that pane: dish state, uptime, obstruction and alert count on the first,
and pop-ping latency and drop rate with downlink/uplink throughput on the
second. They turn red when the dish is not connected or an alert is active.
Detection is a TCP connect to the dish's fixed local endpoint,
`192.168.100.1:9200`, made over the physical interface (the same binding the
latency probes use). The dish sits behind the Starlink router, so this is not a
gateway-address check. rtnetmon probes once a minute until a dish answers and
then reads its status every few seconds; when no dish answers, nothing is drawn
and no status is fetched. The status comes from the dish's local `get_status`
gRPC call.
## Installation ## Installation
@@ -118,21 +67,11 @@ rtnetmon/
├── internal/ ├── internal/
│ ├── cli/ # command-line interface using Cobra │ ├── cli/ # command-line interface using Cobra
│ │ └── root.go │ │ └── root.go
│ ├── netdetect/ # per-platform interface/route detection and selection
│ │ ├── netdetect.go # selection logic and route parsers (pure)
│ │ ├── routes_linux.go # Linux default-route query (build-tagged)
│ │ └── routes_darwin.go # macOS default-route query (build-tagged)
│ ├── starlink/ # Starlink dish detection and status
│ │ ├── starlink.go # Status, Client interface, pure Render
│ │ ├── client.go # real gRPC client (get_status)
│ │ └── pb/ # generated bindings for the get_status RPC
│ └── monitor/ # core monitoring functionality │ └── monitor/ # core monitoring functionality
│ ├── monitor.go # monitor types, probes, logging │ ├── monitor.go # monitor types, probes, logging
│ ├── loops.go # monitoring loops (reachability, loss, TCP) │ ├── loops.go # monitoring loops (reachability, loss, TCP)
│ ├── styles.go # terminal color styles │ ├── styles.go # terminal color styles
── ui.go # user interface rendering ── 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 ├── script/ # Scripts to Rule Them All entrypoints
├── .gitea/workflows/ # CI (runs script/cibuild) ├── .gitea/workflows/ # CI (runs script/cibuild)
├── Dockerfile # lint + test gate phases and the build ├── Dockerfile # lint + test gate phases and the build
@@ -145,20 +84,15 @@ rtnetmon/
## Development ## Development
```bash ```bash
make check # run test, lint, and fmt-check (the default target)
make build # build ./bin/rtnetmon make build # build ./bin/rtnetmon
make run # build, then run ./bin/rtnetmon locally make check # run tests, lint, and fmt-check (the default target)
make dev # go run ./cmd/rtnetmon
make test # run the test suite (test phase of the Dockerfile) make test # run the test suite (test phase of the Dockerfile)
make lint # run golangci-lint (lint phase of the Dockerfile) make lint # run golangci-lint (lint phase of the Dockerfile)
make fmt # format Go code (writes) make fmt # format Go code (writes)
make fmt-check # verify formatting (read-only) make fmt-check # verify formatting (read-only)
make deps # go mod download + go mod tidy
make docker # build the Docker image 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 hooks # install the git pre-commit hook
make dev # go run ./cmd/rtnetmon
make clean # remove build artifacts make clean # remove build artifacts
``` ```
@@ -194,11 +128,8 @@ The monitor package provides an object-oriented API for programmatic use:
```go ```go
import "git.eeqj.de/sneak/rtnetmon/internal/monitor" import "git.eeqj.de/sneak/rtnetmon/internal/monitor"
// Create a new monitor for one or two interfaces // Create a new monitor
mon := monitor.NewMonitor([]monitor.IfaceSpec{ mon := monitor.NewMonitor("eth0", "Primary", "wlan0", "Backup", "/tmp/monitor.log")
{Name: "eth0", Label: "Primary"},
{Name: "wlan0", Label: "Backup"},
}, "/tmp/monitor.log")
// Configure timing parameters (optional - defaults are sensible) // Configure timing parameters (optional - defaults are sensible)
mon.ICMPTimeout = 1 * time.Second mon.ICMPTimeout = 1 * time.Second
+4 -2
View File
@@ -1,5 +1,7 @@
// Command rtnetmon is a real-time network monitoring dashboard for Linux //go:build linux
// and macOS. WTFPL, sneak@sneak.berlin.
// Command rtnetmon is a dual-interface real-time network monitoring
// dashboard for Linux. WTFPL, sneak@sneak.berlin.
package main package main
import ( import (
+1 -5
View File
@@ -6,9 +6,6 @@ require (
github.com/gdamore/tcell/v2 v2.8.1 github.com/gdamore/tcell/v2 v2.8.1
github.com/spf13/cobra v1.8.0 github.com/spf13/cobra v1.8.0
github.com/spf13/viper v1.18.2 github.com/spf13/viper v1.18.2
golang.org/x/sys v0.29.0
google.golang.org/grpc v1.67.1
google.golang.org/protobuf v1.36.6
) )
require ( require (
@@ -32,10 +29,9 @@ require (
go.uber.org/atomic v1.9.0 // indirect go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr 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/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/net v0.28.0 // indirect golang.org/x/sys v0.29.0 // indirect
golang.org/x/term v0.28.0 // indirect golang.org/x/term v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect golang.org/x/text v0.21.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
-8
View File
@@ -91,8 +91,6 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -139,12 +137,6 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 h1:e7S5W7MGGLaSu8j3YjdezkZ+m1/Nm0uRVRMEMGk26Xs=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E=
google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+2
View File
@@ -1,3 +1,5 @@
//go:build linux
package cli package cli
import "github.com/spf13/cobra" import "github.com/spf13/cobra"
+10 -57
View File
@@ -1,3 +1,5 @@
//go:build linux
// Package cli wires command-line flags to the network monitor and runs it. // Package cli wires command-line flags to the network monitor and runs it.
package cli package cli
@@ -5,14 +7,12 @@ import (
"context" "context"
"os" "os"
"os/signal" "os/signal"
"runtime"
"syscall" "syscall"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper" "github.com/spf13/viper"
"git.eeqj.de/sneak/rtnetmon/internal/monitor" "git.eeqj.de/sneak/rtnetmon/internal/monitor"
"git.eeqj.de/sneak/rtnetmon/internal/netdetect"
) )
// config holds the application configuration. // config holds the application configuration.
@@ -59,8 +59,8 @@ func newRootCmd() *cobra.Command {
Short: "Real-time network monitoring dashboard", Short: "Real-time network monitoring dashboard",
Long: `rtnetmon is a dual-interface network monitoring dashboard that provides Long: `rtnetmon is a dual-interface network monitoring dashboard that provides
real-time visibility into network health, packet loss, and latency.`, real-time visibility into network health, packet loss, and latency.`,
RunE: func(cmd *cobra.Command, _ []string) error { RunE: func(_ *cobra.Command, _ []string) error {
return runMonitor(cmd, cfg) return runMonitor(cfg)
}, },
} }
registerFlags(cmd, cfg) registerFlags(cmd, cfg)
@@ -86,22 +86,14 @@ func registerFlags(cmd *cobra.Command, cfg *config) {
} }
} }
// runMonitor detects the interfaces to monitor, then constructs and runs the // runMonitor constructs and runs the monitor from cfg.
// monitor from cfg. func runMonitor(cfg *config) error {
func runMonitor(cmd *cobra.Command, cfg *config) error {
monitor.Logf(cfg.LogFile, "Starting rtnetmon") monitor.Logf(cfg.LogFile, "Starting rtnetmon")
monitor.Logf(cfg.LogFile, "Monitoring interfaces %s and %s",
cfg.IfaceA, cfg.IfaceB)
specs, err := detectInterfaces(cmd, cfg) mon := monitor.NewMonitor(cfg.IfaceA, cfg.LabelA, cfg.IfaceB, cfg.LabelB,
if err != nil { cfg.LogFile)
return err
}
mon := monitor.NewMonitor(specs, cfg.LogFile)
// The non-VPN physical gateway is the last pane: pane B when there are
// two, the only pane when there is one. That is where a Starlink dish,
// if present upstream, is detected and its status shown.
mon.EnableStarlink(specs[len(specs)-1].Name)
for _, host := range cfg.Hosts { for _, host := range cfg.Hosts {
mon.AddReachabilityHost(host) mon.AddReachabilityHost(host)
@@ -130,45 +122,6 @@ func runMonitor(cmd *cobra.Command, cfg *config) error {
return mon.Run(ctx) return mon.Run(ctx)
} }
// detectInterfaces enumerates the host, reads its default routes, and applies
// the platform selection rules to decide which interfaces to monitor.
func detectInterfaces(
cmd *cobra.Command, cfg *config,
) ([]monitor.IfaceSpec, error) {
ifaces, err := netdetect.Interfaces()
if err != nil {
return nil, err
}
routes, err := netdetect.DefaultRoutes()
if err != nil {
return nil, err
}
flags := netdetect.Flags{
IfaceA: cfg.IfaceA,
LabelA: cfg.LabelA,
IfaceB: cfg.IfaceB,
LabelB: cfg.LabelB,
LabelASet: cmd.Flags().Changed("labelA"),
LabelBSet: cmd.Flags().Changed("labelB"),
}
panes, err := netdetect.Select(runtime.GOOS, ifaces, routes, flags)
if err != nil {
return nil, err
}
specs := make([]monitor.IfaceSpec, len(panes))
for i, p := range panes {
specs[i] = monitor.IfaceSpec{Name: p.Name, Label: p.Label}
}
monitor.Logf(cfg.LogFile, "Monitoring %d interface(s)", len(specs))
return specs, nil
}
// Execute builds the root command and runs it. // Execute builds the root command and runs it.
func Execute() error { func Execute() error {
return newRootCmd().Execute() return newRootCmd().Execute()
+2
View File
@@ -1,3 +1,5 @@
//go:build linux
package cli_test package cli_test
import ( import (
-36
View File
@@ -1,36 +0,0 @@
//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
@@ -1,12 +0,0 @@
//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
}
+6 -54
View File
@@ -1,52 +1,9 @@
//go:build linux
package monitor package monitor
import (
"context"
"git.eeqj.de/sneak/rtnetmon/internal/starlink"
)
// Test-only accessors exposing unexported state for white-box assertions. // Test-only accessors exposing unexported state for white-box assertions.
// SetStarlinkClient injects a Starlink client for iface, bypassing the real
// gRPC dialer so the loop can be driven with a fake.
func (m *Monitor) SetStarlinkClient(iface string, c starlink.Client) {
for _, st := range m.interfaces {
if st.Name == iface {
m.slPane = st
m.slState = &starlinkState{}
m.slClient = c
return
}
}
}
// StarlinkEnabled reports whether a Starlink client is configured.
func (m *Monitor) StarlinkEnabled() bool { return m.slClient != nil }
// StarlinkPaneName returns the name of the pane the Starlink lines attach
// to, or "" if none.
func (m *Monitor) StarlinkPaneName() string {
if m.slPane == nil {
return ""
}
return m.slPane.Name
}
// StarlinkProbeStep runs one detection step.
func (m *Monitor) StarlinkProbeStep(ctx context.Context) { m.starlinkProbe(ctx) }
// StarlinkRefreshStep runs one status-refresh step.
func (m *Monitor) StarlinkRefreshStep(ctx context.Context) { m.starlinkRefresh(ctx) }
// StarlinkSnapshot returns the current Starlink display state: detected,
// have-status, and the latest status.
func (m *Monitor) StarlinkSnapshot() (bool, bool, starlink.Status) {
return m.slState.snapshot()
}
// ReachabilityHosts returns the configured reachability hosts. // ReachabilityHosts returns the configured reachability hosts.
func (m *Monitor) ReachabilityHosts() []string { return m.reachabilityHosts } func (m *Monitor) ReachabilityHosts() []string { return m.reachabilityHosts }
@@ -56,13 +13,8 @@ func (m *Monitor) PacketLossHosts() []string { return m.packetLossHosts }
// TCPHosts returns the configured TCP hosts. // TCPHosts returns the configured TCP hosts.
func (m *Monitor) TCPHosts() []string { return m.tcpHosts } func (m *Monitor) TCPHosts() []string { return m.tcpHosts }
// Interfaces returns the monitored interfaces. // InterfaceA returns the first monitored interface.
func (m *Monitor) Interfaces() []*InterfaceStatus { return m.interfaces } func (m *Monitor) InterfaceA() *InterfaceStatus { return m.interfaceA }
// PingArgs exposes pingArgs for external tests. // InterfaceB returns the second monitored interface.
func PingArgs(goos, iface, host string) []string { return pingArgs(goos, iface, host) } func (m *Monitor) InterfaceB() *InterfaceStatus { return m.interfaceB }
// LossArgs exposes lossArgs for external tests.
func LossArgs(goos, iface, host string, count int) []string {
return lossArgs(goos, iface, host, count)
}
+2
View File
@@ -1,3 +1,5 @@
//go:build linux
package monitor package monitor
import ( import (
+25 -66
View File
@@ -1,6 +1,8 @@
// Package monitor implements the real-time network monitoring dashboard: //go:build linux
// ICMP reachability, packet loss, TCP latency, and the terminal UI that
// renders them. It monitors one or two interfaces. // Package monitor implements the dual-interface real-time network
// monitoring dashboard: ICMP reachability, packet loss, TCP latency, and
// the terminal UI that renders them.
package monitor package monitor
import ( import (
@@ -12,15 +14,12 @@ import (
"net" "net"
"os" "os"
"os/exec" "os/exec"
"runtime"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
tcell "github.com/gdamore/tcell/v2" tcell "github.com/gdamore/tcell/v2"
"git.eeqj.de/sneak/rtnetmon/internal/starlink"
) )
// Meter glyphs used to render the ASCII loss meter. // Meter glyphs used to render the ASCII loss meter.
@@ -58,7 +57,7 @@ const (
ipInfoTimeout = 2 * time.Second ipInfoTimeout = 2 * time.Second
lossQueryTimeout = 3 * time.Second lossQueryTimeout = 3 * time.Second
uiUpdateBuffer = 100 uiUpdateBuffer = 100
logFileMode = 0o644 logFileMode = 0o600
) )
// errNoIPv4 is returned when an interface has no usable IPv4 address. // errNoIPv4 is returned when an interface has no usable IPv4 address.
@@ -89,14 +88,9 @@ type Monitor struct {
packetLossHosts []string packetLossHosts []string
tcpHosts []string tcpHosts []string
// Interfaces to monitor (one or two) // Interfaces
interfaces []*InterfaceStatus interfaceA *InterfaceStatus
interfaceB *InterfaceStatus
// Starlink status for the physical (non-VPN gateway) pane. All three
// are nil unless EnableStarlink was called for a monitored interface.
slClient starlink.Client
slState *starlinkState
slPane *InterfaceStatus
// Logging // Logging
logFile string logFile string
@@ -110,16 +104,9 @@ type Monitor struct {
mu sync.RWMutex mu sync.RWMutex
} }
// IfaceSpec names one interface to monitor and its display label. // NewMonitor creates a new Monitor instance with default settings.
type IfaceSpec struct { func NewMonitor(ifaceA, labelA, ifaceB, labelB, logFile string) *Monitor {
Name string return &Monitor{
Label string
}
// NewMonitor creates a new Monitor instance with default settings,
// monitoring the given interfaces (one or two).
func NewMonitor(ifaces []IfaceSpec, logFile string) *Monitor {
m := &Monitor{
ICMPTimeout: defaultICMPTimeout, ICMPTimeout: defaultICMPTimeout,
TCPTimeout: defaultTCPTimeout, TCPTimeout: defaultTCPTimeout,
PacketLossPings: defaultPacketLossPings, PacketLossPings: defaultPacketLossPings,
@@ -140,16 +127,13 @@ func NewMonitor(ifaces []IfaceSpec, logFile string) *Monitor {
packetLossHosts: []string{}, packetLossHosts: []string{},
tcpHosts: []string{}, tcpHosts: []string{},
interfaceA: NewInterfaceStatus(ifaceA, labelA),
interfaceB: NewInterfaceStatus(ifaceB, labelB),
logFile: logFile, logFile: logFile,
startTime: time.Now(), startTime: time.Now(),
uiUpdate: make(chan struct{}, uiUpdateBuffer), 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.
@@ -210,13 +194,12 @@ func (m *Monitor) Run(ctx context.Context) error {
tcpHosts := append([]string{}, m.tcpHosts...) tcpHosts := append([]string{}, m.tcpHosts...)
m.mu.RUnlock() m.mu.RUnlock()
for _, st := range m.interfaces { go m.reachLoop(ctx, m.interfaceA, reachHosts)
go m.reachLoop(ctx, st, reachHosts) go m.reachLoop(ctx, m.interfaceB, reachHosts)
go m.lossLoop(ctx, st, lossHosts) go m.lossLoop(ctx, m.interfaceA, lossHosts)
go m.tcpLoop(ctx, st, tcpHosts) go m.lossLoop(ctx, m.interfaceB, lossHosts)
} go m.tcpLoop(ctx, m.interfaceA, tcpHosts)
go m.tcpLoop(ctx, m.interfaceB, tcpHosts)
go m.starlinkLoop(ctx)
m.logf("Starting UI loop") m.logf("Starting UI loop")
m.uiLoop(ctx) m.uiLoop(ctx)
@@ -323,36 +306,13 @@ func fetchIPInfo(iface string) string {
return fmt.Sprintf("%s [%s] %s", r.IP, r.Hostname, r.Org) return fmt.Sprintf("%s [%s] %s", r.IP, r.Hostname, r.Org)
} }
// pingArgs builds the arguments for a single reachability ping on the given
// OS. Linux binds the interface with -I and takes -W in seconds; macOS binds
// with -b and takes -W in milliseconds.
func pingArgs(goos, iface, host string) []string {
if goos == "darwin" {
return []string{"-b", iface, "-c1", "-W1000", host}
}
return []string{"-I", iface, "-c1", "-W1", host}
}
// lossArgs builds the arguments for a packet-loss ping burst of count pings.
func lossArgs(goos, iface, host string, count int) []string {
c := strconv.Itoa(count)
if goos == "darwin" {
return []string{"-q", "-i", "0.05", "-c", c, "-W1000", "-b", iface, host}
}
return []string{"-q", "-i", "0.05", "-c", c, "-W1", "-I", iface, host}
}
// pingOnce performs a single ping over the named interface. // pingOnce performs a single ping over the named interface.
func (m *Monitor) pingOnce(ctx context.Context, iface, host string) bool { func (m *Monitor) pingOnce(ctx context.Context, iface, host string) bool {
ctx, cancel := context.WithTimeout(ctx, m.ICMPTimeout) ctx, cancel := context.WithTimeout(ctx, m.ICMPTimeout)
defer cancel() defer cancel()
args := pingArgs(runtime.GOOS, iface, host)
cmd := exec.CommandContext( //nolint:gosec // G204: fixed argv, operator CLI input cmd := exec.CommandContext( //nolint:gosec // G204: fixed argv, operator CLI input
ctx, "ping", args...) ctx, "ping", "-I", iface, "-c1", "-W1", host)
return cmd.Run() == nil return cmd.Run() == nil
} }
@@ -362,10 +322,9 @@ func (m *Monitor) lossPercent(ctx context.Context, iface, host string) float64 {
ctx, cancel := context.WithTimeout(ctx, lossQueryTimeout) ctx, cancel := context.WithTimeout(ctx, lossQueryTimeout)
defer cancel() defer cancel()
args := lossArgs(runtime.GOOS, iface, host, m.PacketLossPings)
cmd := exec.CommandContext( //nolint:gosec // G204: fixed argv, operator CLI input cmd := exec.CommandContext( //nolint:gosec // G204: fixed argv, operator CLI input
ctx, "ping", args...) ctx, "ping", "-q", "-i", "0.05",
"-c", strconv.Itoa(m.PacketLossPings), "-W1", "-I", iface, host)
out, err := cmd.CombinedOutput() out, err := cmd.CombinedOutput()
if err != nil { if err != nil {
@@ -416,7 +375,7 @@ func (m *Monitor) tcpDuration(iface, hp string) time.Duration {
return m.TCPTimeout return m.TCPTimeout
} }
d := net.Dialer{Timeout: m.TCPTimeout, LocalAddr: la, Control: bindControl(iface)} d := net.Dialer{Timeout: m.TCPTimeout, LocalAddr: la}
st := time.Now() st := time.Now()
c, err := d.Dial("tcp", hp) c, err := d.Dial("tcp", hp)
+16 -40
View File
@@ -1,3 +1,5 @@
//go:build linux
package monitor_test package monitor_test
import ( import (
@@ -6,33 +8,26 @@ import (
"git.eeqj.de/sneak/rtnetmon/internal/monitor" "git.eeqj.de/sneak/rtnetmon/internal/monitor"
) )
// Interface names and hosts reused across the monitor package tests.
const (
ifaceTest0 = "test0"
ifaceTest1 = "test1"
ifaceEth0 = "eth0"
host8888 = "8.8.8.8"
)
func TestNewMonitor(t *testing.T) { func TestNewMonitor(t *testing.T) {
t.Parallel() t.Parallel()
mon := monitor.NewMonitor([]monitor.IfaceSpec{ mon := monitor.NewMonitor("test0", "Test Interface A", "test1",
{Name: ifaceTest0, Label: "Test Interface A"}, "Test Interface B", "/tmp/test.log")
{Name: ifaceTest1, Label: "Test Interface B"},
}, "/tmp/test.log")
ifaces := mon.Interfaces() if mon.InterfaceA() == nil {
if len(ifaces) != 2 { t.Fatal("interfaceA is nil")
t.Fatalf("interfaces = %d, want 2", len(ifaces))
} }
if ifaces[0].Name != ifaceTest0 { if mon.InterfaceB() == nil {
t.Errorf("interfaces[0].Name = %q, want %q", ifaces[0].Name, ifaceTest0) t.Fatal("interfaceB is nil")
} }
if ifaces[1].Name != ifaceTest1 { if mon.InterfaceA().Name != "test0" {
t.Errorf("interfaces[1].Name = %q, want %q", ifaces[1].Name, ifaceTest1) t.Errorf("interfaceA.Name = %q, want %q", mon.InterfaceA().Name, "test0")
}
if mon.InterfaceB().Name != "test1" {
t.Errorf("interfaceB.Name = %q, want %q", mon.InterfaceB().Name, "test1")
} }
if mon.ICMPTimeout.Milliseconds() != 500 { if mon.ICMPTimeout.Milliseconds() != 500 {
@@ -56,31 +51,12 @@ func TestNewMonitor(t *testing.T) {
} }
} }
func TestNewMonitorSingle(t *testing.T) {
t.Parallel()
mon := monitor.NewMonitor(
[]monitor.IfaceSpec{{Name: ifaceEth0, Label: "default route"}}, "")
ifaces := mon.Interfaces()
if len(ifaces) != 1 {
t.Fatalf("interfaces = %d, want 1", len(ifaces))
}
if ifaces[0].Name != ifaceEth0 {
t.Errorf("interfaces[0].Name = %q, want %q", ifaces[0].Name, ifaceEth0)
}
}
func TestAddHosts(t *testing.T) { func TestAddHosts(t *testing.T) {
t.Parallel() t.Parallel()
mon := monitor.NewMonitor([]monitor.IfaceSpec{ mon := monitor.NewMonitor("test0", "Test A", "test1", "Test B", "")
{Name: ifaceTest0, Label: "Test A"},
{Name: ifaceTest1, Label: "Test B"},
}, "")
mon.AddReachabilityHost(host8888) mon.AddReachabilityHost("8.8.8.8")
mon.AddReachabilityHost("google.com") mon.AddReachabilityHost("google.com")
if len(mon.ReachabilityHosts()) != 2 { if len(mon.ReachabilityHosts()) != 2 {
-58
View File
@@ -1,58 +0,0 @@
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)
}
})
}
}
-166
View File
@@ -1,166 +0,0 @@
package monitor
import (
"context"
"net"
"sync"
"time"
tcell "github.com/gdamore/tcell/v2"
"git.eeqj.de/sneak/rtnetmon/internal/starlink"
)
// Starlink probe and status cadence: detect once a minute until a dish
// answers, then refresh the status every few seconds.
const (
starlinkProbePeriod = time.Minute
starlinkStatusPeriod = 5 * time.Second
)
// starlinkState holds the latest dish status for the physical pane. Nothing
// is drawn until a dish first answers, so an absent dish adds no lines.
type starlinkState struct {
mu sync.RWMutex
detected bool
haveStatus bool
status starlink.Status
}
// snapshot returns whether a dish was detected, whether a status has been
// read, and the latest status, all under the read lock.
func (s *starlinkState) snapshot() (bool, bool, starlink.Status) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.detected, s.haveStatus, s.status
}
// EnableStarlink turns on Starlink detection for the named interface, which
// must be the non-VPN physical gateway pane. It is a no-op if no monitored
// interface has that name.
func (m *Monitor) EnableStarlink(iface string) {
for _, st := range m.interfaces {
if st.Name == iface {
m.slPane = st
m.slState = &starlinkState{}
m.slClient = starlink.NewClient(m.starlinkDialer(iface))
return
}
}
}
// starlinkDialer returns a dialer that binds to iface exactly as the TCP
// latency probes do, so the dish is reached over the physical interface.
func (m *Monitor) starlinkDialer(iface string) starlink.DialFunc {
return func(ctx context.Context, addr string) (net.Conn, error) {
la, err := localAddr(iface)
if err != nil {
return nil, err
}
d := net.Dialer{LocalAddr: la, Control: bindControl(iface)}
return d.DialContext(ctx, "tcp", addr)
}
}
// starlinkLoop probes for a dish and refreshes its status until the context
// is cancelled. It does nothing when Starlink was not enabled.
func (m *Monitor) starlinkLoop(ctx context.Context) {
if m.slClient == nil {
return
}
m.logf("Starting Starlink monitoring for %s", m.slPane.Name)
m.starlinkProbe(ctx)
probe := time.NewTicker(starlinkProbePeriod)
status := time.NewTicker(starlinkStatusPeriod)
defer probe.Stop()
defer status.Stop()
for {
select {
case <-ctx.Done():
m.logf("Stopping Starlink monitoring for %s", m.slPane.Name)
return
case <-probe.C:
m.starlinkProbe(ctx)
case <-status.C:
m.starlinkRefresh(ctx)
}
}
}
// starlinkProbe detects the dish; once detected it stays detected, and a
// first status is fetched immediately.
func (m *Monitor) starlinkProbe(ctx context.Context) {
if detected, _, _ := m.slState.snapshot(); detected {
return
}
if !m.slClient.Probe(ctx) {
return
}
m.slState.mu.Lock()
m.slState.detected = true
m.slState.mu.Unlock()
m.notifyUI()
m.starlinkRefresh(ctx)
}
// starlinkRefresh fetches status only once a dish has been detected, so an
// absent dish produces no status traffic.
func (m *Monitor) starlinkRefresh(ctx context.Context) {
if detected, _, _ := m.slState.snapshot(); !detected {
return
}
st, err := m.slClient.Status(ctx)
if err != nil {
m.logf("Starlink status error: %v", err)
return
}
m.slState.mu.Lock()
m.slState.status = st
m.slState.haveStatus = true
m.slState.mu.Unlock()
m.notifyUI()
}
// drawStarlink draws the two dish status lines under the physical pane. It
// draws nothing until a dish has answered.
func (m *Monitor) drawStarlink(scr tcell.Screen, y int) int {
detected, haveStatus, st := m.slState.snapshot()
if !detected {
return y
}
if !haveStatus {
Put(scr, colLeft, y, "Starlink: detected, status unavailable", styleBrightRed())
return y + lineStep
}
line1, line2, alert := starlink.Render(st)
style := tcell.StyleDefault
if alert {
style = styleBrightRed()
}
Put(scr, colLeft, y, line1, style)
y += lineStep
Put(scr, colLeft, y, line2, style)
return y + lineStep
}
-141
View File
@@ -1,141 +0,0 @@
package monitor_test
import (
"context"
"sync"
"testing"
"time"
"git.eeqj.de/sneak/rtnetmon/internal/monitor"
"git.eeqj.de/sneak/rtnetmon/internal/starlink"
)
// fakeDish is a Starlink client for tests: no dish, no network. It records
// how often each method is called and returns programmed results.
type fakeDish struct {
mu sync.Mutex
probeOK bool
status starlink.Status
statusErr error
probeCalls int
statusCalls int
}
func (f *fakeDish) Probe(_ context.Context) bool {
f.mu.Lock()
defer f.mu.Unlock()
f.probeCalls++
return f.probeOK
}
func (f *fakeDish) Status(_ context.Context) (starlink.Status, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.statusCalls++
return f.status, f.statusErr
}
func (f *fakeDish) counts() (int, int) {
f.mu.Lock()
defer f.mu.Unlock()
return f.probeCalls, f.statusCalls
}
func newTwoPaneMonitor() *monitor.Monitor {
return monitor.NewMonitor([]monitor.IfaceSpec{
{Name: ifaceTest0, Label: "A"},
{Name: ifaceTest1, Label: "B"},
}, "")
}
func TestEnableStarlinkSelectsPane(t *testing.T) {
t.Parallel()
mon := newTwoPaneMonitor()
mon.EnableStarlink(ifaceTest1)
if !mon.StarlinkEnabled() {
t.Fatal("StarlinkEnabled = false, want true")
}
if got := mon.StarlinkPaneName(); got != ifaceTest1 {
t.Errorf("StarlinkPaneName = %q, want %q", got, ifaceTest1)
}
}
func TestEnableStarlinkUnknownInterface(t *testing.T) {
t.Parallel()
mon := newTwoPaneMonitor()
mon.EnableStarlink("does-not-exist")
if mon.StarlinkEnabled() {
t.Error("StarlinkEnabled = true for an unknown interface, want false")
}
}
// TestStarlinkNoDishNoStatus verifies that when no dish answers, no status
// is ever fetched and nothing is marked detected.
func TestStarlinkNoDishNoStatus(t *testing.T) {
t.Parallel()
mon := newTwoPaneMonitor()
fake := &fakeDish{probeOK: false}
mon.SetStarlinkClient(ifaceTest1, fake)
ctx := context.Background()
mon.StarlinkProbeStep(ctx)
mon.StarlinkRefreshStep(ctx)
detected, haveStatus, _ := mon.StarlinkSnapshot()
if detected || haveStatus {
t.Errorf("detected=%v haveStatus=%v, want both false", detected, haveStatus)
}
if _, status := fake.counts(); status != 0 {
t.Errorf("status calls = %d, want 0 (no probing noise)", status)
}
}
// TestStarlinkDetectedFetchesStatus verifies that once a dish answers,
// detection triggers a status fetch and later refreshes fetch again.
func TestStarlinkDetectedFetchesStatus(t *testing.T) {
t.Parallel()
mon := newTwoPaneMonitor()
want := starlink.Status{
State: "CONNECTED",
Uptime: 2 * time.Hour,
DownlinkMbps: 100,
}
fake := &fakeDish{probeOK: true, status: want}
mon.SetStarlinkClient(ifaceTest1, fake)
ctx := context.Background()
mon.StarlinkProbeStep(ctx)
detected, haveStatus, got := mon.StarlinkSnapshot()
if !detected || !haveStatus {
t.Fatalf("detected=%v haveStatus=%v, want both true", detected, haveStatus)
}
if got != want {
t.Errorf("status = %+v, want %+v", got, want)
}
mon.StarlinkRefreshStep(ctx)
probe, status := fake.counts()
if probe != 1 {
t.Errorf("probe calls = %d, want 1", probe)
}
if status != 2 {
t.Errorf("status calls = %d, want 2", status)
}
}
+2
View File
@@ -1,3 +1,5 @@
//go:build linux
package monitor package monitor
import tcell "github.com/gdamore/tcell/v2" import tcell "github.com/gdamore/tcell/v2"
+2
View File
@@ -1,3 +1,5 @@
//go:build linux
package monitor_test package monitor_test
import ( import (
+4 -9
View File
@@ -1,3 +1,5 @@
//go:build linux
package monitor package monitor
import ( import (
@@ -107,11 +109,6 @@ func (m *Monitor) DrawInterface(scr tcell.Screen, y, w int, st *InterfaceStatus)
y = m.drawTCPTable(scr, y, st, tcpHosts) y = m.drawTCPTable(scr, y, st, tcpHosts)
y = drawICMPStats(scr, y, st) y = drawICMPStats(scr, y, st)
// The Starlink lines belong to the physical (non-VPN gateway) pane only.
if st == m.slPane {
y = m.drawStarlink(scr, y)
}
return y return y
} }
@@ -386,11 +383,9 @@ func (m *Monitor) uiLoop(ctx context.Context) {
runtime := time.Since(m.startTime).Round(time.Second).String() runtime := time.Since(m.startTime).Round(time.Second).String()
Put(m.screen, colLeft, rowRuntime, "Runtime: "+runtime, tcell.StyleDefault) Put(m.screen, colLeft, rowRuntime, "Runtime: "+runtime, tcell.StyleDefault)
// Draw one pane per interface; a single interface draws one pane.
y := rowFirstIface y := rowFirstIface
for _, st := range m.interfaces { y = m.DrawInterface(m.screen, y, w, m.interfaceA)
y = m.DrawInterface(m.screen, y, w, st) _ = m.DrawInterface(m.screen, y, w, m.interfaceB)
}
m.screen.Show() m.screen.Show()
} }
-12
View File
@@ -1,12 +0,0 @@
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
@@ -1,375 +0,0 @@
// 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
@@ -1,310 +0,0 @@
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
@@ -1,27 +0,0 @@
//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
@@ -1,36 +0,0 @@
//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
}
-129
View File
@@ -1,129 +0,0 @@
package starlink
import (
"context"
"errors"
"fmt"
"net"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
pb "git.eeqj.de/sneak/rtnetmon/internal/starlink/pb"
)
// errNoDishStatus is returned when the dish answers but the response
// carries no dish status.
var errNoDishStatus = errors.New("no dish status in response")
// Per-call deadlines so a silent dish never wedges the monitor loop.
const (
probeTimeout = 2 * time.Second
statusTimeout = 3 * time.Second
)
// DialFunc opens a TCP connection to addr bound to a chosen interface. The
// monitor supplies one that binds the same way its TCP latency probes do,
// so the dish is reached over the physical (non-VPN) interface rather than
// a VPN tunnel that may hold the default route.
type DialFunc func(ctx context.Context, addr string) (net.Conn, error)
// GRPCClient is the real Client: it reaches a dish at Address over whatever
// interface dial binds to.
type GRPCClient struct {
dial DialFunc
}
// NewClient returns a client that talks to a dish at Address, dialing with
// dial so the connection leaves the intended interface.
func NewClient(dial DialFunc) *GRPCClient {
return &GRPCClient{dial: dial}
}
// Probe connects to the dish's endpoint and closes it. A successful TCP
// connect over the bound interface is the detection signal.
func (c *GRPCClient) Probe(ctx context.Context) bool {
ctx, cancel := context.WithTimeout(ctx, probeTimeout)
defer cancel()
conn, err := c.dial(ctx, Address)
if err != nil {
return false
}
_ = conn.Close()
return true
}
// Status calls the dish's get_status RPC and reduces the response to the
// fields rtnetmon shows.
func (c *GRPCClient) Status(ctx context.Context) (Status, error) {
ctx, cancel := context.WithTimeout(ctx, statusTimeout)
defer cancel()
// passthrough:/// hands Address straight to our dialer, with no name
// resolution, so the connection is made over the bound interface.
conn, err := grpc.NewClient("passthrough:///"+Address,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) {
return c.dial(ctx, addr)
}),
)
if err != nil {
return Status{}, fmt.Errorf("starlink dial: %w", err)
}
defer func() { _ = conn.Close() }()
resp, err := pb.NewDeviceClient(conn).Handle(ctx, &pb.Request{
Request: &pb.Request_GetStatus{GetStatus: &pb.GetStatusRequest{}},
})
if err != nil {
return Status{}, fmt.Errorf("starlink get_status: %w", err)
}
dish := resp.GetDishGetStatus()
if dish == nil {
return Status{}, errNoDishStatus
}
return statusFromDish(dish), nil
}
// statusFromDish reduces the dish's status message to a Status. The
// generated getters are nil-safe, so absent sub-messages read as zero.
func statusFromDish(d *pb.DishGetStatusResponse) Status {
obstruction := float64(d.GetObstructionStats().GetFractionObstructed())
//nolint:gosec // G115: dish uptime in seconds never approaches int64 max
uptime := time.Duration(d.GetDeviceState().GetUptimeS()) * time.Second
return Status{
State: d.GetState().String(),
Uptime: uptime,
ObstructionPct: obstruction * percentScale,
Alerts: countAlerts(d.GetAlerts()),
PopPingMs: float64(d.GetPopPingLatencyMs()),
PopPingDropPct: float64(d.GetPopPingDropRate()) * percentScale,
DownlinkMbps: float64(d.GetDownlinkThroughputBps()) / bitsPerMegabit,
UplinkMbps: float64(d.GetUplinkThroughputBps()) / bitsPerMegabit,
}
}
// countAlerts counts the active dish alert flags.
func countAlerts(a *pb.DishAlerts) int {
n := 0
for _, on := range []bool{
a.GetMotorsStuck(),
a.GetThermalShutdown(),
a.GetThermalThrottle(),
a.GetUnexpectedLocation(),
} {
if on {
n++
}
}
return n
}
-629
View File
@@ -1,629 +0,0 @@
// A minimal slice of the Starlink dish's local gRPC API: just the one
// unary RPC (SpaceX.API.Device.Device/Handle) and the get_status request
// and dish status response, reduced to the handful of fields rtnetmon
// shows. The proto package name and field numbers must match the dish
// exactly for the wire format to line up; every other field the dish sends
// is ignored as an unknown field.
//
// Field numbers, message names and the DishState enum are transcribed from
// the community-maintained pre-generated bindings at
// github.com/starlink-community/starlink-grpc-go, commit
// 2e89f3d7e3092adecbcc04e9e003e61ed5f7c6a3 (get_status = 1004,
// dish_get_status = 2004). They are not verified against a live dish here.
//
// Regenerate with:
// protoc --go_out=. --go_opt=paths=source_relative \
// --go-grpc_out=. --go-grpc_opt=paths=source_relative \
// internal/starlink/pb/starlink.proto
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.6
// protoc v7.36.2
// source: internal/starlink/pb/starlink.proto
package starlinkpb
import (
reflect "reflect"
sync "sync"
unsafe "unsafe"
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type DishState int32
const (
DishState_UNKNOWN DishState = 0
DishState_CONNECTED DishState = 1
DishState_SEARCHING DishState = 2
DishState_BOOTING DishState = 3
)
// Enum value maps for DishState.
var (
DishState_name = map[int32]string{
0: "UNKNOWN",
1: "CONNECTED",
2: "SEARCHING",
3: "BOOTING",
}
DishState_value = map[string]int32{
"UNKNOWN": 0,
"CONNECTED": 1,
"SEARCHING": 2,
"BOOTING": 3,
}
)
func (x DishState) Enum() *DishState {
p := new(DishState)
*p = x
return p
}
func (x DishState) String() string {
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
}
func (DishState) Descriptor() protoreflect.EnumDescriptor {
return file_internal_starlink_pb_starlink_proto_enumTypes[0].Descriptor()
}
func (DishState) Type() protoreflect.EnumType {
return &file_internal_starlink_pb_starlink_proto_enumTypes[0]
}
func (x DishState) Number() protoreflect.EnumNumber {
return protoreflect.EnumNumber(x)
}
// Deprecated: Use DishState.Descriptor instead.
func (DishState) EnumDescriptor() ([]byte, []int) {
return file_internal_starlink_pb_starlink_proto_rawDescGZIP(), []int{0}
}
type Request struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Types that are valid to be assigned to Request:
//
// *Request_GetStatus
Request isRequest_Request `protobuf_oneof:"request"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Request) Reset() {
*x = Request{}
mi := &file_internal_starlink_pb_starlink_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Request) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Request) ProtoMessage() {}
func (x *Request) ProtoReflect() protoreflect.Message {
mi := &file_internal_starlink_pb_starlink_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Request.ProtoReflect.Descriptor instead.
func (*Request) Descriptor() ([]byte, []int) {
return file_internal_starlink_pb_starlink_proto_rawDescGZIP(), []int{0}
}
func (x *Request) GetRequest() isRequest_Request {
if x != nil {
return x.Request
}
return nil
}
func (x *Request) GetGetStatus() *GetStatusRequest {
if x != nil {
if x, ok := x.Request.(*Request_GetStatus); ok {
return x.GetStatus
}
}
return nil
}
type isRequest_Request interface {
isRequest_Request()
}
type Request_GetStatus struct {
GetStatus *GetStatusRequest `protobuf:"bytes,1004,opt,name=get_status,json=getStatus,proto3,oneof"`
}
func (*Request_GetStatus) isRequest_Request() {}
type GetStatusRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetStatusRequest) Reset() {
*x = GetStatusRequest{}
mi := &file_internal_starlink_pb_starlink_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetStatusRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetStatusRequest) ProtoMessage() {}
func (x *GetStatusRequest) ProtoReflect() protoreflect.Message {
mi := &file_internal_starlink_pb_starlink_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetStatusRequest.ProtoReflect.Descriptor instead.
func (*GetStatusRequest) Descriptor() ([]byte, []int) {
return file_internal_starlink_pb_starlink_proto_rawDescGZIP(), []int{1}
}
type Response struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Types that are valid to be assigned to Response:
//
// *Response_DishGetStatus
Response isResponse_Response `protobuf_oneof:"response"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Response) Reset() {
*x = Response{}
mi := &file_internal_starlink_pb_starlink_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Response) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Response) ProtoMessage() {}
func (x *Response) ProtoReflect() protoreflect.Message {
mi := &file_internal_starlink_pb_starlink_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Response.ProtoReflect.Descriptor instead.
func (*Response) Descriptor() ([]byte, []int) {
return file_internal_starlink_pb_starlink_proto_rawDescGZIP(), []int{2}
}
func (x *Response) GetResponse() isResponse_Response {
if x != nil {
return x.Response
}
return nil
}
func (x *Response) GetDishGetStatus() *DishGetStatusResponse {
if x != nil {
if x, ok := x.Response.(*Response_DishGetStatus); ok {
return x.DishGetStatus
}
}
return nil
}
type isResponse_Response interface {
isResponse_Response()
}
type Response_DishGetStatus struct {
DishGetStatus *DishGetStatusResponse `protobuf:"bytes,2004,opt,name=dish_get_status,json=dishGetStatus,proto3,oneof"`
}
func (*Response_DishGetStatus) isResponse_Response() {}
type DishGetStatusResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
DeviceState *DeviceState `protobuf:"bytes,2,opt,name=device_state,json=deviceState,proto3" json:"device_state,omitempty"`
ObstructionStats *DishObstructionStats `protobuf:"bytes,1004,opt,name=obstruction_stats,json=obstructionStats,proto3" json:"obstruction_stats,omitempty"`
Alerts *DishAlerts `protobuf:"bytes,1005,opt,name=alerts,proto3" json:"alerts,omitempty"`
State DishState `protobuf:"varint,1006,opt,name=state,proto3,enum=SpaceX.API.Device.DishState" json:"state,omitempty"`
PopPingDropRate float32 `protobuf:"fixed32,1003,opt,name=pop_ping_drop_rate,json=popPingDropRate,proto3" json:"pop_ping_drop_rate,omitempty"`
DownlinkThroughputBps float32 `protobuf:"fixed32,1007,opt,name=downlink_throughput_bps,json=downlinkThroughputBps,proto3" json:"downlink_throughput_bps,omitempty"`
UplinkThroughputBps float32 `protobuf:"fixed32,1008,opt,name=uplink_throughput_bps,json=uplinkThroughputBps,proto3" json:"uplink_throughput_bps,omitempty"`
PopPingLatencyMs float32 `protobuf:"fixed32,1009,opt,name=pop_ping_latency_ms,json=popPingLatencyMs,proto3" json:"pop_ping_latency_ms,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DishGetStatusResponse) Reset() {
*x = DishGetStatusResponse{}
mi := &file_internal_starlink_pb_starlink_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DishGetStatusResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DishGetStatusResponse) ProtoMessage() {}
func (x *DishGetStatusResponse) ProtoReflect() protoreflect.Message {
mi := &file_internal_starlink_pb_starlink_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DishGetStatusResponse.ProtoReflect.Descriptor instead.
func (*DishGetStatusResponse) Descriptor() ([]byte, []int) {
return file_internal_starlink_pb_starlink_proto_rawDescGZIP(), []int{3}
}
func (x *DishGetStatusResponse) GetDeviceState() *DeviceState {
if x != nil {
return x.DeviceState
}
return nil
}
func (x *DishGetStatusResponse) GetObstructionStats() *DishObstructionStats {
if x != nil {
return x.ObstructionStats
}
return nil
}
func (x *DishGetStatusResponse) GetAlerts() *DishAlerts {
if x != nil {
return x.Alerts
}
return nil
}
func (x *DishGetStatusResponse) GetState() DishState {
if x != nil {
return x.State
}
return DishState_UNKNOWN
}
func (x *DishGetStatusResponse) GetPopPingDropRate() float32 {
if x != nil {
return x.PopPingDropRate
}
return 0
}
func (x *DishGetStatusResponse) GetDownlinkThroughputBps() float32 {
if x != nil {
return x.DownlinkThroughputBps
}
return 0
}
func (x *DishGetStatusResponse) GetUplinkThroughputBps() float32 {
if x != nil {
return x.UplinkThroughputBps
}
return 0
}
func (x *DishGetStatusResponse) GetPopPingLatencyMs() float32 {
if x != nil {
return x.PopPingLatencyMs
}
return 0
}
type DeviceState struct {
state protoimpl.MessageState `protogen:"open.v1"`
UptimeS uint64 `protobuf:"varint,1,opt,name=uptime_s,json=uptimeS,proto3" json:"uptime_s,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DeviceState) Reset() {
*x = DeviceState{}
mi := &file_internal_starlink_pb_starlink_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DeviceState) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DeviceState) ProtoMessage() {}
func (x *DeviceState) ProtoReflect() protoreflect.Message {
mi := &file_internal_starlink_pb_starlink_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DeviceState.ProtoReflect.Descriptor instead.
func (*DeviceState) Descriptor() ([]byte, []int) {
return file_internal_starlink_pb_starlink_proto_rawDescGZIP(), []int{4}
}
func (x *DeviceState) GetUptimeS() uint64 {
if x != nil {
return x.UptimeS
}
return 0
}
type DishObstructionStats struct {
state protoimpl.MessageState `protogen:"open.v1"`
FractionObstructed float32 `protobuf:"fixed32,1,opt,name=fraction_obstructed,json=fractionObstructed,proto3" json:"fraction_obstructed,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DishObstructionStats) Reset() {
*x = DishObstructionStats{}
mi := &file_internal_starlink_pb_starlink_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DishObstructionStats) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DishObstructionStats) ProtoMessage() {}
func (x *DishObstructionStats) ProtoReflect() protoreflect.Message {
mi := &file_internal_starlink_pb_starlink_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DishObstructionStats.ProtoReflect.Descriptor instead.
func (*DishObstructionStats) Descriptor() ([]byte, []int) {
return file_internal_starlink_pb_starlink_proto_rawDescGZIP(), []int{5}
}
func (x *DishObstructionStats) GetFractionObstructed() float32 {
if x != nil {
return x.FractionObstructed
}
return 0
}
type DishAlerts struct {
state protoimpl.MessageState `protogen:"open.v1"`
MotorsStuck bool `protobuf:"varint,1,opt,name=motors_stuck,json=motorsStuck,proto3" json:"motors_stuck,omitempty"`
ThermalShutdown bool `protobuf:"varint,2,opt,name=thermal_shutdown,json=thermalShutdown,proto3" json:"thermal_shutdown,omitempty"`
ThermalThrottle bool `protobuf:"varint,3,opt,name=thermal_throttle,json=thermalThrottle,proto3" json:"thermal_throttle,omitempty"`
UnexpectedLocation bool `protobuf:"varint,4,opt,name=unexpected_location,json=unexpectedLocation,proto3" json:"unexpected_location,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DishAlerts) Reset() {
*x = DishAlerts{}
mi := &file_internal_starlink_pb_starlink_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DishAlerts) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DishAlerts) ProtoMessage() {}
func (x *DishAlerts) ProtoReflect() protoreflect.Message {
mi := &file_internal_starlink_pb_starlink_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DishAlerts.ProtoReflect.Descriptor instead.
func (*DishAlerts) Descriptor() ([]byte, []int) {
return file_internal_starlink_pb_starlink_proto_rawDescGZIP(), []int{6}
}
func (x *DishAlerts) GetMotorsStuck() bool {
if x != nil {
return x.MotorsStuck
}
return false
}
func (x *DishAlerts) GetThermalShutdown() bool {
if x != nil {
return x.ThermalShutdown
}
return false
}
func (x *DishAlerts) GetThermalThrottle() bool {
if x != nil {
return x.ThermalThrottle
}
return false
}
func (x *DishAlerts) GetUnexpectedLocation() bool {
if x != nil {
return x.UnexpectedLocation
}
return false
}
var File_internal_starlink_pb_starlink_proto protoreflect.FileDescriptor
const file_internal_starlink_pb_starlink_proto_rawDesc = "" +
"\n" +
"#internal/starlink/pb/starlink.proto\x12\x11SpaceX.API.Device\"[\n" +
"\aRequest\x12E\n" +
"\n" +
"get_status\x18\xec\a \x01(\v2#.SpaceX.API.Device.GetStatusRequestH\x00R\tgetStatusB\t\n" +
"\arequest\"\x12\n" +
"\x10GetStatusRequest\"k\n" +
"\bResponse\x12S\n" +
"\x0fdish_get_status\x18\xd4\x0f \x01(\v2(.SpaceX.API.Device.DishGetStatusResponseH\x00R\rdishGetStatusB\n" +
"\n" +
"\bresponse\"\xea\x03\n" +
"\x15DishGetStatusResponse\x12A\n" +
"\fdevice_state\x18\x02 \x01(\v2\x1e.SpaceX.API.Device.DeviceStateR\vdeviceState\x12U\n" +
"\x11obstruction_stats\x18\xec\a \x01(\v2'.SpaceX.API.Device.DishObstructionStatsR\x10obstructionStats\x126\n" +
"\x06alerts\x18\xed\a \x01(\v2\x1d.SpaceX.API.Device.DishAlertsR\x06alerts\x123\n" +
"\x05state\x18\xee\a \x01(\x0e2\x1c.SpaceX.API.Device.DishStateR\x05state\x12,\n" +
"\x12pop_ping_drop_rate\x18\xeb\a \x01(\x02R\x0fpopPingDropRate\x127\n" +
"\x17downlink_throughput_bps\x18\xef\a \x01(\x02R\x15downlinkThroughputBps\x123\n" +
"\x15uplink_throughput_bps\x18\xf0\a \x01(\x02R\x13uplinkThroughputBps\x12.\n" +
"\x13pop_ping_latency_ms\x18\xf1\a \x01(\x02R\x10popPingLatencyMs\"(\n" +
"\vDeviceState\x12\x19\n" +
"\buptime_s\x18\x01 \x01(\x04R\auptimeS\"G\n" +
"\x14DishObstructionStats\x12/\n" +
"\x13fraction_obstructed\x18\x01 \x01(\x02R\x12fractionObstructed\"\xb6\x01\n" +
"\n" +
"DishAlerts\x12!\n" +
"\fmotors_stuck\x18\x01 \x01(\bR\vmotorsStuck\x12)\n" +
"\x10thermal_shutdown\x18\x02 \x01(\bR\x0fthermalShutdown\x12)\n" +
"\x10thermal_throttle\x18\x03 \x01(\bR\x0fthermalThrottle\x12/\n" +
"\x13unexpected_location\x18\x04 \x01(\bR\x12unexpectedLocation*C\n" +
"\tDishState\x12\v\n" +
"\aUNKNOWN\x10\x00\x12\r\n" +
"\tCONNECTED\x10\x01\x12\r\n" +
"\tSEARCHING\x10\x02\x12\v\n" +
"\aBOOTING\x10\x032K\n" +
"\x06Device\x12A\n" +
"\x06Handle\x12\x1a.SpaceX.API.Device.Request\x1a\x1b.SpaceX.API.Device.ResponseB<Z:git.eeqj.de/sneak/rtnetmon/internal/starlink/pb;starlinkpbb\x06proto3"
var (
file_internal_starlink_pb_starlink_proto_rawDescOnce sync.Once
file_internal_starlink_pb_starlink_proto_rawDescData []byte
)
func file_internal_starlink_pb_starlink_proto_rawDescGZIP() []byte {
file_internal_starlink_pb_starlink_proto_rawDescOnce.Do(func() {
file_internal_starlink_pb_starlink_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_internal_starlink_pb_starlink_proto_rawDesc), len(file_internal_starlink_pb_starlink_proto_rawDesc)))
})
return file_internal_starlink_pb_starlink_proto_rawDescData
}
var file_internal_starlink_pb_starlink_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
var file_internal_starlink_pb_starlink_proto_msgTypes = make([]protoimpl.MessageInfo, 7)
var file_internal_starlink_pb_starlink_proto_goTypes = []any{
(DishState)(0), // 0: SpaceX.API.Device.DishState
(*Request)(nil), // 1: SpaceX.API.Device.Request
(*GetStatusRequest)(nil), // 2: SpaceX.API.Device.GetStatusRequest
(*Response)(nil), // 3: SpaceX.API.Device.Response
(*DishGetStatusResponse)(nil), // 4: SpaceX.API.Device.DishGetStatusResponse
(*DeviceState)(nil), // 5: SpaceX.API.Device.DeviceState
(*DishObstructionStats)(nil), // 6: SpaceX.API.Device.DishObstructionStats
(*DishAlerts)(nil), // 7: SpaceX.API.Device.DishAlerts
}
var file_internal_starlink_pb_starlink_proto_depIdxs = []int32{
2, // 0: SpaceX.API.Device.Request.get_status:type_name -> SpaceX.API.Device.GetStatusRequest
4, // 1: SpaceX.API.Device.Response.dish_get_status:type_name -> SpaceX.API.Device.DishGetStatusResponse
5, // 2: SpaceX.API.Device.DishGetStatusResponse.device_state:type_name -> SpaceX.API.Device.DeviceState
6, // 3: SpaceX.API.Device.DishGetStatusResponse.obstruction_stats:type_name -> SpaceX.API.Device.DishObstructionStats
7, // 4: SpaceX.API.Device.DishGetStatusResponse.alerts:type_name -> SpaceX.API.Device.DishAlerts
0, // 5: SpaceX.API.Device.DishGetStatusResponse.state:type_name -> SpaceX.API.Device.DishState
1, // 6: SpaceX.API.Device.Device.Handle:input_type -> SpaceX.API.Device.Request
3, // 7: SpaceX.API.Device.Device.Handle:output_type -> SpaceX.API.Device.Response
7, // [7:8] is the sub-list for method output_type
6, // [6:7] is the sub-list for method input_type
6, // [6:6] is the sub-list for extension type_name
6, // [6:6] is the sub-list for extension extendee
0, // [0:6] is the sub-list for field type_name
}
func init() { file_internal_starlink_pb_starlink_proto_init() }
func file_internal_starlink_pb_starlink_proto_init() {
if File_internal_starlink_pb_starlink_proto != nil {
return
}
file_internal_starlink_pb_starlink_proto_msgTypes[0].OneofWrappers = []any{
(*Request_GetStatus)(nil),
}
file_internal_starlink_pb_starlink_proto_msgTypes[2].OneofWrappers = []any{
(*Response_DishGetStatus)(nil),
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_internal_starlink_pb_starlink_proto_rawDesc), len(file_internal_starlink_pb_starlink_proto_rawDesc)),
NumEnums: 1,
NumMessages: 7,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_internal_starlink_pb_starlink_proto_goTypes,
DependencyIndexes: file_internal_starlink_pb_starlink_proto_depIdxs,
EnumInfos: file_internal_starlink_pb_starlink_proto_enumTypes,
MessageInfos: file_internal_starlink_pb_starlink_proto_msgTypes,
}.Build()
File_internal_starlink_pb_starlink_proto = out.File
file_internal_starlink_pb_starlink_proto_goTypes = nil
file_internal_starlink_pb_starlink_proto_depIdxs = nil
}
-74
View File
@@ -1,74 +0,0 @@
// A minimal slice of the Starlink dish's local gRPC API: just the one
// unary RPC (SpaceX.API.Device.Device/Handle) and the get_status request
// and dish status response, reduced to the handful of fields rtnetmon
// shows. The proto package name and field numbers must match the dish
// exactly for the wire format to line up; every other field the dish sends
// is ignored as an unknown field.
//
// Field numbers, message names and the DishState enum are transcribed from
// the community-maintained pre-generated bindings at
// github.com/starlink-community/starlink-grpc-go, commit
// 2e89f3d7e3092adecbcc04e9e003e61ed5f7c6a3 (get_status = 1004,
// dish_get_status = 2004). They are not verified against a live dish here.
//
// Regenerate with:
// protoc --go_out=. --go_opt=paths=source_relative \
// --go-grpc_out=. --go-grpc_opt=paths=source_relative \
// internal/starlink/pb/starlink.proto
syntax = "proto3";
package SpaceX.API.Device;
option go_package = "git.eeqj.de/sneak/rtnetmon/internal/starlink/pb;starlinkpb";
service Device {
rpc Handle(Request) returns (Response);
}
message Request {
oneof request {
GetStatusRequest get_status = 1004;
}
}
message GetStatusRequest {}
message Response {
oneof response {
DishGetStatusResponse dish_get_status = 2004;
}
}
message DishGetStatusResponse {
DeviceState device_state = 2;
DishObstructionStats obstruction_stats = 1004;
DishAlerts alerts = 1005;
DishState state = 1006;
float pop_ping_drop_rate = 1003;
float downlink_throughput_bps = 1007;
float uplink_throughput_bps = 1008;
float pop_ping_latency_ms = 1009;
}
message DeviceState {
uint64 uptime_s = 1;
}
message DishObstructionStats {
float fraction_obstructed = 1;
}
message DishAlerts {
bool motors_stuck = 1;
bool thermal_shutdown = 2;
bool thermal_throttle = 3;
bool unexpected_location = 4;
}
enum DishState {
UNKNOWN = 0;
CONNECTED = 1;
SEARCHING = 2;
BOOTING = 3;
}
-140
View File
@@ -1,140 +0,0 @@
// A minimal slice of the Starlink dish's local gRPC API: just the one
// unary RPC (SpaceX.API.Device.Device/Handle) and the get_status request
// and dish status response, reduced to the handful of fields rtnetmon
// shows. The proto package name and field numbers must match the dish
// exactly for the wire format to line up; every other field the dish sends
// is ignored as an unknown field.
//
// Field numbers, message names and the DishState enum are transcribed from
// the community-maintained pre-generated bindings at
// github.com/starlink-community/starlink-grpc-go, commit
// 2e89f3d7e3092adecbcc04e9e003e61ed5f7c6a3 (get_status = 1004,
// dish_get_status = 2004). They are not verified against a live dish here.
//
// Regenerate with:
// protoc --go_out=. --go_opt=paths=source_relative \
// --go-grpc_out=. --go-grpc_opt=paths=source_relative \
// internal/starlink/pb/starlink.proto
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v7.36.2
// source: internal/starlink/pb/starlink.proto
package starlinkpb
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
Device_Handle_FullMethodName = "/SpaceX.API.Device.Device/Handle"
)
// DeviceClient is the client API for Device service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type DeviceClient interface {
Handle(ctx context.Context, in *Request, opts ...grpc.CallOption) (*Response, error)
}
type deviceClient struct {
cc grpc.ClientConnInterface
}
func NewDeviceClient(cc grpc.ClientConnInterface) DeviceClient {
return &deviceClient{cc}
}
func (c *deviceClient) Handle(ctx context.Context, in *Request, opts ...grpc.CallOption) (*Response, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(Response)
err := c.cc.Invoke(ctx, Device_Handle_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// DeviceServer is the server API for Device service.
// All implementations must embed UnimplementedDeviceServer
// for forward compatibility.
type DeviceServer interface {
Handle(context.Context, *Request) (*Response, error)
mustEmbedUnimplementedDeviceServer()
}
// UnimplementedDeviceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedDeviceServer struct{}
func (UnimplementedDeviceServer) Handle(context.Context, *Request) (*Response, error) {
return nil, status.Errorf(codes.Unimplemented, "method Handle not implemented")
}
func (UnimplementedDeviceServer) mustEmbedUnimplementedDeviceServer() {}
func (UnimplementedDeviceServer) testEmbeddedByValue() {}
// UnsafeDeviceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to DeviceServer will
// result in compilation errors.
type UnsafeDeviceServer interface {
mustEmbedUnimplementedDeviceServer()
}
func RegisterDeviceServer(s grpc.ServiceRegistrar, srv DeviceServer) {
// If the following call pancis, it indicates UnimplementedDeviceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&Device_ServiceDesc, srv)
}
func _Device_Handle_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(Request)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DeviceServer).Handle(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Device_Handle_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DeviceServer).Handle(ctx, req.(*Request))
}
return interceptor(ctx, in, info, handler)
}
// Device_ServiceDesc is the grpc.ServiceDesc for Device service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var Device_ServiceDesc = grpc.ServiceDesc{
ServiceName: "SpaceX.API.Device.Device",
HandlerType: (*DeviceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "Handle",
Handler: _Device_Handle_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "internal/starlink/pb/starlink.proto",
}
-81
View File
@@ -1,81 +0,0 @@
// Package starlink detects a Starlink dish on the local network and reads a
// short status summary from the dish's local gRPC endpoint. Detection and
// the status fetch sit behind the Client interface, so the monitor is
// tested with a fake and needs no dish and no network. Render turns a
// Status into the two lines the UI draws and is a pure function.
package starlink
import (
"context"
"fmt"
"time"
)
// Address is the dish's fixed local gRPC endpoint. Every dish answers here
// from behind the Starlink router, whatever the LAN's own addressing is, so
// this is not the default gateway (that is the router).
const Address = "192.168.100.1:9200"
// StateConnected is the dish state string that means the link is up; any
// other state is treated as an alert condition by Render.
const StateConnected = "CONNECTED"
// Units used when reducing the dish's raw values to display values.
const (
percentScale = 100.0
bitsPerMegabit = 1_000_000.0
hoursPerDay = 24
minutesPerHour = 60
)
// Status is the subset of the dish's get_status response that rtnetmon
// shows. Throughput is megabits per second; obstruction and drop are
// percentages on a 0..100 scale.
type Status struct {
State string
Uptime time.Duration
ObstructionPct float64
Alerts int
PopPingMs float64
PopPingDropPct float64
DownlinkMbps float64
UplinkMbps float64
}
// Client probes for a dish and reads its status. The real implementation
// speaks gRPC to Address over one bound interface; tests use a fake.
type Client interface {
// Probe reports whether a dish answers on Address over the bound
// interface. It is cheap: a TCP connect, no gRPC call.
Probe(ctx context.Context) bool
// Status fetches the dish's current status.
Status(ctx context.Context) (Status, error)
}
// Render formats a Status as the two status lines drawn under the physical
// pane, and reports whether they warrant an alert (red) color: the dish is
// not connected, or an alert is active.
func Render(s Status) (string, string, bool) {
line1 := fmt.Sprintf(
"Starlink: %s uptime %s obstruction %.1f%% alerts %d",
s.State, formatUptime(s.Uptime), s.ObstructionPct, s.Alerts)
line2 := fmt.Sprintf(
" pop ping %.0fms / drop %.1f%% down %.1fMbps / up %.1fMbps",
s.PopPingMs, s.PopPingDropPct, s.DownlinkMbps, s.UplinkMbps)
alert := s.State != StateConnected || s.Alerts > 0
return line1, line2, alert
}
// formatUptime renders a duration as whole days, hours and minutes.
func formatUptime(d time.Duration) string {
if d < 0 {
d = 0
}
days := int64(d / (hoursPerDay * time.Hour))
hours := int64(d/time.Hour) % hoursPerDay
mins := int64(d/time.Minute) % minutesPerHour
return fmt.Sprintf("%dd %dh %dm", days, hours, mins)
}
-79
View File
@@ -1,79 +0,0 @@
package starlink_test
import (
"testing"
"time"
"git.eeqj.de/sneak/rtnetmon/internal/starlink"
)
func TestRender(t *testing.T) {
t.Parallel()
connected := starlink.Status{
State: "CONNECTED",
Uptime: 3*24*time.Hour + 4*time.Hour + 12*time.Minute,
ObstructionPct: 0.1,
Alerts: 0,
PopPingMs: 34,
PopPingDropPct: 0.0,
DownlinkMbps: 145.3,
UplinkMbps: 12.1,
}
tests := []struct {
name string
status starlink.Status
wantLine1 string
wantLine2 string
wantAlert bool
}{
{
name: "connected, no alert",
status: connected,
wantLine1: "Starlink: CONNECTED uptime 3d 4h 12m obstruction 0.1% alerts 0",
wantLine2: " pop ping 34ms / drop 0.0% down 145.3Mbps / up 12.1Mbps",
wantAlert: false,
},
{
name: "searching is an alert",
status: starlink.Status{
State: "SEARCHING",
Uptime: 0,
},
wantLine1: "Starlink: SEARCHING uptime 0d 0h 0m obstruction 0.0% alerts 0",
wantLine2: " pop ping 0ms / drop 0.0% down 0.0Mbps / up 0.0Mbps",
wantAlert: true,
},
{
name: "connected but alerts active",
status: starlink.Status{
State: "CONNECTED",
Uptime: 90 * time.Minute,
Alerts: 2,
},
wantLine1: "Starlink: CONNECTED uptime 0d 1h 30m obstruction 0.0% alerts 2",
wantLine2: " pop ping 0ms / drop 0.0% down 0.0Mbps / up 0.0Mbps",
wantAlert: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
line1, line2, alert := starlink.Render(tt.status)
if line1 != tt.wantLine1 {
t.Errorf("line1 = %q, want %q", line1, tt.wantLine1)
}
if line2 != tt.wantLine2 {
t.Errorf("line2 = %q, want %q", line2, tt.wantLine2)
}
if alert != tt.wantAlert {
t.Errorf("alert = %v, want %v", alert, tt.wantAlert)
}
})
}
}