package routing import ( "bufio" "encoding/json" "fmt" "log" "net" "os" "os/exec" "path/filepath" "sort" "strconv" "strings" "sync" "time" "github.com/vishvananda/netlink" ) const ( StaticRoutesStorePath = "/etc/debtik/routing/static_routes.json" PolicyRulesStorePath = "/etc/debtik/routing/policy_rules.json" CustomTablesStorePath = "/etc/debtik/routing/custom_tables.json" RTTableFilePath = "/etc/iproute2/rt_tables" ) type RoutingManager struct { mu sync.Mutex staticRoutes []RouteItem policyRules []PolicyRuleItem customTables []RoutingTableItem stopChan chan struct{} } func NewRoutingManager() *RoutingManager { m := &RoutingManager{ staticRoutes: make([]RouteItem, 0), policyRules: make([]PolicyRuleItem, 0), customTables: make([]RoutingTableItem, 0), stopChan: make(chan struct{}), } m.initRTTableFile() m.load() m.syncCustomTablesToSystem() m.applyAll() m.startHealthCheckWorker() m.startRouteSyncWorker() return m } func (m *RoutingManager) initRTTableFile() { _ = os.MkdirAll(filepath.Dir(RTTableFilePath), 0755) if _, err := os.Stat(RTTableFilePath); os.IsNotExist(err) { // Copy from /usr/share/iproute2/rt_tables or seed default content, err := os.ReadFile("/usr/share/iproute2/rt_tables") if err == nil { _ = os.WriteFile(RTTableFilePath, content, 0644) } else { defaultContent := "# reserved values\n255\tlocal\n254\tmain\n253\tdefault\n0\tunspec\n" _ = os.WriteFile(RTTableFilePath, []byte(defaultContent), 0644) } } } func (m *RoutingManager) load() { if data, err := os.ReadFile(StaticRoutesStorePath); err == nil { _ = json.Unmarshal(data, &m.staticRoutes) } if data, err := os.ReadFile(PolicyRulesStorePath); err == nil { _ = json.Unmarshal(data, &m.policyRules) } if data, err := os.ReadFile(CustomTablesStorePath); err == nil { _ = json.Unmarshal(data, &m.customTables) } for i := range m.staticRoutes { if !m.staticRoutes[i].Disabled && m.staticRoutes[i].HealthStatus != "offline" { m.staticRoutes[i].Active = true } } } func (m *RoutingManager) save() error { _ = os.MkdirAll("/etc/debtik/routing", 0755) if data, err := json.MarshalIndent(m.staticRoutes, "", " "); err != nil { return err } else if err = os.WriteFile(StaticRoutesStorePath, data, 0600); err != nil { return err } if data, err := json.MarshalIndent(m.policyRules, "", " "); err != nil { return err } else if err = os.WriteFile(PolicyRulesStorePath, data, 0600); err != nil { return err } if data, err := json.MarshalIndent(m.customTables, "", " "); err != nil { return err } else if err = os.WriteFile(CustomTablesStorePath, data, 0600); err != nil { return err } return nil } func (m *RoutingManager) syncCustomTablesToSystem() { content, _ := os.ReadFile(RTTableFilePath) str := string(content) var sb strings.Builder sb.WriteString(str) for _, t := range m.customTables { check := fmt.Sprintf("\t%s", t.Name) if !strings.Contains(str, check) { sb.WriteString(fmt.Sprintf("%d\t%s\n", t.TableID, t.Name)) } } _ = os.WriteFile(RTTableFilePath, []byte(sb.String()), 0644) } func (m *RoutingManager) cleanRogueDefaultRoutes() { hasManagedDefault := false for _, r := range m.staticRoutes { if normalizeDst(r.DstAddress) == "default" && !r.Disabled { hasManagedDefault = true break } } if !hasManagedDefault { return } routes, err := netlink.RouteList(nil, netlink.FAMILY_V4) if err != nil { return } links, _ := netlink.LinkList() linkMap := make(map[int]string) for _, l := range links { linkMap[l.Attrs().Index] = l.Attrs().Name } for _, r := range routes { if (r.Table == 254 || r.Table == 0) && r.Dst == nil && len(r.MultiPath) == 0 { gw := "" if r.Gw != nil { gw = r.Gw.String() } ifName := linkMap[r.LinkIndex] // If priority is 0 (unmetered route from dhclient or boot) and protocol != RTPROT_STATIC: if r.Priority == 0 && int(r.Protocol) != 4 { log.Printf("[RouteManager] Removing rogue unmanaged default route: via %s dev %s proto %d", gw, ifName, r.Protocol) _ = netlink.RouteDel(&r) } } } } func (m *RoutingManager) getTableIDByName(name string) int { name = strings.TrimSpace(name) if name == "" || name == "main" { return 254 } if name == "default" { return 253 } if name == "local" { return 255 } for id, n := range m.getTableMap() { if strings.EqualFold(n, name) { return id } } if id, err := strconv.Atoi(name); err == nil { return id } return 254 } func (m *RoutingManager) startRouteSyncWorker() { ticker := time.NewTicker(3 * time.Second) go func() { for { select { case <-m.stopChan: ticker.Stop() return case <-ticker.C: m.syncAllRoutesWorker() } } }() } func (m *RoutingManager) syncAllRoutesWorker() { m.mu.Lock() defer m.mu.Unlock() // Unmanaged routes belong to DHCP, the host or other routing daemons. liveRoutes, err := netlink.RouteList(nil, netlink.FAMILY_V4) if err != nil { return } type groupKey struct { dst string table int dist int } kernelRouteGroups := make(map[groupKey]bool) for _, lr := range liveRoutes { dst := "default" if lr.Dst != nil { dst = lr.Dst.String() } tbl := lr.Table if tbl == 0 { tbl = 254 } k := groupKey{dst: normalizeDst(dst), table: tbl, dist: lr.Priority} kernelRouteGroups[k] = true } seen := make(map[groupKey]bool) for _, r := range m.staticRoutes { if r.Disabled || r.HealthStatus == "offline" { continue } normDst := normalizeDst(r.DstAddress) tableID := m.getTableIDByName(r.RoutingTable) k := groupKey{dst: normDst, table: tableID, dist: r.Distance} if !seen[k] { seen[k] = true if !kernelRouteGroups[k] { log.Printf("[RouteSync] Missing kernel route %s table %s (id %d) metric %d -> Restoring", normDst, r.RoutingTable, tableID, r.Distance) if err := m.syncRouteGroupToKernel(r.DstAddress, r.RoutingTable, r.Distance); err != nil { log.Printf("route synchronization failed: %v", err) } } } } } func (m *RoutingManager) applyAll() { // Unmanaged routes belong to DHCP, the host or other routing daemons. // Apply all static routes grouped by (dst, table, distance) for ECMP type groupKey struct { dst string table string dist int } seen := make(map[groupKey]bool) for _, r := range m.staticRoutes { normDst := normalizeDst(r.DstAddress) tbl := strings.TrimSpace(r.RoutingTable) if tbl == "" { tbl = "main" } k := groupKey{dst: normDst, table: tbl, dist: r.Distance} if !seen[k] { seen[k] = true _ = m.syncRouteGroupToKernel(r.DstAddress, r.RoutingTable, r.Distance) } } // Apply policy rules for _, pr := range m.policyRules { _ = m.applyRuleToKernel(pr) } } // ==================== Routes (/ip route) ==================== func isDynamicProtocol(proto netlink.RouteProtocol) bool { switch int(proto) { case 11, // RTPROT_ZEBRA 12, // RTPROT_BIRD 16, // RTPROT_DHCP 42, // RTPROT_BABEL 186, // RTPROT_BGP 187, // RTPROT_ISIS 188, // RTPROT_OSPF 189, // RTPROT_RIP 192: // RTPROT_EIGRP return true default: return false } } func getRouteProtocolName(proto netlink.RouteProtocol, priority int, isDirect bool, isMulticast bool) string { if isMulticast { return "multicast" } switch int(proto) { case 16: return "dhcp" case 4: return "static" case 186: return "bgp" case 188: return "ospf" case 189: return "rip" case 11: return "zebra" case 42: return "babel" case 12: // RTPROT_BIRD if priority == 20 { return "bgp" } else if priority == 200 { return "ibgp" } else if priority == 110 { return "ospf" } else if priority == 120 { return "rip" } return "bgp" case 2: return "connected" case 3: return "boot" default: if isDirect { return "connected" } if priority == 20 { return "bgp" } else if priority == 200 { return "ibgp" } else if priority == 110 { return "ospf" } else if priority == 120 { return "rip" } return "static" } } func (m *RoutingManager) listLiveRoutesLocked() []RouteItem { tableNames := m.getTableMap() links, _ := netlink.LinkList() linkMap := make(map[int]string) for _, l := range links { linkMap[l.Attrs().Index] = l.Attrs().Name } var liveRoutes []RouteItem routes, err := netlink.RouteList(nil, netlink.FAMILY_V4) if err == nil { for _, r := range routes { dst := "0.0.0.0/0" if r.Dst != nil { dst = r.Dst.String() } gw := "" if r.Gw != nil { gw = r.Gw.String() } tableName := "main" if name, ok := tableNames[r.Table]; ok { tableName = name } else if r.Table != 254 && r.Table != 0 { tableName = strconv.Itoa(r.Table) } isDyn := isDynamicProtocol(r.Protocol) isDirect := (r.Scope == netlink.SCOPE_LINK || r.Protocol == netlink.RouteProtocol(2)) && !isDyn && r.Protocol != 4 isStatic := !isDirect && !isDyn isMulticast := r.Type == 5 || strings.HasPrefix(dst, "224.") || strings.HasPrefix(dst, "ff") || dst == "224.0.0.0/4" protoName := getRouteProtocolName(r.Protocol, r.Priority, isDirect, isMulticast) if len(r.MultiPath) > 0 { for _, nh := range r.MultiPath { nhGw := "" if nh.Gw != nil { nhGw = nh.Gw.String() } nhIfName := linkMap[nh.LinkIndex] cleanDst := strings.ReplaceAll(dst, "/", "_") cleanGw := nhGw if cleanGw == "" { cleanGw = nhIfName } routeID := fmt.Sprintf("kr-%d-%s-%s", r.Table, cleanDst, cleanGw) liveRoutes = append(liveRoutes, RouteItem{ ID: routeID, DstAddress: dst, Gateway: nhGw, Interface: nhIfName, Distance: r.Priority, Protocol: protoName, RoutingTable: tableName, TableID: r.Table, Active: true, Connect: isDirect, Dynamic: isDyn, Static: isStatic, ECMP: true, IsMultiPath: true, Multicast: isMulticast, Disabled: false, }) } continue } ifName := linkMap[r.LinkIndex] act := "lookup" if r.Type == 6 { // RTN_BLACKHOLE gw = "blackhole" act = "blackhole" } cleanDst := strings.ReplaceAll(dst, "/", "_") cleanGw := gw if cleanGw == "" { cleanGw = ifName } routeID := fmt.Sprintf("kr-%d-%s-%s", r.Table, cleanDst, cleanGw) liveRoutes = append(liveRoutes, RouteItem{ ID: routeID, DstAddress: dst, Gateway: gw, Action: act, Interface: ifName, Distance: r.Priority, Protocol: protoName, RoutingTable: tableName, TableID: r.Table, Active: true, Connect: isDirect, Dynamic: isDyn, Static: isStatic, Multicast: isMulticast, Disabled: false, }) } } return liveRoutes } func (m *RoutingManager) ListRoutes() []RouteItem { m.mu.Lock() defer m.mu.Unlock() liveRoutes := m.listLiveRoutesLocked() // Merge static routes that might be disabled or have custom metadata for _, sr := range m.staticRoutes { found := false for i, lr := range liveRoutes { if lr.DstAddress == sr.DstAddress && lr.RoutingTable == sr.RoutingTable && (sr.Gateway == "" || lr.Gateway == sr.Gateway) { liveRoutes[i].Comment = sr.Comment liveRoutes[i].Static = true liveRoutes[i].Dynamic = false liveRoutes[i].Protocol = "static" liveRoutes[i].ID = sr.ID if sr.Distance > 0 && liveRoutes[i].Distance == 0 { liveRoutes[i].Distance = sr.Distance } liveRoutes[i].CheckGateway = sr.CheckGateway liveRoutes[i].CheckTarget = sr.CheckTarget liveRoutes[i].CheckInterval = sr.CheckInterval liveRoutes[i].CheckThreshold = sr.CheckThreshold liveRoutes[i].HealthStatus = sr.HealthStatus liveRoutes[i].HealthRTT = sr.HealthRTT liveRoutes[i].HealthLossPct = sr.HealthLossPct liveRoutes[i].HealthFailCount = sr.HealthFailCount liveRoutes[i].HealthLastCheck = sr.HealthLastCheck found = true break } } if !found { // Inactive or disabled static route srCopy := sr srCopy.Active = false srCopy.Static = true srCopy.Dynamic = false srCopy.Protocol = "static" liveRoutes = append(liveRoutes, srCopy) } } // Detect Equal-Cost Multi-Path (ECMP) routes groupCounts := make(map[string]int) for _, r := range liveRoutes { if r.Active && !r.Disabled { key := fmt.Sprintf("%s|%s|%d", r.DstAddress, r.RoutingTable, r.Distance) groupCounts[key]++ } } for i := range liveRoutes { key := fmt.Sprintf("%s|%s|%d", liveRoutes[i].DstAddress, liveRoutes[i].RoutingTable, liveRoutes[i].Distance) if liveRoutes[i].IsMultiPath || (liveRoutes[i].Active && !liveRoutes[i].Disabled && groupCounts[key] > 1) { liveRoutes[i].ECMP = true } if liveRoutes[i].DstAddress == "224.0.0.0/4" || strings.HasPrefix(liveRoutes[i].DstAddress, "224.") || strings.HasPrefix(liveRoutes[i].DstAddress, "ff") { liveRoutes[i].Multicast = true } } sort.Slice(liveRoutes, func(i, j int) bool { if liveRoutes[i].RoutingTable != liveRoutes[j].RoutingTable { return liveRoutes[i].RoutingTable < liveRoutes[j].RoutingTable } if liveRoutes[i].DstAddress != liveRoutes[j].DstAddress { return liveRoutes[i].DstAddress < liveRoutes[j].DstAddress } return liveRoutes[i].Gateway < liveRoutes[j].Gateway }) return liveRoutes } func (m *RoutingManager) SaveRoute(r RouteItem) (err error) { m.mu.Lock() defer m.mu.Unlock() before := append([]RouteItem(nil), m.staticRoutes...) dirty, applied := false, false defer func() { if err != nil && dirty { m.staticRoutes = before if applied { _ = m.syncRouteGroupToKernel(r.DstAddress, r.RoutingTable, r.Distance) } m.applyAll() _ = m.save() } }() if r.DstAddress == "" { return fmt.Errorf("destination address is required") } if r.DstAddress == "default" { r.DstAddress = "0.0.0.0/0" } if r.RoutingTable == "" { r.RoutingTable = "main" } if _, _, parseErr := net.ParseCIDR(r.DstAddress); parseErr != nil && net.ParseIP(r.DstAddress) == nil { return fmt.Errorf("invalid destination: %s", r.DstAddress) } dirty = true r.Static = true // 1. If old route details are provided from edit modal, remove old route first if r.OldDstAddress != "" { oldDist := 0 if r.OldDistance != nil { oldDist = *r.OldDistance } oldRoute := RouteItem{ DstAddress: r.OldDstAddress, Gateway: r.OldGateway, RoutingTable: r.OldRoutingTable, Distance: oldDist, Interface: r.OldInterface, } _ = m.removeRouteFromKernel(oldRoute) } // 2. If existing entry in m.staticRoutes exists, remove its old route from kernel foundIndex := -1 for i, existing := range m.staticRoutes { matchByID := r.ID != "" && existing.ID == r.ID matchByExactParams := r.ID == "" && normalizeDst(existing.DstAddress) == normalizeDst(r.DstAddress) && existing.RoutingTable == r.RoutingTable && existing.Gateway == r.Gateway && existing.Interface == r.Interface && existing.Distance == r.Distance if matchByID || matchByExactParams { foundIndex = i _ = m.removeRouteFromKernel(existing) break } } // 3. If r.ID starts with kr- (kernel static route being edited/adopted), remove it from kernel if strings.HasPrefix(r.ID, "kr-") { for _, lr := range m.listLiveRoutesLocked() { if lr.ID == r.ID { _ = m.removeRouteFromKernel(lr) break } } r.ID = fmt.Sprintf("route-%d", time.Now().UnixNano()) } if r.ID == "" { r.ID = fmt.Sprintf("route-%d", time.Now().UnixNano()) } if r.CheckInterval <= 0 { r.CheckInterval = 5 } if r.CheckThreshold <= 0 { r.CheckThreshold = 3 } if r.CheckGateway == "internet" && strings.TrimSpace(r.CheckTarget) == "" { r.CheckTarget = "1.1.1.1" } if r.CheckGateway != "" && r.CheckGateway != "none" { if r.HealthStatus == "" { r.HealthStatus = "checking" } } else { r.CheckGateway = "none" r.HealthStatus = "none" } if foundIndex >= 0 { if r.HealthStatus == "checking" && m.staticRoutes[foundIndex].HealthStatus != "" && m.staticRoutes[foundIndex].CheckGateway == r.CheckGateway { r.HealthStatus = m.staticRoutes[foundIndex].HealthStatus r.HealthRTT = m.staticRoutes[foundIndex].HealthRTT r.HealthLossPct = m.staticRoutes[foundIndex].HealthLossPct r.HealthLastCheck = m.staticRoutes[foundIndex].HealthLastCheck } m.staticRoutes[foundIndex] = r } else { m.staticRoutes = append(m.staticRoutes, r) } for i := range m.staticRoutes { if m.staticRoutes[i].ID == r.ID { if !m.staticRoutes[i].Disabled && m.staticRoutes[i].HealthStatus != "offline" { m.staticRoutes[i].Active = true } else { m.staticRoutes[i].Active = false } break } } if saveErr := m.save(); saveErr != nil { return saveErr } if err := m.syncRouteGroupToKernel(r.DstAddress, r.RoutingTable, r.Distance); err != nil { return err } applied = true if r.OldDstAddress != "" { oldDist := 0 if r.OldDistance != nil { oldDist = *r.OldDistance } if r.OldDstAddress != r.DstAddress || r.OldRoutingTable != r.RoutingTable || oldDist != r.Distance { if err := m.syncRouteGroupToKernel(r.OldDstAddress, r.OldRoutingTable, oldDist); err != nil { return err } } } return nil } func (m *RoutingManager) DeleteRoute(id string) error { m.mu.Lock() defer m.mu.Unlock() var updated []RouteItem var target *RouteItem for _, r := range m.staticRoutes { if r.ID == id { target = &r } else { updated = append(updated, r) } } if target != nil { m.staticRoutes = updated _ = m.save() _ = m.syncRouteGroupToKernel(target.DstAddress, target.RoutingTable, target.Distance) return nil } // If not in staticRoutes, could be a live route (e.g. kr-...) for _, lr := range m.listLiveRoutesLocked() { if lr.ID == id { _ = m.removeRouteFromKernel(lr) return nil } } return fmt.Errorf("route not found") } func (m *RoutingManager) SetRouteState(id string, disabled bool) error { m.mu.Lock() defer m.mu.Unlock() for i := range m.staticRoutes { if m.staticRoutes[i].ID == id { m.staticRoutes[i].Disabled = disabled if !disabled && m.staticRoutes[i].HealthStatus != "offline" { m.staticRoutes[i].Active = true } else if disabled { m.staticRoutes[i].Active = false } _ = m.save() _ = m.syncRouteGroupToKernel(m.staticRoutes[i].DstAddress, m.staticRoutes[i].RoutingTable, m.staticRoutes[i].Distance) return nil } } // If not in staticRoutes, could be a kernel static route to disable if disabled { for _, lr := range m.listLiveRoutesLocked() { if lr.ID == id { lr.Disabled = true lr.Static = true lr.Active = false lr.ID = fmt.Sprintf("route-%d", time.Now().UnixNano()) m.staticRoutes = append(m.staticRoutes, lr) _ = m.save() _ = m.syncRouteGroupToKernel(lr.DstAddress, lr.RoutingTable, lr.Distance) return nil } } } return fmt.Errorf("route not found") } func normalizeDst(dst string) string { dst = strings.TrimSpace(dst) if dst == "" || dst == "default" || dst == "0.0.0.0/0" { return "default" } if !strings.Contains(dst, "/") && net.ParseIP(dst) != nil { return dst + "/32" } return dst } func (m *RoutingManager) syncRouteGroupToKernel(dstAddress, routingTable string, distance int) error { normDst := normalizeDst(dstAddress) tbl := strings.TrimSpace(routingTable) if tbl == "" { tbl = "main" } var activeRoutes []RouteItem for _, r := range m.staticRoutes { rDst := normalizeDst(r.DstAddress) rTbl := strings.TrimSpace(r.RoutingTable) if rTbl == "" { rTbl = "main" } if rDst == normDst && rTbl == tbl && r.Distance == distance { if !r.Disabled && r.Active && r.HealthStatus != "offline" { activeRoutes = append(activeRoutes, r) } } } if len(activeRoutes) == 0 { // Safety guard: if all default routes are marked offline, do NOT delete the default route into a blackout! // Fallback to keeping all enabled static routes in the group active so recovery traffic and emergency pings can flow. if normDst == "default" { var fallbackRoutes []RouteItem for _, r := range m.staticRoutes { rDst := normalizeDst(r.DstAddress) rTbl := strings.TrimSpace(r.RoutingTable) if rTbl == "" { rTbl = "main" } if rDst == normDst && rTbl == tbl && r.Distance == distance && !r.Disabled { fallbackRoutes = append(fallbackRoutes, r) } } if len(fallbackRoutes) > 0 { log.Printf("[RouteManager] All default routes offline in group! Keeping fallback routes active to prevent blackout") activeRoutes = fallbackRoutes } } } if len(activeRoutes) == 0 { args := []string{"route", "del", normDst} if distance > 0 { args = append(args, "metric", strconv.Itoa(distance)) } if tbl != "main" { args = append(args, "table", tbl) } args = append(args, "proto", "static") _ = exec.Command("ip", args...).Run() // Also delete blackhole route variant if it existed bhArgs := []string{"route", "del", "blackhole", normDst} if distance > 0 { bhArgs = append(bhArgs, "metric", strconv.Itoa(distance)) } if tbl != "main" { bhArgs = append(bhArgs, "table", tbl) } _ = exec.Command("ip", bhArgs...).Run() return nil } if len(activeRoutes) == 1 { r := activeRoutes[0] gw := strings.TrimSpace(r.Gateway) isBlackhole := strings.EqualFold(gw, "blackhole") || strings.EqualFold(r.Action, "blackhole") var args []string if isBlackhole { args = []string{"route", "replace", "blackhole", normDst} } else { args = []string{"route", "replace", normDst} if gw != "" { if net.ParseIP(gw) != nil { args = append(args, "via", gw) } else { args = append(args, "dev", gw) } } iface := strings.TrimSpace(r.Interface) if iface != "" && gw != iface && (gw == "" || net.ParseIP(gw) != nil) { args = append(args, "dev", iface) } if gw != "" && iface != "" && net.ParseIP(gw) != nil { args = append(args, "onlink") } } if distance > 0 { args = append(args, "metric", strconv.Itoa(distance)) } if tbl != "main" { args = append(args, "table", tbl) } args = append(args, "proto", "static") cmd := exec.Command("ip", args...) if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("failed to apply route (%s): %s: %w", strings.Join(args, " "), string(out), err) } return nil } // Multiple routes: ECMP multipath route args := []string{"route", "replace", normDst, "scope", "global"} if distance > 0 { args = append(args, "metric", strconv.Itoa(distance)) } if tbl != "main" { args = append(args, "table", tbl) } args = append(args, "proto", "static") for _, r := range activeRoutes { args = append(args, "nexthop") gw := strings.TrimSpace(r.Gateway) if gw != "" && net.ParseIP(gw) != nil { args = append(args, "via", gw) } iface := strings.TrimSpace(r.Interface) if iface == "" && gw != "" && net.ParseIP(gw) == nil { iface = gw } if iface != "" { args = append(args, "dev", iface) } if gw != "" && iface != "" && net.ParseIP(gw) != nil { args = append(args, "onlink") } args = append(args, "weight", "1") } cmd := exec.Command("ip", args...) if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("failed to apply multipath route (%s): %s: %w", strings.Join(args, " "), string(out), err) } return nil } func (m *RoutingManager) applyRouteToKernel(r RouteItem) error { return m.syncRouteGroupToKernel(r.DstAddress, r.RoutingTable, r.Distance) } func (m *RoutingManager) removeRouteFromKernel(r RouteItem) error { return m.syncRouteGroupToKernel(r.DstAddress, r.RoutingTable, r.Distance) } // ==================== Policy Rules (/ip route rule) ==================== func (m *RoutingManager) ListPolicyRules() []PolicyRuleItem { m.mu.Lock() defer m.mu.Unlock() // Query live rules from kernel rules, err := netlink.RuleList(netlink.FAMILY_V4) if err != nil { return m.policyRules } tableNames := m.getTableMap() var res []PolicyRuleItem userRulesByPrio := make(map[int]PolicyRuleItem) for _, pr := range m.policyRules { userRulesByPrio[pr.Priority] = pr } for _, r := range rules { tableName := "main" if name, ok := tableNames[r.Table]; ok { tableName = name } else if r.Table != 254 && r.Table != 0 { tableName = strconv.Itoa(r.Table) } src := "" if r.Src != nil { src = r.Src.String() } dst := "" if r.Dst != nil { dst = r.Dst.String() } markStr := "" if r.Mark > 0 { markStr = fmt.Sprintf("0x%x (%d)", r.Mark, r.Mark) } id := fmt.Sprintf("rule-%d-%d", r.Priority, r.Table) comment := "" isDefault := false isSystem := false if r.Priority == 0 || r.Priority == 32766 || r.Priority == 32767 { isDefault = true isSystem = true } else if userRule, ok := userRulesByPrio[r.Priority]; ok { if userRule.ID != "" { id = userRule.ID } comment = userRule.Comment isDefault = false isSystem = false } else { // System/daemon rule not added by user in debtik (e.g. WireGuard, IPsec) isSystem = true } res = append(res, PolicyRuleItem{ ID: id, Priority: r.Priority, SrcAddress: src, DstAddress: dst, RoutingMark: markStr, Table: tableName, TableID: r.Table, Action: "lookup", Comment: comment, IsDefault: isDefault, IsSystem: isSystem, }) } return res } func (m *RoutingManager) SavePolicyRule(pr PolicyRuleItem) error { m.mu.Lock() defer m.mu.Unlock() if pr.Table == "" { pr.Table = "main" } if pr.ID == "" { pr.ID = fmt.Sprintf("prule-%d", time.Now().UnixNano()) } if pr.Priority == 0 { pr.Priority = 1000 + len(m.policyRules)*10 } if err := m.applyRuleToKernel(pr); err != nil { return err } m.policyRules = append(m.policyRules, pr) return m.save() } func (m *RoutingManager) DeletePolicyRule(id string) error { m.mu.Lock() defer m.mu.Unlock() var updated []PolicyRuleItem var target *PolicyRuleItem for _, pr := range m.policyRules { if pr.ID == id || fmt.Sprintf("rule-%d-%d", pr.Priority, pr.TableID) == id { target = &pr } else { updated = append(updated, pr) } } if target != nil { _ = m.removeRuleFromKernel(*target) } else if strings.HasPrefix(id, "rule-") { parts := strings.Split(id, "-") if len(parts) >= 2 { if prio, err := strconv.Atoi(parts[1]); err == nil && prio > 0 { _ = exec.Command("ip", "rule", "del", "pref", strconv.Itoa(prio)).Run() } } } m.policyRules = updated return m.save() } func (m *RoutingManager) applyRuleToKernel(pr PolicyRuleItem) error { args := []string{"rule", "add"} if pr.Priority > 0 { args = append(args, "pref", strconv.Itoa(pr.Priority)) } if pr.SrcAddress != "" { args = append(args, "from", pr.SrcAddress) } if pr.DstAddress != "" { args = append(args, "to", pr.DstAddress) } if pr.RoutingMark != "" { // Clean mark string if it has (hex) markClean := pr.RoutingMark if strings.Contains(markClean, "(") { markClean = strings.TrimSpace(markClean[:strings.Index(markClean, "(")]) } args = append(args, "fwmark", markClean) } if pr.Table != "" { args = append(args, "lookup", pr.Table) } cmd := exec.Command("ip", args...) out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("failed to add rule (%s): %s: %w", strings.Join(args, " "), string(out), err) } return nil } func (m *RoutingManager) removeRuleFromKernel(pr PolicyRuleItem) error { if pr.Priority > 0 { _ = exec.Command("ip", "rule", "del", "pref", strconv.Itoa(pr.Priority)).Run() return nil } args := []string{"rule", "del"} if pr.RoutingMark != "" { markClean := pr.RoutingMark if strings.Contains(markClean, "(") { markClean = strings.TrimSpace(markClean[:strings.Index(markClean, "(")]) } args = append(args, "fwmark", markClean) } if pr.Table != "" { args = append(args, "lookup", pr.Table) } _ = exec.Command("ip", args...).Run() return nil } // ==================== Routing Tables ==================== func (m *RoutingManager) ListTables() []RoutingTableItem { m.mu.Lock() defer m.mu.Unlock() tables := []RoutingTableItem{ {Name: "main", TableID: 254, Comment: "Default system routing table"}, {Name: "default", TableID: 253, Comment: "Default fallback table"}, {Name: "local", TableID: 255, Comment: "Local kernel device table"}, } tables = append(tables, m.customTables...) return tables } func (m *RoutingManager) SaveTable(t RoutingTableItem) error { m.mu.Lock() defer m.mu.Unlock() if t.Name == "" { return fmt.Errorf("table name is required") } if t.TableID == 0 { // Auto assign FIB ID starting at 100 t.TableID = 100 + len(m.customTables) } for _, existing := range m.customTables { if existing.Name == t.Name || existing.TableID == t.TableID { return fmt.Errorf("table with name %q or ID %d already exists", t.Name, t.TableID) } } m.customTables = append(m.customTables, t) _ = m.save() m.syncCustomTablesToSystem() return nil } func (m *RoutingManager) getTableMap() map[int]string { tableMap := map[int]string{ 254: "main", 253: "default", 255: "local", } // Read /etc/iproute2/rt_tables if f, err := os.Open(RTTableFilePath); err == nil { defer f.Close() scanner := bufio.NewScanner(f) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } parts := strings.Fields(line) if len(parts) >= 2 { if id, err := strconv.Atoi(parts[0]); err == nil { tableMap[id] = parts[1] } } } } for _, t := range m.customTables { tableMap[t.TableID] = t.Name } return tableMap } func (m *RoutingManager) Stop() { if m.stopChan != nil { select { case <-m.stopChan: default: close(m.stopChan) } } } // RenameInterface updates static routes and policy rules when an interface is renamed. func (m *RoutingManager) RenameInterface(oldName, newName string) { m.mu.Lock() defer m.mu.Unlock() changed := false for i := range m.staticRoutes { if m.staticRoutes[i].Interface == oldName { m.staticRoutes[i].Interface = newName changed = true } if m.staticRoutes[i].Gateway == oldName { m.staticRoutes[i].Gateway = newName changed = true } } if changed { _ = m.save() } }