manager.go 29 KB

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