package main import ( "bufio" "context" "io" "net" "net/http" "os" "os/exec" "path/filepath" "strconv" "strings" "sync" "time" ) type NetworkMetrics struct { Interface string `json:"interface"` ReceiveBytes uint64 `json:"receiveBytes"` TransmitBytes uint64 `json:"transmitBytes"` ReceivePerSec float64 `json:"receivePerSec"` TransmitPerSec float64 `json:"transmitPerSec"` Interfaces []NetworkInterface `json:"interfaces"` Health NetworkHealth `json:"health"` } type NetworkHealth struct { Gateway string `json:"gateway"` GatewayUp bool `json:"gatewayUp"` DNSUp bool `json:"dnsUp"` InternetUp bool `json:"internetUp"` PacketLoss float64 `json:"packetLoss"` LatencyMs float64 `json:"latencyMs"` ExternalIP string `json:"externalIp"` ExternalIPChanged bool `json:"externalIpChanged"` DisappearedInterfaces []string `json:"disappearedInterfaces"` CheckedAt time.Time `json:"checkedAt"` } type NetworkInterface struct { Name string `json:"name"` ReceiveBytes uint64 `json:"receiveBytes"` TransmitBytes uint64 `json:"transmitBytes"` ReceiveErrors uint64 `json:"receiveErrors"` TransmitErrors uint64 `json:"transmitErrors"` ReceiveDrops uint64 `json:"receiveDrops"` TransmitDrops uint64 `json:"transmitDrops"` ReceivePerSec float64 `json:"receivePerSec"` TransmitPerSec float64 `json:"transmitPerSec"` VMID int `json:"vmid,omitempty"` GuestName string `json:"guestName,omitempty"` GuestType string `json:"guestType,omitempty"` Kind string `json:"kind"` State string `json:"state"` Carrier bool `json:"carrier"` SpeedMbps int `json:"speedMbps,omitempty"` Duplex string `json:"duplex,omitempty"` Master string `json:"master,omitempty"` } type networkSnapshot struct { at time.Time received uint64 sent uint64 } type networkCollector struct { mu sync.Mutex path string previous networkSnapshot previousInterfaces map[string]NetworkInterface health NetworkHealth healthUpdatedAt time.Time externalIP string knownInterfaces map[string]bool } func newNetworkCollector(path string) (*networkCollector, error) { snapshot, _, _, err := readNetworkSnapshot(path) if err != nil { return nil, err } _, _, details, _ := readNetworkSnapshot(path) previous := map[string]NetworkInterface{} for _, v := range details { previous[v.Name] = v } known := map[string]bool{} for _, v := range details { known[v.Name] = true } return &networkCollector{path: path, previous: snapshot, previousInterfaces: previous, knownInterfaces: known}, nil } func (c *networkCollector) collect() (NetworkMetrics, error) { c.mu.Lock() defer c.mu.Unlock() current, interfaceNames, details, err := readNetworkSnapshot(c.path) if err != nil { return NetworkMetrics{}, err } elapsed := current.at.Sub(c.previous.at).Seconds() var receivedRate, sentRate float64 if elapsed > 0 && current.received >= c.previous.received && current.sent >= c.previous.sent { receivedRate = float64(current.received-c.previous.received) / elapsed sentRate = float64(current.sent-c.previous.sent) / elapsed } for i := range details { v := &details[i] decorateInterface(v) if old, ok := c.previousInterfaces[v.Name]; ok && elapsed > 0 && v.ReceiveBytes >= old.ReceiveBytes && v.TransmitBytes >= old.TransmitBytes { v.ReceivePerSec = float64(v.ReceiveBytes-old.ReceiveBytes) / elapsed v.TransmitPerSec = float64(v.TransmitBytes-old.TransmitBytes) / elapsed } } c.previousInterfaces = map[string]NetworkInterface{} for _, v := range details { c.previousInterfaces[v.Name] = v } c.previous = current if time.Since(c.healthUpdatedAt) > 30*time.Second { c.health = checkNetworkHealth(c.externalIP) if c.health.ExternalIP != "" { c.externalIP = c.health.ExternalIP } c.healthUpdatedAt = time.Now() } currentNames := map[string]bool{} for _, v := range details { currentNames[v.Name] = true } c.health.DisappearedInterfaces = nil for name := range c.knownInterfaces { if !currentNames[name] { c.health.DisappearedInterfaces = append(c.health.DisappearedInterfaces, name) } } c.knownInterfaces = currentNames return NetworkMetrics{ Interface: strings.Join(interfaceNames, ", "), Interfaces: details, ReceiveBytes: current.received, TransmitBytes: current.sent, ReceivePerSec: receivedRate, TransmitPerSec: sentRate, Health: c.health, }, nil } func decorateInterface(v *NetworkInterface) { base := filepath.Join("/sys/class/net", v.Name) v.State = readTrimmed(filepath.Join(base, "operstate")) v.Carrier = readTrimmed(filepath.Join(base, "carrier")) == "1" v.SpeedMbps, _ = strconv.Atoi(readTrimmed(filepath.Join(base, "speed"))) v.Duplex = readTrimmed(filepath.Join(base, "duplex")) if target, err := filepath.EvalSymlinks(filepath.Join(base, "master")); err == nil { v.Master = filepath.Base(target) } switch { case strings.HasPrefix(v.Name, "vmbr"): v.Kind = "Bridge" case strings.HasPrefix(v.Name, "bond"): v.Kind = "Bond" case strings.Contains(v.Name, "."): v.Kind = "VLAN" case strings.HasPrefix(v.Name, "tap") || strings.HasPrefix(v.Name, "veth"): v.Kind = "Guest" default: v.Kind = "Physical" } } func checkNetworkHealth(previousIP string) NetworkHealth { result := NetworkHealth{CheckedAt: time.Now()} ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second) defer cancel() if out, err := exec.CommandContext(ctx, "ip", "route", "show", "default").Output(); err == nil { f := strings.Fields(string(out)) for i := range f { if f[i] == "via" && i+1 < len(f) { result.Gateway = f[i+1] break } } } if result.Gateway != "" { out, _ := exec.CommandContext(ctx, "ping", "-c", "3", "-W", "1", result.Gateway).CombinedOutput() text := string(out) result.GatewayUp = strings.Contains(text, "0% packet loss") if i := strings.Index(text, "packet loss"); i > 0 { start := strings.LastIndex(text[:i], " ") if start >= 0 { result.PacketLoss, _ = strconv.ParseFloat(strings.TrimSuffix(strings.TrimSpace(text[start:i]), "%"), 64) } } if i := strings.Index(text, "min/avg/max"); i >= 0 { if eq := strings.Index(text[i:], "="); eq >= 0 { parts := strings.Split(strings.Fields(text[i+eq+1:])[0], "/") if len(parts) > 1 { result.LatencyMs, _ = strconv.ParseFloat(parts[1], 64) } } } } resolver := net.Resolver{} _, err := resolver.LookupHost(ctx, "deb.debian.org") result.DNSUp = err == nil client := http.Client{Timeout: 4 * time.Second} request, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.ipify.org", nil) if response, err := client.Do(request); err == nil { defer response.Body.Close() body, _ := io.ReadAll(io.LimitReader(response.Body, 128)) result.ExternalIP = strings.TrimSpace(string(body)) result.InternetUp = response.StatusCode == 200 && net.ParseIP(result.ExternalIP) != nil } result.ExternalIPChanged = previousIP != "" && result.ExternalIP != "" && previousIP != result.ExternalIP return result } func readNetworkSnapshot(path string) (networkSnapshot, []string, []NetworkInterface, error) { file, err := os.Open(path) if err != nil { return networkSnapshot{}, nil, nil, err } defer file.Close() var received, sent uint64 var interfaces []string var details []NetworkInterface scanner := bufio.NewScanner(file) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) name, values, found := strings.Cut(line, ":") if !found || strings.TrimSpace(name) == "lo" { continue } fields := strings.Fields(values) if len(fields) < 9 { continue } rx, errRX := strconv.ParseUint(fields[0], 10, 64) tx, errTX := strconv.ParseUint(fields[8], 10, 64) if errRX == nil && errTX == nil { received += rx sent += tx interfaces = append(interfaces, strings.TrimSpace(name)) rxErr, _ := strconv.ParseUint(fields[2], 10, 64) rxDrop, _ := strconv.ParseUint(fields[3], 10, 64) txErr, _ := strconv.ParseUint(fields[10], 10, 64) txDrop, _ := strconv.ParseUint(fields[11], 10, 64) details = append(details, NetworkInterface{Name: strings.TrimSpace(name), ReceiveBytes: rx, TransmitBytes: tx, ReceiveErrors: rxErr, ReceiveDrops: rxDrop, TransmitErrors: txErr, TransmitDrops: txDrop}) } } return networkSnapshot{at: time.Now(), received: received, sent: sent}, interfaces, details, scanner.Err() }