Files
rtnetmon/main.go
T
2025-05-21 21:24:20 -07:00

823 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//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). */