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