Просмотр исходного кода

feat(dhcp): implement RouterOS DHCP client suite, fix ping innerText error & routing telemetry toast

Proxmox Router 1 неделя назад
Родитель
Сommit
b04161fb56

+ 3 - 1
cmd/server/main.go

@@ -13,6 +13,7 @@ import (
 
 	"proxmox-router/internal/api"
 	"proxmox-router/internal/bridge"
+	"proxmox-router/internal/dhcp"
 	"proxmox-router/internal/dynamicrouting"
 	"proxmox-router/internal/firewall"
 	"proxmox-router/internal/network"
@@ -37,9 +38,10 @@ func main() {
 	dynRoutingMgr := dynamicrouting.NewManager("")
 	bridgeMgr := bridge.NewBridgeManager()
 	toolsMgr := tools.NewManager()
+	dhcpMgr := dhcp.NewManager()
 	webHandler := web.Handler()
 
-	srv := api.NewServer(netMgr, persistMgr, metaMgr, vpnMgr, fwMgr, routingMgr, dynRoutingMgr, bridgeMgr, toolsMgr, webHandler)
+	srv := api.NewServer(netMgr, persistMgr, metaMgr, vpnMgr, fwMgr, routingMgr, dynRoutingMgr, bridgeMgr, toolsMgr, dhcpMgr, webHandler)
 
 	httpServer := &http.Server{
 		Addr:         *addr,

+ 14 - 1
internal/api/api.go

@@ -6,6 +6,7 @@ import (
 	"strings"
 
 	"proxmox-router/internal/bridge"
+	"proxmox-router/internal/dhcp"
 	"proxmox-router/internal/dynamicrouting"
 	"proxmox-router/internal/firewall"
 	"proxmox-router/internal/network"
@@ -25,11 +26,12 @@ type Server struct {
 	dynRoutingMgr *dynamicrouting.Manager
 	bridgeMgr     *bridge.BridgeManager
 	toolsMgr      *tools.Manager
+	dhcpMgr       *dhcp.Manager
 	mux           *http.ServeMux
 	fsHandler     http.Handler
 }
 
-func NewServer(netMgr *network.Manager, persistMgr *network.PersistenceManager, metaMgr *network.MetadataManager, vpnMgr *vpn.Manager, fwMgr *firewall.FirewallManager, routingMgr *routing.RoutingManager, dynRoutingMgr *dynamicrouting.Manager, bridgeMgr *bridge.BridgeManager, toolsMgr *tools.Manager, fsHandler http.Handler) *Server {
+func NewServer(netMgr *network.Manager, persistMgr *network.PersistenceManager, metaMgr *network.MetadataManager, vpnMgr *vpn.Manager, fwMgr *firewall.FirewallManager, routingMgr *routing.RoutingManager, dynRoutingMgr *dynamicrouting.Manager, bridgeMgr *bridge.BridgeManager, toolsMgr *tools.Manager, dhcpMgr *dhcp.Manager, fsHandler http.Handler) *Server {
 	s := &Server{
 		netMgr:        netMgr,
 		persistMgr:    persistMgr,
@@ -40,6 +42,7 @@ func NewServer(netMgr *network.Manager, persistMgr *network.PersistenceManager,
 		dynRoutingMgr: dynRoutingMgr,
 		bridgeMgr:     bridgeMgr,
 		toolsMgr:      toolsMgr,
+		dhcpMgr:       dhcpMgr,
 		mux:           http.NewServeMux(),
 		fsHandler:     fsHandler,
 	}
@@ -272,6 +275,16 @@ func (s *Server) registerRoutes() {
 	s.mux.HandleFunc("POST /api/tools/port-probe", s.handleToolsPortProbe)
 	s.mux.HandleFunc("POST /api/tools/mtr", s.handleToolsMTR)
 
+	// DHCP Client (/ip dhcp-client)
+	s.mux.HandleFunc("GET /api/dhcp/clients", s.handleListDHCPClients)
+	s.mux.HandleFunc("GET /api/dhcp/clients/summary", s.handleGetDHCPSummary)
+	s.mux.HandleFunc("POST /api/dhcp/clients", s.handleCreateDHCPClient)
+	s.mux.HandleFunc("PUT /api/dhcp/clients/{id}", s.handleUpdateDHCPClient)
+	s.mux.HandleFunc("DELETE /api/dhcp/clients/{id}", s.handleDeleteDHCPClient)
+	s.mux.HandleFunc("POST /api/dhcp/clients/{id}/state", s.handleToggleDHCPClientState)
+	s.mux.HandleFunc("POST /api/dhcp/clients/{id}/renew", s.handleRenewDHCPClient)
+	s.mux.HandleFunc("POST /api/dhcp/clients/{id}/release", s.handleReleaseDHCPClient)
+
 	// Web UI Static Files
 	if s.fsHandler != nil {
 		s.mux.Handle("/", s.fsHandler)

+ 124 - 0
internal/api/dhcp_handlers.go

@@ -0,0 +1,124 @@
+package api
+
+import (
+	"encoding/json"
+	"net/http"
+
+	"proxmox-router/internal/dhcp"
+)
+
+func (s *Server) handleListDHCPClients(w http.ResponseWriter, r *http.Request) {
+	clients := s.dhcpMgr.ListClients()
+	writeJSON(w, http.StatusOK, clients)
+}
+
+func (s *Server) handleGetDHCPSummary(w http.ResponseWriter, r *http.Request) {
+	summary := s.dhcpMgr.GetSummary()
+	writeJSON(w, http.StatusOK, summary)
+}
+
+func (s *Server) handleCreateDHCPClient(w http.ResponseWriter, r *http.Request) {
+	var req dhcp.CreateDHCPClientRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid JSON: " + err.Error()})
+		return
+	}
+
+	client, err := s.dhcpMgr.AddClient(req)
+	if err != nil {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
+		return
+	}
+
+	writeJSON(w, http.StatusCreated, client)
+}
+
+func (s *Server) handleUpdateDHCPClient(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	if id == "" {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Client ID is required"})
+		return
+	}
+
+	var req dhcp.UpdateDHCPClientRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid JSON: " + err.Error()})
+		return
+	}
+
+	client, err := s.dhcpMgr.UpdateClient(id, req)
+	if err != nil {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
+		return
+	}
+
+	writeJSON(w, http.StatusOK, client)
+}
+
+func (s *Server) handleDeleteDHCPClient(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	if id == "" {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Client ID is required"})
+		return
+	}
+
+	if err := s.dhcpMgr.DeleteClient(id); err != nil {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
+		return
+	}
+
+	writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
+}
+
+func (s *Server) handleToggleDHCPClientState(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	if id == "" {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Client ID is required"})
+		return
+	}
+
+	var payload struct {
+		Enabled bool `json:"enabled"`
+	}
+	if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid JSON: " + err.Error()})
+		return
+	}
+
+	if err := s.dhcpMgr.ToggleState(id, payload.Enabled); err != nil {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
+		return
+	}
+
+	writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
+}
+
+func (s *Server) handleRenewDHCPClient(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	if id == "" {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Client ID is required"})
+		return
+	}
+
+	if err := s.dhcpMgr.RenewLease(id); err != nil {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
+		return
+	}
+
+	writeJSON(w, http.StatusOK, map[string]string{"status": "renewed"})
+}
+
+func (s *Server) handleReleaseDHCPClient(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	if id == "" {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Client ID is required"})
+		return
+	}
+
+	if err := s.dhcpMgr.ReleaseLease(id); err != nil {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
+		return
+	}
+
+	writeJSON(w, http.StatusOK, map[string]string{"status": "released"})
+}

+ 566 - 0
internal/dhcp/manager.go

@@ -0,0 +1,566 @@
+package dhcp
+
+import (
+	"encoding/json"
+	"fmt"
+	"net"
+	"os"
+	"os/exec"
+	"regexp"
+	"strconv"
+	"strings"
+	"sync"
+	"syscall"
+	"time"
+
+	"github.com/vishvananda/netlink"
+)
+
+const (
+	DHCPConfigFile = "/etc/proxmox-router/dhcp_clients.json"
+	DHCPRunDir     = "/run"
+	DHCPLeaseDir   = "/var/lib/dhcp"
+	DHCPConfDir    = "/etc/dhcp"
+)
+
+var (
+	leaseBlockRegex     = regexp.MustCompile(`(?s)lease\s+\{([^}]+)\}`)
+	fixedAddrRegex      = regexp.MustCompile(`fixed-address\s+([0-9.]+);`)
+	subnetMaskRegex     = regexp.MustCompile(`option subnet-mask\s+([0-9.]+);`)
+	routersRegex        = regexp.MustCompile(`option routers\s+([0-9.]+);`)
+	dhcpServerRegex     = regexp.MustCompile(`option dhcp-server-identifier\s+([0-9.]+);`)
+	dnsServersRegex     = regexp.MustCompile(`option domain-name-servers\s+([^;]+);`)
+	domainNameRegex     = regexp.MustCompile(`option domain-name\s+"([^"]+)";`)
+	leaseTimeRegex      = regexp.MustCompile(`option dhcp-lease-time\s+(\d+);`)
+	expireTimeRegex     = regexp.MustCompile(`expire\s+\d+\s+([0-9/]+ [0-9:]+);`)
+	renewTimeRegex      = regexp.MustCompile(`renew\s+\d+\s+([0-9/]+ [0-9:]+);`)
+	validInterfaceRegex = regexp.MustCompile(`^[a-zA-Z0-9.:_-]+$`)
+)
+
+// DHCPConfigStore wraps the persisted configuration.
+type DHCPConfigStore struct {
+	Clients []*DHCPClient `json:"clients"`
+}
+
+// Manager orchestrates all DHCP client instances.
+type Manager struct {
+	mu        sync.RWMutex
+	storePath string
+	clients   map[string]*DHCPClient
+}
+
+// NewManager initializes the DHCP Manager and restores saved clients.
+func NewManager() *Manager {
+	_ = os.MkdirAll("/etc/proxmox-router", 0755)
+	_ = os.MkdirAll(DHCPLeaseDir, 0755)
+	_ = os.MkdirAll(DHCPConfDir, 0755)
+
+	m := &Manager{
+		storePath: DHCPConfigFile,
+		clients:   make(map[string]*DHCPClient),
+	}
+
+	_ = m.Load()
+	return m
+}
+
+// Load loads saved clients from disk and ensures enabled clients are started.
+func (m *Manager) Load() error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	data, err := os.ReadFile(m.storePath)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return nil
+		}
+		return err
+	}
+
+	var store DHCPConfigStore
+	if err := json.Unmarshal(data, &store); err != nil {
+		return err
+	}
+
+	for _, c := range store.Clients {
+		if c.ID == "" {
+			c.ID = "dhcp-" + c.Interface
+		}
+		m.clients[c.ID] = c
+	}
+
+	// Startup active clients
+	go func() {
+		time.Sleep(1 * time.Second)
+		m.SyncAll()
+	}()
+
+	return nil
+}
+
+// Save persists the current DHCP clients to disk.
+func (m *Manager) Save() error {
+	store := DHCPConfigStore{
+		Clients: make([]*DHCPClient, 0, len(m.clients)),
+	}
+	for _, c := range m.clients {
+		store.Clients = append(store.Clients, c)
+	}
+
+	data, err := json.MarshalIndent(store, "", "  ")
+	if err != nil {
+		return err
+	}
+
+	tmpFile := m.storePath + ".tmp"
+	if err := os.WriteFile(tmpFile, data, 0644); err != nil {
+		return err
+	}
+	return os.Rename(tmpFile, m.storePath)
+}
+
+// SyncAll refreshes status and launches any enabled clients that aren't running.
+func (m *Manager) SyncAll() {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	for _, c := range m.clients {
+		m.updateStatus(c)
+		if c.Enabled && c.Status != "bound" && c.Status != "searching" {
+			_ = m.startClient(c)
+			m.updateStatus(c)
+		}
+	}
+}
+
+// ListClients returns all DHCP clients with real-time lease info.
+func (m *Manager) ListClients() []*DHCPClient {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	res := make([]*DHCPClient, 0, len(m.clients))
+	for _, c := range m.clients {
+		m.updateStatus(c)
+		res = append(res, c)
+	}
+	return res
+}
+
+// GetSummary returns summary KPIs.
+func (m *Manager) GetSummary() DHCPSummary {
+	clients := m.ListClients()
+	sum := DHCPSummary{
+		TotalClients: len(clients),
+	}
+	for _, c := range clients {
+		switch c.Status {
+		case "bound":
+			sum.BoundClients++
+		case "searching":
+			sum.Searching++
+		case "disabled":
+			sum.Disabled++
+		}
+	}
+	return sum
+}
+
+// GetClient retrieves a single client by ID.
+func (m *Manager) GetClient(id string) (*DHCPClient, error) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	c, ok := m.clients[id]
+	if !ok {
+		return nil, fmt.Errorf("dhcp client %s not found", id)
+	}
+	m.updateStatus(c)
+	return c, nil
+}
+
+// AddClient creates and optionally starts a DHCP client on an interface.
+func (m *Manager) AddClient(req CreateDHCPClientRequest) (*DHCPClient, error) {
+	iface := strings.TrimSpace(req.Interface)
+	if iface == "" || !validInterfaceRegex.MatchString(iface) {
+		return nil, fmt.Errorf("invalid interface name")
+	}
+
+	// Verify interface exists on system
+	if _, err := net.InterfaceByName(iface); err != nil {
+		return nil, fmt.Errorf("interface %s does not exist on host", iface)
+	}
+
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	id := "dhcp-" + iface
+	if _, exists := m.clients[id]; exists {
+		return nil, fmt.Errorf("dhcp client already exists for interface %s", iface)
+	}
+
+	dist := req.DefaultRouteDistance
+	if dist <= 0 {
+		dist = 1
+	}
+
+	c := &DHCPClient{
+		ID:                   id,
+		Interface:            iface,
+		Enabled:              req.Enabled,
+		AddDefaultRoute:      req.AddDefaultRoute,
+		DefaultRouteDistance: dist,
+		UsePeerDNS:           req.UsePeerDNS,
+		UsePeerNTP:           req.UsePeerNTP,
+		Comment:              req.Comment,
+		Status:               "stopped",
+		DNSServers:           []string{},
+	}
+
+	if c.Enabled {
+		if err := m.startClient(c); err != nil {
+			c.Status = "error"
+		}
+	} else {
+		c.Status = "disabled"
+	}
+
+	m.updateStatus(c)
+	m.clients[id] = c
+	_ = m.Save()
+
+	return c, nil
+}
+
+// UpdateClient modifies client settings and restarts if needed.
+func (m *Manager) UpdateClient(id string, req UpdateDHCPClientRequest) (*DHCPClient, error) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	c, ok := m.clients[id]
+	if !ok {
+		return nil, fmt.Errorf("dhcp client %s not found", id)
+	}
+
+	needsRestart := false
+
+	if req.AddDefaultRoute != nil && *req.AddDefaultRoute != c.AddDefaultRoute {
+		c.AddDefaultRoute = *req.AddDefaultRoute
+		needsRestart = true
+	}
+	if req.DefaultRouteDistance != nil && *req.DefaultRouteDistance != c.DefaultRouteDistance {
+		c.DefaultRouteDistance = *req.DefaultRouteDistance
+		needsRestart = true
+	}
+	if req.UsePeerDNS != nil && *req.UsePeerDNS != c.UsePeerDNS {
+		c.UsePeerDNS = *req.UsePeerDNS
+		needsRestart = true
+	}
+	if req.UsePeerNTP != nil && *req.UsePeerNTP != c.UsePeerNTP {
+		c.UsePeerNTP = *req.UsePeerNTP
+		needsRestart = true
+	}
+	if req.Comment != nil {
+		c.Comment = *req.Comment
+	}
+	if req.Enabled != nil {
+		if *req.Enabled != c.Enabled {
+			c.Enabled = *req.Enabled
+			if c.Enabled {
+				_ = m.startClient(c)
+			} else {
+				_ = m.stopClient(c)
+				c.Status = "disabled"
+			}
+			needsRestart = false
+		}
+	}
+
+	if needsRestart && c.Enabled {
+		_ = m.stopClient(c)
+		_ = m.startClient(c)
+	}
+
+	m.updateStatus(c)
+	_ = m.Save()
+	return c, nil
+}
+
+// ToggleState enables or disables a DHCP client.
+func (m *Manager) ToggleState(id string, enabled bool) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	c, ok := m.clients[id]
+	if !ok {
+		return fmt.Errorf("dhcp client %s not found", id)
+	}
+
+	c.Enabled = enabled
+	if enabled {
+		if err := m.startClient(c); err != nil {
+			c.Status = "error"
+		}
+	} else {
+		_ = m.stopClient(c)
+		c.Status = "disabled"
+	}
+
+	m.updateStatus(c)
+	return m.Save()
+}
+
+// RenewLease forces dhclient to renew its lease.
+func (m *Manager) RenewLease(id string) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	c, ok := m.clients[id]
+	if !ok {
+		return fmt.Errorf("dhcp client %s not found", id)
+	}
+
+	if !c.Enabled {
+		return fmt.Errorf("cannot renew disabled dhcp client")
+	}
+
+	_ = m.stopClient(c)
+	time.Sleep(200 * time.Millisecond)
+	err := m.startClient(c)
+	m.updateStatus(c)
+	return err
+}
+
+// ReleaseLease sends DHCPRELEASE and removes the IP address.
+func (m *Manager) ReleaseLease(id string) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	c, ok := m.clients[id]
+	if !ok {
+		return fmt.Errorf("dhcp client %s not found", id)
+	}
+
+	pidFile := fmt.Sprintf("%s/dhclient.%s.pid", DHCPRunDir, c.Interface)
+	leaseFile := fmt.Sprintf("%s/dhclient.%s.leases", DHCPLeaseDir, c.Interface)
+
+	cmd := exec.Command("dhclient", "-4", "-r", "-pf", pidFile, "-lf", leaseFile, c.Interface)
+	_ = cmd.Run()
+
+	_ = m.stopClient(c)
+	c.Status = "stopped"
+	c.IPAddress = ""
+	c.Gateway = ""
+	c.ExpiresIn = ""
+	return m.Save()
+}
+
+// DeleteClient stops the client and removes it from configuration.
+func (m *Manager) DeleteClient(id string) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	c, ok := m.clients[id]
+	if !ok {
+		return fmt.Errorf("dhcp client %s not found", id)
+	}
+
+	_ = m.stopClient(c)
+
+	// Clean up conf & lease files
+	_ = os.Remove(fmt.Sprintf("%s/dhclient.%s.pid", DHCPRunDir, c.Interface))
+	_ = os.Remove(fmt.Sprintf("%s/dhclient-%s.conf", DHCPConfDir, c.Interface))
+
+	delete(m.clients, id)
+	return m.Save()
+}
+
+func (m *Manager) startClient(c *DHCPClient) error {
+	pidFile := fmt.Sprintf("%s/dhclient.%s.pid", DHCPRunDir, c.Interface)
+	leaseFile := fmt.Sprintf("%s/dhclient.%s.leases", DHCPLeaseDir, c.Interface)
+	confFile := fmt.Sprintf("%s/dhclient-%s.conf", DHCPConfDir, c.Interface)
+
+	// Generate custom dhclient config
+	var sb strings.Builder
+	sb.WriteString("# RouterOS-style dhclient configuration\n")
+	sb.WriteString("send host-name = gethostname();\n")
+	sb.WriteString("request subnet-mask, broadcast-address, time-offset")
+	if c.AddDefaultRoute {
+		sb.WriteString(", routers")
+	}
+	if c.UsePeerDNS {
+		sb.WriteString(", domain-name, domain-name-servers, domain-search, host-name")
+	}
+	if c.UsePeerNTP {
+		sb.WriteString(", ntp-servers")
+	}
+	sb.WriteString(";\n")
+
+	_ = os.WriteFile(confFile, []byte(sb.String()), 0644)
+
+	// If already running, kill first
+	_ = m.stopClient(c)
+
+	// Launch dhclient in background
+	cmd := exec.Command("dhclient", "-4", "-nw", "-pf", pidFile, "-lf", leaseFile, "-cf", confFile, c.Interface)
+	if err := cmd.Run(); err != nil {
+		return fmt.Errorf("failed to start dhclient: %w", err)
+	}
+
+	c.Status = "searching"
+	return nil
+}
+
+func (m *Manager) stopClient(c *DHCPClient) error {
+	pidFile := fmt.Sprintf("%s/dhclient.%s.pid", DHCPRunDir, c.Interface)
+
+	// Check PID file
+	if pidBytes, err := os.ReadFile(pidFile); err == nil {
+		if pid, err := strconv.Atoi(strings.TrimSpace(string(pidBytes))); err == nil && pid > 0 {
+			_ = syscall.Kill(pid, syscall.SIGTERM)
+			time.Sleep(100 * time.Millisecond)
+			if err := syscall.Kill(pid, 0); err == nil {
+				_ = syscall.Kill(pid, syscall.SIGKILL)
+			}
+		}
+	}
+
+	_ = os.Remove(pidFile)
+	c.PID = 0
+	return nil
+}
+
+func (m *Manager) updateStatus(c *DHCPClient) {
+	pidFile := fmt.Sprintf("%s/dhclient.%s.pid", DHCPRunDir, c.Interface)
+	leaseFile := fmt.Sprintf("%s/dhclient.%s.leases", DHCPLeaseDir, c.Interface)
+
+	// Check if process is alive
+	isRunning := false
+	if pidBytes, err := os.ReadFile(pidFile); err == nil {
+		if pid, err := strconv.Atoi(strings.TrimSpace(string(pidBytes))); err == nil && pid > 0 {
+			if err := syscall.Kill(pid, 0); err == nil {
+				isRunning = true
+				c.PID = pid
+			}
+		}
+	}
+
+	if !isRunning {
+		c.PID = 0
+		if !c.Enabled {
+			c.Status = "disabled"
+		} else {
+			c.Status = "stopped"
+		}
+	} else {
+		c.Status = "searching"
+	}
+
+	// Read interface IPv4 addresses via netlink
+	if link, err := netlink.LinkByName(c.Interface); err == nil {
+		if addrs, err := netlink.AddrList(link, netlink.FAMILY_V4); err == nil && len(addrs) > 0 {
+			for _, a := range addrs {
+				ip := a.IPNet.IP.String()
+				if !strings.HasPrefix(ip, "127.") && !strings.HasPrefix(ip, "169.254.") {
+					c.IPAddress = a.IPNet.String()
+					if isRunning {
+						c.Status = "bound"
+					}
+					break
+				}
+			}
+		}
+	}
+
+	// Parse lease file for rich metadata (gateway, dns, lease times)
+	m.parseLeaseFile(leaseFile, c)
+}
+
+func (m *Manager) parseLeaseFile(leaseFile string, c *DHCPClient) {
+	data, err := os.ReadFile(leaseFile)
+	if err != nil {
+		return
+	}
+
+	// Find the last lease block in the file
+	matches := leaseBlockRegex.FindAllStringSubmatch(string(data), -1)
+	if len(matches) == 0 {
+		return
+	}
+	lastLease := matches[len(matches)-1][1]
+
+	if m := fixedAddrRegex.FindStringSubmatch(lastLease); len(m) > 1 {
+		ip := m[1]
+		mask := "24"
+		if sm := subnetMaskRegex.FindStringSubmatch(lastLease); len(sm) > 1 {
+			mask = subnetToCIDR(sm[1])
+		}
+		if c.IPAddress == "" {
+			c.IPAddress = fmt.Sprintf("%s/%s", ip, mask)
+		}
+	}
+
+	if m := routersRegex.FindStringSubmatch(lastLease); len(m) > 1 {
+		c.Gateway = m[1]
+	}
+
+	if m := dhcpServerRegex.FindStringSubmatch(lastLease); len(m) > 1 {
+		c.DHCPServer = m[1]
+	}
+
+	if m := dnsServersRegex.FindStringSubmatch(lastLease); len(m) > 1 {
+		raw := strings.Split(m[1], ",")
+		dns := make([]string, 0, len(raw))
+		for _, s := range raw {
+			trimmed := strings.TrimSpace(s)
+			if trimmed != "" {
+				dns = append(dns, trimmed)
+			}
+		}
+		c.DNSServers = dns
+	}
+
+	if m := domainNameRegex.FindStringSubmatch(lastLease); len(m) > 1 {
+		c.Domain = m[1]
+	}
+
+	if m := leaseTimeRegex.FindStringSubmatch(lastLease); len(m) > 1 {
+		c.LeaseTime, _ = strconv.Atoi(m[1])
+	}
+
+	if m := expireTimeRegex.FindStringSubmatch(lastLease); len(m) > 1 {
+		// e.g. "2026/10/26 18:30:00"
+		if t, err := time.Parse("2006/01/02 15:04:05", m[1]); err == nil {
+			c.ExpiresAt = t
+			rem := time.Until(t)
+			if rem > 0 {
+				hours := int(rem.Hours())
+				mins := int(rem.Minutes()) % 60
+				if hours > 24 {
+					c.ExpiresIn = fmt.Sprintf("%dd %dh", hours/24, hours%24)
+				} else if hours > 0 {
+					c.ExpiresIn = fmt.Sprintf("%dh %dm", hours, mins)
+				} else {
+					c.ExpiresIn = fmt.Sprintf("%dm %ds", mins, int(rem.Seconds())%60)
+				}
+			} else {
+				c.ExpiresIn = "Expired"
+			}
+		}
+	}
+}
+
+func subnetToCIDR(mask string) string {
+	parts := strings.Split(mask, ".")
+	if len(parts) != 4 {
+		return "24"
+	}
+	var count int
+	for _, p := range parts {
+		n, _ := strconv.Atoi(p)
+		for n > 0 {
+			count += n & 1
+			n >>= 1
+		}
+	}
+	return strconv.Itoa(count)
+}

