Pārlūkot izejas kodu

feat(dns): add router DNS forwarder, static DNS records, and unified Settings section

Proxmox Router 1 nedēļu atpakaļ
vecāks
revīzija
c2dd379d3f

BIN
bin/proxmox-router


+ 3 - 1
cmd/server/main.go

@@ -15,6 +15,7 @@ import (
 	"proxmox-router/internal/bridge"
 	"proxmox-router/internal/bwtest"
 	"proxmox-router/internal/dhcp"
+	"proxmox-router/internal/dns"
 	"proxmox-router/internal/dynamicrouting"
 	"proxmox-router/internal/firewall"
 	"proxmox-router/internal/network"
@@ -46,9 +47,10 @@ func main() {
 	vrrpMgr := vrrp.NewManager()
 	bwtestMgr := bwtest.NewManager("")
 	defer bwtestMgr.Stop()
+	dnsMgr := dns.NewManager()
 	webHandler := web.Handler()
 
-	srv := api.NewServer(netMgr, persistMgr, metaMgr, vpnMgr, fwMgr, routingMgr, dynRoutingMgr, bridgeMgr, toolsMgr, dhcpMgr, dhcpServerMgr, vrrpMgr, bwtestMgr, webHandler)
+	srv := api.NewServer(netMgr, persistMgr, metaMgr, vpnMgr, fwMgr, routingMgr, dynRoutingMgr, bridgeMgr, toolsMgr, dhcpMgr, dhcpServerMgr, vrrpMgr, bwtestMgr, dnsMgr, webHandler)
 
 	httpServer := &http.Server{
 		Addr:         *addr,

+ 19 - 2
internal/api/api.go

@@ -6,8 +6,9 @@ import (
 	"strings"
 
 	"proxmox-router/internal/bridge"
-	"proxmox-router/internal/dhcp"
 	"proxmox-router/internal/bwtest"
+	"proxmox-router/internal/dhcp"
+	"proxmox-router/internal/dns"
 	"proxmox-router/internal/dynamicrouting"
 	"proxmox-router/internal/firewall"
 	"proxmox-router/internal/network"
@@ -32,11 +33,12 @@ type Server struct {
 	dhcpServerMgr *dhcp.ServerManager
 	vrrpMgr       *vrrp.Manager
 	bwtestMgr     *bwtest.Manager
+	dnsMgr        *dns.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, dhcpMgr *dhcp.Manager, dhcpServerMgr *dhcp.ServerManager, vrrpMgr *vrrp.Manager, bwtestMgr *bwtest.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, dhcpServerMgr *dhcp.ServerManager, vrrpMgr *vrrp.Manager, bwtestMgr *bwtest.Manager, dnsMgr *dns.Manager, fsHandler http.Handler) *Server {
 	s := &Server{
 		netMgr:        netMgr,
 		persistMgr:    persistMgr,
@@ -51,6 +53,7 @@ func NewServer(netMgr *network.Manager, persistMgr *network.PersistenceManager,
 		dhcpServerMgr: dhcpServerMgr,
 		vrrpMgr:       vrrpMgr,
 		bwtestMgr:     bwtestMgr,
+		dnsMgr:        dnsMgr,
 		mux:           http.NewServeMux(),
 		fsHandler:     fsHandler,
 	}
@@ -385,6 +388,20 @@ func (s *Server) registerRoutes() {
 	s.mux.HandleFunc("DELETE /api/vrrp/instances/{id}", s.handleDeleteVRRPInstance)
 	s.mux.HandleFunc("POST /api/vrrp/instances/{id}/state", s.handleToggleVRRPState)
 
+	// Router DNS & Settings (/ip dns)
+	s.mux.HandleFunc("GET /api/dns/settings", s.handleGetDNSSettings)
+	s.mux.HandleFunc("POST /api/dns/settings", s.handleSaveDNSSettings)
+	s.mux.HandleFunc("GET /api/dns/static", s.handleListDNSStatic)
+	s.mux.HandleFunc("POST /api/dns/static", s.handleSaveDNSStatic)
+	s.mux.HandleFunc("DELETE /api/dns/static/{id}", s.handleDeleteDNSStatic)
+	s.mux.HandleFunc("POST /api/dns/static/{id}/state", s.handleSetDNSStaticState)
+	s.mux.HandleFunc("POST /api/dns/flush", s.handleFlushDNSCache)
+	s.mux.HandleFunc("GET /api/dns/status", s.handleGetDNSStatus)
+
+	// System Settings (/system identity & clock)
+	s.mux.HandleFunc("GET /api/system/settings", s.handleGetSystemSettings)
+	s.mux.HandleFunc("POST /api/system/settings", s.handleSaveSystemSettings)
+
 	// Web UI Static Files
 	if s.fsHandler != nil {
 		s.mux.Handle("/", s.fsHandler)

+ 197 - 0
internal/api/dns_handlers.go

@@ -0,0 +1,197 @@
+package api
+
+import (
+	"encoding/json"
+	"net/http"
+	"os"
+	"os/exec"
+	"strings"
+
+	"proxmox-router/internal/dns"
+)
+
+// handleGetDNSSettings returns the current router DNS configuration.
+func (s *Server) handleGetDNSSettings(w http.ResponseWriter, r *http.Request) {
+	if s.dnsMgr == nil {
+		writeError(w, http.StatusServiceUnavailable, "DNS manager not initialized")
+		return
+	}
+	settings := s.dnsMgr.GetSettings()
+	writeJSON(w, http.StatusOK, settings)
+}
+
+// handleSaveDNSSettings updates the router DNS configuration.
+func (s *Server) handleSaveDNSSettings(w http.ResponseWriter, r *http.Request) {
+	if s.dnsMgr == nil {
+		writeError(w, http.StatusServiceUnavailable, "DNS manager not initialized")
+		return
+	}
+
+	var req dns.DNSSettings
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request body")
+		return
+	}
+
+	if err := s.dnsMgr.SaveSettings(req); err != nil {
+		writeError(w, http.StatusBadRequest, err.Error())
+		return
+	}
+
+	writeJSON(w, http.StatusOK, map[string]any{
+		"success":  true,
+		"settings": s.dnsMgr.GetSettings(),
+	})
+}
+
+// handleListDNSStatic returns all static DNS host mappings.
+func (s *Server) handleListDNSStatic(w http.ResponseWriter, r *http.Request) {
+	if s.dnsMgr == nil {
+		writeError(w, http.StatusServiceUnavailable, "DNS manager not initialized")
+		return
+	}
+	records := s.dnsMgr.ListStaticRecords()
+	writeJSON(w, http.StatusOK, records)
+}
+
+// handleSaveDNSStatic adds or updates a static DNS record.
+func (s *Server) handleSaveDNSStatic(w http.ResponseWriter, r *http.Request) {
+	if s.dnsMgr == nil {
+		writeError(w, http.StatusServiceUnavailable, "DNS manager not initialized")
+		return
+	}
+
+	var rec dns.StaticRecord
+	if err := json.NewDecoder(r.Body).Decode(&rec); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request body")
+		return
+	}
+
+	if err := s.dnsMgr.SaveStaticRecord(rec); err != nil {
+		writeError(w, http.StatusBadRequest, err.Error())
+		return
+	}
+
+	writeJSON(w, http.StatusOK, map[string]any{
+		"success": true,
+		"record":  rec,
+	})
+}
+
+// handleDeleteDNSStatic deletes a static DNS record by ID.
+func (s *Server) handleDeleteDNSStatic(w http.ResponseWriter, r *http.Request) {
+	if s.dnsMgr == nil {
+		writeError(w, http.StatusServiceUnavailable, "DNS manager not initialized")
+		return
+	}
+
+	id := r.PathValue("id")
+	if id == "" {
+		writeError(w, http.StatusBadRequest, "record ID is required")
+		return
+	}
+
+	if err := s.dnsMgr.DeleteStaticRecord(id); err != nil {
+		writeError(w, http.StatusNotFound, err.Error())
+		return
+	}
+
+	writeJSON(w, http.StatusOK, map[string]any{"success": true, "id": id})
+}
+
+// handleSetDNSStaticState toggles enabled/disabled state of a static DNS record.
+func (s *Server) handleSetDNSStaticState(w http.ResponseWriter, r *http.Request) {
+	if s.dnsMgr == nil {
+		writeError(w, http.StatusServiceUnavailable, "DNS manager not initialized")
+		return
+	}
+
+	id := r.PathValue("id")
+	var req struct {
+		Disabled bool `json:"disabled"`
+	}
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request body")
+		return
+	}
+
+	if err := s.dnsMgr.ToggleStaticRecord(id, req.Disabled); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	writeJSON(w, http.StatusOK, map[string]any{"success": true, "id": id, "disabled": req.Disabled})
+}
+
+// handleFlushDNSCache flushes the local DNS cache.
+func (s *Server) handleFlushDNSCache(w http.ResponseWriter, r *http.Request) {
+	if s.dnsMgr == nil {
+		writeError(w, http.StatusServiceUnavailable, "DNS manager not initialized")
+		return
+	}
+
+	if err := s.dnsMgr.FlushCache(); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	writeJSON(w, http.StatusOK, map[string]any{"success": true, "message": "DNS cache flushed successfully"})
+}
+
+// handleGetDNSStatus returns live DNS forwarder and resolv.conf status.
+func (s *Server) handleGetDNSStatus(w http.ResponseWriter, r *http.Request) {
+	if s.dnsMgr == nil {
+		writeError(w, http.StatusServiceUnavailable, "DNS manager not initialized")
+		return
+	}
+	status := s.dnsMgr.GetStatus()
+	writeJSON(w, http.StatusOK, status)
+}
+
+// handleGetSystemSettings returns hostname, timezone, and appliance info.
+func (s *Server) handleGetSystemSettings(w http.ResponseWriter, r *http.Request) {
+	hostname, _ := os.Hostname()
+
+	tz := "UTC"
+	if out, err := exec.Command("timedatectl", "show", "--property=Timezone", "--value").Output(); err == nil {
+		tz = strings.TrimSpace(string(out))
+	} else if data, err := os.ReadFile("/etc/timezone"); err == nil {
+		tz = strings.TrimSpace(string(data))
+	}
+
+	writeJSON(w, http.StatusOK, map[string]any{
+		"hostname": hostname,
+		"timezone": tz,
+	})
+}
+
+// handleSaveSystemSettings updates hostname and/or timezone.
+func (s *Server) handleSaveSystemSettings(w http.ResponseWriter, r *http.Request) {
+	var req struct {
+		Hostname string `json:"hostname"`
+		Timezone string `json:"timezone"`
+	}
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request body")
+		return
+	}
+
+	if req.Hostname != "" {
+		h := strings.TrimSpace(req.Hostname)
+		if err := exec.Command("hostnamectl", "set-hostname", h).Run(); err != nil {
+			_ = os.WriteFile("/etc/hostname", []byte(h+"\n"), 0644)
+		}
+	}
+
+	if req.Timezone != "" {
+		tz := strings.TrimSpace(req.Timezone)
+		_ = exec.Command("timedatectl", "set-timezone", tz).Run()
+	}
+
+	curH, _ := os.Hostname()
+	writeJSON(w, http.StatusOK, map[string]any{
+		"success":  true,
+		"hostname": curH,
+		"timezone": req.Timezone,
+	})
+}

+ 463 - 0
internal/dns/manager.go

@@ -0,0 +1,463 @@
+package dns
+
+import (
+	"encoding/json"
+	"fmt"
+	"log"
+	"net"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"sync"
+	"time"
+)
+
+const (
+	SettingsStorePath = "/etc/proxmox-router/dns/settings.json"
+	DnsmasqDNSConf    = "/etc/dnsmasq.d/debtik_dns.conf"
+	SystemResolvConf  = "/etc/resolv.conf"
+)
+
+// Manager coordinates Router DNS forwarder, upstream resolvers, and static DNS mapping.
+type Manager struct {
+	mu             sync.RWMutex
+	settings       DNSSettings
+	dynamicServers []string
+}
+
+func NewManager() *Manager {
+	m := &Manager{
+		dynamicServers: make([]string, 0),
+		settings: DNSSettings{
+			Servers:             []string{"1.1.1.1", "8.8.8.8"},
+			DynamicServers:      make([]string, 0),
+			AllowRemoteRequests: true,
+			CacheSize:           2048,
+			Domain:              "",
+			StaticRecords:       make([]StaticRecord, 0),
+		},
+	}
+
+	m.load()
+	if err := m.applyLocked(); err != nil {
+		log.Printf("[DNS] Error applying initial DNS configuration: %v", err)
+	}
+	return m
+}
+
+func (m *Manager) load() {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	data, err := os.ReadFile(SettingsStorePath)
+	if err != nil {
+		// If no settings file, read existing resolv.conf nameservers if any
+		if curNameservers := readSystemNameservers(); len(curNameservers) > 0 {
+			m.settings.Servers = curNameservers
+		}
+		return
+	}
+
+	var s DNSSettings
+	if err := json.Unmarshal(data, &s); err == nil {
+		if len(s.Servers) > 0 {
+			m.settings.Servers = s.Servers
+		}
+		m.settings.AllowRemoteRequests = s.AllowRemoteRequests
+		if s.CacheSize > 0 {
+			m.settings.CacheSize = s.CacheSize
+		}
+		m.settings.Domain = s.Domain
+		m.settings.DoHServer = s.DoHServer
+		m.settings.VerifyDoHCert = s.VerifyDoHCert
+		if s.StaticRecords != nil {
+			m.settings.StaticRecords = s.StaticRecords
+		}
+	}
+}
+
+func (m *Manager) saveLocked() error {
+	dir := filepath.Dir(SettingsStorePath)
+	if err := os.MkdirAll(dir, 0755); err != nil {
+		return err
+	}
+
+	data, err := json.MarshalIndent(m.settings, "", "  ")
+	if err != nil {
+		return err
+	}
+
+	return os.WriteFile(SettingsStorePath, data, 0644)
+}
+
+// applyLocked renders the dnsmasq DNS configuration and updates /etc/resolv.conf.
+func (m *Manager) applyLocked() error {
+	var sb strings.Builder
+	sb.WriteString("# ==================================================================\n")
+	sb.WriteString("# Enterprise Router DNS Forwarder & Cache Configuration\n")
+	sb.WriteString("# Auto-generated by Network Appliance Controller. DO NOT EDIT DIRECTLY.\n")
+	sb.WriteString("# ==================================================================\n\n")
+
+	cacheSize := m.settings.CacheSize
+	if cacheSize <= 0 {
+		cacheSize = 2048
+	}
+	sb.WriteString(fmt.Sprintf("cache-size=%d\n", cacheSize))
+
+	// Do not read /etc/resolv.conf in dnsmasq (prevents loop with nameserver 127.0.0.1)
+	sb.WriteString("no-resolv\n")
+
+	// Upstream DNS Servers (Configured + Dynamic)
+	allUpstreams := make([]string, 0)
+	seen := make(map[string]bool)
+
+	for _, s := range m.settings.Servers {
+		trimmed := strings.TrimSpace(s)
+		if trimmed != "" && !seen[trimmed] {
+			allUpstreams = append(allUpstreams, trimmed)
+			seen[trimmed] = true
+		}
+	}
+	for _, s := range m.dynamicServers {
+		trimmed := strings.TrimSpace(s)
+		if trimmed != "" && !seen[trimmed] {
+			allUpstreams = append(allUpstreams, trimmed)
+			seen[trimmed] = true
+		}
+	}
+
+	if len(allUpstreams) == 0 {
+		allUpstreams = append(allUpstreams, "1.1.1.1", "8.8.8.8")
+	}
+
+	sb.WriteString("\n# Upstream DNS Forwarders\n")
+	for _, s := range allUpstreams {
+		sb.WriteString(fmt.Sprintf("server=%s\n", s))
+	}
+
+	// Domain
+	domain := strings.TrimSpace(m.settings.Domain)
+	if domain != "" {
+		sb.WriteString(fmt.Sprintf("\ndomain=%s\n", domain))
+		sb.WriteString(fmt.Sprintf("local=/%s/\n", domain))
+	}
+
+	// Remote requests restriction
+	if !m.settings.AllowRemoteRequests {
+		sb.WriteString("\n# Remote requests disabled: only listen on loopback for host\n")
+		sb.WriteString("listen-address=127.0.0.1\n")
+	}
+
+	// Static DNS Records
+	sb.WriteString("\n# Local Static DNS Records (/ip dns static)\n")
+	for _, rec := range m.settings.StaticRecords {
+		if rec.Disabled {
+			continue
+		}
+		name := strings.TrimSpace(rec.Name)
+		addr := strings.TrimSpace(rec.Address)
+		if name == "" || addr == "" {
+			continue
+		}
+
+		recType := strings.ToUpper(strings.TrimSpace(rec.Type))
+		switch recType {
+		case "CNAME":
+			sb.WriteString(fmt.Sprintf("cname=%s,%s\n", name, addr))
+		case "TXT":
+			sb.WriteString(fmt.Sprintf("txt-record=%s,%q\n", name, addr))
+		default: // "A" or "AAAA"
+			sb.WriteString(fmt.Sprintf("address=/%s/%s\n", name, addr))
+		}
+	}
+
+	// Write dnsmasq config
+	_ = os.MkdirAll(filepath.Dir(DnsmasqDNSConf), 0755)
+	if err := os.WriteFile(DnsmasqDNSConf, []byte(sb.String()), 0644); err != nil {
+		return fmt.Errorf("failed to write %s: %w", DnsmasqDNSConf, err)
+	}
+
+	// Test dnsmasq syntax
+	testOut, testErr := exec.Command("dnsmasq", "--test").CombinedOutput()
+	if testErr != nil {
+		log.Printf("[DNS] dnsmasq syntax check failed: %v, output: %s", testErr, string(testOut))
+		return fmt.Errorf("dnsmasq configuration test failed: %s", string(testOut))
+	}
+
+	// Update host /etc/resolv.conf
+	var rsb strings.Builder
+	rsb.WriteString("# ==================================================================\n")
+	rsb.WriteString("# Network Appliance System Resolver\n")
+	rsb.WriteString("# Managed by DebTik DNS Controller\n")
+	rsb.WriteString("# ==================================================================\n\n")
+	rsb.WriteString("nameserver 127.0.0.1\n")
+	for _, s := range allUpstreams {
+		rsb.WriteString(fmt.Sprintf("nameserver %s\n", s))
+	}
+	if domain != "" {
+		rsb.WriteString(fmt.Sprintf("search %s\n", domain))
+	}
+
+	_ = os.WriteFile(SystemResolvConf, []byte(rsb.String()), 0644)
+
+	// Restart dnsmasq to re-read all configuration files
+	if err := exec.Command("systemctl", "restart", "dnsmasq").Run(); err != nil {
+		log.Printf("[DNS] Failed to restart dnsmasq: %v", err)
+	}
+
+	return nil
+}
+
+// GetSettings returns a snapshot of current DNS settings with dynamic servers populated.
+func (m *Manager) GetSettings() DNSSettings {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+
+	s := m.settings
+	s.DynamicServers = make([]string, len(m.dynamicServers))
+	copy(s.DynamicServers, m.dynamicServers)
+	return s
+}
+
+// SaveSettings updates the DNS configuration and applies it immediately.
+func (m *Manager) SaveSettings(newSettings DNSSettings) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	cleanedServers := make([]string, 0)
+	for _, s := range newSettings.Servers {
+		s = strings.TrimSpace(s)
+		if s != "" {
+			if ip := net.ParseIP(s); ip == nil {
+				return fmt.Errorf("invalid upstream DNS IP address %q", s)
+			}
+			cleanedServers = append(cleanedServers, s)
+		}
+	}
+
+	if len(cleanedServers) == 0 {
+		return fmt.Errorf("at least one valid upstream DNS server is required")
+	}
+
+	m.settings.Servers = cleanedServers
+	m.settings.AllowRemoteRequests = newSettings.AllowRemoteRequests
+	if newSettings.CacheSize > 0 {
+		if newSettings.CacheSize > 50000 {
+			m.settings.CacheSize = 50000
+		} else {
+			m.settings.CacheSize = newSettings.CacheSize
+		}
+	}
+	m.settings.Domain = strings.TrimSpace(newSettings.Domain)
+	m.settings.DoHServer = strings.TrimSpace(newSettings.DoHServer)
+	m.settings.VerifyDoHCert = newSettings.VerifyDoHCert
+
+	if err := m.saveLocked(); err != nil {
+		return err
+	}
+
+	return m.applyLocked()
+}
+
+// SetDynamicServers sets peer DNS servers received dynamically from DHCP or PPP.
+func (m *Manager) SetDynamicServers(servers []string) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	changed := false
+	if len(m.dynamicServers) != len(servers) {
+		changed = true
+	} else {
+		for i := range servers {
+			if m.dynamicServers[i] != servers[i] {
+				changed = true
+				break
+			}
+		}
+	}
+
+	if changed {
+		m.dynamicServers = make([]string, 0, len(servers))
+		for _, s := range servers {
+			trimmed := strings.TrimSpace(s)
+			if trimmed != "" {
+				m.dynamicServers = append(m.dynamicServers, trimmed)
+			}
+		}
+		_ = m.applyLocked()
+	}
+}
+
+// ListStaticRecords returns all user-defined static DNS host mappings.
+func (m *Manager) ListStaticRecords() []StaticRecord {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+
+	res := make([]StaticRecord, len(m.settings.StaticRecords))
+	copy(res, m.settings.StaticRecords)
+	return res
+}
+
+// SaveStaticRecord adds or updates a static DNS record.
+func (m *Manager) SaveStaticRecord(rec StaticRecord) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	rec.Name = strings.TrimSpace(rec.Name)
+	rec.Address = strings.TrimSpace(rec.Address)
+	if rec.Name == "" {
+		return fmt.Errorf("DNS name (hostname) is required")
+	}
+	if rec.Address == "" {
+		return fmt.Errorf("target address is required")
+	}
+
+	rec.Type = strings.ToUpper(strings.TrimSpace(rec.Type))
+	if rec.Type == "" {
+		rec.Type = "A"
+	}
+	if rec.Type != "A" && rec.Type != "AAAA" && rec.Type != "CNAME" && rec.Type != "TXT" {
+		return fmt.Errorf("unsupported record type %q: must be A, AAAA, CNAME, or TXT", rec.Type)
+	}
+
+	if rec.Type == "A" || rec.Type == "AAAA" {
+		if ip := net.ParseIP(rec.Address); ip == nil {
+			return fmt.Errorf("invalid IP address %q for record type %s", rec.Address, rec.Type)
+		}
+	}
+
+	if rec.ID == "" {
+		rec.ID = fmt.Sprintf("dns-rec-%d", time.Now().UnixNano())
+		m.settings.StaticRecords = append(m.settings.StaticRecords, rec)
+	} else {
+		found := false
+		for i, existing := range m.settings.StaticRecords {
+			if existing.ID == rec.ID {
+				m.settings.StaticRecords[i] = rec
+				found = true
+				break
+			}
+		}
+		if !found {
+			m.settings.StaticRecords = append(m.settings.StaticRecords, rec)
+		}
+	}
+
+	if err := m.saveLocked(); err != nil {
+		return err
+	}
+
+	return m.applyLocked()
+}
+
+// DeleteStaticRecord removes a static DNS record by ID.
+func (m *Manager) DeleteStaticRecord(id string) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	newRecords := make([]StaticRecord, 0, len(m.settings.StaticRecords))
+	found := false
+	for _, rec := range m.settings.StaticRecords {
+		if rec.ID == id {
+			found = true
+			continue
+		}
+		newRecords = append(newRecords, rec)
+	}
+
+	if !found {
+		return fmt.Errorf("static DNS record %q not found", id)
+	}
+
+	m.settings.StaticRecords = newRecords
+	if err := m.saveLocked(); err != nil {
+		return err
+	}
+
+	return m.applyLocked()
+}
+
+// ToggleStaticRecord enables or disables a static DNS record.
+func (m *Manager) ToggleStaticRecord(id string, disabled bool) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	found := false
+	for i := range m.settings.StaticRecords {
+		if m.settings.StaticRecords[i].ID == id {
+			m.settings.StaticRecords[i].Disabled = disabled
+			found = true
+			break
+		}
+	}
+	if !found {
+		return fmt.Errorf("static DNS record %q not found", id)
+	}
+
+	if err := m.saveLocked(); err != nil {
+		return err
+	}
+
+	return m.applyLocked()
+}
+
+// FlushCache clears dnsmasq DNS cache by sending SIGHUP.
+func (m *Manager) FlushCache() error {
+	cmd := exec.Command("pkill", "-HUP", "dnsmasq")
+	if out, err := cmd.CombinedOutput(); err != nil {
+		// Fallback to reload
+		_ = exec.Command("systemctl", "reload", "dnsmasq").Run()
+		log.Printf("[DNS] pkill -HUP result: %v (%s), fallback reload triggered", err, string(out))
+	}
+	return nil
+}
+
+// GetStatus returns the operational status of the DNS subsystem.
+func (m *Manager) GetStatus() DNSStatus {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+
+	resolvBytes, _ := os.ReadFile(SystemResolvConf)
+
+	// Check if dnsmasq is running
+	isRunning := false
+	if err := exec.Command("systemctl", "is-active", "--quiet", "dnsmasq").Run(); err == nil {
+		isRunning = true
+	}
+
+	activeServers := make([]string, 0, len(m.settings.Servers)+len(m.dynamicServers))
+	activeServers = append(activeServers, m.settings.Servers...)
+	activeServers = append(activeServers, m.dynamicServers...)
+
+	return DNSStatus{
+		ActiveServers:       activeServers,
+		AllowRemoteRequests: m.settings.AllowRemoteRequests,
+		CacheSize:           m.settings.CacheSize,
+		StaticCount:         len(m.settings.StaticRecords),
+		Domain:              m.settings.Domain,
+		ResolvConfContent:   string(resolvBytes),
+		ServiceRunning:      isRunning,
+	}
+}
+
+// Helper: read existing nameservers from /etc/resolv.conf
+func readSystemNameservers() []string {
+	data, err := os.ReadFile(SystemResolvConf)
+	if err != nil {
+		return nil
+	}
+
+	var servers []string
+	lines := strings.Split(string(data), "\n")
+	for _, l := range lines {
+		l = strings.TrimSpace(l)
+		if strings.HasPrefix(l, "nameserver") {
+			parts := strings.Fields(l)
+			if len(parts) >= 2 && parts[1] != "127.0.0.1" && parts[1] != "::1" {
+				servers = append(servers, parts[1])
+			}
+		}
+	}
+	return servers
+}

