manager.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572
  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. func (m *Manager) startClient(c *DHCPClient) error {
  323. pidFile := fmt.Sprintf("%s/dhclient.%s.pid", DHCPRunDir, c.Interface)
  324. leaseFile := fmt.Sprintf("%s/dhclient.%s.leases", DHCPLeaseDir, c.Interface)
  325. confFile := fmt.Sprintf("%s/dhclient-%s.conf", DHCPConfDir, c.Interface)
  326. // Generate custom dhclient config
  327. var sb strings.Builder
  328. sb.WriteString("# RouterOS-style dhclient configuration\n")
  329. sb.WriteString("send host-name = gethostname();\n")
  330. sb.WriteString("request subnet-mask, broadcast-address, time-offset")
  331. if c.AddDefaultRoute {
  332. sb.WriteString(", routers")
  333. }
  334. if c.UsePeerDNS {
  335. sb.WriteString(", domain-name, domain-name-servers, domain-search, host-name")
  336. }
  337. if c.UsePeerNTP {
  338. sb.WriteString(", ntp-servers")
  339. }
  340. sb.WriteString(";\n")
  341. _ = os.WriteFile(confFile, []byte(sb.String()), 0644)
  342. // If already running, kill first
  343. _ = m.stopClient(c)
  344. // Launch dhclient in background
  345. cmd := exec.Command("dhclient", "-4", "-nw", "-pf", pidFile, "-lf", leaseFile, "-cf", confFile, c.Interface)
  346. if err := cmd.Run(); err != nil {
  347. return fmt.Errorf("failed to start dhclient: %w", err)
  348. }
  349. c.Status = "searching"
  350. return nil
  351. }
  352. func (m *Manager) stopClient(c *DHCPClient) error {
  353. pidFile := fmt.Sprintf("%s/dhclient.%s.pid", DHCPRunDir, c.Interface)
  354. // Check PID file
  355. if pidBytes, err := os.ReadFile(pidFile); err == nil {
  356. if pid, err := strconv.Atoi(strings.TrimSpace(string(pidBytes))); err == nil && pid > 0 {
  357. _ = syscall.Kill(pid, syscall.SIGTERM)
  358. time.Sleep(100 * time.Millisecond)
  359. if err := syscall.Kill(pid, 0); err == nil {
  360. _ = syscall.Kill(pid, syscall.SIGKILL)
  361. }
  362. }
  363. }
  364. _ = os.Remove(pidFile)
  365. c.PID = 0
  366. return nil
  367. }
  368. func (m *Manager) updateStatus(c *DHCPClient) {
  369. pidFile := fmt.Sprintf("%s/dhclient.%s.pid", DHCPRunDir, c.Interface)
  370. leaseFile := fmt.Sprintf("%s/dhclient.%s.leases", DHCPLeaseDir, c.Interface)
  371. // Check if process is alive
  372. isRunning := false
  373. if pidBytes, err := os.ReadFile(pidFile); err == nil {
  374. if pid, err := strconv.Atoi(strings.TrimSpace(string(pidBytes))); err == nil && pid > 0 {
  375. if err := syscall.Kill(pid, 0); err == nil {
  376. isRunning = true
  377. c.PID = pid
  378. }
  379. }
  380. }
  381. if !isRunning {
  382. c.PID = 0
  383. if !c.Enabled {
  384. c.Status = "disabled"
  385. } else {
  386. c.Status = "stopped"
  387. }
  388. } else {
  389. c.Status = "searching"
  390. }
  391. // Read interface IPv4 addresses via netlink
  392. if link, err := netlink.LinkByName(c.Interface); err == nil {
  393. if addrs, err := netlink.AddrList(link, netlink.FAMILY_V4); err == nil && len(addrs) > 0 {
  394. for _, a := range addrs {
  395. ip := a.IPNet.IP.String()
  396. if !strings.HasPrefix(ip, "127.") && !strings.HasPrefix(ip, "169.254.") {
  397. c.IPAddress = a.IPNet.String()
  398. if isRunning {
  399. c.Status = "bound"
  400. }
  401. break
  402. }
  403. }
  404. }
  405. }
  406. // Parse lease file for rich metadata (gateway, dns, lease times)
  407. m.parseLeaseFile(leaseFile, c)
  408. }
  409. func (m *Manager) parseLeaseFile(leaseFile string, c *DHCPClient) {
  410. data, err := os.ReadFile(leaseFile)
  411. if err != nil {
  412. return
  413. }
  414. // Find the last lease block in the file
  415. matches := leaseBlockRegex.FindAllStringSubmatch(string(data), -1)
  416. if len(matches) == 0 {
  417. return
  418. }
  419. lastLease := matches[len(matches)-1][1]
  420. if m := fixedAddrRegex.FindStringSubmatch(lastLease); len(m) > 1 {
  421. ip := m[1]
  422. mask := "24"
  423. if sm := subnetMaskRegex.FindStringSubmatch(lastLease); len(sm) > 1 {
  424. mask = subnetToCIDR(sm[1])
  425. }
  426. if c.IPAddress == "" {
  427. c.IPAddress = fmt.Sprintf("%s/%s", ip, mask)
  428. }
  429. }
  430. if m := routersRegex.FindStringSubmatch(lastLease); len(m) > 1 {
  431. c.Gateway = m[1]
  432. }
  433. if m := dhcpServerRegex.FindStringSubmatch(lastLease); len(m) > 1 {
  434. c.DHCPServer = m[1]
  435. }
  436. if m := dnsServersRegex.FindStringSubmatch(lastLease); len(m) > 1 {
  437. raw := strings.Split(m[1], ",")
  438. dns := make([]string, 0, len(raw))
  439. for _, s := range raw {
  440. trimmed := strings.TrimSpace(s)
  441. if trimmed != "" {
  442. dns = append(dns, trimmed)
  443. }
  444. }
  445. c.DNSServers = dns
  446. }
  447. if m := domainNameRegex.FindStringSubmatch(lastLease); len(m) > 1 {
  448. c.Domain = m[1]
  449. }
  450. if m := leaseTimeRegex.FindStringSubmatch(lastLease); len(m) > 1 {
  451. c.LeaseTime, _ = strconv.Atoi(m[1])
  452. }
  453. if m := expireTimeRegex.FindStringSubmatch(lastLease); len(m) > 1 {
  454. // e.g. "2026/10/26 18:30:00"
  455. if t, err := time.Parse("2006/01/02 15:04:05", m[1]); err == nil {
  456. c.ExpiresAt = t
  457. rem := time.Until(t)
  458. if rem > 0 {
  459. hours := int(rem.Hours())
  460. mins := int(rem.Minutes()) % 60
  461. if hours > 24 {
  462. c.ExpiresIn = fmt.Sprintf("%dd %dh", hours/24, hours%24)
  463. } else if hours > 0 {
  464. c.ExpiresIn = fmt.Sprintf("%dh %dm", hours, mins)
  465. } else {
  466. c.ExpiresIn = fmt.Sprintf("%dm %ds", mins, int(rem.Seconds())%60)
  467. }
  468. } else {
  469. c.ExpiresIn = "Expired"
  470. }
  471. }
  472. }
  473. }
  474. func subnetToCIDR(mask string) string {
  475. parts := strings.Split(mask, ".")
  476. if len(parts) != 4 {
  477. return "24"
  478. }
  479. var count int
  480. for _, p := range parts {
  481. n, _ := strconv.Atoi(p)
  482. for n > 0 {
  483. count += n & 1
  484. n >>= 1
  485. }
  486. }
  487. return strconv.Itoa(count)
  488. }