Files
rtnetmon/internal/cli/root.go
T
sneak e3ef4f2f48
check / check (push) Failing after 1s
Add Starlink status lines for the physical gateway pane (closes #3)
When the non-VPN physical gateway is a Starlink dish, two lines are shown
under that pane: state, uptime, obstruction and alert count; then pop-ping
latency and drop rate with downlink/uplink throughput. They turn red when
the dish is not connected or an alert is active.

Detection is a TCP connect to the dish's fixed endpoint 192.168.100.1:9200,
bound to the physical interface the same way the latency probes bind. Until
a dish answers nothing is drawn and no status is fetched, so an absent dish
adds no noise. Status comes from the dish's local get_status gRPC call.

Detection and the fetch sit behind a small Client interface; the loop and
the pure Render function are tested with a fake, no dish and no network.

Model: opus-4-8
2026-09-21 22:51:45 +00:00

176 lines
4.5 KiB
Go

// Package cli wires command-line flags to the network monitor and runs it.
package cli
import (
"context"
"os"
"os/signal"
"runtime"
"syscall"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"git.eeqj.de/sneak/rtnetmon/internal/monitor"
"git.eeqj.de/sneak/rtnetmon/internal/netdetect"
)
// config holds the application configuration.
type config struct {
IfaceA string
LabelA string
IfaceB string
LabelB string
Hosts []string
LogFile string
}
// defaultReachabilityHosts is the default reachability host list.
func defaultReachabilityHosts() []string {
return []string{
"8.8.8.8", "8.8.4.4", "google.com", "github.com",
"console.aws.amazon.com", "console.cloud.google.com",
"fast.com", "datavi.be", "captive.apple.com",
}
}
// defaultPacketLossHosts is the default packet-loss host list.
func defaultPacketLossHosts() []string {
return []string{
"github.com", "google.com", "8.8.8.8",
"captive.apple.com", "62.115.190.68",
}
}
// defaultTCPHosts is the default TCP connect host:port list.
func defaultTCPHosts() []string {
return []string{
"datavi.be:443", "fast.com:443",
"console.aws.amazon.com:443", "console.cloud.google.com:443",
"captive.apple.com:80", "google.com:443",
}
}
// newRootCmd builds the cobra root command with its flags bound.
func newRootCmd() *cobra.Command {
cfg := &config{}
cmd := &cobra.Command{
Use: "rtnetmon",
Short: "Real-time network monitoring dashboard",
Long: `rtnetmon is a dual-interface network monitoring dashboard that provides
real-time visibility into network health, packet loss, and latency.`,
RunE: func(cmd *cobra.Command, _ []string) error {
return runMonitor(cmd, cfg)
},
}
registerFlags(cmd, cfg)
return cmd
}
// registerFlags defines and viper-binds the command's flags.
func registerFlags(cmd *cobra.Command, cfg *config) {
f := cmd.Flags()
f.StringVar(&cfg.IfaceA, "ifaceA", "gu0", "primary network interface")
f.StringVar(&cfg.LabelA, "labelA", "gu LAN - VPN outbound", "label for ifaceA")
f.StringVar(&cfg.IfaceB, "ifaceB", "backhaul0", "secondary network interface")
f.StringVar(&cfg.LabelB, "labelB", "Cox cable direct", "label for ifaceB")
f.StringSliceVar(&cfg.Hosts, "hosts", defaultReachabilityHosts(),
"comma-separated reachability hosts")
f.StringVar(&cfg.LogFile, "logfile", "/tmp/rtnetmon.log", "path to log file")
for _, name := range []string{
"ifaceA", "labelA", "ifaceB", "labelB", "hosts", "logfile",
} {
_ = viper.BindPFlag(name, f.Lookup(name))
}
}
// runMonitor detects the interfaces to monitor, then constructs and runs the
// monitor from cfg.
func runMonitor(cmd *cobra.Command, cfg *config) error {
monitor.Logf(cfg.LogFile, "Starting rtnetmon")
specs, err := detectInterfaces(cmd, cfg)
if err != nil {
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 {
mon.AddReachabilityHost(host)
}
for _, host := range defaultPacketLossHosts() {
mon.AddPacketLossHost(host)
}
for _, host := range defaultTCPHosts() {
mon.AddTCPHost(host)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
go func() {
s := <-sig
monitor.Logf(cfg.LogFile, "Signal received: %v", s)
cancel()
}()
return mon.Run(ctx)
}
// detectInterfaces enumerates the host, reads its default routes, and applies
// the platform selection rules to decide which interfaces to monitor.
func detectInterfaces(
cmd *cobra.Command, cfg *config,
) ([]monitor.IfaceSpec, error) {
ifaces, err := netdetect.Interfaces()
if err != nil {
return nil, err
}
routes, err := netdetect.DefaultRoutes()
if err != nil {
return nil, err
}
flags := netdetect.Flags{
IfaceA: cfg.IfaceA,
LabelA: cfg.LabelA,
IfaceB: cfg.IfaceB,
LabelB: cfg.LabelB,
LabelASet: cmd.Flags().Changed("labelA"),
LabelBSet: cmd.Flags().Changed("labelB"),
}
panes, err := netdetect.Select(runtime.GOOS, ifaces, routes, flags)
if err != nil {
return nil, err
}
specs := make([]monitor.IfaceSpec, len(panes))
for i, p := range panes {
specs[i] = monitor.IfaceSpec{Name: p.Name, Label: p.Label}
}
monitor.Logf(cfg.LogFile, "Monitoring %d interface(s)", len(specs))
return specs, nil
}
// Execute builds the root command and runs it.
func Execute() error {
return newRootCmd().Execute()
}