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{}, 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 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_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") // 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") 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)) } // 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") } 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() } // ------------------------------------------------------------- // Summary Telemetry // ------------------------------------------------------------- func (m *Manager) GetSummary() DynamicRoutingSummary { m.mu.RLock() defer m.mu.RUnlock() summary := DynamicRoutingSummary{ BGPEnabled: m.config.BGPInstance.Enabled, OSPFEnabled: m.config.OSPFInstance.Enabled, BGPPeerCount: len(m.config.BGPPeers), RoutingFilterCount: len(m.config.RoutingFilters), } out, err := birdcExec("show status") birdRunning := err == nil && strings.Contains(string(out), "Daemon is up and running") if birdRunning && summary.BGPEnabled { summary.BGPRunning = true peers := m.ListBGPPeers() est := 0 for _, p := range peers { if strings.EqualFold(p.State, "Established") || strings.EqualFold(p.State, "Up") { est++ } } summary.BGPEstablishedCount = est } if birdRunning && summary.OSPFEnabled { summary.OSPFRunning = true nbrs := m.GetOSPFNeighbors() summary.OSPFNeighborCount = len(nbrs) adj := 0 for _, n := range nbrs { if strings.Contains(strings.ToLower(n.State), "full") { adj++ } } summary.OSPFAdjacentCount = adj } return summary }