+ 55 - 0
internal/dhcp/types.go

@@ -0,0 +1,55 @@
+package dhcp
+
+import "time"
+
+// DHCPClient represents a MikroTik RouterOS-style /ip dhcp-client instance.
+type DHCPClient struct {
+	ID                   string    `json:"id"`
+	Interface            string    `json:"interface"`
+	Enabled              bool      `json:"enabled"`
+	AddDefaultRoute      bool      `json:"add_default_route"`
+	DefaultRouteDistance int       `json:"default_route_distance"`
+	UsePeerDNS           bool      `json:"use_peer_dns"`
+	UsePeerNTP           bool      `json:"use_peer_ntp"`
+	Comment              string    `json:"comment"`
+	Status               string    `json:"status"` // "bound", "searching", "stopped", "disabled", "error"
+	IPAddress            string    `json:"ip_address"`
+	Gateway              string    `json:"gateway"`
+	DHCPServer           string    `json:"dhcp_server"`
+	DNSServers           []string  `json:"dns_servers"`
+	Domain               string    `json:"domain"`
+	LeaseTime            int       `json:"lease_time"`
+	ExpiresIn            string    `json:"expires_in"`
+	ObtainedAt           time.Time `json:"obtained_at"`
+	ExpiresAt            time.Time `json:"expires_at"`
+	PID                  int       `json:"pid"`
+}
+
+// CreateDHCPClientRequest specifies parameters when creating a new DHCP client on an interface.
+type CreateDHCPClientRequest struct {
+	Interface            string `json:"interface"`
+	Enabled              bool   `json:"enabled"`
+	AddDefaultRoute      bool   `json:"add_default_route"`
+	DefaultRouteDistance int    `json:"default_route_distance"`
+	UsePeerDNS           bool   `json:"use_peer_dns"`
+	UsePeerNTP           bool   `json:"use_peer_ntp"`
+	Comment              string `json:"comment"`
+}
+
+// UpdateDHCPClientRequest allows modifying an existing DHCP client's settings.
+type UpdateDHCPClientRequest struct {
+	Enabled              *bool   `json:"enabled,omitempty"`
+	AddDefaultRoute      *bool   `json:"add_default_route,omitempty"`
+	DefaultRouteDistance *int    `json:"default_route_distance,omitempty"`
+	UsePeerDNS           *bool   `json:"use_peer_dns,omitempty"`
+	UsePeerNTP           *bool   `json:"use_peer_ntp,omitempty"`
+	Comment              *string `json:"comment,omitempty"`
+}
+
+// DHCPSummary provides quick telemetry for badges and KPI cards.
+type DHCPSummary struct {
+	TotalClients int `json:"total_clients"`
+	BoundClients int `json:"bound_clients"`
+	Searching    int `json:"searching"`
+	Disabled     int `json:"disabled"`
+}

