package network import ( "fmt" "log" "net" "os" "regexp" "strconv" "strings" "sync" "time" "github.com/vishvananda/netlink" ) type prevStat struct { rxBytes uint64 txBytes uint64 time time.Time } type rawNetDevStat struct { rxBytes uint64 rxPackets uint64 rxErrors uint64 rxDropped uint64 txBytes uint64 txPackets uint64 txErrors uint64 txDropped uint64 time time.Time } // Manager handles all Linux network interactions via Netlink. type Manager struct { mu sync.Mutex metadata *MetadataManager prevStats map[string]prevStat snapMu sync.RWMutex latestSnapshot *TrafficSnapshot subMu sync.Mutex subscribers map[chan *TrafficSnapshot]bool } func NewManager(mm *MetadataManager) *Manager { mgr := &Manager{ metadata: mm, prevStats: make(map[string]prevStat), subscribers: make(map[chan *TrafficSnapshot]bool), } mgr.startIPSyncWorker() mgr.startTrafficSampler() return mgr } // syncPersistedIPs ensures that all IP addresses saved in metadata are applied to the kernel interfaces func (m *Manager) syncPersistedIPs() { if m.metadata == nil { return } configured := m.metadata.GetAllWithIPs() for name, ips := range configured { link, err := netlink.LinkByName(name) if err != nil { continue // Interface might not be attached/created yet by Proxmox host } // Ensure link is UP if it has configured IPs if link.Attrs().Flags&net.FlagUp == 0 { _ = netlink.LinkSetUp(link) } currentAddrs, err := netlink.AddrList(link, netlink.FAMILY_V4) if err != nil { continue } currentMap := make(map[string]bool) for _, a := range currentAddrs { currentMap[a.IPNet.String()] = true } for _, cidr := range ips { if !currentMap[cidr] { addr, err := netlink.ParseAddr(cidr) if err == nil { if err := netlink.AddrAdd(link, addr); err == nil { log.Printf("[NetSync] Restored IP %s on interface %s", cidr, name) } } } } } } func (m *Manager) startIPSyncWorker() { go func() { m.syncPersistedIPs() ticker := time.NewTicker(3 * time.Second) defer ticker.Stop() for range ticker.C { m.syncPersistedIPs() } }() } // IsKernelDummyInterface returns true if link is an internal kernel fallback/dummy device (e.g. gre0, gretap0, erspan0) func IsKernelDummyInterface(name string) bool { if strings.HasPrefix(name, "ep_") { return true } switch name { case "gre0", "gretap0", "erspan0", "sit0", "tunl0", "ip6tnl0", "ip6gre0", "ip6erspan0": return true default: return false } } // ListInterfaces returns all network links on the system. func (m *Manager) ListInterfaces() ([]InterfaceInfo, error) { links, err := netlink.LinkList() if err != nil { return nil, fmt.Errorf("failed to list links: %w", err) } linkMap := make(map[int]netlink.Link) for _, l := range links { linkMap[l.Attrs().Index] = l } latestSnap := m.GetLatestTrafficSnapshot() var latestRates map[string]InterfaceLiveRate if latestSnap != nil { latestRates = latestSnap.Interfaces } m.mu.Lock() defer m.mu.Unlock() result := make([]InterfaceInfo, 0) for _, link := range links { attrs := link.Attrs() if IsKernelDummyInterface(attrs.Name) { continue } linkType := link.Type() if linkType == "dummy" && strings.HasPrefix(attrs.Name, "sdwan") { linkType = "sdwan" } operState := strings.ToUpper(attrs.OperState.String()) if (operState == "UNKNOWN" || operState == "DOWN") && (attrs.Flags&net.FlagUp != 0) { operState = "UP" } info := InterfaceInfo{ Index: attrs.Index, Name: attrs.Name, Type: linkType, MTU: attrs.MTU, MAC: attrs.HardwareAddr.String(), OperState: operState, IPv4: make([]string, 0), IPv6: make([]string, 0), Flags: make([]string, 0), } // Flags if attrs.Flags&net.FlagUp != 0 { info.State = "UP" } else { info.State = "DOWN" } for _, f := range strings.Split(attrs.Flags.String(), "|") { if f != "" { info.Flags = append(info.Flags, f) } } // Check if interface is a VLAN if vlan, ok := link.(*netlink.Vlan); ok { info.IsVLAN = true info.VlanID = vlan.VlanId info.ParentIndex = vlan.ParentIndex if parent, exists := linkMap[vlan.ParentIndex]; exists { info.ParentName = parent.Attrs().Name } } else if strings.Contains(attrs.Name, ".") { // Sub-interface naming fallback (e.g. eth0.10) parts := strings.Split(attrs.Name, ".") if len(parts) == 2 { var id int if n, _ := fmt.Sscanf(parts[1], "%d", &id); n == 1 { info.IsVLAN = true info.VlanID = id info.ParentName = parts[0] } } } // Addresses addrs, err := netlink.AddrList(link, netlink.FAMILY_ALL) if err == nil { for _, a := range addrs { if a.IP.To4() != nil { info.IPv4 = append(info.IPv4, a.IPNet.String()) } else { info.IPv6 = append(info.IPv6, a.IPNet.String()) } } } // Metadata meta := m.metadata.Get(attrs.Name) info.Role = meta.Role info.Description = meta.Description if info.Role == "" { if attrs.Name == "lo" { info.Role = "LOOPBACK" } else if info.IsVLAN { info.Role = "VLAN" } else if strings.HasPrefix(attrs.Name, "eth0") || strings.HasPrefix(attrs.Name, "ens") { info.Role = "WAN/LAN" } } // Statistics & throughput calculation if attrs.Statistics != nil { info.Stats = NetStats{ RxBytes: attrs.Statistics.RxBytes, TxBytes: attrs.Statistics.TxBytes, RxPackets: attrs.Statistics.RxPackets, TxPackets: attrs.Statistics.TxPackets, RxErrors: attrs.Statistics.RxErrors, TxErrors: attrs.Statistics.TxErrors, RxDropped: attrs.Statistics.RxDropped, TxDropped: attrs.Statistics.TxDropped, } } if lr, ok := latestRates[attrs.Name]; ok { info.Rate = TrafficRate{ RxBytesPerSec: lr.RxBytesPerSec, TxBytesPerSec: lr.TxBytesPerSec, RxHuman: lr.RxHuman, TxHuman: lr.TxHuman, } if attrs.Statistics == nil || attrs.Statistics.RxBytes == 0 { info.Stats = NetStats{ RxBytes: lr.RxBytes, TxBytes: lr.TxBytes, RxPackets: lr.RxPackets, TxPackets: lr.TxPackets, RxErrors: lr.RxErrors, TxErrors: lr.TxErrors, RxDropped: lr.RxDropped, TxDropped: lr.TxDropped, } } } result = append(result, info) } return result, nil } // GetInterface returns information for a specific interface. func (m *Manager) GetInterface(name string) (*InterfaceInfo, error) { interfaces, err := m.ListInterfaces() if err != nil { return nil, err } for _, iface := range interfaces { if iface.Name == name { return &iface, nil } } return nil, fmt.Errorf("interface %s not found", name) } // SetInterfaceState sets the administrative status (UP or DOWN). func (m *Manager) SetInterfaceState(name string, up bool) error { link, err := netlink.LinkByName(name) if err != nil { return fmt.Errorf("link not found: %w", err) } if up { return netlink.LinkSetUp(link) } return netlink.LinkSetDown(link) } // SetInterfaceMTU updates the MTU for the specified interface. func (m *Manager) SetInterfaceMTU(name string, mtu int) error { if mtu < 68 || mtu > 65535 { return fmt.Errorf("invalid MTU size: %d", mtu) } link, err := netlink.LinkByName(name) if err != nil { return fmt.Errorf("link not found: %w", err) } return netlink.LinkSetMTU(link, mtu) } var ifaceNameRegex = regexp.MustCompile(`^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$`) // RenameInterface safely renames an existing network device using Netlink. func (m *Manager) RenameInterface(oldName, newName string) (*InterfaceInfo, error) { oldName = strings.TrimSpace(oldName) newName = strings.TrimSpace(newName) if oldName == "" || newName == "" { return nil, fmt.Errorf("interface name cannot be empty") } if oldName == newName { return m.GetInterface(newName) } if oldName == "lo" || newName == "lo" { return nil, fmt.Errorf("cannot rename the loopback interface") } if len(newName) > 15 { return nil, fmt.Errorf("interface name %q exceeds Linux limit of 15 characters", newName) } if !ifaceNameRegex.MatchString(newName) { return nil, fmt.Errorf("invalid interface name %q: must contain only letters, numbers, underscores, dots, or hyphens", newName) } link, err := netlink.LinkByName(oldName) if err != nil { return nil, fmt.Errorf("interface %q not found: %w", oldName, err) } if _, err := netlink.LinkByName(newName); err == nil { return nil, fmt.Errorf("interface %q already exists", newName) } wasUp := (link.Attrs().Flags & net.FlagUp) != 0 // Linux kernel requires interface to be DOWN to change dev name if wasUp { if err := netlink.LinkSetDown(link); err != nil { return nil, fmt.Errorf("failed to bring interface %q down for renaming: %w", oldName, err) } } if err := netlink.LinkSetName(link, newName); err != nil { if wasUp { _ = netlink.LinkSetUp(link) } return nil, fmt.Errorf("failed to rename interface %q to %q: %w", oldName, newName, err) } // Restore UP state if it was originally UP if wasUp { if newLink, err := netlink.LinkByName(newName); err == nil { _ = netlink.LinkSetUp(newLink) } } // Migrate metadata _ = m.metadata.Rename(oldName, newName) // Update traffic rate history m.mu.Lock() if prev, ok := m.prevStats[oldName]; ok { delete(m.prevStats, oldName) m.prevStats[newName] = prev } m.mu.Unlock() return m.GetInterface(newName) } // AddInterfaceIP adds an IP address with subnet CIDR to an interface and persists it. func (m *Manager) AddInterfaceIP(name string, cidr string) error { link, err := netlink.LinkByName(name) if err != nil { return fmt.Errorf("link not found: %w", err) } addr, err := netlink.ParseAddr(cidr) if err != nil { return fmt.Errorf("invalid IP/CIDR %q: %w", cidr, err) } if err := netlink.AddrAdd(link, addr); err != nil && !strings.Contains(err.Error(), "file exists") { return err } if m.metadata != nil { _ = m.metadata.AddIP(name, cidr) } return nil } // DeleteInterfaceIP removes an IP address from an interface and updates persistence. func (m *Manager) DeleteInterfaceIP(name string, cidr string) error { link, err := netlink.LinkByName(name) if err != nil { return fmt.Errorf("link not found: %w", err) } addr, err := netlink.ParseAddr(cidr) if err != nil { return fmt.Errorf("invalid IP/CIDR %q: %w", cidr, err) } if err := netlink.AddrDel(link, addr); err != nil && !strings.Contains(err.Error(), "cannot assign requested address") { return err } if m.metadata != nil { _ = m.metadata.RemoveIP(name, cidr) } return nil } // CreateVLAN creates a new 802.1Q VLAN sub-interface. func (m *Manager) CreateVLAN(req CreateVLANRequest) (*InterfaceInfo, error) { if req.VlanID < 1 || req.VlanID > 4094 { return nil, fmt.Errorf("VLAN ID must be between 1 and 4094, got %d", req.VlanID) } parent, err := netlink.LinkByName(req.ParentName) if err != nil { return nil, fmt.Errorf("parent interface %q not found: %w", req.ParentName, err) } // Validate parent interface supports 802.1Q VLAN sub-interfaces parentType := strings.ToLower(parent.Type()) parentName := strings.ToLower(parent.Attrs().Name) if parentType == "wireguard" || strings.HasPrefix(parentName, "wg") || parentType == "loopback" || parentName == "lo" || parentType == "ppp" || strings.HasPrefix(parentName, "ppp") || parentType == "dummy" || parentType == "tun" || parentType == "tap" || parentType == "gre" || parent.Attrs().Flags&net.FlagLoopback != 0 || parent.Attrs().Flags&net.FlagPointToPoint != 0 { return nil, fmt.Errorf("interface %q does not support 802.1Q VLAN sub-interfaces", req.ParentName) } vlanName := req.Name if vlanName == "" { vlanName = fmt.Sprintf("%s.%d", req.ParentName, req.VlanID) } // Check if already exists if _, err := netlink.LinkByName(vlanName); err == nil { return nil, fmt.Errorf("interface %q already exists", vlanName) } mtu := req.MTU if mtu <= 0 { mtu = parent.Attrs().MTU if mtu == 0 { mtu = 1500 } } vlanLink := &netlink.Vlan{ LinkAttrs: netlink.LinkAttrs{ Name: vlanName, ParentIndex: parent.Attrs().Index, MTU: mtu, }, VlanId: req.VlanID, } if err := netlink.LinkAdd(vlanLink); err != nil { return nil, fmt.Errorf("failed to create VLAN link: %w", err) } // Bring UP if req.AutoUp { if err := netlink.LinkSetUp(vlanLink); err != nil { _ = netlink.LinkDel(vlanLink) return nil, fmt.Errorf("failed to set VLAN link UP: %w", err) } } // Assign IP if provided if strings.TrimSpace(req.IPAddress) != "" { addr, err := netlink.ParseAddr(strings.TrimSpace(req.IPAddress)) if err != nil { _ = netlink.LinkDel(vlanLink) return nil, fmt.Errorf("invalid IP address %q: %w", req.IPAddress, err) } if err := netlink.AddrAdd(vlanLink, addr); err != nil { _ = netlink.LinkDel(vlanLink) return nil, fmt.Errorf("failed to assign IP to VLAN: %w", err) } } // Store metadata role := req.Role if role == "" { role = "VLAN" } _ = m.metadata.Set(vlanName, role, req.Description) return m.GetInterface(vlanName) } // DeleteVLAN removes a VLAN interface. func (m *Manager) DeleteVLAN(name string) error { link, err := netlink.LinkByName(name) if err != nil { return fmt.Errorf("interface %s not found: %w", name, err) } if _, ok := link.(*netlink.Vlan); !ok && !strings.Contains(name, ".") { return fmt.Errorf("interface %s is not recognized as a VLAN interface", name) } if err := netlink.LinkDel(link); err != nil { return fmt.Errorf("failed to delete link: %w", err) } _ = m.metadata.Delete(name) return nil } // ListVLANs returns only the VLAN interfaces. func (m *Manager) ListVLANs() ([]InterfaceInfo, error) { all, err := m.ListInterfaces() if err != nil { return nil, err } vlans := make([]InterfaceInfo, 0) for _, iface := range all { if iface.IsVLAN { vlans = append(vlans, iface) } } return vlans, nil } func formatBytes(b float64) string { const unit = 1024 if b < unit { return fmt.Sprintf("%.0f B", b) } div, exp := int64(unit), 0 for n := b / unit; n >= unit; n /= unit { div *= unit exp++ } return fmt.Sprintf("%.1f %cB", b/float64(div), "KMGTPE"[exp]) } func readProcNetDev() (map[string]rawNetDevStat, error) { data, err := os.ReadFile("/proc/net/dev") if err != nil { return nil, err } lines := strings.Split(string(data), "\n") result := make(map[string]rawNetDevStat) now := time.Now() for _, line := range lines { colonIdx := strings.Index(line, ":") if colonIdx == -1 { continue } ifaceName := strings.TrimSpace(line[:colonIdx]) fields := strings.Fields(line[colonIdx+1:]) if len(fields) < 16 { continue } rxBytes, _ := strconv.ParseUint(fields[0], 10, 64) rxPackets, _ := strconv.ParseUint(fields[1], 10, 64) rxErrors, _ := strconv.ParseUint(fields[2], 10, 64) rxDropped, _ := strconv.ParseUint(fields[3], 10, 64) txBytes, _ := strconv.ParseUint(fields[8], 10, 64) txPackets, _ := strconv.ParseUint(fields[9], 10, 64) txErrors, _ := strconv.ParseUint(fields[10], 10, 64) txDropped, _ := strconv.ParseUint(fields[11], 10, 64) result[ifaceName] = rawNetDevStat{ rxBytes: rxBytes, rxPackets: rxPackets, rxErrors: rxErrors, rxDropped: rxDropped, txBytes: txBytes, txPackets: txPackets, txErrors: txErrors, txDropped: txDropped, time: now, } } return result, nil } func (m *Manager) startTrafficSampler() { go func() { // Initial sample prev, _ := readProcNetDev() ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() for range ticker.C { curr, err := readProcNetDev() if err != nil { continue } now := time.Now() snap := &TrafficSnapshot{ Timestamp: now.Unix(), Interfaces: make(map[string]InterfaceLiveRate), } var totalRxRate, totalTxRate float64 for name, c := range curr { p, exists := prev[name] var rxRate, txRate float64 if exists { dt := c.time.Sub(p.time).Seconds() if dt > 0.05 && c.rxBytes >= p.rxBytes && c.txBytes >= p.txBytes { rxRate = float64(c.rxBytes-p.rxBytes) / dt txRate = float64(c.txBytes-p.txBytes) / dt } } lr := InterfaceLiveRate{ Name: name, RxBytesPerSec: rxRate, TxBytesPerSec: txRate, RxHuman: formatBytes(rxRate) + "/s", TxHuman: formatBytes(txRate) + "/s", RxBytes: c.rxBytes, TxBytes: c.txBytes, RxPackets: c.rxPackets, TxPackets: c.txPackets, RxErrors: c.rxErrors, TxErrors: c.txErrors, RxDropped: c.rxDropped, TxDropped: c.txDropped, } snap.Interfaces[name] = lr // Exclude loopback from global WAN/LAN totals if name != "lo" && !strings.HasPrefix(name, "dummy") { totalRxRate += rxRate totalTxRate += txRate } } snap.TotalRxRate = totalRxRate snap.TotalTxRate = totalTxRate snap.TotalRxHuman = formatBytes(totalRxRate) + "/s" snap.TotalTxHuman = formatBytes(totalTxRate) + "/s" m.snapMu.Lock() m.latestSnapshot = snap m.snapMu.Unlock() prev = curr // Fan out to SSE subscribers m.subMu.Lock() for ch := range m.subscribers { select { case ch <- snap: default: // drop frame if client is slow } } m.subMu.Unlock() } }() } func (m *Manager) GetLatestTrafficSnapshot() *TrafficSnapshot { m.snapMu.RLock() defer m.snapMu.RUnlock() return m.latestSnapshot } func (m *Manager) SubscribeTraffic() chan *TrafficSnapshot { m.subMu.Lock() defer m.subMu.Unlock() ch := make(chan *TrafficSnapshot, 4) m.subscribers[ch] = true return ch } func (m *Manager) UnsubscribeTraffic(ch chan *TrafficSnapshot) { m.subMu.Lock() defer m.subMu.Unlock() delete(m.subscribers, ch) close(ch) }