commit 9d2aa2b4debf1edb9dc2d9b752e7d805dd24ca04 Author: sneak Date: Fri May 16 04:20:20 2025 -0700 initial diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e97d1dd --- /dev/null +++ b/Makefile @@ -0,0 +1,14 @@ +default: run + +main.go: + pbpaste > main.go + +copy: main.go + ssh root@las1stor1 -v "mkdir -p /tmp/x" + scp ./main.go root@las1stor1:/tmp/x + +run: copy + ssh root@las1stor1 -v "cd /tmp/x && go run ." + +clean: + rm main.go diff --git a/main.go b/main.go new file mode 100644 index 0000000..4283128 --- /dev/null +++ b/main.go @@ -0,0 +1,436 @@ +// netmon – real-time dual-interface network dashboard +// 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" +) + +//----------------------------------------------------------------------------- +// Configuration 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 hosts for reachability checks") +) + +//----------------------------------------------------------------------------- +// Constants +//----------------------------------------------------------------------------- + +const ( + icmpTimeout = 500 * time.Millisecond // per-ping timeout + tcpTimeout = 500 * time.Millisecond // TCP handshake timeout + packetLossPings = 20 // pings per host for loss calc + packetLossPeriod = 5 * time.Second // how often to recalc loss + statsHistory = 300 // 5 minutes of 1 Hz samples + screenRefresh = 500 * time.Millisecond // spinner / UI cadence +) + +//----------------------------------------------------------------------------- +// Runtime data structures +//----------------------------------------------------------------------------- + +type InterfaceStatus struct { + Name string + Label string + IPInfo string + + Reachable map[string]bool // last reachability results + Loss map[string]float64// packet-loss % + TCP map[string][]float64 // rolling TCP ms samples + + LastPing time.Time + + mu sync.RWMutex +} + +//----------------------------------------------------------------------------- +// Helpers – maths without external 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 varSum float64 + for _, v := range nums { + diff := v - avg + varSum += diff * diff + } + std = math.Sqrt(varSum / 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") { + continue + } + for _, f := range strings.Fields(line) { + if strings.HasSuffix(f, "%") { + pct, _ := strconv.ParseFloat(strings.TrimSuffix(f, "%"), 64) + return pct / 100.0 + } + } + } + return 1.0 +} + +//----------------------------------------------------------------------------- +// TCP helpers +//----------------------------------------------------------------------------- + +func localAddrFor(iface string) (net.Addr, error) { + ni, err := net.InterfaceByName(iface) + if err != nil { + return nil, err + } + addrs, err := ni.Addrs() + if err != nil { + return nil, err + } + 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 := localAddrFor(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) +} + +//----------------------------------------------------------------------------- +// Goroutines – reachability, loss, TCP stats +//----------------------------------------------------------------------------- + +func reachabilityLoop(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 + results := 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() + results[host] = ok + mu.Unlock() + }(h) + } + wg.Wait() + + st.mu.Lock() + st.Reachable = results + 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 + results := 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() + results[host] = lp + mu.Unlock() + }(h) + } + wg.Wait() + + st.mu.Lock() + for k, v := range results { + 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() + hist := st.TCP[hp] + if len(hist) >= statsHistory { + hist = hist[1:] // drop oldest + } + st.TCP[hp] = append(hist, ms) + st.mu.Unlock() + } + } + } +} + +//----------------------------------------------------------------------------- +// UI +//----------------------------------------------------------------------------- + +var spin = []rune{'|', '/', '-', '\\'} + +func writeLine(s tcell.Screen, x, y int, str string) { + for i, r := range str { + s.SetContent(x+i, y, r, nil, tcell.StyleDefault) + } +} + +func drawSection(s tcell.Screen, x, y, w int, st *InterfaceStatus) { + st.mu.RLock() + defer st.mu.RUnlock() + + // header + writeLine(s, x, y, fmt.Sprintf("%s — %s", st.Label, st.IPInfo)) + + // 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) + writeLine(s, x, y+1, + fmt.Sprintf("Reachable: %d/%d (at %s, age %s)", + rc, total, st.LastPing.Format("15:04:05"), age)) + if len(down) > 0 { + writeLine(s, x, y+2, "Unreachable: "+strings.Join(down, ", ")) + } else { + writeLine(s, x, y+2, "Unreachable: none") + } + + // packet loss + row := y + 4 + writeLine(s, x, row, "Packet Loss:") + row++ + for _, h := range packetLossHosts { + lp := st.Loss[h] * 100 + writeLine(s, x, row, fmt.Sprintf("%-16s %.0f%%", h+":", lp)) + row++ + } + + // TCP stats + writeLine(s, x, row, "TCP Connect Stats:") + row++ + for _, hp := range tcpTestHosts { + samples := st.TCP[hp] + if len(samples) == 0 { + continue + } + mi, ma, av, sd := minMaxAvgStd(samples) + writeLine(s, x, row, + fmt.Sprintf("%-16s %.0f/%.0f/%.0f/%.0fms", + hp, mi, av, ma, sd)) + row++ + } +} + +func uiLoop(ctx context.Context, scr tcell.Screen, a, b *InterfaceStatus) { + defer scr.Fini() + tick := time.NewTicker(screenRefresh) + defer tick.Stop() + frame := 0 + + for { + select { + case <-ctx.Done(): + return + case <-tick.C: + scr.Clear() + w, h := scr.Size() + mid := h / 2 + + ts := time.Now().Format(time.RFC1123Z) + writeLine(scr, 0, 0, fmt.Sprintf("%c %s", spin[frame%len(spin)], ts)) + frame++ + + drawSection(scr, 0, 1, w, a) + drawSection(scr, 0, mid, w, b) + + scr.Show() + } + } +} + +//----------------------------------------------------------------------------- +// Entry +//----------------------------------------------------------------------------- + +var ( + reachabilityHosts []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() + reachabilityHosts = strings.Split(*hostCSV, ",") + + // prepare interface status objects + stA := &InterfaceStatus{ + Name: *ifaceA, + Label: *labelA, + Reachable: make(map[string]bool), + Loss: make(map[string]float64), + TCP: make(map[string][]float64), + IPInfo: fetchIPInfo(*ifaceA), + } + stB := &InterfaceStatus{ + Name: *ifaceB, + Label: *labelB, + Reachable: make(map[string]bool), + Loss: make(map[string]float64), + TCP: make(map[string][]float64), + IPInfo: fetchIPInfo(*ifaceB), + } + + screen, err := tcell.NewScreen() + if err != nil { + panic(err) + } + if err := screen.Init(); err != nil { + panic(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // background jobs + go reachabilityLoop(ctx, stA, reachabilityHosts) + go reachabilityLoop(ctx, stB, reachabilityHosts) + + go lossLoop(ctx, stA, packetLossHosts) + go lossLoop(ctx, stB, packetLossHosts) + + go tcpLoop(ctx, stA, tcpTestHosts) + go tcpLoop(ctx, stB, tcpTestHosts) + + // UI (blocks) + uiLoop(ctx, screen, stA, stB) +}