package dynamicrouting import ( "encoding/json" "fmt" "log" "net" "os" "os/exec" "path/filepath" "regexp" "sort" "strconv" "strings" "sync" "time" ) const ( defaultConfigPath = "/etc/debtik/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, RedistributeDefault: false, OriginateDefault: "never", DefaultMetric: 10, }, OSPFAreas: []OSPFArea{ { ID: "area-default", Name: "Default", Instance: "Default", AreaID: "0.0.0.0", AreaType: "default", }, }, OSPFAreaRanges: []OSPFAreaRange{}, OSPFInterfaces: []OSPFInterface{}, OSPFStaticNbrs: []OSPFStaticNeighbor{}, RIPInstances: []RIPInstance{}, RIPInterfaces: []RIPInterface{}, RIPNeighbors: []RIPNeighbor{}, BFDInstance: BFDInstance{Enabled: false}, BFDInterfaces: []BFDInterface{}, BFDNeighbors: []BFDNeighbor{}, BabelInstances: []BabelInstance{}, BabelInterfaces: []BabelInterface{}, RAdvInstances: []RAdvInstance{}, RAdvInterfaces: []RAdvInterface{}, RoutingFilters: []RoutingFilterRule{}, 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.RoutingCommunityLists == nil { m.config.RoutingCommunityLists = []RoutingFilterCommunityList{} } if m.config.RoutingASLists == nil { m.config.RoutingASLists = []RoutingFilterASList{} } if m.config.RIPInstances == nil { m.config.RIPInstances = []RIPInstance{} } if m.config.RIPInterfaces == nil { m.config.RIPInterfaces = []RIPInterface{} } if m.config.RIPNeighbors == nil { m.config.RIPNeighbors = []RIPNeighbor{} } if m.config.BFDInterfaces == nil { m.config.BFDInterfaces = []BFDInterface{} } if m.config.BFDNeighbors == nil { m.config.BFDNeighbors = []BFDNeighbor{} } if m.config.BabelInstances == nil { m.config.BabelInstances = []BabelInstance{} } if m.config.BabelInterfaces == nil { m.config.BabelInterfaces = []BabelInterface{} } if m.config.RAdvInstances == nil { m.config.RAdvInstances = []RAdvInstance{} } if m.config.RAdvInterfaces == nil { m.config.RAdvInterfaces = []RAdvInterface{} } 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 } // formatInterfaceTargets formats interface names and network prefixes for BIRD 2 interface pattern func formatInterfaceTargets(ifaceNames, netPrefixes string) string { var targets []string if ifaceNames != "" { for _, raw := range strings.Split(ifaceNames, ",") { item := strings.TrimSpace(raw) if item != "" { if item == "*" { targets = append(targets, "\"*\"") } else if strings.HasPrefix(item, "\"") && strings.HasSuffix(item, "\"") { targets = append(targets, item) } else { targets = append(targets, fmt.Sprintf("%q", item)) } } } } if netPrefixes != "" { for _, raw := range strings.Split(netPrefixes, ",") { item := strings.TrimSpace(raw) if item != "" { targets = append(targets, item) } } } if len(targets) == 0 { return "\"*\"" } return strings.Join(targets, ", ") } // ipInSubnets returns true if ipStr is contained in any comma-separated subnet in networksStr func ipInSubnets(ipStr, networksStr string) bool { ip := net.ParseIP(strings.TrimSpace(ipStr)) if ip == nil { return false } for _, raw := range strings.Split(networksStr, ",") { cidr := strings.TrimSpace(raw) if cidr == "" { continue } _, ipNet, err := net.ParseCIDR(cidr) if err == nil && ipNet.Contains(ip) { return true } } return false } // generateBirdConfig creates candidate bird.conf text from current configuration func (m *Manager) generateBirdConfig() string { 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 DebTik (BIRD 2 Engine)\n\n") sb.WriteString("timeformat protocol iso long;\n") sb.WriteString("timeformat base iso long;\n") sb.WriteString("timeformat log iso long;\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 proto ~ \"ospf_def_*\" then reject;\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(" if source = RTS_BABEL then {\n") sb.WriteString(" krt_metric = 130;\n") sb.WriteString(" }\n") sb.WriteString(" if source ~ [ RTS_STATIC, RTS_INHERIT ] then {\n") sb.WriteString(" krt_metric = 255;\n") sb.WriteString(" }\n") sb.WriteString(" accept;\n") sb.WriteString(" };\n") sb.WriteString(" };\n") sb.WriteString(" learn;\n") sb.WriteString("}\n\n") sb.WriteString("protocol kernel {\n") sb.WriteString(" metric 0;\n") sb.WriteString(" ipv6 {\n") sb.WriteString(" import none;\n") sb.WriteString(" export all;\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) fnName := fmt.Sprintf("fn_%s", filterName) // Procedural function for this chain sb.WriteString(fmt.Sprintf("function %s() {\n", fnName)) 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") } } } } sb.WriteString("}\n\n") // Standalone filter for import and general use (unmatched routes rejected by default) sb.WriteString(fmt.Sprintf("filter %s {\n", filterName)) sb.WriteString(fmt.Sprintf(" %s();\n", fnName)) 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 { fnName := fmt.Sprintf("fn_%s", sanitizeIdentifier("filter", peer.OutFilter)) sb.WriteString(" export filter {\n") sb.WriteString(fmt.Sprintf(" %s();\n", fnName)) if peerNetsProto != "" { sb.WriteString(fmt.Sprintf(" if proto = \"%s\" then accept;\n", peerNetsProto)) } if len(activeNets) > 0 { sb.WriteString(" if proto = \"bgp_static_nets\" then accept;\n") } if peer.RedistConnected || m.config.BGPInstance.RedistributeConnected { sb.WriteString(" if source = RTS_DEVICE then accept;\n") } if peer.RedistStatic || m.config.BGPInstance.RedistributeStatic { sb.WriteString(" if source ~ [ RTS_STATIC, RTS_INHERIT ] then accept;\n") } if peer.RedistOSPF || m.config.BGPInstance.RedistributeOSPF { sb.WriteString(" if source = RTS_OSPF then accept;\n") } sb.WriteString(" reject;\n") sb.WriteString(" };\n") } 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, RTS_INHERIT ]`) } 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") if peer.UseBFD { sb.WriteString(" bfd yes;\n") } sb.WriteString("}\n\n") } } // OSPFv2 Protocols (RouterOS v7 Multi-Instance OSPF) ospfInstances := m.config.OSPFInstances if len(ospfInstances) == 0 { legacy := m.config.OSPFInstance instRouterID := legacy.RouterID if instRouterID == "" { instRouterID = routerID } ospfInstances = []OSPFInstance{ { ID: "ospf-inst-default", Name: "Default", Version: 2, VRF: "main", RouterID: instRouterID, RoutingTable: "main", Enabled: legacy.Enabled, Disabled: legacy.Disabled, RedistributeConnected: legacy.RedistributeConnected, RedistributeStatic: legacy.RedistributeStatic, RedistributeBGP: legacy.RedistributeBGP, RedistributeDefault: legacy.RedistributeDefault, OriginateDefault: legacy.OriginateDefault, DefaultMetric: legacy.DefaultMetric, }, } } for _, inst := range ospfInstances { if inst.Disabled { continue } instRouterID := inst.RouterID if instRouterID == "" { instRouterID = routerID } instProtoName := sanitizeIdentifier("ospf", inst.Name) // Determine Originate Default mode origDefault := strings.ToLower(strings.TrimSpace(inst.OriginateDefault)) if inst.RedistributeDefault && (origDefault == "" || origDefault == "never") { origDefault = "if-installed" } if origDefault == "" { origDefault = "never" } defaultMetric := inst.DefaultMetric if defaultMetric <= 0 { defaultMetric = 10 } defProtoName := "" if origDefault == "always" { defProtoName = fmt.Sprintf("ospf_def_%s", instProtoName) sb.WriteString(fmt.Sprintf("protocol static %s {\n", defProtoName)) sb.WriteString(" ipv4;\n") sb.WriteString(" route 0.0.0.0/0 unreachable;\n") sb.WriteString("}\n\n") } sb.WriteString(fmt.Sprintf("protocol ospf v%d %s {\n", inst.Version, instProtoName)) if inst.VRF != "" && inst.VRF != "main" && inst.VRF != "default" { sb.WriteString(fmt.Sprintf(" vrf %s;\n", inst.VRF)) } sb.WriteString(fmt.Sprintf(" router id %s;\n", instRouterID)) sb.WriteString(" ipv4 {\n") if inst.RoutingTable != "" && inst.RoutingTable != "main" { sb.WriteString(fmt.Sprintf(" table %s;\n", inst.RoutingTable)) } sb.WriteString(" import filter { krt_metric = 110; accept; };\n") var ospfExports []string if inst.RedistributeStatic { ospfExports = append(ospfExports, "RTS_STATIC", "RTS_INHERIT") } if inst.RedistributeConnected { ospfExports = append(ospfExports, "RTS_DEVICE") } if inst.RedistributeBGP { ospfExports = append(ospfExports, "RTS_BGP") } hasDefaultOrig := origDefault == "always" || origDefault == "if-installed" if len(ospfExports) > 0 || hasDefaultOrig { sb.WriteString(" export filter {\n") if origDefault == "always" { sb.WriteString(fmt.Sprintf(" if proto = \"%s\" || (net = 0.0.0.0/0 && source != RTS_OSPF) then {\n", defProtoName)) sb.WriteString(fmt.Sprintf(" ospf_metric1 = %d;\n", defaultMetric)) sb.WriteString(" accept;\n") sb.WriteString(" }\n") } else if origDefault == "if-installed" { sb.WriteString(" if net = 0.0.0.0/0 && source != RTS_OSPF then {\n") sb.WriteString(fmt.Sprintf(" ospf_metric1 = %d;\n", defaultMetric)) sb.WriteString(" accept;\n") sb.WriteString(" }\n") } else { sb.WriteString(" if net = 0.0.0.0/0 then reject;\n") } if len(ospfExports) > 0 { sb.WriteString(fmt.Sprintf(" if source ~ [ %s ] then {\n", strings.Join(ospfExports, ", "))) if inst.DefaultMetric > 0 { sb.WriteString(fmt.Sprintf(" ospf_metric1 = %d;\n", inst.DefaultMetric)) } sb.WriteString(" accept;\n") sb.WriteString(" }\n") } sb.WriteString(" reject;\n") sb.WriteString(" };\n") } 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") } if area.DefaultCost > 0 { sb.WriteString(fmt.Sprintf(" default cost %d;\n", area.DefaultCost)) } if area.NoSummaries { sb.WriteString(" summary no;\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 != "" { var stubnetOpts []string if ar.Cost > 0 { stubnetOpts = append(stubnetOpts, fmt.Sprintf("cost %d;", ar.Cost)) } if !ar.Advertise { stubnetOpts = append(stubnetOpts, "hidden;") } if len(stubnetOpts) > 0 { sb.WriteString(fmt.Sprintf(" stubnet %s { %s };\n", prefix, strings.Join(stubnetOpts, " "))) } else { sb.WriteString(fmt.Sprintf(" stubnet %s;\n", prefix)) } } } } // Collect active static neighbors for this area var areaNbrs []OSPFStaticNeighbor for _, nbr := range m.config.OSPFStaticNbrs { if nbr.Disabled { continue } if strings.EqualFold(nbr.Area, area.Name) || strings.EqualFold(nbr.Area, area.AreaID) || (nbr.Area == "" && (strings.EqualFold(area.Name, "Default") || area.AreaID == "0.0.0.0")) { areaNbrs = append(areaNbrs, nbr) } } usedNbrIDs := make(map[string]bool) // 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) } target := formatInterfaceTargets(ifaceName, netName) 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)) } netType := strings.ToLower(strings.TrimSpace(iface.NetworkType)) if iface.Passive { sb.WriteString(" stub;\n") } else { switch netType { case "ptp", "point-to-point": sb.WriteString(" type ptp;\n") case "nbma", "nonbroadcast": sb.WriteString(" type nonbroadcast;\n") case "ptmp", "point-to-multipoint": sb.WriteString(" type ptmp;\n") default: sb.WriteString(" type bcast;\n") } } auth := strings.ToLower(iface.Auth) if auth == "" { auth = strings.ToLower(iface.AuthType) } if (auth == "md5" || auth == "cryptographic") && 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)) } if iface.UseBFD { sb.WriteString(" bfd yes;\n") } // Find static neighbors matching this interface template var templateNbrs []OSPFStaticNeighbor for _, nbr := range areaNbrs { if usedNbrIDs[nbr.ID] { continue } if netName != "" && ipInSubnets(nbr.Address, netName) { templateNbrs = append(templateNbrs, nbr) usedNbrIDs[nbr.ID] = true } } if len(templateNbrs) == 0 && (strings.Contains(netType, "nbma") || strings.Contains(netType, "nonbroadcast") || strings.Contains(netType, "ptmp")) { for _, nbr := range areaNbrs { if !usedNbrIDs[nbr.ID] { templateNbrs = append(templateNbrs, nbr) usedNbrIDs[nbr.ID] = true } } } if len(templateNbrs) > 0 { for _, nbr := range templateNbrs { if nbr.PollInterval > 0 { sb.WriteString(fmt.Sprintf(" poll %d;\n", nbr.PollInterval)) break } } sb.WriteString(" neighbors {\n") for _, nbr := range templateNbrs { addr := strings.TrimSpace(nbr.Address) if addr != "" { sb.WriteString(fmt.Sprintf(" %s;\n", addr)) } } sb.WriteString(" };\n") } sb.WriteString(" };\n") } // Any remaining static neighbors in this area not matched to a template var remainingNbrs []OSPFStaticNeighbor for _, nbr := range areaNbrs { if !usedNbrIDs[nbr.ID] { remainingNbrs = append(remainingNbrs, nbr) } } if len(remainingNbrs) > 0 { sb.WriteString(" interface \"*\" {\n") sb.WriteString(" type nonbroadcast;\n") for _, nbr := range remainingNbrs { if nbr.PollInterval > 0 { sb.WriteString(fmt.Sprintf(" poll %d;\n", nbr.PollInterval)) break } } sb.WriteString(" neighbors {\n") for _, nbr := range remainingNbrs { addr := strings.TrimSpace(nbr.Address) if addr != "" { sb.WriteString(fmt.Sprintf(" %s;\n", addr)) } } sb.WriteString(" };\n") sb.WriteString(" };\n") } sb.WriteString(" };\n") } sb.WriteString("}\n\n") } // BFD Protocol (/routing bfd) if m.config.BFDInstance.Enabled || len(m.config.BFDInterfaces) > 0 || len(m.config.BFDNeighbors) > 0 { bfdDisabled := m.config.BFDInstance.Disabled sb.WriteString("protocol bfd bfd_main {\n") if bfdDisabled { sb.WriteString(" disabled;\n") } for _, bif := range m.config.BFDInterfaces { if bif.Disabled { continue } ifName := strings.TrimSpace(bif.Interface) if ifName == "" { ifName = "*" } sb.WriteString(fmt.Sprintf(" interface \"%s\" {\n", ifName)) if bif.MinRxInterval > 0 { sb.WriteString(fmt.Sprintf(" min rx interval %d ms;\n", bif.MinRxInterval)) } if bif.MinTxInterval > 0 { sb.WriteString(fmt.Sprintf(" min tx interval %d ms;\n", bif.MinTxInterval)) } if bif.IdleTxInterval > 0 { sb.WriteString(fmt.Sprintf(" idle tx interval %d ms;\n", bif.IdleTxInterval)) } if bif.Multiplier > 0 { sb.WriteString(fmt.Sprintf(" multiplier %d;\n", bif.Multiplier)) } if bif.Passive { sb.WriteString(" passive yes;\n") } if bif.Auth != "" && bif.Auth != "none" && bif.AuthKey != "" { sb.WriteString(fmt.Sprintf(" authentication %s;\n", bif.Auth)) sb.WriteString(fmt.Sprintf(" password \"%s\";\n", bif.AuthKey)) } sb.WriteString(" };\n") } for _, bn := range m.config.BFDNeighbors { if bn.Disabled || strings.TrimSpace(bn.Address) == "" { continue } if bn.Interface != "" { sb.WriteString(fmt.Sprintf(" neighbor %s dev \"%s\";\n", strings.TrimSpace(bn.Address), strings.TrimSpace(bn.Interface))) } else if bn.Multihop { sb.WriteString(fmt.Sprintf(" neighbor %s multihop;\n", strings.TrimSpace(bn.Address))) } else { sb.WriteString(fmt.Sprintf(" neighbor %s;\n", strings.TrimSpace(bn.Address))) } } sb.WriteString("}\n\n") } // RIP Protocols (/routing rip) for idx, inst := range m.config.RIPInstances { if inst.Disabled { continue } instProtoName := sanitizeIdentifier("rip", fmt.Sprintf("%s_%d", inst.Name, idx)) protoKeyword := "protocol rip" channelKeyword := "ipv4" if inst.Version == 1 { protoKeyword = "protocol rip ng" channelKeyword = "ipv6" } sb.WriteString(fmt.Sprintf("%s %s {\n", protoKeyword, instProtoName)) if inst.VRF != "" && inst.VRF != "main" && inst.VRF != "default" { sb.WriteString(fmt.Sprintf(" vrf %s;\n", inst.VRF)) } sb.WriteString(fmt.Sprintf(" %s {\n", channelKeyword)) if inst.RoutingTable != "" && inst.RoutingTable != "main" { sb.WriteString(fmt.Sprintf(" table %s;\n", inst.RoutingTable)) } sb.WriteString(" import filter { krt_metric = 120; accept; };\n") var ripExports []string if inst.RedistributeStatic { ripExports = append(ripExports, "RTS_STATIC", "RTS_INHERIT") } if inst.RedistributeConnected { ripExports = append(ripExports, "RTS_DEVICE") } if inst.RedistributeOSPF { ripExports = append(ripExports, "RTS_OSPF") } if inst.RedistributeBGP { ripExports = append(ripExports, "RTS_BGP") } if len(ripExports) > 0 || inst.RedistributeDefault { sb.WriteString(" export filter {\n") if inst.RedistributeDefault { sb.WriteString(" if net = 0.0.0.0/0 || net = ::/0 then accept;\n") } if len(ripExports) > 0 { sb.WriteString(fmt.Sprintf(" if source ~ [ %s ] then accept;\n", strings.Join(ripExports, ", "))) } sb.WriteString(" reject;\n") sb.WriteString(" };\n") } else { sb.WriteString(" export none;\n") } sb.WriteString(" };\n\n") // Interfaces for this RIP instance for _, iface := range m.config.RIPInterfaces { if iface.Disabled { continue } if iface.Instance != "" && !strings.EqualFold(iface.Instance, inst.Name) && !strings.EqualFold(iface.Instance, "Default") { continue } ifName := strings.TrimSpace(iface.Interface) if ifName == "" { ifName = "*" } sb.WriteString(fmt.Sprintf(" interface \"%s\" {\n", ifName)) if iface.Metric > 0 { sb.WriteString(fmt.Sprintf(" metric %d;\n", iface.Metric)) } if iface.Mode == "broadcast" { sb.WriteString(" mode broadcast;\n") } else { sb.WriteString(" mode multicast;\n") } if inst.Version == 2 || inst.Version == 0 { sb.WriteString(" version 2;\n") } if inst.UpdateTime > 0 { sb.WriteString(fmt.Sprintf(" update time %d;\n", inst.UpdateTime)) } if inst.TimeoutTime > 0 { sb.WriteString(fmt.Sprintf(" timeout time %d;\n", inst.TimeoutTime)) } if iface.Passive { sb.WriteString(" passive yes;\n") } if iface.UseBFD { sb.WriteString(" bfd yes;\n") } if iface.Auth != "" && iface.Auth != "none" && iface.AuthKey != "" { if iface.Auth == "md5" || iface.Auth == "cryptographic" { sb.WriteString(" authentication cryptographic;\n") } else { sb.WriteString(" authentication plaintext;\n") } sb.WriteString(fmt.Sprintf(" password \"%s\";\n", iface.AuthKey)) } sb.WriteString(" };\n") } sb.WriteString("}\n\n") } // Babel Protocols (/routing babel) for idx, inst := range m.config.BabelInstances { if inst.Disabled { continue } instProtoName := sanitizeIdentifier("babel", fmt.Sprintf("%s_%d", inst.Name, idx)) sb.WriteString(fmt.Sprintf("protocol babel %s {\n", instProtoName)) if inst.VRF != "" && inst.VRF != "main" && inst.VRF != "default" { sb.WriteString(fmt.Sprintf(" vrf %s;\n", inst.VRF)) } if inst.RouterID != "" { sb.WriteString(fmt.Sprintf(" router id %s;\n", inst.RouterID)) } sb.WriteString(" ipv4 {\n") if inst.RoutingTable != "" && inst.RoutingTable != "main" { sb.WriteString(fmt.Sprintf(" table %s;\n", inst.RoutingTable)) } sb.WriteString(" import filter { krt_metric = 130; accept; };\n") var babelExports []string if inst.RedistributeStatic { babelExports = append(babelExports, "RTS_STATIC", "RTS_INHERIT") } if inst.RedistributeConnected { babelExports = append(babelExports, "RTS_DEVICE") } if inst.RedistributeOSPF { babelExports = append(babelExports, "RTS_OSPF") } if inst.RedistributeBGP { babelExports = append(babelExports, "RTS_BGP") } if len(babelExports) > 0 || inst.RedistributeDefault { sb.WriteString(" export filter {\n") if inst.RedistributeDefault { sb.WriteString(" if net = 0.0.0.0/0 then accept;\n") } if len(babelExports) > 0 { sb.WriteString(fmt.Sprintf(" if source ~ [ %s ] then accept;\n", strings.Join(babelExports, ", "))) } sb.WriteString(" reject;\n") sb.WriteString(" };\n") } else { sb.WriteString(" export none;\n") } sb.WriteString(" };\n\n") sb.WriteString(" ipv6 {\n") sb.WriteString(" import all;\n") sb.WriteString(" export all;\n") sb.WriteString(" };\n\n") hasIface := false for _, iface := range m.config.BabelInterfaces { if iface.Disabled { continue } if iface.Instance != "" && !strings.EqualFold(iface.Instance, inst.Name) && !strings.EqualFold(iface.Instance, "Default") && !strings.EqualFold(iface.Instance, "*") && !strings.EqualFold(iface.Instance, "all") { continue } hasIface = true ifName := strings.TrimSpace(iface.Interface) if ifName == "" { ifName = "*" } sb.WriteString(fmt.Sprintf(" interface \"%s\" {\n", ifName)) if iface.Type != "" { sb.WriteString(fmt.Sprintf(" type %s;\n", iface.Type)) } else { sb.WriteString(" type wired;\n") } if iface.RxCost > 0 { sb.WriteString(fmt.Sprintf(" rxcost %d;\n", iface.RxCost)) } if iface.HelloInterval > 0 { sb.WriteString(fmt.Sprintf(" hello interval %d s;\n", iface.HelloInterval)) } if iface.UpdateInterval > 0 { sb.WriteString(fmt.Sprintf(" update interval %d s;\n", iface.UpdateInterval)) } if iface.Auth != "" && iface.Auth != "none" && iface.AuthKey != "" { sb.WriteString(" authentication mac;\n") sb.WriteString(fmt.Sprintf(" password \"%s\";\n", iface.AuthKey)) } sb.WriteString(" };\n") } if !hasIface { sb.WriteString(" interface \"*\" {\n") sb.WriteString(" type wired;\n") sb.WriteString(" };\n") } sb.WriteString("}\n\n") } // RAdv Protocols (/routing radv) for idx, inst := range m.config.RAdvInstances { if inst.Disabled { continue } instProtoName := sanitizeIdentifier("radv", fmt.Sprintf("%s_%d", inst.Name, idx)) sb.WriteString(fmt.Sprintf("protocol radv %s {\n", instProtoName)) for _, iface := range m.config.RAdvInterfaces { if iface.Disabled { continue } if iface.Instance != "" && !strings.EqualFold(iface.Instance, inst.Name) && !strings.EqualFold(iface.Instance, "Default") { continue } ifName := strings.TrimSpace(iface.Interface) if ifName == "" { ifName = "*" } sb.WriteString(fmt.Sprintf(" interface \"%s\" {\n", ifName)) if iface.MinRAInterval > 0 { sb.WriteString(fmt.Sprintf(" min ra interval %d;\n", iface.MinRAInterval)) } if iface.MaxRAInterval > 0 { sb.WriteString(fmt.Sprintf(" max ra interval %d;\n", iface.MaxRAInterval)) } if iface.DefaultLifetime > 0 { sb.WriteString(fmt.Sprintf(" default lifetime %d;\n", iface.DefaultLifetime)) } if iface.HopLimit > 0 { sb.WriteString(fmt.Sprintf(" hop limit %d;\n", iface.HopLimit)) } for _, pfx := range iface.Prefixes { pfx = strings.TrimSpace(pfx) if pfx != "" { sb.WriteString(fmt.Sprintf(" prefix %s {\n", pfx)) sb.WriteString(" autonomous 1;\n") sb.WriteString(" onlink 1;\n") sb.WriteString(" };\n") } } if len(iface.RDNSS) > 0 { sb.WriteString(" rdnss {\n") for _, ns := range iface.RDNSS { ns = strings.TrimSpace(ns) if ns != "" { sb.WriteString(fmt.Sprintf(" ns %s;\n", ns)) } } sb.WriteString(" };\n") } sb.WriteString(" };\n") } sb.WriteString("}\n\n") } return sb.String() } // applyBirdConfig generates /etc/bird/bird.conf and live reloads BIRD daemon func (m *Manager) applyBirdConfig() error { cfgText := m.generateBirdConfig() // 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(cfgText), 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) } // Synchronize kernel blackhole routes (distance 255) for all active advertised prefixes m.syncKernelBlackholeRoutes() return nil } func birdcExec(cmd string) ([]byte, error) { return exec.Command("birdc", cmd).CombinedOutput() } // syncKernelBlackholeRoutes ensures all BGP advertised networks (global BGP networks and peer OutputNetworks) // have corresponding blackhole routes with metric 255 installed in the Linux kernel routing table. // This guarantees that BGP can announce these prefixes reliably into the RIB/FIB, while ensuring any active // connected/local routes take precedence (metric 0 < metric 255) and prevents routing loops if subnets are incomplete. func (m *Manager) syncKernelBlackholeRoutes() { desired := make(map[string]bool) if m.config.BGPInstance.Enabled { for _, n := range m.config.BGPNetworks { cidr := strings.TrimSpace(n.Network) if !n.Disabled && cidr != "" { if _, _, err := net.ParseCIDR(cidr); err == nil { desired[cidr] = true } } } for _, p := range m.config.BGPPeers { if p.Disabled { continue } for _, on := range p.OutputNetworks { cidr := strings.TrimSpace(on) if cidr != "" { if _, _, err := net.ParseCIDR(cidr); err == nil { desired[cidr] = true } } } } } // Query existing kernel blackhole routes with metric 255 out, err := exec.Command("ip", "route", "show", "type", "blackhole").Output() existing := make(map[string]bool) if err == nil { lines := strings.Split(string(out), "\n") for _, line := range lines { line = strings.TrimSpace(line) if line == "" { continue } parts := strings.Fields(line) if len(parts) >= 2 && parts[0] == "blackhole" { dst := parts[1] if strings.Contains(line, "metric 255") { existing[dst] = true } } } } // Install desired blackhole routes with metric 255 proto static for cidr := range desired { if !existing[cidr] { cmd := exec.Command("ip", "route", "replace", "blackhole", cidr, "metric", "255", "proto", "static") if out, err := cmd.CombinedOutput(); err != nil { log.Printf("[DynamicRouting] Warning: failed to install kernel blackhole route %s: %s (%v)", cidr, string(out), err) } else { log.Printf("[DynamicRouting] Installed kernel blackhole route %s with metric 255 (BGP advertised)", cidr) } } } // Cleanup any obsolete blackhole routes that were previously installed with metric 255 // Check static_routes.json first to ensure we don't delete user's manual static routes userManualStaticBlackholes := make(map[string]bool) if data, err := os.ReadFile("/etc/debtik/routing/static_routes.json"); err == nil { var userRoutes []struct { DstAddress string `json:"dst_address"` Gateway string `json:"gateway"` Action string `json:"action"` Disabled bool `json:"disabled"` } if json.Unmarshal(data, &userRoutes) == nil { for _, ur := range userRoutes { if !ur.Disabled && (strings.EqualFold(ur.Gateway, "blackhole") || strings.EqualFold(ur.Action, "blackhole")) { userManualStaticBlackholes[strings.TrimSpace(ur.DstAddress)] = true } } } } for cidr := range existing { if !desired[cidr] && !userManualStaticBlackholes[cidr] { _ = exec.Command("ip", "route", "del", "blackhole", cidr, "metric", "255").Run() log.Printf("[DynamicRouting] Removed obsolete kernel blackhole route %s metric 255", cidr) } } } // ------------------------------------------------------------- // 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 now := time.Now() for _, line := range lines { trimmed := strings.TrimSpace(line) // A protocol header line in BIRD is unindented and has "BGP" in column 2 if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") { fields := strings.Fields(line) if len(fields) >= 2 && fields[1] == "BGP" { cur = &livePeerData{} var sinceStr string if len(fields) >= 6 && strings.Contains(fields[4], "-") && strings.Contains(fields[5], ":") { sinceStr = fields[4] + " " + fields[5] } else if len(fields) >= 5 { sinceStr = fields[4] } cur.Uptime = parseBirdSinceToUptime(sinceStr, now) } else { cur = nil } } 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 if live.State == "Established" { peers[i].Uptime = live.Uptime } else { peers[i].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" peers[i].Uptime = "" } else { peers[i].State = "Idle" peers[i].Uptime = "" } } } return peers } func parseBirdSinceToUptime(sinceStr string, now time.Time) string { sinceStr = strings.TrimSpace(sinceStr) if sinceStr == "" { return "" } // Case 1: "2006-01-02 15:04:05" (ISO long) if t, err := time.ParseInLocation("2006-01-02 15:04:05", sinceStr, time.Local); err == nil { return formatDuration(now.Sub(t)) } // Case 2: "2006-01-02 15:04:05.999" (ISO long with milliseconds) if t, err := time.ParseInLocation("2006-01-02 15:04:05.999", sinceStr, time.Local); err == nil { return formatDuration(now.Sub(t)) } // Case 3: "2006-01-02" (date only) if t, err := time.ParseInLocation("2006-01-02", sinceStr, time.Local); err == nil { return formatDuration(now.Sub(t)) } // Case 4: "15:04:05" or "15:04:05.123" (time only, happened today) timeOnly := sinceStr if idx := strings.IndexByte(timeOnly, '.'); idx != -1 { timeOnly = timeOnly[:idx] } if t, err := time.ParseInLocation("15:04:05", timeOnly, time.Local); err == nil { tToday := time.Date(now.Year(), now.Month(), now.Day(), t.Hour(), t.Minute(), t.Second(), 0, time.Local) if tToday.After(now) { tToday = tToday.AddDate(0, 0, -1) } return formatDuration(now.Sub(tToday)) } return "" } func formatDuration(d time.Duration) string { if d < 0 { d = 0 } sec := int64(d.Seconds()) days := sec / 86400 hours := (sec % 86400) / 3600 mins := (sec % 3600) / 60 secs := sec % 60 if days > 0 { return fmt.Sprintf("%dd %02dh %02dm %02ds", days, hours, mins, secs) } return fmt.Sprintf("%02dh %02dm %02ds", hours, mins, secs) } 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) } if err := m.save(); err != nil { return err } return m.applyBirdConfig() } 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 if err := m.save(); err != nil { return err } return m.applyBirdConfig() } func (m *Manager) SetOSPFStaticNeighborState(id string, disabled bool) error { m.mu.Lock() defer m.mu.Unlock() for i := range m.config.OSPFStaticNbrs { if m.config.OSPFStaticNbrs[i].ID == id { m.config.OSPFStaticNbrs[i].Disabled = disabled if err := m.save(); err != nil { return err } return m.applyBirdConfig() } } return fmt.Errorf("static neighbor %s not found", id) } 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 // 0. Convert double-equals (==) to single-equals (=) for BIRD 2, preserving !=, <=, >=, ~= reDoubleEq := regexp.MustCompile(`([^!<>~=])\s*==\s*([^=])`) for reDoubleEq.MatchString(s) { s = reDoubleEq.ReplaceAllString(s, "$1 = $2") } // 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 or dst == A.B.C.D/M -> net = A.B.C.D/M reDstEq := regexp.MustCompile(`\bdst\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(`\bdst\s*!=\s*([0-9\.\/]+)`) s = reDstNeq.ReplaceAllString(s, "net != $1") // 5. Convert dst-len -> net.len s = regexp.MustCompile(`\bdst-len\b`).ReplaceAllString(s, "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;") // Convert passthrough keyword to no-op s = regexp.MustCompile(`\bpassthrough(?:\s*;)?`).ReplaceAllString(s, "") // 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 if r.Action == "passthrough" { // Passthrough: do not emit terminating accept or reject } else { sb.WriteString(" accept;\n") } sb.WriteString("}") return sb.String() } // ------------------------------------------------------------- // 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() } // ------------------------------------------------------------- // RIP Methods (/routing rip) // ------------------------------------------------------------- func (m *Manager) ListRIPInstances() []RIPInstance { m.mu.RLock() defer m.mu.RUnlock() out, err := birdcExec("show status") birdRunning := err == nil && strings.Contains(string(out), "Daemon is up and running") res := make([]RIPInstance, len(m.config.RIPInstances)) copy(res, m.config.RIPInstances) for i := range res { res[i].Running = birdRunning && res[i].Enabled && !res[i].Disabled } return res } func (m *Manager) SaveRIPInstance(inst RIPInstance) (*RIPInstance, error) { m.mu.Lock() defer m.mu.Unlock() if inst.ID == "" { inst.ID = fmt.Sprintf("rip-inst-%d", time.Now().UnixNano()) } if inst.Name == "" { inst.Name = "default" } if inst.Version == 0 { inst.Version = 2 } if inst.UpdateTime == 0 { inst.UpdateTime = 30 } if inst.TimeoutTime == 0 { inst.TimeoutTime = 180 } if inst.GarbageTime == 0 { inst.GarbageTime = 120 } found := false for i, existing := range m.config.RIPInstances { if existing.ID == inst.ID || strings.EqualFold(existing.Name, inst.Name) { m.config.RIPInstances[i] = inst found = true break } } if !found { m.config.RIPInstances = append(m.config.RIPInstances, inst) } if err := m.save(); err != nil { return nil, err } if err := m.applyBirdConfig(); err != nil { return nil, err } return &inst, nil } func (m *Manager) DeleteRIPInstance(id string) error { m.mu.Lock() defer m.mu.Unlock() newInsts := make([]RIPInstance, 0, len(m.config.RIPInstances)) for _, inst := range m.config.RIPInstances { if inst.ID != id && inst.Name != id { newInsts = append(newInsts, inst) } } m.config.RIPInstances = newInsts if err := m.save(); err != nil { return err } return m.applyBirdConfig() } func (m *Manager) SetRIPInstanceState(id string, disabled bool) error { m.mu.Lock() defer m.mu.Unlock() for i := range m.config.RIPInstances { if m.config.RIPInstances[i].ID == id || m.config.RIPInstances[i].Name == id { m.config.RIPInstances[i].Disabled = disabled m.config.RIPInstances[i].Enabled = !disabled break } } if err := m.save(); err != nil { return err } return m.applyBirdConfig() } func (m *Manager) ListRIPInterfaces() []RIPInterface { m.mu.RLock() defer m.mu.RUnlock() res := make([]RIPInterface, len(m.config.RIPInterfaces)) copy(res, m.config.RIPInterfaces) return res } func (m *Manager) SaveRIPInterface(iface RIPInterface) (*RIPInterface, error) { m.mu.Lock() defer m.mu.Unlock() if iface.ID == "" { iface.ID = fmt.Sprintf("rip-iface-%d", time.Now().UnixNano()) } if iface.Interface == "" { iface.Interface = "*" } if iface.Metric == 0 { iface.Metric = 1 } found := false for i, existing := range m.config.RIPInterfaces { if existing.ID == iface.ID { m.config.RIPInterfaces[i] = iface found = true break } } if !found { m.config.RIPInterfaces = append(m.config.RIPInterfaces, iface) } if err := m.save(); err != nil { return nil, err } if err := m.applyBirdConfig(); err != nil { return nil, err } return &iface, nil } func (m *Manager) DeleteRIPInterface(id string) error { m.mu.Lock() defer m.mu.Unlock() newIfaces := make([]RIPInterface, 0, len(m.config.RIPInterfaces)) for _, iface := range m.config.RIPInterfaces { if iface.ID != id && iface.Interface != id { newIfaces = append(newIfaces, iface) } } m.config.RIPInterfaces = newIfaces if err := m.save(); err != nil { return err } return m.applyBirdConfig() } func (m *Manager) SetRIPInterfaceState(id string, disabled bool) error { m.mu.Lock() defer m.mu.Unlock() for i := range m.config.RIPInterfaces { if m.config.RIPInterfaces[i].ID == id || m.config.RIPInterfaces[i].Interface == id { m.config.RIPInterfaces[i].Disabled = disabled break } } if err := m.save(); err != nil { return err } return m.applyBirdConfig() } func (m *Manager) ListRIPNeighbors() []RIPNeighbor { m.mu.RLock() defer m.mu.RUnlock() res := make([]RIPNeighbor, len(m.config.RIPNeighbors)) copy(res, m.config.RIPNeighbors) return res } func (m *Manager) SaveRIPNeighbor(nbr RIPNeighbor) (*RIPNeighbor, error) { m.mu.Lock() defer m.mu.Unlock() if nbr.ID == "" { nbr.ID = fmt.Sprintf("rip-nbr-%d", time.Now().UnixNano()) } found := false for i, existing := range m.config.RIPNeighbors { if existing.ID == nbr.ID || existing.Address == nbr.Address { m.config.RIPNeighbors[i] = nbr found = true break } } if !found { m.config.RIPNeighbors = append(m.config.RIPNeighbors, nbr) } if err := m.save(); err != nil { return nil, err } if err := m.applyBirdConfig(); err != nil { return nil, err } return &nbr, nil } func (m *Manager) DeleteRIPNeighbor(id string) error { m.mu.Lock() defer m.mu.Unlock() newNbrs := make([]RIPNeighbor, 0, len(m.config.RIPNeighbors)) for _, nbr := range m.config.RIPNeighbors { if nbr.ID != id && nbr.Address != id { newNbrs = append(newNbrs, nbr) } } m.config.RIPNeighbors = newNbrs if err := m.save(); err != nil { return err } return m.applyBirdConfig() } func (m *Manager) GetRIPNeighbors() []RIPNeighborLive { var list []RIPNeighborLive out, err := birdcExec("show rip neighbors") if err != nil { return list } lines := strings.Split(string(out), "\n") for _, line := range lines { trimmed := strings.TrimSpace(line) if trimmed == "" || strings.HasPrefix(trimmed, "BIRD") || strings.HasPrefix(trimmed, "IP address") || strings.Contains(trimmed, "There is no RIP") { continue } fields := strings.Fields(trimmed) if len(fields) >= 2 { nbr := RIPNeighborLive{ Address: fields[0], Interface: fields[1], } if len(fields) >= 3 { nbr.LastSeen = fields[len(fields)-1] } list = append(list, nbr) } } return list } func (m *Manager) GetRIPRoutes() []RIPRoute { var routes []RIPRoute out, err := birdcExec("show route where source = RTS_RIP all") if err != nil { return routes } lines := strings.Split(string(out), "\n") var cur *RIPRoute 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, RIPRoute{ Prefix: fields[0], }) cur = &routes[len(routes)-1] for i := 1; i < len(fields); i++ { if fields[i] == "via" && i+1 < len(fields) { cur.NextHop = fields[i+1] } if fields[i] == "dev" && i+1 < len(fields) { cur.Interface = fields[i+1] } } } } else if cur != nil { if strings.Contains(trimmed, "rip_metric:") { parts := strings.Split(trimmed, ":") if len(parts) >= 2 { var mVal int fmt.Sscanf(strings.TrimSpace(parts[1]), "%d", &mVal) cur.Metric = mVal } } } } return routes } // ------------------------------------------------------------- // BFD Methods (/routing bfd) // ------------------------------------------------------------- func (m *Manager) GetBFDInstance() BFDInstance { m.mu.RLock() defer m.mu.RUnlock() inst := m.config.BFDInstance out, err := birdcExec("show status") inst.Running = err == nil && strings.Contains(string(out), "Daemon is up and running") && inst.Enabled && !inst.Disabled return inst } func (m *Manager) SaveBFDInstance(inst BFDInstance) error { m.mu.Lock() defer m.mu.Unlock() m.config.BFDInstance = inst if err := m.save(); err != nil { return err } return m.applyBirdConfig() } func (m *Manager) ListBFDInterfaces() []BFDInterface { m.mu.RLock() defer m.mu.RUnlock() res := make([]BFDInterface, len(m.config.BFDInterfaces)) copy(res, m.config.BFDInterfaces) return res } func (m *Manager) SaveBFDInterface(iface BFDInterface) (*BFDInterface, error) { m.mu.Lock() defer m.mu.Unlock() if iface.ID == "" { iface.ID = fmt.Sprintf("bfd-iface-%d", time.Now().UnixNano()) } if iface.Interface == "" { iface.Interface = "*" } if iface.MinRxInterval == 0 { iface.MinRxInterval = 100 } if iface.MinTxInterval == 0 { iface.MinTxInterval = 100 } if iface.Multiplier == 0 { iface.Multiplier = 3 } found := false for i, existing := range m.config.BFDInterfaces { if existing.ID == iface.ID { m.config.BFDInterfaces[i] = iface found = true break } } if !found { m.config.BFDInterfaces = append(m.config.BFDInterfaces, iface) } if err := m.save(); err != nil { return nil, err } if err := m.applyBirdConfig(); err != nil { return nil, err } return &iface, nil } func (m *Manager) DeleteBFDInterface(id string) error { m.mu.Lock() defer m.mu.Unlock() newIfaces := make([]BFDInterface, 0, len(m.config.BFDInterfaces)) for _, iface := range m.config.BFDInterfaces { if iface.ID != id && iface.Interface != id { newIfaces = append(newIfaces, iface) } } m.config.BFDInterfaces = newIfaces if err := m.save(); err != nil { return err } return m.applyBirdConfig() } func (m *Manager) SetBFDInterfaceState(id string, disabled bool) error { m.mu.Lock() defer m.mu.Unlock() for i := range m.config.BFDInterfaces { if m.config.BFDInterfaces[i].ID == id || m.config.BFDInterfaces[i].Interface == id { m.config.BFDInterfaces[i].Disabled = disabled break } } if err := m.save(); err != nil { return err } return m.applyBirdConfig() } func (m *Manager) ListBFDNeighbors() []BFDNeighbor { m.mu.RLock() defer m.mu.RUnlock() res := make([]BFDNeighbor, len(m.config.BFDNeighbors)) copy(res, m.config.BFDNeighbors) return res } func (m *Manager) SaveBFDNeighbor(nbr BFDNeighbor) (*BFDNeighbor, error) { m.mu.Lock() defer m.mu.Unlock() if nbr.ID == "" { nbr.ID = fmt.Sprintf("bfd-nbr-%d", time.Now().UnixNano()) } found := false for i, existing := range m.config.BFDNeighbors { if existing.ID == nbr.ID || existing.Address == nbr.Address { m.config.BFDNeighbors[i] = nbr found = true break } } if !found { m.config.BFDNeighbors = append(m.config.BFDNeighbors, nbr) } if err := m.save(); err != nil { return nil, err } if err := m.applyBirdConfig(); err != nil { return nil, err } return &nbr, nil } func (m *Manager) DeleteBFDNeighbor(id string) error { m.mu.Lock() defer m.mu.Unlock() newNbrs := make([]BFDNeighbor, 0, len(m.config.BFDNeighbors)) for _, nbr := range m.config.BFDNeighbors { if nbr.ID != id && nbr.Address != id { newNbrs = append(newNbrs, nbr) } } m.config.BFDNeighbors = newNbrs if err := m.save(); err != nil { return err } return m.applyBirdConfig() } func (m *Manager) GetBFDSessions() []BFDSession { var list []BFDSession out, err := birdcExec("show bfd sessions") if err != nil { return list } lines := strings.Split(string(out), "\n") for _, line := range lines { trimmed := strings.TrimSpace(line) if trimmed == "" || strings.HasPrefix(trimmed, "BIRD") || strings.HasPrefix(trimmed, "IP address") || strings.Contains(trimmed, "There is no BFD") { continue } fields := strings.Fields(trimmed) if len(fields) >= 3 { sess := BFDSession{ IP: fields[0], Interface: fields[1], State: fields[2], } if len(fields) >= 4 { sess.Since = fields[3] } if len(fields) >= 5 { sess.Interval = fields[4] } if len(fields) >= 6 { sess.Timeout = fields[5] } list = append(list, sess) } } return list } // ------------------------------------------------------------- // Babel Methods (/routing babel) // ------------------------------------------------------------- func (m *Manager) ListBabelInstances() []BabelInstance { m.mu.RLock() defer m.mu.RUnlock() out, err := birdcExec("show status") birdRunning := err == nil && strings.Contains(string(out), "Daemon is up and running") res := make([]BabelInstance, len(m.config.BabelInstances)) copy(res, m.config.BabelInstances) for i := range res { res[i].Running = birdRunning && (res[i].Enabled || !res[i].Disabled) && !res[i].Disabled } return res } func (m *Manager) SaveBabelInstance(inst BabelInstance) (*BabelInstance, error) { m.mu.Lock() defer m.mu.Unlock() if inst.ID == "" { inst.ID = fmt.Sprintf("babel-inst-%d", time.Now().UnixNano()) } if inst.Name == "" { inst.Name = "default" } if !inst.Disabled && !inst.Enabled { inst.Enabled = true } if inst.Disabled { inst.Enabled = false } found := false for i, existing := range m.config.BabelInstances { if existing.ID == inst.ID || strings.EqualFold(existing.Name, inst.Name) { m.config.BabelInstances[i] = inst found = true break } } if !found { m.config.BabelInstances = append(m.config.BabelInstances, inst) } if err := m.save(); err != nil { return nil, err } if err := m.applyBirdConfig(); err != nil { return nil, err } return &inst, nil } func (m *Manager) DeleteBabelInstance(id string) error { m.mu.Lock() defer m.mu.Unlock() newInsts := make([]BabelInstance, 0, len(m.config.BabelInstances)) for _, inst := range m.config.BabelInstances { if inst.ID != id && inst.Name != id { newInsts = append(newInsts, inst) } } m.config.BabelInstances = newInsts if err := m.save(); err != nil { return err } return m.applyBirdConfig() } func (m *Manager) SetBabelInstanceState(id string, disabled bool) error { m.mu.Lock() defer m.mu.Unlock() for i := range m.config.BabelInstances { if m.config.BabelInstances[i].ID == id || m.config.BabelInstances[i].Name == id { m.config.BabelInstances[i].Disabled = disabled m.config.BabelInstances[i].Enabled = !disabled break } } if err := m.save(); err != nil { return err } return m.applyBirdConfig() } func (m *Manager) ListBabelInterfaces() []BabelInterface { m.mu.RLock() defer m.mu.RUnlock() res := make([]BabelInterface, len(m.config.BabelInterfaces)) copy(res, m.config.BabelInterfaces) return res } func (m *Manager) SaveBabelInterface(iface BabelInterface) (*BabelInterface, error) { m.mu.Lock() defer m.mu.Unlock() if iface.ID == "" { iface.ID = fmt.Sprintf("babel-iface-%d", time.Now().UnixNano()) } if iface.Interface == "" { iface.Interface = "*" } if iface.Instance == "" { if len(m.config.BabelInstances) > 0 { iface.Instance = m.config.BabelInstances[0].Name } else { iface.Instance = "default" } } if iface.Type == "" { iface.Type = "wired" } if iface.RxCost == 0 { iface.RxCost = 10 } if iface.HelloInterval == 0 { iface.HelloInterval = 4 } if iface.UpdateInterval == 0 { iface.UpdateInterval = 16 } found := false for i, existing := range m.config.BabelInterfaces { if existing.ID == iface.ID { m.config.BabelInterfaces[i] = iface found = true break } } if !found { m.config.BabelInterfaces = append(m.config.BabelInterfaces, iface) } if err := m.save(); err != nil { return nil, err } if err := m.applyBirdConfig(); err != nil { return nil, err } return &iface, nil } func (m *Manager) DeleteBabelInterface(id string) error { m.mu.Lock() defer m.mu.Unlock() newIfaces := make([]BabelInterface, 0, len(m.config.BabelInterfaces)) for _, iface := range m.config.BabelInterfaces { if iface.ID != id && iface.Interface != id { newIfaces = append(newIfaces, iface) } } m.config.BabelInterfaces = newIfaces if err := m.save(); err != nil { return err } return m.applyBirdConfig() } func (m *Manager) SetBabelInterfaceState(id string, disabled bool) error { m.mu.Lock() defer m.mu.Unlock() for i := range m.config.BabelInterfaces { if m.config.BabelInterfaces[i].ID == id || m.config.BabelInterfaces[i].Interface == id { m.config.BabelInterfaces[i].Disabled = disabled break } } if err := m.save(); err != nil { return err } return m.applyBirdConfig() } func (m *Manager) resolveBabelInstanceName(protoName string) string { protoName = strings.TrimSuffix(protoName, ":") for _, inst := range m.config.BabelInstances { if strings.Contains(protoName, inst.Name) { return inst.Name } } name := strings.TrimPrefix(protoName, "babel_") if idx := strings.LastIndex(name, "_"); idx > 0 { name = name[:idx] } if name == "" { name = "default" } return name } func (m *Manager) GetBabelInterfacesLive() []BabelInterfaceLive { m.mu.RLock() defer m.mu.RUnlock() var list []BabelInterfaceLive out, err := birdcExec("show babel interfaces") if err != nil { return list } lines := strings.Split(string(out), "\n") currentProto := "" for _, line := range lines { trimmed := strings.TrimSpace(line) if trimmed == "" || strings.HasPrefix(trimmed, "BIRD") || strings.HasPrefix(trimmed, "Interface ") || strings.Contains(trimmed, "There is no Babel") { continue } if strings.HasSuffix(trimmed, ":") { currentProto = strings.TrimSuffix(trimmed, ":") continue } fields := strings.Fields(trimmed) if len(fields) >= 6 { rxCost := 0 fmt.Sscanf(fields[3], "%d", &rxCost) nbrs := 0 fmt.Sscanf(fields[4], "%d", &nbrs) nextHopV4 := "" nextHopV6 := "" if len(fields) >= 7 { nextHopV4 = fields[6] } if len(fields) >= 8 { nextHopV6 = fields[7] } instName := m.resolveBabelInstanceName(currentProto) list = append(list, BabelInterfaceLive{ Instance: instName, Interface: fields[0], State: fields[1], Auth: fields[2], RxCost: rxCost, Neighbors: nbrs, Timer: fields[5], NextHopV4: nextHopV4, NextHopV6: nextHopV6, }) } } return list } func (m *Manager) GetBabelNeighbors() []BabelNeighbor { var list []BabelNeighbor out, err := birdcExec("show babel neighbors") if err != nil { return list } lines := strings.Split(string(out), "\n") for _, line := range lines { trimmed := strings.TrimSpace(line) if trimmed == "" || strings.HasPrefix(trimmed, "BIRD") || strings.HasPrefix(trimmed, "IP address") || strings.Contains(trimmed, "There is no Babel") || strings.HasSuffix(trimmed, ":") { continue } fields := strings.Fields(trimmed) if len(fields) >= 2 { nbr := BabelNeighbor{ IP: fields[0], Interface: fields[1], } if len(fields) >= 3 { fmt.Sscanf(fields[2], "%d", &nbr.Metric) } if len(fields) >= 4 { fmt.Sscanf(fields[3], "%d", &nbr.Routes) } if len(fields) >= 5 { fmt.Sscanf(fields[4], "%d", &nbr.Hellos) } if len(fields) >= 6 { nbr.Expires = fields[5] } if len(fields) >= 7 { nbr.Auth = fields[6] } if len(fields) >= 8 { fmt.Sscanf(fields[7], "%f", &nbr.RTT) } list = append(list, nbr) } } return list } func (m *Manager) GetBabelRoutes() []BabelRoute { var routes []BabelRoute out, err := birdcExec("show babel routes") if err == nil { lines := strings.Split(string(out), "\n") for _, line := range lines { trimmed := strings.TrimSpace(line) if trimmed == "" || strings.HasPrefix(trimmed, "BIRD") || strings.HasPrefix(trimmed, "Prefix") || strings.Contains(trimmed, "There is no Babel") || strings.HasSuffix(trimmed, ":") { continue } fields := strings.Fields(trimmed) if len(fields) >= 4 { metric := 0 fmt.Sscanf(fields[3], "%d", &metric) flag := "" seqno := 0 expires := "" if len(fields) >= 5 { flag = fields[4] } if len(fields) >= 6 { fmt.Sscanf(fields[5], "%d", &seqno) } if len(fields) >= 7 { expires = fields[6] } routes = append(routes, BabelRoute{ Prefix: fields[0], NextHop: fields[1], Interface: fields[2], Metric: metric, Flag: flag, Seqno: seqno, Expires: expires, }) } } } if len(routes) == 0 { outK, errK := birdcExec("show route where source = RTS_BABEL all") if errK == nil { lines := strings.Split(string(outK), "\n") 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 { rt := BabelRoute{ Prefix: fields[0], } for i := 1; i < len(fields); i++ { if fields[i] == "via" && i+1 < len(fields) { rt.NextHop = fields[i+1] } if fields[i] == "dev" && i+1 < len(fields) { rt.Interface = fields[i+1] } } routes = append(routes, rt) } } } } } return routes } // ------------------------------------------------------------- // RAdv Methods (/routing radv) // ------------------------------------------------------------- func (m *Manager) ListRAdvInstances() []RAdvInstance { m.mu.RLock() defer m.mu.RUnlock() out, err := birdcExec("show status") birdRunning := err == nil && strings.Contains(string(out), "Daemon is up and running") res := make([]RAdvInstance, len(m.config.RAdvInstances)) copy(res, m.config.RAdvInstances) for i := range res { res[i].Running = birdRunning && res[i].Enabled && !res[i].Disabled } return res } func (m *Manager) SaveRAdvInstance(inst RAdvInstance) (*RAdvInstance, error) { m.mu.Lock() defer m.mu.Unlock() if inst.ID == "" { inst.ID = fmt.Sprintf("radv-inst-%d", time.Now().UnixNano()) } if inst.Name == "" { inst.Name = "default" } found := false for i, existing := range m.config.RAdvInstances { if existing.ID == inst.ID || strings.EqualFold(existing.Name, inst.Name) { m.config.RAdvInstances[i] = inst found = true break } } if !found { m.config.RAdvInstances = append(m.config.RAdvInstances, inst) } if err := m.save(); err != nil { return nil, err } if err := m.applyBirdConfig(); err != nil { return nil, err } return &inst, nil } func (m *Manager) DeleteRAdvInstance(id string) error { m.mu.Lock() defer m.mu.Unlock() newInsts := make([]RAdvInstance, 0, len(m.config.RAdvInstances)) for _, inst := range m.config.RAdvInstances { if inst.ID != id && inst.Name != id { newInsts = append(newInsts, inst) } } m.config.RAdvInstances = newInsts if err := m.save(); err != nil { return err } return m.applyBirdConfig() } func (m *Manager) SetRAdvInstanceState(id string, disabled bool) error { m.mu.Lock() defer m.mu.Unlock() for i := range m.config.RAdvInstances { if m.config.RAdvInstances[i].ID == id || m.config.RAdvInstances[i].Name == id { m.config.RAdvInstances[i].Disabled = disabled m.config.RAdvInstances[i].Enabled = !disabled break } } if err := m.save(); err != nil { return err } return m.applyBirdConfig() } func (m *Manager) ListRAdvInterfaces() []RAdvInterface { m.mu.RLock() defer m.mu.RUnlock() res := make([]RAdvInterface, len(m.config.RAdvInterfaces)) copy(res, m.config.RAdvInterfaces) return res } func (m *Manager) SaveRAdvInterface(iface RAdvInterface) (*RAdvInterface, error) { m.mu.Lock() defer m.mu.Unlock() if iface.ID == "" { iface.ID = fmt.Sprintf("radv-iface-%d", time.Now().UnixNano()) } if iface.Interface == "" { iface.Interface = "*" } if iface.MinRAInterval == 0 { iface.MinRAInterval = 200 } if iface.MaxRAInterval == 0 { iface.MaxRAInterval = 600 } if iface.DefaultLifetime == 0 { iface.DefaultLifetime = 1800 } found := false for i, existing := range m.config.RAdvInterfaces { if existing.ID == iface.ID { m.config.RAdvInterfaces[i] = iface found = true break } } if !found { m.config.RAdvInterfaces = append(m.config.RAdvInterfaces, iface) } if err := m.save(); err != nil { return nil, err } if err := m.applyBirdConfig(); err != nil { return nil, err } return &iface, nil } func (m *Manager) DeleteRAdvInterface(id string) error { m.mu.Lock() defer m.mu.Unlock() newIfaces := make([]RAdvInterface, 0, len(m.config.RAdvInterfaces)) for _, iface := range m.config.RAdvInterfaces { if iface.ID != id && iface.Interface != id { newIfaces = append(newIfaces, iface) } } m.config.RAdvInterfaces = newIfaces if err := m.save(); err != nil { return err } return m.applyBirdConfig() } func (m *Manager) SetRAdvInterfaceState(id string, disabled bool) error { m.mu.Lock() defer m.mu.Unlock() for i := range m.config.RAdvInterfaces { if m.config.RAdvInterfaces[i].ID == id || m.config.RAdvInterfaces[i].Interface == id { m.config.RAdvInterfaces[i].Disabled = disabled break } } if err := m.save(); err != nil { return err } 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 } for _, inst := range m.config.RIPInstances { if inst.Enabled && !inst.Disabled { summary.RIPEnabled = true break } } if summary.RIPEnabled && birdRunning { summary.RIPRunning = true nbrs := m.GetRIPNeighbors() summary.RIPNeighborCount = len(nbrs) } if m.config.BFDInstance.Enabled && !m.config.BFDInstance.Disabled { summary.BFDEnabled = true } if summary.BFDEnabled && birdRunning { summary.BFDRunning = true sessions := m.GetBFDSessions() summary.BFDSessionCount = len(sessions) } for _, inst := range m.config.BabelInstances { if (inst.Enabled || !inst.Disabled) && !inst.Disabled { summary.BabelEnabled = true break } } if summary.BabelEnabled && birdRunning { summary.BabelRunning = true nbrs := m.GetBabelNeighbors() summary.BabelNeighborCount = len(nbrs) } for _, inst := range m.config.RAdvInstances { if inst.Enabled && !inst.Disabled { summary.RAdvEnabled = true break } } if summary.RAdvEnabled && birdRunning { summary.RAdvRunning = true } return summary }