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

555 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 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")
)
// -----------------------------------------------------------------------------
// Consts
// -----------------------------------------------------------------------------
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 struct
// -----------------------------------------------------------------------------
type InterfaceStatus struct {
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
}
// -----------------------------------------------------------------------------
// 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 {
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 vs float64
for _, v := range nums {
d := v - avg
vs += d * d
}
std = math.Sqrt(vs / float64(len(nums)))
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}
st := time.Now()
c, err := d.Dial("tcp", hp)
if err != nil {
return tcpTimeout
}
c.Close()
return time.Since(st)
}
// -----------------------------------------------------------------------------
// Spinner
// -----------------------------------------------------------------------------
var spins = []rune{'|', '/', '-', '\\'}
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)
}
// -----------------------------------------------------------------------------
// 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) {
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 loop + input watcher
// -----------------------------------------------------------------------------
func uiLoop(ctx context.Context, scr tcell.Screen, a, b *InterfaceStatus, start time.Time) {
defer scr.Fini()
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 <-tk.C:
w, _ := scr.Size()
scr.Clear()
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 = 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, ",")
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 {
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)
go lossLoop(ctx, b, packetLossHosts)
go tcpLoop(ctx, a, tcpTestHosts)
go tcpLoop(ctx, b, tcpTestHosts)
uiLoop(ctx, scr, a, b, time.Now())
}