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
426 lines
10 KiB
Go
426 lines
10 KiB
Go
package monitor
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
tcell "github.com/gdamore/tcell/v2"
|
|
)
|
|
|
|
// Non-configurable constants (meter characters)
|
|
const (
|
|
// ASCII characters for the meter
|
|
MeterStart = '['
|
|
MeterEnd = ']'
|
|
MeterFill = '='
|
|
MeterEmpty = ' '
|
|
)
|
|
|
|
// 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
|
|
|
|
// 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{
|
|
// Default timing configuration
|
|
ICMPTimeout: 500 * time.Millisecond,
|
|
TCPTimeout: 500 * time.Millisecond,
|
|
PacketLossPings: 20,
|
|
PacketLossPeriod: 5 * time.Second,
|
|
StatsHistory: 300,
|
|
ScreenRefresh: 500 * time.Millisecond,
|
|
|
|
// Default display configuration
|
|
MeterWidth: 7,
|
|
MeterFillWidth: 5,
|
|
MaxMeterValue: 10,
|
|
HostWidth: 30,
|
|
NumWidth: 7,
|
|
StdWidth: 8,
|
|
NWidth: 6,
|
|
LostWidth: 7,
|
|
|
|
// Initialize host lists
|
|
reachabilityHosts: []string{},
|
|
packetLossHosts: []string{},
|
|
tcpHosts: []string{},
|
|
|
|
// Logging
|
|
logFile: logFile,
|
|
|
|
startTime: time.Now(),
|
|
}
|
|
|
|
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")
|
|
|
|
// Initialize screen
|
|
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)
|
|
}
|
|
if err = scr.Init(); err != nil {
|
|
m.logf("Error initializing screen: %v", err)
|
|
return fmt.Errorf("error initializing screen: %w", err)
|
|
}
|
|
m.screen = scr
|
|
m.logf("Screen initialized")
|
|
|
|
// Create cancellable context
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
|
|
// Handle keyboard events
|
|
go m.keyboardEventLoop(ctx, cancel)
|
|
|
|
// Start monitoring goroutines
|
|
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)
|
|
}
|
|
|
|
// Run UI loop
|
|
m.logf("Starting UI loop")
|
|
m.uiLoop(ctx)
|
|
m.logf("UI loop exited, monitor ending")
|
|
|
|
return nil
|
|
}
|
|
|
|
// keyboardEventLoop handles keyboard input
|
|
func (m *Monitor) keyboardEventLoop(ctx context.Context, cancel context.CancelFunc) {
|
|
m.logf("Starting keyboard event loop")
|
|
for {
|
|
if ev := m.screen.PollEvent(); ev != nil {
|
|
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
|
|
}
|
|
}
|
|
|
|
// Trigger UI update on any event
|
|
select {
|
|
case UIUpdateChan <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// logf logs a formatted message
|
|
func (m *Monitor) logf(format string, v ...interface{}) {
|
|
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 IP info response
|
|
type ipInfoResp struct {
|
|
IP string `json:"ip"`
|
|
Hostname string `json:"hostname"`
|
|
Org string `json:"org"`
|
|
}
|
|
|
|
// fetchIPInfo fetches IP information for an interface
|
|
func fetchIPInfo(iface string) string {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
out, _ := exec.CommandContext(ctx, "curl", "-s", "--interface", iface, "--max-time", "2", "ipinfo.io").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
|
|
func (m *Monitor) pingOnce(iface, host string) bool {
|
|
ctx, cancel := context.WithTimeout(context.Background(), m.ICMPTimeout)
|
|
defer cancel()
|
|
|
|
args := pingArgs(runtime.GOOS, iface, host)
|
|
|
|
return exec.CommandContext(ctx, "ping", args...).Run() == nil
|
|
}
|
|
|
|
// lossPercent calculates packet loss percentage
|
|
func (m *Monitor) lossPercent(iface, host string) float64 {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
|
|
args := lossArgs(runtime.GOOS, iface, host, m.PacketLossPings)
|
|
|
|
out, err := exec.CommandContext(ctx, "ping", args...).CombinedOutput()
|
|
if err != nil {
|
|
return 1.0
|
|
}
|
|
for _, ln := range strings.Split(string(out), "\n") {
|
|
if strings.Contains(ln, "packet loss") {
|
|
for _, f := range strings.Fields(ln) {
|
|
if strings.HasSuffix(f, "%") {
|
|
p, _ := strconv.ParseFloat(strings.TrimSuffix(f, "%"), 64)
|
|
return p / 100.0
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return 1.0
|
|
}
|
|
|
|
// localAddr gets the local address for an interface
|
|
func localAddr(iface string) (net.Addr, error) {
|
|
ifi, err := net.InterfaceByName(iface)
|
|
if err != nil {
|
|
return nil, 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("no IPv4 on %s", iface)
|
|
}
|
|
|
|
// tcpDuration measures TCP connection duration
|
|
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 calculates min, max, average and standard deviation
|
|
func MinMaxAvgStd(xs []float64) (min, max, avg, std float64) {
|
|
if len(xs) == 0 {
|
|
return
|
|
}
|
|
min, max = xs[0], xs[0]
|
|
var sum float64
|
|
for _, v := range xs {
|
|
if v < min {
|
|
min = v
|
|
}
|
|
if v > max {
|
|
max = v
|
|
}
|
|
sum += v
|
|
}
|
|
avg = sum / float64(len(xs))
|
|
var vs float64
|
|
for _, v := range xs {
|
|
d := v - avg
|
|
vs += d * d
|
|
}
|
|
std = math.Sqrt(vs / float64(len(xs)))
|
|
return
|
|
}
|
|
|
|
// Spin advances the spinner frame
|
|
func (st *InterfaceStatus) Spin() {
|
|
st.SpinFrame = (st.SpinFrame + 1) % len(Spins)
|
|
}
|
|
|
|
// IsHealthy checks if the interface is healthy
|
|
func (st *InterfaceStatus) IsHealthy(tcpTimeout time.Duration) bool {
|
|
st.mu.RLock()
|
|
defer st.mu.RUnlock()
|
|
|
|
// Check if there are any currently unreachable hosts
|
|
for _, ok := range st.Reachable {
|
|
if !ok {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Check if there's any current packet loss
|
|
for _, lp := range st.Loss {
|
|
if lp > 0 {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Check if there are any TCP timeouts
|
|
for _, hist := range st.TCP {
|
|
if len(hist) == 0 || hist[len(hist)-1] >= float64(tcpTimeout.Milliseconds()) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// Spinners and braille characters
|
|
var (
|
|
Spins = []rune{'|', '/', '-', '\\'}
|
|
BrailleSpins = []rune{
|
|
'⠉', '⠘', '⠰', '⠠', '⠄', '⠆', '⠇', '⠋',
|
|
}
|
|
)
|
|
|
|
// Logf is a simple logging function
|
|
func Logf(logFile string, format string, v ...interface{}) {
|
|
if logFile == "" {
|
|
return
|
|
}
|
|
f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer f.Close()
|
|
fmt.Fprintf(f, time.Now().Format("2006-01-02 15:04:05.000 ")+format+"\n", v...)
|
|
}
|