Files
rtnetmon/main.go
T
2025-05-16 04:49:41 -07:00

550 lines
14 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.
// 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"
"github.com/gdamore/tcell/v2"
)
/*──────────────────────── CLI ────────────────────────*/
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 @1 Hz
screenRefresh = 500 * time.Millisecond
)
/*──────────────────────── runtime data ──────────────────*/
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
mu sync.RWMutex
}
/*──────────────────────── 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)
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: // new threshold
return cYellow
default:
return cRed
}
}
func styleLoss(pct float64) tcell.Style {
switch {
case pct == 0:
return cBrightGreen
case pct < 5:
return cYellow
default:
return cBrightRed
}
}
/*──────────────────────── maths ─────────────────────────*/
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, err := exec.CommandContext(ctx, "curl", "-s", "--interface", iface, "--max-time", "2", "ipinfo.io").Output()
if err != nil {
return "(ipinfo error)"
}
var r ipInfoResp
_ = json.Unmarshal(out, &r)
if r.IP == "" {
return "(ipinfo parse 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) {
ni, err := net.InterfaceByName(iface)
if err != nil {
return nil, err
}
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
}
}
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}
start := time.Now()
c, err := d.Dial("tcp", hp)
if err != nil {
return tcpTimeout
}
c.Close()
return time.Since(start)
}
/*──────────────────────── spinner ───────────────────────*/
var spins = []rune{'|', '/', '-', '\\'}
func (st *InterfaceStatus) spin() { st.SpinFrame = (st.SpinFrame + 1) % len(spins) }
/*──────────────────────── 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 draw helpers ───────────────*/
func ifaceHeaderStyle(sta *InterfaceStatus) tcell.Style {
sta.mu.RLock()
defer sta.mu.RUnlock()
if sta.DroppedCount > 0 {
return cBrightRed
}
for _, ok := range sta.Reachable {
if !ok {
return cBrightRed
}
}
for _, lp := range sta.Loss {
if lp > 0 {
return cBrightRed
}
}
return cBrightGreen
}
const (
hostCol = 0
minCol = 18
avgCol = 27
maxCol = 36
stdCol = 45
nCol = 56
)
func drawLatencyRow(scr tcell.Screen, y int, host string, mi, av, ma, sd float64, n int) {
put(scr, hostCol, y, fmt.Sprintf("%-17s", host), cDefault)
put(scr, minCol, y, fmt.Sprintf("%6.0fms", mi), styleLatency(mi))
put(scr, avgCol, y, fmt.Sprintf("%6.0fms", av), styleLatency(av))
put(scr, maxCol, y, fmt.Sprintf("%6.0fms", ma), styleLatency(ma))
put(scr, stdCol, y, fmt.Sprintf("%7.0fms", sd), cDefault)
put(scr, nCol, y, fmt.Sprintf("%5d", n), cDefault)
}
func drawIface(scr tcell.Screen, y, w int, sta *InterfaceStatus) int {
/* header */
put(scr, 0, y, hline(w), cDefault)
hs := ifaceHeaderStyle(sta)
sta.mu.RLock()
head := fmt.Sprintf("== %c %s — %s", spins[sta.SpinFrame], sta.Label, sta.IPInfo)
sta.mu.RUnlock()
if len(head) > w {
head = head[:w]
}
put(scr, 0, y+1, head, hs)
put(scr, 0, y+2, hline(w), cDefault)
y += 4
/* reachability */
sta.mu.RLock()
total := len(sta.Reachable)
good := 0
for _, ok := range sta.Reachable {
if ok {
good++
}
}
rStyle := cBrightGreen
if good != total {
rStyle = cBrightRed
}
age := time.Since(sta.LastPing).Round(time.Second)
put(scr, 0, y, fmt.Sprintf("Reachable: %d/%d (at %s, age %s)", good, total, sta.LastPing.Format("15:04:05"), age), rStyle)
y++
if good == 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, ", "), cBrightRed)
}
y += 2
/* packet loss */
put(scr, 0, y, "Packet Loss:", cDefault)
y++
for _, h := range packetLossHosts {
pct := sta.Loss[h] * 100
put(scr, 0, y, fmt.Sprintf("%-16s %5.0f%%", h+":", pct), styleLoss(pct))
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 table */
put(scr, 0, y, "TCP Connect Stats:", cDefault)
y++
put(scr, hostCol, y, "Host", cDefault)
put(scr, minCol, y, " min", cDefault)
put(scr, avgCol, y, " avg", cDefault)
put(scr, maxCol, y, " max", cDefault)
put(scr, stdCol, y, " stddev", cDefault)
put(scr, nCol, y, " n", cDefault)
y++
for _, hp := range tcpTestHosts {
hist := sta.TCP[hp]
if len(hist) == 0 {
continue
}
mi, ma, av, sd := minMaxAvgStd(hist)
drawLatencyRow(scr, y, hp, mi, av, ma, sd, len(hist))
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) {
tk := time.NewTicker(time.Second)
defer tk.Stop()
for {
select {
case <-ctx.Done():
return
case <-tk.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()
st.mu.Lock()
st.TotalICMPReq++
st.mu.Unlock()
ok := pingOnce(st.Name, host)
mu.Lock()
res[host] = ok
mu.Unlock()
st.mu.Lock()
if ok {
st.TotalICMPRep++
st.spin()
} else {
st.DroppedCount++
st.LastDrop = time.Now()
}
st.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) {
tk := time.NewTicker(packetLossPeriod)
defer tk.Stop()
for {
select {
case <-ctx.Done():
return
case <-tk.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 == 0 {
st.spin()
}
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) {
tk := time.NewTicker(time.Second)
defer tk.Stop()
for {
select {
case <-ctx.Done():
return
case <-tk.C:
for _, hp := range hosts {
ms := float64(tcpDuration(st.Name, hp).Milliseconds())
st.mu.Lock()
if ms < float64(tcpTimeout.Milliseconds()) {
st.spin()
}
hist := st.TCP[hp]
if len(hist) >= statsHistory {
hist = hist[1:]
}
st.TCP[hp] = append(hist, ms)
st.mu.Unlock()
}
}
}
}
/*──────────────────────── UI ───────────────────────────*/
func uiLoop(ctx context.Context, scr tcell.Screen, a, b *InterfaceStatus, start time.Time) {
defer scr.Fini()
tk := time.NewTicker(screenRefresh)
defer tk.Stop()
spin := 0
for {
select {
case <-ctx.Done():
return
case <-tk.C:
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()
}
}
}
/*──────────────────────── 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",
}
)
func main() {
flag.Parse()
reachHosts = strings.Split(*hostCSV, ",")
newStatus := 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 := newStatus(*ifaceA, *labelA), newStatus(*ifaceB, *labelB)
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()
// terminal Ctrl-C / SIGTERM
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
go func() { <-sig; cancel() }()
// in-app Ctrl-C / q
go func() {
for {
ev := screen.PollEvent()
if ev == nil {
return
}
switch e := ev.(type) {
case *tcell.EventKey:
if e.Key() == tcell.KeyCtrlC || e.Rune() == 'q' {
cancel()
return
}
}
}
}()
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, screen, a, b, time.Now())
}