| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871 |
- 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 netmask != "" {
- sb.WriteString(fmt.Sprintf("dhcp-range=set:%s,interface:%s,%s,%s,%s,%s\n", tag, srv.Interface, startIP, endIP, netmask, leaseTime))
- } else {
- sb.WriteString(fmt.Sprintf("dhcp-range=set:%s,interface:%s,%s,%s,%s\n", tag, srv.Interface, 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)
- networkTag := ""
- if err == nil && ipNet != nil {
- networkTag = fmt.Sprintf("net_%s", strings.ReplaceAll(ipNet.IP.String(), ".", "_"))
- // Match server range tag or subnet tag
- 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 {
- _ = exec.Command("systemctl", "restart", "dnsmasq").Run()
- } else {
- _ = exec.Command("systemctl", "reload", "dnsmasq").Run()
- }
- return nil
- }
- 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: <timestamp> <mac> <ip> <hostname> <client-id>
- 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
- }
|