// netmon – dual-interface network dashboard (curses) // 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" ) // ----------------------------------------------------------------------------- // 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", "hosts for reachability checks") ) // ----------------------------------------------------------------------------- // Constants // ----------------------------------------------------------------------------- const ( icmpTimeout = 500 * time.Millisecond tcpTimeout = 500 * time.Millisecond packetLossPings = 20 packetLossPeriod = 5 * time.Second statsHistory = 300 // 5 min of 1 Hz samples screenRefresh = 500 * time.Millisecond ) // ----------------------------------------------------------------------------- // Data structures // ----------------------------------------------------------------------------- type InterfaceStatus struct { Name string Label string IPInfo string Reachable map[string]bool Loss map[string]float64 TCP map[string][]float64 TotalICMPReq int TotalICMPRep int DroppedCount int LastDrop time.Time LastPing time.Time SpinFrame int mu sync.RWMutex } // ----------------------------------------------------------------------------- // Math helpers (no heavy 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 vsum float64 for _, v := range nums { d := v - avg vsum += d * d } std = math.Sqrt(vsum / 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") { for _, f := range strings.Fields(line) { 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) { ni, err := net.InterfaceByName(iface) if err != nil { return nil, err } addrs, _ := ni.Addrs() 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 := localAddr(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) } // ----------------------------------------------------------------------------- // Spinner helpers // ----------------------------------------------------------------------------- var spinner = []rune{'|', '/', '-', '\\'} func (st *InterfaceStatus) tickSpinner() { st.SpinFrame = (st.SpinFrame + 1) % len(spinner) } // ----------------------------------------------------------------------------- // Goroutines // ----------------------------------------------------------------------------- func reachLoop(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 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.tickSpinner() } 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) { tick := time.NewTicker(packetLossPeriod) defer tick.Stop() for { select { case <-ctx.Done(): return case <-tick.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 < 1.0 { st.tickSpinner() } 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) { 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() if ms < float64(tcpTimeout.Milliseconds()) { st.tickSpinner() } hist := st.TCP[hp] if len(hist) >= statsHistory { hist = hist[1:] } st.TCP[hp] = append(hist, ms) st.mu.Unlock() } } } } // ----------------------------------------------------------------------------- // UI helpers // ----------------------------------------------------------------------------- func write(s tcell.Screen, x, y int, str string) { for i, r := range str { s.SetContent(x+i, y, r, nil, tcell.StyleDefault) } } func divider(w int) string { return strings.Repeat("=", w) } func drawDivider(s tcell.Screen, y, w int) { write(s, 0, y, divider(w)) } func drawHeader(s tcell.Screen, y, w int, spin rune, txt string) { line := fmt.Sprintf("== %c %s", spin, txt) if len(line) > w { line = line[:w] } write(s, 0, y, line) } func drawInterface(s tcell.Screen, y, w int, st *InterfaceStatus) int { // divider, header, divider drawDivider(s, y, w) st.mu.RLock() spin := spinner[st.SpinFrame] head := fmt.Sprintf("%s — %s", st.Label, st.IPInfo) st.mu.RUnlock() drawHeader(s, y+1, w, spin, head) drawDivider(s, y+2, w) y += 4 st.mu.RLock() // 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) write(s, 0, y, fmt.Sprintf("Reachable: %d/%d (at %s, age %s)", rc, total, st.LastPing.Format("15:04:05"), age)) y++ if len(down) > 0 { write(s, 0, y, "Unreachable: "+strings.Join(down, ", ")) } else { write(s, 0, y, "Unreachable: none") } y += 2 // Packet loss write(s, 0, y, "Packet Loss:") y++ for _, h := range packetLossHosts { lp := st.Loss[h] * 100 write(s, 0, y, fmt.Sprintf("%-16s %.0f%%", h+":", lp)) y++ } dropAge := "N/A" if !st.LastDrop.IsZero() { dropAge = fmt.Sprintf("%s", time.Since(st.LastDrop).Round(time.Second)) } write(s, 0, y, fmt.Sprintf("Dropped: %d (last at %s, age %s)", st.DroppedCount, st.LastDrop.Format("15:04:05"), dropAge)) y += 2 // TCP stats write(s, 0, y, "TCP Connect Stats:") y++ for _, hp := range tcpTestHosts { samples := st.TCP[hp] if len(samples) == 0 { continue } mi, ma, av, sd := minMaxAvgStd(samples) write(s, 0, y, fmt.Sprintf("%-16s %.0f/%.0f/%.0f/%.0fms", hp, mi, av, ma, sd)) y++ } y++ // ICMP totals write(s, 0, y, fmt.Sprintf("Total ICMP Requests: %d", st.TotalICMPReq)) y++ write(s, 0, y, fmt.Sprintf("Total ICMP Replies: %d", st.TotalICMPRep)) y += 2 st.mu.RUnlock() return y } // ----------------------------------------------------------------------------- // UI loop // ----------------------------------------------------------------------------- func uiLoop(ctx context.Context, scr tcell.Screen, a, b *InterfaceStatus, start time.Time) { defer scr.Fini() tick := time.NewTicker(screenRefresh) defer tick.Stop() topSpin := 0 for { select { case <-ctx.Done(): return case <-tick.C: w, _ := scr.Size() scr.Clear() // top banner drawDivider(scr, 0, w) drawHeader(scr, 1, w, spinner[topSpin%len(spinner)], time.Now().Format(time.RFC1123Z)) drawDivider(scr, 2, w) topSpin++ write(scr, 0, 3, fmt.Sprintf("Runtime: %s", time.Since(start).Round(time.Second))) y := 5 y = drawInterface(scr, y, w, a) _ = drawInterface(scr, y, w, b) scr.Show() } } } // ----------------------------------------------------------------------------- // Main // ----------------------------------------------------------------------------- var ( reachHosts []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() reachHosts = strings.Split(*hostCSV, ",") a := &InterfaceStatus{ Name: *ifaceA, Label: *labelA, Reachable: map[string]bool{}, Loss: map[string]float64{}, TCP: map[string][]float64{}, IPInfo: fetchIPInfo(*ifaceA), } b := &InterfaceStatus{ Name: *ifaceB, Label: *labelB, Reachable: map[string]bool{}, Loss: map[string]float64{}, TCP: map[string][]float64{}, IPInfo: fetchIPInfo(*ifaceB), } 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() 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()) }