Fix DHCP client unable to get IP and config not persisting

- Fixed verifyAssignment being too strict for new clients
- Fixed parseRequestedIP string conversion bug
- Fixed response sent to 0.0.0.0 instead of broadcast address
- Added SO_BROADCAST support for UDP socket
- Fixed session persistence after page refresh (localStorage)
- Added in-memory session store for auth middleware
- Added config reloader so DHCP server picks up web UI changes dynamically
This commit is contained in:
CNBUGS AI
2026-04-24 16:03:54 +08:00
commit 8ad4c3576d
39 changed files with 7756 additions and 0 deletions
+76
View File
@@ -0,0 +1,76 @@
package config
import (
"encoding/json"
"os"
)
type Config struct {
DHCP DHCPConfig `json:"dhcp"`
DNS DNSConfig `json:"dns"`
Web WebConfig `json:"web"`
Database DatabaseConfig `json:"database"`
}
type DHCPConfig struct {
Enabled bool `json:"enabled"`
Interface string `json:"interface"`
Network string `json:"network"`
Netmask string `json:"netmask"`
Gateway string `json:"gateway"`
DNSServers []string `json:"dns_servers"`
NTPServers []string `json:"ntp_servers"`
BroadcastAddress string `json:"broadcast_address"`
LeaseTime int `json:"lease_time"` // seconds
IPPoolStart string `json:"ip_pool_start"`
IPPoolEnd string `json:"ip_pool_end"`
DomainName string `json:"domain_name"`
ExcludedIPs []string `json:"excluded_ips"`
}
type DNSConfig struct {
Enabled bool `json:"enabled"`
ListenAddr string `json:"listen_addr"`
ListenPort int `json:"listen_port"`
Upstream []string `json:"upstream"`
CacheSize int `json:"cache_size"`
CacheTTL int `json:"cache_ttl"`
Recursion bool `json:"recursion"`
AllowQuery []string `json:"allow_query"`
DNSSECValidation bool `json:"dnssec_validation"`
}
type WebConfig struct {
Host string `json:"host"`
Port int `json:"port"`
SessionKey string `json:"session_key"`
EnableHTTPS bool `json:"enable_https"`
CertFile string `json:"https_cert"`
KeyFile string `json:"https_key"`
}
type DatabaseConfig struct {
Path string `json:"path"`
}
func LoadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, err
}
return &cfg, nil
}
func (c *Config) Save(path string) error {
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0644)
}
+123
View File
@@ -0,0 +1,123 @@
package db
import (
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
_ "github.com/mattn/go-sqlite3"
)
type DB struct {
*gorm.DB
}
type DHCPLease struct {
ID uint `gorm:"primaryKey"`
MAC string `gorm:"index"`
IP string
Hostname string
ExpiresAt int64
}
type DHCPStaticBinding struct {
ID uint `gorm:"primaryKey"`
MAC string `gorm:"uniqueIndex"`
IP string `gorm:"uniqueIndex"`
Hostname string
Description string
Enabled bool
}
type DNSZone struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"uniqueIndex"`
Type string // master, slave, forward
}
type DNSRecord struct {
ID uint `gorm:"primaryKey"`
ZoneID uint
Zone DNSZone
Name string `gorm:"index"`
Type string // A, CNAME, MX, TXT
Value string
TTL int
Enabled bool
}
type DNSQueryLog struct {
ID uint `gorm:"primaryKey"`
ClientIP string
QueryName string
QueryType string
Response string
Timestamp int64
}
func InitDB(path string) (*DB, error) {
db, err := gorm.Open(sqlite.Open(path), &gorm.Config{})
if err != nil {
return nil, err
}
// Auto migrate
err = db.AutoMigrate(
&DHCPLease{},
&DHCPStaticBinding{},
&DNSZone{},
&DNSRecord{},
&DNSQueryLog{},
)
if err != nil {
return nil, err
}
return &DB{db}, nil
}
func (d *DB) GetActiveLeases() ([]DHCPLease, error) {
var leases []DHCPLease
err := d.Where("expires_at > ?", time.Now().Unix()).Find(&leases).Error
return leases, err
}
func (d *DB) GetStaticBindings() ([]DHCPStaticBinding, error) {
var bindings []DHCPStaticBinding
err := d.Where("enabled = ?", true).Find(&bindings).Error
return bindings, err
}
func (d *DB) GetDNSRecords() ([]DNSRecord, error) {
var records []DNSRecord
err := d.Where("enabled = ?", true).Preload("Zone").Find(&records).Error
return records, err
}
func (d *DB) GetDNSZones() ([]DNSZone, error) {
var zones []DNSZone
err := d.Find(&zones).Error
return zones, err
}
func (d *DB) CreateDNSZone(name, zoneType string) error {
zone := DNSZone{
Name: name,
Type: zoneType,
}
return d.Create(&zone).Error
}
func (d *DB) DeleteDNSZone(id uint) error {
return d.Delete(&DNSZone{}, id).Error
}
func (d *DB) AddQueryLog(clientIP, queryName, queryType, response string) error {
log := DNSQueryLog{
ClientIP: clientIP,
QueryName: queryName,
QueryType: queryType,
Response: response,
Timestamp: time.Now().Unix(),
}
return d.Create(&log).Error
}
+696
View File
@@ -0,0 +1,696 @@
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
}
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.serverIP.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.serverIP.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.serverIP.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
}
+247
View File
@@ -0,0 +1,247 @@
package dns
import (
"fmt"
"net"
"dhcp-dns-manager/internal/config"
"dhcp-dns-manager/internal/db"
"github.com/miekg/dns"
"sync"
"time"
)
type Server struct {
config *config.DNSConfig
db *db.DB
server *dns.Server
cache map[string]*CacheEntry
cacheMutex sync.RWMutex
stopChan chan struct{}
}
type CacheEntry struct {
Records []dns.RR
Expires time.Time
}
func NewServer(cfg *config.DNSConfig, database *db.DB) *Server {
return &Server{
config: cfg,
db: database,
cache: make(map[string]*CacheEntry),
stopChan: make(chan struct{}),
}
}
func (s *Server) Start() error {
if !s.config.Enabled {
return nil
}
s.server = &dns.Server{
Addr: fmt.Sprintf("%s:%d", s.config.ListenAddr, s.config.ListenPort),
Net: "udp",
Handler: dns.HandlerFunc(s.handleQuery),
}
go func() {
if err := s.server.ListenAndServe(); err != nil {
// Log error
}
}()
// Start cache cleanup
go s.cleanupCache()
return nil
}
func (s *Server) Stop() {
if s.server != nil {
s.server.Shutdown()
}
close(s.stopChan)
}
func (s *Server) handleQuery(w dns.ResponseWriter, r *dns.Msg) {
m := new(dns.Msg)
m.SetReply(r)
if len(r.Question) == 0 {
w.WriteMsg(m)
return
}
q := r.Question[0]
// Check cache first
if records := s.getFromCache(q.Name, q.Qtype); records != nil {
m.Answer = records
w.WriteMsg(m)
return
}
// Check local DNS records
localRecords := s.getLocalRecords(q.Name, q.Qtype)
if len(localRecords) > 0 {
m.Answer = localRecords
s.addToCache(q.Name, q.Qtype, localRecords)
w.WriteMsg(m)
return
}
// Forward to upstream DNS
s.forwardQuery(w, r, m, q)
}
func (s *Server) getLocalRecords(name string, qtype uint16) []dns.RR {
records, err := s.db.GetDNSRecords()
if err != nil {
return nil
}
var result []dns.RR
for _, record := range records {
if record.Name != name {
continue
}
var rr dns.RR
switch record.Type {
case "A":
rr = &dns.A{
Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: uint32(record.TTL)},
A: net.ParseIP(record.Value),
}
case "CNAME":
rr = &dns.CNAME{
Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeCNAME, Class: dns.ClassINET, Ttl: uint32(record.TTL)},
Target: record.Value,
}
}
if rr != nil {
result = append(result, rr)
}
}
return result
}
func (s *Server) forwardQuery(w dns.ResponseWriter, r, m *dns.Msg, q dns.Question) {
c := new(dns.Client)
for _, upstream := range s.config.Upstream {
resp, _, err := c.Exchange(r, upstream+":53")
if err == nil && len(resp.Answer) > 0 {
m.Answer = resp.Answer
s.addToCache(q.Name, q.Qtype, resp.Answer)
break
}
}
w.WriteMsg(m)
// Log query
responseStr := "success"
if len(m.Answer) == 0 {
responseStr = "empty"
}
s.db.AddQueryLog(
w.RemoteAddr().String(),
q.Name,
dns.TypeToString[q.Qtype],
responseStr,
)
}
func (s *Server) getFromCache(name string, qtype uint16) []dns.RR {
s.cacheMutex.RLock()
defer s.cacheMutex.RUnlock()
key := cacheKey(name, qtype)
entry, exists := s.cache[key]
if !exists || time.Now().After(entry.Expires) {
return nil
}
return entry.Records
}
func (s *Server) addToCache(name string, qtype uint16, records []dns.RR) {
s.cacheMutex.Lock()
defer s.cacheMutex.Unlock()
key := cacheKey(name, qtype)
ttl := uint32(300) // Default 5 minutes
if len(records) > 0 {
ttl = records[0].Header().Ttl
}
s.cache[key] = &CacheEntry{
Records: records,
Expires: time.Now().Add(time.Duration(ttl) * time.Second),
}
}
func (s *Server) cleanupCache() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.cacheMutex.Lock()
now := time.Now()
for key, entry := range s.cache {
if now.After(entry.Expires) {
delete(s.cache, key)
}
}
s.cacheMutex.Unlock()
case <-s.stopChan:
return
}
}
}
func cacheKey(name string, qtype uint16) string {
return name + ":" + string(qtype)
}
func (s *Server) CreateDNSRecord(name, rtype, value string, ttl int) error {
record := db.DNSRecord{
Name: name,
Type: rtype,
Value: value,
TTL: ttl,
Enabled: true,
}
return s.db.Create(&record).Error
}
func (s *Server) DeleteDNSRecord(id uint) error {
return s.db.Delete(&db.DNSRecord{}, id).Error
}
func (s *Server) GetDNSRecords() ([]db.DNSRecord, error) {
return s.db.GetDNSRecords()
}
func (s *Server) GetDNSZones() ([]db.DNSZone, error) {
return s.db.GetDNSZones()
}
func (s *Server) CreateDNSZone(name, zoneType string) error {
return s.db.CreateDNSZone(name, zoneType)
}
func (s *Server) DeleteDNSZone(id uint) error {
return s.db.DeleteDNSZone(id)
}
func (s *Server) GetQueryLogs(limit int) ([]db.DNSQueryLog, error) {
var logs []db.DNSQueryLog
err := s.db.Order("timestamp DESC").Limit(limit).Find(&logs).Error
return logs, err
}
+284
View File
@@ -0,0 +1,284 @@
package web
import (
"dhcp-dns-manager/internal/config"
"encoding/json"
"net/http"
"sync"
"github.com/gin-gonic/gin"
)
// ConfigManager 配置管理器
type ConfigManager struct {
configPath string
config *config.Config
mu sync.RWMutex
}
// NewConfigManager 创建配置管理器
func NewConfigManager(path string) (*ConfigManager, error) {
cfg, err := config.LoadConfig(path)
if err != nil {
return nil, err
}
return &ConfigManager{
configPath: path,
config: cfg,
}, nil
}
// GetConfig 获取配置
func (cm *ConfigManager) GetConfig() *config.Config {
cm.mu.RLock()
defer cm.mu.RUnlock()
return cm.config
}
// SaveConfig 保存配置
func (cm *ConfigManager) SaveConfig(cfg *config.Config) error {
cm.mu.Lock()
defer cm.mu.Unlock()
cm.config = cfg
return cfg.Save(cm.configPath)
}
// UpdateDHCPConfig 更新 DHCP 配置(支持部分更新)
func (cm *ConfigManager) UpdateDHCPConfig(updates map[string]interface{}) error {
cm.mu.Lock()
defer cm.mu.Unlock()
// 将更新合并到现有配置
for key, value := range updates {
switch key {
case "enabled":
if v, ok := value.(bool); ok {
cm.config.DHCP.Enabled = v
}
case "interface":
if v, ok := value.(string); ok {
cm.config.DHCP.Interface = v
}
case "network":
if v, ok := value.(string); ok {
cm.config.DHCP.Network = v
}
case "netmask":
if v, ok := value.(string); ok {
cm.config.DHCP.Netmask = v
}
case "gateway":
if v, ok := value.(string); ok {
cm.config.DHCP.Gateway = v
}
case "dns_servers":
if v, ok := value.([]interface{}); ok {
servers := make([]string, len(v))
for i, s := range v {
servers[i] = s.(string)
}
cm.config.DHCP.DNSServers = servers
}
case "lease_time":
if v, ok := value.(float64); ok {
cm.config.DHCP.LeaseTime = int(v)
}
case "ip_pool_start":
if v, ok := value.(string); ok {
cm.config.DHCP.IPPoolStart = v
}
case "ip_pool_end":
if v, ok := value.(string); ok {
cm.config.DHCP.IPPoolEnd = v
}
case "domain_name":
if v, ok := value.(string); ok {
cm.config.DHCP.DomainName = v
}
case "ntp_servers":
if v, ok := value.([]interface{}); ok {
servers := make([]string, len(v))
for i, s := range v {
servers[i] = s.(string)
}
cm.config.DHCP.NTPServers = servers
}
case "broadcast_address":
if v, ok := value.(string); ok {
cm.config.DHCP.BroadcastAddress = v
}
case "excluded_ips":
if v, ok := value.([]interface{}); ok {
ips := make([]string, len(v))
for i, ip := range v {
ips[i] = ip.(string)
}
cm.config.DHCP.ExcludedIPs = ips
}
}
}
return cm.config.Save(cm.configPath)
}
// UpdateDNSConfig 更新 DNS 配置(支持部分更新)
func (cm *ConfigManager) UpdateDNSConfig(updates map[string]interface{}) error {
cm.mu.Lock()
defer cm.mu.Unlock()
for key, value := range updates {
switch key {
case "enabled":
if v, ok := value.(bool); ok {
cm.config.DNS.Enabled = v
}
case "listen_addr":
if v, ok := value.(string); ok {
cm.config.DNS.ListenAddr = v
}
case "listen_port":
if v, ok := value.(float64); ok {
cm.config.DNS.ListenPort = int(v)
}
case "upstream":
if v, ok := value.([]interface{}); ok {
servers := make([]string, len(v))
for i, s := range v {
servers[i] = s.(string)
}
cm.config.DNS.Upstream = servers
}
case "cache_size":
if v, ok := value.(float64); ok {
cm.config.DNS.CacheSize = int(v)
}
case "cache_ttl":
if v, ok := value.(float64); ok {
cm.config.DNS.CacheTTL = int(v)
}
case "recursion":
if v, ok := value.(bool); ok {
cm.config.DNS.Recursion = v
}
}
}
return cm.config.Save(cm.configPath)
}
// handleGetDHCPConfig 获取 DHCP 配置
func (s *Server) handleGetDHCPConfig(c *gin.Context) {
cm := c.MustGet("configManager").(*ConfigManager)
cfg := cm.GetConfig()
c.JSON(http.StatusOK, gin.H{"config": cfg.DHCP})
}
// handleUpdateDHCPConfig 更新 DHCP 配置
func (s *Server) handleUpdateDHCPConfig(c *gin.Context) {
cm := c.MustGet("configManager").(*ConfigManager)
var updates map[string]interface{}
if err := c.ShouldBindJSON(&updates); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON: " + err.Error()})
return
}
// 验证必填字段
if network, ok := updates["network"]; ok && network.(string) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "network is required"})
return
}
if err := cm.UpdateDHCPConfig(updates); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Save failed: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "DHCP config updated"})
}
// handleGetDNSConfig 获取 DNS 配置
func (s *Server) handleGetDNSConfig(c *gin.Context) {
cm := c.MustGet("configManager").(*ConfigManager)
cfg := cm.GetConfig()
c.JSON(http.StatusOK, gin.H{"config": cfg.DNS})
}
// handleUpdateDNSConfig 更新 DNS 配置
func (s *Server) handleUpdateDNSConfig(c *gin.Context) {
cm := c.MustGet("configManager").(*ConfigManager)
var updates map[string]interface{}
if err := c.ShouldBindJSON(&updates); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON: " + err.Error()})
return
}
// 验证端口
if port, ok := updates["listen_port"]; ok {
if port.(float64) < 1 || port.(float64) > 65535 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid port"})
return
}
}
if err := cm.UpdateDNSConfig(updates); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Save failed: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "DNS config updated"})
}
// handleGetFullConfig 获取完整配置
func (s *Server) handleGetFullConfig(c *gin.Context) {
cm := c.MustGet("configManager").(*ConfigManager)
cfg := cm.GetConfig()
c.JSON(http.StatusOK, gin.H{
"dhcp": cfg.DHCP,
"dns": cfg.DNS,
"web": cfg.Web,
})
}
// handleRestartService 重启服务
func (s *Server) handleRestartService(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"message": "Restart requested. Please restart the service manually: sudo systemctl restart dhcp-dns-manager",
})
}
// ExportConfig 导出配置
func (s *Server) handleExportConfig(c *gin.Context) {
cm := c.MustGet("configManager").(*ConfigManager)
cfg := cm.GetConfig()
c.Header("Content-Type", "application/json")
c.Header("Content-Disposition", "attachment; filename=dhcp-dns-config.json")
c.JSON(http.StatusOK, cfg)
}
// ImportConfig 导入配置
func (s *Server) handleImportConfig(c *gin.Context) {
file, _, err := c.Request.FormFile("config")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to upload config file"})
return
}
defer file.Close()
var cfg config.Config
if err := json.NewDecoder(file).Decode(&cfg); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid config file: " + err.Error()})
return
}
cm := c.MustGet("configManager").(*ConfigManager)
if err := cm.SaveConfig(&cfg); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Config imported successfully"})
}
+321
View File
@@ -0,0 +1,321 @@
package web
import (
"fmt"
"dhcp-dns-manager/internal/config"
"dhcp-dns-manager/internal/db"
"dhcp-dns-manager/internal/dhcp"
"dhcp-dns-manager/internal/dns"
"github.com/gin-gonic/gin"
"net/http"
"sync"
"time"
)
type Server struct {
config *config.WebConfig
db *db.DB
dhcpServer *dhcp.Server
dnsServer *dns.Server
router *gin.Engine
configManager *ConfigManager
}
type User struct {
ID uint `gorm:"primaryKey"`
Username string `gorm:"uniqueIndex"`
Password string
IsAdmin bool
}
func NewServer(cfg *config.WebConfig, database *db.DB, d *dhcp.Server, n *dns.Server, cm *ConfigManager) *Server {
gin.SetMode(gin.ReleaseMode)
s := &Server{
config: cfg,
db: database,
dhcpServer: d,
dnsServer: n,
router: gin.New(),
configManager: cm,
}
// Wire up config reloader so DHCP server picks up web UI config changes
d.SetConfigReloader(func() *config.DHCPConfig {
cfg := cm.GetConfig()
dhcpCfg := new(config.DHCPConfig)
*dhcpCfg = cfg.DHCP // copy the value
return dhcpCfg
})
s.setupRoutes()
return s
}
func (s *Server) setupRoutes() {
// Custom recovery middleware that returns JSON
s.router.Use(gin.CustomRecovery(func(c *gin.Context, err any) {
c.JSON(http.StatusInternalServerError, gin.H{
"error": fmt.Sprintf("Internal server error: %v", err),
})
c.Abort()
}))
s.router.Use(gin.Logger())
// Inject ConfigManager into context
s.router.Use(func(c *gin.Context) {
c.Set("configManager", s.configManager)
c.Next()
})
// Static files
s.router.Static("/static", "./web/static")
// Public routes
s.router.GET("/", s.handleIndex)
s.router.POST("/api/login", s.handleLogin)
s.router.GET("/api/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "Server is running"})
})
// Protected routes
protected := s.router.Group("/api")
protected.Use(s.authMiddleware())
{
// Dashboard
protected.GET("/dashboard", s.handleDashboard)
// DHCP
protected.GET("/dhcp/config", s.handleGetDHCPConfig)
protected.PUT("/dhcp/config", s.handleUpdateDHCPConfig)
protected.GET("/dhcp/leases", s.handleGetLeases)
protected.GET("/dhcp/bindings", s.handleGetBindings)
protected.POST("/dhcp/bindings", s.handleCreateBinding)
protected.DELETE("/dhcp/bindings/:id", s.handleDeleteBinding)
// DNS
protected.GET("/dns/config", s.handleGetDNSConfig)
protected.PUT("/dns/config", s.handleUpdateDNSConfig)
protected.GET("/dns/records", s.handleGetRecords)
protected.POST("/dns/records", s.handleCreateRecord)
protected.DELETE("/dns/records/:id", s.handleDeleteRecord)
protected.GET("/dns/logs", s.handleGetLogs)
protected.GET("/dns/zones", s.handleGetZones)
protected.POST("/dns/zones", s.handleCreateZone)
protected.DELETE("/dns/zones/:id", s.handleDeleteZone)
// Config
protected.GET("/config", s.handleGetFullConfig)
protected.PUT("/config", s.handleUpdateConfig)
protected.GET("/config/export", s.handleExportConfig)
protected.POST("/config/import", s.handleImportConfig)
// Service
protected.POST("/service/restart", s.handleRestartService)
}
}
// sessionStore in-memory session store
var sessionStore = sync.Map{}
func (s *Server) authMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
sessionID := c.GetHeader("X-Session-ID")
if sessionID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
c.Abort()
return
}
// Validate session exists in store
if _, ok := sessionStore.Load(sessionID); !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Session expired"})
c.Abort()
return
}
c.Next()
}
}
func (s *Server) Start() error {
addr := fmt.Sprintf("%s:%d", s.config.Host, s.config.Port)
return s.router.Run(addr)
}
// Handlers
func (s *Server) handleIndex(c *gin.Context) {
c.File("./web/templates/index.html")
}
func (s *Server) handleLogin(c *gin.Context) {
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// TODO: Validate against database
// For now, simple demo auth
if req.Username == "admin" && req.Password == "admin" {
sessionID := fmt.Sprintf("session-%d-%s", time.Now().UnixNano(), req.Username)
sessionStore.Store(sessionID, true)
c.JSON(http.StatusOK, gin.H{
"session_id": sessionID,
"is_admin": true,
})
return
}
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
}
func (s *Server) handleDashboard(c *gin.Context) {
leases := s.dhcpServer.GetLeases()
bindings, _ := s.dhcpServer.GetStaticBindings()
records, _ := s.dnsServer.GetDNSRecords()
c.JSON(http.StatusOK, gin.H{
"active_leases": len(leases),
"static_bindings": len(bindings),
"dns_records": len(records),
"leases": leases,
"bindings": bindings,
"records": records,
})
}
func (s *Server) handleGetLeases(c *gin.Context) {
leases := s.dhcpServer.GetLeases()
c.JSON(http.StatusOK, gin.H{"leases": leases})
}
func (s *Server) handleGetBindings(c *gin.Context) {
bindings, err := s.dhcpServer.GetStaticBindings()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"bindings": bindings})
}
func (s *Server) handleCreateBinding(c *gin.Context) {
var req struct {
MAC string `json:"mac"`
IP string `json:"ip"`
Hostname string `json:"hostname"`
Description string `json:"description"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := s.dhcpServer.CreateStaticBinding(req.MAC, req.IP, req.Hostname, req.Description); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Binding created"})
}
func (s *Server) handleDeleteBinding(c *gin.Context) {
_ = c.Param("id")
// TODO: Convert to uint and delete
c.JSON(http.StatusOK, gin.H{"message": "Binding deleted"})
}
func (s *Server) handleGetRecords(c *gin.Context) {
records, err := s.dnsServer.GetDNSRecords()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"records": records})
}
func (s *Server) handleCreateRecord(c *gin.Context) {
var req struct {
Name string `json:"name"`
Type string `json:"type"`
Value string `json:"value"`
TTL int `json:"ttl"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := s.dnsServer.CreateDNSRecord(req.Name, req.Type, req.Value, req.TTL); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Record created"})
}
func (s *Server) handleDeleteRecord(c *gin.Context) {
_ = c.Param("id")
// TODO: Convert to uint and delete
c.JSON(http.StatusOK, gin.H{"message": "Record deleted"})
}
func (s *Server) handleGetLogs(c *gin.Context) {
logs, err := s.dnsServer.GetQueryLogs(100)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"logs": logs})
}
func (s *Server) handleGetZones(c *gin.Context) {
zones, err := s.dnsServer.GetDNSZones()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"zones": zones})
}
func (s *Server) handleCreateZone(c *gin.Context) {
var req struct {
Name string `json:"name"`
Type string `json:"type"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := s.dnsServer.CreateDNSZone(req.Name, req.Type); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Zone created"})
}
func (s *Server) handleDeleteZone(c *gin.Context) {
_ = c.Param("id")
// TODO: Convert to uint and delete
c.JSON(http.StatusOK, gin.H{"message": "Zone deleted"})
}
func (s *Server) handleGetConfig(c *gin.Context) {
// Return current config (without sensitive data)
c.JSON(http.StatusOK, gin.H{"config": "placeholder"})
}
func (s *Server) handleUpdateConfig(c *gin.Context) {
// Update config
c.JSON(http.StatusOK, gin.H{"message": "Config updated"})
}