This commit is contained in:
2025-05-16 04:25:04 -07:00
parent 9d2aa2b4de
commit 26e7fd997f
+159 -152
View File
@@ -1,5 +1,5 @@
// netmon real-time dual-interface network dashboard // netmon dual-interface network dashboard (curses)
// WTFPL 2025-05-16 sneak@sneak.berlin // WTFPL 2025-05-16 sneak@sneak.berlin
package main package main
import ( import (
@@ -19,56 +19,55 @@ import (
) )
//----------------------------------------------------------------------------- //-----------------------------------------------------------------------------
// Configuration flags // Flags
//----------------------------------------------------------------------------- //-----------------------------------------------------------------------------
var ( var (
ifaceA = flag.String("ifaceA", "gu0", "primary network interface") ifaceA = flag.String("ifaceA", "gu0",
labelA = flag.String("labelA", "gu LAN - VPN outbound", "label for ifaceA") "primary network interface")
ifaceB = flag.String("ifaceB", "backhaul0", "secondary network interface") labelA = flag.String("labelA", "gu LAN - VPN outbound",
labelB = flag.String("labelB", "Cox cable direct", "label for ifaceB") "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", hostCSV = flag.String("hosts",
"1.1.1.1,8.8.8.8,8.8.4.4,google.com,github.com,console.aws.amazon.com,"+ "1.1.1.1,8.8.8.8,8.8.4.4,google.com,github.com,"+
"console.cloud.google.com,fast.com,cloudflare.com,datavi.be", "console.aws.amazon.com,console.cloud.google.com,"+
"comma-separated hosts for reachability checks") "fast.com,cloudflare.com,datavi.be",
"hosts for reachability checks")
) )
//----------------------------------------------------------------------------- //-----------------------------------------------------------------------------
// Constants // Constants
//----------------------------------------------------------------------------- //-----------------------------------------------------------------------------
const ( const (
icmpTimeout = 500 * time.Millisecond // per-ping timeout icmpTimeout = 500 * time.Millisecond
tcpTimeout = 500 * time.Millisecond // TCP handshake timeout tcpTimeout = 500 * time.Millisecond
packetLossPings = 20 // pings per host for loss calc packetLossPings = 20
packetLossPeriod = 5 * time.Second // how often to recalc loss packetLossPeriod = 5 * time.Second
statsHistory = 300 // 5 minutes of 1 Hz samples statsHistory = 300 // 5 min of 1 Hz samples
screenRefresh = 500 * time.Millisecond // spinner / UI cadence screenRefresh = 500 * time.Millisecond
) )
//----------------------------------------------------------------------------- //-----------------------------------------------------------------------------
// Runtime data structures // Data
//----------------------------------------------------------------------------- //-----------------------------------------------------------------------------
type InterfaceStatus struct { type InterfaceStatus struct {
Name string Name string
Label string Label string
IPInfo string IPInfo string
Reachable map[string]bool
Reachable map[string]bool // last reachability results Loss map[string]float64
Loss map[string]float64// packet-loss % TCP map[string][]float64
TCP map[string][]float64 // rolling TCP ms samples LastPing time.Time
SpinFrame int
LastPing time.Time mu sync.RWMutex
mu sync.RWMutex
} }
//----------------------------------------------------------------------------- //-----------------------------------------------------------------------------
// Helpers maths without external deps // Math helpers (no heavy deps)
//----------------------------------------------------------------------------- //-----------------------------------------------------------------------------
func minMaxAvgStd(nums []float64) (min, max, avg, std float64) { func minMaxAvgStd(nums []float64) (min, max, avg, std float64) {
if len(nums) == 0 { if len(nums) == 0 {
return return
@@ -85,22 +84,19 @@ func minMaxAvgStd(nums []float64) (min, max, avg, std float64) {
sum += v sum += v
} }
avg = sum / float64(len(nums)) avg = sum / float64(len(nums))
var varSum float64 var vSum float64
for _, v := range nums { for _, v := range nums {
diff := v - avg diff := v - avg
varSum += diff * diff vSum += diff * diff
} }
std = math.Sqrt(varSum / float64(len(nums))) std = math.Sqrt(vSum / float64(len(nums)))
return return
} }
//----------------------------------------------------------------------------- //-----------------------------------------------------------------------------
// External look-ups // External look-ups
//----------------------------------------------------------------------------- //-----------------------------------------------------------------------------
type ipInfo struct{ IP, Hostname, Org string }
type ipInfo struct {
IP, Hostname, Org string
}
func fetchIPInfo(iface string) string { func fetchIPInfo(iface string) string {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
@@ -119,21 +115,18 @@ func fetchIPInfo(iface string) string {
} }
//----------------------------------------------------------------------------- //-----------------------------------------------------------------------------
// Ping helpers // Ping + loss
//----------------------------------------------------------------------------- //-----------------------------------------------------------------------------
func pingOnce(iface, host string) bool { func pingOnce(iface, host string) bool {
ctx, cancel := context.WithTimeout(context.Background(), icmpTimeout) ctx, cancel := context.WithTimeout(context.Background(), icmpTimeout)
defer cancel() defer cancel()
err := exec.CommandContext(ctx, err := exec.CommandContext(ctx, "ping", "-I", iface, "-c1", "-W1", host).Run()
"ping", "-I", iface, "-c1", "-W1", host).Run()
return err == nil return err == nil
} }
func lossPercent(iface, host string) float64 { func lossPercent(iface, host string) float64 {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel() defer cancel()
out, err := exec.CommandContext(ctx, out, err := exec.CommandContext(ctx,
"ping", "-q", "-i", "0.05", "-c", fmt.Sprint(packetLossPings), "ping", "-q", "-i", "0.05", "-c", fmt.Sprint(packetLossPings),
"-W1", "-I", iface, host).CombinedOutput() "-W1", "-I", iface, host).CombinedOutput()
@@ -141,13 +134,12 @@ func lossPercent(iface, host string) float64 {
return 1.0 return 1.0
} }
for _, line := range strings.Split(string(out), "\n") { for _, line := range strings.Split(string(out), "\n") {
if !strings.Contains(line, "packet loss") { if strings.Contains(line, "packet loss") {
continue for _, f := range strings.Fields(line) {
} if strings.HasSuffix(f, "%") {
for _, f := range strings.Fields(line) { p, _ := strconv.ParseFloat(strings.TrimSuffix(f, "%"), 64)
if strings.HasSuffix(f, "%") { return p / 100.0
pct, _ := strconv.ParseFloat(strings.TrimSuffix(f, "%"), 64) }
return pct / 100.0
} }
} }
} }
@@ -155,18 +147,14 @@ func lossPercent(iface, host string) float64 {
} }
//----------------------------------------------------------------------------- //-----------------------------------------------------------------------------
// TCP helpers // TCP timing
//----------------------------------------------------------------------------- //-----------------------------------------------------------------------------
func localAddr(iface string) (net.Addr, error) {
func localAddrFor(iface string) (net.Addr, error) {
ni, err := net.InterfaceByName(iface) ni, err := net.InterfaceByName(iface)
if err != nil { if err != nil {
return nil, err return nil, err
} }
addrs, err := ni.Addrs() addrs, _ := ni.Addrs()
if err != nil {
return nil, err
}
for _, a := range addrs { for _, a := range addrs {
if ipnet, ok := a.(*net.IPNet); ok && ipnet.IP.To4() != nil { if ipnet, ok := a.(*net.IPNet); ok && ipnet.IP.To4() != nil {
return &net.TCPAddr{IP: ipnet.IP}, nil return &net.TCPAddr{IP: ipnet.IP}, nil
@@ -176,7 +164,7 @@ func localAddrFor(iface string) (net.Addr, error) {
} }
func tcpDuration(iface, hostPort string) time.Duration { func tcpDuration(iface, hostPort string) time.Duration {
la, err := localAddrFor(iface) la, err := localAddr(iface)
if err != nil { if err != nil {
return tcpTimeout return tcpTimeout
} }
@@ -191,36 +179,44 @@ func tcpDuration(iface, hostPort string) time.Duration {
} }
//----------------------------------------------------------------------------- //-----------------------------------------------------------------------------
// Goroutines reachability, loss, TCP stats // Spinners
//----------------------------------------------------------------------------- //-----------------------------------------------------------------------------
var spinner = []rune{'|', '/', '-', '\\'}
func reachabilityLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { 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) tick := time.NewTicker(time.Second)
defer tick.Stop() defer tick.Stop()
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return return
case <-tick.C: case <-tick.C:
var wg sync.WaitGroup var wg sync.WaitGroup
results := make(map[string]bool, len(hosts)) res := make(map[string]bool, len(hosts))
mu := sync.Mutex{} mu := sync.Mutex{}
for _, h := range hosts { for _, h := range hosts {
wg.Add(1) wg.Add(1)
go func(host string) { go func(host string) {
defer wg.Done() defer wg.Done()
ok := pingOnce(st.Name, host) ok := pingOnce(st.Name, host)
mu.Lock() mu.Lock()
results[host] = ok res[host] = ok
if ok {
st.tickSpinner()
}
mu.Unlock() mu.Unlock()
}(h) }(h)
} }
wg.Wait() wg.Wait()
st.mu.Lock() st.mu.Lock()
st.Reachable = results st.Reachable = res
st.LastPing = time.Now() st.LastPing = time.Now()
st.mu.Unlock() st.mu.Unlock()
} }
@@ -230,30 +226,30 @@ func reachabilityLoop(ctx context.Context, st *InterfaceStatus, hosts []string)
func lossLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { func lossLoop(ctx context.Context, st *InterfaceStatus, hosts []string) {
tick := time.NewTicker(packetLossPeriod) tick := time.NewTicker(packetLossPeriod)
defer tick.Stop() defer tick.Stop()
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return return
case <-tick.C: case <-tick.C:
var wg sync.WaitGroup var wg sync.WaitGroup
results := make(map[string]float64, len(hosts)) res := make(map[string]float64, len(hosts))
mu := sync.Mutex{} mu := sync.Mutex{}
for _, h := range hosts { for _, h := range hosts {
wg.Add(1) wg.Add(1)
go func(host string) { go func(host string) {
defer wg.Done() defer wg.Done()
lp := lossPercent(st.Name, host) lp := lossPercent(st.Name, host)
mu.Lock() mu.Lock()
results[host] = lp res[host] = lp
if lp < 1.0 {
st.tickSpinner()
}
mu.Unlock() mu.Unlock()
}(h) }(h)
} }
wg.Wait() wg.Wait()
st.mu.Lock() st.mu.Lock()
for k, v := range results { for k, v := range res {
st.Loss[k] = v st.Loss[k] = v
} }
st.mu.Unlock() st.mu.Unlock()
@@ -264,7 +260,6 @@ func lossLoop(ctx context.Context, st *InterfaceStatus, hosts []string) {
func tcpLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { func tcpLoop(ctx context.Context, st *InterfaceStatus, hosts []string) {
tick := time.NewTicker(time.Second) tick := time.NewTicker(time.Second)
defer tick.Stop() defer tick.Stop()
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
@@ -272,11 +267,13 @@ func tcpLoop(ctx context.Context, st *InterfaceStatus, hosts []string) {
case <-tick.C: case <-tick.C:
for _, hp := range hosts { for _, hp := range hosts {
ms := float64(tcpDuration(st.Name, hp).Milliseconds()) ms := float64(tcpDuration(st.Name, hp).Milliseconds())
st.mu.Lock() st.mu.Lock()
if ms < float64(tcpTimeout.Milliseconds()) {
st.tickSpinner()
}
hist := st.TCP[hp] hist := st.TCP[hp]
if len(hist) >= statsHistory { if len(hist) >= statsHistory {
hist = hist[1:] // drop oldest hist = hist[1:]
} }
st.TCP[hp] = append(hist, ms) st.TCP[hp] = append(hist, ms)
st.mu.Unlock() st.mu.Unlock()
@@ -286,25 +283,39 @@ func tcpLoop(ctx context.Context, st *InterfaceStatus, hosts []string) {
} }
//----------------------------------------------------------------------------- //-----------------------------------------------------------------------------
// UI // UI helpers
//----------------------------------------------------------------------------- //-----------------------------------------------------------------------------
func write(s tcell.Screen, x, y int, str string) {
var spin = []rune{'|', '/', '-', '\\'}
func writeLine(s tcell.Screen, x, y int, str string) {
for i, r := range str { for i, r := range str {
s.SetContent(x+i, y, r, nil, tcell.StyleDefault) s.SetContent(x+i, y, r, nil, tcell.StyleDefault)
} }
} }
func drawSection(s tcell.Screen, x, y, w int, st *InterfaceStatus) { func drawDivider(s tcell.Screen, y, w int) {
line := strings.Repeat("=", w)
write(s, 0, y, line)
}
func drawHeaderLine(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()
drawHeaderLine(s, y+1, w, spin, head)
drawDivider(s, y+2, w)
y += 4 // skip blank line after
// contents
st.mu.RLock() st.mu.RLock()
defer st.mu.RUnlock()
// header
writeLine(s, x, y, fmt.Sprintf("%s — %s", st.Label, st.IPInfo))
// reachability
total := len(st.Reachable) total := len(st.Reachable)
rc := 0 rc := 0
var down []string var down []string
@@ -316,121 +327,117 @@ func drawSection(s tcell.Screen, x, y, w int, st *InterfaceStatus) {
} }
} }
age := time.Since(st.LastPing).Round(time.Second) age := time.Since(st.LastPing).Round(time.Second)
writeLine(s, x, y+1, write(s, 0, y, fmt.Sprintf("Reachable: %d/%d (at %s, age %s)",
fmt.Sprintf("Reachable: %d/%d (at %s, age %s)", rc, total, st.LastPing.Format("15:04:05"), age))
rc, total, st.LastPing.Format("15:04:05"), age)) y++
if len(down) > 0 { if len(down) > 0 {
writeLine(s, x, y+2, "Unreachable: "+strings.Join(down, ", ")) write(s, 0, y, "Unreachable: "+strings.Join(down, ", "))
} else { } else {
writeLine(s, x, y+2, "Unreachable: none") write(s, 0, y, "Unreachable: none")
} }
y += 2
// packet loss write(s, 0, y, "Packet Loss:")
row := y + 4 y++
writeLine(s, x, row, "Packet Loss:")
row++
for _, h := range packetLossHosts { for _, h := range packetLossHosts {
lp := st.Loss[h] * 100 lp := st.Loss[h] * 100
writeLine(s, x, row, fmt.Sprintf("%-16s %.0f%%", h+":", lp)) write(s, 0, y, fmt.Sprintf("%-16s %.0f%%", h+":", lp))
row++ y++
} }
y++
// TCP stats write(s, 0, y, "TCP Connect Stats:")
writeLine(s, x, row, "TCP Connect Stats:") y++
row++
for _, hp := range tcpTestHosts { for _, hp := range tcpTestHosts {
samples := st.TCP[hp] samples := st.TCP[hp]
if len(samples) == 0 { if len(samples) == 0 {
continue continue
} }
mi, ma, av, sd := minMaxAvgStd(samples) mi, ma, av, sd := minMaxAvgStd(samples)
writeLine(s, x, row, write(s, 0, y, fmt.Sprintf("%-16s %.0f/%.0f/%.0f/%.0fms",
fmt.Sprintf("%-16s %.0f/%.0f/%.0f/%.0fms", hp, mi, av, ma, sd))
hp, mi, av, ma, sd)) y++
row++
} }
st.mu.RUnlock()
return y + 2 // leave a blank line after section
} }
//-----------------------------------------------------------------------------
// UI main loop
//-----------------------------------------------------------------------------
func uiLoop(ctx context.Context, scr tcell.Screen, a, b *InterfaceStatus) { func uiLoop(ctx context.Context, scr tcell.Screen, a, b *InterfaceStatus) {
defer scr.Fini() defer scr.Fini()
tick := time.NewTicker(screenRefresh) ticker := time.NewTicker(screenRefresh)
defer tick.Stop() defer ticker.Stop()
frame := 0 topSpin := 0
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return return
case <-tick.C: case <-ticker.C:
w, _ := scr.Size()
scr.Clear() scr.Clear()
w, h := scr.Size() // top banner
mid := h / 2 drawDivider(scr, 0, w)
drawHeaderLine(scr, 1, w, spinner[topSpin%len(spinner)],
ts := time.Now().Format(time.RFC1123Z) time.Now().Format(time.RFC1123Z))
writeLine(scr, 0, 0, fmt.Sprintf("%c %s", spin[frame%len(spin)], ts)) drawDivider(scr, 2, w)
frame++ topSpin++
y := 4
drawSection(scr, 0, 1, w, a) y = drawInterface(scr, y, w, a)
drawSection(scr, 0, mid, w, b) y = drawInterface(scr, y, w, b)
scr.Show() scr.Show()
} }
} }
} }
//----------------------------------------------------------------------------- //-----------------------------------------------------------------------------
// Entry // Main
//----------------------------------------------------------------------------- //-----------------------------------------------------------------------------
var ( var (
reachabilityHosts []string reachHosts []string
packetLossHosts = []string{"datavi.be", "google.com", "fast.com", "cloudflare.com", "github.com"} packetLossHosts = []string{"datavi.be", "google.com", "fast.com",
tcpTestHosts = []string{"google.com:443", "github.com:443", "1.1.1.1:443", "8.8.8.8:53"} "cloudflare.com", "github.com"}
tcpTestHosts = []string{"google.com:443", "github.com:443",
"1.1.1.1:443", "8.8.8.8:53"}
) )
func main() { func main() {
flag.Parse() flag.Parse()
reachabilityHosts = strings.Split(*hostCSV, ",") reachHosts = strings.Split(*hostCSV, ",")
// prepare interface status objects a := &InterfaceStatus{
stA := &InterfaceStatus{
Name: *ifaceA, Name: *ifaceA,
Label: *labelA, Label: *labelA,
Reachable: make(map[string]bool), Reachable: map[string]bool{},
Loss: make(map[string]float64), Loss: map[string]float64{},
TCP: make(map[string][]float64), TCP: map[string][]float64{},
IPInfo: fetchIPInfo(*ifaceA), IPInfo: fetchIPInfo(*ifaceA),
} }
stB := &InterfaceStatus{ b := &InterfaceStatus{
Name: *ifaceB, Name: *ifaceB,
Label: *labelB, Label: *labelB,
Reachable: make(map[string]bool), Reachable: map[string]bool{},
Loss: make(map[string]float64), Loss: map[string]float64{},
TCP: make(map[string][]float64), TCP: map[string][]float64{},
IPInfo: fetchIPInfo(*ifaceB), IPInfo: fetchIPInfo(*ifaceB),
} }
screen, err := tcell.NewScreen() scr, err := tcell.NewScreen()
if err != nil { if err != nil {
panic(err) panic(err)
} }
if err := screen.Init(); err != nil { if err := scr.Init(); err != nil {
panic(err) panic(err)
} }
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
// background jobs go reachLoop(ctx, a, reachHosts)
go reachabilityLoop(ctx, stA, reachabilityHosts) go reachLoop(ctx, b, reachHosts)
go reachabilityLoop(ctx, stB, reachabilityHosts) go lossLoop(ctx, a, packetLossHosts)
go lossLoop(ctx, b, packetLossHosts)
go tcpLoop(ctx, a, tcpTestHosts)
go tcpLoop(ctx, b, tcpTestHosts)
go lossLoop(ctx, stA, packetLossHosts) uiLoop(ctx, scr, a, b)
go lossLoop(ctx, stB, packetLossHosts)
go tcpLoop(ctx, stA, tcpTestHosts)
go tcpLoop(ctx, stB, tcpTestHosts)
// UI (blocks)
uiLoop(ctx, screen, stA, stB)
} }