initial
This commit is contained in:
@@ -0,0 +1,436 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user