// netmon – real-time dual-interface network dashboard // WTFPL – 2025-05-16 sneak@sneak.berlin package main import ( "context" "encoding/json" "flag" "fmt" "math" "net" "os/exec" "strconv" "strings" "sync" "time" "github.com/gdamore/tcell/v2" ) //----------------------------------------------------------------------------- // Configuration flags //----------------------------------------------------------------------------- var ( ifaceA = flag.String("ifaceA", "gu0", "primary network interface") labelA = flag.String("labelA", "gu LAN - VPN outbound", "label for ifaceA") ifaceB = flag.String("ifaceB", "backhaul0", "secondary network interface") labelB = flag.String("labelB", "Cox cable direct", "label for ifaceB") hostCSV = flag.String("hosts", "1.1.1.1,8.8.8.8,8.8.4.4,google.com,github.com,console.aws.amazon.com,"+ "console.cloud.google.com,fast.com,cloudflare.com,datavi.be", "comma-separated hosts for reachability checks") ) //----------------------------------------------------------------------------- // Constants //----------------------------------------------------------------------------- const ( icmpTimeout = 500 * time.Millisecond // per-ping timeout tcpTimeout = 500 * time.Millisecond // TCP handshake timeout packetLossPings = 20 // pings per host for loss calc packetLossPeriod = 5 * time.Second // how often to recalc loss statsHistory = 300 // 5 minutes of 1 Hz samples screenRefresh = 500 * time.Millisecond // spinner / UI cadence ) //----------------------------------------------------------------------------- // Runtime data structures //----------------------------------------------------------------------------- type InterfaceStatus struct { Name string Label string IPInfo string Reachable map[string]bool // last reachability results Loss map[string]float64// packet-loss % TCP map[string][]float64 // rolling TCP ms samples LastPing time.Time mu sync.RWMutex } //----------------------------------------------------------------------------- // Helpers – maths without external deps //----------------------------------------------------------------------------- func minMaxAvgStd(nums []float64) (min, max, avg, std float64) { if len(nums) == 0 { return } min, max = nums[0], nums[0] var sum float64 for _, v := range nums { if v < min { min = v } if v > max { max = v } sum += v } avg = sum / float64(len(nums)) var varSum float64 for _, v := range nums { diff := v - avg varSum += diff * diff } std = math.Sqrt(varSum / float64(len(nums))) return } //----------------------------------------------------------------------------- // External look-ups //----------------------------------------------------------------------------- type ipInfo struct { IP, Hostname, Org string } func fetchIPInfo(iface string) string { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() out, err := exec.CommandContext(ctx, "curl", "-s", "--interface", iface, "--max-time", "2", "ipinfo.io").Output() if err != nil { return "(ipinfo error)" } var resp ipInfo _ = json.Unmarshal(out, &resp) if resp.IP == "" { return "(ipinfo parse error)" } return fmt.Sprintf("%s [%s] %s", resp.IP, resp.Hostname, resp.Org) } //----------------------------------------------------------------------------- // Ping helpers //----------------------------------------------------------------------------- func pingOnce(iface, host string) bool { ctx, cancel := context.WithTimeout(context.Background(), icmpTimeout) defer cancel() err := exec.CommandContext(ctx, "ping", "-I", iface, "-c1", "-W1", host).Run() return err == nil } func lossPercent(iface, host string) float64 { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() out, err := exec.CommandContext(ctx, "ping", "-q", "-i", "0.05", "-c", fmt.Sprint(packetLossPings), "-W1", "-I", iface, host).CombinedOutput() if err != nil { return 1.0 } for _, line := range strings.Split(string(out), "\n") { if !strings.Contains(line, "packet loss") { continue } for _, f := range strings.Fields(line) { if strings.HasSuffix(f, "%") { pct, _ := strconv.ParseFloat(strings.TrimSuffix(f, "%"), 64) return pct / 100.0 } } } return 1.0 } //----------------------------------------------------------------------------- // TCP helpers //----------------------------------------------------------------------------- func localAddrFor(iface string) (net.Addr, error) { ni, err := net.InterfaceByName(iface) if err != nil { return nil, err } addrs, err := ni.Addrs() if err != nil { return nil, err } for _, a := range addrs { if ipnet, ok := a.(*net.IPNet); ok && ipnet.IP.To4() != nil { return &net.TCPAddr{IP: ipnet.IP}, nil } } return nil, fmt.Errorf("no IPv4 on %s", iface) } func tcpDuration(iface, hostPort string) time.Duration { la, err := localAddrFor(iface) if err != nil { return tcpTimeout } d := net.Dialer{Timeout: tcpTimeout, LocalAddr: la} start := time.Now() conn, err := d.Dial("tcp", hostPort) if err != nil { return tcpTimeout } conn.Close() return time.Since(start) } //----------------------------------------------------------------------------- // Goroutines – reachability, loss, TCP stats //----------------------------------------------------------------------------- func reachabilityLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { tick := time.NewTicker(time.Second) defer tick.Stop() for { select { case <-ctx.Done(): return case <-tick.C: var wg sync.WaitGroup results := make(map[string]bool, len(hosts)) mu := sync.Mutex{} for _, h := range hosts { wg.Add(1) go func(host string) { defer wg.Done() ok := pingOnce(st.Name, host) mu.Lock() results[host] = ok mu.Unlock() }(h) } wg.Wait() st.mu.Lock() st.Reachable = results st.LastPing = time.Now() st.mu.Unlock() } } } func lossLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { tick := time.NewTicker(packetLossPeriod) defer tick.Stop() for { select { case <-ctx.Done(): return case <-tick.C: var wg sync.WaitGroup results := make(map[string]float64, len(hosts)) mu := sync.Mutex{} for _, h := range hosts { wg.Add(1) go func(host string) { defer wg.Done() lp := lossPercent(st.Name, host) mu.Lock() results[host] = lp mu.Unlock() }(h) } wg.Wait() st.mu.Lock() for k, v := range results { st.Loss[k] = v } st.mu.Unlock() } } } func tcpLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { tick := time.NewTicker(time.Second) defer tick.Stop() for { select { case <-ctx.Done(): return case <-tick.C: for _, hp := range hosts { ms := float64(tcpDuration(st.Name, hp).Milliseconds()) st.mu.Lock() hist := st.TCP[hp] if len(hist) >= statsHistory { hist = hist[1:] // drop oldest } st.TCP[hp] = append(hist, ms) st.mu.Unlock() } } } } //----------------------------------------------------------------------------- // UI //----------------------------------------------------------------------------- var spin = []rune{'|', '/', '-', '\\'} func writeLine(s tcell.Screen, x, y int, str string) { for i, r := range str { s.SetContent(x+i, y, r, nil, tcell.StyleDefault) } } func drawSection(s tcell.Screen, x, y, w int, st *InterfaceStatus) { st.mu.RLock() defer st.mu.RUnlock() // header writeLine(s, x, y, fmt.Sprintf("%s — %s", st.Label, st.IPInfo)) // reachability total := len(st.Reachable) rc := 0 var down []string for h, ok := range st.Reachable { if ok { rc++ } else { down = append(down, h) } } age := time.Since(st.LastPing).Round(time.Second) writeLine(s, x, y+1, fmt.Sprintf("Reachable: %d/%d (at %s, age %s)", rc, total, st.LastPing.Format("15:04:05"), age)) if len(down) > 0 { writeLine(s, x, y+2, "Unreachable: "+strings.Join(down, ", ")) } else { writeLine(s, x, y+2, "Unreachable: none") } // packet loss row := y + 4 writeLine(s, x, row, "Packet Loss:") row++ for _, h := range packetLossHosts { lp := st.Loss[h] * 100 writeLine(s, x, row, fmt.Sprintf("%-16s %.0f%%", h+":", lp)) row++ } // TCP stats writeLine(s, x, row, "TCP Connect Stats:") row++ for _, hp := range tcpTestHosts { samples := st.TCP[hp] if len(samples) == 0 { continue } mi, ma, av, sd := minMaxAvgStd(samples) writeLine(s, x, row, fmt.Sprintf("%-16s %.0f/%.0f/%.0f/%.0fms", hp, mi, av, ma, sd)) row++ } } func uiLoop(ctx context.Context, scr tcell.Screen, a, b *InterfaceStatus) { defer scr.Fini() tick := time.NewTicker(screenRefresh) defer tick.Stop() frame := 0 for { select { case <-ctx.Done(): return case <-tick.C: scr.Clear() w, h := scr.Size() mid := h / 2 ts := time.Now().Format(time.RFC1123Z) writeLine(scr, 0, 0, fmt.Sprintf("%c %s", spin[frame%len(spin)], ts)) frame++ drawSection(scr, 0, 1, w, a) drawSection(scr, 0, mid, w, b) scr.Show() } } } //----------------------------------------------------------------------------- // Entry //----------------------------------------------------------------------------- var ( reachabilityHosts []string packetLossHosts = []string{"datavi.be", "google.com", "fast.com", "cloudflare.com", "github.com"} tcpTestHosts = []string{"google.com:443", "github.com:443", "1.1.1.1:443", "8.8.8.8:53"} ) func main() { flag.Parse() reachabilityHosts = strings.Split(*hostCSV, ",") // prepare interface status objects stA := &InterfaceStatus{ Name: *ifaceA, Label: *labelA, Reachable: make(map[string]bool), Loss: make(map[string]float64), TCP: make(map[string][]float64), IPInfo: fetchIPInfo(*ifaceA), } stB := &InterfaceStatus{ Name: *ifaceB, Label: *labelB, Reachable: make(map[string]bool), Loss: make(map[string]float64), TCP: make(map[string][]float64), IPInfo: fetchIPInfo(*ifaceB), } screen, err := tcell.NewScreen() if err != nil { panic(err) } if err := screen.Init(); err != nil { panic(err) } ctx, cancel := context.WithCancel(context.Background()) defer cancel() // background jobs go reachabilityLoop(ctx, stA, reachabilityHosts) go reachabilityLoop(ctx, stB, reachabilityHosts) go lossLoop(ctx, stA, packetLossHosts) go lossLoop(ctx, stB, packetLossHosts) go tcpLoop(ctx, stA, tcpTestHosts) go tcpLoop(ctx, stB, tcpTestHosts) // UI (blocks) uiLoop(ctx, screen, stA, stB) }