فهرست منبع

fix: resolve dhcp route flaps, scoped nat masquerade, auth race condition and persistence bugs

Proxmox Router 6 روز پیش
والد
کامیت
992eaa3fd2

BIN
bin/proxmox-router


+ 24 - 2
internal/auth/manager.go

@@ -135,7 +135,8 @@ func (m *Manager) initDefaultAdminLocked() error {
 }
 
 func (m *Manager) saveLocked() error {
-	if err := os.MkdirAll(filepath.Dir(m.filePath), 0755); err != nil {
+	dir := filepath.Dir(m.filePath)
+	if err := os.MkdirAll(dir, 0755); err != nil {
 		return err
 	}
 
@@ -149,7 +150,12 @@ func (m *Manager) saveLocked() error {
 		return err
 	}
 
-	return os.WriteFile(m.filePath, data, 0600)
+	tmpFile := filepath.Join(dir, fmt.Sprintf(".users.%d.tmp", time.Now().UnixNano()))
+	if err := os.WriteFile(tmpFile, data, 0600); err != nil {
+		return err
+	}
+
+	return os.Rename(tmpFile, m.filePath)
 }
 
 func (m *Manager) Authenticate(username, password string) (*Session, error) {
@@ -435,6 +441,22 @@ func (m *Manager) cleanupWorker() {
 				delete(m.sessions, token)
 			}
 		}
+
+		// Clean up expired tokens from userSession tracking map to prevent memory leak
+		for username, tokens := range m.userSession {
+			var activeTokens []string
+			for _, t := range tokens {
+				if _, ok := m.sessions[t]; ok {
+					activeTokens = append(activeTokens, t)
+				}
+			}
+			if len(activeTokens) == 0 {
+				delete(m.userSession, username)
+			} else {
+				m.userSession[username] = activeTokens
+			}
+		}
+
 		m.mu.Unlock()
 	}
 }

+ 28 - 10
internal/dhcp/manager.go

@@ -89,6 +89,7 @@ func (m *Manager) Load() error {
 		}
 		m.clients[c.ID] = c
 	}
+	m.syncDHCPHooksLocked()
 
 	// Startup active clients and run periodic background sync
 	go func() {
@@ -406,6 +407,30 @@ func (m *Manager) RenameInterface(oldName, newName string) {
 	}
 }
 
+func (m *Manager) syncDHCPHooksLocked() {
+	_ = os.MkdirAll("/etc/dhcp/dhclient-enter-hooks.d", 0755)
+	_ = os.MkdirAll("/etc/dhcp/dhclient-exit-hooks.d", 0755)
+
+	// Remove dangerous legacy exit hook that previously deleted metric 0 routes matching any metric
+	_ = os.Remove("/etc/dhcp/dhclient-exit-hooks.d/no-default-route")
+
+	var noDefaultIfaces []string
+	for _, client := range m.clients {
+		if client.Enabled && !client.AddDefaultRoute && client.Interface != "" {
+			noDefaultIfaces = append(noDefaultIfaces, client.Interface)
+		}
+	}
+
+	hookPath := "/etc/dhcp/dhclient-enter-hooks.d/no-default-route"
+	if len(noDefaultIfaces) > 0 {
+		pattern := strings.Join(noDefaultIfaces, "|")
+		enterHook := fmt.Sprintf("#!/bin/sh\ncase \"$interface\" in\n    %s)\n        unset new_routers\n        ;;\nesac\n", pattern)
+		_ = os.WriteFile(hookPath, []byte(enterHook), 0755)
+	} else {
+		_ = os.Remove(hookPath)
+	}
+}
+
 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)
