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

496 lines
12 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/exec"
"strconv"
"strings"
"sync"
"time"
"github.com/gdamore/tcell/v2"
)
// -----------------------------------------------------------------------------
// 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",
"hosts for reachability checks")
)
// -----------------------------------------------------------------------------
// Constants
// -----------------------------------------------------------------------------
const (
icmpTimeout = 500 * time.Millisecond
tcpTimeout = 500 * time.Millisecond
packetLossPings = 20
packetLossPeriod = 5 * time.Second
statsHistory = 300 // 5 min of 1 Hz samples
screenRefresh = 500 * time.Millisecond
)
// -----------------------------------------------------------------------------
// Data structures
// -----------------------------------------------------------------------------
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
}
// -----------------------------------------------------------------------------
// Math helpers (no heavy 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 vsum float64
for _, v := range nums {
d := v - avg
vsum += d * d
}
std = math.Sqrt(vsum / 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") {
for _, f := range strings.Fields(line) {
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
}
addrs, _ := ni.Addrs()
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 := localAddr(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)
}
// -----------------------------------------------------------------------------
// Spinner helpers
// -----------------------------------------------------------------------------
var spinner = []rune{'|', '/', '-', '\\'}
func (st *InterfaceStatus) tickSpinner() {
st.SpinFrame = (st.SpinFrame + 1) % len(spinner)
}
// -----------------------------------------------------------------------------
// Goroutines
// -----------------------------------------------------------------------------
func reachLoop(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
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.tickSpinner()
} 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) {
tick := time.NewTicker(packetLossPeriod)
defer tick.Stop()
for {
select {
case <-ctx.Done():
return
case <-tick.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 < 1.0 {
st.tickSpinner()
}
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) {
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()
if ms < float64(tcpTimeout.Milliseconds()) {
st.tickSpinner()
}
hist := st.TCP[hp]
if len(hist) >= statsHistory {
hist = hist[1:]
}
st.TCP[hp] = append(hist, ms)
st.mu.Unlock()
}
}
}
}
// -----------------------------------------------------------------------------
// 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
// -----------------------------------------------------------------------------
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
for {
select {
case <-ctx.Done():
return
case <-tick.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)))
y := 5
y = drawInterface(scr, y, w, a)
_ = drawInterface(scr, y, w, b)
scr.Show()
}
}
}
// -----------------------------------------------------------------------------
// Main
// -----------------------------------------------------------------------------
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",
}
)
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),
}
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()
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())
}