check / check (push) Failing after 1s
Linux runs exactly as before when both bridge interfaces exist. When they do not, the lone default-route interface is monitored, and a single interface now draws a single UI pane instead of an empty second one. macOS is newly supported: a running VPN tunnel (utun) is monitored as the primary pane alongside the physical default-route interface, or the physical interface alone when no VPN is up. Detection lives in internal/netdetect: interface/route data types, pure selection logic keyed on OS name, and route parsers, all unit-tested on Linux for both platforms. Only the real route query and the per-platform TCP dial binding (source address on Linux, IP_BOUND_IF on macOS) are build-tagged. Ping argument construction is a pure, OS-keyed function. NewMonitor now takes a list of interfaces. Model: opus-4-8
506 lines
12 KiB
Go
506 lines
12 KiB
Go
// Package monitor implements the real-time network monitoring dashboard:
|
|
// ICMP reachability, packet loss, TCP latency, and the terminal UI that
|
|
// renders them. It monitors one or two interfaces.
|
|
package monitor
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
tcell "github.com/gdamore/tcell/v2"
|
|
)
|
|
|
|
// Meter glyphs used to render the ASCII loss meter.
|
|
const (
|
|
MeterStart = '['
|
|
MeterEnd = ']'
|
|
MeterFill = '='
|
|
MeterEmpty = ' '
|
|
)
|
|
|
|
// Default monitor timing configuration.
|
|
const (
|
|
defaultICMPTimeout = 500 * time.Millisecond
|
|
defaultTCPTimeout = 500 * time.Millisecond
|
|
defaultPacketLossPings = 20
|
|
defaultPacketLossPeriod = 5 * time.Second
|
|
defaultStatsHistory = 300
|
|
defaultScreenRefresh = 500 * time.Millisecond
|
|
)
|
|
|
|
// Default display geometry (column widths and meter sizing).
|
|
const (
|
|
defaultMeterWidth = 7
|
|
defaultMeterFillWidth = 5
|
|
defaultMaxMeterValue = 10
|
|
defaultHostWidth = 30
|
|
defaultNumWidth = 7
|
|
defaultStdWidth = 8
|
|
defaultNWidth = 6
|
|
defaultLostWidth = 7
|
|
)
|
|
|
|
// Miscellaneous timing constants.
|
|
const (
|
|
ipInfoTimeout = 2 * time.Second
|
|
lossQueryTimeout = 3 * time.Second
|
|
uiUpdateBuffer = 100
|
|
logFileMode = 0o644
|
|
)
|
|
|
|
// errNoIPv4 is returned when an interface has no usable IPv4 address.
|
|
var errNoIPv4 = errors.New("no IPv4 address on interface")
|
|
|
|
// Monitor represents the network monitoring system.
|
|
type Monitor struct {
|
|
// Configuration
|
|
ICMPTimeout time.Duration
|
|
TCPTimeout time.Duration
|
|
PacketLossPings int
|
|
PacketLossPeriod time.Duration
|
|
StatsHistory int
|
|
ScreenRefresh time.Duration
|
|
MeterWidth int
|
|
MeterFillWidth int
|
|
MaxMeterValue int
|
|
|
|
// Column widths
|
|
HostWidth int
|
|
NumWidth int
|
|
StdWidth int
|
|
NWidth int
|
|
LostWidth int
|
|
|
|
// Host lists
|
|
reachabilityHosts []string
|
|
packetLossHosts []string
|
|
tcpHosts []string
|
|
|
|
// Interfaces to monitor (one or two)
|
|
interfaces []*InterfaceStatus
|
|
|
|
// Logging
|
|
logFile string
|
|
|
|
// Runtime state
|
|
screen tcell.Screen
|
|
startTime time.Time
|
|
uiUpdate chan struct{}
|
|
|
|
// Synchronization
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// IfaceSpec names one interface to monitor and its display label.
|
|
type IfaceSpec struct {
|
|
Name string
|
|
Label string
|
|
}
|
|
|
|
// NewMonitor creates a new Monitor instance with default settings,
|
|
// monitoring the given interfaces (one or two).
|
|
func NewMonitor(ifaces []IfaceSpec, logFile string) *Monitor {
|
|
m := &Monitor{
|
|
ICMPTimeout: defaultICMPTimeout,
|
|
TCPTimeout: defaultTCPTimeout,
|
|
PacketLossPings: defaultPacketLossPings,
|
|
PacketLossPeriod: defaultPacketLossPeriod,
|
|
StatsHistory: defaultStatsHistory,
|
|
ScreenRefresh: defaultScreenRefresh,
|
|
|
|
MeterWidth: defaultMeterWidth,
|
|
MeterFillWidth: defaultMeterFillWidth,
|
|
MaxMeterValue: defaultMaxMeterValue,
|
|
HostWidth: defaultHostWidth,
|
|
NumWidth: defaultNumWidth,
|
|
StdWidth: defaultStdWidth,
|
|
NWidth: defaultNWidth,
|
|
LostWidth: defaultLostWidth,
|
|
|
|
reachabilityHosts: []string{},
|
|
packetLossHosts: []string{},
|
|
tcpHosts: []string{},
|
|
|
|
logFile: logFile,
|
|
startTime: time.Now(),
|
|
uiUpdate: make(chan struct{}, uiUpdateBuffer),
|
|
}
|
|
|
|
for _, spec := range ifaces {
|
|
m.interfaces = append(m.interfaces, NewInterfaceStatus(spec.Name, spec.Label))
|
|
}
|
|
|
|
return m
|
|
}
|
|
|
|
// AddReachabilityHost adds a host for reachability monitoring.
|
|
func (m *Monitor) AddReachabilityHost(host string) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
m.reachabilityHosts = append(m.reachabilityHosts, host)
|
|
}
|
|
|
|
// AddPacketLossHost adds a host for packet loss monitoring.
|
|
func (m *Monitor) AddPacketLossHost(host string) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
m.packetLossHosts = append(m.packetLossHosts, host)
|
|
}
|
|
|
|
// AddTCPHost adds a host:port for TCP connectivity monitoring.
|
|
func (m *Monitor) AddTCPHost(hostPort string) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
m.tcpHosts = append(m.tcpHosts, hostPort)
|
|
}
|
|
|
|
// Run starts the monitoring system.
|
|
func (m *Monitor) Run(ctx context.Context) error {
|
|
m.logf("Starting monitor run")
|
|
m.logf("Initializing screen")
|
|
|
|
scr, err := tcell.NewScreen()
|
|
if err != nil {
|
|
m.logf("Error creating screen: %v", err)
|
|
|
|
return fmt.Errorf("error creating screen: %w", err)
|
|
}
|
|
|
|
err = scr.Init()
|
|
if err != nil {
|
|
m.logf("Error initializing screen: %v", err)
|
|
|
|
return fmt.Errorf("error initializing screen: %w", err)
|
|
}
|
|
|
|
m.screen = scr
|
|
m.logf("Screen initialized")
|
|
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
|
|
go m.keyboardEventLoop(ctx, cancel)
|
|
|
|
m.logf("Starting monitoring goroutines")
|
|
m.mu.RLock()
|
|
reachHosts := append([]string{}, m.reachabilityHosts...)
|
|
lossHosts := append([]string{}, m.packetLossHosts...)
|
|
tcpHosts := append([]string{}, m.tcpHosts...)
|
|
m.mu.RUnlock()
|
|
|
|
for _, st := range m.interfaces {
|
|
go m.reachLoop(ctx, st, reachHosts)
|
|
go m.lossLoop(ctx, st, lossHosts)
|
|
go m.tcpLoop(ctx, st, tcpHosts)
|
|
}
|
|
|
|
m.logf("Starting UI loop")
|
|
m.uiLoop(ctx)
|
|
m.logf("UI loop exited, monitor ending")
|
|
|
|
return nil
|
|
}
|
|
|
|
// notifyUI requests a screen redraw without blocking if one is pending.
|
|
func (m *Monitor) notifyUI() {
|
|
select {
|
|
case m.uiUpdate <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
// keyboardEventLoop handles keyboard input. The context is accepted for a
|
|
// uniform loop signature; shutdown is driven by cancel on the quit key and
|
|
// by the screen being finalized (PollEvent then returns nil).
|
|
func (m *Monitor) keyboardEventLoop(_ context.Context, cancel context.CancelFunc) {
|
|
m.logf("Starting keyboard event loop")
|
|
|
|
for {
|
|
ev := m.screen.PollEvent()
|
|
if ev == nil {
|
|
continue
|
|
}
|
|
|
|
m.logf("Event received: %T", ev)
|
|
|
|
if ke, ok := ev.(*tcell.EventKey); ok {
|
|
m.logf("Key event: %v, rune: %c", ke.Key(), ke.Rune())
|
|
|
|
if ke.Key() == tcell.KeyCtrlC || ke.Rune() == 'q' {
|
|
m.logf("Quit key detected")
|
|
cancel()
|
|
|
|
return
|
|
}
|
|
}
|
|
|
|
m.notifyUI()
|
|
}
|
|
}
|
|
|
|
// logf logs a formatted message.
|
|
func (m *Monitor) logf(format string, v ...any) {
|
|
Logf(m.logFile, format, v...)
|
|
}
|
|
|
|
// InterfaceStatus holds the runtime status for a network interface.
|
|
type InterfaceStatus struct {
|
|
Name, Label, IPInfo string
|
|
Reachable map[string]bool
|
|
Loss map[string]float64
|
|
TCP map[string][]float64
|
|
TotalICMPReq, TotalICMPRep int
|
|
DroppedCount int
|
|
LastDrop, LastPing time.Time
|
|
SpinFrame int
|
|
MeterValue int
|
|
LostPackets map[string]int
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// NewInterfaceStatus creates a new interface status.
|
|
func NewInterfaceStatus(name, label string) *InterfaceStatus {
|
|
return &InterfaceStatus{
|
|
Name: name,
|
|
Label: label,
|
|
IPInfo: fetchIPInfo(name),
|
|
Reachable: map[string]bool{},
|
|
Loss: map[string]float64{},
|
|
TCP: map[string][]float64{},
|
|
MeterValue: 0,
|
|
LostPackets: map[string]int{},
|
|
}
|
|
}
|
|
|
|
// ipInfoResp holds the ipinfo.io response.
|
|
type ipInfoResp struct {
|
|
IP string `json:"ip"`
|
|
Hostname string `json:"hostname"`
|
|
Org string `json:"org"`
|
|
}
|
|
|
|
// fetchIPInfo fetches public IP information as seen from an interface.
|
|
func fetchIPInfo(iface string) string {
|
|
ctx, cancel := context.WithTimeout(context.Background(), ipInfoTimeout)
|
|
defer cancel()
|
|
|
|
cmd := exec.CommandContext( //nolint:gosec // G204: fixed argv, operator CLI input
|
|
ctx, "curl", "-s", "--interface", iface, "--max-time", "2", "ipinfo.io")
|
|
|
|
out, _ := cmd.Output()
|
|
|
|
var r ipInfoResp
|
|
|
|
_ = json.Unmarshal(out, &r)
|
|
if r.IP == "" {
|
|
return "(ipinfo error)"
|
|
}
|
|
|
|
return fmt.Sprintf("%s [%s] %s", r.IP, r.Hostname, r.Org)
|
|
}
|
|
|
|
// pingArgs builds the arguments for a single reachability ping on the given
|
|
// OS. Linux binds the interface with -I and takes -W in seconds; macOS binds
|
|
// with -b and takes -W in milliseconds.
|
|
func pingArgs(goos, iface, host string) []string {
|
|
if goos == "darwin" {
|
|
return []string{"-b", iface, "-c1", "-W1000", host}
|
|
}
|
|
|
|
return []string{"-I", iface, "-c1", "-W1", host}
|
|
}
|
|
|
|
// lossArgs builds the arguments for a packet-loss ping burst of count pings.
|
|
func lossArgs(goos, iface, host string, count int) []string {
|
|
c := strconv.Itoa(count)
|
|
if goos == "darwin" {
|
|
return []string{"-q", "-i", "0.05", "-c", c, "-W1000", "-b", iface, host}
|
|
}
|
|
|
|
return []string{"-q", "-i", "0.05", "-c", c, "-W1", "-I", iface, host}
|
|
}
|
|
|
|
// pingOnce performs a single ping over the named interface.
|
|
func (m *Monitor) pingOnce(ctx context.Context, iface, host string) bool {
|
|
ctx, cancel := context.WithTimeout(ctx, m.ICMPTimeout)
|
|
defer cancel()
|
|
|
|
args := pingArgs(runtime.GOOS, iface, host)
|
|
|
|
cmd := exec.CommandContext( //nolint:gosec // G204: fixed argv, operator CLI input
|
|
ctx, "ping", args...)
|
|
|
|
return cmd.Run() == nil
|
|
}
|
|
|
|
// lossPercent measures the packet loss fraction (0..1) for a host.
|
|
func (m *Monitor) lossPercent(ctx context.Context, iface, host string) float64 {
|
|
ctx, cancel := context.WithTimeout(ctx, lossQueryTimeout)
|
|
defer cancel()
|
|
|
|
args := lossArgs(runtime.GOOS, iface, host, m.PacketLossPings)
|
|
|
|
cmd := exec.CommandContext( //nolint:gosec // G204: fixed argv, operator CLI input
|
|
ctx, "ping", args...)
|
|
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return 1.0
|
|
}
|
|
|
|
for _, ln := range strings.Split(string(out), "\n") {
|
|
if !strings.Contains(ln, "packet loss") {
|
|
continue
|
|
}
|
|
|
|
for _, f := range strings.Fields(ln) {
|
|
before, ok := strings.CutSuffix(f, "%")
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
p, _ := strconv.ParseFloat(before, 64)
|
|
|
|
return p / percentFull
|
|
}
|
|
}
|
|
|
|
return 1.0
|
|
}
|
|
|
|
// localAddr returns the first IPv4 address bound to an interface.
|
|
func localAddr(iface string) (net.Addr, error) {
|
|
ifi, err := net.InterfaceByName(iface)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("interface %s: %w", iface, err)
|
|
}
|
|
|
|
add, _ := ifi.Addrs()
|
|
for _, a := range add {
|
|
if ipnet, ok := a.(*net.IPNet); ok && ipnet.IP.To4() != nil {
|
|
return &net.TCPAddr{IP: ipnet.IP}, nil
|
|
}
|
|
}
|
|
|
|
return nil, fmt.Errorf("%w: %s", errNoIPv4, iface)
|
|
}
|
|
|
|
// tcpDuration measures how long a TCP connection to hp takes over iface.
|
|
func (m *Monitor) tcpDuration(iface, hp string) time.Duration {
|
|
la, err := localAddr(iface)
|
|
if err != nil {
|
|
return m.TCPTimeout
|
|
}
|
|
|
|
d := net.Dialer{Timeout: m.TCPTimeout, LocalAddr: la, Control: bindControl(iface)}
|
|
st := time.Now()
|
|
|
|
c, err := d.Dial("tcp", hp)
|
|
if err != nil {
|
|
return m.TCPTimeout
|
|
}
|
|
|
|
_ = c.Close()
|
|
|
|
return time.Since(st)
|
|
}
|
|
|
|
// MinMaxAvgStd returns the minimum, maximum, mean and standard deviation.
|
|
func MinMaxAvgStd(xs []float64) (float64, float64, float64, float64) {
|
|
if len(xs) == 0 {
|
|
return 0, 0, 0, 0
|
|
}
|
|
|
|
mn, mx := xs[0], xs[0]
|
|
|
|
var sum float64
|
|
|
|
for _, v := range xs {
|
|
mn = min(mn, v)
|
|
mx = max(mx, v)
|
|
sum += v
|
|
}
|
|
|
|
avg := sum / float64(len(xs))
|
|
|
|
var variance float64
|
|
|
|
for _, v := range xs {
|
|
variance += (v - avg) * (v - avg)
|
|
}
|
|
|
|
return mn, mx, avg, math.Sqrt(variance / float64(len(xs)))
|
|
}
|
|
|
|
// Spin advances the spinner frame.
|
|
func (st *InterfaceStatus) Spin() {
|
|
st.SpinFrame = (st.SpinFrame + 1) % len(spins())
|
|
}
|
|
|
|
// IsHealthy reports whether the interface currently looks healthy.
|
|
func (st *InterfaceStatus) IsHealthy(tcpTimeout time.Duration) bool {
|
|
st.mu.RLock()
|
|
defer st.mu.RUnlock()
|
|
|
|
for _, ok := range st.Reachable {
|
|
if !ok {
|
|
return false
|
|
}
|
|
}
|
|
|
|
for _, lp := range st.Loss {
|
|
if lp > 0 {
|
|
return false
|
|
}
|
|
}
|
|
|
|
for _, hist := range st.TCP {
|
|
if len(hist) == 0 || hist[len(hist)-1] >= float64(tcpTimeout.Milliseconds()) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// spins returns the ASCII spinner frames.
|
|
func spins() []rune {
|
|
return []rune{'|', '/', '-', '\\'}
|
|
}
|
|
|
|
// brailleSpins returns the braille clock spinner frames.
|
|
func brailleSpins() []rune {
|
|
return []rune{'⠉', '⠘', '⠰', '⠠', '⠄', '⠆', '⠇', '⠋'}
|
|
}
|
|
|
|
// Logf appends a timestamped formatted message to logFile, if set.
|
|
func Logf(logFile, format string, v ...any) {
|
|
if logFile == "" {
|
|
return
|
|
}
|
|
|
|
f, err := os.OpenFile( //nolint:gosec // G304: operator --logfile path
|
|
logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, logFileMode)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
|
|
_, _ = fmt.Fprintf(f,
|
|
time.Now().Format("2006-01-02 15:04:05.000 ")+format+"\n", v...)
|
|
}
|