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

437 lines
10 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 real-time dual-interface network dashboard
// 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"
)
//-----------------------------------------------------------------------------
// Configuration 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")
)
//-----------------------------------------------------------------------------
// Constants
//-----------------------------------------------------------------------------
const (
icmpTimeout = 500 * time.Millisecond // per-ping timeout
tcpTimeout = 500 * time.Millisecond // TCP handshake timeout
packetLossPings = 20 // pings per host for loss calc
packetLossPeriod = 5 * time.Second // how often to recalc loss
statsHistory = 300 // 5 minutes of 1 Hz samples
screenRefresh = 500 * time.Millisecond // spinner / UI cadence
)
//-----------------------------------------------------------------------------
// Runtime data structures
//-----------------------------------------------------------------------------
type InterfaceStatus struct {
Name string
Label string
IPInfo string
Reachable map[string]bool // last reachability results
Loss map[string]float64// packet-loss %
TCP map[string][]float64 // rolling TCP ms samples
LastPing time.Time
mu sync.RWMutex
}
//-----------------------------------------------------------------------------
// Helpers maths without external 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 varSum float64
for _, v := range nums {
diff := v - avg
varSum += diff * diff
}
std = math.Sqrt(varSum / 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") {
continue
}
for _, f := range strings.Fields(line) {
if strings.HasSuffix(f, "%") {
pct, _ := strconv.ParseFloat(strings.TrimSuffix(f, "%"), 64)
return pct / 100.0
}
}
}
return 1.0
}
//-----------------------------------------------------------------------------
// TCP helpers
//-----------------------------------------------------------------------------
func localAddrFor(iface string) (net.Addr, error) {
ni, err := net.InterfaceByName(iface)
if err != nil {
return nil, err
}
addrs, err := ni.Addrs()
if err != nil {
return nil, err
}
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 := localAddrFor(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)
}
//-----------------------------------------------------------------------------
// Goroutines reachability, loss, TCP stats
//-----------------------------------------------------------------------------
func reachabilityLoop(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
results := 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()
results[host] = ok
mu.Unlock()
}(h)
}
wg.Wait()
st.mu.Lock()
st.Reachable = results
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
results := 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()
results[host] = lp
mu.Unlock()
}(h)
}
wg.Wait()
st.mu.Lock()
for k, v := range results {
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()
hist := st.TCP[hp]
if len(hist) >= statsHistory {
hist = hist[1:] // drop oldest
}
st.TCP[hp] = append(hist, ms)
st.mu.Unlock()
}
}
}
}
//-----------------------------------------------------------------------------
// UI
//-----------------------------------------------------------------------------
var spin = []rune{'|', '/', '-', '\\'}
func writeLine(s tcell.Screen, x, y int, str string) {
for i, r := range str {
s.SetContent(x+i, y, r, nil, tcell.StyleDefault)
}
}
func drawSection(s tcell.Screen, x, y, w int, st *InterfaceStatus) {
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)
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)
writeLine(s, x, y+1,
fmt.Sprintf("Reachable: %d/%d (at %s, age %s)",
rc, total, st.LastPing.Format("15:04:05"), age))
if len(down) > 0 {
writeLine(s, x, y+2, "Unreachable: "+strings.Join(down, ", "))
} else {
writeLine(s, x, y+2, "Unreachable: none")
}
// packet loss
row := y + 4
writeLine(s, x, row, "Packet Loss:")
row++
for _, h := range packetLossHosts {
lp := st.Loss[h] * 100
writeLine(s, x, row, fmt.Sprintf("%-16s %.0f%%", h+":", lp))
row++
}
// TCP stats
writeLine(s, x, row, "TCP Connect Stats:")
row++
for _, hp := range tcpTestHosts {
samples := st.TCP[hp]
if len(samples) == 0 {
continue
}
mi, ma, av, sd := minMaxAvgStd(samples)
writeLine(s, x, row,
fmt.Sprintf("%-16s %.0f/%.0f/%.0f/%.0fms",
hp, mi, av, ma, sd))
row++
}
}
func uiLoop(ctx context.Context, scr tcell.Screen, a, b *InterfaceStatus) {
defer scr.Fini()
tick := time.NewTicker(screenRefresh)
defer tick.Stop()
frame := 0
for {
select {
case <-ctx.Done():
return
case <-tick.C:
scr.Clear()
w, h := scr.Size()
mid := h / 2
ts := time.Now().Format(time.RFC1123Z)
writeLine(scr, 0, 0, fmt.Sprintf("%c %s", spin[frame%len(spin)], ts))
frame++
drawSection(scr, 0, 1, w, a)
drawSection(scr, 0, mid, w, b)
scr.Show()
}
}
}
//-----------------------------------------------------------------------------
// Entry
//-----------------------------------------------------------------------------
var (
reachabilityHosts []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()
reachabilityHosts = strings.Split(*hostCSV, ",")
// prepare interface status objects
stA := &InterfaceStatus{
Name: *ifaceA,
Label: *labelA,
Reachable: make(map[string]bool),
Loss: make(map[string]float64),
TCP: make(map[string][]float64),
IPInfo: fetchIPInfo(*ifaceA),
}
stB := &InterfaceStatus{
Name: *ifaceB,
Label: *labelB,
Reachable: make(map[string]bool),
Loss: make(map[string]float64),
TCP: make(map[string][]float64),
IPInfo: fetchIPInfo(*ifaceB),
}
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()
// background jobs
go reachabilityLoop(ctx, stA, reachabilityHosts)
go reachabilityLoop(ctx, stB, reachabilityHosts)
go lossLoop(ctx, stA, packetLossHosts)
go lossLoop(ctx, stB, packetLossHosts)
go tcpLoop(ctx, stA, tcpTestHosts)
go tcpLoop(ctx, stB, tcpTestHosts)
// UI (blocks)
uiLoop(ctx, screen, stA, stB)
}