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

444 lines
11 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
//-----------------------------------------------------------------------------
type InterfaceStatus struct {
Name string
Label string
IPInfo string
Reachable map[string]bool
Loss map[string]float64
TCP map[string][]float64
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 {
diff := v - avg
vSum += diff * diff
}
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 + loss
//-----------------------------------------------------------------------------
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 timing
//-----------------------------------------------------------------------------
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)
}
//-----------------------------------------------------------------------------
// Spinners
//-----------------------------------------------------------------------------
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()
ok := pingOnce(st.Name, host)
mu.Lock()
res[host] = ok
if ok {
st.tickSpinner()
}
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 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()
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
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++
}
y++
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++
}
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) {
defer scr.Fini()
ticker := time.NewTicker(screenRefresh)
defer ticker.Stop()
topSpin := 0
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
w, _ := scr.Size()
scr.Clear()
// top banner
drawDivider(scr, 0, w)
drawHeaderLine(scr, 1, w, spinner[topSpin%len(spinner)],
time.Now().Format(time.RFC1123Z))
drawDivider(scr, 2, w)
topSpin++
y := 4
y = drawInterface(scr, y, w, a)
y = 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)
}