manager.go 23 KB

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