// netmon – dual-interface network dashboard (curses) // WTFPL – 2025-05-16 sneak@sneak.berlin package main import ( "context" "encoding/json" "flag" "fmt" "math" "net" "os" "os/exec" "os/signal" "strconv" "strings" "sync" "syscall" "time" "github.com/gdamore/tcell/v2" ) /*────────────────── CLI 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 reachability hosts") ) /*────────────────── constants ──────────────────*/ const ( icmpTimeout = 500 * time.Millisecond tcpTimeout = 500 * time.Millisecond packetLossPings = 20 packetLossPeriod = 5 * time.Second statsHistory = 300 screenRefresh = 500 * time.Millisecond ) /*────────────────── runtime struct ─────────────*/ type InterfaceStatus struct { Name, Label, IPInfo string Reachable map[string]bool Loss map[string]float64 TCP map[string][]float64 TotalICMPReq, TotalICMPRep int DroppedCount int LastDrop, LastPing time.Time SpinFrame int mu sync.RWMutex } /*────────────────── colour styles ──────────────*/ var ( cBrightGreen = tcell.StyleDefault.Foreground(tcell.ColorGreen).Bold(true) cGreen = tcell.StyleDefault.Foreground(tcell.ColorGreen) cYellow = tcell.StyleDefault.Foreground(tcell.ColorYellow) cRed = tcell.StyleDefault.Foreground(tcell.ColorRed) cBrightRed = tcell.StyleDefault.Foreground(tcell.ColorRed).Bold(true) cDefault = tcell.StyleDefault ) func styleLatency(ms float64) tcell.Style { switch { case ms < 50: return cBrightGreen case ms < 100: return cGreen case ms < 200: return cYellow default: return cRed } } func styleLoss(p float64) tcell.Style { switch { case p == 0: return cBrightGreen case p < 5: return cYellow default: return cBrightRed } } /*────────────────── math helpers ───────────────*/ func minMaxAvgStd(xs []float64) (min, max, avg, std float64) { if len(xs) == 0 { return } min, max = xs[0], xs[0] var sum float64 for _, v := range xs { if v < min { min = v } if v > max { max = v } sum += v } avg = sum / float64(len(xs)) var vs float64 for _, v := range xs { d := v - avg vs += d * d } std = math.Sqrt(vs / float64(len(xs))) return } /*────────────────── external lookup ────────────*/ type ipInfoResp struct{ IP, Hostname, Org string } func fetchIPInfo(iface string) string { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() out, _ := exec.CommandContext(ctx, "curl", "-s", "--interface", iface, "--max-time", "2", "ipinfo.io").Output() var r ipInfoResp _ = json.Unmarshal(out, &r) if r.IP == "" { return "(ipinfo error)" } return fmt.Sprintf("%s [%s] %s", r.IP, r.Hostname, r.Org) } /*────────────────── ICMP helpers ───────────────*/ func pingOnce(iface, host string) bool { ctx, cancel := context.WithTimeout(context.Background(), icmpTimeout) defer cancel() return exec.CommandContext(ctx, "ping", "-I", iface, "-c1", "-W1", host).Run() == 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 _, ln := range strings.Split(string(out), "\n") { if strings.Contains(ln, "packet loss") { for _, f := range strings.Fields(ln) { if strings.HasSuffix(f, "%") { p, _ := strconv.ParseFloat(strings.TrimSuffix(f, "%"), 64) return p / 100.0 } } } } return 1.0 } /*────────────────── TCP helpers ───────────────*/ func localAddr(iface string) (net.Addr, error) { ifi, err := net.InterfaceByName(iface) if err != nil { return nil, err } add, _ := ifi.Addrs() for _, a := range add { 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, hp string) time.Duration { la, err := localAddr(iface) if err != nil { return tcpTimeout } d := net.Dialer{Timeout: tcpTimeout, LocalAddr: la} st := time.Now() c, err := d.Dial("tcp", hp) if err != nil { return tcpTimeout } c.Close() return time.Since(st) } /*────────────────── spinner ────────────────────*/ var spins = []rune{'|', '/', '-', '\\'} func (st *InterfaceStatus) spin() { st.SpinFrame = (st.SpinFrame + 1) % len(spins) } /*────────────────── screen helpers ─────────────*/ func put(scr tcell.Screen, x, y int, txt string, st tcell.Style) { for i, r := range txt { scr.SetContent(x+i, y, r, nil, st) } } func hline(w int) string { return strings.Repeat("=", w) } /*────────────────── UI drawing ─────────────────*/ func ifaceHealthy(st *InterfaceStatus) bool { st.mu.RLock() defer st.mu.RUnlock() if st.DroppedCount > 0 { return false } for _, ok := range st.Reachable { if !ok { return false } } for _, lp := range st.Loss { if lp > 0 { return false } } for _, hp := range tcpTestHosts { h := st.TCP[hp] if len(h) == 0 || h[len(h)-1] >= float64(tcpTimeout.Milliseconds()) { return false } } return true } const ( hostW = 30 // Increased width for host column numW = 7 stdW = 8 nW = 6 ) func drawIface(scr tcell.Screen, y, w int, st *InterfaceStatus) int { put(scr, 0, y, hline(w), cDefault) // header line healthy := ifaceHealthy(st) style := cBrightGreen if !healthy { style = cBrightRed } st.mu.RLock() header := fmt.Sprintf("%s: %s — %s", st.Name, st.Label, st.IPInfo) spin := spins[st.SpinFrame] st.mu.RUnlock() put(scr, 0, y+1, "== ", cDefault) put(scr, 3, y+1, string(spin)+" "+header, style) put(scr, 0, y+2, hline(w), cDefault) y += 4 /* reachability */ st.mu.RLock() total := len(st.Reachable) good := 0 for _, ok := range st.Reachable { if ok { good++ } } age := time.Since(st.LastPing).Round(time.Second) reachStyle := cBrightGreen if good != total { reachStyle = cBrightRed } put(scr, 0, y, fmt.Sprintf("Reachable: %d/%d (at %s, age %s)", good, total, st.LastPing.Format("15:04:05"), age), reachStyle) y++ if good == total { put(scr, 0, y, "Unreachable: none", cDefault) } else { var down []string for h, ok := range st.Reachable { if !ok { down = append(down, h) } } put(scr, 0, y, "Unreachable: "+strings.Join(down, ", "), cBrightRed) } y += 2 /* packet loss */ put(scr, 0, y, "Packet Loss:", cDefault) y++ for _, h := range packetLossHosts { p := st.Loss[h] * 100 put(scr, 0, y, fmt.Sprintf("%-16s %5.0f%%", h+":", p), styleLoss(p)) y++ } dAge := "N/A" if !st.LastDrop.IsZero() { dAge = time.Since(st.LastDrop).Round(time.Second).String() } put(scr, 0, y, fmt.Sprintf("Dropped: %d (last at %s, age %s)", st.DroppedCount, st.LastDrop.Format("15:04:05"), dAge), cDefault) y += 2 /* TCP table */ put(scr, 0, y, "TCP Connect Stats:", cDefault) y++ // header row headerRow := fmt.Sprintf("%-*s %*s %*s %*s %*s %*s %*s", hostW, "Host", numW, "last", numW, "min", numW, "avg", numW, "max", stdW, "stddev", nW, "n") put(scr, 0, y, headerRow, cDefault) y++ for _, hp := range tcpTestHosts { hist := st.TCP[hp] if len(hist) == 0 { continue } last := hist[len(hist)-1] mi, ma, av, sd := minMaxAvgStd(hist) row := fmt.Sprintf("%-*s %*s %*s %*s %*s %*s %*d", hostW, hp, numW, fmt.Sprintf("%.0fms", last), numW, fmt.Sprintf("%.0fms", mi), numW, fmt.Sprintf("%.0fms", av), numW, fmt.Sprintf("%.0fms", ma), stdW, fmt.Sprintf("%.0fms", sd), nW, len(hist), ) put(scr, 0, y, row, cDefault) // colourise individual numbers put(scr, hostW+1, y, fmt.Sprintf("%*s", numW, fmt.Sprintf("%.0fms", last)), styleLatency(last)) put(scr, hostW+1+numW+1, y, fmt.Sprintf("%*s", numW, fmt.Sprintf("%.0fms", mi)), styleLatency(mi)) put(scr, hostW+1+numW*2+2, y, fmt.Sprintf("%*s", numW, fmt.Sprintf("%.0fms", av)), styleLatency(av)) put(scr, hostW+1+numW*3+3, y, fmt.Sprintf("%*s", numW, fmt.Sprintf("%.0fms", ma)), styleLatency(ma)) put(scr, hostW+1+numW*4+4, y, fmt.Sprintf("%*s", stdW, fmt.Sprintf("%.0fms", sd)), cDefault) y++ } y++ /* totals */ put(scr, 0, y, fmt.Sprintf("Total ICMP Requests: %d", st.TotalICMPReq), cDefault) y++ put(scr, 0, y, fmt.Sprintf("Total ICMP Replies: %d", st.TotalICMPRep), cDefault) y += 2 st.mu.RUnlock() return y } /*────────────────── goroutine loops ─────────────*/ func reachLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { tk := time.NewTicker(time.Second) defer tk.Stop() for { select { case <-ctx.Done(): return case <-tk.C: var wg sync.WaitGroup res := make(map[string]bool, len(hosts)) mu := sync.Mutex{} for _, h := range hosts { wg.Add(1) go func(host string) { defer wg.Done() st.mu.Lock(); st.TotalICMPReq++; st.mu.Unlock() ok := pingOnce(st.Name, host) mu.Lock(); res[host] = ok; mu.Unlock() st.mu.Lock() if ok { st.TotalICMPRep++; st.spin() } else { st.DroppedCount++; st.LastDrop = time.Now() } st.mu.Unlock() }(h) } wg.Wait() st.mu.Lock(); st.Reachable = res; st.LastPing = time.Now(); st.mu.Unlock() } } } func lossLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { tk := time.NewTicker(packetLossPeriod) defer tk.Stop() for { select { case <-ctx.Done(): return case <-tk.C: var wg sync.WaitGroup res := 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(); res[host] = lp; if lp == 0 { st.spin() }; mu.Unlock() }(h) } wg.Wait() st.mu.Lock(); for k, v := range res { st.Loss[k] = v }; st.mu.Unlock() } } } func tcpLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { tk := time.NewTicker(time.Second) defer tk.Stop() for { select { case <-ctx.Done(): return case <-tk.C: for _, hp := range hosts { ms := float64(tcpDuration(st.Name, hp).Milliseconds()) st.mu.Lock() if ms < float64(tcpTimeout.Milliseconds()) { st.spin() } hist := st.TCP[hp]; if len(hist) >= statsHistory { hist = hist[1:] } st.TCP[hp] = append(hist, ms); st.mu.Unlock() } } } } /*────────────────── UI loop ─────────────────────*/ func uiLoop(ctx context.Context, scr tcell.Screen, a, b *InterfaceStatus, start time.Time) { defer scr.Fini() tk := time.NewTicker(screenRefresh) defer tk.Stop() spin := 0 for { select { case <-ctx.Done(): return case <-tk.C: w, _ := scr.Size() scr.Clear() put(scr, 0, 0, hline(w), cDefault) put(scr, 0, 1, fmt.Sprintf("== %c %s", spins[spin%len(spins)], time.Now().Format(time.RFC1123Z)), cDefault) put(scr, 0, 2, hline(w), cDefault) spin++ put(scr, 0, 3, "Runtime: "+time.Since(start).Round(time.Second).String(), cDefault) y := 5 y = drawIface(scr, y, w, a) _ = drawIface(scr, y, w, b) scr.Show() } } } /*────────────────── main ─────────────────────────*/ var ( reachHosts []string packetLossHosts = []string{"github.com", "google.com", "1.1.1.1", "8.8.8.8"} tcpTestHosts = []string{ "datavi.be:443", "fast.com:443", "cloudflare.com:443", "console.aws.amazon.com:443", "console.cloud.google.com:443", } ) func main() { flag.Parse() reachHosts = strings.Split(*hostCSV, ",") newStatus := func(name, label string) *InterfaceStatus { return &InterfaceStatus{ Name: name, Label: label, IPInfo: fetchIPInfo(name), Reachable: map[string]bool{}, Loss: map[string]float64{}, TCP: map[string][]float64{}, } } a, b := newStatus(*ifaceA, *labelA), newStatus(*ifaceB, *labelB) scr, err := tcell.NewScreen(); if err != nil { panic(err) } if err = scr.Init(); err != nil { panic(err) } ctx, cancel := context.WithCancel(context.Background()) defer cancel() sig := make(chan os.Signal, 1) signal.Notify(sig, os.Interrupt, syscall.SIGTERM) go func() { <-sig; cancel() }() go func() { for { if ev := scr.PollEvent(); ev != nil { if ke, ok := ev.(*tcell.EventKey); ok { if ke.Key() == tcell.KeyCtrlC || ke.Rune() == 'q' { cancel(); return } } } } }() go reachLoop(ctx, a, reachHosts) go reachLoop(ctx, b, reachHosts) go lossLoop(ctx, a, packetLossHosts) go lossLoop(ctx, b, packetLossHosts) go tcpLoop(ctx, a, tcpTestHosts) go tcpLoop(ctx, b, tcpTestHosts) uiLoop(ctx, scr, a, b, time.Now()) } /*────────────────── Extra packet-loss hosts ───── To widen geographic and CDN coverage you could add: facebook.com microsoft.com apple.com twitter.com akamai.com These are large anycast/CDN endpoints that tend to reveal regional network quirks. Add them to `packetLossHosts` (and `reachHosts` if you also want individual pings every second). */