// 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 //----------------------------------------------------------------------------- type InterfaceStatus struct { Name string Label string IPInfo string Reachable map[string]bool Loss map[string]float64 TCP map[string][]float64 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 { diff := v - avg vSum += diff * diff } 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 + loss //----------------------------------------------------------------------------- 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 timing //----------------------------------------------------------------------------- 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) } //----------------------------------------------------------------------------- // Spinners //----------------------------------------------------------------------------- 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() ok := pingOnce(st.Name, host) mu.Lock() res[host] = ok if ok { st.tickSpinner() } 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 drawDivider(s tcell.Screen, y, w int) { line := strings.Repeat("=", w) write(s, 0, y, line) } func drawHeaderLine(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() drawHeaderLine(s, y+1, w, spin, head) drawDivider(s, y+2, w) y += 4 // skip blank line after // contents st.mu.RLock() 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 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++ } y++ 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++ } st.mu.RUnlock() return y + 2 // leave a blank line after section } //----------------------------------------------------------------------------- // UI main loop //----------------------------------------------------------------------------- func uiLoop(ctx context.Context, scr tcell.Screen, a, b *InterfaceStatus) { defer scr.Fini() ticker := time.NewTicker(screenRefresh) defer ticker.Stop() topSpin := 0 for { select { case <-ctx.Done(): return case <-ticker.C: w, _ := scr.Size() scr.Clear() // top banner drawDivider(scr, 0, w) drawHeaderLine(scr, 1, w, spinner[topSpin%len(spinner)], time.Now().Format(time.RFC1123Z)) drawDivider(scr, 2, w) topSpin++ y := 4 y = drawInterface(scr, y, w, a) y = 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) }