package dhcp import ( "encoding/json" "fmt" "net" "os" "os/exec" "regexp" "sort" "strconv" "strings" "sync" "syscall" "time" "github.com/vishvananda/netlink" ) const ( DHCPConfigFile = "/etc/proxmox-router/dhcp_clients.json" DHCPRunDir = "/run" DHCPLeaseDir = "/var/lib/dhcp" DHCPConfDir = "/etc/dhcp" ) var ( leaseBlockRegex = regexp.MustCompile(`(?s)lease\s+\{([^}]+)\}`) fixedAddrRegex = regexp.MustCompile(`fixed-address\s+([0-9.]+);`) subnetMaskRegex = regexp.MustCompile(`option subnet-mask\s+([0-9.]+);`) routersRegex = regexp.MustCompile(`option routers\s+([0-9.]+);`) dhcpServerRegex = regexp.MustCompile(`option dhcp-server-identifier\s+([0-9.]+);`) dnsServersRegex = regexp.MustCompile(`option domain-name-servers\s+([^;]+);`) domainNameRegex = regexp.MustCompile(`option domain-name\s+"([^"]+)";`) leaseTimeRegex = regexp.MustCompile(`option dhcp-lease-time\s+(\d+);`) expireTimeRegex = regexp.MustCompile(`expire\s+\d+\s+([0-9/]+ [0-9:]+);`) renewTimeRegex = regexp.MustCompile(`renew\s+\d+\s+([0-9/]+ [0-9:]+);`) validInterfaceRegex = regexp.MustCompile(`^[a-zA-Z0-9.:_-]+$`) ) // DHCPConfigStore wraps the persisted configuration. type DHCPConfigStore struct { Clients []*DHCPClient `json:"clients"` } // Manager orchestrates all DHCP client instances. type Manager struct { mu sync.RWMutex storePath string clients map[string]*DHCPClient } // NewManager initializes the DHCP Manager and restores saved clients. func NewManager() *Manager { _ = os.MkdirAll("/etc/proxmox-router", 0755) _ = os.MkdirAll(DHCPLeaseDir, 0755) _ = os.MkdirAll(DHCPConfDir, 0755) m := &Manager{ storePath: DHCPConfigFile, clients: make(map[string]*DHCPClient), } _ = m.Load() return m } // Load loads saved clients from disk and ensures enabled clients are started. func (m *Manager) Load() error { m.mu.Lock() defer m.mu.Unlock() data, err := os.ReadFile(m.storePath) if err != nil { if os.IsNotExist(err) { return nil } return err } var store DHCPConfigStore if err := json.Unmarshal(data, &store); err != nil { return err } for _, c := range store.Clients { if c.ID == "" { c.ID = "dhcp-" + c.Interface } m.clients[c.ID] = c } // Startup active clients go func() { time.Sleep(1 * time.Second) m.SyncAll() }() return nil } // Save persists the current DHCP clients to disk. func (m *Manager) Save() error { store := DHCPConfigStore{ Clients: make([]*DHCPClient, 0, len(m.clients)), } for _, c := range m.clients { store.Clients = append(store.Clients, c) } data, err := json.MarshalIndent(store, "", " ") if err != nil { return err } tmpFile := m.storePath + ".tmp" if err := os.WriteFile(tmpFile, data, 0644); err != nil { return err } return os.Rename(tmpFile, m.storePath) } // SyncAll refreshes status and launches any enabled clients that aren't running. func (m *Manager) SyncAll() { m.mu.Lock() defer m.mu.Unlock() for _, c := range m.clients { m.updateStatus(c) if c.Enabled && c.Status != "bound" && c.Status != "searching" { _ = m.startClient(c) m.updateStatus(c) } } } // ListClients returns all DHCP clients with real-time lease info. func (m *Manager) ListClients() []*DHCPClient { m.mu.Lock() defer m.mu.Unlock() res := make([]*DHCPClient, 0, len(m.clients)) for _, c := range m.clients { m.updateStatus(c) res = append(res, c) } sort.Slice(res, func(i, j int) bool { return res[i].Interface < res[j].Interface }) return res } // GetSummary returns summary KPIs. func (m *Manager) GetSummary() DHCPSummary { clients := m.ListClients() sum := DHCPSummary{ TotalClients: len(clients), } for _, c := range clients { switch c.Status { case "bound": sum.BoundClients++ case "searching": sum.Searching++ case "disabled": sum.Disabled++ } } return sum } // GetClient retrieves a single client by ID. func (m *Manager) GetClient(id string) (*DHCPClient, error) { m.mu.Lock() defer m.mu.Unlock() c, ok := m.clients[id] if !ok { return nil, fmt.Errorf("dhcp client %s not found", id) } m.updateStatus(c) return c, nil } // AddClient creates and optionally starts a DHCP client on an interface. func (m *Manager) AddClient(req CreateDHCPClientRequest) (*DHCPClient, error) { iface := strings.TrimSpace(req.Interface) if iface == "" || !validInterfaceRegex.MatchString(iface) { return nil, fmt.Errorf("invalid interface name") } // Verify interface exists on system if _, err := net.InterfaceByName(iface); err != nil { return nil, fmt.Errorf("interface %s does not exist on host", iface) } m.mu.Lock() defer m.mu.Unlock() id := "dhcp-" + iface if _, exists := m.clients[id]; exists { return nil, fmt.Errorf("dhcp client already exists for interface %s", iface) } dist := req.DefaultRouteDistance if dist <= 0 { dist = 1 } c := &DHCPClient{ ID: id, Interface: iface, Enabled: req.Enabled, AddDefaultRoute: req.AddDefaultRoute, DefaultRouteDistance: dist, UsePeerDNS: req.UsePeerDNS, UsePeerNTP: req.UsePeerNTP, Comment: req.Comment, Status: "stopped", DNSServers: []string{}, } if c.Enabled { if err := m.startClient(c); err != nil { c.Status = "error" } } else { c.Status = "disabled" } m.updateStatus(c) m.clients[id] = c _ = m.Save() return c, nil } // UpdateClient modifies client settings and restarts if needed. func (m *Manager) UpdateClient(id string, req UpdateDHCPClientRequest) (*DHCPClient, error) { m.mu.Lock() defer m.mu.Unlock() c, ok := m.clients[id] if !ok { return nil, fmt.Errorf("dhcp client %s not found", id) } needsRestart := false if req.AddDefaultRoute != nil && *req.AddDefaultRoute != c.AddDefaultRoute { c.AddDefaultRoute = *req.AddDefaultRoute needsRestart = true } if req.DefaultRouteDistance != nil && *req.DefaultRouteDistance != c.DefaultRouteDistance { c.DefaultRouteDistance = *req.DefaultRouteDistance needsRestart = true } if req.UsePeerDNS != nil && *req.UsePeerDNS != c.UsePeerDNS { c.UsePeerDNS = *req.UsePeerDNS needsRestart = true } if req.UsePeerNTP != nil && *req.UsePeerNTP != c.UsePeerNTP { c.UsePeerNTP = *req.UsePeerNTP needsRestart = true } if req.Comment != nil { c.Comment = *req.Comment } if req.Enabled != nil { if *req.Enabled != c.Enabled { c.Enabled = *req.Enabled if c.Enabled { _ = m.startClient(c) } else { _ = m.stopClient(c) c.Status = "disabled" } needsRestart = false } } if needsRestart && c.Enabled { _ = m.stopClient(c) _ = m.startClient(c) } m.updateStatus(c) _ = m.Save() return c, nil } // ToggleState enables or disables a DHCP client. func (m *Manager) ToggleState(id string, enabled bool) error { m.mu.Lock() defer m.mu.Unlock() c, ok := m.clients[id] if !ok { return fmt.Errorf("dhcp client %s not found", id) } c.Enabled = enabled if enabled { if err := m.startClient(c); err != nil { c.Status = "error" } } else { _ = m.stopClient(c) c.Status = "disabled" } m.updateStatus(c) return m.Save() } // RenewLease forces dhclient to renew its lease. func (m *Manager) RenewLease(id string) error { m.mu.Lock() defer m.mu.Unlock() c, ok := m.clients[id] if !ok { return fmt.Errorf("dhcp client %s not found", id) } if !c.Enabled { return fmt.Errorf("cannot renew disabled dhcp client") } _ = m.stopClient(c) time.Sleep(200 * time.Millisecond) err := m.startClient(c) m.updateStatus(c) return err } // ReleaseLease sends DHCPRELEASE and removes the IP address. func (m *Manager) ReleaseLease(id string) error { m.mu.Lock() defer m.mu.Unlock() c, ok := m.clients[id] if !ok { return fmt.Errorf("dhcp client %s not found", id) } pidFile := fmt.Sprintf("%s/dhclient.%s.pid", DHCPRunDir, c.Interface) leaseFile := fmt.Sprintf("%s/dhclient.%s.leases", DHCPLeaseDir, c.Interface) cmd := exec.Command("dhclient", "-4", "-r", "-pf", pidFile, "-lf", leaseFile, c.Interface) _ = cmd.Run() _ = m.stopClient(c) c.Status = "stopped" c.IPAddress = "" c.Gateway = "" c.ExpiresIn = "" return m.Save() } // DeleteClient stops the client and removes it from configuration. func (m *Manager) DeleteClient(id string) error { m.mu.Lock() defer m.mu.Unlock() c, ok := m.clients[id] if !ok { return fmt.Errorf("dhcp client %s not found", id) } _ = m.stopClient(c) // Clean up conf & lease files _ = os.Remove(fmt.Sprintf("%s/dhclient.%s.pid", DHCPRunDir, c.Interface)) _ = os.Remove(fmt.Sprintf("%s/dhclient-%s.conf", DHCPConfDir, c.Interface)) delete(m.clients, id) return m.Save() } func (m *Manager) startClient(c *DHCPClient) error { pidFile := fmt.Sprintf("%s/dhclient.%s.pid", DHCPRunDir, c.Interface) leaseFile := fmt.Sprintf("%s/dhclient.%s.leases", DHCPLeaseDir, c.Interface) confFile := fmt.Sprintf("%s/dhclient-%s.conf", DHCPConfDir, c.Interface) // Generate custom dhclient config var sb strings.Builder sb.WriteString("# RouterOS-style dhclient configuration\n") sb.WriteString("send host-name = gethostname();\n") sb.WriteString("request subnet-mask, broadcast-address, time-offset") if c.AddDefaultRoute { sb.WriteString(", routers") } if c.UsePeerDNS { sb.WriteString(", domain-name, domain-name-servers, domain-search, host-name") } if c.UsePeerNTP { sb.WriteString(", ntp-servers") } sb.WriteString(";\n") _ = os.WriteFile(confFile, []byte(sb.String()), 0644) // If already running, kill first _ = m.stopClient(c) // Launch dhclient in background cmd := exec.Command("dhclient", "-4", "-nw", "-pf", pidFile, "-lf", leaseFile, "-cf", confFile, c.Interface) if err := cmd.Run(); err != nil { return fmt.Errorf("failed to start dhclient: %w", err) } c.Status = "searching" return nil } func (m *Manager) stopClient(c *DHCPClient) error { pidFile := fmt.Sprintf("%s/dhclient.%s.pid", DHCPRunDir, c.Interface) // Check PID file if pidBytes, err := os.ReadFile(pidFile); err == nil { if pid, err := strconv.Atoi(strings.TrimSpace(string(pidBytes))); err == nil && pid > 0 { _ = syscall.Kill(pid, syscall.SIGTERM) time.Sleep(100 * time.Millisecond) if err := syscall.Kill(pid, 0); err == nil { _ = syscall.Kill(pid, syscall.SIGKILL) } } } _ = os.Remove(pidFile) c.PID = 0 return nil } func (m *Manager) updateStatus(c *DHCPClient) { pidFile := fmt.Sprintf("%s/dhclient.%s.pid", DHCPRunDir, c.Interface) leaseFile := fmt.Sprintf("%s/dhclient.%s.leases", DHCPLeaseDir, c.Interface) // Check if process is alive isRunning := false if pidBytes, err := os.ReadFile(pidFile); err == nil { if pid, err := strconv.Atoi(strings.TrimSpace(string(pidBytes))); err == nil && pid > 0 { if err := syscall.Kill(pid, 0); err == nil { isRunning = true c.PID = pid } } } if !isRunning { c.PID = 0 if !c.Enabled { c.Status = "disabled" } else { c.Status = "stopped" } } else { c.Status = "searching" } // Read interface IPv4 addresses via netlink if link, err := netlink.LinkByName(c.Interface); err == nil { if addrs, err := netlink.AddrList(link, netlink.FAMILY_V4); err == nil && len(addrs) > 0 { for _, a := range addrs { ip := a.IPNet.IP.String() if !strings.HasPrefix(ip, "127.") && !strings.HasPrefix(ip, "169.254.") { c.IPAddress = a.IPNet.String() if isRunning { c.Status = "bound" } break } } } } // Parse lease file for rich metadata (gateway, dns, lease times) m.parseLeaseFile(leaseFile, c) } func (m *Manager) parseLeaseFile(leaseFile string, c *DHCPClient) { data, err := os.ReadFile(leaseFile) if err != nil { return } // Find the last lease block in the file matches := leaseBlockRegex.FindAllStringSubmatch(string(data), -1) if len(matches) == 0 { return } lastLease := matches[len(matches)-1][1] if m := fixedAddrRegex.FindStringSubmatch(lastLease); len(m) > 1 { ip := m[1] mask := "24" if sm := subnetMaskRegex.FindStringSubmatch(lastLease); len(sm) > 1 { mask = subnetToCIDR(sm[1]) } if c.IPAddress == "" { c.IPAddress = fmt.Sprintf("%s/%s", ip, mask) } } if m := routersRegex.FindStringSubmatch(lastLease); len(m) > 1 { c.Gateway = m[1] } if m := dhcpServerRegex.FindStringSubmatch(lastLease); len(m) > 1 { c.DHCPServer = m[1] } if m := dnsServersRegex.FindStringSubmatch(lastLease); len(m) > 1 { raw := strings.Split(m[1], ",") dns := make([]string, 0, len(raw)) for _, s := range raw { trimmed := strings.TrimSpace(s) if trimmed != "" { dns = append(dns, trimmed) } } c.DNSServers = dns } if m := domainNameRegex.FindStringSubmatch(lastLease); len(m) > 1 { c.Domain = m[1] } if m := leaseTimeRegex.FindStringSubmatch(lastLease); len(m) > 1 { c.LeaseTime, _ = strconv.Atoi(m[1]) } if m := expireTimeRegex.FindStringSubmatch(lastLease); len(m) > 1 { // e.g. "2026/10/26 18:30:00" if t, err := time.Parse("2006/01/02 15:04:05", m[1]); err == nil { c.ExpiresAt = t rem := time.Until(t) if rem > 0 { hours := int(rem.Hours()) mins := int(rem.Minutes()) % 60 if hours > 24 { c.ExpiresIn = fmt.Sprintf("%dd %dh", hours/24, hours%24) } else if hours > 0 { c.ExpiresIn = fmt.Sprintf("%dh %dm", hours, mins) } else { c.ExpiresIn = fmt.Sprintf("%dm %ds", mins, int(rem.Seconds())%60) } } else { c.ExpiresIn = "Expired" } } } } func subnetToCIDR(mask string) string { parts := strings.Split(mask, ".") if len(parts) != 4 { return "24" } var count int for _, p := range parts { n, _ := strconv.Atoi(p) for n > 0 { count += n & 1 n >>= 1 } } return strconv.Itoa(count) }