+ 35 - 0
internal/dns/types.go

@@ -0,0 +1,35 @@
+package dns
+
+// DNSSettings represents router DNS forwarder, upstream and cache configuration.
+type DNSSettings struct {
+	Servers             []string       `json:"servers"`               // Configured static upstream DNS servers (e.g. 1.1.1.1, 8.8.8.8)
+	DynamicServers      []string       `json:"dynamic_servers"`       // Dynamic DNS servers learned from DHCP/PPP
+	AllowRemoteRequests bool           `json:"allow_remote_requests"` // Accept queries from LAN devices (port 53)
+	CacheSize           int            `json:"cache_size"`            // Dnsmasq cache size in entries (default 1000)
+	Domain              string         `json:"domain"`                // Local domain e.g. "home.lan"
+	DoHServer           string         `json:"doh_server,omitempty"`  // Optional DNS-over-HTTPS URL
+	VerifyDoHCert       bool           `json:"verify_doh_cert"`       // Validate DoH certificate
+	StaticRecords       []StaticRecord `json:"static_records"`        // Local static DNS records
+}
+
+// StaticRecord represents a static DNS mapping (hostname -> IP / CNAME)
+type StaticRecord struct {
+	ID       string `json:"id"`
+	Name     string `json:"name"`               // FQDN or hostname e.g. "myrouter.lan"
+	Type     string `json:"type"`               // "A", "AAAA", "CNAME", "TXT"
+	Address  string `json:"address"`            // Target IP address or target FQDN
+	TTL      int    `json:"ttl,omitempty"`      // TTL in seconds (default 3600)
+	Comment  string `json:"comment,omitempty"`
+	Disabled bool   `json:"disabled"`
+}
+
+// DNSStatus provides live status for the router DNS system.
+type DNSStatus struct {
+	ActiveServers       []string `json:"active_servers"`
+	AllowRemoteRequests bool     `json:"allow_remote_requests"`
+	CacheSize           int      `json:"cache_size"`
+	StaticCount         int      `json:"static_count"`
+	Domain              string   `json:"domain"`
+	ResolvConfContent   string   `json:"resolv_conf_content"`
+	ServiceRunning      bool     `json:"service_running"`
+}