@@ -429,16 +454,8 @@ func (m *Manager) startClient(c *DHCPClient) error {
 
 	_ = os.WriteFile(confFile, []byte(sb.String()), 0644)
 
-	// Ensure enter & exit hooks prevent dhclient from overriding static routes
-	_ = os.MkdirAll("/etc/dhcp/dhclient-enter-hooks.d", 0755)
-	_ = os.MkdirAll("/etc/dhcp/dhclient-exit-hooks.d", 0755)
-	if !c.AddDefaultRoute {
-		enterHook := fmt.Sprintf("#!/bin/sh\ncase \"$interface\" in\n    %s)\n        unset new_routers\n        ;;\nesac\n", c.Interface)
-		_ = os.WriteFile("/etc/dhcp/dhclient-enter-hooks.d/no-default-route", []byte(enterHook), 0755)
-
-		exitHook := fmt.Sprintf("#!/bin/sh\ncase \"$interface\" in\n    %s)\n        ip -4 route del default dev %s metric 0 >/dev/null 2>&1 || true\n        ;;\nesac\n", c.Interface, c.Interface)
-		_ = os.WriteFile("/etc/dhcp/dhclient-exit-hooks.d/no-default-route", []byte(exitHook), 0755)
-	}
+	// Ensure enter hooks prevent dhclient from overriding static routes for non-default clients
+	m.syncDHCPHooksLocked()
 
 	// Dynamic host route hook for DHCP server reachability (Distance 0, Dynamic)
 	serverRouteHook := fmt.Sprintf(`#!/bin/sh
@@ -488,6 +505,7 @@ func (m *Manager) stopClient(c *DHCPClient) error {
 
 	_ = os.Remove(pidFile)
 	c.PID = 0
+	m.syncDHCPHooksLocked()
 	return nil
 }
 

+ 284 - 178
internal/firewall/iptables.go

@@ -3,9 +3,12 @@ package firewall
 import (
 	"bufio"
 	"bytes"
+	"encoding/json"
 	"fmt"
 	"hash/fnv"
+	"os"
 	"os/exec"
+	"sort"
 	"strconv"
 	"strings"
 )
@@ -63,7 +66,8 @@ func ReadRuleCounters(filterRules []FilterRule, natRules []NATRule, mangleRules
 			endQuote := strings.Index(sub, "\"")
 			if endQuote != -1 {
 				ruleID := sub[:endQuote]
-				counters[ruleID] = [2]uint64{pkts, bts}
+				existing := counters[ruleID]
+				counters[ruleID] = [2]uint64{existing[0] + pkts, existing[1] + bts}
 			}
 		}
 	}
@@ -71,6 +75,69 @@ func ReadRuleCounters(filterRules []FilterRule, natRules []NATRule, mangleRules
 	return counters, nil
 }
 
+// resolveInterfaceList resolves an interface list or role name (e.g. "WAN", "LAN") to interface names.
+func resolveInterfaceList(listName string) []string {
+	listName = strings.TrimSpace(listName)
+	if listName == "" {
+		return nil
+	}
+	data, err := os.ReadFile("/etc/proxmox-router/metadata.json")
+	if err != nil {
+		return nil
+	}
+	var meta struct {
+		Interfaces map[string]struct {
+			Role string `json:"role"`
+		} `json:"interfaces"`
+	}
+	if err := json.Unmarshal(data, &meta); err != nil {
+		return nil
+	}
+	var matched []string
+	for ifaceName, info := range meta.Interfaces {
+		if strings.EqualFold(info.Role, listName) {
+			matched = append(matched, ifaceName)
+		}
+	}
+	sort.Strings(matched)
+	return matched
+}
+
+func getInterfaceCombinations(inIface, inList, outIface, outList string) [][2]string {
+	var inListResolved []string
+	if inIface != "" {
+		inListResolved = []string{inIface}
+	} else if inList != "" {
+		inListResolved = resolveInterfaceList(inList)
+	} else {
+		inListResolved = []string{""}
+	}
+
+	var outListResolved []string
+	if outIface != "" {
+		outListResolved = []string{outIface}
+	} else if outList != "" {
+		outListResolved = resolveInterfaceList(outList)
+	} else {
+		outListResolved = []string{""}
+	}
+
+	if inList != "" && inIface == "" && len(inListResolved) == 0 {
+		return nil
+	}
+	if outList != "" && outIface == "" && len(outListResolved) == 0 {
+		return nil
+	}
+
+	var combos [][2]string
+	for _, in := range inListResolved {
+		for _, out := range outListResolved {
+			combos = append(combos, [2]string{in, out})
+		}
+	}
+	return combos
+}
+
 // BuildIPTablesRestore generates the atomic configuration for `iptables-restore`
 func BuildIPTablesRestore(filterRules []FilterRule, natRules []NATRule, mangleRules []MangleRule) string {
 	var sb strings.Builder
@@ -106,9 +173,10 @@ func BuildIPTablesRestore(filterRules []FilterRule, natRules []NATRule, mangleRu
 		if r.Disabled {
 			continue
 		}
-		ruleStr := buildMangleRuleLine(r)
-		if ruleStr != "" {
-			sb.WriteString(ruleStr + "\n")
+		for _, line := range buildMangleRuleLines(r) {
+			if line != "" {
+				sb.WriteString(line + "\n")
+			}
 		}
 	}
 	sb.WriteString("COMMIT\n")
@@ -142,9 +210,10 @@ func BuildIPTablesRestore(filterRules []FilterRule, natRules []NATRule, mangleRu
 		if r.Disabled {
 			continue
 		}
-		ruleStr := buildNATRuleLine(r)
-		if ruleStr != "" {
-			sb.WriteString(ruleStr + "\n")
+		for _, line := range buildNATRuleLines(r) {
+			if line != "" {
+				sb.WriteString(line + "\n")
+			}
 		}
 	}
 	sb.WriteString("COMMIT\n")
@@ -177,9 +246,10 @@ func BuildIPTablesRestore(filterRules []FilterRule, natRules []NATRule, mangleRu
 		if r.Disabled {
 			continue
 		}
-		ruleStr := buildFilterRuleLine(r)
-		if ruleStr != "" {
-			sb.WriteString(ruleStr + "\n")
+		for _, line := range buildFilterRuleLines(r) {
+			if line != "" {
+				sb.WriteString(line + "\n")
+			}
 		}
 	}
 	sb.WriteString("COMMIT\n")
@@ -187,63 +257,80 @@ func BuildIPTablesRestore(filterRules []FilterRule, natRules []NATRule, mangleRu
 	return sb.String()
 }
 
-func buildFilterRuleLine(r FilterRule) string {
+func buildFilterRuleLines(r FilterRule) []string {
+	combos := getInterfaceCombinations(r.InInterface, r.InInterfaceList, r.OutInterface, r.OutInterfaceList)
+	if len(combos) == 0 {
+		return nil
+	}
+
 	chain := strings.ToUpper(r.Chain)
 	if chain == "" {
 		chain = "FORWARD"
 	}
 
-	var args []string
-	args = append(args, fmt.Sprintf("-A %s", chain))
+	var lines []string
+	for _, combo := range combos {
+		inIface, outIface := combo[0], combo[1]
 
-	// Match criteria
-	args = appendMatchCriteria(args, r.SrcAddress, r.DstAddress, r.Protocol, r.SrcPort, r.DstPort, r.InInterface, r.OutInterface, r.ConnectionState, r.ConnectionMark, r.RoutingMark, r.SrcAddressList, r.DstAddressList, r.SrcGeoIP, r.DstGeoIP)
+		var args []string
+		args = append(args, fmt.Sprintf("-A %s", chain))
 
-	// Comment ID for counters mapping & user comment
-	args = append(args, fmt.Sprintf("-m comment --comment \"id:%s\"", r.ID))
+		// Match criteria
+		args = appendMatchCriteria(args, r.SrcAddress, r.DstAddress, r.Protocol, r.SrcPort, r.DstPort, inIface, outIface, r.ConnectionState, r.ConnectionMark, r.RoutingMark, r.SrcAddressList, r.DstAddressList, r.SrcGeoIP, r.DstGeoIP)
 
-	// Action
-	act := strings.ToLower(r.Action)
-	switch act {
-	case "accept":
-		args = append(args, "-j ACCEPT")
-	case "drop":
-		args = append(args, "-j DROP")
-	case "reject":
-		args = append(args, "-j REJECT")
-		if r.RejectWith != "" {
-			args = append(args, fmt.Sprintf("--reject-with %s", r.RejectWith))
-		}
-	case "return":
-		args = append(args, "-j RETURN")
-	case "jump":
-		if r.JumpTarget != "" {
-			args = append(args, fmt.Sprintf("-j %s", strings.ToUpper(r.JumpTarget)))
-		}
-	case "log":
-		args = append(args, "-j LOG")
-		if r.LogPrefix != "" {
-			args = append(args, fmt.Sprintf("--log-prefix \"%s: \"", r.LogPrefix))
-		}
-	case "add-src-to-address-list":
-		if r.AddressList != "" {
-			args = append(args, fmt.Sprintf("-j SET --add-set %s src", GetIPSetName(r.AddressList)))
-		}
-	case "add-dst-to-address-list":
-		if r.AddressList != "" {
-			args = append(args, fmt.Sprintf("-j SET --add-set %s dst", GetIPSetName(r.AddressList)))
+		// Comment ID for counters mapping & user comment
+		args = append(args, fmt.Sprintf("-m comment --comment \"id:%s\"", r.ID))
+
+		// Action
+		act := strings.ToLower(r.Action)
+		switch act {
+		case "accept":
+			args = append(args, "-j ACCEPT")
+		case "drop":
+			args = append(args, "-j DROP")
+		case "reject":
+			args = append(args, "-j REJECT")
+			if r.RejectWith != "" {
+				args = append(args, fmt.Sprintf("--reject-with %s", r.RejectWith))
+			}
+		case "return":
+			args = append(args, "-j RETURN")
+		case "jump":
+			if r.JumpTarget != "" {
+				args = append(args, fmt.Sprintf("-j %s", strings.ToUpper(r.JumpTarget)))
+			}
+		case "log":
+			args = append(args, "-j LOG")
+			if r.LogPrefix != "" {
+				args = append(args, fmt.Sprintf("--log-prefix \"%s: \"", r.LogPrefix))
+			}
+		case "add-src-to-address-list":
+			if r.AddressList != "" {
+				args = append(args, fmt.Sprintf("-j SET --add-set %s src", GetIPSetName(r.AddressList)))
+			}
+		case "add-dst-to-address-list":
+			if r.AddressList != "" {
+				args = append(args, fmt.Sprintf("-j SET --add-set %s dst", GetIPSetName(r.AddressList)))
+			}
+		case "passthrough":
+			// in iptables, passthrough continues evaluation
+			continue
+		default:
+			args = append(args, "-j ACCEPT")
 		}
-	case "passthrough":
-		// in iptables, passthrough continues evaluation
-		return ""
-	default:
-		args = append(args, "-j ACCEPT")
+
+		lines = append(lines, strings.Join(args, " "))
 	}
 
-	return strings.Join(args, " ")
+	return lines
 }
 
-func buildNATRuleLine(r NATRule) string {
+func buildNATRuleLines(r NATRule) []string {
+	combos := getInterfaceCombinations(r.InInterface, r.InInterfaceList, r.OutInterface, r.OutInterfaceList)
+	if len(combos) == 0 {
+		return nil
+	}
+
 	chain := strings.ToUpper(r.Chain)
 	if chain == "SRCNAT" {
 		chain = "POSTROUTING"
@@ -251,152 +338,171 @@ func buildNATRuleLine(r NATRule) string {
 		chain = "PREROUTING"
 	}
 
-	var args []string
-	args = append(args, fmt.Sprintf("-A %s", chain))
+	var lines []string
+	for _, combo := range combos {
+		inIface, outIface := combo[0], combo[1]
 
-	args = appendMatchCriteria(args, r.SrcAddress, r.DstAddress, r.Protocol, r.SrcPort, r.DstPort, r.InInterface, r.OutInterface, nil, "", "", r.SrcAddressList, r.DstAddressList, r.SrcGeoIP, r.DstGeoIP)
-	args = append(args, fmt.Sprintf("-m comment --comment \"id:%s\"", r.ID))
+		var args []string
+		args = append(args, fmt.Sprintf("-A %s", chain))
 
-	act := strings.ToLower(r.Action)
-	switch act {
-	case "masquerade":
-		args = append(args, "-j MASQUERADE")
-	case "src-nat":
-		if r.ToAddresses != "" {
-			target := r.ToAddresses
-			if r.ToPorts != "" {
-				target = fmt.Sprintf("%s:%s", r.ToAddresses, r.ToPorts)
-			}
-			args = append(args, fmt.Sprintf("-j SNAT --to-source %s", target))
-		} else {
+		args = appendMatchCriteria(args, r.SrcAddress, r.DstAddress, r.Protocol, r.SrcPort, r.DstPort, inIface, outIface, nil, "", "", r.SrcAddressList, r.DstAddressList, r.SrcGeoIP, r.DstGeoIP)
+		args = append(args, fmt.Sprintf("-m comment --comment \"id:%s\"", r.ID))
+
+		act := strings.ToLower(r.Action)
+		switch act {
+		case "masquerade":
 			args = append(args, "-j MASQUERADE")
-		}
-	case "dst-nat":
-		if r.ToAddresses != "" {
-			target := r.ToAddresses
+		case "src-nat":
+			if r.ToAddresses != "" {
+				target := r.ToAddresses
+				if r.ToPorts != "" {
+					target = fmt.Sprintf("%s:%s", r.ToAddresses, r.ToPorts)
+				}
+				args = append(args, fmt.Sprintf("-j SNAT --to-source %s", target))
+			} else {
+				args = append(args, "-j MASQUERADE")
+			}
+		case "dst-nat":
+			if r.ToAddresses != "" {
+				target := r.ToAddresses
+				if r.ToPorts != "" {
+					target = fmt.Sprintf("%s:%s", r.ToAddresses, r.ToPorts)
+				}
+				args = append(args, fmt.Sprintf("-j DNAT --to-destination %s", target))
+			}
+		case "redirect":
 			if r.ToPorts != "" {
-				target = fmt.Sprintf("%s:%s", r.ToAddresses, r.ToPorts)
+				args = append(args, fmt.Sprintf("-j REDIRECT --to-ports %s", r.ToPorts))
+			} else {
+				args = append(args, "-j REDIRECT")
 			}
-			args = append(args, fmt.Sprintf("-j DNAT --to-destination %s", target))
-		}
-	case "redirect":
-		if r.ToPorts != "" {
-			args = append(args, fmt.Sprintf("-j REDIRECT --to-ports %s", r.ToPorts))
-		} else {
-			args = append(args, "-j REDIRECT")
-		}
-	case "jump":
-		if r.JumpTarget != "" {
-			args = append(args, fmt.Sprintf("-j %s", strings.ToUpper(r.JumpTarget)))
-		}
-	case "log":
-		args = append(args, "-j LOG")
-		if r.LogPrefix != "" {
-			args = append(args, fmt.Sprintf("--log-prefix \"%s: \"", r.LogPrefix))
-		}
-	case "add-src-to-address-list":
-		if r.AddressList != "" {
-			args = append(args, fmt.Sprintf("-j SET --add-set %s src", GetIPSetName(r.AddressList)))
-		}
-	case "add-dst-to-address-list":
-		if r.AddressList != "" {
-			args = append(args, fmt.Sprintf("-j SET --add-set %s dst", GetIPSetName(r.AddressList)))
+		case "jump":
+			if r.JumpTarget != "" {
+				args = append(args, fmt.Sprintf("-j %s", strings.ToUpper(r.JumpTarget)))
+			}
+		case "log":
+			args = append(args, "-j LOG")
+			if r.LogPrefix != "" {
+				args = append(args, fmt.Sprintf("--log-prefix \"%s: \"", r.LogPrefix))
+			}
+		case "add-src-to-address-list":
+			if r.AddressList != "" {
+				args = append(args, fmt.Sprintf("-j SET --add-set %s src", GetIPSetName(r.AddressList)))
+			}
+		case "add-dst-to-address-list":
+			if r.AddressList != "" {
+				args = append(args, fmt.Sprintf("-j SET --add-set %s dst", GetIPSetName(r.AddressList)))
+			}
+		case "accept":
+			args = append(args, "-j ACCEPT")
+		case "return":
+			args = append(args, "-j RETURN")
+		default:
+			args = append(args, "-j MASQUERADE")
 		}
-	case "accept":
-		args = append(args, "-j ACCEPT")
-	case "return":
-		args = append(args, "-j RETURN")
-	default:
-		args = append(args, "-j MASQUERADE")
+
+		lines = append(lines, strings.Join(args, " "))
 	}
 
-	return strings.Join(args, " ")
+	return lines
 }
 
-func buildMangleRuleLine(r MangleRule) string {
+func buildMangleRuleLines(r MangleRule) []string {
+	combos := getInterfaceCombinations(r.InInterface, r.InInterfaceList, r.OutInterface, r.OutInterfaceList)
+	if len(combos) == 0 {
+		return nil
+	}
+
 	chain := strings.ToUpper(r.Chain)
 	if chain == "" {
 		chain = "PREROUTING"
 	}
 
-	var args []string
-	args = append(args, fmt.Sprintf("-A %s", chain))
+	var lines []string
+	for _, combo := range combos {
+		inIface, outIface := combo[0], combo[1]
 
-	args = appendMatchCriteria(args, r.SrcAddress, r.DstAddress, r.Protocol, r.SrcPort, r.DstPort, r.InInterface, r.OutInterface, r.ConnectionState, r.ConnectionMark, r.RoutingMark, r.SrcAddressList, r.DstAddressList, r.SrcGeoIP, r.DstGeoIP)
-	args = append(args, fmt.Sprintf("-m comment --comment \"id:%s\"", r.ID))
+		var args []string
+		args = append(args, fmt.Sprintf("-A %s", chain))
 
-	act := strings.ToLower(r.Action)
-	switch act {
-	case "mark-connection":
-		if r.NewConnectionMark != "" {
-			markID := MarkNameToID(r.NewConnectionMark)
-			args = append(args, fmt.Sprintf("-j CONNMARK --set-mark %d", markID))
-		}
-	case "mark-routing":
-		if r.NewRoutingMark != "" {
-			markID := MarkNameToID(r.NewRoutingMark)
-			args = append(args, fmt.Sprintf("-j MARK --set-mark %d", markID))
-		}
-	case "mark-packet":
-		if r.NewPacketMark != "" {
-			markID := MarkNameToID(r.NewPacketMark)
-			args = append(args, fmt.Sprintf("-j MARK --set-mark %d", markID))
-		}
-	case "change-mss":
-		args = append(args, "-p tcp --tcp-flags SYN,RST SYN -j TCPMSS")
-		if r.NewMSS == "clamp-to-pmtu" || r.NewMSS == "" {
-			args = append(args, "--clamp-mss-to-pmtu")
-		} else {
-			args = append(args, fmt.Sprintf("--set-mss %s", r.NewMSS))
-		}
-	case "set-dscp", "change-dscp":
-		if r.NewDSCP > 0 {
-			args = append(args, fmt.Sprintf("-j DSCP --set-dscp %d", r.NewDSCP))
-		}
-	case "change-ttl":
-		if r.NewTTL != "" {
-			if strings.HasPrefix(r.NewTTL, "+") {
-				args = append(args, fmt.Sprintf("-j TTL --ttl-inc %s", strings.TrimPrefix(r.NewTTL, "+")))
-			} else if strings.HasPrefix(r.NewTTL, "-") {
-				args = append(args, fmt.Sprintf("-j TTL --ttl-dec %s", strings.TrimPrefix(r.NewTTL, "-")))
+		args = appendMatchCriteria(args, r.SrcAddress, r.DstAddress, r.Protocol, r.SrcPort, r.DstPort, inIface, outIface, r.ConnectionState, r.ConnectionMark, r.RoutingMark, r.SrcAddressList, r.DstAddressList, r.SrcGeoIP, r.DstGeoIP)
+		args = append(args, fmt.Sprintf("-m comment --comment \"id:%s\"", r.ID))
+
+		act := strings.ToLower(r.Action)
+		switch act {
+		case "mark-connection":
+			if r.NewConnectionMark != "" {
+				markID := MarkNameToID(r.NewConnectionMark)
+				args = append(args, fmt.Sprintf("-j CONNMARK --set-mark %d", markID))
+			}
+		case "mark-routing":
+			if r.NewRoutingMark != "" {
+				markID := MarkNameToID(r.NewRoutingMark)
+				args = append(args, fmt.Sprintf("-j MARK --set-mark %d", markID))
+			}
+		case "mark-packet":
+			if r.NewPacketMark != "" {
+				markID := MarkNameToID(r.NewPacketMark)
+				args = append(args, fmt.Sprintf("-j MARK --set-mark %d", markID))
+			}
+		case "change-mss":
+			args = append(args, "-p tcp --tcp-flags SYN,RST SYN -j TCPMSS")
+			if r.NewMSS == "clamp-to-pmtu" || r.NewMSS == "" {
+				args = append(args, "--clamp-mss-to-pmtu")
 			} else {
-				args = append(args, fmt.Sprintf("-j TTL --ttl-set %s", r.NewTTL))
+				args = append(args, fmt.Sprintf("--set-mss %s", r.NewMSS))
 			}
+		case "set-dscp", "change-dscp":
+			if r.NewDSCP > 0 {
+				args = append(args, fmt.Sprintf("-j DSCP --set-dscp %d", r.NewDSCP))
+			}
+		case "change-ttl":
+			if r.NewTTL != "" {
+				if strings.HasPrefix(r.NewTTL, "+") {
+					args = append(args, fmt.Sprintf("-j TTL --ttl-inc %s", strings.TrimPrefix(r.NewTTL, "+")))
+				} else if strings.HasPrefix(r.NewTTL, "-") {
+					args = append(args, fmt.Sprintf("-j TTL --ttl-dec %s", strings.TrimPrefix(r.NewTTL, "-")))
+				} else {
+					args = append(args, fmt.Sprintf("-j TTL --ttl-set %s", r.NewTTL))
+				}
+			}
+		case "set-priority":
+			if r.NewPriority != "" && chain == "POSTROUTING" {
+				args = append(args, fmt.Sprintf("-j CLASSIFY --set-class 0:%s", r.NewPriority))
+			}
+		case "drop":
+			args = append(args, "-j DROP")
+		case "jump":
+			if r.JumpTarget != "" {
+				args = append(args, fmt.Sprintf("-j %s", strings.ToUpper(r.JumpTarget)))
+			}
+		case "log":
+			args = append(args, "-j LOG")
+			if r.LogPrefix != "" {
+				args = append(args, fmt.Sprintf("--log-prefix \"%s: \"", r.LogPrefix))
+			}
+		case "add-src-to-address-list":
+			if r.AddressList != "" {
+				args = append(args, fmt.Sprintf("-j SET --add-set %s src", GetIPSetName(r.AddressList)))
+			}
+		case "add-dst-to-address-list":
+			if r.AddressList != "" {
+				args = append(args, fmt.Sprintf("-j SET --add-set %s dst", GetIPSetName(r.AddressList)))
+			}
+		case "accept":
+			args = append(args, "-j ACCEPT")
+		case "return":
+			args = append(args, "-j RETURN")
+		case "clear-df", "strip-ipv4-options", "route", "sniff-pc", "sniff-tzsp", "passthrough":
+			continue
+		default:
+			continue
 		}
-	case "set-priority":
-		if r.NewPriority != "" && chain == "POSTROUTING" {
-			args = append(args, fmt.Sprintf("-j CLASSIFY --set-class 0:%s", r.NewPriority))
-		}
-	case "drop":
-		args = append(args, "-j DROP")
-	case "jump":
-		if r.JumpTarget != "" {
-			args = append(args, fmt.Sprintf("-j %s", strings.ToUpper(r.JumpTarget)))
-		}
-	case "log":
-		args = append(args, "-j LOG")
-		if r.LogPrefix != "" {
-			args = append(args, fmt.Sprintf("--log-prefix \"%s: \"", r.LogPrefix))
-		}
-	case "add-src-to-address-list":
-		if r.AddressList != "" {
-			args = append(args, fmt.Sprintf("-j SET --add-set %s src", GetIPSetName(r.AddressList)))
-		}
-	case "add-dst-to-address-list":
-		if r.AddressList != "" {
-			args = append(args, fmt.Sprintf("-j SET --add-set %s dst", GetIPSetName(r.AddressList)))
-		}
-	case "accept":
-		args = append(args, "-j ACCEPT")
-	case "return":
-		args = append(args, "-j RETURN")
-	case "clear-df", "strip-ipv4-options", "route", "sniff-pc", "sniff-tzsp", "passthrough":
-		return ""
-	default:
-		return ""
+
+		lines = append(lines, strings.Join(args, " "))
 	}
 
-	return strings.Join(args, " ")
+	return lines
 }
 
 func appendMatchCriteria(args []string, srcAddr, dstAddr, proto, srcPort, dstPort, inIface, outIface string, ctStates []string, connMark, routingMark, srcList, dstList, srcGeoIP, dstGeoIP string) []string {

+ 31 - 0
internal/firewall/iptables_test.go

@@ -0,0 +1,31 @@
+package firewall
+
+import (
+	"strings"
+	"testing"
+)
+
+func TestBuildNATRuleLines_InterfaceList(t *testing.T) {
+	rule := NATRule{
+		ID:               "nat-test-1",
+		Order:            1,
+		Chain:            "srcnat",
+		Action:           "masquerade",
+		OutInterfaceList: "WAN",
+		Disabled:         false,
+	}
+
+	lines := buildNATRuleLines(rule)
+	if len(lines) == 0 {
+		t.Fatalf("expected non-empty lines for WAN interface list, got 0")
+	}
+
+	for _, l := range lines {
+		if !strings.Contains(l, "-j MASQUERADE") {
+			t.Errorf("line missing -j MASQUERADE: %s", l)
+		}
+		if !strings.Contains(l, "-o ") {
+			t.Errorf("expected -o flag in expanded NAT rule, got: %s", l)
+		}
+	}
+}

+ 8 - 7
internal/firewall/manager.go

@@ -46,14 +46,15 @@ func NewFirewallManager() *FirewallManager {
 
 func (m *FirewallManager) initDefaultRules() {
 	if len(m.natRules) == 0 {
-		// Default Masquerade NAT
+		// Default Masquerade NAT only on WAN
 		m.natRules = append(m.natRules, NATRule{
-			ID:       fmt.Sprintf("nat-%d", time.Now().UnixNano()),
-			Order:    1,
-			Chain:    "srcnat",
-			Action:   "masquerade",
-			Comment:  "defconf: masquerade outgoing traffic",
-			Disabled: false,
+			ID:               fmt.Sprintf("nat-%d", time.Now().UnixNano()),
+			Order:            1,
+			Chain:            "srcnat",
+			Action:           "masquerade",
+			OutInterfaceList: "WAN",
+			Comment:          "defconf: masquerade outgoing traffic to WAN",
+			Disabled:         false,
 		})
 	}
 

+ 12 - 2
internal/network/persistence.go

@@ -93,6 +93,16 @@ source /etc/network/interfaces.d/*
 	return os.WriteFile(DebianInterfacesPath, []byte(cleanContent), 0644)
 }
 
+func isDynamicOrVirtualInterface(name string) bool {
+	lower := strings.ToLower(name)
+	return strings.HasPrefix(lower, "eoip-") ||
+		strings.HasPrefix(lower, "wgx-") ||
+		strings.HasPrefix(lower, "wg") ||
+		strings.HasPrefix(lower, "aerotun-") ||
+		strings.HasPrefix(lower, "ppp") ||
+		strings.HasPrefix(lower, "gre")
+}
+
 // GenerateInterfacesConfig creates /etc/network/interfaces.d/interfaces.cfg for regular interfaces
 func (p *PersistenceManager) GenerateInterfacesConfig() (string, error) {
 	ifacesWithIPs := p.netMgr.metadata.GetAllWithIPs()
@@ -109,8 +119,8 @@ func (p *PersistenceManager) GenerateInterfacesConfig() (string, error) {
 	}
 
 	for ifaceName, ips := range ifacesWithIPs {
-		if len(ips) == 0 || strings.HasPrefix(ifaceName, "vlan") || strings.Contains(ifaceName, ".") {
-			continue // Skip VLANs (handled in vlans.cfg)
+		if len(ips) == 0 || strings.HasPrefix(ifaceName, "vlan") || strings.Contains(ifaceName, ".") || isDynamicOrVirtualInterface(ifaceName) {
+			continue // Skip VLANs (handled in vlans.cfg) and daemon-managed virtual/tunnel interfaces
 		}
 		sb.WriteString(fmt.Sprintf("# --- Interface %s ---\n", ifaceName))
 		sb.WriteString(fmt.Sprintf("auto %s\n", ifaceName))

+ 59 - 12
internal/web/static/app.js

@@ -28,8 +28,22 @@ window.fetch = async function(...args) {
 
   // If 401 Unauthorized on protected API endpoint, prompt login immediately
   if (response.status === 401 && typeof resource === 'string' && resource.includes('/api/') && !resource.includes('/api/auth/login')) {
-    if (state.isPolling) {
+    if (state.currentUser) {
+      if (window._telemetryInterval) {
+        clearInterval(window._telemetryInterval);
+        window._telemetryInterval = null;
+      }
+      if (trafficEventSource) {
+        try { trafficEventSource.close(); } catch(e) {}
+        trafficEventSource = null;
+      }
+      if (trafficPollFallback) {
+        clearInterval(trafficPollFallback);
+        trafficPollFallback = null;
+      }
       state.isPolling = false;
+      state.currentUser = null;
+      localStorage.removeItem('debtik_token');
       showLoginScreen(true);
     }
   }
@@ -65,7 +79,7 @@ let state = {
   ipsecServer: null,
   ifaceFilter: 'all',
   currentModalIface: null,
-  isPolling: true,
+  isPolling: false,
   expandedParents: new Set(['eth0']),
   pppoeData: null,
   wgData: null,
@@ -116,19 +130,23 @@ function formatBytes(bytes) {
 
 // Initialize on page load
 document.addEventListener('DOMContentLoaded', async () => {
-  const hash = window.location.hash.replace('#', '') || 'dashboard';
-  navigate(hash);
   initTableSorting();
 
-  // Verify authentication before bootstrapping telemetry
-  await checkAuthStatus();
-
-  // Full state telemetry polling every 5 seconds (smooth & flicker-free)
-  setInterval(() => {
-    if (state.isPolling && state.currentUser) {
-      fetchData(false);
+  // Verify authentication before bootstrapping telemetry and views
+  const isAuthenticated = await checkAuthStatus();
+  if (isAuthenticated) {
+    const hash = window.location.hash.replace('#', '') || 'dashboard';
+    navigate(hash);
+
+    // Full state telemetry polling every 5 seconds (smooth & flicker-free)
+    if (!window._telemetryInterval) {
+      window._telemetryInterval = setInterval(() => {
+        if (state.isPolling && state.currentUser) {
+          fetchData(false);
+        }
+      }, 5000);
     }
-  }, 5000);
+  }
 });
 
 let trafficEventSource = null;
@@ -15936,8 +15954,19 @@ async function handleLoginSubmit(event) {
       state.isPolling = true;
 
       showToast('Authenticated', `Signed in as ${data.user.username}`, 'success');
+      const hash = window.location.hash.replace('#', '') || 'dashboard';
+      navigate(hash);
       fetchData();
       initLiveTrafficStream();
+
+      // Ensure background telemetry polling is active
+      if (!window._telemetryInterval) {
+        window._telemetryInterval = setInterval(() => {
+          if (state.isPolling && state.currentUser) {
+            fetchData(false);
+          }
+        }, 5000);
+      }
     } else {
       if (errTxt) errTxt.innerText = data.message || 'Invalid username or password';
       if (errEl) errEl.classList.remove('hidden');
@@ -15966,6 +15995,10 @@ async function handleLogoutClick() {
   state.currentUser = null;
   state.isPolling = false;
 
+  if (window._telemetryInterval) {
+    clearInterval(window._telemetryInterval);
+    window._telemetryInterval = null;
+  }
   if (trafficEventSource) {
     trafficEventSource.close();
     trafficEventSource = null;
@@ -16026,6 +16059,20 @@ function updateUserProfileHeader(user) {
 
   const statCurRole = document.getElementById('stat-current-role');
   if (statCurRole) statCurRole.innerText = (user.role || 'read').toUpperCase();
+
+  // Role permissions UI visibility: hide user management for non-admins
+  const isFullAdmin = user.role === 'full';
+  const navUsers = document.getElementById('nav-users');
+  if (navUsers) {
+    if (isFullAdmin) navUsers.classList.remove('hidden');
+    else navUsers.classList.add('hidden');
+  }
+
+  const dropdownUsers = document.getElementById('dropdown-item-users');
+  if (dropdownUsers) {
+    if (isFullAdmin) dropdownUsers.classList.remove('hidden');
+    else dropdownUsers.classList.add('hidden');
+  }
 }
 
 // ================= User Management CRUD =================

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

@@ -518,7 +518,7 @@
               <div class="px-3.5 py-2 border-b border-slate-800 text-[11px] text-slate-400">
                 Signed in as <span class="font-bold text-white font-mono" id="dropdown-user-name">admin</span>
               </div>
-              <button onclick="navigate('users'); closeUserMenu();" class="w-full text-left px-3.5 py-2 text-xs text-slate-300 hover:bg-slate-800/80 hover:text-white flex items-center gap-2.5 transition">
+              <button id="dropdown-item-users" onclick="navigate('users'); closeUserMenu();" class="w-full text-left px-3.5 py-2 text-xs text-slate-300 hover:bg-slate-800/80 hover:text-white flex items-center gap-2.5 transition">
                 <svg class="w-4 h-4 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/></svg>
                 <span>User Manager</span>
               </button>