//go:build linux // +build linux // 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" tcell "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") logFile = flag.String("logfile", "/tmp/rtnetmon.log", "path to log file") ) /*────────────────── constants ──────────────────*/ const ( icmpTimeout = 500 * time.Millisecond tcpTimeout = 500 * time.Millisecond packetLossPings = 20 packetLossPeriod = 5 * time.Second statsHistory = 300 screenRefresh = 500 * time.Millisecond // Still used as backup refresh rate // Unicode characters for the meter greenDot = '🟢' // Unicode green circle redDot = '🔴' // Unicode red circle emptyDot = '⚪' // Unicode white circle meterWidth = 6 // Fixed width of the meter maxMeterValue = 10 // Maximum value for the meter ) /*────────────────── 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 MeterValue int // Current value for the packet loss meter 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{'|', '/', '-', '\\'} // Update to add a channel for signaling UI updates var uiUpdateChan = make(chan struct{}, 100) // Buffered channel to avoid blocking // Advances the spinner frame and signals a UI update // This is called only when packets are successfully received, // so the spinner only moves when there's actual network activity func (st *InterfaceStatus) spin() { st.SpinFrame = (st.SpinFrame + 1) % len(spins) // Signal UI update after spinner changes select { case uiUpdateChan <- struct{}{}: default: // Non-blocking send - if channel is full, just continue } } /*────────────────── 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] meterValue := st.MeterValue st.mu.RUnlock() // Create the meter - fixed width of 6 characters meter := createMeter(meterValue, meterWidth) put(scr, 0, y+1, "== ", cDefault) put(scr, 3, y+1, string(spin)+" ", cDefault) put(scr, 5, y+1, meter+" ", cDefault) // Add the meter after the spinner put(scr, 5+meterWidth+1, y+1, header, style) // Move the header after the meter 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 } // Create a visual meter using green and red dots func createMeter(value, width int) string { if value > maxMeterValue { value = maxMeterValue } if value < 0 { value = 0 } // Calculate how many dots of each color to show redDots := value if redDots > width { redDots = width } greenDots := width - redDots // Build the meter string var b strings.Builder // Add red dots for unreplied packets for i := 0; i < redDots; i++ { b.WriteRune(redDot) } // Add green dots for available capacity for i := 0; i < greenDots; i++ { b.WriteRune(greenDot) } return b.String() } /*────────────────── goroutine loops ─────────────*/ func reachLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { logf("Starting reachability monitoring for %s with %d hosts", st.Name, len(hosts)) tk := time.NewTicker(time.Second) defer tk.Stop() for { select { case <-ctx.Done(): logf("Stopping reachability monitoring for %s", st.Name) return case <-tk.C: // logf("Checking reachability for %s", st.Name) // Too verbose 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++ // Increase meter value when a packet is sent st.MeterValue++ if st.MeterValue > maxMeterValue { st.MeterValue = maxMeterValue } st.mu.Unlock() ok := pingOnce(st.Name, host) mu.Lock() res[host] = ok mu.Unlock() st.mu.Lock() if ok { st.TotalICMPRep++ // Decrease meter value when a packet is successfully received st.MeterValue-- if st.MeterValue < 0 { st.MeterValue = 0 } // Only update spinner when packets are successfully received st.spin() } else { st.DroppedCount++ st.LastDrop = time.Now() // For failed pings, we don't decrease the meter value // Trigger UI update on ping failure select { case uiUpdateChan <- struct{}{}: default: } } st.mu.Unlock() }(h) } wg.Wait() // Check if reachability status changed statusChanged := false st.mu.Lock() for host, newStatus := range res { if oldStatus, ok := st.Reachable[host]; !ok || oldStatus != newStatus { statusChanged = true break } } st.Reachable = res st.LastPing = time.Now() // Reset DroppedCount if all hosts are reachable allReachable := true for _, ok := range res { if !ok { allReachable = false break } } if allReachable { st.DroppedCount = 0 // If all hosts are reachable, gradually decay the meter value if st.MeterValue > 0 { st.MeterValue-- } } st.mu.Unlock() // Always trigger UI update when reachability status changes if statusChanged { select { case uiUpdateChan <- struct{}{}: default: } } } } } func lossLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { logf("Starting packet loss monitoring for %s with %d hosts", st.Name, len(hosts)) tk := time.NewTicker(packetLossPeriod) defer tk.Stop() for { select { case <-ctx.Done(): logf("Stopping packet loss monitoring for %s", st.Name) return case <-tk.C: // logf("Checking packet loss for %s", st.Name) // Too verbose 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 mu.Unlock() st.mu.Lock() if lp == 0 { // Only update spinner when there's 0% packet loss (successful receipt) st.spin() } else { // Trigger UI update on packet loss select { case uiUpdateChan <- struct{}{}: default: } } st.mu.Unlock() }(h) } wg.Wait() // Check if loss status changed statusChanged := false st.mu.Lock() for host, newLoss := range res { if oldLoss, ok := st.Loss[host]; !ok || math.Abs(oldLoss-newLoss) > 0.01 { statusChanged = true break } } for k, v := range res { st.Loss[k] = v } st.mu.Unlock() // Always trigger UI update when loss status changes if statusChanged { select { case uiUpdateChan <- struct{}{}: default: } } } } } func tcpLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { logf("Starting TCP monitoring for %s with %d hosts", st.Name, len(hosts)) tk := time.NewTicker(time.Second) defer tk.Stop() for { select { case <-ctx.Done(): logf("Stopping TCP monitoring for %s", st.Name) return case <-tk.C: // logf("Checking TCP for %s", st.Name) // Too verbose statusChanged := false for _, hp := range hosts { ms := float64(tcpDuration(st.Name, hp).Milliseconds()) st.mu.Lock() // Check if TCP latency significantly changed hist := st.TCP[hp] if len(hist) > 0 { lastMs := hist[len(hist)-1] if math.Abs(lastMs-ms) > 20 { // 20ms threshold for significant change statusChanged = true } } else { // First measurement statusChanged = true } if ms < float64(tcpTimeout.Milliseconds()) { // Only update spinner on successful TCP connections st.spin() } else { // Trigger UI update on TCP timeout select { case uiUpdateChan <- struct{}{}: default: } } if len(hist) >= statsHistory { hist = hist[1:] } st.TCP[hp] = append(hist, ms) st.mu.Unlock() } // Always trigger UI update when TCP status changes significantly if statusChanged { select { case uiUpdateChan <- struct{}{}: default: } } } } } /*────────────────── UI loop ─────────────────────*/ func uiLoop(ctx context.Context, scr tcell.Screen, a, b *InterfaceStatus, start time.Time) { logf("UI loop started") defer func() { logf("UI loop cleanup") scr.Clear() scr.ShowCursor(0, 0) scr.Fini() logf("Screen finalized") }() // Remove the ticker as we'll update on spinner ticks // tk := time.NewTicker(screenRefresh) // defer tk.Stop() spin := 0 // Function to draw the screen drawScreen := func() { 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() } // Initial draw drawScreen() // Even without a ticker, ensure we update at least every second // This is a backup in case there are no spinner updates backupTicker := time.NewTicker(time.Second) defer backupTicker.Stop() for { select { case <-ctx.Done(): logf("Context cancelled, exiting UI loop") return case <-uiUpdateChan: // Update on spinner ticks (no rate limiting) drawScreen() case <-backupTicker.C: // Fallback to ensure we update at least once per second drawScreen() } } } /*────────────────── 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", } ) // Simple logging function func logf(format string, v ...interface{}) { if logFile == nil || *logFile == "" { return } f, err := os.OpenFile(*logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { return // silently fail if we can't log } defer f.Close() fmt.Fprintf(f, time.Now().Format("2006-01-02 15:04:05.000 ")+format+"\n", v...) } func main() { flag.Parse() logf("Starting rtnetmon") reachHosts = strings.Split(*hostCSV, ",") logf("Monitoring interfaces %s and %s", *ifaceA, *ifaceB) // Initialize UI update channel uiUpdateChan = make(chan struct{}, 100) logf("UI update channel initialized with buffer size 100") newStatus := func(name, label string) *InterfaceStatus { logf("Initializing status for interface %s", name) return &InterfaceStatus{ Name: name, Label: label, IPInfo: fetchIPInfo(name), Reachable: map[string]bool{}, Loss: map[string]float64{}, TCP: map[string][]float64{}, MeterValue: 0, // Initialize meter value to 0 } } a, b := newStatus(*ifaceA, *labelA), newStatus(*ifaceB, *labelB) logf("Initializing screen") scr, err := tcell.NewScreen() if err != nil { logf("Error creating screen: %v", err) panic(err) } if err = scr.Init(); err != nil { logf("Error initializing screen: %v", err) panic(err) } logf("Screen initialized") 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 logf("Signal received: %v", s) cancel() }() // Event polling loop with logging go func() { logf("Starting keyboard event loop") for { if ev := scr.PollEvent(); ev != nil { logf("Event received: %T", ev) if ke, ok := ev.(*tcell.EventKey); ok { logf("Key event: %v, rune: %c", ke.Key(), ke.Rune()) if ke.Key() == tcell.KeyCtrlC || ke.Rune() == 'q' { logf("Quit key detected") cancel() return } } // Trigger UI update on any event select { case uiUpdateChan <- struct{}{}: default: } } } }() // Start monitoring goroutines logf("Starting monitoring goroutines") 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) logf("Starting UI loop") uiLoop(ctx, scr, a, b, time.Now()) logf("UI loop exited, program ending") } /*────────────────── 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). */