package dhcp import ( "encoding/json" "fmt" "log" "net" "os" "os/exec" "path/filepath" "sort" "strconv" "strings" "sync" "time" ) const ( DHCPServerStoreFile = "/etc/proxmox-router/dhcp_server.json" DnsmasqConfFile = "/etc/dnsmasq.d/debtik_dhcp.conf" DnsmasqLeasesFile = "/var/lib/misc/dnsmasq.leases" ) type ServerManager struct { mu sync.RWMutex storePath string confPath string leasesPath string servers map[string]DHCPServerInstance networks map[string]DHCPNetwork staticLeases map[string]DHCPLease options map[string]DHCPOption } func NewServerManager() *ServerManager { sm := &ServerManager{ storePath: DHCPServerStoreFile, confPath: DnsmasqConfFile, leasesPath: DnsmasqLeasesFile, servers: make(map[string]DHCPServerInstance), networks: make(map[string]DHCPNetwork), staticLeases: make(map[string]DHCPLease), options: make(map[string]DHCPOption), } _ = os.MkdirAll(filepath.Dir(sm.storePath), 0755) _ = os.MkdirAll(filepath.Dir(sm.confPath), 0755) if err := sm.load(); err != nil { log.Printf("[DHCP-Server] Error loading config: %v", err) } // Initial render and reload dnsmasq if err := sm.applyDnsmasqConfigLocked(); err != nil { log.Printf("[DHCP-Server] Error applying dnsmasq config: %v", err) } return sm } func (sm *ServerManager) load() error { sm.mu.Lock() defer sm.mu.Unlock() data, err := os.ReadFile(sm.storePath) if err != nil { if os.IsNotExist(err) { // Populate default option presets (MikroTik standard presets) sm.initDefaultOptions() return nil } return err } var store DHCPServerConfigStore if err := json.Unmarshal(data, &store); err != nil { return err } for _, s := range store.Servers { sm.servers[s.ID] = s } for _, n := range store.Networks { sm.networks[n.ID] = n } for _, l := range store.StaticLeases { sm.staticLeases[l.ID] = l } for _, o := range store.Options { sm.options[o.ID] = o } if len(sm.options) == 0 { sm.initDefaultOptions() } return nil } func (sm *ServerManager) initDefaultOptions() { presets := []DHCPOption{ {ID: "opt-ntp", Name: "ntp-servers", Code: 42, Value: "192.168.88.1", Comment: "NTP Time Servers (Option 42)"}, {ID: "opt-tftp-name", Name: "tftp-server-name", Code: 66, Value: "192.168.88.50", Comment: "TFTP Server Name / IP (Option 66)"}, {ID: "opt-bootfile", Name: "bootfile-name", Code: 67, Value: "pxelinux.0", Comment: "PXE Boot File (Option 67)"}, {ID: "opt-unifi", Name: "unifi-controller", Code: 43, Value: "01:04:c0:a8:58:0a", Comment: "UniFi Inform Controller (Option 43 Hex IP)"}, {ID: "opt-voip", Name: "cisco-voip-tftp", Code: 150, Value: "192.168.88.50", Comment: "Cisco VoIP TFTP (Option 150)"}, {ID: "opt-capwap", Name: "capwap-ac", Code: 138, Value: "192.168.88.10", Comment: "CAPWAP Access Controller (Option 138)"}, {ID: "opt-wpad", Name: "wpad-url", Code: 252, Value: "\"http://wpad/wpad.dat\"", Comment: "Web Proxy Auto-Discovery (Option 252)"}, } for _, p := range presets { sm.options[p.ID] = p } } func (sm *ServerManager) saveLocked() error { store := DHCPServerConfigStore{ Servers: make([]DHCPServerInstance, 0, len(sm.servers)), Networks: make([]DHCPNetwork, 0, len(sm.networks)), StaticLeases: make([]DHCPLease, 0, len(sm.staticLeases)), Options: make([]DHCPOption, 0, len(sm.options)), } for _, s := range sm.servers { copyS := s copyS.Running = false copyS.ActiveLeases = 0 store.Servers = append(store.Servers, copyS) } for _, n := range sm.networks { store.Networks = append(store.Networks, n) } for _, l := range sm.staticLeases { store.StaticLeases = append(store.StaticLeases, l) } for _, o := range sm.options { store.Options = append(store.Options, o) } data, err := json.MarshalIndent(store, "", " ") if err != nil { return err } return os.WriteFile(sm.storePath, data, 0644) } func (sm *ServerManager) applyDnsmasqConfigLocked() error { var sb strings.Builder sb.WriteString("# ==================================================================\n") sb.WriteString("# Enterprise DHCP Server Configuration\n") sb.WriteString("# Auto-generated by Network Appliance. DO NOT EDIT DIRECTLY.\n") sb.WriteString("# ==================================================================\n\n") sb.WriteString("dhcp-authoritative\n") sb.WriteString("dhcp-leasefile=/var/lib/misc/dnsmasq.leases\n") sb.WriteString("log-dhcp\n") sb.WriteString("bind-dynamic\n\n") activeServerCount := 0 for _, srv := range sm.servers { if srv.Disabled { continue } activeServerCount++ tag := sanitizeTag(srv.Name) sb.WriteString(fmt.Sprintf("# --- DHCP Server: %s on %s ---\n", srv.Name, srv.Interface)) sb.WriteString(fmt.Sprintf("interface=%s\n", srv.Interface)) leaseTime := srv.LeaseTime if leaseTime == "" { leaseTime = "12h" } // Find assigned network for server var assignedNet *DHCPNetwork if srv.Network != "" { for _, n := range sm.networks { if n.Address == srv.Network || n.ID == srv.Network { copyN := n assignedNet = ©N break } } } // Parse Address Pool range (e.g. 192.168.88.10-192.168.88.254) startIP, endIP, netmask := parseAddressPool(srv.AddressPool) if netmask == "" && assignedNet != nil { if assignedNet.Netmask != "" { netmask = assignedNet.Netmask } else if _, ipNet, err := net.ParseCIDR(assignedNet.Address); err == nil && ipNet != nil { netmask = net.IP(ipNet.Mask).String() } } if startIP != "" && endIP != "" { if srv.Interface != "" { if netmask != "" { sb.WriteString(fmt.Sprintf("dhcp-range=tag:%s,set:%s,%s,%s,%s,%s\n", srv.Interface, tag, startIP, endIP, netmask, leaseTime)) } else { sb.WriteString(fmt.Sprintf("dhcp-range=tag:%s,set:%s,%s,%s,%s\n", srv.Interface, tag, startIP, endIP, leaseTime)) } } else { if netmask != "" { sb.WriteString(fmt.Sprintf("dhcp-range=set:%s,%s,%s,%s,%s\n", tag, startIP, endIP, netmask, leaseTime)) } else { sb.WriteString(fmt.Sprintf("dhcp-range=set:%s,%s,%s,%s\n", tag, startIP, endIP, leaseTime)) } } } // Emit assigned network options specifically for this DHCP server tag if assignedNet != nil { targetTag := fmt.Sprintf("tag:%s,", tag) if assignedNet.Gateway != "" { sb.WriteString(fmt.Sprintf("dhcp-option=%soption:router,%s\n", targetTag, assignedNet.Gateway)) } if len(assignedNet.DNSServers) > 0 { sb.WriteString(fmt.Sprintf("dhcp-option=%soption:dns-server,%s\n", targetTag, strings.Join(assignedNet.DNSServers, ","))) } if assignedNet.Domain != "" { sb.WriteString(fmt.Sprintf("dhcp-option=%soption:domain-name,%s\n", targetTag, assignedNet.Domain)) } if len(assignedNet.NTPServers) > 0 { sb.WriteString(fmt.Sprintf("dhcp-option=%soption:ntp-server,%s\n", targetTag, strings.Join(assignedNet.NTPServers, ","))) } if assignedNet.NextServer != "" { sb.WriteString(fmt.Sprintf("dhcp-option=%soption:tftp-server,%s\n", targetTag, assignedNet.NextServer)) } if assignedNet.BootFileName != "" { sb.WriteString(fmt.Sprintf("dhcp-option=%soption:bootfile-name,%s\n", targetTag, assignedNet.BootFileName)) if assignedNet.NextServer != "" { sb.WriteString(fmt.Sprintf("dhcp-boot=%s%s,,%s\n", targetTag, assignedNet.BootFileName, assignedNet.NextServer)) } else { sb.WriteString(fmt.Sprintf("dhcp-boot=%s%s\n", targetTag, assignedNet.BootFileName)) } } if len(assignedNet.WINSServers) > 0 { sb.WriteString(fmt.Sprintf("dhcp-option=%soption:netbios-ns,%s\n", targetTag, strings.Join(assignedNet.WINSServers, ","))) } for _, optID := range assignedNet.DHCPOptions { if opt, exists := sm.options[optID]; exists { sb.WriteString(fmt.Sprintf("dhcp-option=%s%d,%s # %s\n", targetTag, opt.Code, opt.Value, opt.Name)) } } } sb.WriteString("\n") } // Subnet Networks & Options (Fallback and IP-matching) sb.WriteString("# --- Subnet Networks Fallbacks & IP matching ---\n") for _, netw := range sm.networks { sb.WriteString(fmt.Sprintf("# Subnet: %s\n", netw.Address)) _, ipNet, err := net.ParseCIDR(netw.Address) isCoveredByServer := false if ipNet != nil { for _, srv := range sm.servers { if !srv.Disabled && (srv.Network == netw.ID || srv.Network == netw.Address) { isCoveredByServer = true break } } } networkTag := "" if err == nil && ipNet != nil { networkTag = fmt.Sprintf("net_%s", strings.ReplaceAll(ipNet.IP.String(), ".", "_")) if !isCoveredByServer { sb.WriteString(fmt.Sprintf("dhcp-range=set:%s,%s,static\n", networkTag, ipNet.IP.String())) } } // Determine target tag (either network tag or global/matched) targetTag := "" if networkTag != "" { targetTag = fmt.Sprintf("tag:%s,", networkTag) } // Option 3: Gateway (Router) if netw.Gateway != "" { sb.WriteString(fmt.Sprintf("dhcp-option=%soption:router,%s\n", targetTag, netw.Gateway)) } // Option 6: DNS Servers if len(netw.DNSServers) > 0 { sb.WriteString(fmt.Sprintf("dhcp-option=%soption:dns-server,%s\n", targetTag, strings.Join(netw.DNSServers, ","))) } // Option 15: Domain Name if netw.Domain != "" { sb.WriteString(fmt.Sprintf("dhcp-option=%soption:domain-name,%s\n", targetTag, netw.Domain)) } // Option 42: NTP Servers (MikroTik /ip dhcp-server network ntp-server) if len(netw.NTPServers) > 0 { sb.WriteString(fmt.Sprintf("dhcp-option=%soption:ntp-server,%s\n", targetTag, strings.Join(netw.NTPServers, ","))) } // Option 66: TFTP Server Name / IP (next-server) if netw.NextServer != "" { sb.WriteString(fmt.Sprintf("dhcp-option=%soption:tftp-server,%s\n", targetTag, netw.NextServer)) } // Option 67: Boot File Name (PXE) if netw.BootFileName != "" { sb.WriteString(fmt.Sprintf("dhcp-option=%soption:bootfile-name,%s\n", targetTag, netw.BootFileName)) if netw.NextServer != "" { sb.WriteString(fmt.Sprintf("dhcp-boot=%s%s,,%s\n", targetTag, netw.BootFileName, netw.NextServer)) } else { sb.WriteString(fmt.Sprintf("dhcp-boot=%s%s\n", targetTag, netw.BootFileName)) } } // Option 44: WINS Servers if len(netw.WINSServers) > 0 { sb.WriteString(fmt.Sprintf("dhcp-option=%soption:netbios-ns,%s\n", targetTag, strings.Join(netw.WINSServers, ","))) } // Linked custom DHCP options for _, optID := range netw.DHCPOptions { if opt, exists := sm.options[optID]; exists { sb.WriteString(fmt.Sprintf("dhcp-option=%s%d,%s # %s\n", targetTag, opt.Code, opt.Value, opt.Name)) } } sb.WriteString("\n") } // Static Leases & Reservations sb.WriteString("# --- Static Leases & Reservations ---\n") for _, l := range sm.staticLeases { if l.Disabled { continue } mac := strings.ToLower(strings.TrimSpace(l.MACAddress)) ip := strings.TrimSpace(l.Address) hostname := strings.TrimSpace(l.HostName) if hostname == "" { hostname = "reserved" } if mac != "" && ip != "" { sb.WriteString(fmt.Sprintf("dhcp-host=%s,%s,%s,infinite\n", mac, ip, hostname)) } } // Write config to disk if err := os.WriteFile(sm.confPath, []byte(sb.String()), 0644); err != nil { return fmt.Errorf("failed to write dnsmasq config: %w", err) } // Restart or reload dnsmasq if activeServerCount > 0 { out, err := exec.Command("systemctl", "restart", "dnsmasq").CombinedOutput() if err != nil { log.Printf("[DHCPServer] Failed to restart dnsmasq: %v, output: %s", err, string(out)) } else { log.Printf("[DHCPServer] dnsmasq restarted successfully with %d active DHCP server(s)", activeServerCount) } } else { _ = exec.Command("systemctl", "reload", "dnsmasq").Run() } return nil } // RenameInterface updates DHCP server interface bindings when an interface is renamed. func (sm *ServerManager) RenameInterface(oldName, newName string) { sm.mu.Lock() defer sm.mu.Unlock() changed := false for _, srv := range sm.servers { if srv.Interface == oldName { srv.Interface = newName changed = true } } if changed { _ = sm.saveLocked() _ = sm.applyDnsmasqConfigLocked() log.Printf("[DHCPServer] Updated DHCP server interface binding: %s -> %s", oldName, newName) } } func (sm *ServerManager) ListServers() []DHCPServerInstance { sm.mu.RLock() defer sm.mu.RUnlock() activeLeases := sm.countActiveLeasesByServer() list := make([]DHCPServerInstance, 0, len(sm.servers)) for _, s := range sm.servers { copyS := s copyS.Running = !s.Disabled && isInterfaceUp(s.Interface) copyS.ActiveLeases = activeLeases[s.Name] list = append(list, copyS) } sort.Slice(list, func(i, j int) bool { return list[i].Name < list[j].Name }) return list } func (sm *ServerManager) GetServer(id string) (DHCPServerInstance, bool) { sm.mu.RLock() defer sm.mu.RUnlock() s, ok := sm.servers[id] if !ok { return DHCPServerInstance{}, false } s.Running = !s.Disabled && isInterfaceUp(s.Interface) return s, true } func (sm *ServerManager) SaveServer(inst DHCPServerInstance) (DHCPServerInstance, error) { sm.mu.Lock() defer sm.mu.Unlock() if inst.ID == "" { inst.ID = fmt.Sprintf("dhcp-%d", time.Now().UnixNano()) } if inst.Name == "" { inst.Name = fmt.Sprintf("dhcp%d", len(sm.servers)+1) } if inst.LeaseTime == "" { inst.LeaseTime = "12h" } inst.Authoritative = true sm.servers[inst.ID] = inst if err := sm.saveLocked(); err != nil { return inst, err } _ = sm.applyDnsmasqConfigLocked() return inst, nil } func (sm *ServerManager) DeleteServer(id string) error { sm.mu.Lock() defer sm.mu.Unlock() delete(sm.servers, id) if err := sm.saveLocked(); err != nil { return err } return sm.applyDnsmasqConfigLocked() } func (sm *ServerManager) ToggleServerState(id string, disabled bool) error { sm.mu.Lock() defer sm.mu.Unlock() s, exists := sm.servers[id] if !exists { return fmt.Errorf("server not found: %s", id) } s.Disabled = disabled sm.servers[id] = s if err := sm.saveLocked(); err != nil { return err } return sm.applyDnsmasqConfigLocked() } // ======================== NETWORKS ======================== func (sm *ServerManager) ListNetworks() []DHCPNetwork { sm.mu.RLock() defer sm.mu.RUnlock() list := make([]DHCPNetwork, 0, len(sm.networks)) for _, n := range sm.networks { list = append(list, n) } sort.Slice(list, func(i, j int) bool { return list[i].Address < list[j].Address }) return list } func (sm *ServerManager) SaveNetwork(netw DHCPNetwork) (DHCPNetwork, error) { sm.mu.Lock() defer sm.mu.Unlock() if netw.ID == "" { netw.ID = fmt.Sprintf("net-%d", time.Now().UnixNano()) } if netw.Address == "" { return netw, fmt.Errorf("subnet address (CIDR) is required") } sm.networks[netw.ID] = netw if err := sm.saveLocked(); err != nil { return netw, err } _ = sm.applyDnsmasqConfigLocked() return netw, nil } func (sm *ServerManager) DeleteNetwork(id string) error { sm.mu.Lock() defer sm.mu.Unlock() delete(sm.networks, id) if err := sm.saveLocked(); err != nil { return err } return sm.applyDnsmasqConfigLocked() } // ======================== LEASES ======================== func (sm *ServerManager) ListLeases() []DHCPLease { sm.mu.RLock() defer sm.mu.RUnlock() dynamicLeases := sm.readDnsmasqLeasesFile() staticMap := make(map[string]DHCPLease) for _, st := range sm.staticLeases { staticMap[strings.ToLower(st.MACAddress)] = st } result := make([]DHCPLease, 0) seenMACs := make(map[string]bool) // Combine dynamic leases for _, dl := range dynamicLeases { macLower := strings.ToLower(dl.MACAddress) seenMACs[macLower] = true if st, isStatic := staticMap[macLower]; isStatic { dl.Dynamic = false dl.Status = "static" dl.Comment = st.Comment dl.Disabled = st.Disabled dl.ExpiresIn = "never (static)" } else { dl.Dynamic = true dl.Status = "bound" } result = append(result, dl) } // Add static reservations that haven't acquired lease yet for macLower, st := range staticMap { if !seenMACs[macLower] { st.Dynamic = false st.Status = "waiting" st.ExpiresIn = "never (static)" result = append(result, st) } } sort.Slice(result, func(i, j int) bool { return compareIPs(net.ParseIP(result[i].Address), net.ParseIP(result[j].Address)) < 0 }) return result } func (sm *ServerManager) readDnsmasqLeasesFile() []DHCPLease { data, err := os.ReadFile(sm.leasesPath) if err != nil { return nil } lines := strings.Split(string(data), "\n") leases := make([]DHCPLease, 0, len(lines)) now := time.Now() for _, line := range lines { line = strings.TrimSpace(line) if line == "" { continue } // Format: parts := strings.Fields(line) if len(parts) < 3 { continue } expTimestamp, _ := strconv.ParseInt(parts[0], 10, 64) mac := parts[1] ip := parts[2] hostname := "*" if len(parts) >= 4 && parts[3] != "*" { hostname = parts[3] } clientID := "" if len(parts) >= 5 && parts[4] != "*" { clientID = parts[4] } expiresAt := time.Unix(expTimestamp, 0) expiresIn := "expired" if expiresAt.After(now) { dur := expiresAt.Sub(now).Truncate(time.Minute) hours := int(dur.Hours()) mins := int(dur.Minutes()) % 60 expiresIn = fmt.Sprintf("%dh %dm", hours, mins) } // Determine server by matching IP against configured networks/pools serverName := sm.matchServerForIP(ip) leases = append(leases, DHCPLease{ ID: fmt.Sprintf("lease-%s", strings.ReplaceAll(mac, ":", "")), Address: ip, MACAddress: mac, HostName: hostname, ClientID: clientID, Server: serverName, ExpiresAt: expiresAt, ExpiresIn: expiresIn, Dynamic: true, Status: "bound", }) } return leases } func (sm *ServerManager) matchServerForIP(ipStr string) string { targetIP := net.ParseIP(ipStr) if targetIP == nil { return "dhcp1" } for _, srv := range sm.servers { startIP, endIP, _ := parseAddressPool(srv.AddressPool) if startIP != "" && endIP != "" { s := net.ParseIP(startIP) e := net.ParseIP(endIP) if s != nil && e != nil && compareIPs(targetIP, s) >= 0 && compareIPs(targetIP, e) <= 0 { return srv.Name } } } return "dhcp1" } func (sm *ServerManager) countActiveLeasesByServer() map[string]int { counts := make(map[string]int) leases := sm.readDnsmasqLeasesFile() for _, l := range leases { counts[l.Server]++ } return counts } func (sm *ServerManager) SaveStaticLease(lease DHCPLease) (DHCPLease, error) { sm.mu.Lock() defer sm.mu.Unlock() if lease.ID == "" { lease.ID = fmt.Sprintf("static-%d", time.Now().UnixNano()) } if lease.MACAddress == "" || lease.Address == "" { return lease, fmt.Errorf("both MAC address and IP address are required") } lease.MACAddress = strings.ToLower(lease.MACAddress) lease.Dynamic = false lease.Status = "static" sm.staticLeases[lease.ID] = lease if err := sm.saveLocked(); err != nil { return lease, err } _ = sm.applyDnsmasqConfigLocked() return lease, nil } func (sm *ServerManager) DeleteStaticLease(id string) error { sm.mu.Lock() defer sm.mu.Unlock() delete(sm.staticLeases, id) if err := sm.saveLocked(); err != nil { return err } return sm.applyDnsmasqConfigLocked() } func (sm *ServerManager) MakeStaticLease(mac, ip, hostname, comment string) (DHCPLease, error) { sm.mu.Lock() defer sm.mu.Unlock() macLower := strings.ToLower(strings.TrimSpace(mac)) // Check if already in static leases for _, st := range sm.staticLeases { if strings.ToLower(st.MACAddress) == macLower { st.Address = ip if hostname != "" && hostname != "*" { st.HostName = hostname } if comment != "" { st.Comment = comment } sm.staticLeases[st.ID] = st _ = sm.saveLocked() _ = sm.applyDnsmasqConfigLocked() return st, nil } } id := fmt.Sprintf("static-%d", time.Now().UnixNano()) st := DHCPLease{ ID: id, MACAddress: macLower, Address: ip, HostName: hostname, Server: "all", Comment: comment, Dynamic: false, Status: "static", } sm.staticLeases[id] = st if err := sm.saveLocked(); err != nil { return st, err } _ = sm.applyDnsmasqConfigLocked() return st, nil } // ======================== OPTIONS ======================== func (sm *ServerManager) ListOptions() []DHCPOption { sm.mu.RLock() defer sm.mu.RUnlock() list := make([]DHCPOption, 0, len(sm.options)) for _, o := range sm.options { list = append(list, o) } sort.Slice(list, func(i, j int) bool { return list[i].Code < list[j].Code }) return list } func (sm *ServerManager) SaveOption(opt DHCPOption) (DHCPOption, error) { sm.mu.Lock() defer sm.mu.Unlock() if opt.ID == "" { opt.ID = fmt.Sprintf("opt-%d", time.Now().UnixNano()) } if opt.Name == "" || opt.Code <= 0 || opt.Code > 254 { return opt, fmt.Errorf("valid option name and code (1-254) are required") } sm.options[opt.ID] = opt if err := sm.saveLocked(); err != nil { return opt, err } _ = sm.applyDnsmasqConfigLocked() return opt, nil } func (sm *ServerManager) DeleteOption(id string) error { sm.mu.Lock() defer sm.mu.Unlock() delete(sm.options, id) if err := sm.saveLocked(); err != nil { return err } return sm.applyDnsmasqConfigLocked() } // ======================== SETUP WIZARD (MikroTik style) ======================== func (sm *ServerManager) RunSetupWizard(req DHCPServerSetupRequest) error { sm.mu.Lock() defer sm.mu.Unlock() if req.Interface == "" { return fmt.Errorf("interface is required") } if req.AddressSpace == "" { req.AddressSpace = "192.168.88.0/24" } if req.Gateway == "" { req.Gateway = "192.168.88.1" } if req.AddressPool == "" { req.AddressPool = "192.168.88.10-192.168.88.254" } if len(req.DNSServers) == 0 { req.DNSServers = []string{req.Gateway, "1.1.1.1"} } if req.LeaseTime == "" { req.LeaseTime = "12h" } srvID := fmt.Sprintf("dhcp-%s", req.Interface) srv := DHCPServerInstance{ ID: srvID, Name: fmt.Sprintf("dhcp-%s", req.Interface), Interface: req.Interface, Network: req.AddressSpace, AddressPool: req.AddressPool, LeaseTime: req.LeaseTime, Authoritative: true, Disabled: false, Comment: "Created by DHCP Setup Wizard", } sm.servers[srvID] = srv netID := fmt.Sprintf("net-%s", strings.ReplaceAll(req.AddressSpace, "/", "_")) netw := DHCPNetwork{ ID: netID, Address: req.AddressSpace, Gateway: req.Gateway, DNSServers: req.DNSServers, Domain: req.Domain, NTPServers: req.NTPServers, NextServer: req.NextServer, BootFileName: req.BootFileName, Comment: fmt.Sprintf("Network for %s", req.Interface), } sm.networks[netID] = netw if err := sm.saveLocked(); err != nil { return err } return sm.applyDnsmasqConfigLocked() } // ======================== SUMMARY ======================== func (sm *ServerManager) GetSummary() DHCPServerSummary { sm.mu.RLock() defer sm.mu.RUnlock() summary := DHCPServerSummary{ TotalServers: len(sm.servers), TotalNetworks: len(sm.networks), StaticLeases: len(sm.staticLeases), TotalOptions: len(sm.options), } for _, s := range sm.servers { if !s.Disabled && isInterfaceUp(s.Interface) { summary.ActiveServers++ } } leases := sm.readDnsmasqLeasesFile() summary.ActiveLeases = len(leases) return summary } // ======================== HELPERS ======================== func sanitizeTag(name string) string { res := strings.Map(func(r rune) rune { if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' { return r } return '_' }, name) return strings.ToLower(res) } func parseAddressPool(pool string) (startIP, endIP, netmask string) { pool = strings.TrimSpace(pool) parts := strings.Split(pool, "-") if len(parts) == 2 { return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]), "" } return "", "", "" } func isInterfaceUp(ifaceName string) bool { iface, err := net.InterfaceByName(ifaceName) if err != nil { return false } return (iface.Flags & net.FlagUp) != 0 } func compareIPs(a, b net.IP) int { a4 := a.To4() b4 := b.To4() if a4 == nil || b4 == nil { return 0 } for i := 0; i < 4; i++ { if a4[i] < b4[i] { return -1 } if a4[i] > b4[i] { return 1 } } return 0 }