| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684 |
- package dhcp
- import (
- "encoding/json"
- "fmt"
- "net"
- "os"
- "os/exec"
- "regexp"
- "sort"
- "strconv"
- "strings"
- "sync"
- "syscall"
- "time"
- "github.com/vishvananda/netlink"
- )
- const (
- DHCPConfigFile = "/etc/debtik/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/debtik", 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
- }
- m.syncDHCPHooksLocked()
- // Startup active clients and run periodic background sync
- go func() {
- time.Sleep(1 * time.Second)
- m.SyncAll()
- ticker := time.NewTicker(5 * time.Second)
- defer ticker.Stop()
- for range ticker.C {
- 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()
- }
- // RenameInterface updates DHCP client interface bindings when an interface is renamed.
- func (m *Manager) RenameInterface(oldName, newName string) {
- m.mu.Lock()
- defer m.mu.Unlock()
- for _, c := range m.clients {
- if c.Interface == oldName {
- wasRunning := c.Enabled
- if wasRunning {
- _ = m.stopClient(c)
- }
- c.Interface = newName
- if wasRunning {
- _ = m.startClient(c)
- }
- _ = m.Save()
- break
- }
- }
- }
- func (m *Manager) syncDHCPHooksLocked() {
- _ = os.MkdirAll("/etc/dhcp/dhclient-enter-hooks.d", 0755)
- _ = os.MkdirAll("/etc/dhcp/dhclient-exit-hooks.d", 0755)
- // Remove dangerous legacy exit hook that previously deleted metric 0 routes matching any metric
- _ = os.Remove("/etc/dhcp/dhclient-exit-hooks.d/no-default-route")
- var noDefaultIfaces []string
- for _, client := range m.clients {
- if client.Enabled && !client.AddDefaultRoute && client.Interface != "" {
- noDefaultIfaces = append(noDefaultIfaces, client.Interface)
- }
- }
- hookPath := "/etc/dhcp/dhclient-enter-hooks.d/no-default-route"
- if len(noDefaultIfaces) > 0 {
- pattern := strings.Join(noDefaultIfaces, "|")
- enterHook := fmt.Sprintf("#!/bin/sh\ncase \"$interface\" in\n %s)\n unset new_routers\n ;;\nesac\n", pattern)
- _ = os.WriteFile(hookPath, []byte(enterHook), 0755)
- } else {
- _ = os.Remove(hookPath)
- }
- }
- 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)
- // Ensure enter hooks prevent dhclient from overriding static routes for non-default clients
- m.syncDHCPHooksLocked()
- // Dynamic host route hook for DHCP server reachability (Distance 0, Dynamic)
- serverRouteHook := fmt.Sprintf(`#!/bin/sh
- case "$reason" in
- BOUND|RENEW|REBIND|REBOOT)
- if [ -n "$new_dhcp_server_identifier" ] && [ -n "$new_routers" ]; then
- gw="${new_routers%%%% *}"
- ip -4 route replace "${new_dhcp_server_identifier}/32" via "$gw" dev "$interface" proto dhcp metric 0 onlink >/dev/null 2>&1 || true
- fi
- ;;
- EXPIRE|FAIL|RELEASE|STOP)
- if [ -n "$old_dhcp_server_identifier" ]; then
- ip -4 route del "${old_dhcp_server_identifier}/32" dev "$interface" proto dhcp >/dev/null 2>&1 || true
- fi
- ;;
- esac
- `)
- _ = os.WriteFile("/etc/dhcp/dhclient-exit-hooks.d/dhcp-server-route", []byte(serverRouteHook), 0755)
- // 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 {
- m.removeDHCPServerRoute(c)
- 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
- m.syncDHCPHooksLocked()
- return nil
- }
- func (m *Manager) syncDHCPServerRoute(c *DHCPClient) {
- if c == nil || c.Interface == "" {
- return
- }
- // Only maintain dynamic server route when client is bound
- if c.Status != "bound" || c.DHCPServer == "" || c.Gateway == "" {
- return
- }
- serverIP := net.ParseIP(c.DHCPServer)
- if serverIP == nil {
- return
- }
- // If DHCP server is already part of the directly assigned subnet, no explicit host route is required
- if c.IPAddress != "" {
- if _, ipNet, err := net.ParseCIDR(c.IPAddress); err == nil && ipNet.Contains(serverIP) {
- return
- }
- }
- // Install dynamic host route (Distance 0, Dynamic proto dhcp, onlink)
- cmd := exec.Command("ip", "route", "replace", c.DHCPServer+"/32", "via", c.Gateway, "dev", c.Interface, "proto", "dhcp", "metric", "0", "onlink")
- _ = cmd.Run()
- }
- func (m *Manager) removeDHCPServerRoute(c *DHCPClient) {
- if c == nil || c.DHCPServer == "" || c.Interface == "" {
- return
- }
- cmd := exec.Command("ip", "route", "del", c.DHCPServer+"/32", "dev", c.Interface, "proto", "dhcp")
- _ = cmd.Run()
- }
- 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)
- // Dynamically maintain DHCP Server reachability route (Distance 0, Dynamic)
- m.syncDHCPServerRoute(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)
- }
|