+ 366 - 1
internal/web/static/app.js

@@ -138,7 +138,8 @@ function navigate(tabName) {
     vrrp: { title: 'VRRP High Availability', sub: 'Virtual Router Redundancy Protocol, active-backup gateway failover & Virtual IP (VIP) routing' },
     bwtest: { title: 'Bandwidth Test Benchmark', sub: 'High-speed line-rate throughput benchmark, token-bucket rate limiting, loss & jitter telemetry' },
     tools: { title: 'Diagnostic & Troubleshooting Tools', sub: 'Network latency analysis, ICMP/MTR telemetry, path tracing, DNS resolution and TCP probing' },
-    persistence: { title: 'Debian Configuration', sub: 'Native /etc/network/interfaces.d/ persistence manager' }
+    persistence: { title: 'Debian Configuration', sub: 'Native /etc/network/interfaces.d/ persistence manager' },
+    settings: { title: 'Router Settings & DNS', sub: 'System DNS forwarder, upstream resolvers, static host mappings, and appliance identity' }
   };
 
   const header = titles[tabName] || titles.dashboard;
@@ -183,6 +184,7 @@ function navigate(tabName) {
   if (tabName === 'dhcp-server') loadDHCPServer();
   if (tabName === 'vrrp') loadVRRP();
   if (tabName === 'bwtest') loadBwTestView();
+  if (tabName === 'settings') loadSettingsData();
   setTimeout(initTableSorting, 50);
 }
 
