manager.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684
  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/debtik/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/debtik", 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. m.syncDHCPHooksLocked()
  80. // Startup active clients and run periodic background sync
  81. go func() {
  82. time.Sleep(1 * time.Second)
  83. m.SyncAll()
  84. ticker := time.NewTicker(5 * time.Second)
  85. defer ticker.Stop()
  86. for range ticker.C {
  87. m.SyncAll()
  88. }
  89. }()
  90. return nil
  91. }
  92. // Save persists the current DHCP clients to disk.
  93. func (m *Manager) Save() error {
  94. store := DHCPConfigStore{
  95. Clients: make([]*DHCPClient, 0, len(m.clients)),
  96. }
  97. for _, c := range m.clients {
  98. store.Clients = append(store.Clients, c)
  99. }
  100. data, err := json.MarshalIndent(store, "", " ")
  101. if err != nil {
  102. return err
  103. }
  104. tmpFile := m.storePath + ".tmp"
  105. if err := os.WriteFile(tmpFile, data, 0644); err != nil {
  106. return err
  107. }
  108. return os.Rename(tmpFile, m.storePath)
  109. }
  110. // SyncAll refreshes status and launches any enabled clients that aren't running.
  111. func (m *Manager) SyncAll() {
  112. m.mu.Lock()
  113. defer m.mu.Unlock()
  114. for _, c := range m.clients {
  115. m.updateStatus(c)
  116. if c.Enabled && c.Status != "bound" && c.Status != "searching" {
  117. _ = m.startClient(c)
  118. m.updateStatus(c)
  119. }
  120. }
  121. }
  122. // ListClients returns all DHCP clients with real-time lease info.
  123. func (m *Manager) ListClients() []*DHCPClient {
  124. m.mu.Lock()
  125. defer m.mu.Unlock()
  126. res := make([]*DHCPClient, 0, len(m.clients))
  127. for _, c := range m.clients {
  128. m.updateStatus(c)
  129. res = append(res, c)
  130. }
  131. sort.Slice(res, func(i, j int) bool {
  132. return res[i].Interface < res[j].Interface
  133. })
  134. return res
  135. }
  136. // GetSummary returns summary KPIs.
  137. func (m *Manager) GetSummary() DHCPSummary {
  138. clients := m.ListClients()
  139. sum := DHCPSummary{
  140. TotalClients: len(clients),
  141. }
  142. for _, c := range clients {
  143. switch c.Status {
  144. case "bound":
  145. sum.BoundClients++
  146. case "searching":
  147. sum.Searching++
  148. case "disabled":
  149. sum.Disabled++
  150. }
  151. }
  152. return sum
  153. }
  154. // GetClient retrieves a single client by ID.
  155. func (m *Manager) GetClient(id string) (*DHCPClient, error) {
  156. m.mu.Lock()
  157. defer m.mu.Unlock()
  158. c, ok := m.clients[id]
  159. if !ok {
  160. return nil, fmt.Errorf("dhcp client %s not found", id)
  161. }
  162. m.updateStatus(c)
  163. return c, nil
  164. }
  165. // AddClient creates and optionally starts a DHCP client on an interface.
  166. func (m *Manager) AddClient(req CreateDHCPClientRequest) (*DHCPClient, error) {
  167. iface := strings.TrimSpace(req.Interface)
  168. if iface == "" || !validInterfaceRegex.MatchString(iface) {
  169. return nil, fmt.Errorf("invalid interface name")
  170. }
  171. // Verify interface exists on system
  172. if _, err := net.InterfaceByName(iface); err != nil {
  173. return nil, fmt.Errorf("interface %s does not exist on host", iface)
  174. }
  175. m.mu.Lock()
  176. defer m.mu.Unlock()
  177. id := "dhcp-" + iface
  178. if _, exists := m.clients[id]; exists {
  179. return nil, fmt.Errorf("dhcp client already exists for interface %s", iface)
  180. }
  181. dist := req.DefaultRouteDistance
  182. if dist <= 0 {
  183. dist = 1
  184. }
  185. c := &DHCPClient{
  186. ID: id,
  187. Interface: iface,
  188. Enabled: req.Enabled,
  189. AddDefaultRoute: req.AddDefaultRoute,
  190. DefaultRouteDistance: dist,
  191. UsePeerDNS: req.UsePeerDNS,
  192. UsePeerNTP: req.UsePeerNTP,
  193. Comment: req.Comment,
  194. Status: "stopped",
  195. DNSServers: []string{},
  196. }
  197. if c.Enabled {
  198. if err := m.startClient(c); err != nil {
  199. c.Status = "error"
  200. }
  201. } else {
  202. c.Status = "disabled"
  203. }
  204. m.updateStatus(c)
  205. m.clients[id] = c
  206. _ = m.Save()
  207. return c, nil
  208. }
  209. // UpdateClient modifies client settings and restarts if needed.
  210. func (m *Manager) UpdateClient(id string, req UpdateDHCPClientRequest) (*DHCPClient, error) {
  211. m.mu.Lock()
  212. defer m.mu.Unlock()
  213. c, ok := m.clients[id]
  214. if !ok {
  215. return nil, fmt.Errorf("dhcp client %s not found", id)
  216. }
  217. needsRestart := false
  218. if req.AddDefaultRoute != nil && *req.AddDefaultRoute != c.AddDefaultRoute {
  219. c.AddDefaultRoute = *req.AddDefaultRoute
  220. needsRestart = true
  221. }
  222. if req.DefaultRouteDistance != nil && *req.DefaultRouteDistance != c.DefaultRouteDistance {
  223. c.DefaultRouteDistance = *req.DefaultRouteDistance
  224. needsRestart = true
  225. }
  226. if req.UsePeerDNS != nil && *req.UsePeerDNS != c.UsePeerDNS {
  227. c.UsePeerDNS = *req.UsePeerDNS
  228. needsRestart = true
  229. }
  230. if req.UsePeerNTP != nil && *req.UsePeerNTP != c.UsePeerNTP {
  231. c.UsePeerNTP = *req.UsePeerNTP
  232. needsRestart = true
  233. }
  234. if req.Comment != nil {
  235. c.Comment = *req.Comment
  236. }
  237. if req.Enabled != nil {
  238. if *req.Enabled != c.Enabled {
  239. c.Enabled = *req.Enabled
  240. if c.Enabled {
  241. _ = m.startClient(c)
  242. } else {
  243. _ = m.stopClient(c)
  244. c.Status = "disabled"
  245. }
  246. needsRestart = false
  247. }
  248. }
  249. if needsRestart && c.Enabled {
  250. _ = m.stopClient(c)
  251. _ = m.startClient(c)
  252. }
  253. m.updateStatus(c)
  254. _ = m.Save()
  255. return c, nil
  256. }
  257. // ToggleState enables or disables a DHCP client.
  258. func (m *Manager) ToggleState(id string, enabled bool) error {
  259. m.mu.Lock()
  260. defer m.mu.Unlock()
  261. c, ok := m.clients[id]
  262. if !ok {
  263. return fmt.Errorf("dhcp client %s not found", id)
  264. }
  265. c.Enabled = enabled
  266. if enabled {
  267. if err := m.startClient(c); err != nil {
  268. c.Status = "error"
  269. }
  270. } else {
  271. _ = m.stopClient(c)
  272. c.Status = "disabled"
  273. }
  274. m.updateStatus(c)
  275. return m.Save()
  276. }
  277. // RenewLease forces dhclient to renew its lease.
  278. func (m *Manager) RenewLease(id string) error {
  279. m.mu.Lock()
  280. defer m.mu.Unlock()
  281. c, ok := m.clients[id]
  282. if !ok {
  283. return fmt.Errorf("dhcp client %s not found", id)
  284. }
  285. if !c.Enabled {
  286. return fmt.Errorf("cannot renew disabled dhcp client")
  287. }
  288. _ = m.stopClient(c)
  289. time.Sleep(200 * time.Millisecond)
  290. err := m.startClient(c)
  291. m.updateStatus(c)
  292. return err
  293. }
  294. // ReleaseLease sends DHCPRELEASE and removes the IP address.
  295. func (m *Manager) ReleaseLease(id string) error {
  296. m.mu.Lock()
  297. defer m.mu.Unlock()
  298. c, ok := m.clients[id]
  299. if !ok {
  300. return fmt.Errorf("dhcp client %s not found", id)
  301. }
  302. pidFile := fmt.Sprintf("%s/dhclient.%s.pid", DHCPRunDir, c.Interface)
  303. leaseFile := fmt.Sprintf("%s/dhclient.%s.leases", DHCPLeaseDir, c.Interface)
  304. cmd := exec.Command("dhclient", "-4", "-r", "-pf", pidFile, "-lf", leaseFile, c.Interface)
  305. _ = cmd.Run()
  306. _ = m.stopClient(c)
  307. c.Status = "stopped"
  308. c.IPAddress = ""
  309. c.Gateway = ""
  310. c.ExpiresIn = ""
  311. return m.Save()
  312. }
  313. // DeleteClient stops the client and removes it from configuration.
  314. func (m *Manager) DeleteClient(id string) error {
  315. m.mu.Lock()
  316. defer m.mu.Unlock()
  317. c, ok := m.clients[id]
  318. if !ok {
  319. return fmt.Errorf("dhcp client %s not found", id)
  320. }
  321. _ = m.stopClient(c)
  322. // Clean up conf & lease files
  323. _ = os.Remove(fmt.Sprintf("%s/dhclient.%s.pid", DHCPRunDir, c.Interface))
  324. _ = os.Remove(fmt.Sprintf("%s/dhclient-%s.conf", DHCPConfDir, c.Interface))
  325. delete(m.clients, id)
  326. return m.Save()
  327. }
  328. // RenameInterface updates DHCP client interface bindings when an interface is renamed.
  329. func (m *Manager) RenameInterface(oldName, newName string) {
  330. m.mu.Lock()
  331. defer m.mu.Unlock()
  332. for _, c := range m.clients {
  333. if c.Interface == oldName {
  334. wasRunning := c.Enabled
  335. if wasRunning {
  336. _ = m.stopClient(c)
  337. }
  338. c.Interface = newName
  339. if wasRunning {
  340. _ = m.startClient(c)
  341. }
  342. _ = m.Save()
  343. break
  344. }
  345. }
  346. }
  347. func (m *Manager) syncDHCPHooksLocked() {
  348. _ = os.MkdirAll("/etc/dhcp/dhclient-enter-hooks.d", 0755)
  349. _ = os.MkdirAll("/etc/dhcp/dhclient-exit-hooks.d", 0755)
  350. // Remove dangerous legacy exit hook that previously deleted metric 0 routes matching any metric
  351. _ = os.Remove("/etc/dhcp/dhclient-exit-hooks.d/no-default-route")
  352. var noDefaultIfaces []string
  353. for _, client := range m.clients {
  354. if client.Enabled && !client.AddDefaultRoute && client.Interface != "" {
  355. noDefaultIfaces = append(noDefaultIfaces, client.Interface)
  356. }
  357. }
  358. hookPath := "/etc/dhcp/dhclient-enter-hooks.d/no-default-route"
  359. if len(noDefaultIfaces) > 0 {
  360. pattern := strings.Join(noDefaultIfaces, "|")
  361. enterHook := fmt.Sprintf("#!/bin/sh\ncase \"$interface\" in\n %s)\n unset new_routers\n ;;\nesac\n", pattern)
  362. _ = os.WriteFile(hookPath, []byte(enterHook), 0755)
  363. } else {
  364. _ = os.Remove(hookPath)
  365. }
  366. }
  367. func (m *Manager) startClient(c *DHCPClient) error {
  368. pidFile := fmt.Sprintf("%s/dhclient.%s.pid", DHCPRunDir, c.Interface)
  369. leaseFile := fmt.Sprintf("%s/dhclient.%s.leases", DHCPLeaseDir, c.Interface)
  370. confFile := fmt.Sprintf("%s/dhclient-%s.conf", DHCPConfDir, c.Interface)
  371. // Generate custom dhclient config
  372. var sb strings.Builder
  373. sb.WriteString("# RouterOS-style dhclient configuration\n")
  374. sb.WriteString("send host-name = gethostname();\n")
  375. sb.WriteString("request subnet-mask, broadcast-address, time-offset")
  376. if c.AddDefaultRoute {
  377. sb.WriteString(", routers")
  378. }
  379. if c.UsePeerDNS {
  380. sb.WriteString(", domain-name, domain-name-servers, domain-search, host-name")
  381. }
  382. if c.UsePeerNTP {
  383. sb.WriteString(", ntp-servers")
  384. }
  385. sb.WriteString(";\n")
  386. _ = os.WriteFile(confFile, []byte(sb.String()), 0644)
  387. // Ensure enter hooks prevent dhclient from overriding static routes for non-default clients
  388. m.syncDHCPHooksLocked()
  389. // Dynamic host route hook for DHCP server reachability (Distance 0, Dynamic)
  390. serverRouteHook := fmt.Sprintf(`#!/bin/sh
  391. case "$reason" in
  392. BOUND|RENEW|REBIND|REBOOT)
  393. if [ -n "$new_dhcp_server_identifier" ] && [ -n "$new_routers" ]; then
  394. gw="${new_routers%%%% *}"
  395. ip -4 route replace "${new_dhcp_server_identifier}/32" via "$gw" dev "$interface" proto dhcp metric 0 onlink >/dev/null 2>&1 || true
  396. fi
  397. ;;
  398. EXPIRE|FAIL|RELEASE|STOP)
  399. if [ -n "$old_dhcp_server_identifier" ]; then
  400. ip -4 route del "${old_dhcp_server_identifier}/32" dev "$interface" proto dhcp >/dev/null 2>&1 || true
  401. fi
  402. ;;
  403. esac
  404. `)
  405. _ = os.WriteFile("/etc/dhcp/dhclient-exit-hooks.d/dhcp-server-route", []byte(serverRouteHook), 0755)
  406. // If already running, kill first
  407. _ = m.stopClient(c)
  408. // Launch dhclient in background
  409. cmd := exec.Command("dhclient", "-4", "-nw", "-pf", pidFile, "-lf", leaseFile, "-cf", confFile, c.Interface)
  410. if err := cmd.Run(); err != nil {
  411. return fmt.Errorf("failed to start dhclient: %w", err)
  412. }
  413. c.Status = "searching"
  414. return nil
  415. }
  416. func (m *Manager) stopClient(c *DHCPClient) error {
  417. m.removeDHCPServerRoute(c)
  418. pidFile := fmt.Sprintf("%s/dhclient.%s.pid", DHCPRunDir, c.Interface)
  419. // Check PID file
  420. if pidBytes, err := os.ReadFile(pidFile); err == nil {
  421. if pid, err := strconv.Atoi(strings.TrimSpace(string(pidBytes))); err == nil && pid > 0 {
  422. _ = syscall.Kill(pid, syscall.SIGTERM)
  423. time.Sleep(100 * time.Millisecond)
  424. if err := syscall.Kill(pid, 0); err == nil {
  425. _ = syscall.Kill(pid, syscall.SIGKILL)
  426. }
  427. }
  428. }
  429. _ = os.Remove(pidFile)
  430. c.PID = 0
  431. m.syncDHCPHooksLocked()
  432. return nil
  433. }
  434. func (m *Manager) syncDHCPServerRoute(c *DHCPClient) {
  435. if c == nil || c.Interface == "" {
  436. return
  437. }
  438. // Only maintain dynamic server route when client is bound
  439. if c.Status != "bound" || c.DHCPServer == "" || c.Gateway == "" {
  440. return
  441. }
  442. serverIP := net.ParseIP(c.DHCPServer)
  443. if serverIP == nil {
  444. return
  445. }
  446. // If DHCP server is already part of the directly assigned subnet, no explicit host route is required
  447. if c.IPAddress != "" {
  448. if _, ipNet, err := net.ParseCIDR(c.IPAddress); err == nil && ipNet.Contains(serverIP) {
  449. return
  450. }
  451. }
  452. // Install dynamic host route (Distance 0, Dynamic proto dhcp, onlink)
  453. cmd := exec.Command("ip", "route", "replace", c.DHCPServer+"/32", "via", c.Gateway, "dev", c.Interface, "proto", "dhcp", "metric", "0", "onlink")
  454. _ = cmd.Run()
  455. }
  456. func (m *Manager) removeDHCPServerRoute(c *DHCPClient) {
  457. if c == nil || c.DHCPServer == "" || c.Interface == "" {
  458. return
  459. }
  460. cmd := exec.Command("ip", "route", "del", c.DHCPServer+"/32", "dev", c.Interface, "proto", "dhcp")
  461. _ = cmd.Run()
  462. }
  463. func (m *Manager) updateStatus(c *DHCPClient) {
  464. pidFile := fmt.Sprintf("%s/dhclient.%s.pid", DHCPRunDir, c.Interface)
  465. leaseFile := fmt.Sprintf("%s/dhclient.%s.leases", DHCPLeaseDir, c.Interface)
  466. // Check if process is alive
  467. isRunning := false
  468. if pidBytes, err := os.ReadFile(pidFile); err == nil {
  469. if pid, err := strconv.Atoi(strings.TrimSpace(string(pidBytes))); err == nil && pid > 0 {
  470. if err := syscall.Kill(pid, 0); err == nil {
  471. isRunning = true
  472. c.PID = pid
  473. }
  474. }
  475. }
  476. if !isRunning {
  477. c.PID = 0
  478. if !c.Enabled {
  479. c.Status = "disabled"
  480. } else {
  481. c.Status = "stopped"
  482. }
  483. } else {
  484. c.Status = "searching"
  485. }
  486. // Read interface IPv4 addresses via netlink
  487. if link, err := netlink.LinkByName(c.Interface); err == nil {
  488. if addrs, err := netlink.AddrList(link, netlink.FAMILY_V4); err == nil && len(addrs) > 0 {
  489. for _, a := range addrs {
  490. ip := a.IPNet.IP.String()
  491. if !strings.HasPrefix(ip, "127.") && !strings.HasPrefix(ip, "169.254.") {
  492. c.IPAddress = a.IPNet.String()
  493. if isRunning {
  494. c.Status = "bound"
  495. }
  496. break
  497. }
  498. }
  499. }
  500. }
  501. // Parse lease file for rich metadata (gateway, dns, lease times)
  502. m.parseLeaseFile(leaseFile, c)
  503. // Dynamically maintain DHCP Server reachability route (Distance 0, Dynamic)
  504. m.syncDHCPServerRoute(c)
  505. }
  506. func (m *Manager) parseLeaseFile(leaseFile string, c *DHCPClient) {
  507. data, err := os.ReadFile(leaseFile)
  508. if err != nil {
  509. return
  510. }
  511. // Find the last lease block in the file
  512. matches := leaseBlockRegex.FindAllStringSubmatch(string(data), -1)
  513. if len(matches) == 0 {
  514. return
  515. }
  516. lastLease := matches[len(matches)-1][1]
  517. if m := fixedAddrRegex.FindStringSubmatch(lastLease); len(m) > 1 {
  518. ip := m[1]
  519. mask := "24"
  520. if sm := subnetMaskRegex.FindStringSubmatch(lastLease); len(sm) > 1 {
  521. mask = subnetToCIDR(sm[1])
  522. }
  523. if c.IPAddress == "" {
  524. c.IPAddress = fmt.Sprintf("%s/%s", ip, mask)
  525. }
  526. }
  527. if m := routersRegex.FindStringSubmatch(lastLease); len(m) > 1 {
  528. c.Gateway = m[1]
  529. }
  530. if m := dhcpServerRegex.FindStringSubmatch(lastLease); len(m) > 1 {
  531. c.DHCPServer = m[1]
  532. }
  533. if m := dnsServersRegex.FindStringSubmatch(lastLease); len(m) > 1 {
  534. raw := strings.Split(m[1], ",")
  535. dns := make([]string, 0, len(raw))
  536. for _, s := range raw {
  537. trimmed := strings.TrimSpace(s)
  538. if trimmed != "" {
  539. dns = append(dns, trimmed)
  540. }
  541. }
  542. c.DNSServers = dns
  543. }
  544. if m := domainNameRegex.FindStringSubmatch(lastLease); len(m) > 1 {
  545. c.Domain = m[1]
  546. }
  547. if m := leaseTimeRegex.FindStringSubmatch(lastLease); len(m) > 1 {
  548. c.LeaseTime, _ = strconv.Atoi(m[1])
  549. }
  550. if m := expireTimeRegex.FindStringSubmatch(lastLease); len(m) > 1 {
  551. // e.g. "2026/10/26 18:30:00"
  552. if t, err := time.Parse("2006/01/02 15:04:05", m[1]); err == nil {
  553. c.ExpiresAt = t
  554. rem := time.Until(t)
  555. if rem > 0 {
  556. hours := int(rem.Hours())
  557. mins := int(rem.Minutes()) % 60
  558. if hours > 24 {
  559. c.ExpiresIn = fmt.Sprintf("%dd %dh", hours/24, hours%24)
  560. } else if hours > 0 {
  561. c.ExpiresIn = fmt.Sprintf("%dh %dm", hours, mins)
  562. } else {
  563. c.ExpiresIn = fmt.Sprintf("%dm %ds", mins, int(rem.Seconds())%60)
  564. }
  565. } else {
  566. c.ExpiresIn = "Expired"
  567. }
  568. }
  569. }
  570. }
  571. func subnetToCIDR(mask string) string {
  572. parts := strings.Split(mask, ".")
  573. if len(parts) != 4 {
  574. return "24"
  575. }
  576. var count int
  577. for _, p := range parts {
  578. n, _ := strconv.Atoi(p)
  579. for n > 0 {
  580. count += n & 1
  581. n >>= 1
  582. }
  583. }
  584. return strconv.Itoa(count)
  585. }