diff --git a/internal/dhcp/server.go b/internal/dhcp/server.go index f0d8e6a..e9de283 100644 --- a/internal/dhcp/server.go +++ b/internal/dhcp/server.go @@ -700,3 +700,26 @@ func newBroadcastUDPConn(host string, port int) (*net.UDPConn, error) { 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 +} diff --git a/internal/web/server.go b/internal/web/server.go index 2656c10..3300194 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -117,6 +117,7 @@ func (s *Server) setupRoutes() { protected.GET("/dhcp/bindings", s.handleGetBindings) protected.POST("/dhcp/bindings", s.handleCreateBinding) protected.DELETE("/dhcp/bindings/:id", s.handleDeleteBinding) + protected.POST("/dhcp/leases/evict", s.handleEvictClient) // DNS protected.GET("/dns/config", s.handleGetDNSConfig) @@ -282,6 +283,30 @@ func (s *Server) handleDeleteBinding(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "Binding deleted"}) } +func (s *Server) handleEvictClient(c *gin.Context) { + var req struct { + MAC string `json:"mac"` + IP string `json:"ip"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if req.MAC == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "MAC is required"}) + return + } + + if err := s.dhcpServer.EvictClient(req.MAC); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Client evicted successfully"}) +} + func (s *Server) handleGetRecords(c *gin.Context) { records, err := s.dnsServer.GetDNSRecords() if err != nil { diff --git a/web/static/js/app.js b/web/static/js/app.js index a24fbd0..36e51b1 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -185,6 +185,17 @@ async function loadClients() { statusCell.innerHTML = isActive ? '● 在线' : '● 已过期'; row.appendChild(statusCell); + // Action buttons + const actionCell = document.createElement('td'); + if (isActive) { + const evictBtn = document.createElement('button'); + evictBtn.textContent = '剔除'; + evictBtn.style.cssText = 'background-color: #f39c12; color: white; border: none; padding: 5px 10px; border-radius: 3px; cursor: pointer; margin-right: 5px;'; + evictBtn.onclick = () => evictClient(lease.MAC, lease.IP); + actionCell.appendChild(evictBtn); + } + row.appendChild(actionCell); + tbody.appendChild(row); }); @@ -502,6 +513,28 @@ async function deleteBinding(id) { } } +async function evictClient(mac, ip) { + if (!confirm(`确定要剔除主机 ${mac} (${ip}) 吗?\n\n主机将被迫释放该 IP 并重新获取。`)) return; + + try { + const response = await fetch(`/api/dhcp/leases/evict`, { + method: 'POST', + headers: { 'X-Session-ID': sessionId, 'Content-Type': 'application/json' }, + body: JSON.stringify({ mac, ip }) + }); + + if (response.ok) { + alert('主机已剔除,将重新获取 IP'); + loadClients(); + } else { + const data = await response.json(); + alert('剔除失败:' + (data.error || '未知错误')); + } + } catch (error) { + alert('剔除失败:' + error.message); + } +} + // Load DNS Config async function loadDNSConfig() { try { diff --git a/web/templates/index.html b/web/templates/index.html index d2e839d..3175a57 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -182,11 +182,12 @@