diff --git a/main.go b/main.go index 35ce285..4c695e1 100644 --- a/main.go +++ b/main.go @@ -9,73 +9,99 @@ import ( "fmt" "math" "net" + "os" "os/exec" + "os/signal" "strconv" "strings" "sync" + "syscall" "time" "github.com/gdamore/tcell/v2" ) // ----------------------------------------------------------------------------- -// Flags +// 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") + 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") + "comma-separated hosts for reachability checks") ) // ----------------------------------------------------------------------------- -// Constants +// Consts // ----------------------------------------------------------------------------- const ( icmpTimeout = 500 * time.Millisecond tcpTimeout = 500 * time.Millisecond packetLossPings = 20 packetLossPeriod = 5 * time.Second - statsHistory = 300 // 5 min of 1 Hz samples + statsHistory = 300 // 5 min @1 Hz screenRefresh = 500 * time.Millisecond ) // ----------------------------------------------------------------------------- -// Data structures +// Runtime struct // ----------------------------------------------------------------------------- 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 + Name, Label, IPInfo string + Reachable map[string]bool + Loss map[string]float64 + TCP map[string][]float64 + TotalICMPReq int + TotalICMPRep int + DroppedCount int + LastDrop, LastPing time.Time + SpinFrame int + mu sync.RWMutex } // ----------------------------------------------------------------------------- -// Math helpers (no heavy deps) +// Styles / colours +// ----------------------------------------------------------------------------- +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) + cDefault = tcell.StyleDefault +) + +func styleLatency(ms float64) tcell.Style { + switch { + case ms < 50: + return cBrightGreen + case ms < 100: + return cGreen + case ms < 150: + return cYellow + default: + return cRed + } +} + +func styleLoss(pct float64) tcell.Style { + switch { + case pct == 0: + return cBrightGreen + case pct < 5: + return cYellow + default: + return cRed + } +} + +// ----------------------------------------------------------------------------- +// Small maths // ----------------------------------------------------------------------------- func minMaxAvgStd(nums []float64) (min, max, avg, std float64) { if len(nums) == 0 { @@ -93,58 +119,56 @@ func minMaxAvgStd(nums []float64) (min, max, avg, std float64) { sum += v } avg = sum / float64(len(nums)) - var vsum float64 + var vs float64 for _, v := range nums { d := v - avg - vsum += d * d + vs += d * d } - std = math.Sqrt(vsum / float64(len(nums))) + std = math.Sqrt(vs / float64(len(nums))) return } // ----------------------------------------------------------------------------- -// External look-ups +// External lookup // ----------------------------------------------------------------------------- -type ipInfo struct{ IP, Hostname, Org string } +type ipInfoResp 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() + 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 == "" { + var r ipInfoResp + _ = json.Unmarshal(out, &r) + if r.IP == "" { return "(ipinfo parse error)" } - return fmt.Sprintf("%s [%s] %s", resp.IP, resp.Hostname, resp.Org) + return fmt.Sprintf("%s [%s] %s", r.IP, r.Hostname, r.Org) } // ----------------------------------------------------------------------------- -// Ping helpers +// ICMP 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 + 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() + 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) { + 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 @@ -163,8 +187,7 @@ func localAddr(iface string) (net.Addr, error) { if err != nil { return nil, err } - addrs, _ := ni.Addrs() - for _, a := range addrs { + for _, a := range func() []net.Addr { a, _ := ni.Addrs(); return a }() { if ipnet, ok := a.(*net.IPNet); ok && ipnet.IP.To4() != nil { return &net.TCPAddr{IP: ipnet.IP}, nil } @@ -172,41 +195,167 @@ func localAddr(iface string) (net.Addr, error) { return nil, fmt.Errorf("no IPv4 on %s", iface) } -func tcpDuration(iface, hostPort string) time.Duration { +func tcpDuration(iface, hp 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) + st := time.Now() + c, err := d.Dial("tcp", hp) if err != nil { return tcpTimeout } - conn.Close() - return time.Since(start) + c.Close() + return time.Since(st) } // ----------------------------------------------------------------------------- -// Spinner helpers +// Spinner // ----------------------------------------------------------------------------- -var spinner = []rune{'|', '/', '-', '\\'} +var spins = []rune{'|', '/', '-', '\\'} -func (st *InterfaceStatus) tickSpinner() { - st.SpinFrame = (st.SpinFrame + 1) % len(spinner) +func (st *InterfaceStatus) spin() { st.SpinFrame = (st.SpinFrame + 1) % len(spins) } + +// ----------------------------------------------------------------------------- +// Screen helpers +// ----------------------------------------------------------------------------- +func put(s tcell.Screen, x, y int, str string, st tcell.Style) { + for i, r := range str { + s.SetContent(x+i, y, r, nil, st) + } +} + +func hline(w int) string { return strings.Repeat("=", w) } + +func headerLine(s tcell.Screen, y, w int, spin rune, txt string) { + line := fmt.Sprintf("== %c %s", spin, txt) + if len(line) > w { + line = line[:w] + } + put(s, 0, y, line, cDefault) } // ----------------------------------------------------------------------------- -// Goroutines +// UI drawing +// ----------------------------------------------------------------------------- +func ifaceHeaderStyle(sta *InterfaceStatus) tcell.Style { + sta.mu.RLock() + defer sta.mu.RUnlock() + if sta.DroppedCount > 0 { + return cRed + } + for _, ok := range sta.Reachable { + if !ok { + return cRed + } + } + for _, lp := range sta.Loss { + if lp > 0 { + return cRed + } + } + return cBrightGreen +} + +func drawIface(scr tcell.Screen, y, w int, sta *InterfaceStatus) int { + put(scr, 0, y, hline(w), cDefault) + hs := ifaceHeaderStyle(sta) + sta.mu.RLock() + spin := spins[sta.SpinFrame] + head := fmt.Sprintf("%s — %s", sta.Label, sta.IPInfo) + sta.mu.RUnlock() + put(scr, 0, y+1, fmt.Sprintf("== %c %s", spin, head), hs) + put(scr, 0, y+2, hline(w), cDefault) + y += 4 + + sta.mu.RLock() + total := len(sta.Reachable) + okCnt := 0 + for _, ok := range sta.Reachable { + if ok { + okCnt++ + } + } + rStyle := cBrightGreen + if okCnt != total { + rStyle = cRed + } + age := time.Since(sta.LastPing).Round(time.Second) + put(scr, 0, y, fmt.Sprintf("Reachable: %d/%d (at %s, age %s)", + okCnt, total, sta.LastPing.Format("15:04:05"), age), rStyle) + y++ + if okCnt == total { + put(scr, 0, y, "Unreachable: none", cDefault) + } else { + var down []string + for h, ok := range sta.Reachable { + if !ok { + down = append(down, h) + } + } + put(scr, 0, y, "Unreachable: "+strings.Join(down, ", "), cRed) + } + y += 2 + + // loss table + put(scr, 0, y, "Packet Loss:", cDefault) + y++ + for _, h := range packetLossHosts { + lp := sta.Loss[h] * 100 + put(scr, 0, y, fmt.Sprintf("%-16s %5.0f%%", h+":", lp), + styleLoss(lp)) + y++ + } + dAge := "N/A" + if !sta.LastDrop.IsZero() { + dAge = time.Since(sta.LastDrop).Round(time.Second).String() + } + put(scr, 0, y, fmt.Sprintf("Dropped: %d (last at %s, age %s)", + sta.DroppedCount, sta.LastDrop.Format("15:04:05"), dAge), cDefault) + y += 2 + + // TCP + put(scr, 0, y, "TCP Connect Stats:", cDefault) + y++ + put(scr, 0, y, "Host min avg max stddev n", cDefault) + y++ + for _, hp := range tcpTestHosts { + hist := sta.TCP[hp] + if len(hist) == 0 { + continue + } + mi, ma, av, sd := minMaxAvgStd(hist) + put(scr, 0, y, fmt.Sprintf("%-16s", hp), cDefault) + put(scr, 17, y, fmt.Sprintf("%4.0fms", mi), styleLatency(mi)) + put(scr, 24, y, fmt.Sprintf("%4.0fms", av), styleLatency(av)) + put(scr, 31, y, fmt.Sprintf("%4.0fms", ma), styleLatency(ma)) + put(scr, 38, y, fmt.Sprintf("%6.0fms", sd), cDefault) + put(scr, 46, y, fmt.Sprintf("%5d", len(hist)), cDefault) + y++ + } + y++ + + // totals + put(scr, 0, y, fmt.Sprintf("Total ICMP Requests: %d", sta.TotalICMPReq), cDefault) + y++ + put(scr, 0, y, fmt.Sprintf("Total ICMP Replies: %d", sta.TotalICMPRep), cDefault) + y += 2 + sta.mu.RUnlock() + return y +} + +// ----------------------------------------------------------------------------- +// Loops // ----------------------------------------------------------------------------- func reachLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { - tick := time.NewTicker(time.Second) - defer tick.Stop() + tk := time.NewTicker(time.Second) + defer tk.Stop() for { select { case <-ctx.Done(): return - case <-tick.C: + case <-tk.C: var wg sync.WaitGroup res := make(map[string]bool, len(hosts)) mu := sync.Mutex{} @@ -227,7 +376,7 @@ func reachLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { st.mu.Lock() if ok { st.TotalICMPRep++ - st.tickSpinner() + st.spin() } else { st.DroppedCount++ st.LastDrop = time.Now() @@ -245,13 +394,13 @@ func reachLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { } func lossLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { - tick := time.NewTicker(packetLossPeriod) - defer tick.Stop() + tk := time.NewTicker(packetLossPeriod) + defer tk.Stop() for { select { case <-ctx.Done(): return - case <-tick.C: + case <-tk.C: var wg sync.WaitGroup res := make(map[string]float64, len(hosts)) mu := sync.Mutex{} @@ -262,8 +411,8 @@ func lossLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { lp := lossPercent(st.Name, host) mu.Lock() res[host] = lp - if lp < 1.0 { - st.tickSpinner() + if lp == 0 { + st.spin() } mu.Unlock() }(h) @@ -279,18 +428,18 @@ func lossLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { } func tcpLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { - tick := time.NewTicker(time.Second) - defer tick.Stop() + tk := time.NewTicker(time.Second) + defer tk.Stop() for { select { case <-ctx.Done(): return - case <-tick.C: + case <-tk.C: for _, hp := range hosts { ms := float64(tcpDuration(st.Name, hp).Milliseconds()) st.mu.Lock() if ms < float64(tcpTimeout.Milliseconds()) { - st.tickSpinner() + st.spin() } hist := st.TCP[hp] if len(hist) >= statsHistory { @@ -304,134 +453,47 @@ func tcpLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { } // ----------------------------------------------------------------------------- -// 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 +// UI loop + input watcher // ----------------------------------------------------------------------------- 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 + tk := time.NewTicker(screenRefresh) + defer tk.Stop() + // keyboard watcher (Ctrl-C or q) + go func() { + for { + ev := scr.PollEvent() + switch v := ev.(type) { + case *tcell.EventKey: + if v.Key() == tcell.KeyCtrlC || (v.Rune() == 'q') { + // cancel context -> exit + if cancel := ctx.Done(); cancel != nil { + // nothing + } + } + case nil: + return + } + } + }() + + spin := 0 for { select { case <-ctx.Done(): return - case <-tick.C: + case <-tk.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))) - + put(scr, 0, 0, hline(w), cDefault) + headerLine(scr, 1, w, spins[spin%len(spins)], time.Now().Format(time.RFC1123Z)) + put(scr, 0, 2, hline(w), cDefault) + spin++ + put(scr, 0, 3, fmt.Sprintf("Runtime: %s", time.Since(start).Round(time.Second)), cDefault) y := 5 - y = drawInterface(scr, y, w, a) - _ = drawInterface(scr, y, w, b) - + y = drawIface(scr, y, w, a) + _ = drawIface(scr, y, w, b) scr.Show() } } @@ -442,13 +504,10 @@ func uiLoop(ctx context.Context, scr tcell.Screen, a, b *InterfaceStatus, start // ----------------------------------------------------------------------------- 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", + 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", } ) @@ -456,34 +515,34 @@ 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), + mkStatus := func(name, label string) *InterfaceStatus { + return &InterfaceStatus{ + Name: name, + Label: label, + IPInfo: fetchIPInfo(name), + Reachable: map[string]bool{}, + Loss: map[string]float64{}, + TCP: map[string][]float64{}, + } } + a, b := mkStatus(*ifaceA, *labelA), mkStatus(*ifaceB, *labelB) scr, err := tcell.NewScreen() if err != nil { panic(err) } - if err := scr.Init(); err != nil { + if err = scr.Init(); err != nil { panic(err) } ctx, cancel := context.WithCancel(context.Background()) defer cancel() + // Ctrl-C from terminal (outside raw mode) + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt, syscall.SIGTERM) + go func() { <-sig; cancel() }() + go reachLoop(ctx, a, reachHosts) go reachLoop(ctx, b, reachHosts) go lossLoop(ctx, a, packetLossHosts)