@@ -270,6 +272,7 @@ async function fetchData(isManual = false) {
     if (state.activeTab === 'dhcp-client') loadDHCPClients(false);
     if (state.activeTab === 'dhcp-server') loadDHCPServer(false);
     if (state.activeTab === 'vrrp') loadVRRP(false);
+    if (state.activeTab === 'settings') loadSettingsData(false);
     fetchBwTestServerStatus();
 
   } catch (err) {
@@ -14764,5 +14767,367 @@ function renderBwTestHistory(list) {
   });
 }
 
+// ======================== SETTINGS & ROUTER DNS ========================
+state.settingsSubTab = 'dns';
+state.dnsSettings = null;
+state.dnsStaticRecords = [];
+state.dnsStatus = null;
+
+function switchSettingsSubTab(sub) {
+  state.settingsSubTab = sub;
+  const btnDns = document.getElementById('btn-settings-subtab-dns');
+  const btnSys = document.getElementById('btn-settings-subtab-system');
+  const tabDns = document.getElementById('settings-subtab-dns');
+  const tabSys = document.getElementById('settings-subtab-system');
+
+  if (sub === 'dns') {
+    if (btnDns) {
+      btnDns.className = 'px-3.5 py-1.5 rounded-lg text-xs font-semibold bg-cyan-600 text-white transition flex items-center gap-1.5';
+    }
+    if (btnSys) {
+      btnSys.className = 'px-3.5 py-1.5 rounded-lg text-xs font-semibold bg-slate-800 text-slate-300 hover:text-white transition flex items-center gap-1.5';
+    }
+    tabDns?.classList.remove('hidden');
+    tabSys?.classList.add('hidden');
+    loadDNSSettings();
+  } else {
+    if (btnSys) {
+      btnSys.className = 'px-3.5 py-1.5 rounded-lg text-xs font-semibold bg-cyan-600 text-white transition flex items-center gap-1.5';
+    }
+    if (btnDns) {
+      btnDns.className = 'px-3.5 py-1.5 rounded-lg text-xs font-semibold bg-slate-800 text-slate-300 hover:text-white transition flex items-center gap-1.5';
+    }
+    tabSys?.classList.remove('hidden');
+    tabDns?.classList.add('hidden');
+    loadSystemSettings();
+  }
+}
+
+async function loadSettingsData(showLoading = false) {
+  if (state.settingsSubTab === 'system') {
+    await loadSystemSettings();
+  } else {
+    await loadDNSSettings();
+  }
+}
+
+async function loadDNSSettings() {
+  try {
+    const [settingsRes, staticRes, statusRes] = await Promise.all([
+      fetch(`${API_BASE}/dns/settings`),
+      fetch(`${API_BASE}/dns/static`),
+      fetch(`${API_BASE}/dns/status`)
+    ]);
+
+    if (settingsRes.ok) {
+      state.dnsSettings = await settingsRes.json();
+      populateDNSSettingsForm(state.dnsSettings);
+    }
+    if (staticRes.ok) {
+      state.dnsStaticRecords = await staticRes.json();
+      renderStaticDNSList();
+    }
+    if (statusRes.ok) {
+      state.dnsStatus = await statusRes.json();
+      updateDNSStatusView(state.dnsStatus);
+    }
+  } catch (err) {
+    console.error('Failed to load DNS settings:', err);
+  }
+}
+
+function populateDNSSettingsForm(s) {
+  if (!s) return;
+  const serversInput = document.getElementById('dns-servers-input');
+  if (serversInput) serversInput.value = (s.servers || []).join(', ');
+
+  const cacheSizeInput = document.getElementById('dns-cache-size-input');
+  if (cacheSizeInput) cacheSizeInput.value = s.cache_size || 2048;
+
+  const domainInput = document.getElementById('dns-domain-input');
+  if (domainInput) domainInput.value = s.domain || '';
+
+  const allowRemoteInput = document.getElementById('dns-allow-remote-input');
+  if (allowRemoteInput) allowRemoteInput.checked = s.allow_remote_requests !== false;
+
+  const dynamicEl = document.getElementById('dns-dynamic-servers-list');
+  if (dynamicEl) {
+    if (Array.isArray(s.dynamic_servers) && s.dynamic_servers.length > 0) {
+      dynamicEl.innerHTML = s.dynamic_servers.map(d => `<span class="px-2 py-0.5 rounded bg-cyan-950 text-cyan-300 border border-cyan-800 font-mono">${escapeHtml(d)}</span>`).join(' ');
+    } else {
+      dynamicEl.innerText = 'None';
+    }
+  }
+}
+
+function updateDNSStatusView(st) {
+  if (!st) return;
+  const preview = document.getElementById('dns-resolv-preview');
+  if (preview) preview.innerText = st.resolv_conf_content || '(empty)';
+
+  const pillText = document.getElementById('dns-live-status-text');
+  const pill = document.getElementById('dns-live-status-pill');
+  if (pill && pillText) {
+    if (st.service_running) {
+      pill.className = 'px-2.5 py-1 rounded-full text-[11px] font-mono bg-emerald-500/10 text-emerald-400 border border-emerald-500/30 flex items-center gap-1.5';
+      pillText.innerText = 'DNS Service Active';
+    } else {
+      pill.className = 'px-2.5 py-1 rounded-full text-[11px] font-mono bg-rose-500/10 text-rose-400 border border-rose-500/30 flex items-center gap-1.5';
+      pillText.innerText = 'DNS Service Inactive';
+    }
+  }
+}
+
+function appendDNSPreset(preset) {
+  const input = document.getElementById('dns-servers-input');
+  if (!input) return;
+  const current = input.value.trim();
+  if (!current) {
+    input.value = preset;
+  } else {
+    const existing = current.split(/[,\s]+/).map(s => s.trim()).filter(Boolean);
+    const toAdd = preset.split(/[,\s]+/).map(s => s.trim()).filter(Boolean);
+    toAdd.forEach(ip => {
+      if (!existing.includes(ip)) existing.push(ip);
+    });
+    input.value = existing.join(', ');
+  }
+}
+
+async function saveDNSSettings(e) {
+  e.preventDefault();
+  const rawServers = document.getElementById('dns-servers-input')?.value || '';
+  const servers = rawServers.split(/[,\s]+/).map(s => s.trim()).filter(Boolean);
+
+  if (servers.length === 0) {
+    showToast('Validation Error', 'Please enter at least one upstream DNS server IP', 'error');
+    return;
+  }
+
+  const cacheSize = parseInt(document.getElementById('dns-cache-size-input')?.value || '2048', 10);
+  const domain = (document.getElementById('dns-domain-input')?.value || '').trim();
+  const allowRemote = document.getElementById('dns-allow-remote-input')?.checked ?? true;
+
+  try {
+    const res = await fetch(`${API_BASE}/dns/settings`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        servers: servers,
+        cache_size: cacheSize,
+        domain: domain,
+        allow_remote_requests: allowRemote
+      })
+    });
+    const data = await res.json();
+    if (res.ok) {
+      showToast('DNS Applied', 'Router DNS forwarders and resolver settings updated successfully', 'success');
+      loadDNSSettings();
+    } else {
+      showToast('Error', data.error || data.message || 'Failed to save DNS settings', 'error');
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function flushDNSCache() {
+  try {
+    const res = await fetch(`${API_BASE}/dns/flush`, { method: 'POST' });
+    if (res.ok) {
+      showToast('Cache Flushed', 'Router DNS cache cleared and static hosts reloaded', 'success');
+      loadDNSSettings();
+    } else {
+      const err = await res.json();
+      showToast('Error', err.error || 'Failed to flush cache', 'error');
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+function renderStaticDNSList() {
+  const tbody = document.getElementById('dns-static-tbody');
+  const empty = document.getElementById('dns-static-empty');
+  const badge = document.getElementById('dns-static-count-badge');
+  if (!tbody) return;
+  tbody.innerHTML = '';
+
+  const records = state.dnsStaticRecords || [];
+  if (badge) badge.innerText = `${records.length} Records`;
+
+  if (records.length === 0) {
+    empty?.classList.remove('hidden');
+    return;
+  }
+  empty?.classList.add('hidden');
+
+  records.forEach(rec => {
+    const tr = document.createElement('tr');
+    tr.className = `hover:bg-slate-800/40 transition text-xs ${rec.disabled ? 'opacity-50' : ''}`;
+
+    tr.innerHTML = `
+      <td class="py-2.5 px-4 font-bold text-white">${escapeHtml(rec.name)}</td>
+      <td class="py-2.5 px-4">
+        <span class="px-2 py-0.5 rounded text-[10px] font-bold ${
+          rec.type === 'CNAME' ? 'bg-purple-950 text-purple-300 border border-purple-800' :
+          (rec.type === 'AAAA' ? 'bg-blue-950 text-blue-300 border border-blue-800' : 'bg-cyan-950 text-cyan-300 border border-cyan-800')
+        }">
+          ${rec.type || 'A'}
+        </span>
+      </td>
+      <td class="py-2.5 px-4 text-emerald-400 font-mono">${escapeHtml(rec.address)}</td>
+      <td class="py-2.5 px-4 text-slate-400 font-mono">${rec.ttl || 3600}s</td>
+      <td class="py-2.5 px-4 text-slate-400 italic truncate max-w-[140px]">${escapeHtml(rec.comment || '-')}</td>
+      <td class="py-2.5 px-4">
+        <span class="px-1.5 py-0.5 rounded text-[10px] font-semibold ${rec.disabled ? 'bg-slate-800 text-slate-500' : 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/30'}">
+          ${rec.disabled ? 'Disabled' : 'Active'}
+        </span>
+      </td>
+      <td class="py-2.5 px-4 text-right space-x-2 font-sans whitespace-nowrap">
+        <button onclick="toggleStaticDNS('${rec.id}', ${!rec.disabled})" class="text-xs ${rec.disabled ? 'text-emerald-400' : 'text-slate-400'} hover:underline">${rec.disabled ? 'Enable' : 'Disable'}</button>
+        <button onclick="openStaticDNSModal('${rec.id}')" class="text-xs text-cyan-400 hover:text-cyan-300 font-semibold">Edit</button>
+        <button onclick="deleteStaticDNS('${rec.id}')" class="text-xs text-slate-500 hover:text-rose-400">Delete</button>
+      </td>
+    `;
+    tbody.appendChild(tr);
+  });
+}
+
+function openStaticDNSModal(id = null) {
+  const modal = document.getElementById('modal-static-dns');
+  const title = document.getElementById('modal-static-dns-title');
+  const form = document.getElementById('form-static-dns');
+  if (!modal || !form) return;
+
+  form.reset();
+  document.getElementById('modal-static-dns-id').value = '';
+
+  if (id) {
+    const rec = (state.dnsStaticRecords || []).find(r => r.id === id);
+    if (rec) {
+      if (title) title.innerText = 'Edit Static DNS Record';
+      document.getElementById('modal-static-dns-id').value = rec.id;
+      document.getElementById('modal-static-dns-name').value = rec.name;
+      document.getElementById('modal-static-dns-type').value = rec.type || 'A';
+      document.getElementById('modal-static-dns-addr').value = rec.address;
+      document.getElementById('modal-static-dns-ttl').value = rec.ttl || 3600;
+      document.getElementById('modal-static-dns-comment').value = rec.comment || '';
+      document.getElementById('modal-static-dns-disabled').checked = !!rec.disabled;
+    }
+  } else {
+    if (title) title.innerText = 'Add Static DNS Record';
+  }
+
+  modal.classList.remove('hidden');
+}
+
+function closeStaticDNSModal() {
+  document.getElementById('modal-static-dns')?.classList.add('hidden');
+}
+
+async function submitSaveStaticDNS(e) {
+  e.preventDefault();
+  const id = document.getElementById('modal-static-dns-id')?.value || '';
+  const name = (document.getElementById('modal-static-dns-name')?.value || '').trim();
+  const type = document.getElementById('modal-static-dns-type')?.value || 'A';
+  const address = (document.getElementById('modal-static-dns-addr')?.value || '').trim();
+  const ttl = parseInt(document.getElementById('modal-static-dns-ttl')?.value || '3600', 10);
+  const comment = (document.getElementById('modal-static-dns-comment')?.value || '').trim();
+  const disabled = document.getElementById('modal-static-dns-disabled')?.checked ?? false;
+
+  if (!name || !address) {
+    showToast('Validation Error', 'Name and Target Address are required', 'error');
+    return;
+  }
+
+  try {
+    const res = await fetch(`${API_BASE}/dns/static`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ id, name, type, address, ttl, comment, disabled })
+    });
+    const data = await res.json();
+    if (res.ok) {
+      showToast('Static DNS Saved', `Saved record ${name} -> ${address}`, 'success');
+      closeStaticDNSModal();
+      loadDNSSettings();
+    } else {
+      showToast('Error', data.error || data.message || 'Failed to save record', 'error');
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function deleteStaticDNS(id) {
+  if (!confirm('Are you sure you want to delete this static DNS entry?')) return;
+  try {
+    const res = await fetch(`${API_BASE}/dns/static/${encodeURIComponent(id)}`, { method: 'DELETE' });
+    if (res.ok) {
+      showToast('Deleted', 'Static DNS record deleted', 'success');
+      loadDNSSettings();
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function toggleStaticDNS(id, disabled) {
+  try {
+    const res = await fetch(`${API_BASE}/dns/static/${encodeURIComponent(id)}/state`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ disabled })
+    });
+    if (res.ok) {
+      showToast('Updated', `Record ${disabled ? 'disabled' : 'enabled'}`, 'success');
+      loadDNSSettings();
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function loadSystemSettings() {
+  try {
+    const res = await fetch(`${API_BASE}/system/settings`);
+    if (res.ok) {
+      const data = await res.json();
+      const hostInput = document.getElementById('system-hostname-input');
+      if (hostInput) hostInput.value = data.hostname || '';
+
+      const tzInput = document.getElementById('system-timezone-input');
+      if (tzInput && data.timezone) tzInput.value = data.timezone;
+    }
+  } catch (err) {
+    console.error('Failed to load system settings:', err);
+  }
+}
+
+async function saveSystemSettings(e) {
+  e.preventDefault();
+  const hostname = (document.getElementById('system-hostname-input')?.value || '').trim();
+  const timezone = document.getElementById('system-timezone-input')?.value || 'UTC';
+
+  try {
+    const res = await fetch(`${API_BASE}/system/settings`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ hostname, timezone })
+    });
+    const data = await res.json();
+    if (res.ok) {
+      showToast('System Updated', 'Hostname and timezone saved successfully', 'success');
+      const sideH = document.getElementById('side-hostname');
+      if (sideH) sideH.innerText = data.hostname;
+    } else {
+      showToast('Error', data.error || 'Failed to save system settings', 'error');
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
 
 

+ 247 - 0
internal/web/static/index.html

@@ -353,6 +353,18 @@
           <span class="text-[10px] bg-slate-800 text-slate-400 px-1.5 py-0.2 rounded">.cfg</span>
         </a>
 
+        <a href="#settings" onclick="navigate('settings')" id="nav-settings"
+           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-cyan-400 group-hover:rotate-45 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
+              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
+            </svg>
+            <span>Settings</span>
+          </div>
+          <span class="text-[10px] bg-cyan-950 text-cyan-400 border border-cyan-800/60 px-1.5 py-0.2 rounded font-mono">DNS</span>
+        </a>
+
       </nav>
 
       <!-- Sidebar Footer: Host System Metrics -->
@@ -4099,10 +4111,245 @@
           </div>
         </section>
 
+        <!-- ================= SECTION: SETTINGS ================= -->
+        <section id="section-settings" class="hidden space-y-6">
+          <!-- Settings Sub-Navigation Tabs -->
+          <div class="flex items-center justify-between border-b border-slate-800 pb-3 flex-wrap gap-3">
+            <div class="flex space-x-2">
+              <button onclick="switchSettingsSubTab('dns')" id="btn-settings-subtab-dns"
+                      class="px-3.5 py-1.5 rounded-lg text-xs font-semibold bg-cyan-600 text-white transition flex items-center gap-1.5">
+                <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/></svg>
+                <span>DNS Settings</span>
+              </button>
+              <button onclick="switchSettingsSubTab('system')" id="btn-settings-subtab-system"
+                      class="px-3.5 py-1.5 rounded-lg text-xs font-semibold bg-slate-800 text-slate-300 hover:text-white transition flex items-center gap-1.5">
+                <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
+                <span>System & Hostname</span>
+              </button>
+            </div>
+            <div class="flex items-center gap-2">
+              <span id="dns-live-status-pill" class="px-2.5 py-1 rounded-full text-[11px] font-mono bg-emerald-500/10 text-emerald-400 border border-emerald-500/30 flex items-center gap-1.5">
+                <span class="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></span>
+                <span id="dns-live-status-text">DNS Service Active</span>
+              </span>
+            </div>
+          </div>
+
+          <!-- Sub-Tab 1: DNS Settings -->
+          <div id="settings-subtab-dns" class="space-y-6">
+            <!-- DNS Forwarder Config Card -->
+            <div class="bg-slate-900/90 border border-slate-800 rounded-xl p-5 shadow-lg">
+              <div class="flex items-center justify-between pb-4 mb-4 border-b border-slate-800 flex-wrap gap-2">
+                <div>
+                  <h3 class="text-sm font-bold text-white flex items-center gap-2">
+                    <span>Router DNS & Upstream Resolvers</span>
+                  </h3>
+                  <p class="text-xs text-slate-400 mt-0.5">Configure system recursive DNS forwarders, caching, and LAN client request resolution.</p>
+                </div>
+                <div class="flex items-center gap-2">
+                  <button type="button" onclick="flushDNSCache()" class="px-3 py-1.5 rounded-lg text-xs font-semibold bg-slate-800 hover:bg-slate-700 text-slate-300 hover:text-white border border-slate-700 transition flex items-center gap-1.5">
+                    <svg class="w-3.5 h-3.5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
+                    <span>Flush Cache</span>
+                  </button>
+                </div>
+              </div>
+
+              <form id="form-dns-settings" onsubmit="saveDNSSettings(event)" class="space-y-4">
+                <!-- Upstream DNS Servers -->
+                <div>
+                  <label class="block text-xs font-semibold text-slate-300 mb-1.5">Upstream DNS Servers (Comma or space separated) *</label>
+                  <div class="flex gap-2">
+                    <input type="text" id="dns-servers-input" required placeholder="e.g. 1.1.1.1, 8.8.8.8, 9.9.9.9" class="flex-1 bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-cyan-500">
+                  </div>
+                  <!-- Quick Preset Tags -->
+                  <div class="flex items-center gap-1.5 mt-2 flex-wrap">
+                    <span class="text-[11px] text-slate-500 font-sans">Quick Presets:</span>
+                    <button type="button" onclick="appendDNSPreset('1.1.1.1, 1.0.0.1')" class="px-2 py-0.5 rounded text-[10px] font-mono bg-slate-800 hover:bg-cyan-950 hover:text-cyan-400 text-slate-400 border border-slate-700/80 transition">+ Cloudflare (1.1.1.1)</button>
+                    <button type="button" onclick="appendDNSPreset('8.8.8.8, 8.8.4.4')" class="px-2 py-0.5 rounded text-[10px] font-mono bg-slate-800 hover:bg-cyan-950 hover:text-cyan-400 text-slate-400 border border-slate-700/80 transition">+ Google (8.8.8.8)</button>
+                    <button type="button" onclick="appendDNSPreset('9.9.9.9, 149.112.112.112')" class="px-2 py-0.5 rounded text-[10px] font-mono bg-slate-800 hover:bg-cyan-950 hover:text-cyan-400 text-slate-400 border border-slate-700/80 transition">+ Quad9 (9.9.9.9)</button>
+                    <button type="button" onclick="appendDNSPreset('208.67.222.222, 208.67.220.220')" class="px-2 py-0.5 rounded text-[10px] font-mono bg-slate-800 hover:bg-cyan-950 hover:text-cyan-400 text-slate-400 border border-slate-700/80 transition">+ OpenDNS</button>
+                  </div>
+                </div>
+
+                <!-- Dynamic Servers badge display -->
+                <div id="dns-dynamic-servers-container" class="p-3 bg-slate-950/60 rounded-xl border border-slate-800 flex items-center justify-between flex-wrap gap-2">
+                  <div class="flex items-center gap-2">
+                    <span class="text-xs font-semibold text-slate-400 font-sans">Dynamic Servers (Learned from DHCP Client):</span>
+                    <span id="dns-dynamic-servers-list" class="text-xs font-mono text-cyan-400 font-bold">None</span>
+                  </div>
+                  <span class="text-[10px] text-slate-500">Auto-injected into resolver forwarders</span>
+                </div>
+
+                <div class="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
+                  <div>
+                    <label class="block text-xs font-semibold text-slate-300 mb-1.5">Cache Size (Entries)</label>
+                    <input type="number" id="dns-cache-size-input" min="150" max="50000" value="2048" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-cyan-500">
+                    <span class="text-[10px] text-slate-500 mt-1 block">Maximum cached records (Default: 2048)</span>
+                  </div>
+                  <div>
+                    <label class="block text-xs font-semibold text-slate-300 mb-1.5">Local Domain</label>
+                    <input type="text" id="dns-domain-input" placeholder="e.g. home.lan" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-cyan-500">
+                    <span class="text-[10px] text-slate-500 mt-1 block">Appended to unqualified host queries</span>
+                  </div>
+                  <div>
+                    <label class="block text-xs font-semibold text-slate-300 mb-1.5">Allow Remote Requests</label>
+                    <div class="flex items-center space-x-2 mt-2">
+                      <input type="checkbox" id="dns-allow-remote-input" checked class="rounded bg-slate-800 border-slate-700 text-cyan-600 focus:ring-0">
+                      <label for="dns-allow-remote-input" class="text-xs text-slate-300 cursor-pointer">Serve DNS to LAN clients (Port 53)</label>
+                    </div>
+                    <span class="text-[10px] text-slate-500 mt-1 block">When enabled, clients use this router as primary DNS</span>
+                  </div>
+                </div>
+
+                <div class="pt-3 border-t border-slate-800 flex justify-end">
+                  <button type="submit" class="px-5 py-2 rounded-lg text-xs font-semibold bg-cyan-600 hover:bg-cyan-500 text-white shadow-lg 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="M5 13l4 4L19 7"/></svg>
+                    <span>Apply DNS Settings</span>
+                  </button>
+                </div>
+              </form>
+            </div>
+
+            <!-- Static DNS Table (/ip dns static) -->
+            <div class="bg-slate-900/90 border border-slate-800 rounded-xl overflow-hidden shadow-lg">
+              <div class="p-4 border-b border-slate-800 flex items-center justify-between flex-wrap gap-2">
+                <div>
+                  <h3 class="text-sm font-bold text-white flex items-center gap-2">
+                    <span>Static DNS Records</span>
+                    <span id="dns-static-count-badge" class="text-xs font-mono text-cyan-400 bg-cyan-950 border border-cyan-800/60 px-2 py-0.5 rounded-full">0 Records</span>
+                  </h3>
+                  <p class="text-xs text-slate-400 mt-0.5">Authoritative local hostname-to-IP overrides and local CNAME aliases.</p>
+                </div>
+                <button type="button" onclick="openStaticDNSModal()" class="px-3 py-1.5 rounded-lg text-xs font-semibold bg-cyan-600 hover:bg-cyan-500 text-white transition flex items-center gap-1.5">
+                  <svg class="w-3.5 h-3.5" 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 Static DNS</span>
+                </button>
+              </div>
+
+              <div class="overflow-x-auto">
+                <table class="w-full text-left text-xs font-mono">
+                  <thead class="text-slate-400 bg-slate-950/60 border-b border-slate-800 uppercase tracking-wider text-[10px] font-sans">
+                    <tr>
+                      <th class="py-2.5 px-4">Name (Hostname)</th>
+                      <th class="py-2.5 px-4">Type</th>
+                      <th class="py-2.5 px-4">Address / Target</th>
+                      <th class="py-2.5 px-4">TTL</th>
+                      <th class="py-2.5 px-4">Comment</th>
+                      <th class="py-2.5 px-4">Status</th>
+                      <th class="py-2.5 px-4 text-right font-sans">Actions</th>
+                    </tr>
+                  </thead>
+                  <tbody id="dns-static-tbody" class="divide-y divide-slate-800/80"></tbody>
+                </table>
+              </div>
+              <div id="dns-static-empty" class="hidden p-8 text-center text-slate-500 text-xs font-sans">
+                No static DNS records configured yet. Click "+ Add Static DNS" to add local hostname mappings.
+              </div>
+            </div>
+
+            <!-- System Resolv.conf Preview -->
+            <div class="bg-slate-900/90 border border-slate-800 rounded-xl p-4 shadow">
+              <div class="flex items-center justify-between mb-2">
+                <span class="text-xs font-bold text-slate-300 font-sans">Active Host Resolver Configuration (/etc/resolv.conf)</span>
+                <span class="text-[10px] text-slate-500 font-mono">System Host View</span>
+              </div>
+              <pre id="dns-resolv-preview" class="p-3 bg-slate-950 rounded-lg text-slate-400 font-mono text-[11px] overflow-x-auto">Loading...</pre>
+            </div>
+          </div>
+
+          <!-- Sub-Tab 2: System & Hostname -->
+          <div id="settings-subtab-system" class="hidden space-y-6">
+            <div class="bg-slate-900/90 border border-slate-800 rounded-xl p-5 shadow-lg">
+              <div class="pb-4 mb-4 border-b border-slate-800">
+                <h3 class="text-sm font-bold text-white">System Identity & Hostname</h3>
+                <p class="text-xs text-slate-400 mt-0.5">Set the router appliance identity and timezone.</p>
+              </div>
+
+              <form id="form-system-settings" onsubmit="saveSystemSettings(event)" class="space-y-4 max-w-xl">
+                <div>
+                  <label class="block text-xs font-semibold text-slate-300 mb-1.5">Router Identity / Hostname *</label>
+                  <input type="text" id="system-hostname-input" required class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-cyan-500">
+                </div>
+                <div>
+                  <label class="block text-xs font-semibold text-slate-300 mb-1.5">System Timezone</label>
+                  <select id="system-timezone-input" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-cyan-500">
+                    <option value="Europe/Athens">Europe/Athens (UTC+2 / UTC+3 EEST)</option>
+                    <option value="Etc/UTC">Etc/UTC (Coordinated Universal Time)</option>
+                    <option value="Europe/London">Europe/London (GMT/BST)</option>
+                    <option value="Europe/Berlin">Europe/Berlin (CET/CEST)</option>
+                    <option value="America/New_York">America/New_York (EST/EDT)</option>
+                    <option value="America/Los_Angeles">America/Los_Angeles (PST/PDT)</option>
+                    <option value="Asia/Tokyo">Asia/Tokyo (JST)</option>
+                  </select>
+                </div>
+                <div class="pt-3">
+                  <button type="submit" class="px-5 py-2 rounded-lg text-xs font-semibold bg-cyan-600 hover:bg-cyan-500 text-white transition">
+                    Save System Settings
+                  </button>
+                </div>
+              </form>
+            </div>
+          </div>
+        </section>
+
       </main>
     </div>
   </div>
 
+  <!-- ================= MODAL: STATIC DNS RECORD ================= -->
+  <div id="modal-static-dns" 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">
+        <h3 id="modal-static-dns-title" class="text-sm font-bold text-white">Add Static DNS Record</h3>
+        <button onclick="closeStaticDNSModal()" class="text-slate-400 hover:text-white p-1 rounded-lg">✕</button>
+      </div>
+      <form id="form-static-dns" onsubmit="submitSaveStaticDNS(event)" class="p-6 space-y-4">
+        <input type="hidden" id="modal-static-dns-id">
+
+        <div>
+          <label class="block text-xs font-semibold text-slate-300 mb-1.5">DNS Name (FQDN or Hostname) *</label>
+          <input type="text" id="modal-static-dns-name" placeholder="e.g. router.lan or nas.home" required class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-cyan-500">
+        </div>
+
+        <div class="grid grid-cols-2 gap-4">
+          <div>
+            <label class="block text-xs font-semibold text-slate-300 mb-1.5">Record Type</label>
+            <select id="modal-static-dns-type" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-cyan-500">
+              <option value="A">A (IPv4 Address)</option>
+              <option value="AAAA">AAAA (IPv6 Address)</option>
+              <option value="CNAME">CNAME (Alias)</option>
+              <option value="TXT">TXT (Text record)</option>
+            </select>
+          </div>
+          <div>
+            <label class="block text-xs font-semibold text-slate-300 mb-1.5">TTL (Seconds)</label>
+            <input type="number" id="modal-static-dns-ttl" min="0" value="3600" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-cyan-500">
+          </div>
+        </div>
+
+        <div>
+          <label class="block text-xs font-semibold text-slate-300 mb-1.5">Target IP / Canonical Name *</label>
+          <input type="text" id="modal-static-dns-addr" placeholder="e.g. 192.168.130.1 or router.lan" required class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-cyan-500">
+        </div>
+
+        <div>
+          <label class="block text-xs font-semibold text-slate-300 mb-1.5">Comment</label>
+          <input type="text" id="modal-static-dns-comment" placeholder="Optional description" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white focus:outline-none focus:border-cyan-500">
+        </div>
+
+        <div class="flex items-center space-x-2 pt-1">
+          <input type="checkbox" id="modal-static-dns-disabled" class="rounded bg-slate-800 border-slate-700 text-rose-500 focus:ring-0">
+          <label for="modal-static-dns-disabled" class="text-xs text-slate-300 cursor-pointer">Disabled (Commented out)</label>
+        </div>
+
+        <div class="pt-4 border-t border-slate-800 flex justify-end space-x-3">
+          <button type="button" onclick="closeStaticDNSModal()" class="px-4 py-2 rounded-lg text-xs font-medium text-slate-400 hover:bg-slate-800">Cancel</button>
+          <button type="submit" class="px-5 py-2 rounded-lg text-xs font-semibold bg-cyan-600 hover:bg-cyan-500 text-white">Save Record</button>
+        </div>
+      </form>
+    </div>
+  </div>
+
   <!-- ================= MODAL: CREATE VLAN ================= -->
   <div id="vlan-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">