+ 442 - 27
internal/web/static/app.js

@@ -42,7 +42,9 @@ let state = {
   routingTables: [],
   routingFilters: [],
   filterChainSelected: 'all',
-  activeToolsSubTab: 'ping'
+  activeToolsSubTab: 'ping',
+  dhcpClients: [],
+  dhcpSummary: null
 };
 
 const API_BASE = '/api';
@@ -99,6 +101,7 @@ function navigate(tabName) {
   const titles = {
     dashboard: { title: 'Dashboard Overview', sub: 'Live router performance and network telemetry' },
     interfaces: { title: 'Network Interfaces', sub: 'Hierarchical view of physical interfaces and nested 802.1Q VLANs' },
+    'dhcp-client': { title: 'DHCP Client (/ip dhcp-client)', sub: 'RouterOS-style dynamic IPv4 lease configuration, default gateway & peer DNS on any interface' },
     bridge: { title: 'Bridge Interfaces', sub: 'MikroTik-style IEEE 802.1D / 802.1Q VLAN Filtering Bridge with RSTP & FDB' },
     vlans: { title: 'VLAN Virtual Networks', sub: 'IEEE 802.1Q sub-interface segmentation and routing' },
     firewall: { title: 'MikroTik Firewall', sub: 'Filter rules, NAT, Mangle, Address Lists & Active Connections tracking' },
@@ -155,6 +158,7 @@ function navigate(tabName) {
   if (tabName === 'l2tp') loadL2TP();
   if (tabName === 'pptp') loadPPTP();
   if (tabName === 'tools') loadToolsView();
+  if (tabName === 'dhcp-client') loadDHCPClients();
 }
 
 // Fetch all core system, network and VPN telemetry
@@ -163,16 +167,27 @@ async function fetchData(isManual = false) {
   if (isManual && spinner) spinner.classList.add('animate-spin');
 
   try {
-    const [statusRes, ifacesRes, vlansRes, vpnRes, fwRes, dynRes, brSummaryRes] = await Promise.all([
+    const [statusRes, ifacesRes, vlansRes, vpnRes, fwRes, dynRes, brSummaryRes, dhcpSumRes, dhcpClientsRes] = await Promise.all([
       fetch(`${API_BASE}/system/status`),
       fetch(`${API_BASE}/interfaces`),
       fetch(`${API_BASE}/vlans`),
       fetch(`${API_BASE}/vpn/status`),
       fetch(`${API_BASE}/firewall/summary`),
       fetch(`${API_BASE}/routing/dynamic/summary`),
-      fetch(`${API_BASE}/bridge/summary`)
+      fetch(`${API_BASE}/bridge/summary`),
+      fetch(`${API_BASE}/dhcp/clients/summary`),
+      fetch(`${API_BASE}/dhcp/clients`)
     ]);
 
+    if (dhcpSumRes && dhcpSumRes.ok) {
+      state.dhcpSummary = await dhcpSumRes.json();
+      const bDhcp = document.getElementById('badge-dhcp-client-count');
+      if (bDhcp) bDhcp.innerText = state.dhcpSummary?.total_clients || 0;
+    }
+    if (dhcpClientsRes && dhcpClientsRes.ok) {
+      state.dhcpClients = await dhcpClientsRes.json();
+    }
+
     if (brSummaryRes.ok) {
       state.bridgeSummary = await brSummaryRes.json();
       const bBridge = document.getElementById('badge-bridge-count');
@@ -220,6 +235,7 @@ async function fetchData(isManual = false) {
     if (state.activeTab === 'ipsec') loadIPSec(false);
     if (state.activeTab === 'l2tp') loadL2TP(false);
     if (state.activeTab === 'pptp') loadPPTP(false);
+    if (state.activeTab === 'dhcp-client') loadDHCPClients(false);
 
   } catch (err) {
     console.error('Failed to fetch router state:', err);
@@ -538,6 +554,12 @@ function renderInterfacesList() {
           ? `<div class="text-white font-medium">${parent.ipv4[0]}</div>`
           : '<span class="text-slate-600 font-sans">Unassigned</span>'
         }
+        ${(() => {
+          const c = (state.dhcpClients || []).find(dc => dc.interface === parent.name);
+          if (!c) return '';
+          const statusCls = c.status === 'bound' ? 'bg-amber-500/20 text-amber-300 border-amber-500/30' : (c.status === 'searching' ? 'bg-sky-500/20 text-sky-300 border-sky-500/30' : 'bg-slate-800 text-slate-400 border-slate-700');
+          return `<div class="mt-1"><span class="px-1.5 py-0.5 rounded text-[10px] font-mono border ${statusCls} cursor-pointer hover:underline" onclick="navigate('dhcp-client')" title="DHCP Client: ${c.status.toUpperCase()} (${c.ip_address || 'requesting...'})">⚡ DHCP: ${c.status.toUpperCase()}</span></div>`;
+        })()}
       </td>
       <td class="py-3 px-4 text-slate-400 text-[11px]">
         <div>${parent.mac || 'N/A'}</div>
@@ -559,6 +581,13 @@ function renderInterfacesList() {
         </button>
       </td>
       <td class="py-3 px-4 text-right space-x-2 font-sans">
+        ${(() => {
+          const c = (state.dhcpClients || []).find(dc => dc.interface === parent.name);
+          if (c) {
+            return `<button onclick="navigate('dhcp-client')" title="Manage DHCP Client (${c.status})" class="text-xs bg-amber-500/10 hover:bg-amber-500/20 text-amber-400 font-medium px-2.5 py-1 rounded-lg border border-amber-500/30 transition">⚡ DHCP</button>`;
+          }
+          return `<button onclick="openDHCPClientModalForIface('${parent.name}')" title="Add DHCP Client on ${parent.name}" class="text-xs bg-slate-800/80 hover:bg-slate-700 text-slate-300 hover:text-amber-400 font-medium px-2 py-1 rounded-lg border border-slate-700 transition">+ DHCP</button>`;
+        })()}
         <button onclick="openVlanModal('${parent.name}')"
                 class="text-xs bg-cyan-950/60 hover:bg-cyan-900 text-cyan-300 font-semibold px-2.5 py-1 rounded-lg border border-cyan-800/70 shadow-sm transition">
           + Add VLAN
@@ -634,6 +663,12 @@ function renderInterfacesList() {
                         </td>
                         <td class="py-2.5 px-4 text-emerald-400 font-medium">
                           ${v.ipv4 && v.ipv4.length > 0 ? v.ipv4[0] : '<span class="text-slate-600 font-sans">Unassigned</span>'}
+                          ${(() => {
+                            const c = (state.dhcpClients || []).find(dc => dc.interface === v.name);
+                            if (!c) return '';
+                            const statusCls = c.status === 'bound' ? 'bg-amber-500/20 text-amber-300 border-amber-500/30' : 'bg-slate-800 text-slate-400 border-slate-700';
+                            return `<div class="mt-0.5"><span class="px-1.5 py-0.2 rounded text-[9px] font-mono border ${statusCls} cursor-pointer hover:underline" onclick="navigate('dhcp-client')" title="DHCP Client: ${c.status.toUpperCase()}">⚡ DHCP: ${c.status.toUpperCase()}</span></div>`;
+                          })()}
                         </td>
                         <td class="py-2.5 px-4 font-sans text-slate-300">
                           <span class="px-1.5 py-0.5 rounded text-[10px] bg-slate-800 text-slate-300 mr-1.5">${v.role || 'VLAN'}</span>
@@ -654,6 +689,13 @@ function renderInterfacesList() {
                           </button>
                         </td>
                         <td class="py-2.5 px-4 text-right space-x-1.5 font-sans">
+                          ${(() => {
+                            const c = (state.dhcpClients || []).find(dc => dc.interface === v.name);
+                            if (c) {
+                              return `<button onclick="navigate('dhcp-client')" class="text-xs text-amber-400 hover:text-amber-300 px-2 py-0.5 rounded bg-amber-950/40 border border-amber-800/50">⚡ DHCP</button>`;
+                            }
+                            return `<button onclick="openDHCPClientModalForIface('${v.name}')" class="text-xs text-slate-400 hover:text-amber-400 px-1.5 py-0.5 rounded border border-slate-700">+ DHCP</button>`;
+                          })()}
                           <button onclick="openIfaceModal('${v.name}')" class="text-xs bg-slate-800 hover:bg-slate-700 text-slate-200 px-2 py-0.5 rounded border border-slate-700">Edit</button>
                           <button onclick="confirmDeleteVlan('${v.name}')" class="text-xs text-rose-400 hover:text-rose-300 hover:bg-rose-950/40 px-2 py-0.5 rounded transition">Delete</button>
                         </td>
@@ -5143,31 +5185,40 @@ function switchDynSubTab(subTab) {
 
 async function loadDynamicRouting(showToastMsg = false) {
   try {
-    const [sumRes, bgpInstRes, bgpPeersRes, bgpNetsRes, bgpRoutesRes, ospfInstRes, ospfIfacesRes, ospfAreasRes, ospfNbrsRes, ospfRoutesRes, filtersRes] = await Promise.all([
-      fetch(`${API_BASE}/routing/dynamic/summary`),
-      fetch(`${API_BASE}/routing/bgp/instance`),
-      fetch(`${API_BASE}/routing/bgp/peers`),
-      fetch(`${API_BASE}/routing/bgp/networks`),
-      fetch(`${API_BASE}/routing/bgp/routes`),
-      fetch(`${API_BASE}/routing/ospf/instance`),
-      fetch(`${API_BASE}/routing/ospf/interfaces`),
-      fetch(`${API_BASE}/routing/ospf/areas`),
-      fetch(`${API_BASE}/routing/ospf/neighbors`),
-      fetch(`${API_BASE}/routing/ospf/routes`),
-      fetch(`${API_BASE}/routing/filters`)
+    const fetchSafeJson = async (url) => {
+      try {
+        const r = await fetch(url);
+        return r.ok ? await r.json() : null;
+      } catch (e) {
+        return null;
+      }
+    };
+
+    const [sum, bgpInst, bgpPeers, bgpNets, bgpRoutes, ospfInst, ospfIfaces, ospfAreas, ospfNbrs, ospfRoutes, filters] = await Promise.all([
+      fetchSafeJson(`${API_BASE}/routing/dynamic/summary`),
+      fetchSafeJson(`${API_BASE}/routing/bgp/instance`),
+      fetchSafeJson(`${API_BASE}/routing/bgp/peers`),
+      fetchSafeJson(`${API_BASE}/routing/bgp/networks`),
+      fetchSafeJson(`${API_BASE}/routing/bgp/routes`),
+      fetchSafeJson(`${API_BASE}/routing/ospf/instance`),
+      fetchSafeJson(`${API_BASE}/routing/ospf/interfaces`),
+      fetchSafeJson(`${API_BASE}/routing/ospf/areas`),
+      fetchSafeJson(`${API_BASE}/routing/ospf/neighbors`),
+      fetchSafeJson(`${API_BASE}/routing/ospf/routes`),
+      fetchSafeJson(`${API_BASE}/routing/filters`)
     ]);
 
-    if (sumRes.ok) state.dynSummary = await sumRes.json();
-    if (bgpInstRes.ok) state.bgpInstance = await bgpInstRes.json();
-    if (bgpPeersRes.ok) state.bgpPeers = await bgpPeersRes.json();
-    if (bgpNetsRes.ok) state.bgpNets = await bgpNetsRes.json();
-    if (bgpRoutesRes.ok) state.bgpRoutes = await bgpRoutesRes.json();
-    if (ospfInstRes.ok) state.ospfInstance = await ospfInstRes.json();
-    if (ospfIfacesRes.ok) state.ospfInterfaces = await ospfIfacesRes.json();
-    if (ospfAreasRes.ok) state.ospfAreas = await ospfAreasRes.json();
-    if (ospfNbrsRes.ok) state.ospfNeighbors = await ospfNbrsRes.json();
-    if (ospfRoutesRes.ok) state.ospfRoutes = await ospfRoutesRes.json();
-    if (filtersRes && filtersRes.ok) state.routingFilters = await filtersRes.json();
+    if (sum) state.dynSummary = sum;
+    if (bgpInst) state.bgpInstance = bgpInst;
+    if (bgpPeers) state.bgpPeers = bgpPeers;
+    if (bgpNets) state.bgpNets = bgpNets;
+    if (bgpRoutes) state.bgpRoutes = bgpRoutes;
+    if (ospfInst) state.ospfInstance = ospfInst;
+    if (ospfIfaces) state.ospfInterfaces = ospfIfaces;
+    if (ospfAreas) state.ospfAreas = ospfAreas;
+    if (ospfNbrs) state.ospfNeighbors = ospfNbrs;
+    if (ospfRoutes) state.ospfRoutes = ospfRoutes;
+    if (filters) state.routingFilters = filters;
 
     try { renderBgpStatusPill(); } catch(e) { console.error('renderBgpStatusPill error:', e); }
     try { renderBgpPeers(); } catch(e) { console.error('renderBgpPeers error:', e); }
@@ -5184,7 +5235,7 @@ async function loadDynamicRouting(showToastMsg = false) {
     if (showToastMsg) showToast('Refreshed', 'Dynamic routing telemetry updated', 'info');
   } catch (err) {
     console.error('Failed to load dynamic routing:', err);
-    if (showToastMsg) showToast('Error', 'Failed to load dynamic routing telemetry', 'error');
+    if (showToastMsg) showToast('Notice', 'Unable to refresh dynamic routing telemetry', 'warning');
   }
 }
 
@@ -7739,4 +7790,368 @@ async function executePortProbe(e) {
   }
 }
 
+// ==========================================
+// DHCP CLIENT (/ip dhcp-client) CONTROLLER
+// ==========================================
+
+async function loadDHCPClients(showToastMsg = false) {
+  try {
+    const [clientsRes, sumRes] = await Promise.all([
+      fetch(`${API_BASE}/dhcp/clients`),
+      fetch(`${API_BASE}/dhcp/clients/summary`)
+    ]);
+
+    if (clientsRes.ok) {
+      state.dhcpClients = await clientsRes.json();
+    }
+    if (sumRes.ok) {
+      state.dhcpSummary = await sumRes.json();
+    }
+
+    renderDHCPClients();
+    updateDHCPSummaryCards();
+
+    const bDhcp = document.getElementById('badge-dhcp-client-count');
+    if (bDhcp && state.dhcpSummary) {
+      bDhcp.innerText = state.dhcpSummary.total_clients || 0;
+    }
+
+    if (showToastMsg) {
+      showToast('Refreshed', 'DHCP Client telemetry updated', 'info');
+    }
+  } catch (err) {
+    console.error('Failed to load DHCP clients:', err);
+    if (showToastMsg) {
+      showToast('Notice', 'Unable to fetch DHCP client status', 'warning');
+    }
+  }
+}
+
+function updateDHCPSummaryCards() {
+  const sum = state.dhcpSummary || {
+    total_clients: (state.dhcpClients || []).length,
+    bound_clients: (state.dhcpClients || []).filter(c => c.status === 'bound').length,
+    searching_clients: (state.dhcpClients || []).filter(c => c.status === 'searching').length,
+    disabled_clients: (state.dhcpClients || []).filter(c => !c.enabled).length
+  };
+
+  const totalEl = document.getElementById('dhcp-stat-total');
+  const boundEl = document.getElementById('dhcp-stat-bound');
+  const searchEl = document.getElementById('dhcp-stat-searching');
+  const disEl = document.getElementById('dhcp-stat-disabled');
+
+  if (totalEl) totalEl.innerText = sum.total_clients ?? 0;
+  if (boundEl) boundEl.innerText = sum.bound_clients ?? 0;
+  if (searchEl) searchEl.innerText = sum.searching_clients ?? 0;
+  if (disEl) disEl.innerText = sum.disabled_clients ?? 0;
+}
+
+function renderDHCPClients() {
+  const tbody = document.getElementById('dhcp-clients-tbody');
+  const emptyState = document.getElementById('dhcp-clients-empty');
+  if (!tbody) return;
+
+  const clients = Array.isArray(state.dhcpClients) ? state.dhcpClients : [];
+
+  if (clients.length === 0) {
+    tbody.innerHTML = '';
+    if (emptyState) emptyState.classList.remove('hidden');
+    return;
+  }
+  if (emptyState) emptyState.classList.add('hidden');
+
+  tbody.innerHTML = clients.map(c => {
+    let statusBadge = '';
+    if (!c.enabled) {
+      statusBadge = '<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-slate-800 text-slate-400 border border-slate-700">DISABLED</span>';
+    } else if (c.status === 'bound') {
+      statusBadge = '<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-950 text-emerald-300 border border-emerald-800/60 inline-flex items-center gap-1.5"><span class="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></span>BOUND</span>';
+    } else if (c.status === 'searching') {
+      statusBadge = '<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-amber-950 text-amber-300 border border-amber-800/60 inline-flex items-center gap-1.5"><span class="w-1.5 h-1.5 rounded-full bg-amber-400 animate-ping"></span>SEARCHING...</span>';
+    } else if (c.status === 'stopped') {
+      statusBadge = '<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-slate-800 text-slate-400 border border-slate-700">STOPPED</span>';
+    } else {
+      statusBadge = `<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-rose-950 text-rose-300 border border-rose-800/60">${escapeHtml((c.status || 'UNKNOWN').toUpperCase())}</span>`;
+    }
+
+    const ipDisplay = c.ip_address ? `<span class="font-bold text-white">${escapeHtml(c.ip_address)}</span>` : '<span class="text-slate-500 italic">No lease</span>';
+    const gwDisplay = c.gateway ? `<span class="text-slate-300">${escapeHtml(c.gateway)}</span>` : '<span class="text-slate-600">--</span>';
+    const srvDisplay = c.dhcp_server ? `<span class="text-slate-300">${escapeHtml(c.dhcp_server)}</span>` : '<span class="text-slate-600">--</span>';
+    const dnsDisplay = (Array.isArray(c.dns_servers) && c.dns_servers.length > 0) ? `<span class="text-cyan-400">${c.dns_servers.map(d => escapeHtml(d)).join(', ')}</span>` : '<span class="text-slate-600">--</span>';
+    const expDisplay = c.expires_in ? `<span class="text-purple-300">${escapeHtml(c.expires_in)}</span>` : '<span class="text-slate-600">--</span>';
+
+    // Option flags
+    const flags = [];
+    if (c.add_default_route) flags.push('<span class="px-1.5 py-0.5 rounded text-[10px] bg-slate-800 text-slate-300 border border-slate-700" title="Add Default Route">+GW</span>');
+    if (c.use_peer_dns) flags.push('<span class="px-1.5 py-0.5 rounded text-[10px] bg-cyan-950/60 text-cyan-300 border border-cyan-800/50" title="Use Peer DNS">+DNS</span>');
+    if (c.use_peer_ntp) flags.push('<span class="px-1.5 py-0.5 rounded text-[10px] bg-indigo-950/60 text-indigo-300 border border-indigo-800/50" title="Use Peer NTP">+NTP</span>');
+    flags.push(`<span class="px-1.5 py-0.5 rounded text-[10px] bg-slate-800/60 text-slate-400" title="Default Route Distance">dist:${c.default_route_distance || 1}</span>`);
+
+    return `
+      <tr class="hover:bg-slate-800/40 border-b border-slate-800/60 text-xs transition">
+        <td class="py-3 px-4">
+          <div class="flex items-center gap-2">
+            <span class="font-bold text-white text-sm">${escapeHtml(c.interface)}</span>
+            ${c.comment ? `<span class="text-[11px] text-slate-400 font-sans italic">(${escapeHtml(c.comment)})</span>` : ''}
+          </div>
+        </td>
+        <td class="py-3 px-4">${statusBadge}</td>
+        <td class="py-3 px-4 font-mono">${ipDisplay}</td>
+        <td class="py-3 px-4 font-mono">${gwDisplay}</td>
+        <td class="py-3 px-4 font-mono">${srvDisplay}</td>
+        <td class="py-3 px-4 font-mono text-[11px]">${dnsDisplay}</td>
+        <td class="py-3 px-4 font-mono text-[11px]">${expDisplay}</td>
+        <td class="py-3 px-4">
+          <div class="flex flex-wrap gap-1 font-mono">${flags.join('')}</div>
+        </td>
+        <td class="py-3 px-4 text-right space-x-1.5 font-sans whitespace-nowrap">
+          ${c.enabled ? `
+            <button onclick="renewDHCPClient('${c.id}')" class="px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 text-amber-400 text-xs font-medium border border-slate-700 transition" title="Renew Lease">Renew</button>
+            <button onclick="releaseDHCPClient('${c.id}')" class="px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-medium border border-slate-700 transition" title="Release Lease">Release</button>
+            <button onclick="toggleDHCPClientState('${c.id}', false)" class="px-2 py-1 rounded bg-amber-500/10 hover:bg-amber-500/20 text-amber-400 text-xs font-medium border border-amber-500/30 transition" title="Disable DHCP Client">Disable</button>
+          ` : `
+            <button onclick="toggleDHCPClientState('${c.id}', true)" class="px-2.5 py-1 rounded bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 text-xs font-semibold border border-emerald-500/30 transition" title="Enable DHCP Client">Enable</button>
+          `}
+          <button onclick="openDHCPClientModal('${c.id}')" class="px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 text-white text-xs font-medium border border-slate-700 transition" title="Edit Settings">Edit</button>
+          <button onclick="deleteDHCPClient('${c.id}', '${c.interface}')" class="px-2 py-1 rounded bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 text-xs font-medium border border-rose-500/30 transition" title="Remove Client">✕</button>
+        </td>
+      </tr>
+    `;
+  }).join('');
+}
+
+function openDHCPClientModal(clientId = null) {
+  const modal = document.getElementById('modal-dhcp-client');
+  if (!modal) return;
+
+  const title = document.getElementById('modal-dhcp-title');
+  const idInput = document.getElementById('modal-dhcp-id');
+  const ifaceSelect = document.getElementById('modal-dhcp-iface');
+  const distInput = document.getElementById('modal-dhcp-distance');
+  const commentInput = document.getElementById('modal-dhcp-comment');
+  const defRouteCheck = document.getElementById('modal-dhcp-def-route');
+  const peerDnsCheck = document.getElementById('modal-dhcp-peer-dns');
+  const peerNtpCheck = document.getElementById('modal-dhcp-peer-ntp');
+  const enabledCheck = document.getElementById('modal-dhcp-enabled');
+
+  // Populate interfaces
+  if (ifaceSelect) {
+    let opts = '<option value="">Select interface...</option>';
+    const allIfaces = state.interfaces || [];
+    const allVlans = state.vlans || [];
+    const usedIfaces = new Set((state.dhcpClients || []).map(c => c.interface));
+
+    // Group 1: Physical & Bridge Interfaces
+    opts += '<optgroup label="Physical & Bridge Interfaces">';
+    allIfaces.forEach(i => {
+      const isConfigured = usedIfaces.has(i.name) ? ' (Configured)' : '';
+      opts += `<option value="${escapeHtml(i.name)}">${escapeHtml(i.name)} [${escapeHtml(i.role || i.type)}]${isConfigured}</option>`;
+    });
+    opts += '</optgroup>';
+
+    // Group 2: VLAN Interfaces
+    if (allVlans.length > 0) {
+      opts += '<optgroup label="802.1Q VLAN Sub-interfaces">';
+      allVlans.forEach(v => {
+        const isConfigured = usedIfaces.has(v.name) ? ' (Configured)' : '';
+        opts += `<option value="${escapeHtml(v.name)}">${escapeHtml(v.name)} (VLAN ${v.vlan_id})${isConfigured}</option>`;
+      });
+      opts += '</optgroup>';
+    }
+
+    ifaceSelect.innerHTML = opts;
+  }
+
+  if (clientId) {
+    const client = (state.dhcpClients || []).find(c => c.id === clientId);
+    if (client) {
+      if (title) title.innerText = `Edit DHCP Client: ${client.interface}`;
+      if (idInput) idInput.value = client.id;
+      if (ifaceSelect) {
+        ifaceSelect.value = client.interface;
+        ifaceSelect.disabled = true; // interface cannot be changed on edit
+      }
+      if (distInput) distInput.value = client.default_route_distance || 1;
+      if (commentInput) commentInput.value = client.comment || '';
+      if (defRouteCheck) defRouteCheck.checked = client.add_default_route !== false;
+      if (peerDnsCheck) peerDnsCheck.checked = client.use_peer_dns !== false;
+      if (peerNtpCheck) peerNtpCheck.checked = client.use_peer_ntp !== false;
+      if (enabledCheck) enabledCheck.checked = client.enabled !== false;
+    }
+  } else {
+    if (title) title.innerText = 'Add DHCP Client';
+    if (idInput) idInput.value = '';
+    if (ifaceSelect) {
+      ifaceSelect.value = '';
+      ifaceSelect.disabled = false;
+    }
+    if (distInput) distInput.value = 1;
+    if (commentInput) commentInput.value = '';
+    if (defRouteCheck) defRouteCheck.checked = true;
+    if (peerDnsCheck) peerDnsCheck.checked = true;
+    if (peerNtpCheck) peerNtpCheck.checked = true;
+    if (enabledCheck) enabledCheck.checked = true;
+  }
+
+  modal.classList.remove('hidden');
+}
+
+function openDHCPClientModalForIface(ifaceName) {
+  openDHCPClientModal();
+  const ifaceSelect = document.getElementById('modal-dhcp-iface');
+  if (ifaceSelect && ifaceName) {
+    ifaceSelect.value = ifaceName;
+  }
+}
+
+function closeDHCPClientModal() {
+  const modal = document.getElementById('modal-dhcp-client');
+  if (modal) modal.classList.add('hidden');
+}
+
+async function submitSaveDHCPClient(e) {
+  if (e) e.preventDefault();
+
+  const id = document.getElementById('modal-dhcp-id')?.value;
+  const iface = document.getElementById('modal-dhcp-iface')?.value?.trim();
+  const distance = parseInt(document.getElementById('modal-dhcp-distance')?.value || '1', 10);
+  const comment = document.getElementById('modal-dhcp-comment')?.value?.trim() || '';
+  const defRoute = document.getElementById('modal-dhcp-def-route')?.checked ?? true;
+  const peerDns = document.getElementById('modal-dhcp-peer-dns')?.checked ?? true;
+  const peerNtp = document.getElementById('modal-dhcp-peer-ntp')?.checked ?? true;
+  const enabled = document.getElementById('modal-dhcp-enabled')?.checked ?? true;
+
+  if (!id && !iface) {
+    showToast('Input Required', 'Please select an interface for the DHCP client', 'warning');
+    return;
+  }
+
+  try {
+    let res;
+    if (id) {
+      // PUT update
+      res = await fetch(`${API_BASE}/dhcp/clients/${id}`, {
+        method: 'PUT',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          enabled,
+          add_default_route: defRoute,
+          default_route_distance: distance,
+          use_peer_dns: peerDns,
+          use_peer_ntp: peerNtp,
+          comment
+        })
+      });
+    } else {
+      // POST create
+      res = await fetch(`${API_BASE}/dhcp/clients`, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          interface: iface,
+          enabled,
+          add_default_route: defRoute,
+          default_route_distance: distance,
+          use_peer_dns: peerDns,
+          use_peer_ntp: peerNtp,
+          comment
+        })
+      });
+    }
+
+    const data = await res.json();
+    if (!res.ok) {
+      showToast('Error', data.error || 'Failed to save DHCP client', 'error');
+      return;
+    }
+
+    closeDHCPClientModal();
+    showToast('Success', id ? 'DHCP Client updated successfully' : `DHCP Client activated on ${iface}`, 'success');
+    await loadDHCPClients();
+    fetchData(); // refresh interfaces list with new lease/status
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function toggleDHCPClientState(id, enabled) {
+  try {
+    const res = await fetch(`${API_BASE}/dhcp/clients/${id}/state`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ enabled })
+    });
+    const data = await res.json();
+    if (!res.ok) {
+      showToast('Error', data.error || 'Failed to toggle client state', 'error');
+      return;
+    }
+    showToast('Updated', `DHCP client ${enabled ? 'enabled' : 'disabled'}`, 'info');
+    await loadDHCPClients();
+    fetchData();
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function renewDHCPClient(id) {
+  try {
+    showToast('Renewing...', 'Requesting DHCP lease renewal from upstream server', 'info');
+    const res = await fetch(`${API_BASE}/dhcp/clients/${id}/renew`, {
+      method: 'POST'
+    });
+    const data = await res.json();
+    if (!res.ok) {
+      showToast('Error', data.error || 'Failed to renew lease', 'error');
+      return;
+    }
+    showToast('Lease Renewed', 'DHCP renewal signal sent successfully', 'success');
+    setTimeout(() => {
+      loadDHCPClients();
+      fetchData();
+    }, 1500);
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function releaseDHCPClient(id) {
+  try {
+    const res = await fetch(`${API_BASE}/dhcp/clients/${id}/release`, {
+      method: 'POST'
+    });
+    const data = await res.json();
+    if (!res.ok) {
+      showToast('Error', data.error || 'Failed to release lease', 'error');
+      return;
+    }
+    showToast('Lease Released', 'DHCP lease released from upstream server', 'info');
+    await loadDHCPClients();
+    fetchData();
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function deleteDHCPClient(id, ifaceName) {
+  if (!confirm(`Are you sure you want to remove the DHCP Client on ${ifaceName}? The interface will release its dynamic IP.`)) {
+    return;
+  }
+  try {
+    const res = await fetch(`${API_BASE}/dhcp/clients/${id}`, {
+      method: 'DELETE'
+    });
+    const data = await res.json();
+    if (!res.ok) {
+      showToast('Error', data.error || 'Failed to delete DHCP client', 'error');
+      return;
+    }
+    showToast('Deleted', `DHCP client removed from ${ifaceName}`, 'info');
+    await loadDHCPClients();
+    fetchData();
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
 

+ 153 - 1
internal/web/static/index.html

@@ -102,6 +102,17 @@
           <span id="badge-ifaces-count" class="text-[10px] bg-slate-800 text-slate-300 px-1.5 py-0.2 rounded-full font-mono">0</span>
         </a>
 
+        <a href="#dhcp-client" onclick="navigate('dhcp-client')" id="nav-dhcp-client"
+           class="nav-link flex items-center justify-between px-3 py-2 rounded-lg text-xs font-medium transition-all group text-slate-300 hover:bg-slate-800/80 hover:text-white">
+          <div class="flex items-center space-x-2.5">
+            <svg class="w-4 h-4 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
+            </svg>
+            <span>DHCP Client</span>
+          </div>
+          <span id="badge-dhcp-client-count" class="text-[10px] bg-slate-800 text-slate-300 px-1.5 py-0.2 rounded-full font-mono">0</span>
+        </a>
+
         <a href="#bridge" onclick="navigate('bridge')" id="nav-bridge"
            class="nav-link flex items-center justify-between px-3 py-2 rounded-lg text-xs font-medium transition-all group text-slate-300 hover:bg-slate-800/80 hover:text-white">
           <div class="flex items-center space-x-2.5">
@@ -526,6 +537,82 @@
           </div>
         </section>
 
+        <!-- ================= SECTION: DHCP CLIENT (/ip dhcp-client) ================= -->
+        <section id="section-dhcp-client" class="hidden space-y-6">
+          <!-- Top Banner -->
+          <div class="bg-gradient-to-r from-amber-950/40 via-slate-900 to-slate-900 border border-amber-900/30 rounded-xl p-5 flex flex-wrap items-center justify-between gap-4">
+            <div>
+              <div class="flex items-center gap-2 mb-1">
+                <span class="px-2 py-0.5 rounded text-[10px] font-mono bg-amber-500/20 text-amber-300 border border-amber-500/30 font-semibold">/ip dhcp-client</span>
+                <h2 class="text-base font-bold text-white">DHCP Client Configuration</h2>
+              </div>
+              <p class="text-xs text-slate-400">RouterOS-style dynamic IP configuration on any physical interface, bridge or VLAN sub-interface.</p>
+            </div>
+            <div class="flex items-center gap-2">
+              <button onclick="openDHCPClientModal()" class="px-4 py-2 rounded-lg font-semibold text-xs bg-amber-500 hover:bg-amber-400 text-slate-950 shadow-lg shadow-amber-500/20 transition flex items-center gap-1.5">
+                <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
+                <span>Add DHCP Client</span>
+              </button>
+              <button onclick="loadDHCPClients(true)" class="p-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 border border-slate-700 transition" title="Refresh Telemetry">
+                <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
+              </button>
+            </div>
+          </div>
+
+          <!-- KPI Summary Cards -->
+          <div class="grid grid-cols-2 md:grid-cols-4 gap-4">
+            <div class="bg-slate-900 border border-slate-800 rounded-xl p-4">
+              <div class="text-xs text-slate-400">Total Configured</div>
+              <div id="dhcp-stat-total" class="text-xl font-bold text-white font-mono mt-1">0</div>
+            </div>
+            <div class="bg-slate-900 border border-slate-800 rounded-xl p-4">
+              <div class="text-xs text-slate-400">Bound Leases</div>
+              <div id="dhcp-stat-bound" class="text-xl font-bold text-emerald-400 font-mono mt-1">0</div>
+            </div>
+            <div class="bg-slate-900 border border-slate-800 rounded-xl p-4">
+              <div class="text-xs text-slate-400">Searching / Requesting</div>
+              <div id="dhcp-stat-searching" class="text-xl font-bold text-amber-400 font-mono mt-1">0</div>
+            </div>
+            <div class="bg-slate-900 border border-slate-800 rounded-xl p-4">
+              <div class="text-xs text-slate-400">Disabled</div>
+              <div id="dhcp-stat-disabled" class="text-xl font-bold text-slate-500 font-mono mt-1">0</div>
+            </div>
+          </div>
+
+          <!-- DHCP Clients Table -->
+          <div class="bg-slate-900/90 border border-slate-800 rounded-xl overflow-hidden">
+            <div class="p-4 border-b border-slate-800 flex items-center justify-between">
+              <div>
+                <h3 class="text-xs font-bold text-white">DHCP Client Interfaces</h3>
+                <p class="text-[11px] text-slate-400 mt-0.5">Active leases, assigned IP addresses, default gateways and lease duration</p>
+              </div>
+            </div>
+            <div class="overflow-x-auto">
+              <table class="w-full text-left text-xs">
+                <thead class="text-slate-400 bg-slate-950/60 border-b border-slate-800 uppercase tracking-wider text-[10px]">
+                  <tr>
+                    <th class="py-3 px-4">Interface</th>
+                    <th class="py-3 px-4">Status</th>
+                    <th class="py-3 px-4">IP Address / Netmask</th>
+                    <th class="py-3 px-4">Gateway</th>
+                    <th class="py-3 px-4">DHCP Server</th>
+                    <th class="py-3 px-4">DNS Servers</th>
+                    <th class="py-3 px-4">Expires In</th>
+                    <th class="py-3 px-4">Options</th>
+                    <th class="py-3 px-4 text-right">Actions</th>
+                  </tr>
+                </thead>
+                <tbody id="dhcp-clients-tbody" class="divide-y divide-slate-800 font-mono"></tbody>
+              </table>
+            </div>
+            <div id="dhcp-clients-empty" class="hidden p-12 text-center text-slate-500">
+              <div class="text-3xl mb-2">⚡</div>
+              <div class="font-semibold text-slate-400 text-sm">No DHCP Clients Configured</div>
+              <div class="text-xs mt-1 text-slate-500">Click &ldquo;Add DHCP Client&rdquo; to automatically obtain an IP on any interface.</div>
+            </div>
+          </div>
+        </section>
+
         <!-- ================= SECTION: BRIDGE (/interface bridge) ================= -->
         <section id="section-bridge" class="hidden space-y-6">
           <!-- Bridge Navigation Header -->
@@ -4731,6 +4818,71 @@
     </div>
   </div>
 
+  <!-- ================= MODAL: ADD / EDIT DHCP CLIENT ================= -->
+  <div id="modal-dhcp-client" class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
+    <div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-md shadow-2xl overflow-hidden animate-in">
+      <div class="px-6 py-4 border-b border-slate-800 flex items-center justify-between bg-slate-950/40">
+        <div class="flex items-center gap-2">
+          <span class="px-2 py-0.5 rounded text-[10px] font-mono bg-amber-500/20 text-amber-300 border border-amber-500/30 font-semibold">/ip dhcp-client</span>
+          <h3 id="modal-dhcp-title" class="text-sm font-bold text-white">Add DHCP Client</h3>
+        </div>
+        <button onclick="closeDHCPClientModal()" class="text-slate-400 hover:text-white p-1 rounded-lg">✕</button>
+      </div>
+
+      <form onsubmit="submitSaveDHCPClient(event)" class="p-6 space-y-4 text-xs">
+        <input type="hidden" id="modal-dhcp-id">
+
+        <div>
+          <label class="block font-semibold text-slate-300 mb-1.5">Interface *</label>
+          <select id="modal-dhcp-iface" required class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-amber-500">
+            <option value="">Select interface...</option>
+          </select>
+          <p class="text-[11px] text-slate-500 mt-1">Physical port, VLAN sub-interface, or Bridge to acquire dynamic lease on.</p>
+        </div>
+
+        <div class="grid grid-cols-2 gap-4">
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Default Route Distance</label>
+            <input type="number" id="modal-dhcp-distance" value="1" min="1" max="255" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-amber-500">
+          </div>
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Comment</label>
+            <input type="text" id="modal-dhcp-comment" placeholder="e.g. ISP WAN DHCP" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white focus:outline-none focus:border-amber-500">
+          </div>
+        </div>
+
+        <div class="bg-slate-950/60 border border-slate-800/80 rounded-xl p-3 space-y-2.5">
+          <div class="text-[11px] font-bold text-slate-400 uppercase tracking-wider">RouterOS Options</div>
+          
+          <label class="flex items-center space-x-2.5 text-slate-300 cursor-pointer">
+            <input type="checkbox" id="modal-dhcp-def-route" checked class="rounded text-amber-500 bg-slate-800 border-slate-700 focus:ring-amber-500">
+            <span>Add Default Route (default gateway from DHCP)</span>
+          </label>
+
+          <label class="flex items-center space-x-2.5 text-slate-300 cursor-pointer">
+            <input type="checkbox" id="modal-dhcp-peer-dns" checked class="rounded text-amber-500 bg-slate-800 border-slate-700 focus:ring-amber-500">
+            <span>Use Peer DNS (DNS servers from DHCP)</span>
+          </label>
+
+          <label class="flex items-center space-x-2.5 text-slate-300 cursor-pointer">
+            <input type="checkbox" id="modal-dhcp-peer-ntp" checked class="rounded text-amber-500 bg-slate-800 border-slate-700 focus:ring-amber-500">
+            <span>Use Peer NTP (NTP servers from DHCP)</span>
+          </label>
+
+          <label class="flex items-center space-x-2.5 text-slate-300 cursor-pointer pt-1 border-t border-slate-800/80">
+            <input type="checkbox" id="modal-dhcp-enabled" checked class="rounded text-emerald-500 bg-slate-800 border-slate-700 focus:ring-emerald-500">
+            <span class="font-semibold text-white">Enable DHCP Client immediately</span>
+          </label>
+        </div>
+
+        <div class="pt-4 border-t border-slate-800 flex justify-end space-x-3">
+          <button type="button" onclick="closeDHCPClientModal()" class="px-4 py-2 rounded-lg text-slate-400 hover:bg-slate-800 transition">Cancel</button>
+          <button type="submit" class="px-5 py-2 rounded-lg font-semibold bg-amber-500 hover:bg-amber-400 text-slate-950 shadow-lg shadow-amber-500/20 transition">Save DHCP Client</button>
+        </div>
+      </form>
+    </div>
+  </div>
+
   <!-- ================= MODAL: ADD / EDIT BRIDGE ================= -->
   <div id="bridge-modal" class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
     <div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-lg shadow-2xl overflow-hidden animate-in">
@@ -4975,6 +5127,6 @@
     </div>
   </div>
 
-  <script src="app.js?v=3.9"></script>
+  <script src="app.js?v=4.0"></script>
 </body>
 </html>

+ 8 - 2
internal/web/web.go

@@ -9,11 +9,17 @@ import (
 //go:embed static/*
 var staticFS embed.FS
 
-// Handler returns an http.Handler that serves the embedded web dashboard.
+// Handler returns an http.Handler that serves the embedded web dashboard with no-cache headers.
 func Handler() http.Handler {
 	sub, err := fs.Sub(staticFS, "static")
 	if err != nil {
 		panic(err)
 	}
-	return http.FileServer(http.FS(sub))
+	fileServer := http.FileServer(http.FS(sub))
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
+		w.Header().Set("Pragma", "no-cache")
+		w.Header().Set("Expires", "0")
+		fileServer.ServeHTTP(w, r)
+	})
 }