check / check (push) Failing after 1s
Linux runs exactly as before when both bridge interfaces exist. When they do not, the lone default-route interface is monitored, and a single interface now draws a single UI pane instead of an empty second one. macOS is newly supported: a running VPN tunnel (utun) is monitored as the primary pane alongside the physical default-route interface, or the physical interface alone when no VPN is up. Detection lives in internal/netdetect: interface/route data types, pure selection logic keyed on OS name, and route parsers, all unit-tested on Linux for both platforms. Only the real route query and the per-platform TCP dial binding (source address on Linux, IP_BOUND_IF on macOS) are build-tagged. Ping argument construction is a pure, OS-keyed function. NewMonitor now takes a list of interfaces. Model: opus-4-8
171 lines
4.3 KiB
Go
171 lines
4.3 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)
|
|
|
|
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()
|
|
}
|