package bridge import ( "bufio" "encoding/json" "fmt" "net" "os" "os/exec" "path/filepath" "sort" "strconv" "strings" "sync" "time" "github.com/vishvananda/netlink" ) const BridgeConfigFile = "/etc/proxmox-router/bridge.json" type BridgeConfigStore struct { Bridges []*BridgeInstance `json:"bridges"` Ports []*BridgePort `json:"ports"` VLANs []*BridgeVLAN `json:"vlans"` } type BridgeManager struct { mu sync.RWMutex storePath string bridges map[string]*BridgeInstance ports map[string]*BridgePort vlans map[string]*BridgeVLAN } func NewBridgeManager() *BridgeManager { bm := &BridgeManager{ storePath: BridgeConfigFile, bridges: make(map[string]*BridgeInstance), ports: make(map[string]*BridgePort), vlans: make(map[string]*BridgeVLAN), } bm.load() bm.SyncAll() return bm } func (m *BridgeManager) load() { m.mu.Lock() defer m.mu.Unlock() data, err := os.ReadFile(m.storePath) if err != nil { return } var store BridgeConfigStore if err := json.Unmarshal(data, &store); err != nil { return } for _, b := range store.Bridges { m.bridges[b.ID] = b } for _, p := range store.Ports { m.ports[p.ID] = p } for _, v := range store.VLANs { m.vlans[v.ID] = v } } func (m *BridgeManager) save() error { _ = os.MkdirAll(filepath.Dir(m.storePath), 0755) store := BridgeConfigStore{ Bridges: make([]*BridgeInstance, 0, len(m.bridges)), Ports: make([]*BridgePort, 0, len(m.ports)), VLANs: make([]*BridgeVLAN, 0, len(m.vlans)), } for _, b := range m.bridges { store.Bridges = append(store.Bridges, b) } for _, p := range m.ports { store.Ports = append(store.Ports, p) } for _, v := range m.vlans { store.VLANs = append(store.VLANs, v) } data, err := json.MarshalIndent(store, "", " ") if err != nil { return err } return os.WriteFile(m.storePath, data, 0644) } // ==================== Bridge Interfaces ==================== func (m *BridgeManager) ListBridges() []*BridgeInstance { m.mu.Lock() defer m.mu.Unlock() list := make([]*BridgeInstance, 0, len(m.bridges)) for _, b := range m.bridges { m.updateBridgeLiveStats(b) list = append(list, b) } sort.Slice(list, func(i, j int) bool { return list[i].Name < list[j].Name }) return list } func (m *BridgeManager) GetBridge(idOrName string) (*BridgeInstance, bool) { m.mu.Lock() defer m.mu.Unlock() if b, ok := m.bridges[idOrName]; ok { m.updateBridgeLiveStats(b) return b, true } for _, b := range m.bridges { if b.Name == idOrName { m.updateBridgeLiveStats(b) return b, true } } return nil, false } func (m *BridgeManager) SaveBridge(b BridgeInstance) error { m.mu.Lock() defer m.mu.Unlock() if b.Name == "" { return fmt.Errorf("bridge name is required") } if b.MTU <= 0 { b.MTU = 1500 } if b.PVID <= 0 { b.PVID = 1 } if b.STP == "" { b.STP = "rstp" } if b.Priority <= 0 { b.Priority = 32768 } if b.ID == "" { b.ID = fmt.Sprintf("br-%d", time.Now().UnixNano()) } // Update existing or add new m.bridges[b.ID] = &b _ = m.save() // Apply in Linux kernel return m.syncBridge(&b) } func (m *BridgeManager) DeleteBridge(id string) error { m.mu.Lock() defer m.mu.Unlock() b, ok := m.bridges[id] if !ok { return fmt.Errorf("bridge not found") } // Detach any ports belonging to this bridge for _, p := range m.ports { if p.Bridge == b.Name { _ = exec.Command("ip", "link", "set", p.Interface, "nomaster").Run() delete(m.ports, p.ID) } } // Delete any VLAN entries for this bridge for vid, v := range m.vlans { if v.Bridge == b.Name { delete(m.vlans, vid) } } // Delete bridge link in kernel _ = exec.Command("ip", "link", "del", b.Name).Run() delete(m.bridges, id) return m.save() } func (m *BridgeManager) SetBridgeState(id string, disabled bool) error { m.mu.Lock() defer m.mu.Unlock() b, ok := m.bridges[id] if !ok { return fmt.Errorf("bridge not found") } b.Disabled = disabled _ = m.save() if disabled { _ = exec.Command("ip", "link", "set", b.Name, "down").Run() } else { _ = exec.Command("ip", "link", "set", b.Name, "up").Run() } return nil } func (m *BridgeManager) AddBridgeIP(idOrName string, ipCIDR string) error { m.mu.Lock() defer m.mu.Unlock() b, ok := m.bridges[idOrName] if !ok { for _, br := range m.bridges { if br.Name == idOrName { b = br ok = true break } } } if !ok { return fmt.Errorf("bridge not found") } ipCIDR = strings.TrimSpace(ipCIDR) if _, _, err := net.ParseCIDR(ipCIDR); err != nil { return fmt.Errorf("invalid IP/CIDR: %w", err) } // Check if already in list for _, ip := range b.IPAddresses { if ip == ipCIDR { return nil } } b.IPAddresses = append(b.IPAddresses, ipCIDR) _ = m.save() // Add to kernel _ = exec.Command("ip", "addr", "add", ipCIDR, "dev", b.Name).Run() return nil } func (m *BridgeManager) DeleteBridgeIP(idOrName string, ipCIDR string) error { m.mu.Lock() defer m.mu.Unlock() b, ok := m.bridges[idOrName] if !ok { for _, br := range m.bridges { if br.Name == idOrName { b = br ok = true break } } } if !ok { return fmt.Errorf("bridge not found") } ipCIDR = strings.TrimSpace(ipCIDR) var updated []string for _, ip := range b.IPAddresses { if ip != ipCIDR { updated = append(updated, ip) } } b.IPAddresses = updated _ = m.save() _ = exec.Command("ip", "addr", "del", ipCIDR, "dev", b.Name).Run() return nil } func (m *BridgeManager) syncBridge(b *BridgeInstance) error { link, err := netlink.LinkByName(b.Name) if err != nil { // Create bridge args := []string{"link", "add", b.Name, "type", "bridge"} if b.VlanFiltering { args = append(args, "vlan_filtering", "1") } else { args = append(args, "vlan_filtering", "0") } if b.PVID > 0 { args = append(args, "vlan_default_pvid", strconv.Itoa(b.PVID)) } if out, err := exec.Command("ip", args...).CombinedOutput(); err != nil { return fmt.Errorf("failed to create bridge %s: %s: %w", b.Name, string(out), err) } link, _ = netlink.LinkByName(b.Name) } if link != nil { // Set MTU if b.MTU > 0 { _ = netlink.LinkSetMTU(link, b.MTU) } // Set Admin MAC if specified if b.MACAddress != "" { if hw, err := net.ParseMAC(b.MACAddress); err == nil { _ = netlink.LinkSetHardwareAddr(link, hw) } } // Set VLAN filtering on existing bridge vfVal := "0" if b.VlanFiltering { vfVal = "1" } pvidVal := strconv.Itoa(b.PVID) if b.PVID <= 0 { pvidVal = "1" } _ = exec.Command("ip", "link", "set", b.Name, "type", "bridge", "vlan_filtering", vfVal, "vlan_default_pvid", pvidVal).Run() // Set STP stpVal := "0" if b.STP == "rstp" || b.STP == "stp" { stpVal = "1" } _ = os.WriteFile(fmt.Sprintf("/sys/class/net/%s/bridge/stp_state", b.Name), []byte(stpVal), 0644) // Set link up or down if b.Disabled { _ = netlink.LinkSetDown(link) } else { _ = netlink.LinkSetUp(link) } // Sync IP addresses for _, ip := range b.IPAddresses { _ = exec.Command("ip", "addr", "add", ip, "dev", b.Name).Run() } } return nil } func (m *BridgeManager) updateBridgeLiveStats(b *BridgeInstance) { // Count ports belonging to this bridge portsCount := 0 for _, p := range m.ports { if p.Bridge == b.Name && !p.Disabled { portsCount++ } } b.PortsCount = portsCount link, err := netlink.LinkByName(b.Name) if err != nil { b.Running = false return } attrs := link.Attrs() b.Running = (attrs.Flags&net.FlagUp != 0) && (attrs.OperState == netlink.OperUp || attrs.OperState == netlink.OperUnknown) if b.MACAddress == "" { b.MACAddress = attrs.HardwareAddr.String() } if attrs.Statistics != nil { b.RxPackets = attrs.Statistics.RxPackets b.TxPackets = attrs.Statistics.TxPackets b.RxBytes = attrs.Statistics.RxBytes b.TxBytes = attrs.Statistics.TxBytes b.RxHuman = formatBytes(b.RxBytes) b.TxHuman = formatBytes(b.TxBytes) } // Read IP addresses from kernel if addrs, err := netlink.AddrList(link, netlink.FAMILY_V4); err == nil { var ips []string for _, a := range addrs { ips = append(ips, a.IPNet.String()) } b.IPAddresses = ips } } // ==================== Bridge Ports ==================== func (m *BridgeManager) ListPorts() []*BridgePort { m.mu.Lock() defer m.mu.Unlock() list := make([]*BridgePort, 0, len(m.ports)) for _, p := range m.ports { m.updatePortLiveStatus(p) list = append(list, p) } sort.Slice(list, func(i, j int) bool { if list[i].Bridge != list[j].Bridge { return list[i].Bridge < list[j].Bridge } return list[i].Interface < list[j].Interface }) return list } func (m *BridgeManager) SavePort(p BridgePort) error { m.mu.Lock() defer m.mu.Unlock() if p.Bridge == "" || p.Interface == "" { return fmt.Errorf("bridge and interface are required") } if p.PVID <= 0 { p.PVID = 1 } if p.Priority <= 0 { p.Priority = 128 } if p.PathCost <= 0 { p.PathCost = 10 } if p.FrameTypes == "" { p.FrameTypes = "admit-all" } if p.ID == "" { p.ID = fmt.Sprintf("port-%d", time.Now().UnixNano()) } m.ports[p.ID] = &p _ = m.save() return m.syncPort(&p) } func (m *BridgeManager) DeletePort(id string) error { m.mu.Lock() defer m.mu.Unlock() p, ok := m.ports[id] if !ok { return fmt.Errorf("port not found") } // Remove from bridge in kernel _ = exec.Command("ip", "link", "set", p.Interface, "nomaster").Run() delete(m.ports, id) return m.save() } func (m *BridgeManager) SetPortState(id string, disabled bool) error { m.mu.Lock() defer m.mu.Unlock() p, ok := m.ports[id] if !ok { return fmt.Errorf("port not found") } p.Disabled = disabled _ = m.save() if disabled { _ = exec.Command("ip", "link", "set", p.Interface, "nomaster").Run() } else { _ = m.syncPort(p) } return nil } func (m *BridgeManager) syncPort(p *BridgePort) error { if p.Disabled { _ = exec.Command("ip", "link", "set", p.Interface, "nomaster").Run() return nil } // Check if interface exists if _, err := netlink.LinkByName(p.Interface); err != nil { return fmt.Errorf("interface %s does not exist", p.Interface) } // Attach interface to bridge if out, err := exec.Command("ip", "link", "set", p.Interface, "master", p.Bridge).CombinedOutput(); err != nil { return fmt.Errorf("failed to attach %s to bridge %s: %s: %w", p.Interface, p.Bridge, string(out), err) } // Bring interface UP _ = exec.Command("ip", "link", "set", p.Interface, "up").Run() // Port isolation / Split Horizon isolated := "off" if p.Horizon > 0 { isolated = "on" } _ = exec.Command("bridge", "link", "set", "dev", p.Interface, "isolated", isolated).Run() // Learning learning := "on" if !p.Learn { learning = "off" } _ = exec.Command("bridge", "link", "set", "dev", p.Interface, "learning", learning).Run() // Ingress filtering ingress := "off" if p.IngressFiltering { ingress = "on" } _ = exec.Command("bridge", "link", "set", "dev", p.Interface, "ingress_filtering", ingress).Run() // Priority & Path Cost prioCostArgs := []string{"link", "set", "dev", p.Interface} if p.Priority > 0 { prioCostArgs = append(prioCostArgs, "priority", strconv.Itoa(p.Priority)) } if p.PathCost > 0 { prioCostArgs = append(prioCostArgs, "cost", strconv.Itoa(p.PathCost)) } if len(prioCostArgs) > 4 { _ = exec.Command("bridge", prioCostArgs...).Run() } // VLAN filtering configuration if p.PVID > 0 { _ = exec.Command("bridge", "vlan", "add", "dev", p.Interface, "vid", strconv.Itoa(p.PVID), "pvid", "untagged").Run() } return nil } func (m *BridgeManager) updatePortLiveStatus(p *BridgePort) { if p.Disabled { p.Status = "disabled" return } link, err := netlink.LinkByName(p.Interface) if err != nil { p.Status = "inactive" return } attrs := link.Attrs() if attrs.OperState == netlink.OperDown { p.Status = "disabled" } else if attrs.MasterIndex > 0 { p.Status = "forwarding" } else { p.Status = "unattached" } } // ==================== Bridge VLANs ==================== func (m *BridgeManager) ListVLANs() []*BridgeVLAN { m.mu.Lock() defer m.mu.Unlock() list := make([]*BridgeVLAN, 0, len(m.vlans)) for _, v := range m.vlans { v.CurrentTagged = v.Tagged v.CurrentUntagged = v.Untagged list = append(list, v) } sort.Slice(list, func(i, j int) bool { if list[i].Bridge != list[j].Bridge { return list[i].Bridge < list[j].Bridge } return list[i].VlanIDs < list[j].VlanIDs }) return list } func (m *BridgeManager) SaveVLAN(v BridgeVLAN) error { m.mu.Lock() defer m.mu.Unlock() if v.Bridge == "" || v.VlanIDs == "" { return fmt.Errorf("bridge and vlan_ids are required") } if v.ID == "" { v.ID = fmt.Sprintf("bvlan-%d", time.Now().UnixNano()) } m.vlans[v.ID] = &v _ = m.save() return m.syncVLAN(&v) } func (m *BridgeManager) DeleteVLAN(id string) error { m.mu.Lock() defer m.mu.Unlock() v, ok := m.vlans[id] if !ok { return fmt.Errorf("vlan entry not found") } // Remove VLANs from kernel vids := parseVLANRange(v.VlanIDs) for _, vid := range vids { vidStr := strconv.Itoa(vid) for _, iface := range append(v.Tagged, v.Untagged...) { _ = exec.Command("bridge", "vlan", "del", "dev", iface, "vid", vidStr).Run() } } delete(m.vlans, id) return m.save() } func (m *BridgeManager) SetVLANState(id string, disabled bool) error { m.mu.Lock() defer m.mu.Unlock() v, ok := m.vlans[id] if !ok { return fmt.Errorf("vlan entry not found") } v.Disabled = disabled _ = m.save() if disabled { vids := parseVLANRange(v.VlanIDs) for _, vid := range vids { vidStr := strconv.Itoa(vid) for _, iface := range append(v.Tagged, v.Untagged...) { _ = exec.Command("bridge", "vlan", "del", "dev", iface, "vid", vidStr).Run() } } } else { _ = m.syncVLAN(v) } return nil } func (m *BridgeManager) syncVLAN(v *BridgeVLAN) error { if v.Disabled { return nil } vids := parseVLANRange(v.VlanIDs) for _, vid := range vids { vidStr := strconv.Itoa(vid) for _, iface := range v.Tagged { _ = exec.Command("bridge", "vlan", "add", "dev", iface, "vid", vidStr).Run() } for _, iface := range v.Untagged { _ = exec.Command("bridge", "vlan", "add", "dev", iface, "vid", vidStr, "untagged").Run() } } return nil } // ==================== Bridge Hosts (FDB / MAC Table) ==================== func (m *BridgeManager) ListHosts(bridgeFilter string) ([]BridgeHost, error) { args := []string{"fdb", "show"} if bridgeFilter != "" { args = append(args, "br", bridgeFilter) } out, err := exec.Command("bridge", args...).Output() if err != nil { return nil, fmt.Errorf("failed to run bridge fdb show: %w", err) } var hosts []BridgeHost scanner := bufio.NewScanner(strings.NewReader(string(out))) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" { continue } fields := strings.Fields(line) if len(fields) < 3 { continue } mac := fields[0] dev := "" vlan := 1 master := "" age := 0.0 isLocal := false isStatic := false for i := 1; i < len(fields); i++ { switch fields[i] { case "dev": if i+1 < len(fields) { dev = fields[i+1] i++ } case "vlan": if i+1 < len(fields) { vlan, _ = strconv.Atoi(fields[i+1]) i++ } case "master": if i+1 < len(fields) { master = fields[i+1] i++ } case "self": isLocal = true case "permanent": isLocal = true isStatic = true case "static": isStatic = true default: if val, err := strconv.ParseFloat(fields[i], 64); err == nil { age = val } } } if master == "" && strings.HasPrefix(dev, "br") { master = dev } var flags []string if isLocal { flags = append(flags, "L") } else if isStatic { flags = append(flags, "S") } else { flags = append(flags, "D") } hosts = append(hosts, BridgeHost{ MAC: mac, Interface: dev, Bridge: master, VlanID: vlan, Age: age, Local: isLocal, Static: isStatic, Flags: strings.Join(flags, ","), }) } return hosts, nil } func (m *BridgeManager) FlushHosts(bridge string) error { if bridge == "" { return fmt.Errorf("bridge name required for flush") } _ = exec.Command("bridge", "fdb", "flush", "dev", bridge, "dynamic").Run() return nil } // ==================== Summary & SyncAll ==================== func (m *BridgeManager) GetSummary() BridgeSummary { m.mu.RLock() bCount := len(m.bridges) pCount := len(m.ports) vCount := len(m.vlans) m.mu.RUnlock() hCount := 0 if hosts, err := m.ListHosts(""); err == nil { hCount = len(hosts) } return BridgeSummary{ BridgesCount: bCount, PortsCount: pCount, VLANsCount: vCount, HostsCount: hCount, } } func (m *BridgeManager) SyncAll() { m.mu.RLock() defer m.mu.RUnlock() // 1. Sync all bridges for _, b := range m.bridges { _ = m.syncBridge(b) } // 2. Sync all ports for _, p := range m.ports { _ = m.syncPort(p) } // 3. Sync all VLAN entries for _, v := range m.vlans { _ = m.syncVLAN(v) } } // RenameInterface updates bridge, port, and VLAN configurations when an interface is renamed. func (m *BridgeManager) RenameInterface(oldName, newName string) { m.mu.Lock() defer m.mu.Unlock() changed := false for _, b := range m.bridges { if b.Name == oldName { b.Name = newName changed = true } } for _, p := range m.ports { if p.Interface == oldName { p.Interface = newName changed = true } } for _, v := range m.vlans { if v.Bridge == oldName { v.Bridge = newName changed = true } for i, tagged := range v.Tagged { if tagged == oldName { v.Tagged[i] = newName changed = true } } for i, untagged := range v.Untagged { if untagged == oldName { v.Untagged[i] = newName changed = true } } } if changed { _ = m.save() } } // Helper: parse "10" or "10,20,30" or "10-20" into int slice func parseVLANRange(s string) []int { var result []int parts := strings.Split(s, ",") for _, p := range parts { p = strings.TrimSpace(p) if strings.Contains(p, "-") { rangeParts := strings.Split(p, "-") if len(rangeParts) == 2 { start, err1 := strconv.Atoi(strings.TrimSpace(rangeParts[0])) end, err2 := strconv.Atoi(strings.TrimSpace(rangeParts[1])) if err1 == nil && err2 == nil && start <= end { for i := start; i <= end; i++ { result = append(result, i) } continue } } } if val, err := strconv.Atoi(p); err == nil { result = append(result, val) } } return result } func formatBytes(b uint64) string { const unit = 1024 if b < unit { return fmt.Sprintf("%d B", b) } div, exp := uint64(unit), 0 for n := b / unit; n >= unit; n /= unit { div *= unit exp++ } return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp]) }