//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" "math/rand" "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", "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", "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 // ASCII characters for the meter meterStart = '[' meterEnd = ']' meterFill = '=' meterEmpty = ' ' meterWidth = 7 // Fixed width of the meter (including brackets) meterFillWidth = 5 // Width of the fillable area (meterWidth - 2 for brackets) maxMeterValue = 10 // Maximum value for the meter hostW = 30 // Increased width for host column numW = 7 stdW = 8 nW = 6 lostW = 7 // Width for the lost column ) /*────────────────── 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 LostPackets map[string]int // Track lost packets per host mu sync.RWMutex } /*────────────────── colour styles ──────────────*/ var ( cBrightGreen = tcell.StyleDefault.Foreground(tcell.ColorGreen).Bold(true) cGreen = tcell.StyleDefault.Foreground(tcell.ColorGreen) cDimGreen = tcell.StyleDefault.Foreground(tcell.ColorGreen).Dim(true) cYellow = tcell.StyleDefault.Foreground(tcell.ColorYellow) cOrange = tcell.StyleDefault.Foreground(tcell.ColorOrange) cRed = tcell.StyleDefault.Foreground(tcell.ColorRed) cBrightRed = tcell.StyleDefault.Foreground(tcell.ColorRed).Bold(true) cDefault = tcell.StyleDefault // Rainbow colors for the spinner cRainbow = []tcell.Style{ tcell.StyleDefault.Foreground(tcell.ColorRed), tcell.StyleDefault.Foreground(tcell.ColorOrange), tcell.StyleDefault.Foreground(tcell.ColorYellow), tcell.StyleDefault.Foreground(tcell.ColorGreen), tcell.StyleDefault.Foreground(tcell.ColorBlue), tcell.StyleDefault.Foreground(tcell.ColorPurple), } ) // Get the appropriate meter style based on the value func getMeterStyle(value int) tcell.Style { switch { case value <= 0: return cBrightGreen case value == 1: return cGreen case value == 2: return cDimGreen case value == 3: return cYellow case value == 4: return cOrange default: return cRed } } 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 } 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) spinChar := spins[st.SpinFrame] spinnerFrame := st.SpinFrame meterValue := st.MeterValue st.mu.RUnlock() // Create the meter with appropriate style meter, meterStyle := createMeter(meterValue, meterWidth) // Get rainbow color for spinner (cycle through colors) spinnerStyle := cRainbow[spinnerFrame%len(cRainbow)] put(scr, 0, y+1, "== ", cDefault) put(scr, 3, y+1, string(spinChar)+" ", spinnerStyle) // Colorful spinner put(scr, 5, y+1, meter+" ", meterStyle) // Colored meter 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 %*s", hostW, "Host", numW, "last", numW, "min", numW, "avg", numW, "max", stdW, "stddev", nW, "n", lostW, "lost") 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) // Get host name without port for lost packets lookup hostName := strings.Split(hp, ":")[0] lost := st.LostPackets[hostName] // Add lost column row := fmt.Sprintf("%-*s %*s %*s %*s %*s %*s %*d %*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), lostW, lost, ) 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) // Colorize the lost packets column lostStyle := cDefault if lost > 0 { lostStyle = cRed } put(scr, hostW+1+numW*4+4+stdW+1+nW+1, y, fmt.Sprintf("%*d", lostW, lost), lostStyle) 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++ // Add a line showing lost packets lost := st.TotalICMPReq - st.TotalICMPRep lostStyle := cDefault if lost > 0 { lostStyle = cRed } put(scr, 0, y, fmt.Sprintf("Total ICMP Lost: %d", lost), lostStyle) y += 2 st.mu.RUnlock() return y } // Create a visual meter using ASCII characters // Returns the meter string and the appropriate style func createMeter(value, width int) (string, tcell.Style) { if value > maxMeterValue { value = maxMeterValue } if value < 0 { value = 0 } // Calculate the number of fill characters to show // Map value (0-maxMeterValue) to fill width (0-meterFillWidth) fillCount := value * meterFillWidth / maxMeterValue if fillCount > meterFillWidth { fillCount = meterFillWidth } // Build the meter string var b strings.Builder // Add opening bracket b.WriteRune(meterStart) // Add fill characters for i := 0; i < fillCount; i++ { b.WriteRune(meterFill) } // Add empty spaces for i := 0; i < meterFillWidth-fillCount; i++ { b.WriteRune(meterEmpty) } // Add closing bracket b.WriteRune(meterEnd) // Get the appropriate style for this meter value style := getMeterStyle(value) return b.String(), style } /*────────────────── goroutine loops ─────────────*/ func reachLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { logf("Starting reachability monitoring for %s with %d hosts", st.Name, len(hosts)) // Add random offset to avoid clustering at 1-second intervals randomOffset := time.Duration(100+rand.Intn(800)) * time.Millisecond logf("Reachability monitoring for %s will start after %v offset", st.Name, randomOffset) time.Sleep(randomOffset) 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() // Track lost packets per host st.LostPackets[host]++ // 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)) // Add random offset to avoid clustering at periodic intervals randomOffset := time.Duration(100+rand.Intn(800)) * time.Millisecond logf("Packet loss monitoring for %s will start after %v offset", st.Name, randomOffset) time.Sleep(randomOffset) 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)) // Add random offset to avoid clustering at 1-second intervals randomOffset := time.Duration(100+rand.Intn(800)) * time.Millisecond logf("TCP monitoring for %s will start after %v offset", st.Name, randomOffset) time.Sleep(randomOffset) 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", "8.8.8.8", "captive.apple.com"} tcpTestHosts = []string{ "datavi.be:443", "fast.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") // Seed the random number generator rand.Seed(time.Now().UnixNano()) logf("Random number generator seeded") 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 LostPackets: map[string]int{}, // Initialize lost packets map } } 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). */