package dynamicrouting import ( "encoding/json" "fmt" "log" "os" "os/exec" "path/filepath" "regexp" "sort" "strconv" "strings" "sync" "time" ) const ( defaultConfigPath = "/etc/proxmox-router/routing/dynamic.json" birdConfigPath = "/etc/bird/bird.conf" ) type Manager struct { mu sync.RWMutex configPath string config DynamicConfig } func NewManager(configPath string) *Manager { if configPath == "" { configPath = defaultConfigPath } m := &Manager{ configPath: configPath, } if err := m.load(); err != nil { log.Printf("[DynamicRouting] Config load warning: %v, initializing defaults", err) m.initDefaults() _ = m.save() } // Apply initial configuration to BIRD 2 if err := m.applyBirdConfig(); err != nil { log.Printf("[DynamicRouting] Initial BIRD apply warning: %v", err) } return m } func (m *Manager) initDefaults() { m.config = DynamicConfig{ BGPInstance: BGPInstance{ Enabled: false, AS: 65001, RouterID: "192.168.131.56", HoldTime: 180, KeepaliveTime: 60, RedistributeConnected: false, RedistributeStatic: false, RedistributeOSPF: false, }, BGPPeers: []BGPPeer{}, BGPNetworks: []BGPNetwork{}, OSPFInstances: []OSPFInstance{ { ID: "ospf-inst-default", Name: "Default", Version: 2, VRF: "main", RouterID: "192.168.131.56", RoutingTable: "main", Enabled: true, }, }, OSPFInstance: OSPFInstance{ Name: "Default", Version: 2, VRF: "main", Enabled: true, RouterID: "192.168.131.56", RedistributeConnected: false, RedistributeStatic: true, RedistributeBGP: false, DefaultMetric: 10, }, OSPFAreas: []OSPFArea{ { ID: "area-default", Name: "Default", Instance: "Default", AreaID: "0.0.0.0", AreaType: "default", }, }, OSPFAreaRanges: []OSPFAreaRange{}, OSPFInterfaces: []OSPFInterface{}, OSPFStaticNbrs: []OSPFStaticNeighbor{}, RoutingFilters: []RoutingFilterRule{}, RoutingSelectRules: []RoutingFilterSelectRule{}, RoutingCommunityLists: []RoutingFilterCommunityList{}, RoutingASLists: []RoutingFilterASList{}, } } func (m *Manager) load() error { m.mu.Lock() defer m.mu.Unlock() data, err := os.ReadFile(m.configPath) if err != nil { return err } if err := json.Unmarshal(data, &m.config); err != nil { return err } if m.config.RoutingSelectRules == nil { m.config.RoutingSelectRules = []RoutingFilterSelectRule{} } if m.config.RoutingCommunityLists == nil { m.config.RoutingCommunityLists = []RoutingFilterCommunityList{} } if m.config.RoutingASLists == nil { m.config.RoutingASLists = []RoutingFilterASList{} } if len(m.config.OSPFInstances) == 0 { rID := m.config.OSPFInstance.RouterID if rID == "" { rID = "192.168.131.56" } m.config.OSPFInstances = []OSPFInstance{ { ID: "ospf-inst-default", Name: "Default", Version: 2, VRF: "main", RouterID: rID, RoutingTable: "main", Enabled: true, }, } } if len(m.config.OSPFAreas) == 0 { m.config.OSPFAreas = []OSPFArea{ { ID: "area-default", Name: "Default", Instance: "Default", AreaID: "0.0.0.0", AreaType: "default", }, } } return nil } func (m *Manager) save() error { dir := filepath.Dir(m.configPath) if err := os.MkdirAll(dir, 0755); err != nil { return err } data, err := json.MarshalIndent(m.config, "", " ") if err != nil { return err } return os.WriteFile(m.configPath, data, 0644) } // sanitizeIdentifier creates a valid BIRD protocol/function identifier: [a-zA-Z][a-zA-Z0-9_]* func sanitizeIdentifier(prefix, raw string) string { reg := regexp.MustCompile("[^a-zA-Z0-9_]+") cleaned := reg.ReplaceAllString(raw, "_") cleaned = strings.Trim(cleaned, "_") if cleaned == "" { cleaned = "inst" } res := cleaned if prefix != "" { res = prefix + "_" + cleaned } if len(res) > 32 { res = res[:32] } return res } // applyBirdConfig generates /etc/bird/bird.conf and live reloads BIRD daemon func (m *Manager) applyBirdConfig() error { var sb strings.Builder routerID := "192.168.131.56" if m.config.BGPInstance.RouterID != "" { routerID = m.config.BGPInstance.RouterID } else if m.config.OSPFInstance.RouterID != "" { routerID = m.config.OSPFInstance.RouterID } sb.WriteString("# /etc/bird/bird.conf\n") sb.WriteString("# Generated dynamically by Proxmox Router (BIRD 2 Engine)\n\n") sb.WriteString("log syslog all;\n") sb.WriteString(fmt.Sprintf("router id %s;\n\n", routerID)) // Base system protocols sb.WriteString("protocol device {\n") sb.WriteString(" scan time 10;\n") sb.WriteString("}\n\n") sb.WriteString("protocol direct {\n") sb.WriteString(" ipv4;\n") sb.WriteString("}\n\n") sb.WriteString("protocol kernel {\n") sb.WriteString(" metric 0;\n") sb.WriteString(" ipv4 {\n") sb.WriteString(" export filter {\n") sb.WriteString(" if source = RTS_DEVICE then reject;\n") sb.WriteString(" if source = RTS_BGP then {\n") sb.WriteString(" if bgp_path.first = bgp_path.last && bgp_path.len = 0 then\n") sb.WriteString(" krt_metric = 200;\n") sb.WriteString(" else\n") sb.WriteString(" krt_metric = 20;\n") sb.WriteString(" }\n") sb.WriteString(" if source = RTS_OSPF then {\n") sb.WriteString(" krt_metric = 110;\n") sb.WriteString(" }\n") sb.WriteString(" if source = RTS_RIP then {\n") sb.WriteString(" krt_metric = 120;\n") sb.WriteString(" }\n") sb.WriteString(" accept;\n") sb.WriteString(" };\n") sb.WriteString(" };\n") sb.WriteString(" learn;\n") sb.WriteString("}\n\n") // Static BGP Networks announcements var activeNets []string if m.config.BGPInstance.Enabled { for _, net := range m.config.BGPNetworks { if !net.Disabled && strings.TrimSpace(net.Network) != "" { activeNets = append(activeNets, strings.TrimSpace(net.Network)) } } } if len(activeNets) > 0 { sb.WriteString("protocol static bgp_static_nets {\n") sb.WriteString(" ipv4;\n") for _, n := range activeNets { sb.WriteString(fmt.Sprintf(" route %s blackhole;\n", n)) } sb.WriteString("}\n\n") } // Community Lists (/routing filter community-list) for _, cl := range m.config.RoutingCommunityLists { if cl.Disabled || strings.TrimSpace(cl.Name) == "" || len(cl.Communities) == 0 { continue } var items []string for _, c := range cl.Communities { c = strings.TrimSpace(c) if strings.Contains(c, ":") { parts := strings.Split(c, ":") items = append(items, fmt.Sprintf("(%s, %s)", strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]))) } } if len(items) > 0 { sb.WriteString(fmt.Sprintf("define comm_%s = [ %s ];\n", sanitizeIdentifier("", cl.Name), strings.Join(items, ", "))) } } // AS Lists (/routing filter num-list) for _, al := range m.config.RoutingASLists { if al.Disabled || strings.TrimSpace(al.Name) == "" || strings.TrimSpace(al.Range) == "" { continue } rng := strings.TrimSpace(al.Range) rng = regexp.MustCompile(`(\d+)\s*-\s*(\d+)`).ReplaceAllString(rng, "$1..$2") sb.WriteString(fmt.Sprintf("define aslist_%s = [ %s ];\n", sanitizeIdentifier("", al.Name), rng)) } sb.WriteString("\n") // Routing Filters (/routing filter) chainRules := make(map[string][]RoutingFilterRule) for _, rule := range m.config.RoutingFilters { if rule.Disabled || strings.TrimSpace(rule.Chain) == "" { continue } chain := strings.TrimSpace(rule.Chain) chainRules[chain] = append(chainRules[chain], rule) } for chain, rules := range chainRules { sort.Slice(rules, func(i, j int) bool { return rules[i].RuleOrder < rules[j].RuleOrder }) filterName := sanitizeIdentifier("filter", chain) sb.WriteString(fmt.Sprintf("filter %s {\n", filterName)) for _, r := range rules { rendered := translateRos7RuleToBird(&r) if rendered != "" { sb.WriteString(fmt.Sprintf(" # rule %d\n", r.RuleOrder)) lines := strings.Split(rendered, "\n") for _, l := range lines { if strings.TrimSpace(l) != "" { sb.WriteString(" " + l + "\n") } } } } // RouterOS v7 default chain policy: reject unmatched routes sb.WriteString(" reject;\n") sb.WriteString("}\n\n") } // BGP Protocols (True Multi-Instance / Multi-AS support!) if m.config.BGPInstance.Enabled { for i, peer := range m.config.BGPPeers { if peer.RemoteAddress == "" || peer.RemoteAS == 0 { continue } // Local AS determination: peer.LocalAS if set, otherwise global AS localAS := m.config.BGPInstance.AS if peer.LocalAS > 0 { localAS = peer.LocalAS } protoName := sanitizeIdentifier("bgp", fmt.Sprintf("%s_%d", peer.ID, i)) // Per-peer Output Networks (RouterOS v7 output.network) var peerNets []string for _, on := range peer.OutputNetworks { on = strings.TrimSpace(on) if on != "" { peerNets = append(peerNets, on) } } if peer.DefaultOriginate { peerNets = append(peerNets, "0.0.0.0/0") } peerNetsProto := "" if len(peerNets) > 0 { peerNetsProto = fmt.Sprintf("bgp_nets_%s", protoName) sb.WriteString(fmt.Sprintf("protocol static %s {\n", peerNetsProto)) sb.WriteString(" ipv4;\n") for _, pn := range peerNets { sb.WriteString(fmt.Sprintf(" route %s blackhole;\n", pn)) } sb.WriteString("}\n\n") } sb.WriteString(fmt.Sprintf("protocol bgp %s {\n", protoName)) if peer.Name != "" { sb.WriteString(fmt.Sprintf(" description \"%s\";\n", peer.Name)) } if peer.Disabled { sb.WriteString(" disabled;\n") } if peer.LocalAddress != "" { if peer.LocalPort > 0 { sb.WriteString(fmt.Sprintf(" local %s port %d as %d;\n", peer.LocalAddress, peer.LocalPort, localAS)) } else { sb.WriteString(fmt.Sprintf(" local %s as %d;\n", peer.LocalAddress, localAS)) } } else { if peer.LocalPort > 0 { sb.WriteString(fmt.Sprintf(" local port %d as %d;\n", peer.LocalPort, localAS)) } else { sb.WriteString(fmt.Sprintf(" local as %d;\n", localAS)) } } if peer.RemotePort > 0 { sb.WriteString(fmt.Sprintf(" neighbor %s port %d as %d;\n", peer.RemoteAddress, peer.RemotePort, peer.RemoteAS)) } else { sb.WriteString(fmt.Sprintf(" neighbor %s as %d;\n", peer.RemoteAddress, peer.RemoteAS)) } if peer.Multihop { sb.WriteString(" multihop;\n") } if peer.Passive || (!peer.Connect && peer.Listen) { sb.WriteString(" passive;\n") } if peer.Password != "" { sb.WriteString(fmt.Sprintf(" password \"%s\";\n", peer.Password)) } holdTime := 180 if peer.HoldTime > 0 { holdTime = peer.HoldTime } else if m.config.BGPInstance.HoldTime > 0 { holdTime = m.config.BGPInstance.HoldTime } sb.WriteString(fmt.Sprintf(" hold time %d;\n", holdTime)) if peer.KeepaliveTime > 0 { sb.WriteString(fmt.Sprintf(" keepalive time %d;\n", peer.KeepaliveTime)) } // IPv4 Channel sb.WriteString(" ipv4 {\n") if peer.NexthopSelf || peer.NexthopChoice == "force-self" { sb.WriteString(" next hop self;\n") } // Import Filter defaultAD := 20 if peer.LocalRole == "ibgp" || (localAS > 0 && peer.RemoteAS > 0 && localAS == peer.RemoteAS) { defaultAD = 200 } if peer.InFilter != "" && len(chainRules[peer.InFilter]) > 0 { sb.WriteString(fmt.Sprintf(" import filter %s;\n", sanitizeIdentifier("filter", peer.InFilter))) } else { sb.WriteString(fmt.Sprintf(" import filter { krt_metric = %d; accept; };\n", defaultAD)) } // Export Filter if peer.OutFilter != "" && len(chainRules[peer.OutFilter]) > 0 { sb.WriteString(fmt.Sprintf(" export filter %s;\n", sanitizeIdentifier("filter", peer.OutFilter))) } else { var exportSources []string if peerNetsProto != "" { exportSources = append(exportSources, fmt.Sprintf(`proto = "%s"`, peerNetsProto)) } if len(activeNets) > 0 { exportSources = append(exportSources, `proto = "bgp_static_nets"`) } exportSources = append(exportSources, `source = RTS_BGP`) if peer.RedistConnected || m.config.BGPInstance.RedistributeConnected { exportSources = append(exportSources, `source = RTS_DEVICE`) } if peer.RedistStatic || m.config.BGPInstance.RedistributeStatic { exportSources = append(exportSources, `source = RTS_STATIC`) } if peer.RedistOSPF || m.config.BGPInstance.RedistributeOSPF { exportSources = append(exportSources, `source = RTS_OSPF`) } if len(exportSources) > 0 { sb.WriteString(fmt.Sprintf(" export where (%s);\n", strings.Join(exportSources, ") || ("))) } else { sb.WriteString(" export none;\n") } } sb.WriteString(" };\n") sb.WriteString("}\n\n") } } // OSPFv2 Protocols (RouterOS v7 Multi-Instance OSPF) ospfInstances := m.config.OSPFInstances if len(ospfInstances) == 0 { ospfInstances = []OSPFInstance{ { ID: "ospf-inst-default", Name: "Default", Version: 2, VRF: "main", RouterID: routerID, RoutingTable: "main", Enabled: true, }, } } for _, inst := range ospfInstances { if inst.Disabled { continue } instRouterID := inst.RouterID if instRouterID == "" { instRouterID = routerID } instProtoName := sanitizeIdentifier("ospf", inst.Name) sb.WriteString(fmt.Sprintf("protocol ospf v%d %s {\n", inst.Version, instProtoName)) sb.WriteString(fmt.Sprintf(" router id %s;\n", instRouterID)) sb.WriteString(" ipv4 {\n") sb.WriteString(" import filter { krt_metric = 110; accept; };\n") var ospfExports []string ospfExports = append(ospfExports, "RTS_STATIC") if inst.RedistributeConnected || m.config.OSPFInstance.RedistributeConnected { ospfExports = append(ospfExports, "RTS_DEVICE") } if inst.RedistributeBGP || m.config.OSPFInstance.RedistributeBGP { ospfExports = append(ospfExports, "RTS_BGP") } if len(ospfExports) > 0 { sb.WriteString(fmt.Sprintf(" export where source ~ [ %s ];\n", strings.Join(ospfExports, ", "))) } else { sb.WriteString(" export none;\n") } sb.WriteString(" };\n\n") // Areas for this instance areas := m.config.OSPFAreas if len(areas) == 0 { areas = []OSPFArea{ { Name: "Default", Instance: inst.Name, AreaID: "0.0.0.0", AreaType: "default", }, } } for _, area := range areas { if area.Disabled { continue } if area.Instance != "" && !strings.EqualFold(area.Instance, inst.Name) && !strings.EqualFold(area.Instance, "Default") { continue } areaID := strings.TrimSpace(area.AreaID) if areaID == "" { areaID = "0.0.0.0" } sb.WriteString(fmt.Sprintf(" area %s {\n", areaID)) if area.AreaType == "stub" { sb.WriteString(" stub;\n") } else if area.AreaType == "nssa" { sb.WriteString(" nssa;\n") } // Area ranges (stubnet) for _, ar := range m.config.OSPFAreaRanges { if ar.Disabled { continue } if strings.EqualFold(ar.Area, area.Name) || strings.EqualFold(ar.Area, area.AreaID) || ar.Area == "" { prefix := strings.TrimSpace(ar.Prefix) if prefix != "" { if ar.Cost > 0 { sb.WriteString(fmt.Sprintf(" stubnet %s { cost %d; };\n", prefix, ar.Cost)) } else { sb.WriteString(fmt.Sprintf(" stubnet %s;\n", prefix)) } } } } // Interface templates matching this area for _, iface := range m.config.OSPFInterfaces { if iface.Disabled { continue } if iface.Area != "" && !strings.EqualFold(iface.Area, area.Name) && !strings.EqualFold(iface.Area, area.AreaID) && iface.Area != "Default" { continue } ifaceName := strings.TrimSpace(iface.Interfaces) if ifaceName == "" { ifaceName = strings.TrimSpace(iface.Interface) } netName := strings.TrimSpace(iface.Networks) if netName == "" { netName = strings.TrimSpace(iface.Network) } var target string if ifaceName != "" && netName != "" { target = fmt.Sprintf("\"%s\", %s", ifaceName, netName) } else if ifaceName != "" { target = fmt.Sprintf("\"%s\"", ifaceName) } else if netName != "" { target = netName } else { target = "\"*\"" } sb.WriteString(fmt.Sprintf(" interface %s {\n", target)) if iface.Cost > 0 { sb.WriteString(fmt.Sprintf(" cost %d;\n", iface.Cost)) } if iface.Priority > 0 { sb.WriteString(fmt.Sprintf(" priority %d;\n", iface.Priority)) } if iface.HelloInterval > 0 { sb.WriteString(fmt.Sprintf(" hello %d;\n", iface.HelloInterval)) } if iface.DeadInterval > 0 { sb.WriteString(fmt.Sprintf(" dead %d;\n", iface.DeadInterval)) } if iface.Passive { sb.WriteString(" stub;\n") } else { netType := strings.ToLower(strings.TrimSpace(iface.NetworkType)) if netType == "ptp" || netType == "point-to-point" { sb.WriteString(" type ptp;\n") } else { sb.WriteString(" type broadcast;\n") } } auth := strings.ToLower(iface.Auth) if auth == "" { auth = strings.ToLower(iface.AuthType) } if auth == "md5" && iface.AuthKey != "" { sb.WriteString(" authentication cryptographic;\n") sb.WriteString(fmt.Sprintf(" password \"%s\";\n", iface.AuthKey)) } else if auth == "simple" && iface.AuthKey != "" { sb.WriteString(" authentication simple;\n") sb.WriteString(fmt.Sprintf(" password \"%s\";\n", iface.AuthKey)) } sb.WriteString(" };\n") } sb.WriteString(" };\n") } sb.WriteString("}\n\n") } // Ensure directory exists with proper permissions dir := filepath.Dir(birdConfigPath) if err := os.MkdirAll(dir, 0775); err == nil { _ = os.Chmod(dir, 0775) _ = exec.Command("chown", "root:bird", dir).Run() } // Write candidate configuration to temporary file tmpPath := birdConfigPath + ".new" if err := os.WriteFile(tmpPath, []byte(sb.String()), 0664); err != nil { return fmt.Errorf("failed to write %s: %w", tmpPath, err) } _ = exec.Command("chown", "root:bird", tmpPath).Run() // Validate syntax with bird -p out, err := exec.Command("bird", "-p", "-c", tmpPath).CombinedOutput() if err != nil { _ = os.Remove(tmpPath) return fmt.Errorf("bird syntax check failed: %s: %w", string(out), err) } // Replace active config if err := os.Rename(tmpPath, birdConfigPath); err != nil { return fmt.Errorf("failed to replace %s: %w", birdConfigPath, err) } _ = os.Chmod(birdConfigPath, 0664) _ = exec.Command("chown", "root:bird", birdConfigPath).Run() // Ensure BIRD systemd service is active _ = exec.Command("systemctl", "start", "bird").Run() // Live reload without packet loss reloadOut, reloadErr := birdcExec("configure") if reloadErr != nil { return fmt.Errorf("BIRD reload failed: %s: %w", string(reloadOut), reloadErr) } return nil } func birdcExec(cmd string) ([]byte, error) { return exec.Command("birdc", cmd).CombinedOutput() } // ------------------------------------------------------------- // BGP API & Operations (Multi-Instance BGP) // ------------------------------------------------------------- func (m *Manager) GetBGPInstance() BGPInstance { m.mu.RLock() defer m.mu.RUnlock() inst := m.config.BGPInstance out, err := birdcExec("show status") if err == nil && strings.Contains(string(out), "Daemon is up and running") && inst.Enabled { inst.Running = true } return inst } func (m *Manager) UpdateBGPInstance(inst BGPInstance) error { m.mu.Lock() m.config.BGPInstance = inst _ = m.save() m.mu.Unlock() return m.applyBirdConfig() } func (m *Manager) ListBGPPeers() []BGPPeer { m.mu.RLock() defer m.mu.RUnlock() peers := make([]BGPPeer, len(m.config.BGPPeers)) copy(peers, m.config.BGPPeers) // Fetch live peer telemetry from birdc out, err := birdcExec("show protocols all") if err == nil { type livePeerData struct { State string RemoteAddr string RemoteAS uint32 LocalAS uint32 PfxRcd int PfxSnt int Uptime string } liveMap := make(map[string]*livePeerData) lines := strings.Split(string(out), "\n") var cur *livePeerData for _, line := range lines { trimmed := strings.TrimSpace(line) if strings.Contains(line, " BGP ") { cur = &livePeerData{} fields := strings.Fields(line) if len(fields) >= 5 { cur.Uptime = fields[4] } } if cur == nil { continue } if strings.HasPrefix(trimmed, "BGP state:") { cur.State = strings.TrimSpace(strings.TrimPrefix(trimmed, "BGP state:")) } else if strings.HasPrefix(trimmed, "Neighbor address:") { cur.RemoteAddr = strings.TrimSpace(strings.TrimPrefix(trimmed, "Neighbor address:")) liveMap[cur.RemoteAddr] = cur } else if strings.HasPrefix(trimmed, "Neighbor AS:") { v, _ := strconv.ParseUint(strings.TrimSpace(strings.TrimPrefix(trimmed, "Neighbor AS:")), 10, 32) cur.RemoteAS = uint32(v) } else if strings.HasPrefix(trimmed, "Local AS:") { v, _ := strconv.ParseUint(strings.TrimSpace(strings.TrimPrefix(trimmed, "Local AS:")), 10, 32) cur.LocalAS = uint32(v) } else if strings.HasPrefix(trimmed, "Routes:") { parts := strings.Split(trimmed, ",") for _, p := range parts { p = strings.TrimSpace(p) if strings.Contains(p, "imported") { f := strings.Fields(p) if len(f) >= 2 { cur.PfxRcd, _ = strconv.Atoi(f[len(f)-2]) } } else if strings.Contains(p, "exported") { f := strings.Fields(p) if len(f) >= 2 { cur.PfxSnt, _ = strconv.Atoi(f[0]) } } } } } for i := range peers { if live, ok := liveMap[peers[i].RemoteAddress]; ok { peers[i].State = live.State peers[i].Uptime = live.Uptime peers[i].PrefixReceived = live.PfxRcd peers[i].PrefixSent = live.PfxSnt if peers[i].LocalAS == 0 && live.LocalAS > 0 { peers[i].LocalAS = live.LocalAS } } else if peers[i].Disabled { peers[i].State = "Disabled" } else { peers[i].State = "Idle" } } } return peers } func (m *Manager) SaveBGPPeer(peer *BGPPeer) error { m.mu.Lock() defer m.mu.Unlock() if peer.ID == "" { peer.ID = fmt.Sprintf("bgp-peer-%d", time.Now().UnixNano()) } found := false for i, p := range m.config.BGPPeers { if p.ID == peer.ID { m.config.BGPPeers[i] = *peer found = true break } } if !found { m.config.BGPPeers = append(m.config.BGPPeers, *peer) } if err := m.save(); err != nil { return err } return m.applyBirdConfig() } func (m *Manager) DeleteBGPPeer(id string) error { m.mu.Lock() defer m.mu.Unlock() newPeers := make([]BGPPeer, 0, len(m.config.BGPPeers)) for _, p := range m.config.BGPPeers { if p.ID != id { newPeers = append(newPeers, p) } } m.config.BGPPeers = newPeers if err := m.save(); err != nil { return err } return m.applyBirdConfig() } func (m *Manager) SetBGPPeerState(id string, disabled bool) error { m.mu.Lock() defer m.mu.Unlock() for i := range m.config.BGPPeers { if m.config.BGPPeers[i].ID == id { m.config.BGPPeers[i].Disabled = disabled break } } if err := m.save(); err != nil { return err } return m.applyBirdConfig() } func (m *Manager) ListBGPNetworks() []BGPNetwork { m.mu.RLock() defer m.mu.RUnlock() return m.config.BGPNetworks } func (m *Manager) SaveBGPNetwork(net BGPNetwork) error { m.mu.Lock() defer m.mu.Unlock() if net.ID == "" { net.ID = fmt.Sprintf("bgp-net-%d", time.Now().UnixNano()) } found := false for i, n := range m.config.BGPNetworks { if n.ID == net.ID { m.config.BGPNetworks[i] = net found = true break } } if !found { m.config.BGPNetworks = append(m.config.BGPNetworks, net) } if err := m.save(); err != nil { return err } return m.applyBirdConfig() } func (m *Manager) DeleteBGPNetwork(id string) error { m.mu.Lock() defer m.mu.Unlock() newNets := make([]BGPNetwork, 0, len(m.config.BGPNetworks)) for _, n := range m.config.BGPNetworks { if n.ID != id { newNets = append(newNets, n) } } m.config.BGPNetworks = newNets if err := m.save(); err != nil { return err } return m.applyBirdConfig() } func (m *Manager) GetBGPRoutes() []BGPRoute { var routes []BGPRoute out, err := birdcExec("show route where source = RTS_BGP all") if err != nil { return routes } lines := strings.Split(string(out), "\n") var cur *BGPRoute for _, line := range lines { trimmed := strings.TrimSpace(line) if trimmed == "" || strings.HasPrefix(trimmed, "BIRD") || strings.HasPrefix(trimmed, "Table") { continue } if !strings.HasPrefix(line, "\t") && !strings.HasPrefix(line, " ") { fields := strings.Fields(trimmed) if len(fields) >= 2 { routes = append(routes, BGPRoute{ Prefix: fields[0], Best: strings.Contains(line, "*"), Origin: "IGP", }) cur = &routes[len(routes)-1] } continue } if cur == nil { continue } if strings.HasPrefix(trimmed, "via ") { parts := strings.Fields(trimmed) if len(parts) >= 2 { cur.NextHop = parts[1] } } else if strings.HasPrefix(trimmed, "BGP.as_path:") { cur.Path = strings.TrimSpace(strings.TrimPrefix(trimmed, "BGP.as_path:")) } else if strings.HasPrefix(trimmed, "BGP.origin:") { cur.Origin = strings.TrimSpace(strings.TrimPrefix(trimmed, "BGP.origin:")) } else if strings.HasPrefix(trimmed, "BGP.next_hop:") { cur.NextHop = strings.TrimSpace(strings.TrimPrefix(trimmed, "BGP.next_hop:")) } else if strings.HasPrefix(trimmed, "BGP.med:") { cur.Metric, _ = strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(trimmed, "BGP.med:"))) } } return routes } // ------------------------------------------------------------- // OSPF API & Operations (RouterOS v7 Style OSPF) // ------------------------------------------------------------- func (m *Manager) ListOSPFInstances() []OSPFInstance { m.mu.RLock() defer m.mu.RUnlock() out, err := birdcExec("show protocols") protocols := map[string]bool{} if err == nil { for _, line := range strings.Split(string(out), "\n") { fields := strings.Fields(line) if len(fields) >= 4 && fields[1] == "OSPF" { protocols[fields[0]] = fields[3] == "up" } } } res := make([]OSPFInstance, len(m.config.OSPFInstances)) for i, inst := range m.config.OSPFInstances { instCopy := inst instCopy.Running = false if protocols[sanitizeIdentifier("ospf", inst.Name)] && !inst.Disabled { instCopy.Running = true } res[i] = instCopy } return res } func (m *Manager) SaveOSPFInstance(inst *OSPFInstance) error { m.mu.Lock() defer m.mu.Unlock() if inst.ID == "" { inst.ID = fmt.Sprintf("ospf-inst-%d", time.Now().UnixNano()) } if inst.Name == "" { inst.Name = "Default" } if inst.Version <= 0 { inst.Version = 2 } if inst.VRF == "" { inst.VRF = "main" } if inst.RoutingTable == "" { inst.RoutingTable = "main" } found := false for i, it := range m.config.OSPFInstances { if it.ID == inst.ID { m.config.OSPFInstances[i] = *inst found = true break } } if !found { m.config.OSPFInstances = append(m.config.OSPFInstances, *inst) } // Update legacy instance holder as well m.config.OSPFInstance = *inst if err := m.save(); err != nil { return err } return m.applyBirdConfig() } func (m *Manager) DeleteOSPFInstance(id string) error { m.mu.Lock() defer m.mu.Unlock() var newInst []OSPFInstance for _, it := range m.config.OSPFInstances { if it.ID != id { newInst = append(newInst, it) } } m.config.OSPFInstances = newInst if err := m.save(); err != nil { return err } return m.applyBirdConfig() } func (m *Manager) SetOSPFInstanceState(id string, disabled bool) error { m.mu.Lock() defer m.mu.Unlock() for i := range m.config.OSPFInstances { if m.config.OSPFInstances[i].ID == id { m.config.OSPFInstances[i].Disabled = disabled break } } if err := m.save(); err != nil { return err } return m.applyBirdConfig() } func (m *Manager) GetOSPFInstance() OSPFInstance { m.mu.RLock() defer m.mu.RUnlock() if len(m.config.OSPFInstances) > 0 { inst := m.config.OSPFInstances[0] out, err := birdcExec("show protocols") if err == nil && strings.Contains(string(out), "OSPF") && strings.Contains(string(out), "up") && !inst.Disabled { inst.Running = true } return inst } inst := m.config.OSPFInstance out, err := birdcExec("show protocols") if err == nil && strings.Contains(string(out), "OSPF") && strings.Contains(string(out), "up") && inst.Enabled { inst.Running = true } return inst } func (m *Manager) UpdateOSPFInstance(inst OSPFInstance) error { return m.SaveOSPFInstance(&inst) } func (m *Manager) ListOSPFAreas() []OSPFArea { m.mu.RLock() defer m.mu.RUnlock() return m.config.OSPFAreas } func (m *Manager) SaveOSPFArea(area OSPFArea) error { m.mu.Lock() defer m.mu.Unlock() if area.ID == "" { area.ID = fmt.Sprintf("area-%d", time.Now().UnixNano()) } if area.Name == "" { area.Name = "Default" } if area.Instance == "" { area.Instance = "Default" } if area.AreaID == "" { area.AreaID = "0.0.0.0" } if area.AreaType == "" { area.AreaType = "default" } found := false for i, a := range m.config.OSPFAreas { if a.ID == area.ID { m.config.OSPFAreas[i] = area found = true break } } if !found { m.config.OSPFAreas = append(m.config.OSPFAreas, area) } if err := m.save(); err != nil { return err } return m.applyBirdConfig() } func (m *Manager) DeleteOSPFArea(id string) error { m.mu.Lock() defer m.mu.Unlock() newAreas := make([]OSPFArea, 0, len(m.config.OSPFAreas)) for _, a := range m.config.OSPFAreas { if a.ID != id { newAreas = append(newAreas, a) } } m.config.OSPFAreas = newAreas if err := m.save(); err != nil { return err } return m.applyBirdConfig() } func (m *Manager) SetOSPFAreaState(id string, disabled bool) error { m.mu.Lock() defer m.mu.Unlock() for i := range m.config.OSPFAreas { if m.config.OSPFAreas[i].ID == id { m.config.OSPFAreas[i].Disabled = disabled break } } if err := m.save(); err != nil { return err } return m.applyBirdConfig() } func (m *Manager) ListOSPFAreaRanges() []OSPFAreaRange { m.mu.RLock() defer m.mu.RUnlock() return m.config.OSPFAreaRanges } func (m *Manager) SaveOSPFAreaRange(ar OSPFAreaRange) error { m.mu.Lock() defer m.mu.Unlock() if ar.ID == "" { ar.ID = fmt.Sprintf("range-%d", time.Now().UnixNano()) } if ar.Area == "" { ar.Area = "Default" } found := false for i, item := range m.config.OSPFAreaRanges { if item.ID == ar.ID { m.config.OSPFAreaRanges[i] = ar found = true break } } if !found { m.config.OSPFAreaRanges = append(m.config.OSPFAreaRanges, ar) } if err := m.save(); err != nil { return err } return m.applyBirdConfig() } func (m *Manager) DeleteOSPFAreaRange(id string) error { m.mu.Lock() defer m.mu.Unlock() newRanges := make([]OSPFAreaRange, 0, len(m.config.OSPFAreaRanges)) for _, item := range m.config.OSPFAreaRanges { if item.ID != id { newRanges = append(newRanges, item) } } m.config.OSPFAreaRanges = newRanges if err := m.save(); err != nil { return err } return m.applyBirdConfig() } func (m *Manager) ListOSPFInterfaces() []OSPFInterface { m.mu.RLock() defer m.mu.RUnlock() return m.config.OSPFInterfaces } func (m *Manager) SaveOSPFInterface(iface OSPFInterface) error { m.mu.Lock() defer m.mu.Unlock() if iface.ID == "" { iface.ID = fmt.Sprintf("ospf-tmpl-%d", time.Now().UnixNano()) } if iface.Area == "" { iface.Area = "Default" } if iface.Cost <= 0 { iface.Cost = 1 } if iface.Priority <= 0 { iface.Priority = 128 } if iface.NetworkType == "" { iface.NetworkType = "broadcast" } if iface.HelloInterval <= 0 { iface.HelloInterval = 10 } if iface.DeadInterval <= 0 { iface.DeadInterval = 40 } found := false for i, item := range m.config.OSPFInterfaces { if item.ID == iface.ID { m.config.OSPFInterfaces[i] = iface found = true break } } if !found { m.config.OSPFInterfaces = append(m.config.OSPFInterfaces, iface) } if err := m.save(); err != nil { return err } return m.applyBirdConfig() } func (m *Manager) DeleteOSPFInterface(id string) error { m.mu.Lock() defer m.mu.Unlock() newInterfaces := make([]OSPFInterface, 0, len(m.config.OSPFInterfaces)) for _, item := range m.config.OSPFInterfaces { if item.ID != id { newInterfaces = append(newInterfaces, item) } } m.config.OSPFInterfaces = newInterfaces if err := m.save(); err != nil { return err } return m.applyBirdConfig() } func (m *Manager) SetOSPFInterfaceState(id string, disabled bool) error { m.mu.Lock() defer m.mu.Unlock() for i := range m.config.OSPFInterfaces { if m.config.OSPFInterfaces[i].ID == id { m.config.OSPFInterfaces[i].Disabled = disabled break } } if err := m.save(); err != nil { return err } return m.applyBirdConfig() } func (m *Manager) GetOSPFInterfacesLive() []OSPFInterfaceLive { var list []OSPFInterfaceLive out, err := birdcExec("show ospf interface") if err != nil { return list } lines := strings.Split(string(out), "\n") var cur *OSPFInterfaceLive for _, line := range lines { trimmed := strings.TrimSpace(line) if trimmed == "" || strings.HasPrefix(trimmed, "BIRD") || strings.HasSuffix(trimmed, ":") { continue } if strings.HasPrefix(trimmed, "Interface ") { if cur != nil { list = append(list, *cur) } parts := strings.SplitN(strings.TrimPrefix(trimmed, "Interface "), " ", 2) ifName := parts[0] addr := "" if len(parts) > 1 { addr = strings.Trim(parts[1], "()") } cur = &OSPFInterfaceLive{ Flags: "D", Interface: ifName, Address: addr, Area: "Default", InstanceID: 0, Cost: 1, Priority: 128, State: "waiting", } continue } if cur == nil { continue } if strings.HasPrefix(trimmed, "Area: ") { val := strings.TrimPrefix(trimmed, "Area: ") areaParts := strings.Fields(val) if len(areaParts) > 0 { cur.Area = areaParts[0] } } else if strings.HasPrefix(trimmed, "State: ") { cur.State = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(trimmed, "State: "))) } else if strings.HasPrefix(trimmed, "Priority: ") { fmt.Sscanf(strings.TrimPrefix(trimmed, "Priority: "), "%d", &cur.Priority) } else if strings.HasPrefix(trimmed, "Cost: ") { fmt.Sscanf(strings.TrimPrefix(trimmed, "Cost: "), "%d", &cur.Cost) } } if cur != nil { list = append(list, *cur) } return list } func (m *Manager) ListOSPFStaticNeighbors() []OSPFStaticNeighbor { m.mu.RLock() defer m.mu.RUnlock() return m.config.OSPFStaticNbrs } func (m *Manager) SaveOSPFStaticNeighbor(nbr OSPFStaticNeighbor) error { m.mu.Lock() defer m.mu.Unlock() if nbr.ID == "" { nbr.ID = fmt.Sprintf("snbr-%d", time.Now().UnixNano()) } if nbr.Area == "" { nbr.Area = "Default" } found := false for i, item := range m.config.OSPFStaticNbrs { if item.ID == nbr.ID { m.config.OSPFStaticNbrs[i] = nbr found = true break } } if !found { m.config.OSPFStaticNbrs = append(m.config.OSPFStaticNbrs, nbr) } return m.save() } func (m *Manager) DeleteOSPFStaticNeighbor(id string) error { m.mu.Lock() defer m.mu.Unlock() newNbrs := make([]OSPFStaticNeighbor, 0, len(m.config.OSPFStaticNbrs)) for _, item := range m.config.OSPFStaticNbrs { if item.ID != id { newNbrs = append(newNbrs, item) } } m.config.OSPFStaticNbrs = newNbrs return m.save() } func (m *Manager) GetOSPFNeighbors() []OSPFNeighbor { var neighbors []OSPFNeighbor out, err := birdcExec("show ospf neighbors") if err != nil { return neighbors } lines := strings.Split(string(out), "\n") for _, line := range lines { trimmed := strings.TrimSpace(line) if trimmed == "" || strings.HasPrefix(trimmed, "BIRD") || strings.HasPrefix(trimmed, "Router ID") || strings.HasSuffix(trimmed, ":") { continue } fields := strings.Fields(trimmed) if len(fields) >= 5 { rID := fields[0] pri := 0 fmt.Sscanf(fields[1], "%d", &pri) rawState := fields[2] state := rawState if idx := strings.Index(rawState, "/"); idx != -1 { state = rawState[:idx] } dTime := fields[3] iface := fields[4] ip := rID if len(fields) >= 6 { ip = fields[5] } neighbors = append(neighbors, OSPFNeighbor{ Flags: "D", Comment: "", Instance: "Default", Area: "Default", Address: ip, State: state, StateChanges: 6, NeighborID: rID, Interface: iface, DeadTimer: dTime, Priority: pri, }) } } return neighbors } func (m *Manager) GetOSPFLSA() []OSPFLSA { var lsaList []OSPFLSA out, err := birdcExec("show ospf lsadb") if err != nil { return lsaList } localRouterID := "192.168.131.56" if len(m.config.OSPFInstances) > 0 && m.config.OSPFInstances[0].RouterID != "" { localRouterID = m.config.OSPFInstances[0].RouterID } lines := strings.Split(string(out), "\n") currentArea := "Default" for _, line := range lines { trimmed := strings.TrimSpace(line) if trimmed == "" || strings.HasPrefix(trimmed, "BIRD") { continue } if strings.HasPrefix(trimmed, "Area ") { currentArea = strings.TrimSpace(strings.TrimPrefix(trimmed, "Area ")) continue } else if strings.EqualFold(trimmed, "Global") { currentArea = "Default" continue } if strings.HasPrefix(trimmed, "Type") || strings.HasPrefix(trimmed, "ospf_") { continue } fields := strings.Fields(trimmed) if len(fields) >= 5 { rawType := fields[0] typeName := "router" switch rawType { case "0001": typeName = "router" case "0002": typeName = "network" case "0003": typeName = "summary-net" case "0004": typeName = "summary-asbr" case "0005": typeName = "as-external" default: typeName = rawType } lsID := fields[1] router := fields[2] seq := fields[3] age := 0 fmt.Sscanf(fields[4], "%d", &age) flags := "D" if router == localRouterID { flags = "SD" } lsaList = append(lsaList, OSPFLSA{ Flags: flags, Instance: "Default", Area: currentArea, Type: typeName, Originator: router, ID: lsID, Link: "", LinkIndex: 0, Sequence: seq, Age: age, }) } } return lsaList } func (m *Manager) GetOSPFRoutes() []OSPFRoute { var routes []OSPFRoute out, err := birdcExec("show route where source = RTS_OSPF all") if err != nil { return routes } lines := strings.Split(string(out), "\n") var cur *OSPFRoute for _, line := range lines { trimmed := strings.TrimSpace(line) if trimmed == "" || strings.HasPrefix(trimmed, "BIRD") || strings.HasPrefix(trimmed, "Table") { continue } if !strings.HasPrefix(line, "\t") && !strings.HasPrefix(line, " ") { fields := strings.Fields(trimmed) if len(fields) >= 2 { cost := 10 if idx := strings.Index(line, "/"); idx != -1 { sub := line[idx+1:] if end := strings.Index(sub, ")"); end != -1 { cost, _ = strconv.Atoi(sub[:end]) } } routes = append(routes, OSPFRoute{ Prefix: fields[0], Cost: cost, RouteType: "Intra-Area", }) cur = &routes[len(routes)-1] } continue } if cur == nil { continue } if strings.HasPrefix(trimmed, "via ") { parts := strings.Fields(trimmed) if len(parts) >= 2 { cur.NextHop = parts[1] } if len(parts) >= 4 && parts[2] == "on" { cur.Interface = parts[3] } } else if strings.HasPrefix(trimmed, "Type:") { cur.RouteType = strings.TrimSpace(strings.TrimPrefix(trimmed, "Type:")) } } return routes } // ------------------------------------------------------------- // Routing Filter API & Operations (/routing filter) // ------------------------------------------------------------- func (m *Manager) ListRoutingFilters() []RoutingFilterRule { m.mu.RLock() defer m.mu.RUnlock() filters := make([]RoutingFilterRule, len(m.config.RoutingFilters)) copy(filters, m.config.RoutingFilters) return filters } func (m *Manager) SaveRoutingFilter(rule *RoutingFilterRule) error { m.mu.Lock() if rule.ID == "" { rule.ID = fmt.Sprintf("rf-%d", time.Now().UnixNano()/1e6) } if rule.RuleOrder <= 0 { rule.RuleOrder = (len(m.config.RoutingFilters) + 1) * 10 } found := false for i, r := range m.config.RoutingFilters { if r.ID == rule.ID { m.config.RoutingFilters[i] = *rule found = true break } } if !found { m.config.RoutingFilters = append(m.config.RoutingFilters, *rule) } _ = m.save() m.mu.Unlock() return m.applyBirdConfig() } func (m *Manager) DeleteRoutingFilter(id string) error { m.mu.Lock() newRules := make([]RoutingFilterRule, 0, len(m.config.RoutingFilters)) for _, r := range m.config.RoutingFilters { if r.ID != id { newRules = append(newRules, r) } } m.config.RoutingFilters = newRules _ = m.save() m.mu.Unlock() return m.applyBirdConfig() } func (m *Manager) SetRoutingFilterState(id string, disabled bool) error { m.mu.Lock() for i := range m.config.RoutingFilters { if m.config.RoutingFilters[i].ID == id { m.config.RoutingFilters[i].Disabled = disabled break } } _ = m.save() m.mu.Unlock() return m.applyBirdConfig() } // translateRos7RuleToBird converts a RouterOS v7 rule script (or fallback fields) to valid BIRD 2 syntax func translateRos7RuleToBird(r *RoutingFilterRule) string { script := strings.TrimSpace(r.Rule) if script != "" { s := script // 1. Convert dst in A.B.C.D/M{L,H} -> net ~ [ A.B.C.D/M{L,H} ] reDstRange := regexp.MustCompile(`dst\s+in\s+([0-9\.\/]+)\{([0-9]+),([0-9]+)\}`) s = reDstRange.ReplaceAllString(s, "net ~ [ $1{$2,$3} ]") // 2. Convert dst in A.B.C.D/M -> net ~ [ A.B.C.D/M+ ] reDstIn := regexp.MustCompile(`dst\s+in\s+([0-9\.\/]+)`) s = reDstIn.ReplaceAllString(s, "net ~ [ $1+ ]") // 3. Convert dst == A.B.C.D/M -> net = A.B.C.D/M reDstEq := regexp.MustCompile(`dst\s*==\s*([0-9\.\/]+)`) s = reDstEq.ReplaceAllString(s, "net = $1") // 4. Convert dst != A.B.C.D/M -> net != A.B.C.D/M reDstNeq := regexp.MustCompile(`dst\s*!=\s*([0-9\.\/]+)`) s = reDstNeq.ReplaceAllString(s, "net != $1") // 5. Convert dst-len -> net.len s = strings.ReplaceAll(s, "dst-len", "net.len") // 6. Convert protocol matches s = regexp.MustCompile(`protocol\s+bgp\b`).ReplaceAllString(s, "source = RTS_BGP") s = regexp.MustCompile(`protocol\s+ospf\b`).ReplaceAllString(s, "source = RTS_OSPF") s = regexp.MustCompile(`protocol\s+connected\b`).ReplaceAllString(s, "source = RTS_DEVICE") s = regexp.MustCompile(`protocol\s+static\b`).ReplaceAllString(s, "source = RTS_STATIC") s = regexp.MustCompile(`protocol\s+rip\b`).ReplaceAllString(s, "source = RTS_RIP") // 7. Convert bgp-communities includes A:B -> (A, B) ~ bgp_community reCommInc := regexp.MustCompile(`bgp-communities\s+includes\s+([0-9]+):([0-9]+)`) s = reCommInc.ReplaceAllString(s, "($1, $2) ~ bgp_community") // 8. Convert bgp-communities (in|equal-list) ListName -> bgp_community ~ comm_ListName reCommList := regexp.MustCompile(`bgp-communities\s+(?:in|equal-list)\s+([A-Za-z0-9_]+)`) s = reCommList.ReplaceAllString(s, "bgp_community ~ comm_$1") // 9. Convert bgp-as-path in ListName -> bgp_path.last ~ aslist_ListName reASList := regexp.MustCompile(`bgp-as-path\s+in\s+([A-Za-z0-9_]+)`) s = reASList.ReplaceAllString(s, "bgp_path.last ~ aslist_$1") // 10. Convert bgp-as-path "pattern" -> bgp_path ~ [= * pattern * =] reASPattern := regexp.MustCompile(`bgp-as-path\s+"([^"]+)"`) s = reASPattern.ReplaceAllStringFunc(s, func(match string) string { sub := reASPattern.FindStringSubmatch(match) if len(sub) == 2 { pat := strings.Trim(sub[1], "^$") return fmt.Sprintf("bgp_path ~ [= * %s * =]", pat) } return match }) // 11. Convert actions: reSetLP := regexp.MustCompile(`set\s+bgp-local-pref\s*=?\s*([0-9]+);?`) s = reSetLP.ReplaceAllString(s, "bgp_local_pref = $1;") reSetMed := regexp.MustCompile(`set\s+bgp-med\s*=?\s*([0-9]+);?`) s = reSetMed.ReplaceAllString(s, "bgp_med = $1;") reSetDist := regexp.MustCompile(`set\s+distance\s*=?\s*([0-9]+);?`) s = reSetDist.ReplaceAllString(s, "preference = $1;") reSetGW := regexp.MustCompile(`set\s+gw\s*=?\s*([0-9\.]+);?`) s = reSetGW.ReplaceAllString(s, "gw = $1;") reSetPrepend := regexp.MustCompile(`set\s+bgp-path-prepend\s*=?\s*([0-9]+);?`) s = reSetPrepend.ReplaceAllStringFunc(s, func(match string) string { sub := reSetPrepend.FindStringSubmatch(match) if len(sub) == 2 { n, _ := strconv.Atoi(sub[1]) if n > 0 && n <= 10 { var b strings.Builder for i := 0; i < n; i++ { b.WriteString("bgp_path.prepend(bgp_path.first); ") } return b.String() } else if n > 10 { return fmt.Sprintf("bgp_path.prepend(%d);", n) } } return match }) reSetCommAdd := regexp.MustCompile(`set\s+bgp-communities\.add\s+([0-9]+):([0-9]+);?`) s = reSetCommAdd.ReplaceAllString(s, "bgp_community.add(($1, $2));") reSetCommDel := regexp.MustCompile(`set\s+bgp-communities\.delete\s+([0-9]+):([0-9]+);?`) s = reSetCommDel.ReplaceAllString(s, "bgp_community.delete(($1, $2));") // Ensure semicolons on accept and reject s = regexp.MustCompile(`\baccept(?:\s*;)?`).ReplaceAllString(s, "accept;") s = regexp.MustCompile(`\b(reject|discard)(?:\s*;)?`).ReplaceAllString(s, "reject;") // Ensure "then" before opening brace of if statement reIfThen := regexp.MustCompile(`\)\s*\{`) s = reIfThen.ReplaceAllString(s, ") then {") s = strings.ReplaceAll(s, ") then then {", ") then {") return s } // Fallback for rules configured with structured UI form var conditions []string if r.Prefix != "" { pfx := strings.TrimSpace(r.Prefix) pfxLen := strings.TrimSpace(r.PrefixLen) if pfxLen != "" { if strings.Contains(pfxLen, "-") { parts := strings.Split(pfxLen, "-") if len(parts) == 2 { conditions = append(conditions, fmt.Sprintf("net ~ [ %s{%s,%s} ]", pfx, strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]))) } } else { conditions = append(conditions, fmt.Sprintf("net ~ [ %s{%s,32} ]", pfx, pfxLen)) } } else { conditions = append(conditions, fmt.Sprintf("net ~ [ %s+ ]", pfx)) } } if r.MatchProtocol != "" && r.MatchProtocol != "any" { switch strings.ToLower(r.MatchProtocol) { case "bgp": conditions = append(conditions, "source = RTS_BGP") case "ospf": conditions = append(conditions, "source = RTS_OSPF") case "connected": conditions = append(conditions, "source = RTS_DEVICE") case "static": conditions = append(conditions, "source = RTS_STATIC") } } if r.MatchASPath != "" { conditions = append(conditions, fmt.Sprintf("bgp_path ~ [= * %s * =]", strings.Trim(strings.TrimSpace(r.MatchASPath), "^$"))) } if r.MatchCommunity != "" { comm := strings.TrimSpace(r.MatchCommunity) if strings.Contains(comm, ":") { parts := strings.Split(comm, ":") conditions = append(conditions, fmt.Sprintf("(%s, %s) ~ bgp_community", parts[0], parts[1])) } } condStr := "true" if len(conditions) > 0 { condStr = strings.Join(conditions, " && ") } var sb strings.Builder sb.WriteString(fmt.Sprintf("if %s then {\n", condStr)) if r.SetLocalPref > 0 { sb.WriteString(fmt.Sprintf(" bgp_local_pref = %d;\n", r.SetLocalPref)) } if r.SetMetric > 0 { sb.WriteString(fmt.Sprintf(" bgp_med = %d;\n", r.SetMetric)) } if r.SetASPrepend > 0 { for i := 0; i < r.SetASPrepend; i++ { sb.WriteString(" bgp_path.prepend(bgp_path.first);\n") } } if r.SetCommunity != "" { comm := strings.TrimSpace(r.SetCommunity) if strings.Contains(comm, ":") { parts := strings.Split(comm, ":") sb.WriteString(fmt.Sprintf(" bgp_community.add((%s, %s));\n", parts[0], parts[1])) } } if r.SetNextHop != "" { sb.WriteString(fmt.Sprintf(" bgp_next_hop = %s;\n", strings.TrimSpace(r.SetNextHop))) } if r.SetDistance > 0 { sb.WriteString(fmt.Sprintf(" preference = %d;\n", r.SetDistance)) } if r.Action == "discard" || r.Action == "reject" { sb.WriteString(" reject;\n") } else { sb.WriteString(" accept;\n") } sb.WriteString("}") return sb.String() } // ------------------------------------------------------------- // Select Rules (/routing filter select-rule) // ------------------------------------------------------------- func (m *Manager) ListSelectRules() []RoutingFilterSelectRule { m.mu.RLock() defer m.mu.RUnlock() res := make([]RoutingFilterSelectRule, len(m.config.RoutingSelectRules)) copy(res, m.config.RoutingSelectRules) return res } func (m *Manager) SaveSelectRule(rule *RoutingFilterSelectRule) error { m.mu.Lock() if rule.ID == "" { rule.ID = fmt.Sprintf("sr-%d", time.Now().UnixNano()/1e6) } if rule.RuleOrder <= 0 { rule.RuleOrder = (len(m.config.RoutingSelectRules) + 1) * 10 } found := false for i, r := range m.config.RoutingSelectRules { if r.ID == rule.ID { m.config.RoutingSelectRules[i] = *rule found = true break } } if !found { m.config.RoutingSelectRules = append(m.config.RoutingSelectRules, *rule) } _ = m.save() m.mu.Unlock() return m.applyBirdConfig() } func (m *Manager) DeleteSelectRule(id string) error { m.mu.Lock() newRules := make([]RoutingFilterSelectRule, 0, len(m.config.RoutingSelectRules)) for _, r := range m.config.RoutingSelectRules { if r.ID != id { newRules = append(newRules, r) } } m.config.RoutingSelectRules = newRules _ = m.save() m.mu.Unlock() return m.applyBirdConfig() } func (m *Manager) SetSelectRuleState(id string, disabled bool) error { m.mu.Lock() for i := range m.config.RoutingSelectRules { if m.config.RoutingSelectRules[i].ID == id { m.config.RoutingSelectRules[i].Disabled = disabled break } } _ = m.save() m.mu.Unlock() return m.applyBirdConfig() } // ------------------------------------------------------------- // Community Lists (/routing filter community-list) // ------------------------------------------------------------- func (m *Manager) ListCommunityLists() []RoutingFilterCommunityList { m.mu.RLock() defer m.mu.RUnlock() res := make([]RoutingFilterCommunityList, len(m.config.RoutingCommunityLists)) copy(res, m.config.RoutingCommunityLists) return res } func (m *Manager) SaveCommunityList(cl *RoutingFilterCommunityList) error { m.mu.Lock() if cl.ID == "" { cl.ID = fmt.Sprintf("cl-%d", time.Now().UnixNano()/1e6) } found := false for i, r := range m.config.RoutingCommunityLists { if r.ID == cl.ID { m.config.RoutingCommunityLists[i] = *cl found = true break } } if !found { m.config.RoutingCommunityLists = append(m.config.RoutingCommunityLists, *cl) } _ = m.save() m.mu.Unlock() return m.applyBirdConfig() } func (m *Manager) DeleteCommunityList(id string) error { m.mu.Lock() newLists := make([]RoutingFilterCommunityList, 0, len(m.config.RoutingCommunityLists)) for _, r := range m.config.RoutingCommunityLists { if r.ID != id { newLists = append(newLists, r) } } m.config.RoutingCommunityLists = newLists _ = m.save() m.mu.Unlock() return m.applyBirdConfig() } func (m *Manager) SetCommunityListState(id string, disabled bool) error { m.mu.Lock() for i := range m.config.RoutingCommunityLists { if m.config.RoutingCommunityLists[i].ID == id { m.config.RoutingCommunityLists[i].Disabled = disabled break } } _ = m.save() m.mu.Unlock() return m.applyBirdConfig() } // ------------------------------------------------------------- // AS Lists (/routing filter num-list) // ------------------------------------------------------------- func (m *Manager) ListASLists() []RoutingFilterASList { m.mu.RLock() defer m.mu.RUnlock() res := make([]RoutingFilterASList, len(m.config.RoutingASLists)) copy(res, m.config.RoutingASLists) return res } func (m *Manager) SaveASList(al *RoutingFilterASList) error { m.mu.Lock() if al.ID == "" { al.ID = fmt.Sprintf("al-%d", time.Now().UnixNano()/1e6) } found := false for i, r := range m.config.RoutingASLists { if r.ID == al.ID { m.config.RoutingASLists[i] = *al found = true break } } if !found { m.config.RoutingASLists = append(m.config.RoutingASLists, *al) } _ = m.save() m.mu.Unlock() return m.applyBirdConfig() } func (m *Manager) DeleteASList(id string) error { m.mu.Lock() newLists := make([]RoutingFilterASList, 0, len(m.config.RoutingASLists)) for _, r := range m.config.RoutingASLists { if r.ID != id { newLists = append(newLists, r) } } m.config.RoutingASLists = newLists _ = m.save() m.mu.Unlock() return m.applyBirdConfig() } func (m *Manager) SetASListState(id string, disabled bool) error { m.mu.Lock() for i := range m.config.RoutingASLists { if m.config.RoutingASLists[i].ID == id { m.config.RoutingASLists[i].Disabled = disabled break } } _ = m.save() m.mu.Unlock() return m.applyBirdConfig() } // ------------------------------------------------------------- // Summary Telemetry // ------------------------------------------------------------- func (m *Manager) GetSummary() DynamicRoutingSummary { m.mu.RLock() defer m.mu.RUnlock() summary := DynamicRoutingSummary{ BGPEnabled: m.config.BGPInstance.Enabled, OSPFEnabled: m.config.OSPFInstance.Enabled, BGPPeerCount: len(m.config.BGPPeers), RoutingFilterCount: len(m.config.RoutingFilters), } out, err := birdcExec("show status") birdRunning := err == nil && strings.Contains(string(out), "Daemon is up and running") if birdRunning && summary.BGPEnabled { summary.BGPRunning = true peers := m.ListBGPPeers() est := 0 for _, p := range peers { if strings.EqualFold(p.State, "Established") || strings.EqualFold(p.State, "Up") { est++ } } summary.BGPEstablishedCount = est } if birdRunning && summary.OSPFEnabled { summary.OSPFRunning = true nbrs := m.GetOSPFNeighbors() summary.OSPFNeighborCount = len(nbrs) adj := 0 for _, n := range nbrs { if strings.Contains(strings.ToLower(n.State), "full") { adj++ } } summary.OSPFAdjacentCount = adj } return summary }