Files
ProxmoxDash/network.go
2026-08-01 16:58:25 +03:00

253 lines
8.5 KiB
Go

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()
}