1
0
Files
dhcp-dns-manager/internal/dhcp/server.go
T
CNBUGS AI 5bb84c159a Add client eviction feature
- Add EvictClient method to DHCP Server
- Add /api/dhcp/leases/evict endpoint to force client IP release
- Add 'Evict' button in Web UI for online clients
- Update table layout to include Action column
- Evicted client will be forced to get a new IP on next DHCP request
2026-04-24 16:59:14 +08:00

726 rindas
18 KiB
Go

package dhcp
import (
"encoding/binary"
"fmt"
"log"
"net"
"os"
"sync"
"time"
"dhcp-dns-manager/internal/db"
"dhcp-dns-manager/internal/config"
"golang.org/x/sys/unix"
)
// DHCP 消息类型
const (
MsgDiscover = 1
MsgOffer = 2
MsgRequest = 3
MsgDecline = 4
MsgACK = 5
MsgNAK = 6
MsgRelease = 7
)
// DHCP 选项
const (
OptionSubnetMask = 1
OptionRouter = 3
OptionDNS = 6
OptionHostname = 12
OptionLeaseTime = 51
OptionMessageType = 53
OptionServerIdentifier = 54
OptionRequestedIP = 50
OptionEnd = 255
)
type Server struct {
config *config.DHCPConfig
configReloader func() *config.DHCPConfig // optional: reload config from ConfigManager
db *db.DB
leases map[string]*db.DHCPLease
staticBindings map[string]db.DHCPStaticBinding
leaseMutex sync.RWMutex
conn *net.UDPConn
stopChan chan struct{}
serverIP net.IP
usedIPs map[string]string // IP -> MAC
}
func NewServer(cfg *config.DHCPConfig, database *db.DB) *Server {
return &Server{
config: cfg,
db: database,
leases: make(map[string]*db.DHCPLease),
staticBindings: make(map[string]db.DHCPStaticBinding),
usedIPs: make(map[string]string),
stopChan: make(chan struct{}),
serverIP: net.ParseIP(cfg.Gateway).To4(),
}
}
// SetConfigReloader sets a function to reload config dynamically.
// This allows the DHCP server to pick up config changes made via the web UI.
func (s *Server) SetConfigReloader(reloader func() *config.DHCPConfig) {
s.configReloader = reloader
}
// getConfig returns the current config, reloading if a reloader is set.
func (s *Server) getConfig() *config.DHCPConfig {
if s.configReloader != nil {
return s.configReloader()
}
return s.config
}
// getServerIP returns the server IP for DHCP options
func (s *Server) getServerIP() net.IP {
cfg := s.getConfig()
return net.ParseIP(cfg.Gateway).To4()
}
func (s *Server) Start() error {
if !s.config.Enabled {
return nil
}
// Load existing leases
s.loadLeases()
s.loadStaticBindings()
// Start lease cleanup goroutine
go s.cleanupLeases()
// Start DHCP server on UDP port 67
// Use a raw connection to properly handle broadcast responses
conn, err := newBroadcastUDPConn("0.0.0.0", 67)
if err != nil {
return fmt.Errorf("failed to listen on UDP 67: %v", err)
}
s.conn = conn
log.Printf("DHCP server listening on 0.0.0.0:67")
// Handle DHCP requests
go s.handleDHCP()
return nil
}
func (s *Server) Stop() {
if s.conn != nil {
s.conn.Close()
}
close(s.stopChan)
}
func (s *Server) handleDHCP() {
buf := make([]byte, 1024)
for {
select {
case <-s.stopChan:
return
default:
s.conn.SetReadDeadline(time.Now().Add(1 * time.Second))
n, remoteAddr, err := s.conn.ReadFromUDP(buf)
if err != nil {
continue
}
s.processDHCPMessage(buf[:n], remoteAddr)
}
}
}
func (s *Server) processDHCPMessage(data []byte, remoteAddr *net.UDPAddr) {
if len(data) < 240 {
return
}
// Parse DHCP message
msgType := parseMessageType(data)
if msgType == 0 {
return
}
// Get client MAC
clientMAC := formatMAC(data[28:34])
// Get client IP from packet
clientIP := net.IP(data[16:20])
switch msgType {
case MsgDiscover:
s.handleDiscover(data, clientMAC, clientIP, remoteAddr)
case MsgRequest:
s.handleRequest(data, clientMAC, clientIP, remoteAddr)
case MsgRelease:
s.handleRelease(clientMAC)
case MsgDecline:
s.handleDecline(clientMAC)
}
}
func (s *Server) handleDiscover(data []byte, clientMAC string, clientIP net.IP, remoteAddr *net.UDPAddr) {
// Find or assign IP
offeredIP := s.assignIP(clientMAC, data)
if offeredIP == "" {
log.Printf("DHCP: No available IP for %s", clientMAC)
return
}
// Record the offered IP as a provisional lease BEFORE sending the Offer.
// This ensures that when the client sends Request back, verifyAssignment
// will find the lease and return true (instead of NAKing the client).
s.recordLease(clientMAC, offeredIP, data)
// Send DHCP Offer
s.sendOffer(data, clientMAC, offeredIP, remoteAddr)
log.Printf("DHCP: Offered %s to %s", offeredIP, clientMAC)
}
func (s *Server) handleRequest(data []byte, clientMAC string, clientIP net.IP, remoteAddr *net.UDPAddr) {
// Get requested IP
requestedIP := parseRequestedIP(data).String()
if requestedIP == "<nil>" || requestedIP == "" {
// Fallback to ciaddr
requestedIP = clientIP.String()
}
// Verify the requested IP
if s.verifyAssignment(clientMAC, requestedIP) {
// Send DHCP ACK
s.sendACK(data, clientMAC, requestedIP, remoteAddr)
log.Printf("DHCP: ACK %s to %s", requestedIP, clientMAC)
// Record lease
s.recordLease(clientMAC, requestedIP, data)
} else {
// Send DHCP NAK
s.sendNAK(data, remoteAddr)
log.Printf("DHCP: NAK for %s (requested %s)", clientMAC, requestedIP)
}
}
func (s *Server) handleRelease(clientMAC string) {
s.leaseMutex.Lock()
defer s.leaseMutex.Unlock()
if lease, exists := s.leases[clientMAC]; exists {
log.Printf("DHCP: Released %s for %s", lease.IP, clientMAC)
delete(s.leases, clientMAC)
delete(s.usedIPs, lease.IP)
}
}
func (s *Server) handleDecline(clientMAC string) {
s.leaseMutex.Lock()
defer s.leaseMutex.Unlock()
if lease, exists := s.leases[clientMAC]; exists {
log.Printf("DHCP: Declined %s for %s", lease.IP, clientMAC)
delete(s.leases, clientMAC)
delete(s.usedIPs, lease.IP)
}
}
func (s *Server) assignIP(clientMAC string, data []byte) string {
s.leaseMutex.Lock()
defer s.leaseMutex.Unlock()
// Check static binding first
if binding, exists := s.staticBindings[clientMAC]; exists {
return binding.IP
}
// Check if client already has a lease
if lease, exists := s.leases[clientMAC]; exists {
return lease.IP
}
// Find available IP
startIP := net.ParseIP(s.getConfig().IPPoolStart).To4()
endIP := net.ParseIP(s.getConfig().IPPoolEnd).To4()
if startIP == nil || endIP == nil {
return ""
}
startInt := uint32(startIP[0])<<24 | uint32(startIP[1])<<16 | uint32(startIP[2])<<8 | uint32(startIP[3])
endInt := uint32(endIP[0])<<24 | uint32(endIP[1])<<16 | uint32(endIP[2])<<8 | uint32(endIP[3])
// Build set of used IPs
usedIPs := make(map[string]bool)
for _, lease := range s.leases {
usedIPs[lease.IP] = true
}
// Find first available IP
for ip := startInt; ip <= endInt; ip++ {
ipBytes := []byte{
byte(ip >> 24),
byte(ip >> 16),
byte(ip >> 8),
byte(ip),
}
ipStr := fmt.Sprintf("%d.%d.%d.%d", ipBytes[0], ipBytes[1], ipBytes[2], ipBytes[3])
// Skip gateway and excluded IPs
if ipStr == s.getConfig().Gateway {
continue
}
excluded := false
for _, excl := range s.getConfig().ExcludedIPs {
if ipStr == excl {
excluded = true
break
}
}
if excluded {
continue
}
if !usedIPs[ipStr] {
return ipStr
}
}
return ""
}
func (s *Server) verifyAssignment(clientMAC, ip string) bool {
s.leaseMutex.RLock()
defer s.leaseMutex.RUnlock()
// Check static binding
if binding, exists := s.staticBindings[clientMAC]; exists {
return binding.IP == ip
}
// Check if IP is assigned to this client
if lease, exists := s.leases[clientMAC]; exists {
return lease.IP == ip
}
// IP not in lease map yet — this is a new client that just got an Offer.
// Verify the IP is within the configured pool range and not already taken.
startIP := net.ParseIP(s.getConfig().IPPoolStart).To4()
endIP := net.ParseIP(s.getConfig().IPPoolEnd).To4()
if startIP == nil || endIP == nil {
return false
}
clientIP := net.ParseIP(ip).To4()
if clientIP == nil {
return false
}
clientInt := uint32(clientIP[0])<<24 | uint32(clientIP[1])<<16 | uint32(clientIP[2])<<8 | uint32(clientIP[3])
startInt := uint32(startIP[0])<<24 | uint32(startIP[1])<<16 | uint32(startIP[2])<<8 | uint32(startIP[3])
endInt := uint32(endIP[0])<<24 | uint32(endIP[1])<<16 | uint32(endIP[2])<<8 | uint32(endIP[3])
if clientInt < startInt || clientInt > endInt {
return false
}
// Make sure no other client already has this IP
for mac, lease := range s.leases {
if lease.IP == ip && mac != clientMAC {
return false
}
}
return true
}
func (s *Server) recordLease(clientMAC, ip string, data []byte) {
lease := &db.DHCPLease{
MAC: clientMAC,
IP: ip,
ExpiresAt: time.Now().Add(time.Duration(s.getConfig().LeaseTime) * time.Second).Unix(),
}
// Try to get hostname from DHCP options
if hostname := parseHostname(data); hostname != "" {
lease.Hostname = hostname
}
s.leaseMutex.Lock()
// Check if lease already exists (e.g. from Offer phase)
existingLease, alreadyExists := s.leases[clientMAC]
s.leases[clientMAC] = lease
s.usedIPs[ip] = clientMAC
s.leaseMutex.Unlock()
// Save to database — update if already exists to avoid duplicates
if alreadyExists && existingLease.ID > 0 {
lease.ID = existingLease.ID
s.db.Save(lease)
} else {
s.db.Create(lease)
}
}
func (s *Server) sendOffer(data []byte, clientMAC, offeredIP string, remoteAddr *net.UDPAddr) {
// Build DHCP OFFER message
response := buildDHCPMessage(MsgOffer, data, offeredIP, s.config)
// Add options
response = appendOption(response, OptionSubnetMask, []byte(net.ParseIP(s.getConfig().Netmask).To4()))
response = appendOption(response, OptionRouter, []byte(net.ParseIP(s.getConfig().Gateway).To4()))
// Add DNS servers
if len(s.getConfig().DNSServers) > 0 {
var dnsBytes []byte
for _, dns := range s.getConfig().DNSServers {
dnsBytes = append(dnsBytes, net.ParseIP(dns).To4()...)
}
response = appendOption(response, OptionDNS, dnsBytes)
}
// Add lease time
leaseTime := make([]byte, 4)
binary.BigEndian.PutUint32(leaseTime, uint32(s.getConfig().LeaseTime))
response = appendOption(response, OptionLeaseTime, leaseTime)
// Add server identifier
response = appendOption(response, OptionServerIdentifier, []byte(s.getServerIP().To4()))
// Add end option
response = append(response, OptionEnd)
// Send response — use broadcast if client has no IP yet (ciaddr == 0.0.0.0)
targetAddr := s.getResponseAddr(data, remoteAddr)
s.conn.WriteToUDP(response, targetAddr)
}
func (s *Server) sendACK(data []byte, clientMAC, ip string, remoteAddr *net.UDPAddr) {
response := buildDHCPMessage(MsgACK, data, ip, s.config)
// Add options
response = appendOption(response, OptionSubnetMask, []byte(net.ParseIP(s.getConfig().Netmask).To4()))
response = appendOption(response, OptionRouter, []byte(net.ParseIP(s.getConfig().Gateway).To4()))
if len(s.getConfig().DNSServers) > 0 {
var dnsBytes []byte
for _, dns := range s.getConfig().DNSServers {
dnsBytes = append(dnsBytes, net.ParseIP(dns).To4()...)
}
response = appendOption(response, OptionDNS, dnsBytes)
}
leaseTime := make([]byte, 4)
binary.BigEndian.PutUint32(leaseTime, uint32(s.getConfig().LeaseTime))
response = appendOption(response, OptionLeaseTime, leaseTime)
response = appendOption(response, OptionServerIdentifier, []byte(s.getServerIP().To4()))
response = append(response, OptionEnd)
targetAddr := s.getResponseAddr(data, remoteAddr)
s.conn.WriteToUDP(response, targetAddr)
}
func (s *Server) sendNAK(data []byte, remoteAddr *net.UDPAddr) {
response := buildDHCPMessage(MsgNAK, data, "0.0.0.0", s.config)
response = appendOption(response, OptionServerIdentifier, []byte(s.getServerIP().To4()))
response = append(response, OptionEnd)
// NAK must always be broadcast
broadcastAddr := &net.UDPAddr{IP: net.IPv4bcast, Port: 68}
s.conn.WriteToUDP(response, broadcastAddr)
}
// getResponseAddr determines where to send the DHCP response.
// Per RFC 2131: if ciaddr is 0.0.0.0, broadcast to 255.255.255.255:68.
// Otherwise unicast to ciaddr:68.
func (s *Server) getResponseAddr(data []byte, remoteAddr *net.UDPAddr) *net.UDPAddr {
if len(data) < 28 {
return remoteAddr
}
// ciaddr is at bytes 24-27
ciaddr := net.IP(data[24:28])
if ciaddr.Equal(net.IPv4zero) || ciaddr.IsUnspecified() {
// Client has no IP yet — broadcast
return &net.UDPAddr{IP: net.IPv4bcast, Port: 68}
}
// Client already has an IP — unicast
return &net.UDPAddr{IP: ciaddr, Port: 68}
}
func (s *Server) loadLeases() {
leases, err := s.db.GetActiveLeases()
if err != nil {
return
}
s.leaseMutex.Lock()
defer s.leaseMutex.Unlock()
for _, lease := range leases {
s.leases[lease.MAC] = &lease
s.usedIPs[lease.IP] = lease.MAC
}
}
func (s *Server) loadStaticBindings() {
bindings, err := s.db.GetStaticBindings()
if err != nil {
return
}
for _, binding := range bindings {
s.staticBindings[binding.MAC] = binding
}
}
func (s *Server) cleanupLeases() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.leaseMutex.Lock()
now := time.Now().Unix()
for mac, lease := range s.leases {
if lease.ExpiresAt < now {
delete(s.leases, mac)
delete(s.usedIPs, lease.IP)
log.Printf("DHCP: Lease expired for %s (%s)", mac, lease.IP)
}
}
s.leaseMutex.Unlock()
case <-s.stopChan:
return
}
}
}
func (s *Server) GetLeases() []db.DHCPLease {
s.leaseMutex.RLock()
defer s.leaseMutex.RUnlock()
leases := make([]db.DHCPLease, 0, len(s.leases))
for _, lease := range s.leases {
leases = append(leases, *lease)
}
return leases
}
func (s *Server) CreateStaticBinding(mac, ip, hostname, description string) error {
binding := db.DHCPStaticBinding{
MAC: mac,
IP: ip,
Hostname: hostname,
Description: description,
Enabled: true,
}
err := s.db.Create(&binding).Error
if err == nil {
s.staticBindings[mac] = binding
}
return err
}
func (s *Server) DeleteStaticBinding(id uint) error {
// Get binding first to remove from cache
var binding db.DHCPStaticBinding
s.db.First(&binding, id)
delete(s.staticBindings, binding.MAC)
return s.db.Delete(&db.DHCPStaticBinding{}, id).Error
}
func (s *Server) GetStaticBindings() ([]db.DHCPStaticBinding, error) {
return s.db.GetStaticBindings()
}
// Helper functions
func parseMessageType(data []byte) byte {
// DHCP message type is option 53
for i := 240; i < len(data)-1; i++ {
if data[i] == OptionMessageType && i+2 < len(data) {
return data[i+2]
}
if data[i] == OptionEnd {
break
}
}
return 0
}
func parseRequestedIP(data []byte) net.IP {
// DHCP requested IP is option 50
for i := 240; i < len(data)-1; i++ {
if data[i] == OptionRequestedIP && i+5 < len(data) && data[i+1] == 4 {
return net.IP(data[i+2 : i+6])
}
if data[i] == OptionEnd {
break
}
}
return nil
}
func parseHostname(data []byte) string {
// DHCP hostname is option 12
for i := 240; i < len(data)-1; i++ {
if data[i] == OptionHostname && i+2 < len(data) {
length := int(data[i+1])
if i+2+length <= len(data) {
return string(data[i+2 : i+2+length])
}
}
if data[i] == OptionEnd {
break
}
}
return ""
}
func formatMAC(mac []byte) string {
return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x",
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5])
}
func buildDHCPMessage(msgType byte, request []byte, ip string, cfg *config.DHCPConfig) []byte {
response := make([]byte, 240)
// Copy operation, hardware type, hardware address length, hops
response[0] = 2 // BOOTREPLY
response[1] = request[1] // hardware type
response[2] = request[2] // hardware address length
// Copy transaction ID
copy(response[4:8], request[4:8])
// Set offered IP
ipBytes := net.ParseIP(ip).To4()
copy(response[16:20], ipBytes)
// Set server IP
serverIP := net.ParseIP(cfg.Gateway).To4()
copy(response[128:132], serverIP)
// Copy client MAC
copy(response[28:34], request[28:34])
// DHCP magic cookie
response[236] = 99
response[237] = 130
response[238] = 83
response[239] = 99
// Add message type option
response = append(response, OptionMessageType)
response = append(response, 1) // length
response = append(response, msgType)
return response
}
func appendOption(data []byte, option byte, value []byte) []byte {
data = append(data, option)
data = append(data, byte(len(value)))
data = append(data, value...)
return data
}
// IP 地址管理工具函数
func IPInRange(ip, start, end string) bool {
ipAddr := net.ParseIP(ip)
startAddr := net.ParseIP(start)
endAddr := net.ParseIP(end)
if ipAddr == nil || startAddr == nil || endAddr == nil {
return false
}
ipBytes := ipAddr.To4()
startBytes := startAddr.To4()
endBytes := endAddr.To4()
if ipBytes == nil || startBytes == nil || endBytes == nil {
return false
}
ipInt := uint32(ipBytes[0])<<24 | uint32(ipBytes[1])<<16 | uint32(ipBytes[2])<<8 | uint32(ipBytes[3])
startInt := uint32(startBytes[0])<<24 | uint32(startBytes[1])<<16 | uint32(startBytes[2])<<8 | uint32(startBytes[3])
endInt := uint32(endBytes[0])<<24 | uint32(endBytes[1])<<16 | uint32(endBytes[2])<<8 | uint32(endBytes[3])
return ipInt >= startInt && ipInt <= endInt
}
// newBroadcastUDPConn creates a UDP listener on the given host:port with SO_BROADCAST enabled.
// This is required for DHCP because responses to clients without an IP must be sent to
// the broadcast address (255.255.255.255:68).
func newBroadcastUDPConn(host string, port int) (*net.UDPConn, error) {
// Create a raw UDP socket so we can set SO_BROADCAST
sock, err := unix.Socket(unix.AF_INET, unix.SOCK_DGRAM, unix.IPPROTO_UDP)
if err != nil {
return nil, fmt.Errorf("failed to create socket: %v", err)
}
// Enable SO_BROADCAST so we can send to 255.255.255.255
if err := unix.SetsockoptInt(sock, unix.SOL_SOCKET, unix.SO_BROADCAST, 1); err != nil {
unix.Close(sock)
return nil, fmt.Errorf("failed to set SO_BROADCAST: %v", err)
}
// Bind to the address
sa := &unix.SockaddrInet4{Port: port}
copy(sa.Addr[:], net.ParseIP(host).To4())
if err := unix.Bind(sock, sa); err != nil {
unix.Close(sock)
return nil, fmt.Errorf("failed to bind: %v", err)
}
// Wrap the raw socket in a net.UDPConn
file := os.NewFile(uintptr(sock), fmt.Sprintf("udp-%s-%d", host, port))
conn, err := net.FileConn(file)
if err != nil {
unix.Close(sock)
return nil, fmt.Errorf("failed to wrap socket: %v", err)
}
udpConn, ok := conn.(*net.UDPConn)
if !ok {
conn.Close()
return nil, fmt.Errorf("not a UDP connection")
}
return udpConn, nil
}
// EvictClient removes a client's lease, forcing them to get a new IP
func (s *Server) EvictClient(mac string) error {
s.leaseMutex.Lock()
defer s.leaseMutex.Unlock()
lease, exists := s.leases[mac]
if !exists {
return fmt.Errorf("lease not found for MAC %s", mac)
}
// Remove from in-memory lease map
delete(s.leases, mac)
delete(s.usedIPs, lease.IP)
// Delete from database
if err := s.db.Where("mac = ?", mac).Delete(&db.DHCPLease{}).Error; err != nil {
return fmt.Errorf("failed to delete lease from database: %v", err)
}
log.Printf("DHCP: Evicted client %s (%s)", mac, lease.IP)
return nil
}