manager.go 28 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132
  1. package routing
  2. import (
  3. "bufio"
  4. "encoding/json"
  5. "fmt"
  6. "log"
  7. "net"
  8. "os"
  9. "os/exec"
  10. "path/filepath"
  11. "sort"
  12. "strconv"
  13. "strings"
  14. "sync"
  15. "time"
  16. "github.com/vishvananda/netlink"
  17. )
  18. const (
  19. StaticRoutesStorePath = "/etc/proxmox-router/routing/static_routes.json"
  20. PolicyRulesStorePath = "/etc/proxmox-router/routing/policy_rules.json"
  21. CustomTablesStorePath = "/etc/proxmox-router/routing/custom_tables.json"
  22. RTTableFilePath = "/etc/iproute2/rt_tables"
  23. )
  24. type RoutingManager struct {
  25. mu sync.Mutex
  26. staticRoutes []RouteItem
  27. policyRules []PolicyRuleItem
  28. customTables []RoutingTableItem
  29. stopChan chan struct{}
  30. }
  31. func NewRoutingManager() *RoutingManager {
  32. m := &RoutingManager{
  33. staticRoutes: make([]RouteItem, 0),
  34. policyRules: make([]PolicyRuleItem, 0),
  35. customTables: make([]RoutingTableItem, 0),
  36. stopChan: make(chan struct{}),
  37. }
  38. m.initRTTableFile()
  39. m.load()
  40. m.syncCustomTablesToSystem()
  41. m.applyAll()
  42. m.startHealthCheckWorker()
  43. m.startRouteSyncWorker()
  44. return m
  45. }
  46. func (m *RoutingManager) initRTTableFile() {
  47. _ = os.MkdirAll(filepath.Dir(RTTableFilePath), 0755)
  48. if _, err := os.Stat(RTTableFilePath); os.IsNotExist(err) {
  49. // Copy from /usr/share/iproute2/rt_tables or seed default
  50. content, err := os.ReadFile("/usr/share/iproute2/rt_tables")
  51. if err == nil {
  52. _ = os.WriteFile(RTTableFilePath, content, 0644)
  53. } else {
  54. defaultContent := "# reserved values\n255\tlocal\n254\tmain\n253\tdefault\n0\tunspec\n"
  55. _ = os.WriteFile(RTTableFilePath, []byte(defaultContent), 0644)
  56. }
  57. }
  58. }
  59. func (m *RoutingManager) load() {
  60. if data, err := os.ReadFile(StaticRoutesStorePath); err == nil {
  61. _ = json.Unmarshal(data, &m.staticRoutes)
  62. }
  63. if data, err := os.ReadFile(PolicyRulesStorePath); err == nil {
  64. _ = json.Unmarshal(data, &m.policyRules)
  65. }
  66. if data, err := os.ReadFile(CustomTablesStorePath); err == nil {
  67. _ = json.Unmarshal(data, &m.customTables)
  68. }
  69. for i := range m.staticRoutes {
  70. if !m.staticRoutes[i].Disabled && m.staticRoutes[i].HealthStatus != "offline" {
  71. m.staticRoutes[i].Active = true
  72. }
  73. }
  74. }
  75. func (m *RoutingManager) save() error {
  76. _ = os.MkdirAll("/etc/proxmox-router/routing", 0755)
  77. if d, err := json.MarshalIndent(m.staticRoutes, "", " "); err == nil {
  78. _ = os.WriteFile(StaticRoutesStorePath, d, 0600)
  79. }
  80. if d, err := json.MarshalIndent(m.policyRules, "", " "); err == nil {
  81. _ = os.WriteFile(PolicyRulesStorePath, d, 0600)
  82. }
  83. if d, err := json.MarshalIndent(m.customTables, "", " "); err == nil {
  84. _ = os.WriteFile(CustomTablesStorePath, d, 0600)
  85. }
  86. return nil
  87. }
  88. func (m *RoutingManager) syncCustomTablesToSystem() {
  89. content, _ := os.ReadFile(RTTableFilePath)
  90. str := string(content)
  91. var sb strings.Builder
  92. sb.WriteString(str)
  93. for _, t := range m.customTables {
  94. check := fmt.Sprintf("\t%s", t.Name)
  95. if !strings.Contains(str, check) {
  96. sb.WriteString(fmt.Sprintf("%d\t%s\n", t.TableID, t.Name))
  97. }
  98. }
  99. _ = os.WriteFile(RTTableFilePath, []byte(sb.String()), 0644)
  100. }
  101. func (m *RoutingManager) cleanRogueDefaultRoutes() {
  102. hasManagedDefault := false
  103. for _, r := range m.staticRoutes {
  104. if normalizeDst(r.DstAddress) == "default" && !r.Disabled {
  105. hasManagedDefault = true
  106. break
  107. }
  108. }
  109. if !hasManagedDefault {
  110. return
  111. }
  112. routes, err := netlink.RouteList(nil, netlink.FAMILY_V4)
  113. if err != nil {
  114. return
  115. }
  116. links, _ := netlink.LinkList()
  117. linkMap := make(map[int]string)
  118. for _, l := range links {
  119. linkMap[l.Attrs().Index] = l.Attrs().Name
  120. }
  121. for _, r := range routes {
  122. if (r.Table == 254 || r.Table == 0) && r.Dst == nil && len(r.MultiPath) == 0 {
  123. gw := ""
  124. if r.Gw != nil {
  125. gw = r.Gw.String()
  126. }
  127. ifName := linkMap[r.LinkIndex]
  128. // If priority is 0 (unmetered route from dhclient or boot) and protocol != RTPROT_STATIC:
  129. if r.Priority == 0 && int(r.Protocol) != 4 {
  130. log.Printf("[RouteManager] Removing rogue unmanaged default route: via %s dev %s proto %d", gw, ifName, r.Protocol)
  131. _ = netlink.RouteDel(&r)
  132. }
  133. }
  134. }
  135. }
  136. func (m *RoutingManager) getTableIDByName(name string) int {
  137. name = strings.TrimSpace(name)
  138. if name == "" || name == "main" {
  139. return 254
  140. }
  141. if name == "default" {
  142. return 253
  143. }
  144. if name == "local" {
  145. return 255
  146. }
  147. for id, n := range m.getTableMap() {
  148. if strings.EqualFold(n, name) {
  149. return id
  150. }
  151. }
  152. if id, err := strconv.Atoi(name); err == nil {
  153. return id
  154. }
  155. return 254
  156. }
  157. func (m *RoutingManager) startRouteSyncWorker() {
  158. ticker := time.NewTicker(3 * time.Second)
  159. go func() {
  160. for {
  161. select {
  162. case <-m.stopChan:
  163. ticker.Stop()
  164. return
  165. case <-ticker.C:
  166. m.syncAllRoutesWorker()
  167. }
  168. }
  169. }()
  170. }
  171. func (m *RoutingManager) syncAllRoutesWorker() {
  172. m.mu.Lock()
  173. defer m.mu.Unlock()
  174. m.cleanRogueDefaultRoutes()
  175. liveRoutes, err := netlink.RouteList(nil, netlink.FAMILY_V4)
  176. if err != nil {
  177. return
  178. }
  179. type groupKey struct {
  180. dst string
  181. table int
  182. dist int
  183. }
  184. kernelRouteGroups := make(map[groupKey]bool)
  185. for _, lr := range liveRoutes {
  186. dst := "default"
  187. if lr.Dst != nil {
  188. dst = lr.Dst.String()
  189. }
  190. tbl := lr.Table
  191. if tbl == 0 {
  192. tbl = 254
  193. }
  194. k := groupKey{dst: normalizeDst(dst), table: tbl, dist: lr.Priority}
  195. kernelRouteGroups[k] = true
  196. }
  197. seen := make(map[groupKey]bool)
  198. for _, r := range m.staticRoutes {
  199. if r.Disabled || r.HealthStatus == "offline" {
  200. continue
  201. }
  202. normDst := normalizeDst(r.DstAddress)
  203. tableID := m.getTableIDByName(r.RoutingTable)
  204. k := groupKey{dst: normDst, table: tableID, dist: r.Distance}
  205. if !seen[k] {
  206. seen[k] = true
  207. if !kernelRouteGroups[k] {
  208. log.Printf("[RouteSync] Missing kernel route %s table %s (id %d) metric %d -> Restoring", normDst, r.RoutingTable, tableID, r.Distance)
  209. _ = m.syncRouteGroupToKernel(r.DstAddress, r.RoutingTable, r.Distance)
  210. }
  211. }
  212. }
  213. }
  214. func (m *RoutingManager) applyAll() {
  215. m.cleanRogueDefaultRoutes()
  216. // Apply all static routes grouped by (dst, table, distance) for ECMP
  217. type groupKey struct {
  218. dst string
  219. table string
  220. dist int
  221. }
  222. seen := make(map[groupKey]bool)
  223. for _, r := range m.staticRoutes {
  224. normDst := normalizeDst(r.DstAddress)
  225. tbl := strings.TrimSpace(r.RoutingTable)
  226. if tbl == "" {
  227. tbl = "main"
  228. }
  229. k := groupKey{dst: normDst, table: tbl, dist: r.Distance}
  230. if !seen[k] {
  231. seen[k] = true
  232. _ = m.syncRouteGroupToKernel(r.DstAddress, r.RoutingTable, r.Distance)
  233. }
  234. }
  235. // Apply policy rules
  236. for _, pr := range m.policyRules {
  237. _ = m.applyRuleToKernel(pr)
  238. }
  239. }
  240. // ==================== Routes (/ip route) ====================
  241. func isDynamicProtocol(proto netlink.RouteProtocol) bool {
  242. switch int(proto) {
  243. case 11, // RTPROT_ZEBRA
  244. 12, // RTPROT_BIRD
  245. 16, // RTPROT_DHCP
  246. 42, // RTPROT_BABEL
  247. 186, // RTPROT_BGP
  248. 187, // RTPROT_ISIS
  249. 188, // RTPROT_OSPF
  250. 189, // RTPROT_RIP
  251. 192: // RTPROT_EIGRP
  252. return true
  253. default:
  254. return false
  255. }
  256. }
  257. func getRouteProtocolName(proto netlink.RouteProtocol, priority int, isDirect bool, isMulticast bool) string {
  258. if isMulticast {
  259. return "multicast"
  260. }
  261. switch int(proto) {
  262. case 16:
  263. return "dhcp"
  264. case 4:
  265. return "static"
  266. case 186:
  267. return "bgp"
  268. case 188:
  269. return "ospf"
  270. case 189:
  271. return "rip"
  272. case 11:
  273. return "zebra"
  274. case 42:
  275. return "babel"
  276. case 12: // RTPROT_BIRD
  277. if priority == 20 {
  278. return "bgp"
  279. } else if priority == 200 {
  280. return "ibgp"
  281. } else if priority == 110 {
  282. return "ospf"
  283. } else if priority == 120 {
  284. return "rip"
  285. }
  286. return "bgp"
  287. case 2:
  288. return "connected"
  289. case 3:
  290. return "boot"
  291. default:
  292. if isDirect {
  293. return "connected"
  294. }
  295. if priority == 20 {
  296. return "bgp"
  297. } else if priority == 200 {
  298. return "ibgp"
  299. } else if priority == 110 {
  300. return "ospf"
  301. } else if priority == 120 {
  302. return "rip"
  303. }
  304. return "static"
  305. }
  306. }
  307. func (m *RoutingManager) listLiveRoutesLocked() []RouteItem {
  308. tableNames := m.getTableMap()
  309. links, _ := netlink.LinkList()
  310. linkMap := make(map[int]string)
  311. for _, l := range links {
  312. linkMap[l.Attrs().Index] = l.Attrs().Name
  313. }
  314. var liveRoutes []RouteItem
  315. routes, err := netlink.RouteList(nil, netlink.FAMILY_V4)
  316. if err == nil {
  317. for _, r := range routes {
  318. dst := "0.0.0.0/0"
  319. if r.Dst != nil {
  320. dst = r.Dst.String()
  321. }
  322. gw := ""
  323. if r.Gw != nil {
  324. gw = r.Gw.String()
  325. }
  326. tableName := "main"
  327. if name, ok := tableNames[r.Table]; ok {
  328. tableName = name
  329. } else if r.Table != 254 && r.Table != 0 {
  330. tableName = strconv.Itoa(r.Table)
  331. }
  332. isDyn := isDynamicProtocol(r.Protocol)
  333. isDirect := (r.Scope == netlink.SCOPE_LINK || r.Protocol == netlink.RouteProtocol(2)) && !isDyn && r.Protocol != 4
  334. isStatic := !isDirect && !isDyn
  335. isMulticast := r.Type == 5 || strings.HasPrefix(dst, "224.") || strings.HasPrefix(dst, "ff") || dst == "224.0.0.0/4"
  336. protoName := getRouteProtocolName(r.Protocol, r.Priority, isDirect, isMulticast)
  337. if len(r.MultiPath) > 0 {
  338. for _, nh := range r.MultiPath {
  339. nhGw := ""
  340. if nh.Gw != nil {
  341. nhGw = nh.Gw.String()
  342. }
  343. nhIfName := linkMap[nh.LinkIndex]
  344. cleanDst := strings.ReplaceAll(dst, "/", "_")
  345. cleanGw := nhGw
  346. if cleanGw == "" {
  347. cleanGw = nhIfName
  348. }
  349. routeID := fmt.Sprintf("kr-%d-%s-%s", r.Table, cleanDst, cleanGw)
  350. liveRoutes = append(liveRoutes, RouteItem{
  351. ID: routeID,
  352. DstAddress: dst,
  353. Gateway: nhGw,
  354. Interface: nhIfName,
  355. Distance: r.Priority,
  356. Protocol: protoName,
  357. RoutingTable: tableName,
  358. TableID: r.Table,
  359. Active: true,
  360. Connect: isDirect,
  361. Dynamic: isDyn,
  362. Static: isStatic,
  363. ECMP: true,
  364. IsMultiPath: true,
  365. Multicast: isMulticast,
  366. Disabled: false,
  367. })
  368. }
  369. continue
  370. }
  371. ifName := linkMap[r.LinkIndex]
  372. cleanDst := strings.ReplaceAll(dst, "/", "_")
  373. cleanGw := gw
  374. if cleanGw == "" {
  375. cleanGw = ifName
  376. }
  377. routeID := fmt.Sprintf("kr-%d-%s-%s", r.Table, cleanDst, cleanGw)
  378. liveRoutes = append(liveRoutes, RouteItem{
  379. ID: routeID,
  380. DstAddress: dst,
  381. Gateway: gw,
  382. Interface: ifName,
  383. Distance: r.Priority,
  384. Protocol: protoName,
  385. RoutingTable: tableName,
  386. TableID: r.Table,
  387. Active: true,
  388. Connect: isDirect,
  389. Dynamic: isDyn,
  390. Static: isStatic,
  391. Multicast: isMulticast,
  392. Disabled: false,
  393. })
  394. }
  395. }
  396. return liveRoutes
  397. }
  398. func (m *RoutingManager) ListRoutes() []RouteItem {
  399. m.mu.Lock()
  400. defer m.mu.Unlock()
  401. liveRoutes := m.listLiveRoutesLocked()
  402. // Merge static routes that might be disabled or have custom metadata
  403. for _, sr := range m.staticRoutes {
  404. found := false
  405. for i, lr := range liveRoutes {
  406. if lr.DstAddress == sr.DstAddress && lr.RoutingTable == sr.RoutingTable && (sr.Gateway == "" || lr.Gateway == sr.Gateway) {
  407. liveRoutes[i].Comment = sr.Comment
  408. liveRoutes[i].Static = true
  409. liveRoutes[i].Dynamic = false
  410. liveRoutes[i].Protocol = "static"
  411. liveRoutes[i].ID = sr.ID
  412. if sr.Distance > 0 && liveRoutes[i].Distance == 0 {
  413. liveRoutes[i].Distance = sr.Distance
  414. }
  415. liveRoutes[i].CheckGateway = sr.CheckGateway
  416. liveRoutes[i].CheckTarget = sr.CheckTarget
  417. liveRoutes[i].CheckInterval = sr.CheckInterval
  418. liveRoutes[i].CheckThreshold = sr.CheckThreshold
  419. liveRoutes[i].HealthStatus = sr.HealthStatus
  420. liveRoutes[i].HealthRTT = sr.HealthRTT
  421. liveRoutes[i].HealthLossPct = sr.HealthLossPct
  422. liveRoutes[i].HealthFailCount = sr.HealthFailCount
  423. liveRoutes[i].HealthLastCheck = sr.HealthLastCheck
  424. found = true
  425. break
  426. }
  427. }
  428. if !found {
  429. // Inactive or disabled static route
  430. srCopy := sr
  431. srCopy.Active = false
  432. srCopy.Static = true
  433. srCopy.Dynamic = false
  434. srCopy.Protocol = "static"
  435. liveRoutes = append(liveRoutes, srCopy)
  436. }
  437. }
  438. // Detect Equal-Cost Multi-Path (ECMP) routes
  439. groupCounts := make(map[string]int)
  440. for _, r := range liveRoutes {
  441. if r.Active && !r.Disabled {
  442. key := fmt.Sprintf("%s|%s|%d", r.DstAddress, r.RoutingTable, r.Distance)
  443. groupCounts[key]++
  444. }
  445. }
  446. for i := range liveRoutes {
  447. key := fmt.Sprintf("%s|%s|%d", liveRoutes[i].DstAddress, liveRoutes[i].RoutingTable, liveRoutes[i].Distance)
  448. if liveRoutes[i].IsMultiPath || (liveRoutes[i].Active && !liveRoutes[i].Disabled && groupCounts[key] > 1) {
  449. liveRoutes[i].ECMP = true
  450. }
  451. if liveRoutes[i].DstAddress == "224.0.0.0/4" || strings.HasPrefix(liveRoutes[i].DstAddress, "224.") || strings.HasPrefix(liveRoutes[i].DstAddress, "ff") {
  452. liveRoutes[i].Multicast = true
  453. }
  454. }
  455. sort.Slice(liveRoutes, func(i, j int) bool {
  456. if liveRoutes[i].RoutingTable != liveRoutes[j].RoutingTable {
  457. return liveRoutes[i].RoutingTable < liveRoutes[j].RoutingTable
  458. }
  459. if liveRoutes[i].DstAddress != liveRoutes[j].DstAddress {
  460. return liveRoutes[i].DstAddress < liveRoutes[j].DstAddress
  461. }
  462. return liveRoutes[i].Gateway < liveRoutes[j].Gateway
  463. })
  464. return liveRoutes
  465. }
  466. func (m *RoutingManager) SaveRoute(r RouteItem) error {
  467. m.mu.Lock()
  468. defer m.mu.Unlock()
  469. if r.DstAddress == "" {
  470. return fmt.Errorf("destination address is required")
  471. }
  472. if r.DstAddress == "default" {
  473. r.DstAddress = "0.0.0.0/0"
  474. }
  475. if r.RoutingTable == "" {
  476. r.RoutingTable = "main"
  477. }
  478. r.Static = true
  479. // 1. If old route details are provided from edit modal, remove old route first
  480. if r.OldDstAddress != "" {
  481. oldDist := 0
  482. if r.OldDistance != nil {
  483. oldDist = *r.OldDistance
  484. }
  485. oldRoute := RouteItem{
  486. DstAddress: r.OldDstAddress,
  487. Gateway: r.OldGateway,
  488. RoutingTable: r.OldRoutingTable,
  489. Distance: oldDist,
  490. Interface: r.OldInterface,
  491. }
  492. _ = m.removeRouteFromKernel(oldRoute)
  493. }
  494. // 2. If existing entry in m.staticRoutes exists, remove its old route from kernel
  495. foundIndex := -1
  496. for i, existing := range m.staticRoutes {
  497. matchByID := r.ID != "" && existing.ID == r.ID
  498. matchByExactParams := r.ID == "" &&
  499. normalizeDst(existing.DstAddress) == normalizeDst(r.DstAddress) &&
  500. existing.RoutingTable == r.RoutingTable &&
  501. existing.Gateway == r.Gateway &&
  502. existing.Interface == r.Interface &&
  503. existing.Distance == r.Distance
  504. if matchByID || matchByExactParams {
  505. foundIndex = i
  506. _ = m.removeRouteFromKernel(existing)
  507. break
  508. }
  509. }
  510. // 3. If r.ID starts with kr- (kernel static route being edited/adopted), remove it from kernel
  511. if strings.HasPrefix(r.ID, "kr-") {
  512. for _, lr := range m.listLiveRoutesLocked() {
  513. if lr.ID == r.ID {
  514. _ = m.removeRouteFromKernel(lr)
  515. break
  516. }
  517. }
  518. r.ID = fmt.Sprintf("route-%d", time.Now().UnixNano())
  519. }
  520. if r.ID == "" {
  521. r.ID = fmt.Sprintf("route-%d", time.Now().UnixNano())
  522. }
  523. if r.CheckInterval <= 0 {
  524. r.CheckInterval = 5
  525. }
  526. if r.CheckThreshold <= 0 {
  527. r.CheckThreshold = 3
  528. }
  529. if r.CheckGateway == "internet" && strings.TrimSpace(r.CheckTarget) == "" {
  530. r.CheckTarget = "1.1.1.1"
  531. }
  532. if r.CheckGateway != "" && r.CheckGateway != "none" {
  533. if r.HealthStatus == "" {
  534. r.HealthStatus = "checking"
  535. }
  536. } else {
  537. r.CheckGateway = "none"
  538. r.HealthStatus = "none"
  539. }
  540. if foundIndex >= 0 {
  541. if r.HealthStatus == "checking" && m.staticRoutes[foundIndex].HealthStatus != "" && m.staticRoutes[foundIndex].CheckGateway == r.CheckGateway {
  542. r.HealthStatus = m.staticRoutes[foundIndex].HealthStatus
  543. r.HealthRTT = m.staticRoutes[foundIndex].HealthRTT
  544. r.HealthLossPct = m.staticRoutes[foundIndex].HealthLossPct
  545. r.HealthLastCheck = m.staticRoutes[foundIndex].HealthLastCheck
  546. }
  547. m.staticRoutes[foundIndex] = r
  548. } else {
  549. m.staticRoutes = append(m.staticRoutes, r)
  550. }
  551. for i := range m.staticRoutes {
  552. if m.staticRoutes[i].ID == r.ID {
  553. if !m.staticRoutes[i].Disabled && m.staticRoutes[i].HealthStatus != "offline" {
  554. m.staticRoutes[i].Active = true
  555. } else {
  556. m.staticRoutes[i].Active = false
  557. }
  558. break
  559. }
  560. }
  561. _ = m.save()
  562. _ = m.syncRouteGroupToKernel(r.DstAddress, r.RoutingTable, r.Distance)
  563. if r.OldDstAddress != "" {
  564. oldDist := 0
  565. if r.OldDistance != nil {
  566. oldDist = *r.OldDistance
  567. }
  568. if r.OldDstAddress != r.DstAddress || r.OldRoutingTable != r.RoutingTable || oldDist != r.Distance {
  569. _ = m.syncRouteGroupToKernel(r.OldDstAddress, r.OldRoutingTable, oldDist)
  570. }
  571. }
  572. return nil
  573. }
  574. func (m *RoutingManager) DeleteRoute(id string) error {
  575. m.mu.Lock()
  576. defer m.mu.Unlock()
  577. var updated []RouteItem
  578. var target *RouteItem
  579. for _, r := range m.staticRoutes {
  580. if r.ID == id {
  581. target = &r
  582. } else {
  583. updated = append(updated, r)
  584. }
  585. }
  586. if target != nil {
  587. m.staticRoutes = updated
  588. _ = m.save()
  589. _ = m.syncRouteGroupToKernel(target.DstAddress, target.RoutingTable, target.Distance)
  590. return nil
  591. }
  592. // If not in staticRoutes, could be a live route (e.g. kr-...)
  593. for _, lr := range m.listLiveRoutesLocked() {
  594. if lr.ID == id {
  595. _ = m.removeRouteFromKernel(lr)
  596. return nil
  597. }
  598. }
  599. return fmt.Errorf("route not found")
  600. }
  601. func (m *RoutingManager) SetRouteState(id string, disabled bool) error {
  602. m.mu.Lock()
  603. defer m.mu.Unlock()
  604. for i := range m.staticRoutes {
  605. if m.staticRoutes[i].ID == id {
  606. m.staticRoutes[i].Disabled = disabled
  607. if !disabled && m.staticRoutes[i].HealthStatus != "offline" {
  608. m.staticRoutes[i].Active = true
  609. } else if disabled {
  610. m.staticRoutes[i].Active = false
  611. }
  612. _ = m.save()
  613. _ = m.syncRouteGroupToKernel(m.staticRoutes[i].DstAddress, m.staticRoutes[i].RoutingTable, m.staticRoutes[i].Distance)
  614. return nil
  615. }
  616. }
  617. // If not in staticRoutes, could be a kernel static route to disable
  618. if disabled {
  619. for _, lr := range m.listLiveRoutesLocked() {
  620. if lr.ID == id {
  621. lr.Disabled = true
  622. lr.Static = true
  623. lr.Active = false
  624. lr.ID = fmt.Sprintf("route-%d", time.Now().UnixNano())
  625. m.staticRoutes = append(m.staticRoutes, lr)
  626. _ = m.save()
  627. _ = m.syncRouteGroupToKernel(lr.DstAddress, lr.RoutingTable, lr.Distance)
  628. return nil
  629. }
  630. }
  631. }
  632. return fmt.Errorf("route not found")
  633. }
  634. func normalizeDst(dst string) string {
  635. dst = strings.TrimSpace(dst)
  636. if dst == "" || dst == "default" || dst == "0.0.0.0/0" {
  637. return "default"
  638. }
  639. if !strings.Contains(dst, "/") && net.ParseIP(dst) != nil {
  640. return dst + "/32"
  641. }
  642. return dst
  643. }
  644. func (m *RoutingManager) syncRouteGroupToKernel(dstAddress, routingTable string, distance int) error {
  645. normDst := normalizeDst(dstAddress)
  646. tbl := strings.TrimSpace(routingTable)
  647. if tbl == "" {
  648. tbl = "main"
  649. }
  650. var activeRoutes []RouteItem
  651. for _, r := range m.staticRoutes {
  652. rDst := normalizeDst(r.DstAddress)
  653. rTbl := strings.TrimSpace(r.RoutingTable)
  654. if rTbl == "" {
  655. rTbl = "main"
  656. }
  657. if rDst == normDst && rTbl == tbl && r.Distance == distance {
  658. if !r.Disabled && r.Active && r.HealthStatus != "offline" {
  659. activeRoutes = append(activeRoutes, r)
  660. }
  661. }
  662. }
  663. if len(activeRoutes) == 0 {
  664. // Safety guard: if all default routes are marked offline, do NOT delete the default route into a blackout!
  665. // Fallback to keeping all enabled static routes in the group active so recovery traffic and emergency pings can flow.
  666. if normDst == "default" {
  667. var fallbackRoutes []RouteItem
  668. for _, r := range m.staticRoutes {
  669. rDst := normalizeDst(r.DstAddress)
  670. rTbl := strings.TrimSpace(r.RoutingTable)
  671. if rTbl == "" {
  672. rTbl = "main"
  673. }
  674. if rDst == normDst && rTbl == tbl && r.Distance == distance && !r.Disabled {
  675. fallbackRoutes = append(fallbackRoutes, r)
  676. }
  677. }
  678. if len(fallbackRoutes) > 0 {
  679. log.Printf("[RouteManager] All default routes offline in group! Keeping fallback routes active to prevent blackout")
  680. activeRoutes = fallbackRoutes
  681. }
  682. }
  683. }
  684. if len(activeRoutes) == 0 {
  685. args := []string{"route", "del", normDst}
  686. if distance > 0 {
  687. args = append(args, "metric", strconv.Itoa(distance))
  688. }
  689. if tbl != "main" {
  690. args = append(args, "table", tbl)
  691. }
  692. args = append(args, "proto", "static")
  693. _ = exec.Command("ip", args...).Run()
  694. return nil
  695. }
  696. if len(activeRoutes) == 1 {
  697. r := activeRoutes[0]
  698. args := []string{"route", "replace", normDst}
  699. gw := strings.TrimSpace(r.Gateway)
  700. if gw != "" {
  701. if net.ParseIP(gw) != nil {
  702. args = append(args, "via", gw)
  703. } else {
  704. args = append(args, "dev", gw)
  705. }
  706. }
  707. iface := strings.TrimSpace(r.Interface)
  708. if iface != "" && gw != iface && (gw == "" || net.ParseIP(gw) != nil) {
  709. args = append(args, "dev", iface)
  710. }
  711. if gw != "" && iface != "" && net.ParseIP(gw) != nil {
  712. args = append(args, "onlink")
  713. }
  714. if distance > 0 {
  715. args = append(args, "metric", strconv.Itoa(distance))
  716. }
  717. if tbl != "main" {
  718. args = append(args, "table", tbl)
  719. }
  720. args = append(args, "proto", "static")
  721. cmd := exec.Command("ip", args...)
  722. if out, err := cmd.CombinedOutput(); err != nil {
  723. return fmt.Errorf("failed to apply route (%s): %s: %w", strings.Join(args, " "), string(out), err)
  724. }
  725. return nil
  726. }
  727. // Multiple routes: ECMP multipath route
  728. args := []string{"route", "replace", normDst, "scope", "global"}
  729. if distance > 0 {
  730. args = append(args, "metric", strconv.Itoa(distance))
  731. }
  732. if tbl != "main" {
  733. args = append(args, "table", tbl)
  734. }
  735. args = append(args, "proto", "static")
  736. for _, r := range activeRoutes {
  737. args = append(args, "nexthop")
  738. gw := strings.TrimSpace(r.Gateway)
  739. if gw != "" && net.ParseIP(gw) != nil {
  740. args = append(args, "via", gw)
  741. }
  742. iface := strings.TrimSpace(r.Interface)
  743. if iface == "" && gw != "" && net.ParseIP(gw) == nil {
  744. iface = gw
  745. }
  746. if iface != "" {
  747. args = append(args, "dev", iface)
  748. }
  749. if gw != "" && iface != "" && net.ParseIP(gw) != nil {
  750. args = append(args, "onlink")
  751. }
  752. args = append(args, "weight", "1")
  753. }
  754. cmd := exec.Command("ip", args...)
  755. if out, err := cmd.CombinedOutput(); err != nil {
  756. return fmt.Errorf("failed to apply multipath route (%s): %s: %w", strings.Join(args, " "), string(out), err)
  757. }
  758. return nil
  759. }
  760. func (m *RoutingManager) applyRouteToKernel(r RouteItem) error {
  761. return m.syncRouteGroupToKernel(r.DstAddress, r.RoutingTable, r.Distance)
  762. }
  763. func (m *RoutingManager) removeRouteFromKernel(r RouteItem) error {
  764. return m.syncRouteGroupToKernel(r.DstAddress, r.RoutingTable, r.Distance)
  765. }
  766. // ==================== Policy Rules (/ip route rule) ====================
  767. func (m *RoutingManager) ListPolicyRules() []PolicyRuleItem {
  768. m.mu.Lock()
  769. defer m.mu.Unlock()
  770. // Query live rules from kernel
  771. rules, err := netlink.RuleList(netlink.FAMILY_V4)
  772. if err != nil {
  773. return m.policyRules
  774. }
  775. tableNames := m.getTableMap()
  776. var res []PolicyRuleItem
  777. userRulesByPrio := make(map[int]PolicyRuleItem)
  778. for _, pr := range m.policyRules {
  779. userRulesByPrio[pr.Priority] = pr
  780. }
  781. for _, r := range rules {
  782. tableName := "main"
  783. if name, ok := tableNames[r.Table]; ok {
  784. tableName = name
  785. } else if r.Table != 254 && r.Table != 0 {
  786. tableName = strconv.Itoa(r.Table)
  787. }
  788. src := ""
  789. if r.Src != nil {
  790. src = r.Src.String()
  791. }
  792. dst := ""
  793. if r.Dst != nil {
  794. dst = r.Dst.String()
  795. }
  796. markStr := ""
  797. if r.Mark > 0 {
  798. markStr = fmt.Sprintf("0x%x (%d)", r.Mark, r.Mark)
  799. }
  800. id := fmt.Sprintf("rule-%d-%d", r.Priority, r.Table)
  801. comment := ""
  802. isDefault := false
  803. isSystem := false
  804. if r.Priority == 0 || r.Priority == 32766 || r.Priority == 32767 {
  805. isDefault = true
  806. isSystem = true
  807. } else if userRule, ok := userRulesByPrio[r.Priority]; ok {
  808. if userRule.ID != "" {
  809. id = userRule.ID
  810. }
  811. comment = userRule.Comment
  812. isDefault = false
  813. isSystem = false
  814. } else {
  815. // System/daemon rule not added by user in proxmox-router (e.g. WireGuard, IPsec)
  816. isSystem = true
  817. }
  818. res = append(res, PolicyRuleItem{
  819. ID: id,
  820. Priority: r.Priority,
  821. SrcAddress: src,
  822. DstAddress: dst,
  823. RoutingMark: markStr,
  824. Table: tableName,
  825. TableID: r.Table,
  826. Action: "lookup",
  827. Comment: comment,
  828. IsDefault: isDefault,
  829. IsSystem: isSystem,
  830. })
  831. }
  832. return res
  833. }
  834. func (m *RoutingManager) SavePolicyRule(pr PolicyRuleItem) error {
  835. m.mu.Lock()
  836. defer m.mu.Unlock()
  837. if pr.Table == "" {
  838. pr.Table = "main"
  839. }
  840. if pr.ID == "" {
  841. pr.ID = fmt.Sprintf("prule-%d", time.Now().UnixNano())
  842. }
  843. if pr.Priority == 0 {
  844. pr.Priority = 1000 + len(m.policyRules)*10
  845. }
  846. if err := m.applyRuleToKernel(pr); err != nil {
  847. return err
  848. }
  849. m.policyRules = append(m.policyRules, pr)
  850. return m.save()
  851. }
  852. func (m *RoutingManager) DeletePolicyRule(id string) error {
  853. m.mu.Lock()
  854. defer m.mu.Unlock()
  855. var updated []PolicyRuleItem
  856. var target *PolicyRuleItem
  857. for _, pr := range m.policyRules {
  858. if pr.ID == id || fmt.Sprintf("rule-%d-%d", pr.Priority, pr.TableID) == id {
  859. target = &pr
  860. } else {
  861. updated = append(updated, pr)
  862. }
  863. }
  864. if target != nil {
  865. _ = m.removeRuleFromKernel(*target)
  866. } else if strings.HasPrefix(id, "rule-") {
  867. parts := strings.Split(id, "-")
  868. if len(parts) >= 2 {
  869. if prio, err := strconv.Atoi(parts[1]); err == nil && prio > 0 {
  870. _ = exec.Command("ip", "rule", "del", "pref", strconv.Itoa(prio)).Run()
  871. }
  872. }
  873. }
  874. m.policyRules = updated
  875. return m.save()
  876. }
  877. func (m *RoutingManager) applyRuleToKernel(pr PolicyRuleItem) error {
  878. args := []string{"rule", "add"}
  879. if pr.Priority > 0 {
  880. args = append(args, "pref", strconv.Itoa(pr.Priority))
  881. }
  882. if pr.SrcAddress != "" {
  883. args = append(args, "from", pr.SrcAddress)
  884. }
  885. if pr.DstAddress != "" {
  886. args = append(args, "to", pr.DstAddress)
  887. }
  888. if pr.RoutingMark != "" {
  889. // Clean mark string if it has (hex)
  890. markClean := pr.RoutingMark
  891. if strings.Contains(markClean, "(") {
  892. markClean = strings.TrimSpace(markClean[:strings.Index(markClean, "(")])
  893. }
  894. args = append(args, "fwmark", markClean)
  895. }
  896. if pr.Table != "" {
  897. args = append(args, "lookup", pr.Table)
  898. }
  899. cmd := exec.Command("ip", args...)
  900. out, err := cmd.CombinedOutput()
  901. if err != nil {
  902. return fmt.Errorf("failed to add rule (%s): %s: %w", strings.Join(args, " "), string(out), err)
  903. }
  904. return nil
  905. }
  906. func (m *RoutingManager) removeRuleFromKernel(pr PolicyRuleItem) error {
  907. if pr.Priority > 0 {
  908. _ = exec.Command("ip", "rule", "del", "pref", strconv.Itoa(pr.Priority)).Run()
  909. return nil
  910. }
  911. args := []string{"rule", "del"}
  912. if pr.RoutingMark != "" {
  913. markClean := pr.RoutingMark
  914. if strings.Contains(markClean, "(") {
  915. markClean = strings.TrimSpace(markClean[:strings.Index(markClean, "(")])
  916. }
  917. args = append(args, "fwmark", markClean)
  918. }
  919. if pr.Table != "" {
  920. args = append(args, "lookup", pr.Table)
  921. }
  922. _ = exec.Command("ip", args...).Run()
  923. return nil
  924. }
  925. // ==================== Routing Tables ====================
  926. func (m *RoutingManager) ListTables() []RoutingTableItem {
  927. m.mu.Lock()
  928. defer m.mu.Unlock()
  929. tables := []RoutingTableItem{
  930. {Name: "main", TableID: 254, Comment: "Default system routing table"},
  931. {Name: "default", TableID: 253, Comment: "Default fallback table"},
  932. {Name: "local", TableID: 255, Comment: "Local kernel device table"},
  933. }
  934. tables = append(tables, m.customTables...)
  935. return tables
  936. }
  937. func (m *RoutingManager) SaveTable(t RoutingTableItem) error {
  938. m.mu.Lock()
  939. defer m.mu.Unlock()
  940. if t.Name == "" {
  941. return fmt.Errorf("table name is required")
  942. }
  943. if t.TableID == 0 {
  944. // Auto assign FIB ID starting at 100
  945. t.TableID = 100 + len(m.customTables)
  946. }
  947. for _, existing := range m.customTables {
  948. if existing.Name == t.Name || existing.TableID == t.TableID {
  949. return fmt.Errorf("table with name %q or ID %d already exists", t.Name, t.TableID)
  950. }
  951. }
  952. m.customTables = append(m.customTables, t)
  953. _ = m.save()
  954. m.syncCustomTablesToSystem()
  955. return nil
  956. }
  957. func (m *RoutingManager) getTableMap() map[int]string {
  958. tableMap := map[int]string{
  959. 254: "main",
  960. 253: "default",
  961. 255: "local",
  962. }
  963. // Read /etc/iproute2/rt_tables
  964. if f, err := os.Open(RTTableFilePath); err == nil {
  965. defer f.Close()
  966. scanner := bufio.NewScanner(f)
  967. for scanner.Scan() {
  968. line := strings.TrimSpace(scanner.Text())
  969. if line == "" || strings.HasPrefix(line, "#") {
  970. continue
  971. }
  972. parts := strings.Fields(line)
  973. if len(parts) >= 2 {
  974. if id, err := strconv.Atoi(parts[0]); err == nil {
  975. tableMap[id] = parts[1]
  976. }
  977. }
  978. }
  979. }
  980. for _, t := range m.customTables {
  981. tableMap[t.TableID] = t.Name
  982. }
  983. return tableMap
  984. }
  985. func (m *RoutingManager) Stop() {
  986. if m.stopChan != nil {
  987. select {
  988. case <-m.stopChan:
  989. default:
  990. close(m.stopChan)
  991. }
  992. }
  993. }
  994. // RenameInterface updates static routes and policy rules when an interface is renamed.
  995. func (m *RoutingManager) RenameInterface(oldName, newName string) {
  996. m.mu.Lock()
  997. defer m.mu.Unlock()
  998. changed := false
  999. for i := range m.staticRoutes {
  1000. if m.staticRoutes[i].Interface == oldName {
  1001. m.staticRoutes[i].Interface = newName
  1002. changed = true
  1003. }
  1004. if m.staticRoutes[i].Gateway == oldName {
  1005. m.staticRoutes[i].Gateway = newName
  1006. changed = true
  1007. }
  1008. }
  1009. if changed {
  1010. _ = m.save()
  1011. }
  1012. }