check / check (push) Failing after 3s
rtnetmon had no version. main.Version is now set at link time and logged in the first startup line. The Dockerfile takes it from the VERSION build argument when one is given, otherwise from `git describe --tags --always` of the .git the build context now carries, and fails the build if the context carries .git and no version comes out. .dockerignore follows the canonical copy: .git is sent, .git/config, which can hold a credential, is not. Model: opus-5-5
177 lines
4.7 KiB
Go
177 lines
4.7 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. version is
|
|
// logged at startup.
|
|
func newRootCmd(version string) *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, version)
|
|
},
|
|
}
|
|
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, version string) error {
|
|
monitor.Logf(cfg.LogFile, "Starting rtnetmon %s", version)
|
|
|
|
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. version is logged at startup.
|
|
func Execute(version string) error {
|
|
return newRootCmd(version).Execute()
|
|
}
|