manager.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593
  1. package dhcp
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "net"
  6. "os"
  7. "os/exec"
  8. "regexp"
  9. "sort"
  10. "strconv"
  11. "strings"
  12. "sync"
  13. "syscall"
  14. "time"
  15. "github.com/vishvananda/netlink"
  16. )
  17. const (
  18. DHCPConfigFile = "/etc/proxmox-router/dhcp_clients.json"
  19. DHCPRunDir = "/run"
  20. DHCPLeaseDir = "/var/lib/dhcp"
  21. DHCPConfDir = "/etc/dhcp"
  22. )
  23. var (
  24. leaseBlockRegex = regexp.MustCompile(`(?s)lease\s+\{([^}]+)\}`)
  25. fixedAddrRegex = regexp.MustCompile(`fixed-address\s+([0-9.]+);`)
  26. subnetMaskRegex = regexp.MustCompile(`option subnet-mask\s+([0-9.]+);`)
  27. routersRegex = regexp.MustCompile(`option routers\s+([0-9.]+);`)
  28. dhcpServerRegex = regexp.MustCompile(`option dhcp-server-identifier\s+([0-9.]+);`)
  29. dnsServersRegex = regexp.MustCompile(`option domain-name-servers\s+([^;]+);`)
  30. domainNameRegex = regexp.MustCompile(`option domain-name\s+"([^"]+)";`)
  31. leaseTimeRegex = regexp.MustCompile(`option dhcp-lease-time\s+(\d+);`)
  32. expireTimeRegex = regexp.MustCompile(`expire\s+\d+\s+([0-9/]+ [0-9:]+);`)
  33. renewTimeRegex = regexp.MustCompile(`renew\s+\d+\s+([0-9/]+ [0-9:]+);`)
  34. validInterfaceRegex = regexp.MustCompile(`^[a-zA-Z0-9.:_-]+$`)
  35. )
  36. // DHCPConfigStore wraps the persisted configuration.
  37. type DHCPConfigStore struct {
  38. Clients []*DHCPClient `json:"clients"`
  39. }
  40. // Manager orchestrates all DHCP client instances.
  41. type Manager struct {
  42. mu sync.RWMutex
  43. storePath string
  44. clients map[string]*DHCPClient
  45. }
  46. // NewManager initializes the DHCP Manager and restores saved clients.
  47. func NewManager() *Manager {
  48. _ = os.MkdirAll("/etc/proxmox-router", 0755)
  49. _ = os.MkdirAll(DHCPLeaseDir, 0755)
  50. _ = os.MkdirAll(DHCPConfDir, 0755)
  51. m := &Manager{
  52. storePath: DHCPConfigFile,
  53. clients: make(map[string]*DHCPClient),
  54. }
  55. _ = m.Load()
  56. return m
  57. }
  58. // Load loads saved clients from disk and ensures enabled clients are started.
  59. func (m *Manager) Load() error {
  60. m.mu.Lock()
  61. defer m.mu.Unlock()
  62. data, err := os.ReadFile(m.storePath)
  63. if err != nil {
  64. if os.IsNotExist(err) {
  65. return nil
  66. }
  67. return err
  68. }
  69. var store DHCPConfigStore
  70. if err := json.Unmarshal(data, &store); err != nil {
  71. return err
  72. }
  73. for _, c := range store.Clients {
  74. if c.ID == "" {
  75. c.ID = "dhcp-" + c.Interface
  76. }
  77. m.clients[c.ID] = c
  78. }
  79. // Startup active clients
  80. go func() {
  81. time.Sleep(1 * time.Second)
  82. m.SyncAll()
  83. }()
  84. return nil
  85. }
  86. // Save persists the current DHCP clients to disk.
  87. func (m *Manager) Save() error {
  88. store := DHCPConfigStore{
  89. Clients: make([]*DHCPClient, 0, len(m.clients)),
  90. }
  91. for _, c := range m.clients {
  92. store.Clients = append(store.Clients, c)
  93. }
  94. data, err := json.MarshalIndent(store, "", " ")
  95. if err != nil {
  96. return err
  97. }
  98. tmpFile := m.storePath + ".tmp"
  99. if err := os.WriteFile(tmpFile, data, 0644); err != nil {
  100. return err
  101. }
  102. return os.Rename(tmpFile, m.storePath)
  103. }
  104. // SyncAll refreshes status and launches any enabled clients that aren't running.
  105. func (m *Manager) SyncAll() {
  106. m.mu.Lock()
  107. defer m.mu.Unlock()
  108. for _, c := range m.clients {
  109. m.updateStatus(c)
  110. if c.Enabled && c.Status != "bound" && c.Status != "searching" {
  111. _ = m.startClient(c)
  112. m.updateStatus(c)
  113. }
  114. }
  115. }
  116. // ListClients returns all DHCP clients with real-time lease info.
  117. func (m *Manager) ListClients() []*DHCPClient {
  118. m.mu.Lock()
  119. defer m.mu.Unlock()
  120. res := make([]*DHCPClient, 0, len(m.clients))
  121. for _, c := range m.clients {
  122. m.updateStatus(c)
  123. res = append(res, c)
  124. }
  125. sort.Slice(res, func(i, j int) bool {
  126. return res[i].Interface < res[j].Interface
  127. })
  128. return res
  129. }
  130. // GetSummary returns summary KPIs.
  131. func (m *Manager) GetSummary() DHCPSummary {
  132. clients := m.ListClients()
  133. sum := DHCPSummary{
  134. TotalClients: len(clients),
  135. }
  136. for _, c := range clients {
  137. switch c.Status {
  138. case "bound":
  139. sum.BoundClients++
  140. case "searching":
  141. sum.Searching++
  142. case "disabled":
  143. sum.Disabled++
  144. }
  145. }
  146. return sum
  147. }
  148. // GetClient retrieves a single client by ID.
  149. func (m *Manager) GetClient(id string) (*DHCPClient, error) {
  150. m.mu.Lock()
  151. defer m.mu.Unlock()
  152. c, ok := m.clients[id]
  153. if !ok {
  154. return nil, fmt.Errorf("dhcp client %s not found", id)
  155. }
  156. m.updateStatus(c)
  157. return c, nil
  158. }
  159. // AddClient creates and optionally starts a DHCP client on an interface.
  160. func (m *Manager) AddClient(req CreateDHCPClientRequest) (*DHCPClient, error) {
  161. iface := strings.TrimSpace(req.Interface)
  162. if iface == "" || !validInterfaceRegex.MatchString(iface) {
  163. return nil, fmt.Errorf("invalid interface name")
  164. }
  165. // Verify interface exists on system
  166. if _, err := net.InterfaceByName(iface); err != nil {
  167. return nil, fmt.Errorf("interface %s does not exist on host", iface)
  168. }
  169. m.mu.Lock()
  170. defer m.mu.Unlock()
  171. id := "dhcp-" + iface
  172. if _, exists := m.clients[id]; exists {
  173. return nil, fmt.Errorf("dhcp client already exists for interface %s", iface)
  174. }
  175. dist := req.DefaultRouteDistance
  176. if dist <= 0 {
  177. dist = 1
  178. }
  179. c := &DHCPClient{
  180. ID: id,
  181. Interface: iface,
  182. Enabled: req.Enabled,
  183. AddDefaultRoute: req.AddDefaultRoute,
  184. DefaultRouteDistance: dist,
  185. UsePeerDNS: req.UsePeerDNS,
  186. UsePeerNTP: req.UsePeerNTP,
  187. Comment: req.Comment,
  188. Status: "stopped",
  189. DNSServers: []string{},
  190. }
  191. if c.Enabled {
  192. if err := m.startClient(c); err != nil {
  193. c.Status = "error"
  194. }
  195. } else {
  196. c.Status = "disabled"
  197. }
  198. m.updateStatus(c)
  199. m.clients[id] = c
  200. _ = m.Save()
  201. return c, nil
  202. }
  203. // UpdateClient modifies client settings and restarts if needed.
  204. func (m *Manager) UpdateClient(id string, req UpdateDHCPClientRequest) (*DHCPClient, error) {
  205. m.mu.Lock()
  206. defer m.mu.Unlock()
  207. c, ok := m.clients[id]
  208. if !ok {
  209. return nil, fmt.Errorf("dhcp client %s not found", id)
  210. }
  211. needsRestart := false
  212. if req.AddDefaultRoute != nil && *req.AddDefaultRoute != c.AddDefaultRoute {
  213. c.AddDefaultRoute = *req.AddDefaultRoute
  214. needsRestart = true
  215. }
  216. if req.DefaultRouteDistance != nil && *req.DefaultRouteDistance != c.DefaultRouteDistance {
  217. c.DefaultRouteDistance = *req.DefaultRouteDistance
  218. needsRestart = true
  219. }
  220. if req.UsePeerDNS != nil && *req.UsePeerDNS != c.UsePeerDNS {
  221. c.UsePeerDNS = *req.UsePeerDNS
  222. needsRestart = true
  223. }
  224. if req.UsePeerNTP != nil && *req.UsePeerNTP != c.UsePeerNTP {
  225. c.UsePeerNTP = *req.UsePeerNTP
  226. needsRestart = true
  227. }
  228. if req.Comment != nil {
  229. c.Comment = *req.Comment
  230. }
  231. if req.Enabled != nil {
  232. if *req.Enabled != c.Enabled {
  233. c.Enabled = *req.Enabled
  234. if c.Enabled {
  235. _ = m.startClient(c)
  236. } else {
  237. _ = m.stopClient(c)
  238. c.Status = "disabled"
  239. }
  240. needsRestart = false
  241. }
  242. }
  243. if needsRestart && c.Enabled {
  244. _ = m.stopClient(c)
  245. _ = m.startClient(c)
  246. }
  247. m.updateStatus(c)
  248. _ = m.Save()
  249. return c, nil
  250. }
  251. // ToggleState enables or disables a DHCP client.
  252. func (m *Manager) ToggleState(id string, enabled bool) error {
  253. m.mu.Lock()
  254. defer m.mu.Unlock()
  255. c, ok := m.clients[id]
  256. if !ok {
  257. return fmt.Errorf("dhcp client %s not found", id)
  258. }
  259. c.Enabled = enabled
  260. if enabled {
  261. if err := m.startClient(c); err != nil {
  262. c.Status = "error"
  263. }
  264. } else {
  265. _ = m.stopClient(c)
  266. c.Status = "disabled"
  267. }
  268. m.updateStatus(c)
  269. return m.Save()
  270. }
  271. // RenewLease forces dhclient to renew its lease.
  272. func (m *Manager) RenewLease(id string) error {
  273. m.mu.Lock()
  274. defer m.mu.Unlock()
  275. c, ok := m.clients[id]
  276. if !ok {
  277. return fmt.Errorf("dhcp client %s not found", id)
  278. }
  279. if !c.Enabled {
  280. return fmt.Errorf("cannot renew disabled dhcp client")
  281. }
  282. _ = m.stopClient(c)
  283. time.Sleep(200 * time.Millisecond)
  284. err := m.startClient(c)
  285. m.updateStatus(c)
  286. return err
  287. }
  288. // ReleaseLease sends DHCPRELEASE and removes the IP address.
  289. func (m *Manager) ReleaseLease(id string) error {
  290. m.mu.Lock()
  291. defer m.mu.Unlock()
  292. c, ok := m.clients[id]
  293. if !ok {
  294. return fmt.Errorf("dhcp client %s not found", id)
  295. }
  296. pidFile := fmt.Sprintf("%s/dhclient.%s.pid", DHCPRunDir, c.Interface)
  297. leaseFile := fmt.Sprintf("%s/dhclient.%s.leases", DHCPLeaseDir, c.Interface)
  298. cmd := exec.Command("dhclient", "-4", "-r", "-pf", pidFile, "-lf", leaseFile, c.Interface)
  299. _ = cmd.Run()
  300. _ = m.stopClient(c)
  301. c.Status = "stopped"
  302. c.IPAddress = ""
  303. c.Gateway = ""
  304. c.ExpiresIn = ""
  305. return m.Save()
  306. }
  307. // DeleteClient stops the client and removes it from configuration.
  308. func (m *Manager) DeleteClient(id string) error {
  309. m.mu.Lock()
  310. defer m.mu.Unlock()
  311. c, ok := m.clients[id]
  312. if !ok {
  313. return fmt.Errorf("dhcp client %s not found", id)
  314. }
  315. _ = m.stopClient(c)
  316. // Clean up conf & lease files
  317. _ = os.Remove(fmt.Sprintf("%s/dhclient.%s.pid", DHCPRunDir, c.Interface))
  318. _ = os.Remove(fmt.Sprintf("%s/dhclient-%s.conf", DHCPConfDir, c.Interface))
  319. delete(m.clients, id)
  320. return m.Save()
  321. }
  322. // RenameInterface updates DHCP client interface bindings when an interface is renamed.
  323. func (m *Manager) RenameInterface(oldName, newName string) {
  324. m.mu.Lock()
  325. defer m.mu.Unlock()
  326. for _, c := range m.clients {
  327. if c.Interface == oldName {
  328. wasRunning := c.Enabled
  329. if wasRunning {
  330. _ = m.stopClient(c)
  331. }
  332. c.Interface = newName
  333. if wasRunning {
  334. _ = m.startClient(c)
  335. }
  336. _ = m.Save()
  337. break
  338. }
  339. }
  340. }
  341. func (m *Manager) startClient(c *DHCPClient) error {
  342. pidFile := fmt.Sprintf("%s/dhclient.%s.pid", DHCPRunDir, c.Interface)
  343. leaseFile := fmt.Sprintf("%s/dhclient.%s.leases", DHCPLeaseDir, c.Interface)
  344. confFile := fmt.Sprintf("%s/dhclient-%s.conf", DHCPConfDir, c.Interface)
  345. // Generate custom dhclient config
  346. var sb strings.Builder
  347. sb.WriteString("# RouterOS-style dhclient configuration\n")
  348. sb.WriteString("send host-name = gethostname();\n")
  349. sb.WriteString("request subnet-mask, broadcast-address, time-offset")
  350. if c.AddDefaultRoute {
  351. sb.WriteString(", routers")
  352. }
  353. if c.UsePeerDNS {
  354. sb.WriteString(", domain-name, domain-name-servers, domain-search, host-name")
  355. }
  356. if c.UsePeerNTP {
  357. sb.WriteString(", ntp-servers")
  358. }
  359. sb.WriteString(";\n")
  360. _ = os.WriteFile(confFile, []byte(sb.String()), 0644)
  361. // If already running, kill first
  362. _ = m.stopClient(c)
  363. // Launch dhclient in background
  364. cmd := exec.Command("dhclient", "-4", "-nw", "-pf", pidFile, "-lf", leaseFile, "-cf", confFile, c.Interface)
  365. if err := cmd.Run(); err != nil {
  366. return fmt.Errorf("failed to start dhclient: %w", err)
  367. }
  368. c.Status = "searching"
  369. return nil
  370. }
  371. func (m *Manager) stopClient(c *DHCPClient) error {
  372. pidFile := fmt.Sprintf("%s/dhclient.%s.pid", DHCPRunDir, c.Interface)
  373. // Check PID file
  374. if pidBytes, err := os.ReadFile(pidFile); err == nil {
  375. if pid, err := strconv.Atoi(strings.TrimSpace(string(pidBytes))); err == nil && pid > 0 {
  376. _ = syscall.Kill(pid, syscall.SIGTERM)
  377. time.Sleep(100 * time.Millisecond)
  378. if err := syscall.Kill(pid, 0); err == nil {
  379. _ = syscall.Kill(pid, syscall.SIGKILL)
  380. }
  381. }
  382. }
  383. _ = os.Remove(pidFile)
  384. c.PID = 0
  385. return nil
  386. }
  387. func (m *Manager) updateStatus(c *DHCPClient) {
  388. pidFile := fmt.Sprintf("%s/dhclient.%s.pid", DHCPRunDir, c.Interface)
  389. leaseFile := fmt.Sprintf("%s/dhclient.%s.leases", DHCPLeaseDir, c.Interface)
  390. // Check if process is alive
  391. isRunning := false
  392. if pidBytes, err := os.ReadFile(pidFile); err == nil {
  393. if pid, err := strconv.Atoi(strings.TrimSpace(string(pidBytes))); err == nil && pid > 0 {
  394. if err := syscall.Kill(pid, 0); err == nil {
  395. isRunning = true
  396. c.PID = pid
  397. }
  398. }
  399. }
  400. if !isRunning {
  401. c.PID = 0
  402. if !c.Enabled {
  403. c.Status = "disabled"
  404. } else {
  405. c.Status = "stopped"
  406. }
  407. } else {
  408. c.Status = "searching"
  409. }
  410. // Read interface IPv4 addresses via netlink
  411. if link, err := netlink.LinkByName(c.Interface); err == nil {
  412. if addrs, err := netlink.AddrList(link, netlink.FAMILY_V4); err == nil && len(addrs) > 0 {
  413. for _, a := range addrs {
  414. ip := a.IPNet.IP.String()
  415. if !strings.HasPrefix(ip, "127.") && !strings.HasPrefix(ip, "169.254.") {
  416. c.IPAddress = a.IPNet.String()
  417. if isRunning {
  418. c.Status = "bound"
  419. }
  420. break
  421. }
  422. }
  423. }
  424. }
  425. // Parse lease file for rich metadata (gateway, dns, lease times)
  426. m.parseLeaseFile(leaseFile, c)
  427. }
  428. func (m *Manager) parseLeaseFile(leaseFile string, c *DHCPClient) {
  429. data, err := os.ReadFile(leaseFile)
  430. if err != nil {
  431. return
  432. }
  433. // Find the last lease block in the file
  434. matches := leaseBlockRegex.FindAllStringSubmatch(string(data), -1)
  435. if len(matches) == 0 {
  436. return
  437. }
  438. lastLease := matches[len(matches)-1][1]
  439. if m := fixedAddrRegex.FindStringSubmatch(lastLease); len(m) > 1 {
  440. ip := m[1]
  441. mask := "24"
  442. if sm := subnetMaskRegex.FindStringSubmatch(lastLease); len(sm) > 1 {
  443. mask = subnetToCIDR(sm[1])
  444. }
  445. if c.IPAddress == "" {
  446. c.IPAddress = fmt.Sprintf("%s/%s", ip, mask)
  447. }
  448. }
  449. if m := routersRegex.FindStringSubmatch(lastLease); len(m) > 1 {
  450. c.Gateway = m[1]
  451. }
  452. if m := dhcpServerRegex.FindStringSubmatch(lastLease); len(m) > 1 {
  453. c.DHCPServer = m[1]
  454. }
  455. if m := dnsServersRegex.FindStringSubmatch(lastLease); len(m) > 1 {
  456. raw := strings.Split(m[1], ",")
  457. dns := make([]string, 0, len(raw))
  458. for _, s := range raw {
  459. trimmed := strings.TrimSpace(s)
  460. if trimmed != "" {
  461. dns = append(dns, trimmed)
  462. }
  463. }
  464. c.DNSServers = dns
  465. }
  466. if m := domainNameRegex.FindStringSubmatch(lastLease); len(m) > 1 {
  467. c.Domain = m[1]
  468. }
  469. if m := leaseTimeRegex.FindStringSubmatch(lastLease); len(m) > 1 {
  470. c.LeaseTime, _ = strconv.Atoi(m[1])
  471. }
  472. if m := expireTimeRegex.FindStringSubmatch(lastLease); len(m) > 1 {
  473. // e.g. "2026/10/26 18:30:00"
  474. if t, err := time.Parse("2006/01/02 15:04:05", m[1]); err == nil {
  475. c.ExpiresAt = t
  476. rem := time.Until(t)
  477. if rem > 0 {
  478. hours := int(rem.Hours())
  479. mins := int(rem.Minutes()) % 60
  480. if hours > 24 {
  481. c.ExpiresIn = fmt.Sprintf("%dd %dh", hours/24, hours%24)
  482. } else if hours > 0 {
  483. c.ExpiresIn = fmt.Sprintf("%dh %dm", hours, mins)
  484. } else {
  485. c.ExpiresIn = fmt.Sprintf("%dm %ds", mins, int(rem.Seconds())%60)
  486. }
  487. } else {
  488. c.ExpiresIn = "Expired"
  489. }
  490. }
  491. }
  492. }
  493. func subnetToCIDR(mask string) string {
  494. parts := strings.Split(mask, ".")
  495. if len(parts) != 4 {
  496. return "24"
  497. }
  498. var count int
  499. for _, p := range parts {
  500. n, _ := strconv.Atoi(p)
  501. for n > 0 {
  502. count += n & 1
  503. n >>= 1
  504. }
  505. }
  506. return strconv.Itoa(count)
  507. }