manager.go 25 KB

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