Explorar o código

Release 1.2.0: preserve host networking and fix router configuration workflows

Proxmox Router hai 4 días
pai
achega
644c114c64
Modificáronse 42 ficheiros con 2173 adicións e 556 borrados
  1. 4 2
      README.md
  2. 1 1
      VERSION
  3. 107 34
      cmd/server/main.go
  4. 72 0
      docs/functionality-fixes.md
  5. 17 4
      install.sh
  6. 21 3
      internal/api/api.go
  7. 201 104
      internal/bridge/manager.go
  8. 75 0
      internal/bridge/vlan_sync.go
  9. 60 0
      internal/bridge/vlan_sync_test.go
  10. 26 16
      internal/certs/manager.go
  11. 108 0
      internal/certs/renewal.go
  12. 40 0
      internal/certs/renewal_test.go
  13. 22 3
      internal/dhcp/server_manager.go
  14. 26 3
      internal/dns/manager.go
  15. 12 3
      internal/dynamicrouting/manager.go
  16. 22 0
      internal/dynamicrouting/ospf_test.go
  17. 93 0
      internal/firewall/family.go
  18. 29 34
      internal/firewall/geoip.go
  19. 21 22
      internal/firewall/ipset.go
  20. 53 0
      internal/firewall/ipset_atomic.go
  21. 21 6
      internal/firewall/iptables.go
  22. 159 62
      internal/firewall/manager.go
  23. 78 0
      internal/firewall/owned.go
  24. 153 0
      internal/firewall/owned_test.go
  25. 84 81
      internal/firewall/types.go
  26. 114 22
      internal/network/persistence.go
  27. 30 0
      internal/network/persistence_test.go
  28. 44 13
      internal/routing/manager.go
  29. 55 34
      internal/services/manager.go
  30. 34 0
      internal/services/manager_test.go
  31. 23 8
      internal/vpn/ipsec.go
  32. 22 30
      internal/vpn/l2tp.go
  33. 10 9
      internal/vpn/pppoe.go
  34. 7 8
      internal/vpn/pptp.go
  35. 108 42
      internal/vpn/wireguard.go
  36. 77 0
      internal/vpn/wireguard_integration_test.go
  37. 17 7
      internal/web/static/app.js
  38. 7 4
      internal/web/static/index.html
  39. 14 0
      scripts/debtik-update
  40. 1 1
      scripts/ui-smoke.cjs
  41. 61 0
      update_helpers.go
  42. 44 0
      update_helpers_test.go

+ 4 - 2
README.md

@@ -2,6 +2,8 @@
 
 DebTik is a Debian/Proxmox router appliance with an embedded web interface, REST API and RouterOS-style CLI. It manages network interfaces, routing, firewall/NAT, DHCP/DNS, WireGuard, IPsec, PPP, VRRP and AeroTUN. AeroTUN v3 uses pinned peer identities and encrypted tunnels; it works with native TUN or a veth fallback when `/dev/net/tun` is unavailable.
 
+Current release: **1.2.0**. See [functionality corrections and verification](docs/functionality-fixes.md) for network persistence, firewall isolation/IPv6, service error handling and certificate renewal changes.
+
 ## Install
 
 On a Debian 13 host or container with systemd and root access:
@@ -12,7 +14,7 @@ cd DebTik
 sudo ./install.sh
 ```
 
-`install.sh` installs the Debian packages used by the application, including BIRD, WireGuard, firewall, DHCP/DNS, PPP and diagnostic tools. It obtains the Go toolchain specified by `go.mod` when the installed Go is older, builds `proxmox-router` and `debtik-cli`, installs the updater helper, and starts `proxmox-router.service`. It can be run again; existing `/etc/proxmox-router` files and tunnel keys are preserved. Previous binaries are saved as `/usr/local/bin/*.previous`. For native TUN in an LXC container, enable `/dev/net/tun` on the Proxmox host; otherwise AeroTUN uses veth.
+`install.sh` installs the Debian packages used by the application, including BIRD, WireGuard, firewall, DHCP/DNS, PPP, keepalived, pptp-linux, charon-systemd and diagnostic tools. It obtains the Go toolchain specified by `go.mod` when the installed Go is older, builds `proxmox-router` and `debtik-cli`, installs the updater helper, and starts `proxmox-router.service`. It can be run again; existing `/etc/proxmox-router` files and tunnel keys are preserved. Previous binaries are saved as `/usr/local/bin/*.previous`. For native TUN in an LXC container, enable `/dev/net/tun` on the Proxmox host; otherwise AeroTUN uses veth.
 
 The web UI and authenticated REST API use HTTP port **80** by default. HTTPS is an optional separate `www-ssl` management service, normally port **443**, configured in **Certificates** and **Management Services**. The application SSH CLI normally listens on **2222**, and Telnet/`debtik-cli` on **8282**. The built-in bandwidth test server uses **2000**. Ports can be changed in the management services UI where available. The main service command is `/usr/local/bin/proxmox-router -addr :80`; `systemd/proxmox-router.service` defines the installed unit.
 
@@ -31,7 +33,7 @@ Run `help` within the CLI for supported commands. The API is under `/api/` on th
 
 ## Git updates
 
-In **Updates**, enter an HTTP(S) or SSH Git repository, then save and check for releases. Releases are stable Git tags named `vMAJOR.MINOR.PATCH` (for example `v1.1.0`). The largest numeric version is selected; installation is allowed only when it is strictly greater than the installed version. Equal versions, older versions, malformed tags and prereleases are not installed. The selected tag must match the repository’s `VERSION` file. The updater clones into `/var/lib/debtik/update`, builds the server and CLI first, backs up the installed binaries, restarts the service and checks that it remains active. If installation fails, it restores the previous binaries. **Restore previous version** is available in the same menu. Logs and the current state are shown there. Only full administrators can change the source or run update actions. New releases require bumping `VERSION`, committing the change, creating its matching `vX.Y.Z` tag and pushing both the commit and tag. Recovery rollback remains an explicit separate action. Each appliance updates independently; for paired AeroTUN peers, update one side and verify its connection before updating the other.
+In **Updates**, enter an HTTP(S) or SSH Git repository, then save and check for releases. Releases are stable Git tags named `vMAJOR.MINOR.PATCH` (for example `v1.1.0`). The largest numeric version is selected; installation is allowed only when it is strictly greater than the installed version. Equal versions, older versions, malformed tags and prereleases are not installed. The selected tag must match the repository’s `VERSION` file. The updater clones into `/var/lib/debtik/update`, builds the server and CLI first, backs up the installed binaries, restarts the service and checks that it remains active. If installation fails, it restores the previous binaries. **Restore previous version** is available in the same menu. Logs and the current state are shown there. Only full administrators can change the source or run update actions. New releases require bumping `VERSION`, committing the change, creating its matching `vX.Y.Z` tag and pushing both the commit and tag. Recovery rollback remains an explicit separate action. The server carries its matching updater helpers and refreshes an existing managed installation at startup, including upgrades started by older updater versions. Each appliance updates independently; for paired AeroTUN peers, update one side and verify its connection before updating the other.
 
 `install.sh` must have been run from a Git checkout at least once to install the updater helper. Git access from the appliance to the selected repository and the required Go toolchain are needed. Git credentials belong in the appliance's Git/SSH configuration; repository URLs containing embedded credentials are rejected. For a private Git server whose CA is not trusted by Debian, enter its verified `sha256//...` TLS public key pin in Updates. Rotate the pin when that server changes its TLS key.
 

+ 1 - 1
VERSION

@@ -1 +1 @@
-1.1.0
+1.2.0

+ 107 - 34
cmd/server/main.go

@@ -6,9 +6,11 @@ import (
 	"flag"
 	"fmt"
 	"log"
+	"net"
 	"net/http"
 	"os"
 	"os/signal"
+	release "proxmox-router"
 	"sync"
 	"syscall"
 	"time"
@@ -38,6 +40,11 @@ func main() {
 	cliAddr := flag.String("cli-addr", ":8282", "CLI service address for Telnet and debtik-cli (e.g. :8282)")
 	metaPath := flag.String("meta", "/etc/proxmox-router/metadata.json", "Path to interface metadata storage file")
 	flag.Parse()
+	if os.Geteuid() == 0 {
+		if err := release.RefreshUpdateHelpers("/usr/local/libexec"); err != nil {
+			log.Printf("Cannot refresh updater helpers: %v", err)
+		}
+	}
 
 	// Initialize Logging Ring Buffer
 	_ = logging.GetLogger()
@@ -66,6 +73,9 @@ func main() {
 	if err != nil {
 		log.Fatalf("Failed to initialize certificate manager: %v", err)
 	}
+	renewCtx, stopRenewal := context.WithCancel(context.Background())
+	defer stopRenewal()
+	go certMgr.RunRenewal(renewCtx)
 	webHandler := web.Handler()
 
 	// Build Unified RouterOS CLI Executor
@@ -105,11 +115,7 @@ func main() {
 		cliTelnetServer.Stop()
 		if !disabled {
 			cliTelnetServer.SetAddr(fmt.Sprintf(":%d", port))
-			go func() {
-				if err := cliTelnetServer.Start(); err != nil {
-					log.Printf("[CLI-Telnet] Listener error: %v", err)
-				}
-			}()
+			return waitListener(cliTelnetServer.Start, cliTelnetServer.IsRunning)
 		}
 		return nil
 	})
@@ -118,11 +124,7 @@ func main() {
 		cliSSHServer.Stop()
 		if !disabled {
 			cliSSHServer.SetAddr(fmt.Sprintf(":%d", port))
-			go func() {
-				if err := cliSSHServer.Start(); err != nil {
-					log.Printf("[CLI-SSH] Listener error: %v", err)
-				}
-			}()
+			return waitListener(cliSSHServer.Start, cliSSHServer.IsRunning)
 		}
 		return nil
 	})
@@ -175,7 +177,7 @@ func main() {
 		httpsRunning bool
 	)
 
-	startOrRestartHTTPS := func(port int, disabled bool) {
+	startOrRestartHTTPS := func(port int, disabled bool) error {
 		httpsMu.Lock()
 		defer httpsMu.Unlock()
 
@@ -189,19 +191,17 @@ func main() {
 
 		if disabled {
 			log.Printf("[HTTPS] Web SSL (www-ssl) listener stopped")
-			return
+			return nil
 		}
 
 		sslSvc, ok := servicesMgr.GetService("www-ssl")
 		if !ok || sslSvc.Certificate == "" {
-			log.Printf("[HTTPS] Cannot start www-ssl: no certificate configured (set certificate=<name>)")
-			return
+			return fmt.Errorf("HTTPS requires a configured certificate")
 		}
 
 		tlsCert, err := certMgr.GetTLSCertificate(sslSvc.Certificate)
 		if err != nil {
-			log.Printf("[HTTPS] Cannot start www-ssl: failed loading certificate %q: %v", sslSvc.Certificate, err)
-			return
+			return fmt.Errorf("HTTPS certificate %s: %w", sslSvc.Certificate, err)
 		}
 
 		tlsConfig := &tls.Config{
@@ -226,27 +226,33 @@ func main() {
 			WriteTimeout: 60 * time.Second,
 			IdleTimeout:  120 * time.Second,
 		}
+		listener, err := tls.Listen("tcp", listenAddr, tlsConfig)
+		if err != nil {
+			return err
+		}
 		httpsServer = newSrv
 		httpsRunning = true
 
 		go func() {
 			log.Printf("[HTTPS] Web SSL (www-ssl) server listening on %s with certificate %q", listenAddr, sslSvc.Certificate)
-			if err := newSrv.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
+			if err := newSrv.Serve(listener); err != nil && err != http.ErrServerClosed {
 				log.Printf("[HTTPS] Server error: %v", err)
 				httpsMu.Lock()
 				httpsRunning = false
 				httpsMu.Unlock()
 			}
 		}()
+		return nil
 	}
 
 	servicesMgr.RegisterHook("www-ssl", func(serviceName string, port int, disabled bool) error {
-		startOrRestartHTTPS(port, disabled)
-		return nil
+		return startOrRestartHTTPS(port, disabled)
 	})
 
 	if sslSvc, ok := servicesMgr.GetService("www-ssl"); ok && !sslSvc.Disabled {
-		startOrRestartHTTPS(sslSvc.Port, sslSvc.Disabled)
+		if err := startOrRestartHTTPS(sslSvc.Port, sslSvc.Disabled); err != nil {
+			log.Printf("HTTPS startup failed: %v", err)
+		}
 	}
 	defer func() {
 		httpsMu.Lock()
@@ -256,12 +262,59 @@ func main() {
 		httpsMu.Unlock()
 	}()
 
-	httpServer := &http.Server{
-		Addr:         *addr,
-		Handler:      srv,
-		ReadTimeout:  60 * time.Second,
-		WriteTimeout: 60 * time.Second,
-		IdleTimeout:  120 * time.Second,
+	var httpMu sync.Mutex
+	var httpServer *http.Server
+	host, _, _ := net.SplitHostPort(*addr)
+	startHTTP := func(port int, disabled bool) error {
+		httpMu.Lock()
+		defer httpMu.Unlock()
+		old := httpServer
+		if disabled {
+			httpServer = nil
+			if old != nil {
+				go old.Shutdown(context.Background())
+			}
+			return nil
+		}
+		address := net.JoinHostPort(host, fmt.Sprint(port))
+		if old != nil && old.Addr == address {
+			return nil
+		}
+		listener, err := net.Listen("tcp", address)
+		if err != nil {
+			return err
+		}
+		next := &http.Server{Addr: address, Handler: srv, ReadTimeout: 60 * time.Second, WriteTimeout: 60 * time.Second, IdleTimeout: 120 * time.Second}
+		httpServer = next
+		go func() {
+			if err := next.Serve(listener); err != nil && err != http.ErrServerClosed {
+				log.Printf("HTTP listener failed: %v", err)
+			}
+		}()
+		if old != nil {
+			go old.Shutdown(context.Background())
+		}
+		return nil
+	}
+	servicesMgr.RegisterHook("www", func(_ string, port int, disabled bool) error { return startHTTP(port, disabled) })
+	_, httpPortText, _ := net.SplitHostPort(*addr)
+	var httpPort int
+	_, _ = fmt.Sscan(httpPortText, &httpPort)
+	httpDisabled := false
+	explicitAddr := false
+	flag.Visit(func(f *flag.Flag) {
+		if f.Name == "addr" {
+			explicitAddr = true
+		}
+	})
+	if svc, ok := servicesMgr.GetService("www"); ok {
+		httpDisabled = svc.Disabled
+		if !explicitAddr {
+			httpPort = svc.Port
+		}
+	}
+	if err := startHTTP(httpPort, httpDisabled); err != nil {
+		log.Fatalf("HTTP startup failed: %v", err)
 	}
 
 	// Print startup banner
@@ -287,20 +340,40 @@ func main() {
 	stopChan := make(chan os.Signal, 1)
 	signal.Notify(stopChan, os.Interrupt, syscall.SIGTERM)
 
-	go func() {
-		if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
-			log.Fatalf("HTTP server failed: %v", err)
-		}
-	}()
-
 	<-stopChan
 	fmt.Println("\nShutting down DebTik Controller gracefully...")
 
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	if err := httpServer.Shutdown(ctx); err != nil {
-		log.Printf("Server shutdown error: %v", err)
+	httpMu.Lock()
+	currentHTTP := httpServer
+	httpMu.Unlock()
+	if currentHTTP != nil {
+		if err := currentHTTP.Shutdown(ctx); err != nil {
+			log.Printf("Server shutdown error: %v", err)
+		}
 	}
 	fmt.Println("DebTik Controller stopped.")
 }
+
+func waitListener(start func() error, running func() bool) error {
+	result := make(chan error, 1)
+	go func() { result <- start() }()
+	timer := time.NewTimer(5 * time.Second)
+	defer timer.Stop()
+	ticker := time.NewTicker(20 * time.Millisecond)
+	defer ticker.Stop()
+	for {
+		select {
+		case err := <-result:
+			return err
+		case <-timer.C:
+			return fmt.Errorf("listener did not become ready")
+		case <-ticker.C:
+			if running() {
+				return nil
+			}
+		}
+	}
+}

+ 72 - 0
docs/functionality-fixes.md

@@ -0,0 +1,72 @@
+# DebTik 1.2.0 — functionality corrections
+
+This release addresses the 2026-10-31 functionality review. Changes were tested
+before deployment; disruptive tests use private mount and network namespaces.
+
+- Network persistence preserves host gateway, DHCP, bridge and other existing
+  stanzas. Addresses on existing host interfaces use bounded DebTik-managed
+  hooks; generated VLAN and physical snippets support IPv4 and IPv6. Failed
+  persistence is returned to the caller instead of a success response.
+- Firewall rules live in `DT_<table>_<chain>` chains. Applying rules preserves
+  external chains and built-in policies, migrates legacy DebTik ID rules, and
+  validates both IP families before applying. Failed edits restore the previous
+  saved/in-memory rule set and kernel rules. Counter reset affects owned chains.
+- Filter/NAT/mangle forms explicitly select IPv4 or IPv6. Existing rules default
+  to IPv4. Address lists maintain separate IPv4/IPv6 sets using temporary sets
+  and atomic swaps. Invalid entries preserve the old set. GeoIP datasets remain
+  IPv4; an IPv6 GeoIP rule is rejected explicitly rather than applied incorrectly.
+- Interface role changes immediately rebuild the corresponding firewall rules;
+  failure restores the previous role and rules. Rule tables refresh when any
+  configuration field changes, while counter-only refreshes avoid rebuilding.
+- Route synchronization leaves DHCP/host-owned default routes alone. Static
+  route Save propagates kernel failures and restores the previous managed state.
+- Bridge VLAN edits remove old memberships and add new memberships, with rollback
+  on error. Bridge/port commands return failures. Linux port priority values are
+  mapped from the UI representation. Ingress filtering is shown as the actual
+  bridge-wide VLAN filtering setting, not a fictitious per-port command.
+- Kernel STP and userspace RSTP are distinguished. New RSTP settings require
+  `mstpd/mstpctl`. Existing legacy RSTP labels fall back to real kernel STP if the
+  daemon is absent, preserving boot connectivity and reporting the real mode.
+  See the [mstpd control manual](https://github.com/mstpd/mstpd/blob/master/utils/mstpctl.8).
+- WireGuard edits preserve an existing private identity when omitted; comment
+  edits leave the running link intact. Start/Save return errors synchronously.
+  Peer option clearing is applied to the kernel, including zero keepalive,
+  empty allowed IPs/PSK, and a removed endpoint. Already-running WireGuard links
+  are preserved during appliance startup.
+- DHCP/DNS restart failures reach the API. Failed application restores previous
+  daemon/resolver files and attempts to restart the previous running service.
+- BIRD reload failures reach the caller. OSPF running state is matched to each
+  instance's own protocol, not a global indication that any OSPF process is up.
+- Management hooks run synchronously outside the settings lock, returning bind
+  failures and restoring previous settings. SSH/Telnet wait for readiness;
+  HTTPS binds before claiming success, and HTTP port changes actually rebind.
+- IPsec load failures and PPP client launch/control failures are reported.
+  L2TP control writes are nonblocking and report unavailable control channels.
+- Installation includes keepalived, pptp-linux and charon-systemd (the daemon
+  used by swanctl), and suppresses package-triggered daemon starts. Installation
+  and update jobs share a lock.
+- Updates install and restore updater helpers together with server/CLI binaries.
+  The server also embeds and refreshes existing helpers, allowing upgrades launched by legacy updaters to migrate their tooling. Rollback also restores the previous firewall snapshot after restarting the
+  previous service. Version gating remains strictly increasing.
+- ACME certificates renew when less than 30 days remain. Checks begin after the
+  web listener starts and repeat every 12 hours. ACME account keys are reused;
+  renewal errors and attempts are persisted. TLS reloads the renewed files on
+  subsequent handshakes. HTTP-01 still needs public reachability on port 80.
+
+## Verification
+
+Normal tests: `go test ./...`, `go vet ./...`, race checks for affected packages,
+Node UI regression checks and shell syntax checks.
+
+Kernel integration tests use private namespaces and temporary filesystem binds:
+
+- Firewall: repeated apply, external rule/policy preservation, actual IPv6 rules,
+  failed-save rollback, IPv4/IPv6 address lists, rejected list updates.
+- Migration: a snapshot of the first appliance's real firewall configuration/rules.
+- Bridge: changing VLAN ID and port membership and disabling a VLAN.
+- WireGuard: stable link/index and key across comment edits; actual kernel clearing
+  of keepalive, endpoint, allowed IPs and preshared key.
+
+Public CA issuance is not triggered by tests. Renewal selection and account-key
+reuse are unit tested; actual renewal depends on the configured public domain
+and challenge connectivity.

+ 17 - 4
install.sh

@@ -3,15 +3,29 @@ set -euo pipefail
 if [[ $EUID -ne 0 ]]; then echo 'Run as root' >&2; exit 1; fi
 src=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
 command -v apt-get >/dev/null || { echo 'Debian apt-get required' >&2; exit 1; }
+archive="";build="";policy_created=false
+cleanup(){
+ if [[ -n $archive ]];then rm -f "$archive";fi
+ if [[ -n $build ]];then rm -rf "$build";fi
+ if [[ $policy_created == true ]];then rm -f /usr/sbin/policy-rc.d;fi
+}
+trap cleanup EXIT
+install -d -m 0700 /var/lib/debtik/update
+exec 9>/var/lib/debtik/update/lock
+flock -n 9 || { echo 'An update or installation is already running' >&2;exit 1; }
+if [[ ! -e /usr/sbin/policy-rc.d && ! -L /usr/sbin/policy-rc.d ]];then
+ printf '#!/bin/sh\nexit 101\n' > /usr/sbin/policy-rc.d
+ chmod 0755 /usr/sbin/policy-rc.d
+ policy_created=true
+fi
 export DEBIAN_FRONTEND=noninteractive
 apt-get update
-apt-get install -y --no-install-recommends ca-certificates curl git tar iproute2 bridge-utils iptables ipset conntrack wireguard-tools bird2 dnsmasq isc-dhcp-client ppp pppoe strongswan strongswan-swanctl xl2tpd iputils-ping iputils-tracepath traceroute procps openssl certbot python3 util-linux
+apt-get install -y --no-install-recommends ca-certificates curl git tar iproute2 bridge-utils iptables ipset conntrack wireguard-tools keepalived pptp-linux bird2 dnsmasq isc-dhcp-client ppp pppoe charon-systemd strongswan-swanctl libcharon-extra-plugins xl2tpd iputils-ping iputils-tracepath traceroute procps openssl certbot python3 util-linux
 required=$(awk '$1=="toolchain" {sub(/^go/,"",$2);print $2}' "$src/go.mod")
 if [[ -z $required ]]; then required=$(awk '$1=="go" {print $2}' "$src/go.mod"); fi
 if ! command -v go >/dev/null || [[ $(printf '%s\n%s\n' "$required" "$(go version | awk '{sub(/^go/,"",$3);print $3}')" | sort -V | head -n1) != "$required" ]]; then
   case $(uname -m) in x86_64) arch=amd64;; aarch64) arch=arm64;; *) echo 'Unsupported CPU' >&2; exit 1;; esac
   archive=$(mktemp)
-  trap 'rm -f "$archive"' EXIT
   curl --fail --location --retry 3 "https://go.dev/dl/go${required}.linux-${arch}.tar.gz" -o "$archive"
   install -d /opt/debtik
   rm -rf /opt/debtik/go
@@ -21,13 +35,11 @@ if ! command -v go >/dev/null || [[ $(printf '%s\n%s\n' "$required" "$(go versio
 fi
 cd "$src"
 build=$(mktemp -d)
-trap 'rm -rf "$build"' EXIT
 commit=$(git rev-parse HEAD 2>/dev/null || printf unknown)
 CGO_ENABLED=0 go build -trimpath -ldflags "-X proxmox-router/internal/api.BuildCommit=$commit" -o "$build/proxmox-router" ./cmd/server
 CGO_ENABLED=0 go build -trimpath -o "$build/debtik-cli" ./cmd/debtik-cli
 install -d -m 0700 /etc/proxmox-router
 install -d -m 0700 /var/lib/debtik/update
-if [[ $commit =~ ^[a-f0-9]{40}$ ]]; then printf '%s\n' "$commit" > /var/lib/debtik/update/installed_commit; fi
 install -d -m 0755 /usr/local/libexec
 install -m 0750 scripts/debtik-update /usr/local/libexec/debtik-update
 install -m 0750 scripts/release-version.py /usr/local/libexec/debtik-release-version
@@ -43,5 +55,6 @@ systemctl daemon-reload
 systemctl enable proxmox-router.service
 systemctl restart proxmox-router.service
 systemctl is-active --quiet proxmox-router.service
+if [[ $commit =~ ^[a-f0-9]{40}$ ]]; then printf '%s\n' "$commit" > /var/lib/debtik/update/installed_commit; fi
 install -m 0600 VERSION /var/lib/debtik/update/installed_version
 echo 'DebTik installed: http://<host>/ and debtik-cli'

+ 21 - 3
internal/api/api.go

@@ -696,7 +696,10 @@ func (s *Server) handleAddInterfaceIP(w http.ResponseWriter, r *http.Request) {
 
 	// Persist to Debian network configuration
 	if s.persistMgr != nil {
-		_, _ = s.persistMgr.SaveDebianConfig()
+		if _, err := s.persistMgr.SaveDebianConfig(); err != nil {
+			writeError(w, 500, "Live change applied but persistence failed: "+err.Error())
+			return
+		}
 	}
 
 	writeJSON(w, http.StatusOK, map[string]any{"success": true, "name": name, "ip": req.IP})
@@ -722,7 +725,10 @@ func (s *Server) handleDeleteInterfaceIP(w http.ResponseWriter, r *http.Request)
 
 	// Persist to Debian network configuration
 	if s.persistMgr != nil {
-		_, _ = s.persistMgr.SaveDebianConfig()
+		if _, err := s.persistMgr.SaveDebianConfig(); err != nil {
+			writeError(w, 500, "Live change applied but persistence failed: "+err.Error())
+			return
+		}
 	}
 
 	writeJSON(w, http.StatusOK, map[string]any{"success": true, "name": name, "ip": req.IP})
@@ -739,11 +745,20 @@ func (s *Server) handleSetInterfaceMetadata(w http.ResponseWriter, r *http.Reque
 		return
 	}
 
+	previous := s.metaMgr.Get(name)
 	if err := s.metaMgr.Set(name, req.Role, req.Description); err != nil {
 		writeError(w, http.StatusInternalServerError, err.Error())
 		return
 	}
 
+	if s.fwMgr != nil {
+		if err := s.fwMgr.Reapply(); err != nil {
+			_ = s.metaMgr.Set(name, previous.Role, previous.Description)
+			_ = s.fwMgr.Reapply()
+			writeError(w, 500, "Interface role update rolled back: "+err.Error())
+			return
+		}
+	}
 	writeJSON(w, http.StatusOK, map[string]any{"success": true, "name": name, "role": req.Role, "description": req.Description})
 }
 
@@ -833,7 +848,10 @@ func (s *Server) handleRenameInterface(w http.ResponseWriter, r *http.Request) {
 		s.vpnMgr.RenameInterface(name, targetName)
 	}
 	if s.persistMgr != nil {
-		_, _ = s.persistMgr.SaveDebianConfig()
+		if _, err := s.persistMgr.SaveDebianConfig(); err != nil {
+			writeError(w, 500, "Live change applied but persistence failed: "+err.Error())
+			return
+		}
 	}
 
 	writeJSON(w, http.StatusOK, map[string]any{

+ 201 - 104
internal/bridge/manager.go

@@ -3,7 +3,9 @@ package bridge
 import (
 	"bufio"
 	"encoding/json"
+	"errors"
 	"fmt"
+	"log"
 	"net"
 	"os"
 	"os/exec"
@@ -12,6 +14,7 @@ import (
 	"strconv"
 	"strings"
 	"sync"
+	"syscall"
 	"time"
 
 	"github.com/vishvananda/netlink"
@@ -103,8 +106,10 @@ func (m *BridgeManager) ListBridges() []*BridgeInstance {
 
 	list := make([]*BridgeInstance, 0, len(m.bridges))
 	for _, b := range m.bridges {
-		m.updateBridgeLiveStats(b)
-		list = append(list, b)
+		copy := *b
+		copy.IPAddresses = append([]string(nil), b.IPAddresses...)
+		m.updateBridgeLiveStats(&copy)
+		list = append(list, &copy)
 	}
 
 	sort.Slice(list, func(i, j int) bool {
@@ -119,19 +124,28 @@ func (m *BridgeManager) GetBridge(idOrName string) (*BridgeInstance, bool) {
 	defer m.mu.Unlock()
 
 	if b, ok := m.bridges[idOrName]; ok {
-		m.updateBridgeLiveStats(b)
-		return b, true
+		copy := *b
+		copy.IPAddresses = append([]string(nil), b.IPAddresses...)
+		m.updateBridgeLiveStats(&copy)
+		return &copy, true
 	}
 	for _, b := range m.bridges {
 		if b.Name == idOrName {
-			m.updateBridgeLiveStats(b)
-			return b, true
+			copy := *b
+			copy.IPAddresses = append([]string(nil), b.IPAddresses...)
+			m.updateBridgeLiveStats(&copy)
+			return &copy, true
 		}
 	}
 	return nil, false
 }
 
 func (m *BridgeManager) SaveBridge(b BridgeInstance) error {
+	if b.STP == "rstp" {
+		if _, err := exec.LookPath("mstpctl"); err != nil {
+			return fmt.Errorf("RSTP requires mstpd/mstpctl; select STP")
+		}
+	}
 	m.mu.Lock()
 	defer m.mu.Unlock()
 
@@ -145,7 +159,7 @@ func (m *BridgeManager) SaveBridge(b BridgeInstance) error {
 		b.PVID = 1
 	}
 	if b.STP == "" {
-		b.STP = "rstp"
+		b.STP = "stp"
 	}
 	if b.Priority <= 0 {
 		b.Priority = 32768
@@ -156,11 +170,21 @@ func (m *BridgeManager) SaveBridge(b BridgeInstance) error {
 	}
 
 	// Update existing or add new
+	old := m.bridges[b.ID]
+	if err := m.syncBridge(&b); err != nil {
+		return err
+	}
 	m.bridges[b.ID] = &b
-	_ = m.save()
-
-	// Apply in Linux kernel
-	return m.syncBridge(&b)
+	if err := m.save(); err != nil {
+		if old == nil {
+			delete(m.bridges, b.ID)
+		} else {
+			m.bridges[b.ID] = old
+			_ = m.syncBridge(old)
+		}
+		return err
+	}
+	return nil
 }
 
 func (m *BridgeManager) DeleteBridge(id string) error {
@@ -175,7 +199,9 @@ func (m *BridgeManager) DeleteBridge(id string) error {
 	// Detach any ports belonging to this bridge
 	for _, p := range m.ports {
 		if p.Bridge == b.Name {
-			_ = exec.Command("ip", "link", "set", p.Interface, "nomaster").Run()
+			if out, err := exec.Command("ip", "link", "set", p.Interface, "nomaster").CombinedOutput(); err != nil {
+				return fmt.Errorf("bridge command failed: %s: %w", out, err)
+			}
 			delete(m.ports, p.ID)
 		}
 	}
@@ -188,7 +214,9 @@ func (m *BridgeManager) DeleteBridge(id string) error {
 	}
 
 	// Delete bridge link in kernel
-	_ = exec.Command("ip", "link", "del", b.Name).Run()
+	if out, err := exec.Command("ip", "link", "del", b.Name).CombinedOutput(); err != nil {
+		return fmt.Errorf("bridge command failed: %s: %w", out, err)
+	}
 
 	delete(m.bridges, id)
 	return m.save()
@@ -204,12 +232,18 @@ func (m *BridgeManager) SetBridgeState(id string, disabled bool) error {
 	}
 
 	b.Disabled = disabled
-	_ = m.save()
+	if err := m.save(); err != nil {
+		return err
+	}
 
 	if disabled {
-		_ = exec.Command("ip", "link", "set", b.Name, "down").Run()
+		if out, err := exec.Command("ip", "link", "set", b.Name, "down").CombinedOutput(); err != nil {
+			return fmt.Errorf("bridge command failed: %s: %w", out, err)
+		}
 	} else {
-		_ = exec.Command("ip", "link", "set", b.Name, "up").Run()
+		if out, err := exec.Command("ip", "link", "set", b.Name, "up").CombinedOutput(); err != nil {
+			return fmt.Errorf("bridge command failed: %s: %w", out, err)
+		}
 	}
 	return nil
 }
@@ -245,10 +279,14 @@ func (m *BridgeManager) AddBridgeIP(idOrName string, ipCIDR string) error {
 	}
 
 	b.IPAddresses = append(b.IPAddresses, ipCIDR)
-	_ = m.save()
+	if err := m.save(); err != nil {
+		return err
+	}
 
 	// Add to kernel
-	_ = exec.Command("ip", "addr", "add", ipCIDR, "dev", b.Name).Run()
+	if out, err := exec.Command("ip", "addr", "add", ipCIDR, "dev", b.Name).CombinedOutput(); err != nil {
+		return fmt.Errorf("bridge command failed: %s: %w", out, err)
+	}
 	return nil
 }
 
@@ -278,13 +316,23 @@ func (m *BridgeManager) DeleteBridgeIP(idOrName string, ipCIDR string) error {
 		}
 	}
 	b.IPAddresses = updated
-	_ = m.save()
+	if err := m.save(); err != nil {
+		return err
+	}
 
-	_ = exec.Command("ip", "addr", "del", ipCIDR, "dev", b.Name).Run()
+	if out, err := exec.Command("ip", "addr", "del", ipCIDR, "dev", b.Name).CombinedOutput(); err != nil {
+		return fmt.Errorf("bridge command failed: %s: %w", out, err)
+	}
 	return nil
 }
 
 func (m *BridgeManager) syncBridge(b *BridgeInstance) error {
+	if b.STP == "rstp" {
+		if _, err := exec.LookPath("mstpctl"); err != nil {
+			log.Printf("Bridge %s: legacy RSTP configuration uses kernel STP because mstpd is unavailable", b.Name)
+			b.STP = "stp"
+		}
+	}
 	link, err := netlink.LinkByName(b.Name)
 	if err != nil {
 		// Create bridge
@@ -306,13 +354,17 @@ func (m *BridgeManager) syncBridge(b *BridgeInstance) error {
 	if link != nil {
 		// Set MTU
 		if b.MTU > 0 {
-			_ = netlink.LinkSetMTU(link, b.MTU)
+			if err := netlink.LinkSetMTU(link, b.MTU); err != nil {
+				return err
+			}
 		}
 
 		// Set Admin MAC if specified
 		if b.MACAddress != "" {
 			if hw, err := net.ParseMAC(b.MACAddress); err == nil {
-				_ = netlink.LinkSetHardwareAddr(link, hw)
+				if err := netlink.LinkSetHardwareAddr(link, hw); err != nil {
+					return err
+				}
 			}
 		}
 
@@ -325,25 +377,47 @@ func (m *BridgeManager) syncBridge(b *BridgeInstance) error {
 		if b.PVID <= 0 {
 			pvidVal = "1"
 		}
-		_ = exec.Command("ip", "link", "set", b.Name, "type", "bridge", "vlan_filtering", vfVal, "vlan_default_pvid", pvidVal).Run()
+		if out, err := exec.Command("ip", "link", "set", b.Name, "type", "bridge", "vlan_filtering", vfVal, "vlan_default_pvid", pvidVal).CombinedOutput(); err != nil {
+			return fmt.Errorf("bridge command failed: %s: %w", out, err)
+		}
 
 		// Set STP
 		stpVal := "0"
 		if b.STP == "rstp" || b.STP == "stp" {
 			stpVal = "1"
 		}
-		_ = os.WriteFile(fmt.Sprintf("/sys/class/net/%s/bridge/stp_state", b.Name), []byte(stpVal), 0644)
+		if err := os.WriteFile(fmt.Sprintf("/sys/class/net/%s/bridge/stp_state", b.Name), []byte(stpVal), 0644); err != nil {
+			return err
+		}
+		if b.STP == "rstp" {
+			if out, err := exec.Command("mstpctl", "addbridge", b.Name).CombinedOutput(); err != nil {
+				return fmt.Errorf("RSTP daemon: %s: %w", out, err)
+			}
+			if out, err := exec.Command("mstpctl", "setforcevers", b.Name, "rstp").CombinedOutput(); err != nil {
+				return fmt.Errorf("RSTP configuration: %s: %w", out, err)
+			}
+		}
 
 		// Set link up or down
 		if b.Disabled {
-			_ = netlink.LinkSetDown(link)
+			if err := netlink.LinkSetDown(link); err != nil {
+				return err
+			}
 		} else {
-			_ = netlink.LinkSetUp(link)
+			if err := netlink.LinkSetUp(link); err != nil {
+				return err
+			}
 		}
 
 		// Sync IP addresses
 		for _, ip := range b.IPAddresses {
-			_ = exec.Command("ip", "addr", "add", ip, "dev", b.Name).Run()
+			addr, err := netlink.ParseAddr(ip)
+			if err != nil {
+				return err
+			}
+			if err = netlink.AddrAdd(link, addr); err != nil && !errors.Is(err, syscall.EEXIST) {
+				return err
+			}
 		}
 	}
 
@@ -437,10 +511,24 @@ func (m *BridgeManager) SavePort(p BridgePort) error {
 		p.ID = fmt.Sprintf("port-%d", time.Now().UnixNano())
 	}
 
+	old := m.ports[p.ID]
+	if err := m.syncPort(&p); err != nil {
+		if old != nil {
+			_ = m.syncPort(old)
+		}
+		return err
+	}
 	m.ports[p.ID] = &p
-	_ = m.save()
-
-	return m.syncPort(&p)
+	if err := m.save(); err != nil {
+		if old != nil {
+			m.ports[p.ID] = old
+			_ = m.syncPort(old)
+		} else {
+			delete(m.ports, p.ID)
+		}
+		return err
+	}
+	return nil
 }
 
 func (m *BridgeManager) DeletePort(id string) error {
@@ -453,7 +541,9 @@ func (m *BridgeManager) DeletePort(id string) error {
 	}
 
 	// Remove from bridge in kernel
-	_ = exec.Command("ip", "link", "set", p.Interface, "nomaster").Run()
+	if out, err := exec.Command("ip", "link", "set", p.Interface, "nomaster").CombinedOutput(); err != nil {
+		return fmt.Errorf("bridge command failed: %s: %w", out, err)
+	}
 
 	delete(m.ports, id)
 	return m.save()
@@ -469,19 +559,27 @@ func (m *BridgeManager) SetPortState(id string, disabled bool) error {
 	}
 
 	p.Disabled = disabled
-	_ = m.save()
+	if err := m.save(); err != nil {
+		return err
+	}
 
 	if disabled {
-		_ = exec.Command("ip", "link", "set", p.Interface, "nomaster").Run()
+		if out, err := exec.Command("ip", "link", "set", p.Interface, "nomaster").CombinedOutput(); err != nil {
+			return fmt.Errorf("bridge command failed: %s: %w", out, err)
+		}
 	} else {
-		_ = m.syncPort(p)
+		if err := m.syncPort(p); err != nil {
+			return err
+		}
 	}
 	return nil
 }
 
 func (m *BridgeManager) syncPort(p *BridgePort) error {
 	if p.Disabled {
-		_ = exec.Command("ip", "link", "set", p.Interface, "nomaster").Run()
+		if out, err := exec.Command("ip", "link", "set", p.Interface, "nomaster").CombinedOutput(); err != nil {
+			return fmt.Errorf("bridge command failed: %s: %w", out, err)
+		}
 		return nil
 	}
 
@@ -496,50 +594,61 @@ func (m *BridgeManager) syncPort(p *BridgePort) error {
 	}
 
 	// Bring interface UP
-	_ = exec.Command("ip", "link", "set", p.Interface, "up").Run()
+	if out, err := exec.Command("ip", "link", "set", p.Interface, "up").CombinedOutput(); err != nil {
+		return fmt.Errorf("bridge command failed: %s: %w", out, err)
+	}
 
 	// Port isolation / Split Horizon
 	isolated := "off"
 	if p.Horizon > 0 {
 		isolated = "on"
 	}
-	_ = exec.Command("bridge", "link", "set", "dev", p.Interface, "isolated", isolated).Run()
+	if out, err := exec.Command("bridge", "link", "set", "dev", p.Interface, "isolated", isolated).CombinedOutput(); err != nil {
+		return fmt.Errorf("bridge command failed: %s: %w", out, err)
+	}
 
 	// Learning
 	learning := "on"
 	if !p.Learn {
 		learning = "off"
 	}
-	_ = exec.Command("bridge", "link", "set", "dev", p.Interface, "learning", learning).Run()
+	if out, err := exec.Command("bridge", "link", "set", "dev", p.Interface, "learning", learning).CombinedOutput(); err != nil {
+		return fmt.Errorf("bridge command failed: %s: %w", out, err)
+	}
 
-	// Ingress filtering
-	ingress := "off"
-	if p.IngressFiltering {
-		ingress = "on"
+	// Linux VLAN admission is controlled by the bridge's VLAN filtering state.
+	if data, err := os.ReadFile(fmt.Sprintf("/sys/class/net/%s/bridge/vlan_filtering", p.Bridge)); err == nil {
+		p.IngressFiltering = strings.TrimSpace(string(data)) == "1"
 	}
-	_ = exec.Command("bridge", "link", "set", "dev", p.Interface, "ingress_filtering", ingress).Run()
 
 	// Priority & Path Cost
 	prioCostArgs := []string{"link", "set", "dev", p.Interface}
 	if p.Priority > 0 {
-		prioCostArgs = append(prioCostArgs, "priority", strconv.Itoa(p.Priority))
+		prioCostArgs = append(prioCostArgs, "priority", strconv.Itoa(p.Priority/4))
 	}
 	if p.PathCost > 0 {
 		prioCostArgs = append(prioCostArgs, "cost", strconv.Itoa(p.PathCost))
 	}
 	if len(prioCostArgs) > 4 {
-		_ = exec.Command("bridge", prioCostArgs...).Run()
+		if out, err := exec.Command("bridge", prioCostArgs...).CombinedOutput(); err != nil {
+			return fmt.Errorf("bridge command failed: %s: %w", out, err)
+		}
 	}
 
 	// VLAN filtering configuration
 	if p.PVID > 0 {
-		_ = exec.Command("bridge", "vlan", "add", "dev", p.Interface, "vid", strconv.Itoa(p.PVID), "pvid", "untagged").Run()
+		if out, err := exec.Command("bridge", "vlan", "add", "dev", p.Interface, "vid", strconv.Itoa(p.PVID), "pvid", "untagged").CombinedOutput(); err != nil {
+			return fmt.Errorf("bridge command failed: %s: %w", out, err)
+		}
 	}
 
 	return nil
 }
 
 func (m *BridgeManager) updatePortLiveStatus(p *BridgePort) {
+	if data, err := os.ReadFile(fmt.Sprintf("/sys/class/net/%s/bridge/vlan_filtering", p.Bridge)); err == nil {
+		p.IngressFiltering = strings.TrimSpace(string(data)) == "1"
+	}
 	if p.Disabled {
 		p.Status = "disabled"
 		return
@@ -587,86 +696,72 @@ func (m *BridgeManager) ListVLANs() []*BridgeVLAN {
 func (m *BridgeManager) SaveVLAN(v BridgeVLAN) error {
 	m.mu.Lock()
 	defer m.mu.Unlock()
-
-	if v.Bridge == "" || v.VlanIDs == "" {
-		return fmt.Errorf("bridge and vlan_ids are required")
+	if v.Bridge == "" || len(parseVLANRange(v.VlanIDs)) == 0 {
+		return fmt.Errorf("valid bridge and VLAN IDs are required")
+	}
+	for _, vid := range parseVLANRange(v.VlanIDs) {
+		if vid < 1 || vid > 4094 {
+			return fmt.Errorf("VLAN ID must be 1-4094")
+		}
 	}
-
 	if v.ID == "" {
 		v.ID = fmt.Sprintf("bvlan-%d", time.Now().UnixNano())
 	}
-
+	old := m.vlans[v.ID]
+	if err := syncMembers(old, &v); err != nil {
+		return err
+	}
 	m.vlans[v.ID] = &v
-	_ = m.save()
-
-	return m.syncVLAN(&v)
+	if err := m.save(); err != nil {
+		_ = syncMembers(&v, old)
+		if old == nil {
+			delete(m.vlans, v.ID)
+		} else {
+			m.vlans[v.ID] = old
+		}
+		return err
+	}
+	return nil
 }
-
 func (m *BridgeManager) DeleteVLAN(id string) error {
 	m.mu.Lock()
 	defer m.mu.Unlock()
-
-	v, ok := m.vlans[id]
+	old, ok := m.vlans[id]
 	if !ok {
-		return fmt.Errorf("vlan entry not found")
+		return fmt.Errorf("VLAN not found")
 	}
-
-	// Remove VLANs from kernel
-	vids := parseVLANRange(v.VlanIDs)
-	for _, vid := range vids {
-		vidStr := strconv.Itoa(vid)
-		for _, iface := range append(v.Tagged, v.Untagged...) {
-			_ = exec.Command("bridge", "vlan", "del", "dev", iface, "vid", vidStr).Run()
-		}
+	if err := syncMembers(old, nil); err != nil {
+		return err
 	}
-
 	delete(m.vlans, id)
-	return m.save()
+	if err := m.save(); err != nil {
+		m.vlans[id] = old
+		_ = syncMembers(nil, old)
+		return err
+	}
+	return nil
 }
-
 func (m *BridgeManager) SetVLANState(id string, disabled bool) error {
 	m.mu.Lock()
 	defer m.mu.Unlock()
-
-	v, ok := m.vlans[id]
+	old, ok := m.vlans[id]
 	if !ok {
-		return fmt.Errorf("vlan entry not found")
+		return fmt.Errorf("VLAN not found")
 	}
-
-	v.Disabled = disabled
-	_ = m.save()
-
-	if disabled {
-		vids := parseVLANRange(v.VlanIDs)
-		for _, vid := range vids {
-			vidStr := strconv.Itoa(vid)
-			for _, iface := range append(v.Tagged, v.Untagged...) {
-				_ = exec.Command("bridge", "vlan", "del", "dev", iface, "vid", vidStr).Run()
-			}
-		}
-	} else {
-		_ = m.syncVLAN(v)
-	}
-	return nil
-}
-
-func (m *BridgeManager) syncVLAN(v *BridgeVLAN) error {
-	if v.Disabled {
-		return nil
+	next := *old
+	next.Disabled = disabled
+	if err := syncMembers(old, &next); err != nil {
+		return err
 	}
-
-	vids := parseVLANRange(v.VlanIDs)
-	for _, vid := range vids {
-		vidStr := strconv.Itoa(vid)
-		for _, iface := range v.Tagged {
-			_ = exec.Command("bridge", "vlan", "add", "dev", iface, "vid", vidStr).Run()
-		}
-		for _, iface := range v.Untagged {
-			_ = exec.Command("bridge", "vlan", "add", "dev", iface, "vid", vidStr, "untagged").Run()
-		}
+	m.vlans[id] = &next
+	if err := m.save(); err != nil {
+		m.vlans[id] = old
+		_ = syncMembers(&next, old)
+		return err
 	}
 	return nil
 }
+func (m *BridgeManager) syncVLAN(v *BridgeVLAN) error { return syncMembers(nil, v) }
 
 // ==================== Bridge Hosts (FDB / MAC Table) ====================
 
@@ -765,7 +860,9 @@ func (m *BridgeManager) FlushHosts(bridge string) error {
 	if bridge == "" {
 		return fmt.Errorf("bridge name required for flush")
 	}
-	_ = exec.Command("bridge", "fdb", "flush", "dev", bridge, "dynamic").Run()
+	if out, err := exec.Command("bridge", "fdb", "flush", "dev", bridge, "dynamic").CombinedOutput(); err != nil {
+		return fmt.Errorf("bridge command failed: %s: %w", out, err)
+	}
 	return nil
 }
 

+ 75 - 0
internal/bridge/vlan_sync.go

@@ -0,0 +1,75 @@
+package bridge
+
+import (
+	"fmt"
+	"os/exec"
+	"strconv"
+)
+
+type vlanMember struct {
+	Device   string
+	VID      int
+	Untagged bool
+	Self     bool
+}
+
+func members(v *BridgeVLAN) map[vlanMember]bool {
+	result := map[vlanMember]bool{}
+	if v == nil || v.Disabled {
+		return result
+	}
+	for _, vid := range parseVLANRange(v.VlanIDs) {
+		for _, dev := range v.Tagged {
+			result[vlanMember{dev, vid, false, dev == v.Bridge}] = true
+		}
+		for _, dev := range v.Untagged {
+			result[vlanMember{dev, vid, true, dev == v.Bridge}] = true
+		}
+	}
+	return result
+}
+func changeMember(action string, m vlanMember) error {
+	args := []string{"vlan", action, "dev", m.Device, "vid", strconv.Itoa(m.VID)}
+	if m.Self {
+		args = append(args, "self")
+	}
+	if action == "add" && m.Untagged {
+		args = append(args, "untagged")
+	}
+	out, err := exec.Command("bridge", args...).CombinedOutput()
+	if err != nil {
+		return fmt.Errorf("bridge VLAN %v failed: %s: %w", args, out, err)
+	}
+	return nil
+}
+func syncMembers(old, next *BridgeVLAN) error {
+	before, after := members(old), members(next)
+	var removed, added []vlanMember
+	rollback := func() {
+		for _, m := range added {
+			_ = changeMember("del", m)
+		}
+		for _, m := range removed {
+			_ = changeMember("add", m)
+		}
+	}
+	for m := range before {
+		if !after[m] {
+			if err := changeMember("del", m); err != nil {
+				rollback()
+				return err
+			}
+			removed = append(removed, m)
+		}
+	}
+	for m := range after {
+		if !before[m] {
+			if err := changeMember("add", m); err != nil {
+				rollback()
+				return err
+			}
+			added = append(added, m)
+		}
+	}
+	return nil
+}

+ 60 - 0
internal/bridge/vlan_sync_test.go

@@ -0,0 +1,60 @@
+package bridge
+
+import (
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+func TestMembershipChangesRepresentRemovalAndAddition(t *testing.T) {
+	before := members(&BridgeVLAN{Bridge: "br0", VlanIDs: "10", Tagged: []string{"p1"}})
+	after := members(&BridgeVLAN{Bridge: "br0", VlanIDs: "20", Untagged: []string{"p2"}})
+	if len(before) != 1 || len(after) != 1 || before[vlanMember{"p2", 20, true, false}] || !after[vlanMember{"p2", 20, true, false}] {
+		t.Fatal(before, after)
+	}
+}
+func TestVLANEditInNamespace(t *testing.T) {
+	if os.Getenv("DEBTIK_BRIDGE_INTEGRATION") != "1" {
+		t.Skip("isolated namespace only")
+	}
+	run := func(binary string, args ...string) string {
+		t.Helper()
+		out, err := exec.Command(binary, args...).CombinedOutput()
+		if err != nil {
+			t.Fatalf("%s %v: %s: %v", binary, args, out, err)
+		}
+		return string(out)
+	}
+	run("ip", "link", "add", "br0", "type", "bridge", "vlan_filtering", "1")
+	for _, name := range []string{"p1", "p2"} {
+		run("ip", "link", "add", name, "type", "dummy")
+		run("ip", "link", "set", name, "master", "br0")
+	}
+	m := &BridgeManager{storePath: filepath.Join(t.TempDir(), "bridge.json"), bridges: map[string]*BridgeInstance{}, ports: map[string]*BridgePort{}, vlans: map[string]*BridgeVLAN{}}
+	if err := m.SavePort(BridgePort{ID: "p1", Bridge: "br0", Interface: "p1", PVID: 1, Learn: true}); err != nil {
+		t.Fatal(err)
+	}
+	v := BridgeVLAN{ID: "v1", Bridge: "br0", VlanIDs: "10", Tagged: []string{"p1"}}
+	if err := m.SaveVLAN(v); err != nil {
+		t.Fatal(err)
+	}
+	v.VlanIDs = "20"
+	v.Tagged = nil
+	v.Untagged = []string{"p2"}
+	if err := m.SaveVLAN(v); err != nil {
+		t.Fatal(err)
+	}
+	p1 := run("bridge", "vlan", "show", "dev", "p1")
+	p2 := run("bridge", "vlan", "show", "dev", "p2")
+	if strings.Contains(p1, "10") || !strings.Contains(p2, "20") {
+		t.Fatal(p1, p2)
+	}
+	if err := m.SetVLANState("v1", true); err != nil {
+		t.Fatal(err)
+	}
+	if strings.Contains(run("bridge", "vlan", "show", "dev", "p2"), "20") {
+		t.Fatal("disabled VLAN retained")
+	}
+}

+ 26 - 16
internal/certs/manager.go

@@ -43,20 +43,23 @@ const (
 )
 
 type CertificateInfo struct {
-	ID          string    `json:"id"`
-	Name        string    `json:"name"`
-	CommonName  string    `json:"common_name"`
-	SANs        []string  `json:"sans"`
-	IsCA        bool      `json:"is_ca"`
-	Issuer      string    `json:"issuer"`
-	KeyType     string    `json:"key_type"`
-	ValidFrom   time.Time `json:"valid_from"`
-	ValidTo     time.Time `json:"valid_to"`
-	Fingerprint string    `json:"fingerprint"`
-	CertPath    string    `json:"cert_path"`
-	KeyPath     string    `json:"key_path"`
-	Type        string    `json:"type"`
-	Comment     string    `json:"comment,omitempty"`
+	ACMERequest      *LetsEncryptRequest `json:"acme_request,omitempty"`
+	RenewalError     string              `json:"renewal_error,omitempty"`
+	LastRenewAttempt time.Time           `json:"last_renew_attempt,omitempty"`
+	ID               string              `json:"id"`
+	Name             string              `json:"name"`
+	CommonName       string              `json:"common_name"`
+	SANs             []string            `json:"sans"`
+	IsCA             bool                `json:"is_ca"`
+	Issuer           string              `json:"issuer"`
+	KeyType          string              `json:"key_type"`
+	ValidFrom        time.Time           `json:"valid_from"`
+	ValidTo          time.Time           `json:"valid_to"`
+	Fingerprint      string              `json:"fingerprint"`
+	CertPath         string              `json:"cert_path"`
+	KeyPath          string              `json:"key_path"`
+	Type             string              `json:"type"`
+	Comment          string              `json:"comment,omitempty"`
 }
 
 type CreateCARequest struct {
@@ -90,6 +93,7 @@ type ImportCertRequest struct {
 }
 
 type LetsEncryptRequest struct {
+	renew   bool
 	Name    string `json:"name"`
 	Domain  string `json:"domain"`
 	Email   string `json:"email"`
@@ -104,6 +108,8 @@ type CertManager struct {
 	certs       map[string]*CertificateInfo
 	challenges  map[string]string // token -> keyAuth
 	challengeMu sync.RWMutex
+	renewMu     sync.Mutex
+	accountMu   sync.Mutex
 }
 
 func NewCertManager(customDir string) (*CertManager, error) {
@@ -665,7 +671,7 @@ func (cm *CertManager) IssueLetsEncrypt(ctx context.Context, req LetsEncryptRequ
 	}
 
 	cm.mu.Lock()
-	if _, exists := cm.certs[strings.ToLower(name)]; exists {
+	if old, exists := cm.certs[strings.ToLower(name)]; exists && (!req.renew || old.Type != TypeACME) {
 		cm.mu.Unlock()
 		return nil, fmt.Errorf("certificate %q already exists", name)
 	}
@@ -674,7 +680,7 @@ func (cm *CertManager) IssueLetsEncrypt(ctx context.Context, req LetsEncryptRequ
 	logging.Log("certs,info", "Starting Let's Encrypt order for domain %s (Staging=%t)", domain, req.Staging)
 
 	// Generate ACME account key
-	accountKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+	accountKey, err := cm.acmeAccountKey(req.Staging)
 	if err != nil {
 		return nil, fmt.Errorf("failed generating ACME account key: %w", err)
 	}
@@ -811,7 +817,11 @@ func (cm *CertManager) IssueLetsEncrypt(ctx context.Context, req LetsEncryptRequ
 		issuer = "Let's Encrypt (Staging)"
 	}
 
+	requestCopy := req
+	requestCopy.Name = name
+	requestCopy.renew = false
 	info := &CertificateInfo{
+		ACMERequest: &requestCopy,
 		ID:          fmt.Sprintf("cert-%d", time.Now().UnixNano()),
 		Name:        name,
 		CommonName:  domain,

+ 108 - 0
internal/certs/renewal.go

@@ -0,0 +1,108 @@
+package certs
+
+import (
+	"context"
+	"crypto/ecdsa"
+	"crypto/elliptic"
+	"crypto/rand"
+	"crypto/x509"
+	"encoding/pem"
+	"fmt"
+	"os"
+	"path/filepath"
+	"proxmox-router/internal/logging"
+	"strings"
+	"time"
+)
+
+func (cm *CertManager) renewCandidates(now time.Time) []LetsEncryptRequest {
+	cm.mu.RLock()
+	defer cm.mu.RUnlock()
+	var requests []LetsEncryptRequest
+	for _, cert := range cm.certs {
+		if cert.Type == TypeACME && cert.ValidTo.Before(now.Add(30*24*time.Hour)) {
+			req := LetsEncryptRequest{Name: cert.Name, Domain: cert.CommonName, Comment: cert.Comment, Staging: strings.Contains(cert.Issuer, "Staging")}
+			if cert.ACMERequest != nil {
+				req = *cert.ACMERequest
+			}
+			if req.Domain == "" {
+				continue
+			}
+			req.renew = true
+			requests = append(requests, req)
+		}
+	}
+	return requests
+}
+func (cm *CertManager) RenewDue(ctx context.Context) {
+	cm.renewMu.Lock()
+	defer cm.renewMu.Unlock()
+	for _, request := range cm.renewCandidates(time.Now()) {
+		if ctx.Err() != nil {
+			return
+		}
+		attemptCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
+		_, err := cm.IssueLetsEncrypt(attemptCtx, request)
+		cancel()
+		cm.mu.Lock()
+		if cert := cm.certs[strings.ToLower(request.Name)]; cert != nil {
+			cert.LastRenewAttempt = time.Now()
+			cert.RenewalError = ""
+			if err != nil {
+				cert.RenewalError = err.Error()
+			}
+			if saveErr := cm.saveLocked(); saveErr != nil {
+				logging.Log("certs,error", "Failed to persist renewal result: %v", saveErr)
+			}
+		}
+		cm.mu.Unlock()
+		if err != nil {
+			logging.Log("certs,error", "Certificate %s renewal failed: %v", request.Name, err)
+		} else {
+			logging.Log("certs,info", "Certificate %s renewed", request.Name)
+		}
+	}
+}
+func (cm *CertManager) RunRenewal(ctx context.Context) {
+	// Allow the HTTP challenge listener to become available first.
+	timer := time.NewTimer(5 * time.Minute)
+	defer timer.Stop()
+	for {
+		select {
+		case <-ctx.Done():
+			return
+		case <-timer.C:
+			cm.RenewDue(ctx)
+			timer.Reset(12 * time.Hour)
+		}
+	}
+}
+
+func (cm *CertManager) acmeAccountKey(staging bool) (*ecdsa.PrivateKey, error) {
+	cm.accountMu.Lock()
+	defer cm.accountMu.Unlock()
+	name := "acme-account-production.key"
+	if staging {
+		name = "acme-account-staging.key"
+	}
+	path := filepath.Join(cm.dir, name)
+	data, err := os.ReadFile(path)
+	if err == nil {
+		block, _ := pem.Decode(data)
+		if block == nil {
+			return nil, fmt.Errorf("invalid ACME account key")
+		}
+		return x509.ParseECPrivateKey(block.Bytes)
+	}
+	if !os.IsNotExist(err) {
+		return nil, err
+	}
+	key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+	if err != nil {
+		return nil, err
+	}
+	if err = savePrivateKeyPEM(path, key); err != nil {
+		return nil, err
+	}
+	return key, nil
+}

+ 40 - 0
internal/certs/renewal_test.go

@@ -0,0 +1,40 @@
+package certs
+
+import (
+	"testing"
+	"time"
+)
+
+func TestRenewalCandidatesExcludeFreshAndUnmanagedCertificates(t *testing.T) {
+	now := time.Now()
+	m := &CertManager{certs: map[string]*CertificateInfo{
+		"due":       {Type: TypeACME, ValidTo: now.Add(29 * 24 * time.Hour), ACMERequest: &LetsEncryptRequest{Name: "due", Domain: "example.com"}},
+		"fresh":     {Type: TypeACME, ValidTo: now.Add(60 * 24 * time.Hour), ACMERequest: &LetsEncryptRequest{Name: "fresh"}},
+		"unmanaged": {Type: TypeACME, ValidTo: now.Add(time.Hour)},
+	}}
+	candidates := m.renewCandidates(now)
+	if len(candidates) != 1 || candidates[0].Name != "due" || !candidates[0].renew {
+		t.Fatal(candidates)
+	}
+}
+func TestACMEAccountKeyIsReused(t *testing.T) {
+	m := &CertManager{dir: t.TempDir()}
+	first, err := m.acmeAccountKey(false)
+	if err != nil {
+		t.Fatal(err)
+	}
+	second, err := m.acmeAccountKey(false)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !first.Equal(second) {
+		t.Fatal("ACME account key changed between requests")
+	}
+	staging, err := m.acmeAccountKey(true)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if first.Equal(staging) {
+		t.Fatal("production and staging shared account key")
+	}
+}

+ 22 - 3
internal/dhcp/server_manager.go

@@ -145,7 +145,24 @@ func (sm *ServerManager) saveLocked() error {
 	return os.WriteFile(sm.storePath, data, 0644)
 }
 
-func (sm *ServerManager) applyDnsmasqConfigLocked() error {
+func (sm *ServerManager) applyDnsmasqConfigLocked() (result error) {
+	old, readErr := os.ReadFile(sm.confPath)
+	wasRunning := exec.Command("systemctl", "is-active", "--quiet", "dnsmasq").Run() == nil
+	defer func() {
+		if result != nil {
+			if readErr == nil {
+				_ = os.WriteFile(sm.confPath, old, 0644)
+			} else if os.IsNotExist(readErr) {
+				_ = os.Remove(sm.confPath)
+			}
+			if wasRunning {
+				if err := exec.Command("systemctl", "restart", "dnsmasq").Run(); err != nil {
+					result = fmt.Errorf("DHCP apply failed: %v; rollback restart failed: %w", result, err)
+				}
+			}
+		}
+	}()
+
 	var sb strings.Builder
 	sb.WriteString("# ==================================================================\n")
 	sb.WriteString("# Enterprise DHCP Server Configuration\n")
@@ -362,12 +379,14 @@ func (sm *ServerManager) applyDnsmasqConfigLocked() error {
 	if activeServerCount > 0 {
 		out, err := exec.Command("systemctl", "restart", "dnsmasq").CombinedOutput()
 		if err != nil {
-			log.Printf("[DHCPServer] Failed to restart dnsmasq: %v, output: %s", err, string(out))
+			return fmt.Errorf("failed to restart dnsmasq: %s: %w", string(out), err)
 		} else {
 			log.Printf("[DHCPServer] dnsmasq restarted successfully with %d active DHCP server(s)", activeServerCount)
 		}
 	} else {
-		_ = exec.Command("systemctl", "reload", "dnsmasq").Run()
+		if err := exec.Command("systemctl", "reload", "dnsmasq").Run(); err != nil {
+			return err
+		}
 	}
 
 	return nil

+ 26 - 3
internal/dns/manager.go

@@ -92,7 +92,28 @@ func (m *Manager) saveLocked() error {
 }
 
 // applyLocked renders the dnsmasq DNS configuration and updates /etc/resolv.conf.
-func (m *Manager) applyLocked() error {
+func (m *Manager) applyLocked() (result error) {
+	oldConfig, configErr := os.ReadFile(DnsmasqDNSConf)
+	oldResolver, resolverErr := os.ReadFile(SystemResolvConf)
+	wasRunning := exec.Command("systemctl", "is-active", "--quiet", "dnsmasq").Run() == nil
+	defer func() {
+		if result != nil {
+			if configErr == nil {
+				_ = os.WriteFile(DnsmasqDNSConf, oldConfig, 0644)
+			} else if os.IsNotExist(configErr) {
+				_ = os.Remove(DnsmasqDNSConf)
+			}
+			if resolverErr == nil {
+				_ = os.WriteFile(SystemResolvConf, oldResolver, 0644)
+			}
+			if wasRunning {
+				if err := exec.Command("systemctl", "restart", "dnsmasq").Run(); err != nil {
+					result = fmt.Errorf("DNS apply failed: %v; previous config restart failed: %w", result, err)
+				}
+			}
+		}
+	}()
+
 	var sb strings.Builder
 	sb.WriteString("# ==================================================================\n")
 	sb.WriteString("# Enterprise Router DNS Forwarder & Cache Configuration\n")
@@ -199,11 +220,13 @@ func (m *Manager) applyLocked() error {
 		rsb.WriteString(fmt.Sprintf("search %s\n", domain))
 	}
 
-	_ = os.WriteFile(SystemResolvConf, []byte(rsb.String()), 0644)
+	if err := os.WriteFile(SystemResolvConf, []byte(rsb.String()), 0644); err != nil {
+		return err
+	}
 
 	// 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 fmt.Errorf("failed to restart dnsmasq: %w", err)
 	}
 
 	return nil

+ 12 - 3
internal/dynamicrouting/manager.go

@@ -664,7 +664,7 @@ func (m *Manager) applyBirdConfig() error {
 	// Live reload without packet loss
 	reloadOut, reloadErr := birdcExec("configure")
 	if reloadErr != nil {
-		log.Printf("[DynamicRouting] birdc configure notice: %s", string(reloadOut))
+		return fmt.Errorf("BIRD reload failed: %s: %w", string(reloadOut), reloadErr)
 	}
 
 	return nil
@@ -957,12 +957,21 @@ func (m *Manager) ListOSPFInstances() []OSPFInstance {
 	defer m.mu.RUnlock()
 
 	out, err := birdcExec("show protocols")
-	birdUp := err == nil && strings.Contains(string(out), "OSPF") && strings.Contains(string(out), "up")
+	protocols := map[string]bool{}
+	if err == nil {
+		for _, line := range strings.Split(string(out), "\n") {
+			fields := strings.Fields(line)
+			if len(fields) >= 4 && fields[1] == "OSPF" {
+				protocols[fields[0]] = fields[3] == "up"
+			}
+		}
+	}
 
 	res := make([]OSPFInstance, len(m.config.OSPFInstances))
 	for i, inst := range m.config.OSPFInstances {
 		instCopy := inst
-		if birdUp && !inst.Disabled {
+		instCopy.Running = false
+		if protocols[sanitizeIdentifier("ospf", inst.Name)] && !inst.Disabled {
 			instCopy.Running = true
 		}
 		res[i] = instCopy

+ 22 - 0
internal/dynamicrouting/ospf_test.go

@@ -0,0 +1,22 @@
+package dynamicrouting
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+func TestOSPFStatusIsPerInstance(t *testing.T) {
+	dir := t.TempDir()
+	text := "#!/bin/sh\nprintf 'ospf_a OSPF master4 up 00:00\\nospf_b OSPF master4 down 00:00\\n'\n"
+	if err := os.WriteFile(filepath.Join(dir, "birdc"), []byte(text), 0755); err != nil {
+		t.Fatal(err)
+	}
+	t.Setenv("PATH", dir+":"+os.Getenv("PATH"))
+	m := &Manager{}
+	m.config.OSPFInstances = []OSPFInstance{{Name: "a"}, {Name: "b"}, {Name: "c"}}
+	got := m.ListOSPFInstances()
+	if !got[0].Running || got[1].Running || got[2].Running {
+		t.Fatal("one OSPF instance marked other instances running", got)
+	}
+}

+ 93 - 0
internal/firewall/family.go

@@ -0,0 +1,93 @@
+package firewall
+
+import (
+	"fmt"
+	"os/exec"
+	"regexp"
+	"strings"
+)
+
+func ApplyDualStack(filters []FilterRule, nats []NATRule, mangles []MangleRule) error {
+	var f4, f6 []FilterRule
+	var n4, n6 []NATRule
+	var m4, m6 []MangleRule
+	validate := func(family int, srcGeo, dstGeo string) error {
+		if family != 0 && family != 4 && family != 6 {
+			return fmt.Errorf("IP family must be 4 or 6")
+		}
+		if family == 6 && (srcGeo != "" || dstGeo != "") {
+			return fmt.Errorf("GeoIP datasets currently support IPv4 only; remove GeoIP matching from the IPv6 rule")
+		}
+		return nil
+	}
+	for _, r := range filters {
+		if err := validate(r.IPVersion, r.SrcGeoIP, r.DstGeoIP); err != nil {
+			return err
+		}
+		if r.IPVersion == 6 {
+			if r.Protocol == "icmp" {
+				r.Protocol = "ipv6-icmp"
+			}
+			f6 = append(f6, r)
+		} else {
+			f4 = append(f4, r)
+		}
+	}
+	for _, r := range nats {
+		if err := validate(r.IPVersion, r.SrcGeoIP, r.DstGeoIP); err != nil {
+			return err
+		}
+		if r.IPVersion == 6 {
+			n6 = append(n6, r)
+		} else {
+			n4 = append(n4, r)
+		}
+	}
+	for _, r := range mangles {
+		if err := validate(r.IPVersion, r.SrcGeoIP, r.DstGeoIP); err != nil {
+			return err
+		}
+		if r.IPVersion == 6 {
+			m6 = append(m6, r)
+		} else {
+			m4 = append(m4, r)
+		}
+	}
+	type family struct {
+		binary  string
+		payload string
+	}
+	families := []family{{"iptables", BuildIPTablesRestore(f4, n4, m4)}}
+	six := BuildIPTablesRestore(f6, n6, m6)
+	sets := regexp.MustCompile(`(--(?:match|add)-set) ([^ ]+)`)
+	six = sets.ReplaceAllString(six, "${1} ${2}_v6")
+	live6, err := exec.Command("ip6tables-save").Output()
+	need6 := len(f6)+len(n6)+len(m6) > 0 || strings.Contains(string(live6), "DT_")
+	if need6 {
+		if err != nil {
+			return fmt.Errorf("IPv6 firewall unavailable: %w", err)
+		}
+		families = append(families, family{"ip6tables", six})
+	}
+	// Validate all families before applying either one.
+	for i := range families {
+		live, err := exec.Command(families[i].binary + "-save").Output()
+		if err != nil {
+			return err
+		}
+		families[i].payload = ownedPayload(families[i].payload, string(live))
+		cmd := exec.Command(families[i].binary+"-restore", "--noflush", "-c", "--test")
+		cmd.Stdin = strings.NewReader(families[i].payload)
+		if out, err := cmd.CombinedOutput(); err != nil {
+			return fmt.Errorf("%s validation: %s: %w", families[i].binary, out, err)
+		}
+	}
+	for _, family := range families {
+		cmd := exec.Command(family.binary+"-restore", "--noflush", "-c")
+		cmd.Stdin = strings.NewReader(family.payload)
+		if out, err := cmd.CombinedOutput(); err != nil {
+			return fmt.Errorf("%s apply: %s: %w", family.binary, out, err)
+		}
+	}
+	return nil
+}

+ 29 - 34
internal/firewall/geoip.go

@@ -73,7 +73,9 @@ func NewGeoIPManager() *GeoIPManager {
 			if _, err := os.Stat(zonePath); os.IsNotExist(err) {
 				_, _ = m.SyncCountryZone(code)
 			} else {
-				_ = m.ApplyCountryIPSet(code)
+				if err := m.ApplyCountryIPSet(code); err != nil {
+					fmt.Fprintf(os.Stderr, "GeoIP restore failed for %s: %v\n", code, err)
+				}
 			}
 		}
 	}()
@@ -377,7 +379,9 @@ func (m *GeoIPManager) SyncCountryZone(code string) (int, error) {
 	m.mu.RUnlock()
 
 	if enabled {
-		_ = m.ApplyCountryIPSet(code)
+		if err := m.ApplyCountryIPSet(code); err != nil {
+			return 0, err
+		}
 	}
 
 	return count, nil
@@ -396,26 +400,19 @@ func (m *GeoIPManager) ApplyCountryIPSet(code string) error {
 
 	setName := "mk_geo_" + code
 
-	var sb strings.Builder
-	sb.WriteString(fmt.Sprintf("create %s hash:net maxelem 262144 -exist\n", setName))
-	sb.WriteString(fmt.Sprintf("flush %s\n", setName))
-
+	var addresses []string
 	scanner := bufio.NewScanner(f)
 	for scanner.Scan() {
 		line := strings.TrimSpace(scanner.Text())
 		if line != "" && !strings.HasPrefix(line, "#") {
-			sb.WriteString(fmt.Sprintf("add %s %s -exist\n", setName, line))
+			addresses = append(addresses, line)
 		}
 	}
-
-	cmd := exec.Command("ipset", "restore")
-	cmd.Stdin = strings.NewReader(sb.String())
-	out, err := cmd.CombinedOutput()
-	if err != nil {
-		return fmt.Errorf("ipset restore failed for %s: %s: %w", code, string(out), err)
+	if err := scanner.Err(); err != nil {
+		return err
 	}
+	return replaceSet(setName, "inet", addresses)
 
-	return nil
 }
 
 // ApplyAllEnabledSets ensures all enabled countries are present in kernel ipset
@@ -430,7 +427,9 @@ func (m *GeoIPManager) ApplyAllEnabledSets() error {
 	m.mu.RUnlock()
 
 	for _, code := range enabled {
-		_ = m.ApplyCountryIPSet(code)
+		if err := m.ApplyCountryIPSet(code); err != nil {
+			return err
+		}
 	}
 	return nil
 }
@@ -524,30 +523,26 @@ func (m *GeoIPManager) EnsureCountryOrPreset(code string) error {
 
 	if len(targetCodes) > 0 {
 		setName := "mk_geo_" + code
-		var sb strings.Builder
-		sb.WriteString(fmt.Sprintf("create %s hash:net maxelem 262144 -exist\n", setName))
-		sb.WriteString(fmt.Sprintf("flush %s\n", setName))
-
-		for _, tc := range targetCodes {
-			zoneFile := filepath.Join(GeoIPZonesDir, strings.ToLower(tc)+".zone")
+		var addresses []string
+		for _, country := range targetCodes {
+			zoneFile := filepath.Join(GeoIPZonesDir, strings.ToLower(country)+".zone")
 			if _, err := os.Stat(zoneFile); os.IsNotExist(err) {
-				_, _ = m.SyncCountryZone(tc)
+				if _, err = m.SyncCountryZone(country); err != nil {
+					return err
+				}
+			}
+			data, err := os.ReadFile(zoneFile)
+			if err != nil {
+				return err
 			}
-			if f, err := os.Open(zoneFile); err == nil {
-				scanner := bufio.NewScanner(f)
-				for scanner.Scan() {
-					line := strings.TrimSpace(scanner.Text())
-					if line != "" && !strings.HasPrefix(line, "#") {
-						sb.WriteString(fmt.Sprintf("add %s %s -exist\n", setName, line))
-					}
+			for _, line := range strings.Split(string(data), "\n") {
+				line = strings.TrimSpace(line)
+				if line != "" && !strings.HasPrefix(line, "#") {
+					addresses = append(addresses, line)
 				}
-				f.Close()
 			}
 		}
-
-		cmd := exec.Command("ipset", "restore")
-		cmd.Stdin = strings.NewReader(sb.String())
-		return cmd.Run()
+		return replaceSet(setName, "inet", addresses)
 	}
 
 	// Single country

+ 21 - 22
internal/firewall/ipset.go

@@ -1,7 +1,8 @@
 package firewall
 
 import (
-	"os/exec"
+	"fmt"
+	"net"
 	"strings"
 )
 
@@ -26,29 +27,27 @@ func SyncAddressLists(entries []AddressListEntry, referencedLists ...string) err
 		}
 	}
 
-	// Clean up any mk_ sets that no longer exist or are now empty (ignoring mk_geo_ sets managed by GeoIP)
-	if out, err := exec.Command("ipset", "list", "-name").Output(); err == nil {
-		lines := strings.Split(string(out), "\n")
-		for _, line := range lines {
-			line = strings.TrimSpace(line)
-			if strings.HasPrefix(line, "mk_") && !strings.HasPrefix(line, "mk_geo_") {
-				name := strings.TrimPrefix(line, "mk_")
-				if _, ok := listMap[name]; !ok {
-					_ = exec.Command("ipset", "flush", line).Run()
-					_ = exec.Command("ipset", "destroy", line).Run()
-				}
+	for name, addrs := range listMap {
+		var v4, v6 []string
+		for _, address := range addrs {
+			ip := net.ParseIP(address)
+			if ip == nil {
+				ip, _, _ = net.ParseCIDR(address)
+			}
+			if ip == nil {
+				return fmt.Errorf("invalid address: %s", address)
+			}
+			if ip.To4() != nil {
+				v4 = append(v4, address)
+			} else {
+				v6 = append(v6, address)
 			}
 		}
-	}
-
-	for name, addrs := range listMap {
-		setName := "mk_" + strings.ReplaceAll(name, " ", "_")
-		// Create set if not exists
-		_ = exec.Command("ipset", "create", setName, "hash:net", "-exist").Run()
-		_ = exec.Command("ipset", "flush", setName).Run()
-
-		for _, a := range addrs {
-			_ = exec.Command("ipset", "add", setName, a, "-exist").Run()
+		if err := replaceSet(GetIPSetName(name), "inet", v4); err != nil {
+			return err
+		}
+		if err := replaceSet(GetIPSetName(name)+"_v6", "inet6", v6); err != nil {
+			return err
 		}
 	}
 

+ 53 - 0
internal/firewall/ipset_atomic.go

@@ -0,0 +1,53 @@
+package firewall
+
+import (
+	"crypto/sha256"
+	"fmt"
+	"net"
+	"os/exec"
+	"sync"
+)
+
+var setApplyMu sync.Mutex
+
+func replaceSet(name, family string, addresses []string) error {
+	setApplyMu.Lock()
+	defer setApplyMu.Unlock()
+	if len(name) > 31 {
+		return fmt.Errorf("address list name too long: %s", name)
+	}
+	for _, address := range addresses {
+		ip := net.ParseIP(address)
+		if ip == nil {
+			ip, _, _ = net.ParseCIDR(address)
+		}
+		if ip == nil || (ip.To4() != nil) != (family == "inet") {
+			return fmt.Errorf("invalid %s address: %s", family, address)
+		}
+	}
+	hash := sha256.Sum256([]byte(name))
+	temp := fmt.Sprintf("dt_tmp_%x", hash[:8])
+	run := func(args ...string) error {
+		out, err := exec.Command("ipset", args...).CombinedOutput()
+		if err != nil {
+			return fmt.Errorf("ipset %v: %s: %w", args, out, err)
+		}
+		return nil
+	}
+	if err := run("create", temp, "hash:net", "family", family, "maxelem", "262144", "-exist"); err != nil {
+		return err
+	}
+	defer exec.Command("ipset", "destroy", temp).Run()
+	if err := run("flush", temp); err != nil {
+		return err
+	}
+	for _, address := range addresses {
+		if err := run("add", temp, address, "-exist"); err != nil {
+			return err
+		}
+	}
+	if err := run("create", name, "hash:net", "family", family, "maxelem", "262144", "-exist"); err != nil {
+		return err
+	}
+	return run("swap", temp, name)
+}

+ 21 - 6
internal/firewall/iptables.go

@@ -6,6 +6,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"hash/fnv"
+	"net"
 	"os"
 	"os/exec"
 	"sort"
@@ -37,6 +38,10 @@ func ReadRuleCounters(filterRules []FilterRule, natRules []NATRule, mangleRules
 		return nil, err
 	}
 
+	if v6, err := exec.Command("ip6tables-save", "-c").Output(); err == nil {
+		out = append(out, '\n')
+		out = append(out, v6...)
+	}
 	counters := make(map[string][2]uint64)
 	scanner := bufio.NewScanner(bytes.NewReader(out))
 
@@ -356,7 +361,7 @@ func buildNATRuleLines(r NATRule) []string {
 			if r.ToAddresses != "" {
 				target := r.ToAddresses
 				if r.ToPorts != "" {
-					target = fmt.Sprintf("%s:%s", r.ToAddresses, r.ToPorts)
+					target = net.JoinHostPort(r.ToAddresses, r.ToPorts)
 				}
 				args = append(args, fmt.Sprintf("-j SNAT --to-source %s", target))
 			} else {
@@ -366,7 +371,7 @@ func buildNATRuleLines(r NATRule) []string {
 			if r.ToAddresses != "" {
 				target := r.ToAddresses
 				if r.ToPorts != "" {
-					target = fmt.Sprintf("%s:%s", r.ToAddresses, r.ToPorts)
+					target = net.JoinHostPort(r.ToAddresses, r.ToPorts)
 				}
 				args = append(args, fmt.Sprintf("-j DNAT --to-destination %s", target))
 			}
@@ -629,11 +634,21 @@ func appendMatchCriteria(args []string, srcAddr, dstAddr, proto, srcPort, dstPor
 
 // ApplyIPTables executes `iptables-restore` with the given rule set payload
 func ApplyIPTables(payload string) error {
-	cmd := exec.Command("iptables-restore", "-c")
-	cmd.Stdin = strings.NewReader(payload)
-	out, err := cmd.CombinedOutput()
+	live, err := exec.Command("iptables-save").Output()
 	if err != nil {
-		return fmt.Errorf("failed to apply iptables: %s: %w", string(out), err)
+		return fmt.Errorf("cannot snapshot existing firewall: %w", err)
+	}
+	payload = ownedPayload(payload, string(live))
+	for _, test := range []bool{true, false} {
+		args := []string{"--noflush", "-c"}
+		if test {
+			args = append(args, "--test")
+		}
+		cmd := exec.Command("iptables-restore", args...)
+		cmd.Stdin = strings.NewReader(payload)
+		if out, err := cmd.CombinedOutput(); err != nil {
+			return fmt.Errorf("iptables apply (test=%v) failed: %s: %w", test, out, err)
+		}
 	}
 	return nil
 }

+ 159 - 62
internal/firewall/manager.go

@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"os"
 	"os/exec"
+	"strings"
 	"sync"
 	"time"
 )
@@ -18,6 +19,7 @@ const (
 
 type FirewallManager struct {
 	mu           sync.Mutex
+	committed    []byte
 	filterRules  []FilterRule
 	natRules     []NATRule
 	mangleRules  []MangleRule
@@ -34,6 +36,7 @@ func NewFirewallManager() *FirewallManager {
 		geoIPMgr:     NewGeoIPManager(),
 	}
 	m.load()
+	m.remember()
 
 	// If clean installation and no rules exist, initialize default sensible router rules (like RouterOS default firewall)
 	if len(m.filterRules) == 0 || len(m.natRules) == 0 {
@@ -41,6 +44,7 @@ func NewFirewallManager() *FirewallManager {
 	}
 
 	_ = m.Apply()
+	m.remember()
 	return m
 }
 
@@ -112,47 +116,114 @@ func (m *FirewallManager) load() {
 	}
 }
 
-func (m *FirewallManager) save() error {
-	_ = os.MkdirAll("/etc/proxmox-router/firewall", 0755)
-
-	if d, err := json.MarshalIndent(m.filterRules, "", "  "); err == nil {
-		_ = os.WriteFile(FilterStorePath, d, 0600)
-	}
-	if d, err := json.MarshalIndent(m.natRules, "", "  "); err == nil {
-		_ = os.WriteFile(NATStorePath, d, 0600)
+func (m *FirewallManager) snapshot() []byte {
+	data, _ := json.Marshal([]any{m.filterRules, m.natRules, m.mangleRules, m.addressLists})
+	return data
+}
+func (m *FirewallManager) remember() { m.committed = m.snapshot() }
+func (m *FirewallManager) restore(data []byte) {
+	var fields []json.RawMessage
+	if json.Unmarshal(data, &fields) == nil && len(fields) == 4 {
+		_ = json.Unmarshal(fields[0], &m.filterRules)
+		_ = json.Unmarshal(fields[1], &m.natRules)
+		_ = json.Unmarshal(fields[2], &m.mangleRules)
+		_ = json.Unmarshal(fields[3], &m.addressLists)
 	}
-	if d, err := json.MarshalIndent(m.mangleRules, "", "  "); err == nil {
-		_ = os.WriteFile(MangleStorePath, d, 0600)
+}
+func (m *FirewallManager) save() error {
+	if err := os.MkdirAll("/etc/proxmox-router/firewall", 0755); err != nil {
+		return err
 	}
-	if d, err := json.MarshalIndent(m.addressLists, "", "  "); err == nil {
-		_ = os.WriteFile(AddressListsStorePath, d, 0600)
+	for path, value := range map[string]any{FilterStorePath: m.filterRules, NATStorePath: m.natRules, MangleStorePath: m.mangleRules, AddressListsStorePath: m.addressLists} {
+		data, err := json.MarshalIndent(value, "", "  ")
+		if err != nil {
+			return err
+		}
+		f, err := os.CreateTemp("/etc/proxmox-router/firewall", ".config-*")
+		if err != nil {
+			return err
+		}
+		_, err = f.Write(data)
+		closeErr := f.Close()
+		if err == nil {
+			err = closeErr
+		}
+		if err == nil {
+			err = os.Rename(f.Name(), path)
+		}
+		_ = os.Remove(f.Name())
+		if err != nil {
+			return err
+		}
 	}
 	return nil
 }
+func (m *FirewallManager) commitChange() error {
+	before := append([]byte(nil), m.committed...)
+	err := m.Apply()
+	if err == nil {
+		err = m.save()
+	}
+	if err != nil {
+		m.restore(before)
+		rollbackErr := m.Apply()
+		saveErr := m.save()
+		if rollbackErr != nil || saveErr != nil {
+			return fmt.Errorf("firewall change failed: %v; rollback apply: %v; rollback save: %v", err, rollbackErr, saveErr)
+		}
+		return err
+	}
+	m.remember()
+	return nil
+}
+func (m *FirewallManager) Reapply() error { m.mu.Lock(); defer m.mu.Unlock(); return m.Apply() }
 
 // Apply re-synchronizes AddressLists & GeoIP sets with ipset and applies iptables
 func (m *FirewallManager) Apply() error {
 	// Sync GeoIP country sets
 	if m.geoIPMgr != nil {
-		_ = m.geoIPMgr.ApplyAllEnabledSets()
 
 		// Also ensure any GeoIP lists explicitly referenced in active rules are loaded into ipset
 		for _, r := range m.filterRules {
 			if !r.Disabled {
-				if r.SrcGeoIP != "" { _ = m.geoIPMgr.EnsureCountryOrPreset(r.SrcGeoIP) }
-				if r.DstGeoIP != "" { _ = m.geoIPMgr.EnsureCountryOrPreset(r.DstGeoIP) }
+				if r.SrcGeoIP != "" {
+					if err := m.geoIPMgr.EnsureCountryOrPreset(r.SrcGeoIP); err != nil {
+						return err
+					}
+				}
+				if r.DstGeoIP != "" {
+					if err := m.geoIPMgr.EnsureCountryOrPreset(r.DstGeoIP); err != nil {
+						return err
+					}
+				}
 			}
 		}
 		for _, r := range m.natRules {
 			if !r.Disabled {
-				if r.SrcGeoIP != "" { _ = m.geoIPMgr.EnsureCountryOrPreset(r.SrcGeoIP) }
-				if r.DstGeoIP != "" { _ = m.geoIPMgr.EnsureCountryOrPreset(r.DstGeoIP) }
+				if r.SrcGeoIP != "" {
+					if err := m.geoIPMgr.EnsureCountryOrPreset(r.SrcGeoIP); err != nil {
+						return err
+					}
+				}
+				if r.DstGeoIP != "" {
+					if err := m.geoIPMgr.EnsureCountryOrPreset(r.DstGeoIP); err != nil {
+						return err
+					}
+				}
 			}
 		}
 		for _, r := range m.mangleRules {
 			if !r.Disabled {
-				if r.SrcGeoIP != "" { _ = m.geoIPMgr.EnsureCountryOrPreset(r.SrcGeoIP) }
-				if r.DstGeoIP != "" { _ = m.geoIPMgr.EnsureCountryOrPreset(r.DstGeoIP) }
+				if r.SrcGeoIP != "" {
+					if err := m.geoIPMgr.EnsureCountryOrPreset(r.SrcGeoIP); err != nil {
+						return err
+					}
+				}
+				if r.DstGeoIP != "" {
+					if err := m.geoIPMgr.EnsureCountryOrPreset(r.DstGeoIP); err != nil {
+						return err
+					}
+				}
 			}
 		}
 	}
@@ -161,31 +232,50 @@ func (m *FirewallManager) Apply() error {
 	var refLists []string
 	for _, r := range m.filterRules {
 		if !r.Disabled {
-			if r.SrcAddressList != "" { refLists = append(refLists, r.SrcAddressList) }
-			if r.DstAddressList != "" { refLists = append(refLists, r.DstAddressList) }
-			if r.AddressList != "" { refLists = append(refLists, r.AddressList) }
+			if r.SrcAddressList != "" {
+				refLists = append(refLists, r.SrcAddressList)
+			}
+			if r.DstAddressList != "" {
+				refLists = append(refLists, r.DstAddressList)
+			}
+			if r.AddressList != "" {
+				refLists = append(refLists, r.AddressList)
+			}
 		}
 	}
 	for _, r := range m.natRules {
 		if !r.Disabled {
-			if r.SrcAddressList != "" { refLists = append(refLists, r.SrcAddressList) }
-			if r.DstAddressList != "" { refLists = append(refLists, r.DstAddressList) }
-			if r.AddressList != "" { refLists = append(refLists, r.AddressList) }
+			if r.SrcAddressList != "" {
+				refLists = append(refLists, r.SrcAddressList)
+			}
+			if r.DstAddressList != "" {
+				refLists = append(refLists, r.DstAddressList)
+			}
+			if r.AddressList != "" {
+				refLists = append(refLists, r.AddressList)
+			}
 		}
 	}
 	for _, r := range m.mangleRules {
 		if !r.Disabled {
-			if r.SrcAddressList != "" { refLists = append(refLists, r.SrcAddressList) }
-			if r.DstAddressList != "" { refLists = append(refLists, r.DstAddressList) }
-			if r.AddressList != "" { refLists = append(refLists, r.AddressList) }
+			if r.SrcAddressList != "" {
+				refLists = append(refLists, r.SrcAddressList)
+			}
+			if r.DstAddressList != "" {
+				refLists = append(refLists, r.DstAddressList)
+			}
+			if r.AddressList != "" {
+				refLists = append(refLists, r.AddressList)
+			}
 		}
 	}
 
 	// Sync custom ipset address lists
-	_ = SyncAddressLists(m.addressLists, refLists...)
+	if err := SyncAddressLists(m.addressLists, refLists...); err != nil {
+		return err
+	}
 
-	payload := BuildIPTablesRestore(m.filterRules, m.natRules, m.mangleRules)
-	return ApplyIPTables(payload)
+	return ApplyDualStack(m.filterRules, m.natRules, m.mangleRules)
 }
 
 func (m *FirewallManager) GeoIP() *GeoIPManager {
@@ -233,8 +323,7 @@ func (m *FirewallManager) SaveFilterRule(r FilterRule) error {
 		m.filterRules = append(m.filterRules, r)
 	}
 
-	_ = m.save()
-	return m.Apply()
+	return m.commitChange()
 }
 
 func (m *FirewallManager) DeleteFilterRule(id string) error {
@@ -252,8 +341,7 @@ func (m *FirewallManager) DeleteFilterRule(id string) error {
 	}
 	m.filterRules = updated
 
-	_ = m.save()
-	return m.Apply()
+	return m.commitChange()
 }
 
 func (m *FirewallManager) MoveFilterRule(id string, dir string) error {
@@ -281,8 +369,7 @@ func (m *FirewallManager) MoveFilterRule(id string, dir string) error {
 		m.filterRules[i].Order = i + 1
 	}
 
-	_ = m.save()
-	return m.Apply()
+	return m.commitChange()
 }
 
 func (m *FirewallManager) SetFilterRuleState(id string, disabled bool) error {
@@ -292,8 +379,7 @@ func (m *FirewallManager) SetFilterRuleState(id string, disabled bool) error {
 	for i := range m.filterRules {
 		if m.filterRules[i].ID == id {
 			m.filterRules[i].Disabled = disabled
-			_ = m.save()
-			return m.Apply()
+			return m.commitChange()
 		}
 	}
 	return fmt.Errorf("rule not found")
@@ -340,8 +426,7 @@ func (m *FirewallManager) SaveNATRule(r NATRule) error {
 		m.natRules = append(m.natRules, r)
 	}
 
-	_ = m.save()
-	return m.Apply()
+	return m.commitChange()
 }
 
 func (m *FirewallManager) DeleteNATRule(id string) error {
@@ -359,8 +444,7 @@ func (m *FirewallManager) DeleteNATRule(id string) error {
 	}
 	m.natRules = updated
 
-	_ = m.save()
-	return m.Apply()
+	return m.commitChange()
 }
 
 func (m *FirewallManager) MoveNATRule(id string, dir string) error {
@@ -388,8 +472,7 @@ func (m *FirewallManager) MoveNATRule(id string, dir string) error {
 		m.natRules[i].Order = i + 1
 	}
 
-	_ = m.save()
-	return m.Apply()
+	return m.commitChange()
 }
 
 func (m *FirewallManager) SetNATRuleState(id string, disabled bool) error {
@@ -399,8 +482,7 @@ func (m *FirewallManager) SetNATRuleState(id string, disabled bool) error {
 	for i := range m.natRules {
 		if m.natRules[i].ID == id {
 			m.natRules[i].Disabled = disabled
-			_ = m.save()
-			return m.Apply()
+			return m.commitChange()
 		}
 	}
 	return fmt.Errorf("rule not found")
@@ -447,8 +529,7 @@ func (m *FirewallManager) SaveMangleRule(r MangleRule) error {
 		m.mangleRules = append(m.mangleRules, r)
 	}
 
-	_ = m.save()
-	return m.Apply()
+	return m.commitChange()
 }
 
 func (m *FirewallManager) DeleteMangleRule(id string) error {
@@ -466,8 +547,7 @@ func (m *FirewallManager) DeleteMangleRule(id string) error {
 	}
 	m.mangleRules = updated
 
-	_ = m.save()
-	return m.Apply()
+	return m.commitChange()
 }
 
 func (m *FirewallManager) MoveMangleRule(id string, dir string) error {
@@ -495,8 +575,7 @@ func (m *FirewallManager) MoveMangleRule(id string, dir string) error {
 		m.mangleRules[i].Order = i + 1
 	}
 
-	_ = m.save()
-	return m.Apply()
+	return m.commitChange()
 }
 
 func (m *FirewallManager) SetMangleRuleState(id string, disabled bool) error {
@@ -506,8 +585,7 @@ func (m *FirewallManager) SetMangleRuleState(id string, disabled bool) error {
 	for i := range m.mangleRules {
 		if m.mangleRules[i].ID == id {
 			m.mangleRules[i].Disabled = disabled
-			_ = m.save()
-			return m.Apply()
+			return m.commitChange()
 		}
 	}
 	return fmt.Errorf("rule not found")
@@ -544,8 +622,7 @@ func (m *FirewallManager) SaveAddressListEntry(e AddressListEntry) error {
 		m.addressLists = append(m.addressLists, e)
 	}
 
-	_ = m.save()
-	return m.Apply()
+	return m.commitChange()
 }
 
 func (m *FirewallManager) DeleteAddressListEntry(id string) error {
@@ -560,8 +637,7 @@ func (m *FirewallManager) DeleteAddressListEntry(id string) error {
 	}
 	m.addressLists = updated
 
-	_ = m.save()
-	return m.Apply()
+	return m.commitChange()
 }
 
 func (m *FirewallManager) SetAddressListEntryState(id string, disabled bool) error {
@@ -571,15 +647,36 @@ func (m *FirewallManager) SetAddressListEntryState(id string, disabled bool) err
 	for i := range m.addressLists {
 		if m.addressLists[i].ID == id {
 			m.addressLists[i].Disabled = disabled
-			_ = m.save()
-			return m.Apply()
+			return m.commitChange()
 		}
 	}
 	return fmt.Errorf("address list entry not found")
 }
 
 func (m *FirewallManager) ResetCounters() error {
-	_ = exec.Command("iptables", "-Z").Run()
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	for _, binary := range []string{"iptables", "ip6tables"} {
+		out, err := exec.Command(binary + "-save").Output()
+		if err != nil {
+			if binary == "iptables" {
+				return err
+			}
+			continue
+		}
+		table := ""
+		for _, line := range strings.Split(string(out), "\n") {
+			if strings.HasPrefix(line, "*") {
+				table = line[1:]
+			}
+			if strings.HasPrefix(line, ":DT_") {
+				chain := strings.Fields(line)[0][1:]
+				if err := exec.Command(binary, "-t", table, "-Z", chain).Run(); err != nil {
+					return err
+				}
+			}
+		}
+	}
 	return nil
 }
 

+ 78 - 0
internal/firewall/owned.go

@@ -0,0 +1,78 @@
+package firewall
+
+import (
+	"fmt"
+	"strings"
+)
+
+// ownedPayload confines DebTik rules to dedicated chains. The host's chains,
+// policies and rules are left intact; only legacy DebTik ID rules are migrated.
+func ownedPayload(payload, live string) string {
+	liveTables := map[string][]string{}
+	table := ""
+	for _, line := range strings.Split(live, "\n") {
+		if strings.HasPrefix(line, "*") {
+			table = line[1:]
+		} else if strings.HasPrefix(line, "-A ") {
+			liveTables[table] = append(liveTables[table], line)
+		}
+	}
+	var out strings.Builder
+	for _, block := range strings.Split(payload, "COMMIT") {
+		lines := strings.Split(strings.TrimSpace(block), "\n")
+		if len(lines) == 0 || !strings.HasPrefix(lines[0], "*") {
+			continue
+		}
+		table = lines[0][1:]
+		names := map[string]string{}
+		var chains []string
+		for _, line := range lines[1:] {
+			if strings.HasPrefix(line, ":") {
+				name := strings.Fields(line)[0][1:]
+				names[name] = "DT_" + table + "_" + name
+				chains = append(chains, name)
+			}
+		}
+		fmt.Fprintf(&out, "*%s\n", table)
+		for _, chain := range chains {
+			fmt.Fprintf(&out, ":%s - [0:0]\n-F %s\n", names[chain], names[chain])
+		}
+		for _, line := range liveTables[table] {
+			if !strings.HasPrefix(strings.Fields(line)[1], "DT_") && strings.Contains(line, "--comment") && (strings.Contains(line, "id:filter-") || strings.Contains(line, "id:nat-") || strings.Contains(line, "id:mangle-")) {
+				out.WriteString("-D " + strings.TrimPrefix(line, "-A ") + "\n")
+			}
+		}
+		for _, line := range lines[1:] {
+			fields := strings.Fields(line)
+			if len(fields) < 2 || fields[0] != "-A" {
+				continue
+			}
+			line = strings.Replace(line, "-A "+fields[1]+" ", "-A "+names[fields[1]]+" ", 1)
+			for original, owned := range names {
+				line = strings.ReplaceAll(line, "-j "+original+" ", "-j "+owned+" ")
+				if strings.HasSuffix(line, "-j "+original) {
+					line = strings.TrimSuffix(line, "-j "+original) + "-j " + owned
+				}
+			}
+			out.WriteString(line + "\n")
+		}
+		for _, chain := range chains {
+			builtin := chain == "INPUT" || chain == "OUTPUT" || chain == "FORWARD" || chain == "PREROUTING" || chain == "POSTROUTING"
+			if !builtin {
+				continue
+			}
+			exists := false
+			for _, line := range liveTables[table] {
+				if strings.HasPrefix(line, "-A "+chain+" ") && strings.Contains(line, "debtik:hook") && strings.HasSuffix(line, "-j "+names[chain]) {
+					exists = true
+					break
+				}
+			}
+			if !exists {
+				fmt.Fprintf(&out, "-I %s 1 -m comment --comment debtik:hook -j %s\n", chain, names[chain])
+			}
+		}
+		out.WriteString("COMMIT\n")
+	}
+	return out.String()
+}

+ 153 - 0
internal/firewall/owned_test.go

@@ -0,0 +1,153 @@
+package firewall
+
+import (
+	"os"
+	"os/exec"
+	"strings"
+	"testing"
+)
+
+func TestOwnedPayloadPreservesForeignRulesAndPolicies(t *testing.T) {
+	live := "*filter\n:INPUT DROP [0:0]\n-A INPUT -m comment --comment foreign -j ACCEPT\n-A INPUT -m comment --comment \"id:filter-old\" -j DROP\nCOMMIT\n"
+	payload := BuildIPTablesRestore([]FilterRule{{ID: "filter-new", Chain: "input", Action: "accept"}}, nil, nil)
+	result := ownedPayload(payload, live)
+	if strings.Contains(result, ":INPUT ACCEPT") || strings.Contains(result, "-F INPUT") || strings.Contains(result, "-D INPUT -m comment --comment foreign") {
+		t.Fatal("changed a foreign rule or host policy")
+	}
+	if !strings.Contains(result, "-D INPUT -m comment --comment \"id:filter-old\"") || !strings.Contains(result, "-F DT_filter_INPUT") {
+		t.Fatal(result)
+	}
+	second := ownedPayload(payload, live+"\n*filter\n-A INPUT -m comment --comment debtik:hook -j DT_filter_INPUT\n")
+	if strings.Contains(strings.Split(second, "*filter\n")[1], "-I INPUT 1") {
+		t.Fatal("duplicated hook")
+	}
+}
+func TestOwnedFirewallInNamespace(t *testing.T) {
+	if os.Getenv("DEBTIK_FIREWALL_INTEGRATION") != "1" {
+		t.Skip("requires an isolated network namespace")
+	}
+	run := func(args ...string) string {
+		t.Helper()
+		out, err := exec.Command("iptables", args...).CombinedOutput()
+		if err != nil {
+			t.Fatalf("%v: %s: %v", args, out, err)
+		}
+		return string(out)
+	}
+	run("-P", "INPUT", "DROP")
+	run("-A", "INPUT", "-m", "comment", "--comment", "foreign", "-j", "ACCEPT")
+	rules := []FilterRule{{ID: "filter-test", Chain: "input", Action: "drop", SrcAddress: "192.0.2.0/24"}}
+	if err := ApplyDualStack(rules, nil, nil); err != nil {
+		t.Fatal(err)
+	}
+	if err := ApplyDualStack(rules, nil, nil); err != nil {
+		t.Fatal(err)
+	}
+	state := run("-S")
+	if !strings.Contains(state, "-P INPUT DROP") || !strings.Contains(state, "--comment foreign") || strings.Count(state, "-j DT_filter_INPUT") != 1 {
+		t.Fatal(state)
+	}
+	rules[0].Action = "accept"
+	if err := ApplyDualStack(rules, nil, nil); err != nil {
+		t.Fatal(err)
+	}
+	if strings.Contains(run("-S", "DT_filter_INPUT"), "-j DROP") {
+		t.Fatal("old rule retained")
+	}
+	rules = append(rules, FilterRule{ID: "filter-v6", IPVersion: 6, Chain: "input", Action: "drop", SrcAddress: "2001:db8::/32"})
+	if err := ApplyDualStack(rules, nil, nil); err != nil {
+		t.Fatal(err)
+	}
+	out, err := exec.Command("ip6tables", "-S", "DT_filter_INPUT").CombinedOutput()
+	if err != nil || !strings.Contains(string(out), "2001:db8::/32") {
+		t.Fatal(string(out), err)
+	}
+}
+
+// Run only under both an isolated mount namespace and network namespace.
+func TestFirewallFailedSaveRollsBack(t *testing.T) {
+	if os.Getenv("DEBTIK_FIREWALL_PERSIST_TEST") != "1" {
+		t.Skip("requires isolated mount and network namespaces")
+	}
+	dir := t.TempDir()
+	if err := exec.Command("mount", "--bind", dir, "/etc/proxmox-router/firewall").Run(); err != nil {
+		t.Fatal(err)
+	}
+	m := NewFirewallManager()
+	good := FilterRule{ID: "filter-transaction", Chain: "input", Action: "accept", Protocol: "tcp", DstPort: "22"}
+	if err := m.SaveFilterRule(good); err != nil {
+		t.Fatal(err)
+	}
+	before := string(m.snapshot())
+	disk, _ := os.ReadFile(FilterStorePath)
+	bad := good
+	bad.Protocol = "not-a-real-protocol"
+	if err := m.SaveFilterRule(bad); err == nil {
+		t.Fatal("invalid firewall save accepted")
+	}
+	afterDisk, _ := os.ReadFile(FilterStorePath)
+	if string(m.snapshot()) != before || string(disk) != string(afterDisk) {
+		t.Fatal("failed apply changed saved state")
+	}
+	if err := SyncAddressLists([]AddressListEntry{{Name: "test", Address: "192.0.2.0/24"}, {Name: "test", Address: "2001:db8::/32"}}, "test"); err != nil {
+		t.Fatal(err)
+	}
+	out, err := exec.Command("ipset", "list", "mk_test_v6").CombinedOutput()
+	if err != nil || !strings.Contains(string(out), "2001:db8::/32") {
+		t.Fatal(string(out), err)
+	}
+	if err := SyncAddressLists([]AddressListEntry{{Name: "test", Address: "invalid-address"}}, "test"); err == nil {
+		t.Fatal("invalid list address accepted")
+	}
+	out, err = exec.Command("ipset", "list", "mk_test").CombinedOutput()
+	if err != nil || !strings.Contains(string(out), "192.0.2.0/24") {
+		t.Fatal("failed list update destroyed old set", string(out), err)
+	}
+}
+
+func TestProductionFirewallMigration(t *testing.T) {
+	fixture := os.Getenv("DEBTIK_FIREWALL_PRODUCTION_FIXTURE")
+	if fixture == "" {
+		t.Skip("production snapshot in private namespaces only")
+	}
+	dir := t.TempDir()
+	if _, err := os.Stat(fixture + "/metadata.json"); err == nil {
+		if err = exec.Command("mount", "--bind", fixture+"/metadata.json", "/etc/proxmox-router/metadata.json").Run(); err != nil {
+			t.Fatal(err)
+		}
+	}
+	if out, err := exec.Command("cp", "-a", fixture+"/config/.", dir).CombinedOutput(); err != nil {
+		t.Fatal(string(out), err)
+	}
+	if err := exec.Command("mount", "--bind", dir, "/etc/proxmox-router/firewall").Run(); err != nil {
+		t.Fatal(err)
+	}
+	restore := func(binary, path string) {
+		t.Helper()
+		data, err := os.ReadFile(path)
+		if err != nil {
+			t.Fatal(err)
+		}
+		if len(data) == 0 {
+			return
+		}
+		cmd := exec.Command(binary)
+		if binary == "ipset" {
+			cmd = exec.Command("ipset", "restore")
+		}
+		cmd.Stdin = strings.NewReader(string(data))
+		if out, err := cmd.CombinedOutput(); err != nil {
+			t.Fatal(binary, string(out), err)
+		}
+	}
+	restore("ipset", fixture+"/ipset.commands")
+	restore("iptables-restore", fixture+"/iptables.save")
+	restore("ip6tables-restore", fixture+"/ip6tables.save")
+	m := NewFirewallManager()
+	if err := m.Reapply(); err != nil {
+		t.Fatal("production migration failed", err)
+	}
+	if err := m.Reapply(); err != nil {
+		t.Fatal("second apply failed", err)
+	}
+}

+ 84 - 81
internal/firewall/types.go

@@ -4,10 +4,11 @@ import "time"
 
 // FilterRule represents a MikroTik-style /ip firewall filter rule
 type FilterRule struct {
+	IPVersion       int      `json:"ip_version,omitempty"`
 	ID              string   `json:"id"`
 	Order           int      `json:"order"`
-	Chain           string   `json:"chain"` // input, forward, output, or custom
-	Action          string   `json:"action"` // accept, drop, reject, return, jump, log, passthrough
+	Chain           string   `json:"chain"`                 // input, forward, output, or custom
+	Action          string   `json:"action"`                // accept, drop, reject, return, jump, log, passthrough
 	RejectWith      string   `json:"reject_with,omitempty"` // icmp-network-unreachable, icmp-host-unreachable, tcp-reset, etc.
 	JumpTarget      string   `json:"jump_target,omitempty"`
 	SrcAddress      string   `json:"src_address,omitempty"` // e.g. 192.168.1.0/24, !10.0.0.1
@@ -40,29 +41,29 @@ type FilterRule struct {
 	ConnectionNATState []string `json:"connection_nat_state,omitempty"`
 
 	// Advanced tab
-	Layer7Protocol   string `json:"layer7_protocol,omitempty"`
-	Content          string `json:"content,omitempty"`
-	ConnectionBytes  string `json:"connection_bytes,omitempty"`
-	ConnectionRate   string `json:"connection_rate,omitempty"`
-	PCC              string `json:"pcc,omitempty"`
-	SrcMACAddress    string `json:"src_mac_address,omitempty"`
-	InBridgePort     string `json:"in_bridge_port,omitempty"`
-	OutBridgePort    string `json:"out_bridge_port,omitempty"`
-	InBridgePortList string `json:"in_bridge_port_list,omitempty"`
+	Layer7Protocol    string `json:"layer7_protocol,omitempty"`
+	Content           string `json:"content,omitempty"`
+	ConnectionBytes   string `json:"connection_bytes,omitempty"`
+	ConnectionRate    string `json:"connection_rate,omitempty"`
+	PCC               string `json:"pcc,omitempty"`
+	SrcMACAddress     string `json:"src_mac_address,omitempty"`
+	InBridgePort      string `json:"in_bridge_port,omitempty"`
+	OutBridgePort     string `json:"out_bridge_port,omitempty"`
+	InBridgePortList  string `json:"in_bridge_port_list,omitempty"`
 	OutBridgePortList string `json:"out_bridge_port_list,omitempty"`
-	IPsecPolicy      string `json:"ipsec_policy,omitempty"`
-	TLSHost          string `json:"tls_host,omitempty"`
-	IngressPriority  string `json:"ingress_priority,omitempty"`
-	Priority         string `json:"priority,omitempty"`
-	DSCP             string `json:"dscp,omitempty"`
-	TCPMSS           string `json:"tcp_mss,omitempty"`
-	TOS              string `json:"tos,omitempty"`
-	PacketSize       string `json:"packet_size,omitempty"`
-	Random           string `json:"random,omitempty"`
-	TCPFlags         string `json:"tcp_flags,omitempty"`
-	ICMPOptions      string `json:"icmp_options,omitempty"`
-	IPv4Options      string `json:"ipv4_options,omitempty"`
-	TTL              string `json:"ttl,omitempty"`
+	IPsecPolicy       string `json:"ipsec_policy,omitempty"`
+	TLSHost           string `json:"tls_host,omitempty"`
+	IngressPriority   string `json:"ingress_priority,omitempty"`
+	Priority          string `json:"priority,omitempty"`
+	DSCP              string `json:"dscp,omitempty"`
+	TCPMSS            string `json:"tcp_mss,omitempty"`
+	TOS               string `json:"tos,omitempty"`
+	PacketSize        string `json:"packet_size,omitempty"`
+	Random            string `json:"random,omitempty"`
+	TCPFlags          string `json:"tcp_flags,omitempty"`
+	ICMPOptions       string `json:"icmp_options,omitempty"`
+	IPv4Options       string `json:"ipv4_options,omitempty"`
+	TTL               string `json:"ttl,omitempty"`
 
 	// Extra tab
 	Time     string `json:"time,omitempty"`
@@ -78,12 +79,13 @@ type FilterRule struct {
 
 // NATRule represents a MikroTik-style /ip firewall nat rule
 type NATRule struct {
+	IPVersion      int    `json:"ip_version,omitempty"`
 	ID             string `json:"id"`
 	Order          int    `json:"order"`
-	Chain          string `json:"chain"` // srcnat, dstnat
-	Action         string `json:"action"` // masquerade, src-nat, dst-nat, redirect, accept, return
+	Chain          string `json:"chain"`                  // srcnat, dstnat
+	Action         string `json:"action"`                 // masquerade, src-nat, dst-nat, redirect, accept, return
 	ToAddresses    string `json:"to_addresses,omitempty"` // for src-nat or dst-nat
-	ToPorts        string `json:"to_ports,omitempty"` // for dst-nat, redirect, src-nat
+	ToPorts        string `json:"to_ports,omitempty"`     // for dst-nat, redirect, src-nat
 	SrcAddress     string `json:"src_address,omitempty"`
 	DstAddress     string `json:"dst_address,omitempty"`
 	Protocol       string `json:"protocol,omitempty"`
@@ -149,38 +151,39 @@ type NATRule struct {
 
 // MangleRule represents a MikroTik-style /ip firewall mangle rule
 type MangleRule struct {
-	ID                 string   `json:"id"`
-	Order              int      `json:"order"`
-	Chain              string   `json:"chain"` // prerouting, postrouting, input, output, forward
-	Action             string   `json:"action"` // mark-connection, mark-routing, mark-packet, change-mss, set-dscp, accept, return
-	NewConnectionMark  string   `json:"new_connection_mark,omitempty"`
-	NewRoutingMark     string   `json:"new_routing_mark,omitempty"`
-	NewPacketMark      string   `json:"new_packet_mark,omitempty"`
-	NewMSS             string   `json:"new_mss,omitempty"` // e.g. "1440" or "clamp-to-pmtu"
-	NewDSCP            int      `json:"new_dscp,omitempty"`
-	NewTTL             string   `json:"new_ttl,omitempty"`
-	NewPriority        string   `json:"new_priority,omitempty"`
-	Route              string   `json:"route,omitempty"`
-	SniffTarget        string   `json:"sniff_target,omitempty"`
-	Passthrough        bool     `json:"passthrough"` // default true in MikroTik
-	SrcAddress         string   `json:"src_address,omitempty"`
-	DstAddress         string   `json:"dst_address,omitempty"`
-	Protocol           string   `json:"protocol,omitempty"`
-	SrcPort            string   `json:"src_port,omitempty"`
-	DstPort            string   `json:"dst_port,omitempty"`
-	InInterface        string   `json:"in_interface,omitempty"`
-	OutInterface       string   `json:"out_interface,omitempty"`
-	ConnectionState    []string `json:"connection_state,omitempty"`
-	ConnectionMark     string   `json:"connection_mark,omitempty"`
-	RoutingMark        string   `json:"routing_mark,omitempty"`
-	SrcAddressList     string   `json:"src_address_list,omitempty"`
-	DstAddressList     string   `json:"dst_address_list,omitempty"`
-	SrcGeoIP           string   `json:"src_geoip,omitempty"`
-	DstGeoIP           string   `json:"dst_geoip,omitempty"`
-	Comment            string   `json:"comment,omitempty"`
-	Disabled           bool     `json:"disabled"`
-	Packets            uint64   `json:"packets"`
-	Bytes              uint64   `json:"bytes"`
+	IPVersion         int      `json:"ip_version,omitempty"`
+	ID                string   `json:"id"`
+	Order             int      `json:"order"`
+	Chain             string   `json:"chain"`  // prerouting, postrouting, input, output, forward
+	Action            string   `json:"action"` // mark-connection, mark-routing, mark-packet, change-mss, set-dscp, accept, return
+	NewConnectionMark string   `json:"new_connection_mark,omitempty"`
+	NewRoutingMark    string   `json:"new_routing_mark,omitempty"`
+	NewPacketMark     string   `json:"new_packet_mark,omitempty"`
+	NewMSS            string   `json:"new_mss,omitempty"` // e.g. "1440" or "clamp-to-pmtu"
+	NewDSCP           int      `json:"new_dscp,omitempty"`
+	NewTTL            string   `json:"new_ttl,omitempty"`
+	NewPriority       string   `json:"new_priority,omitempty"`
+	Route             string   `json:"route,omitempty"`
+	SniffTarget       string   `json:"sniff_target,omitempty"`
+	Passthrough       bool     `json:"passthrough"` // default true in MikroTik
+	SrcAddress        string   `json:"src_address,omitempty"`
+	DstAddress        string   `json:"dst_address,omitempty"`
+	Protocol          string   `json:"protocol,omitempty"`
+	SrcPort           string   `json:"src_port,omitempty"`
+	DstPort           string   `json:"dst_port,omitempty"`
+	InInterface       string   `json:"in_interface,omitempty"`
+	OutInterface      string   `json:"out_interface,omitempty"`
+	ConnectionState   []string `json:"connection_state,omitempty"`
+	ConnectionMark    string   `json:"connection_mark,omitempty"`
+	RoutingMark       string   `json:"routing_mark,omitempty"`
+	SrcAddressList    string   `json:"src_address_list,omitempty"`
+	DstAddressList    string   `json:"dst_address_list,omitempty"`
+	SrcGeoIP          string   `json:"src_geoip,omitempty"`
+	DstGeoIP          string   `json:"dst_geoip,omitempty"`
+	Comment           string   `json:"comment,omitempty"`
+	Disabled          bool     `json:"disabled"`
+	Packets           uint64   `json:"packets"`
+	Bytes             uint64   `json:"bytes"`
 
 	// Extended WebFig / WinBox v4 properties
 	AnyPort            string   `json:"any_port,omitempty"`
@@ -246,33 +249,33 @@ type AddressListEntry struct {
 
 // GeoCountryInfo represents a Geo-Location country entry in the firewall
 type GeoCountryInfo struct {
-	Code        string `json:"code"`         // e.g. "GR"
-	Name        string `json:"name"`         // e.g. "Greece"
-	Continent   string `json:"continent"`    // e.g. "Europe"
-	SubnetCount int    `json:"subnet_count"` // number of CIDR subnets
-	Enabled     bool   `json:"enabled"`      // enabled in firewall (mk_geo_XX)
-	IPSetName   string `json:"ipset_name"`   // e.g. "mk_geo_GR"
+	Code        string    `json:"code"`         // e.g. "GR"
+	Name        string    `json:"name"`         // e.g. "Greece"
+	Continent   string    `json:"continent"`    // e.g. "Europe"
+	SubnetCount int       `json:"subnet_count"` // number of CIDR subnets
+	Enabled     bool      `json:"enabled"`      // enabled in firewall (mk_geo_XX)
+	IPSetName   string    `json:"ipset_name"`   // e.g. "mk_geo_GR"
 	LastUpdated time.Time `json:"last_updated,omitempty"`
 }
 
 // ActiveConnection represents an active flow from /proc/net/nf_conntrack / conntrack table
 type ActiveConnection struct {
-	ID               string `json:"id"`
-	Protocol         string `json:"protocol"`
-	SrcAddress       string `json:"src_address"`
-	SrcPort          int    `json:"src_port"`
-	DstAddress       string `json:"dst_address"`
-	DstPort          int    `json:"dst_port"`
-	ReplySrcAddress  string `json:"reply_src_address"`
-	ReplySrcPort     int    `json:"reply_src_port"`
-	ReplyDstAddress  string `json:"reply_dst_address"`
-	ReplyDstPort     int    `json:"reply_dst_port"`
-	TCPState         string `json:"tcp_state"`
-	Timeout          int    `json:"timeout"`
-	ConnectionMark   string `json:"connection_mark,omitempty"`
-	Assured          bool   `json:"assured"`
-	Bytes            uint64 `json:"bytes,omitempty"`
-	Packets          uint64 `json:"packets,omitempty"`
+	ID              string `json:"id"`
+	Protocol        string `json:"protocol"`
+	SrcAddress      string `json:"src_address"`
+	SrcPort         int    `json:"src_port"`
+	DstAddress      string `json:"dst_address"`
+	DstPort         int    `json:"dst_port"`
+	ReplySrcAddress string `json:"reply_src_address"`
+	ReplySrcPort    int    `json:"reply_src_port"`
+	ReplyDstAddress string `json:"reply_dst_address"`
+	ReplyDstPort    int    `json:"reply_dst_port"`
+	TCPState        string `json:"tcp_state"`
+	Timeout         int    `json:"timeout"`
+	ConnectionMark  string `json:"connection_mark,omitempty"`
+	Assured         bool   `json:"assured"`
+	Bytes           uint64 `json:"bytes,omitempty"`
+	Packets         uint64 `json:"packets,omitempty"`
 }
 
 // FirewallSummary contains total rule and connection counts

+ 114 - 22
internal/network/persistence.go

@@ -2,6 +2,7 @@ package network
 
 import (
 	"fmt"
+	"net"
 	"os"
 	"path/filepath"
 	"strings"
@@ -74,23 +75,44 @@ func (p *PersistenceManager) GenerateDebianConfig() (string, error) {
 			}
 		}
 
+		if len(v.IPv6) > 0 {
+			sb.WriteString(fmt.Sprintf("iface %s inet6 static\n    address %s\n", v.Name, v.IPv6[0]))
+			for _, ip := range v.IPv6[1:] {
+				sb.WriteString(fmt.Sprintf("    up ip -6 addr add %s dev %s\n    down ip -6 addr del %s dev %s\n", ip, v.Name, ip, v.Name))
+			}
+		}
 		sb.WriteString("\n")
 	}
-
 	return sb.String(), nil
 }
 
-// EnsureSourceInclude ensures that /etc/network/interfaces contains loopback and "source /etc/network/interfaces.d/*"
-// while removing duplicate interface stanzas that are managed inside interfaces.d/.
+// EnsureSourceInclude preserves all existing host configuration.
 func (p *PersistenceManager) EnsureSourceInclude() error {
-	cleanContent := `# The loopback network interface
-auto lo
-iface lo inet loopback
-
-# Include extra interface configurations managed by DebTik / Proxmox Router
-source /etc/network/interfaces.d/*
-`
-	return os.WriteFile(DebianInterfacesPath, []byte(cleanContent), 0644)
+	data, err := os.ReadFile(DebianInterfacesPath)
+	if err != nil && !os.IsNotExist(err) {
+		return err
+	}
+	content := string(data)
+	if p.netMgr != nil && p.netMgr.metadata != nil {
+		content = withManagedAddresses(content, p.netMgr.metadata.GetAllWithIPs())
+	}
+	return os.WriteFile(DebianInterfacesPath, []byte(withSourceInclude(content)), 0644)
+}
+func withSourceInclude(data string) string {
+	for _, line := range strings.Split(data, "\n") {
+		fields := strings.Fields(strings.SplitN(line, "#", 2)[0])
+		if len(fields) >= 2 && fields[0] == "source" {
+			for _, pattern := range fields[1:] {
+				if pattern == "/etc/network/interfaces.d/*" {
+					return data
+				}
+			}
+		}
+	}
+	if data != "" && !strings.HasSuffix(data, "\n") {
+		data += "\n"
+	}
+	return data + "\n# DebTik managed snippets\nsource /etc/network/interfaces.d/*\n"
 }
 
 func isDynamicOrVirtualInterface(name string) bool {
@@ -98,7 +120,7 @@ func isDynamicOrVirtualInterface(name string) bool {
 	return strings.HasPrefix(lower, "eoip-") ||
 		strings.HasPrefix(lower, "wgx-") ||
 		strings.HasPrefix(lower, "wg") ||
-		strings.HasPrefix(lower, "aerotun-") ||
+		strings.HasPrefix(lower, "aerotun") ||
 		strings.HasPrefix(lower, "ppp") ||
 		strings.HasPrefix(lower, "gre")
 }
@@ -106,6 +128,8 @@ func isDynamicOrVirtualInterface(name string) bool {
 // GenerateInterfacesConfig creates /etc/network/interfaces.d/interfaces.cfg for regular interfaces
 func (p *PersistenceManager) GenerateInterfacesConfig() (string, error) {
 	ifacesWithIPs := p.netMgr.metadata.GetAllWithIPs()
+	mainData, _ := os.ReadFile(DebianInterfacesPath)
+	existing := declaredInterfaces(string(mainData))
 	var sb strings.Builder
 	sb.WriteString("# ====================================================\n")
 	sb.WriteString("# Proxmox Router Appliance - Interface IP Configuration\n")
@@ -119,17 +143,31 @@ func (p *PersistenceManager) GenerateInterfacesConfig() (string, error) {
 	}
 
 	for ifaceName, ips := range ifacesWithIPs {
-		if len(ips) == 0 || strings.HasPrefix(ifaceName, "vlan") || strings.Contains(ifaceName, ".") || isDynamicOrVirtualInterface(ifaceName) {
+		if existing[ifaceName] || 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))
-		sb.WriteString(fmt.Sprintf("iface %s inet static\n", ifaceName))
-		sb.WriteString(fmt.Sprintf("    address %s\n", ips[0]))
-		for i := 1; i < len(ips); i++ {
-			sb.WriteString(fmt.Sprintf("    up ip addr add %s dev %s || true\n", ips[i], ifaceName))
-			sb.WriteString(fmt.Sprintf("    down ip addr del %s dev %s || true\n", ips[i], ifaceName))
+		for _, family := range []string{"inet", "inet6"} {
+			var addresses []string
+			for _, cidr := range ips {
+				ip, _, err := net.ParseCIDR(cidr)
+				if err != nil {
+					return "", err
+				}
+				if (ip.To4() != nil) == (family == "inet") {
+					addresses = append(addresses, cidr)
+				}
+			}
+			if len(addresses) == 0 {
+				continue
+			}
+			sb.WriteString(fmt.Sprintf("iface %s %s static\n    address %s\n", ifaceName, family, addresses[0]))
+			for _, ip := range addresses[1:] {
+				sb.WriteString(fmt.Sprintf("    up ip addr replace %s dev %s\n    down ip addr del %s dev %s\n", ip, ifaceName, ip, ifaceName))
+			}
 		}
+
 		sb.WriteString("\n")
 	}
 
@@ -153,10 +191,64 @@ func (p *PersistenceManager) SaveDebianConfig() (string, error) {
 	}
 
 	ifaceCfg, err := p.GenerateInterfacesConfig()
-	if err == nil {
-		_ = os.WriteFile(DebianIfacesCfgPath, []byte(ifaceCfg), 0644)
+	if err != nil {
+		return "", err
+	}
+	if err = os.WriteFile(DebianIfacesCfgPath, []byte(ifaceCfg), 0644); err != nil {
+		return "", err
+	}
+	if err = p.EnsureSourceInclude(); err != nil {
+		return "", err
 	}
-
-	_ = p.EnsureSourceInclude()
 	return cfg, nil
 }
+
+func declaredInterfaces(data string) map[string]bool {
+	result := map[string]bool{}
+	for _, line := range strings.Split(data, "\n") {
+		f := strings.Fields(line)
+		if len(f) >= 4 && f[0] == "iface" {
+			result[f[1]] = true
+		}
+	}
+	return result
+}
+func withManagedAddresses(data string, addresses map[string][]string) string {
+	var clean []string
+	skip := false
+	for _, line := range strings.Split(data, "\n") {
+		if strings.TrimSpace(line) == "# BEGIN DEBTIK ADDRESSES" {
+			skip = true
+			continue
+		}
+		if strings.TrimSpace(line) == "# END DEBTIK ADDRESSES" {
+			skip = false
+			continue
+		}
+		if !skip {
+			clean = append(clean, line)
+		}
+	}
+	var out []string
+	seen := map[string]bool{}
+	for _, line := range clean {
+		out = append(out, line)
+		f := strings.Fields(line)
+		if len(f) < 4 || f[0] != "iface" || seen[f[1]] || isDynamicOrVirtualInterface(f[1]) {
+			continue
+		}
+		name := f[1]
+		seen[name] = true
+		if len(addresses[name]) == 0 {
+			continue
+		}
+		out = append(out, "    # BEGIN DEBTIK ADDRESSES")
+		for _, cidr := range addresses[name] {
+			if _, _, err := net.ParseCIDR(cidr); err == nil {
+				out = append(out, fmt.Sprintf("    up ip addr replace %s dev %s", cidr, name), fmt.Sprintf("    down ip addr del %s dev %s", cidr, name))
+			}
+		}
+		out = append(out, "    # END DEBTIK ADDRESSES")
+	}
+	return strings.Join(out, "\n")
+}

+ 30 - 0
internal/network/persistence_test.go

@@ -0,0 +1,30 @@
+package network
+
+import (
+	"strings"
+	"testing"
+)
+
+func TestPersistencePreservesHostConfiguration(t *testing.T) {
+	original := "auto lo\niface lo inet loopback\nauto eth0\niface eth0 inet dhcp\n    mtu 1450\n    post-up ip route add 10.0.0.0/8 via 192.0.2.1\n"
+	ips := map[string][]string{"eth0": {"192.0.2.5/24", "2001:db8::5/64"}}
+	updated := withSourceInclude(withManagedAddresses(original, ips))
+	for _, line := range strings.Split(strings.TrimSpace(original), "\n") {
+		if !strings.Contains(updated, line) {
+			t.Fatalf("lost host setting: %s", line)
+		}
+	}
+	if strings.Count(updated, "iface eth0") != 1 || strings.Count(updated, "source /etc/network/interfaces.d/*") != 1 {
+		t.Fatal(updated)
+	}
+	if !strings.Contains(updated, "2001:db8::5/64") {
+		t.Fatal("IPv6 address lost")
+	}
+	if again := withSourceInclude(withManagedAddresses(updated, ips)); again != updated {
+		t.Fatal("repeated persistence changed configuration")
+	}
+	removed := withManagedAddresses(updated, map[string][]string{})
+	if strings.Contains(removed, "192.0.2.5") || !strings.Contains(removed, "inet dhcp") {
+		t.Fatal("address deletion touched host config or retained removed address")
+	}
+}

+ 44 - 13
internal/routing/manager.go

@@ -83,14 +83,20 @@ func (m *RoutingManager) load() {
 func (m *RoutingManager) save() error {
 	_ = os.MkdirAll("/etc/proxmox-router/routing", 0755)
 
-	if d, err := json.MarshalIndent(m.staticRoutes, "", "  "); err == nil {
-		_ = os.WriteFile(StaticRoutesStorePath, d, 0600)
+	if data, err := json.MarshalIndent(m.staticRoutes, "", "  "); err != nil {
+		return err
+	} else if err = os.WriteFile(StaticRoutesStorePath, data, 0600); err != nil {
+		return err
 	}
-	if d, err := json.MarshalIndent(m.policyRules, "", "  "); err == nil {
-		_ = os.WriteFile(PolicyRulesStorePath, d, 0600)
+	if data, err := json.MarshalIndent(m.policyRules, "", "  "); err != nil {
+		return err
+	} else if err = os.WriteFile(PolicyRulesStorePath, data, 0600); err != nil {
+		return err
 	}
-	if d, err := json.MarshalIndent(m.customTables, "", "  "); err == nil {
-		_ = os.WriteFile(CustomTablesStorePath, d, 0600)
+	if data, err := json.MarshalIndent(m.customTables, "", "  "); err != nil {
+		return err
+	} else if err = os.WriteFile(CustomTablesStorePath, data, 0600); err != nil {
+		return err
 	}
 	return nil
 }
@@ -193,7 +199,7 @@ func (m *RoutingManager) syncAllRoutesWorker() {
 	m.mu.Lock()
 	defer m.mu.Unlock()
 
-	m.cleanRogueDefaultRoutes()
+	// Unmanaged routes belong to DHCP, the host or other routing daemons.
 
 	liveRoutes, err := netlink.RouteList(nil, netlink.FAMILY_V4)
 	if err != nil {
@@ -232,14 +238,16 @@ func (m *RoutingManager) syncAllRoutesWorker() {
 			seen[k] = true
 			if !kernelRouteGroups[k] {
 				log.Printf("[RouteSync] Missing kernel route %s table %s (id %d) metric %d -> Restoring", normDst, r.RoutingTable, tableID, r.Distance)
-				_ = m.syncRouteGroupToKernel(r.DstAddress, r.RoutingTable, r.Distance)
+				if err := m.syncRouteGroupToKernel(r.DstAddress, r.RoutingTable, r.Distance); err != nil {
+					log.Printf("route synchronization failed: %v", err)
+				}
 			}
 		}
 	}
 }
 
 func (m *RoutingManager) applyAll() {
-	m.cleanRogueDefaultRoutes()
+	// Unmanaged routes belong to DHCP, the host or other routing daemons.
 
 	// Apply all static routes grouped by (dst, table, distance) for ECMP
 	type groupKey struct {
@@ -512,9 +520,21 @@ func (m *RoutingManager) ListRoutes() []RouteItem {
 	return liveRoutes
 }
 
-func (m *RoutingManager) SaveRoute(r RouteItem) error {
+func (m *RoutingManager) SaveRoute(r RouteItem) (err error) {
 	m.mu.Lock()
 	defer m.mu.Unlock()
+	before := append([]RouteItem(nil), m.staticRoutes...)
+	dirty, applied := false, false
+	defer func() {
+		if err != nil && dirty {
+			m.staticRoutes = before
+			if applied {
+				_ = m.syncRouteGroupToKernel(r.DstAddress, r.RoutingTable, r.Distance)
+			}
+			m.applyAll()
+			_ = m.save()
+		}
+	}()
 
 	if r.DstAddress == "" {
 		return fmt.Errorf("destination address is required")
@@ -525,6 +545,10 @@ func (m *RoutingManager) SaveRoute(r RouteItem) error {
 	if r.RoutingTable == "" {
 		r.RoutingTable = "main"
 	}
+	if _, _, parseErr := net.ParseCIDR(r.DstAddress); parseErr != nil && net.ParseIP(r.DstAddress) == nil {
+		return fmt.Errorf("invalid destination: %s", r.DstAddress)
+	}
+	dirty = true
 	r.Static = true
 
 	// 1. If old route details are provided from edit modal, remove old route first
@@ -617,16 +641,23 @@ func (m *RoutingManager) SaveRoute(r RouteItem) error {
 		}
 	}
 
-	_ = m.save()
+	if saveErr := m.save(); saveErr != nil {
+		return saveErr
+	}
 
-	_ = m.syncRouteGroupToKernel(r.DstAddress, r.RoutingTable, r.Distance)
+	if err := m.syncRouteGroupToKernel(r.DstAddress, r.RoutingTable, r.Distance); err != nil {
+		return err
+	}
+	applied = true
 	if r.OldDstAddress != "" {
 		oldDist := 0
 		if r.OldDistance != nil {
 			oldDist = *r.OldDistance
 		}
 		if r.OldDstAddress != r.DstAddress || r.OldRoutingTable != r.RoutingTable || oldDist != r.Distance {
-			_ = m.syncRouteGroupToKernel(r.OldDstAddress, r.OldRoutingTable, oldDist)
+			if err := m.syncRouteGroupToKernel(r.OldDstAddress, r.OldRoutingTable, oldDist); err != nil {
+				return err
+			}
 		}
 	}
 

+ 55 - 34
internal/services/manager.go

@@ -33,6 +33,7 @@ type ServiceHook func(serviceName string, port int, disabled bool) error
 
 type Manager struct {
 	mu       sync.RWMutex
+	applyMu  sync.Mutex
 	filePath string
 	services map[string]Service
 	hooks    map[string]ServiceHook
@@ -189,8 +190,15 @@ func (m *Manager) GetService(name string) (Service, bool) {
 }
 
 func (m *Manager) SetServiceState(name string, disabled bool) error {
+	m.applyMu.Lock()
+	defer m.applyMu.Unlock()
 	m.mu.Lock()
-	defer m.mu.Unlock()
+	locked := true
+	defer func() {
+		if locked {
+			m.mu.Unlock()
+		}
+	}()
 
 	key := normalizeServiceName(name)
 	svc, ok := m.services[key]
@@ -198,28 +206,48 @@ func (m *Manager) SetServiceState(name string, disabled bool) error {
 		return fmt.Errorf("service %q not found", name)
 	}
 
+	previous := svc
 	svc.Disabled = disabled
 	m.services[key] = svc
 
 	if err := m.saveLocked(); err != nil {
+		m.services[key] = previous
 		return err
 	}
 
 	logging.Log("system,info", "Service %s state changed: disabled=%t", key, disabled)
 
 	// Trigger hook if registered
-	if hook, exists := m.hooks[key]; exists {
-		go func() {
-			_ = hook(key, svc.Port, svc.Disabled)
-		}()
+	hook := m.hooks[key]
+	m.mu.Unlock()
+	locked = false
+	if hook != nil {
+		if err := hook(key, svc.Port, svc.Disabled); err != nil {
+			m.mu.Lock()
+			m.services[key] = previous
+			saveErr := m.saveLocked()
+			m.mu.Unlock()
+			_ = hook(key, previous.Port, previous.Disabled)
+			if saveErr != nil {
+				return fmt.Errorf("service apply failed: %v; rollback save failed: %w", err, saveErr)
+			}
+			return err
+		}
 	}
 
 	return nil
 }
 
 func (m *Manager) SetService(name string, port int, disabled bool, certificate ...string) error {
+	m.applyMu.Lock()
+	defer m.applyMu.Unlock()
 	m.mu.Lock()
-	defer m.mu.Unlock()
+	locked := true
+	defer func() {
+		if locked {
+			m.mu.Unlock()
+		}
+	}()
 
 	key := normalizeServiceName(name)
 	svc, ok := m.services[key]
@@ -227,6 +255,7 @@ func (m *Manager) SetService(name string, port int, disabled bool, certificate .
 		return fmt.Errorf("service %q not found", name)
 	}
 
+	previous := svc
 	if port > 0 && port < 65536 {
 		svc.Port = port
 	}
@@ -237,44 +266,36 @@ func (m *Manager) SetService(name string, port int, disabled bool, certificate .
 	m.services[key] = svc
 
 	if err := m.saveLocked(); err != nil {
+		m.services[key] = previous
 		return err
 	}
 
 	logging.Log("system,info", "Service %s updated: port=%d, disabled=%t, certificate=%s", key, svc.Port, svc.Disabled, svc.Certificate)
 
-	if hook, exists := m.hooks[key]; exists {
-		go func() {
-			_ = hook(key, svc.Port, svc.Disabled)
-		}()
+	hook := m.hooks[key]
+	m.mu.Unlock()
+	locked = false
+	if hook != nil {
+		if err := hook(key, svc.Port, svc.Disabled); err != nil {
+			m.mu.Lock()
+			m.services[key] = previous
+			saveErr := m.saveLocked()
+			m.mu.Unlock()
+			_ = hook(key, previous.Port, previous.Disabled)
+			if saveErr != nil {
+				return fmt.Errorf("service apply failed: %v; rollback save failed: %w", err, saveErr)
+			}
+			return err
+		}
 	}
 
 	return nil
 }
 
-func (m *Manager) SetServiceCertificate(name string, certName string) error {
-	m.mu.Lock()
-	defer m.mu.Unlock()
-
-	key := normalizeServiceName(name)
-	svc, ok := m.services[key]
+func (m *Manager) SetServiceCertificate(name, certName string) error {
+	svc, ok := m.GetService(name)
 	if !ok {
-		return fmt.Errorf("service %q not found", name)
-	}
-
-	svc.Certificate = certName
-	m.services[key] = svc
-
-	if err := m.saveLocked(); err != nil {
-		return err
-	}
-
-	logging.Log("system,info", "Service %s certificate set to %q", key, certName)
-
-	if hook, exists := m.hooks[key]; exists {
-		go func() {
-			_ = hook(key, svc.Port, svc.Disabled)
-		}()
+		return fmt.Errorf("service not found: %s", name)
 	}
-
-	return nil
+	return m.SetService(name, svc.Port, svc.Disabled, certName)
 }

+ 34 - 0
internal/services/manager_test.go

@@ -0,0 +1,34 @@
+package services
+
+import (
+	"bytes"
+	"fmt"
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+func TestFailedHookRestoresStoredService(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "services.json")
+	m := NewManager(path)
+	if err := m.SetService("ssh", 2222, false); err != nil {
+		t.Fatal(err)
+	}
+	before, _ := os.ReadFile(path)
+	old, _ := m.GetService("ssh")
+	m.RegisterHook("ssh", func(name string, port int, disabled bool) error {
+		_, _ = m.GetService(name)
+		if port == 3333 {
+			return fmt.Errorf("port in use")
+		}
+		return nil
+	})
+	if err := m.SetService("ssh", 3333, false); err == nil {
+		t.Fatal("listener error lost")
+	}
+	current, _ := m.GetService("ssh")
+	after, _ := os.ReadFile(path)
+	if current != old || !bytes.Equal(before, after) {
+		t.Fatal("failed change was not rolled back")
+	}
+}

+ 23 - 8
internal/vpn/ipsec.go

@@ -308,8 +308,12 @@ func (m *IPSecManager) generateSwanctlConfig() error {
 		return err
 	}
 
-	_ = exec.Command("systemctl", "start", "strongswan").Run()
-	_ = exec.Command("swanctl", "--load-all").Run()
+	if err := exec.Command("systemctl", "start", "strongswan").Run(); err != nil {
+		return err
+	}
+	if out, err := exec.Command("swanctl", "--load-all").CombinedOutput(); err != nil {
+		return fmt.Errorf("swanctl load failed: %s: %w", out, err)
+	}
 
 	return nil
 }
@@ -334,9 +338,16 @@ func sanitizeConnName(raw string) string {
 func (m *IPSecManager) StartTunnel(name string) error {
 	m.mu.Lock()
 	defer m.mu.Unlock()
+	return m.startTunnelLocked(name)
+}
+func (m *IPSecManager) startTunnelLocked(name string) error {
 
-	_ = exec.Command("systemctl", "start", "strongswan").Run()
-	_ = exec.Command("swanctl", "--load-all").Run()
+	if err := exec.Command("systemctl", "start", "strongswan").Run(); err != nil {
+		return err
+	}
+	if out, err := exec.Command("swanctl", "--load-all").CombinedOutput(); err != nil {
+		return fmt.Errorf("swanctl load failed: %s: %w", out, err)
+	}
 
 	var p2Name string
 	for _, c := range m.config.Connections {
@@ -362,6 +373,9 @@ func (m *IPSecManager) StartTunnel(name string) error {
 func (m *IPSecManager) StopTunnel(name string) error {
 	m.mu.Lock()
 	defer m.mu.Unlock()
+	return m.stopTunnelLocked(name)
+}
+func (m *IPSecManager) stopTunnelLocked(name string) error {
 
 	var p2Name string
 	for _, c := range m.config.Connections {
@@ -387,13 +401,14 @@ func (m *IPSecManager) SetEnabled(name string, enabled bool) error {
 	for i, c := range m.config.Connections {
 		if c.Name == name {
 			m.config.Connections[i].Disabled = !enabled
-			_ = m.save()
+			if err := m.save(); err != nil {
+				return err
+			}
 			if enabled {
-				go m.StartTunnel(name)
+				return m.startTunnelLocked(name)
 			} else {
-				go m.StopTunnel(name)
+				return m.stopTunnelLocked(name)
 			}
-			return nil
 		}
 	}
 	return fmt.Errorf("connection %q not found", name)

+ 22 - 30
internal/vpn/l2tp.go

@@ -9,7 +9,7 @@ import (
 	"path/filepath"
 	"strings"
 	"sync"
-	"time"
+	"syscall"
 
 	"github.com/vishvananda/netlink"
 )
@@ -103,12 +103,10 @@ func (m *L2TPManager) Save(inst L2TPInstance) error {
 	}
 
 	if !inst.Disabled {
-		go m.connectInstance(inst.Name)
+		return m.connectInstance(inst.Name)
 	} else {
-		go m.disconnectInstance(inst.Name)
+		return m.disconnectInstance(inst.Name)
 	}
-
-	return nil
 }
 
 func (m *L2TPManager) Delete(name string) error {
@@ -125,7 +123,9 @@ func (m *L2TPManager) Delete(name string) error {
 	}
 	m.instances = updated
 
-	_ = m.save()
+	if err := m.save(); err != nil {
+		return err
+	}
 	return m.regenerateXl2tpdConf()
 }
 
@@ -136,13 +136,14 @@ func (m *L2TPManager) SetEnabled(name string, enabled bool) error {
 	for i, inst := range m.instances {
 		if inst.Name == name {
 			m.instances[i].Disabled = !enabled
-			_ = m.save()
+			if err := m.save(); err != nil {
+				return err
+			}
 			if enabled {
-				go m.connectInstance(name)
+				return m.connectInstance(name)
 			} else {
-				go m.disconnectInstance(name)
+				return m.disconnectInstance(name)
 			}
-			return nil
 		}
 	}
 	return fmt.Errorf("instance %q not found", name)
@@ -246,32 +247,23 @@ func (m *L2TPManager) regenerateXl2tpdConf() error {
 		return err
 	}
 
-	_ = exec.Command("systemctl", "restart", "xl2tpd").Run()
-	return nil
-}
-
-func (m *L2TPManager) connectInstance(name string) error {
-	time.Sleep(300 * time.Millisecond)
-	if _, err := os.Stat(L2TPControlFIFO); err == nil {
-		f, err := os.OpenFile(L2TPControlFIFO, os.O_WRONLY, 0600)
-		if err == nil {
-			_, _ = f.WriteString(fmt.Sprintf("c %s\n", name))
-			_ = f.Close()
-		}
+	if err := exec.Command("systemctl", "restart", "xl2tpd").Run(); err != nil {
+		return err
 	}
 	return nil
 }
 
-func (m *L2TPManager) disconnectInstance(name string) error {
-	if _, err := os.Stat(L2TPControlFIFO); err == nil {
-		f, err := os.OpenFile(L2TPControlFIFO, os.O_WRONLY, 0600)
-		if err == nil {
-			_, _ = f.WriteString(fmt.Sprintf("d %s\n", name))
-			_ = f.Close()
-		}
+func writeL2TPControl(command, name string) error {
+	f, err := os.OpenFile(L2TPControlFIFO, os.O_WRONLY|syscall.O_NONBLOCK, 0600)
+	if err != nil {
+		return fmt.Errorf("L2TP control unavailable: %w", err)
 	}
-	return nil
+	defer f.Close()
+	_, err = fmt.Fprintf(f, "%s %s\n", command, name)
+	return err
 }
+func (m *L2TPManager) connectInstance(name string) error    { return writeL2TPControl("c", name) }
+func (m *L2TPManager) disconnectInstance(name string) error { return writeL2TPControl("d", name) }
 
 func (m *L2TPManager) refreshRuntimeStatus() {
 	psOut, _ := exec.Command("ps", "-ef").Output()

+ 10 - 9
internal/vpn/pppoe.go

@@ -114,12 +114,10 @@ func (m *PPPoEManager) Save(inst PPPoEInstance) error {
 
 	// Auto-connect if enabled and not disabled
 	if !inst.Disabled {
-		go m.connectInstance(inst.Name)
+		return m.connectInstance(inst.Name)
 	} else {
-		go m.disconnectInstance(inst.Name)
+		return m.disconnectInstance(inst.Name)
 	}
-
-	return nil
 }
 
 func (m *PPPoEManager) Delete(name string) error {
@@ -146,13 +144,14 @@ func (m *PPPoEManager) SetEnabled(name string, enabled bool) error {
 	for i, inst := range m.instances {
 		if inst.Name == name {
 			m.instances[i].Disabled = !enabled
-			_ = m.save()
+			if err := m.save(); err != nil {
+				return err
+			}
 			if enabled {
-				go m.connectInstance(name)
+				return m.connectInstance(name)
 			} else {
-				go m.disconnectInstance(name)
+				return m.disconnectInstance(name)
 			}
-			return nil
 		}
 	}
 	return fmt.Errorf("instance %q not found", name)
@@ -275,7 +274,9 @@ func (m *PPPoEManager) RenameInterface(oldName, newName string) {
 		}
 	}
 	if changed {
-		_ = m.save()
+		if err := m.save(); err != nil {
+			return
+		}
 	}
 }
 

+ 7 - 8
internal/vpn/pptp.go

@@ -101,12 +101,10 @@ func (m *PPTPManager) Save(inst PPTPInstance) error {
 	}
 
 	if !inst.Disabled {
-		go m.connectInstance(inst.Name)
+		return m.connectInstance(inst.Name)
 	} else {
-		go m.disconnectInstance(inst.Name)
+		return m.disconnectInstance(inst.Name)
 	}
-
-	return nil
 }
 
 func (m *PPTPManager) Delete(name string) error {
@@ -133,13 +131,14 @@ func (m *PPTPManager) SetEnabled(name string, enabled bool) error {
 	for i, inst := range m.instances {
 		if inst.Name == name {
 			m.instances[i].Disabled = !enabled
-			_ = m.save()
+			if err := m.save(); err != nil {
+				return err
+			}
 			if enabled {
-				go m.connectInstance(name)
+				return m.connectInstance(name)
 			} else {
-				go m.disconnectInstance(name)
+				return m.disconnectInstance(name)
 			}
-			return nil
 		}
 	}
 	return fmt.Errorf("instance %q not found", name)

+ 108 - 42
internal/vpn/wireguard.go

@@ -36,6 +36,9 @@ func NewWireGuardManager() *WireGuardManager {
 	// Autostart all enabled WireGuard interfaces
 	for _, iface := range m.interfaces {
 		if !iface.Disabled {
+			if link, err := netlink.LinkByName(iface.Name); err == nil && link.Type() == "wireguard" {
+				continue
+			}
 			name := iface.Name
 			go func(n string) {
 				_ = m.startWGInterface(n)
@@ -100,11 +103,15 @@ func (m *WireGuardManager) load() {
 
 func (m *WireGuardManager) save() error {
 	_ = os.MkdirAll(filepath.Dir(WGInterfacesStorePath), 0755)
-	if data, err := json.MarshalIndent(m.interfaces, "", "  "); err == nil {
-		_ = os.WriteFile(WGInterfacesStorePath, data, 0600)
+	if data, err := json.MarshalIndent(m.interfaces, "", "  "); err != nil {
+		return err
+	} else if err = os.WriteFile(WGInterfacesStorePath, data, 0600); err != nil {
+		return err
 	}
-	if data, err := json.MarshalIndent(m.peers, "", "  "); err == nil {
-		_ = os.WriteFile(WGPeersStorePath, data, 0600)
+	if data, err := json.MarshalIndent(m.peers, "", "  "); err != nil {
+		return err
+	} else if err = os.WriteFile(WGPeersStorePath, data, 0600); err != nil {
+		return err
 	}
 	return nil
 }
@@ -176,18 +183,35 @@ func (m *WireGuardManager) SaveInterface(req SaveWireGuardInterfaceRequest) erro
 		req.MTU = 1420
 	}
 
+	var old *WireGuardInterface
+	for i := range m.interfaces {
+		if m.interfaces[i].Name == req.Name || (req.OrigName != "" && m.interfaces[i].Name == req.OrigName) {
+			copy := m.interfaces[i]
+			old = &copy
+			break
+		}
+	}
+	if req.PrivateKey == "" && old != nil {
+		req.PrivateKey = old.PrivateKey
+		req.PublicKey = old.PublicKey
+	}
 	// Auto generate or derive public key
 	if req.PrivateKey != "" {
 		pub, err := m.CalcPublicKey(req.PrivateKey)
-		if err == nil && pub != "" {
-			req.PublicKey = pub
+		if err != nil {
+			return err
 		}
+		if pub == "" {
+			return fmt.Errorf("invalid private key")
+		}
+		req.PublicKey = pub
 	} else {
 		priv, pub, err := m.GenerateKeyPair()
-		if err == nil {
-			req.PrivateKey = priv
-			req.PublicKey = pub
+		if err != nil {
+			return err
 		}
+		req.PrivateKey = priv
+		req.PublicKey = pub
 	}
 
 	// Support interface renaming: clean up old interface if renamed
@@ -224,16 +248,20 @@ func (m *WireGuardManager) SaveInterface(req SaveWireGuardInterfaceRequest) erro
 		m.interfaces = append(m.interfaces, req.WireGuardInterface)
 	}
 
-	_ = m.save()
-	_ = m.generateWGConfigFile(req.Name)
+	if err := m.save(); err != nil {
+		return err
+	}
+	if err := m.generateWGConfigFile(req.Name); err != nil {
+		return err
+	}
 
+	if old != nil && old.Name == req.Name && old.Disabled == req.Disabled && old.PrivateKey == req.PrivateKey && old.ListenPort == req.ListenPort && old.Address == req.Address && old.MTU == req.MTU {
+		return nil
+	}
 	if !req.Disabled {
-		go m.startWGInterface(req.Name)
-	} else {
-		go m.stopWGInterface(req.Name)
+		return m.startWGInterface(req.Name)
 	}
-
-	return nil
+	return m.stopWGInterface(req.Name)
 }
 
 func (m *WireGuardManager) DeleteInterface(name string) error {
@@ -270,13 +298,14 @@ func (m *WireGuardManager) SetInterfaceEnabled(name string, enabled bool) error
 	for i, iface := range m.interfaces {
 		if iface.Name == name {
 			m.interfaces[i].Disabled = !enabled
-			_ = m.save()
+			if err := m.save(); err != nil {
+				return err
+			}
 			if enabled {
-				go m.startWGInterface(name)
+				return m.startWGInterface(name)
 			} else {
-				go m.stopWGInterface(name)
+				return m.stopWGInterface(name)
 			}
-			return nil
 		}
 	}
 	return fmt.Errorf("interface %q not found", name)
@@ -295,7 +324,9 @@ func (m *WireGuardManager) StopInterface(name string) error {
 }
 
 func (m *WireGuardManager) startWGInterface(name string) error {
-	_ = m.generateWGConfigFile(name)
+	if err := m.generateWGConfigFile(name); err != nil {
+		return err
+	}
 	_ = exec.Command("wg-quick", "down", name).Run()
 
 	cmd := exec.Command("wg-quick", "up", name)
@@ -308,8 +339,16 @@ func (m *WireGuardManager) startWGInterface(name string) error {
 
 func (m *WireGuardManager) stopWGInterface(name string) error {
 	_ = exec.Command("systemctl", "disable", fmt.Sprintf("wg-quick@%s", name)).Run()
+	if _, err := netlink.LinkByName(name); err != nil {
+		if _, absent := err.(netlink.LinkNotFoundError); absent {
+			return nil
+		}
+		return err
+	}
 	cmd := exec.Command("wg-quick", "down", name)
-	_ = cmd.Run()
+	if out, err := cmd.CombinedOutput(); err != nil {
+		return fmt.Errorf("failed to stop %s: %s: %w", name, out, err)
+	}
 	return nil
 }
 
@@ -420,7 +459,9 @@ func (m *WireGuardManager) SavePeer(req SaveWireGuardPeerRequest) error {
 		}
 		// Remove old peer from runtime
 		if _, err := netlink.LinkByName(oldIface); err == nil {
-			_ = exec.Command("wg", "set", oldIface, "peer", req.OrigPublicKey, "remove").Run()
+			if out, err := exec.Command("wg", "set", oldIface, "peer", req.OrigPublicKey, "remove").CombinedOutput(); err != nil {
+				return fmt.Errorf("wg command failed: %s: %w", out, err)
+			}
 		}
 
 		// Remove old peer entry
@@ -441,6 +482,12 @@ func (m *WireGuardManager) SavePeer(req SaveWireGuardPeerRequest) error {
 	req.WireGuardPeer.EndpointAddress = req.EndpointAddress
 	req.WireGuardPeer.EndpointPort = req.EndpointPort
 
+	clearEndpoint := false
+	for _, old := range m.peers {
+		if old.Interface == req.Interface && old.PublicKey == req.PublicKey && old.EndpointAddress != "" && req.EndpointAddress == "" {
+			clearEndpoint = true
+		}
+	}
 	found := false
 	for i, existing := range m.peers {
 		if existing.PublicKey == req.PublicKey && existing.Interface == req.Interface {
@@ -453,29 +500,36 @@ func (m *WireGuardManager) SavePeer(req SaveWireGuardPeerRequest) error {
 		m.peers = append(m.peers, req.WireGuardPeer)
 	}
 
-	_ = m.save()
-	_ = m.generateWGConfigFile(req.Interface)
+	if err := m.save(); err != nil {
+		return err
+	}
+	if err := m.generateWGConfigFile(req.Interface); err != nil {
+		return err
+	}
 
 	// Apply dynamically to kernel if running
 	if _, err := netlink.LinkByName(req.Interface); err == nil {
 		if req.Disabled {
-			_ = exec.Command("wg", "set", req.Interface, "peer", req.PublicKey, "remove").Run()
+			if out, err := exec.Command("wg", "set", req.Interface, "peer", req.PublicKey, "remove").CombinedOutput(); err != nil {
+				return fmt.Errorf("wg command failed: %s: %w", out, err)
+			}
 		} else {
-			args := []string{"set", req.Interface, "peer", req.PublicKey}
-			if len(req.AllowedIPs) > 0 {
-				args = append(args, "allowed-ips", strings.Join(req.AllowedIPs, ","))
+			if clearEndpoint {
+				if out, err := exec.Command("wg", "set", req.Interface, "peer", req.PublicKey, "remove").CombinedOutput(); err != nil {
+					return fmt.Errorf("clear WireGuard endpoint: %s: %w", out, err)
+				}
 			}
+			args := []string{"set", req.Interface, "peer", req.PublicKey}
+			args = append(args, "allowed-ips", strings.Join(req.AllowedIPs, ","))
 			if req.EndpointAddress != "" {
 				ep := req.EndpointAddress
 				if req.EndpointPort > 0 {
-					ep = fmt.Sprintf("%s:%d", req.EndpointAddress, req.EndpointPort)
+					ep = net.JoinHostPort(req.EndpointAddress, strconv.Itoa(req.EndpointPort))
 				}
 				args = append(args, "endpoint", ep)
 			}
-			if req.PersistentKeepalive > 0 {
-				args = append(args, "persistent-keepalive", strconv.Itoa(req.PersistentKeepalive))
-			}
-			if req.PresharedKey != "" {
+			args = append(args, "persistent-keepalive", strconv.Itoa(req.PersistentKeepalive))
+			if true {
 				pskFile, err := os.CreateTemp("", "wg-psk-*")
 				if err == nil {
 					_, _ = pskFile.WriteString(req.PresharedKey)
@@ -484,7 +538,9 @@ func (m *WireGuardManager) SavePeer(req SaveWireGuardPeerRequest) error {
 					defer os.Remove(pskFile.Name())
 				}
 			}
-			_ = exec.Command("wg", args...).Run()
+			if out, err := exec.Command("wg", args...).CombinedOutput(); err != nil {
+				return fmt.Errorf("wg set failed: %s: %w", out, err)
+			}
 		}
 	}
 
@@ -507,12 +563,16 @@ func (m *WireGuardManager) SetPeerEnabled(ifaceName, pubKey string, enabled bool
 		return fmt.Errorf("peer with public key %s not found on interface %s", pubKey, ifaceName)
 	}
 
-	_ = m.save()
+	if err := m.save(); err != nil {
+		return err
+	}
 	_ = m.generateWGConfigFile(ifaceName)
 
 	if _, err := netlink.LinkByName(ifaceName); err == nil {
 		if !enabled {
-			_ = exec.Command("wg", "set", ifaceName, "peer", pubKey, "remove").Run()
+			if out, err := exec.Command("wg", "set", ifaceName, "peer", pubKey, "remove").CombinedOutput(); err != nil {
+				return fmt.Errorf("wg command failed: %s: %w", out, err)
+			}
 		} else {
 			for _, p := range m.peers {
 				if p.Interface == ifaceName && p.PublicKey == pubKey {
@@ -524,7 +584,7 @@ func (m *WireGuardManager) SetPeerEnabled(ifaceName, pubKey string, enabled bool
 					if epHost != "" {
 						ep := epHost
 						if epPort > 0 {
-							ep = fmt.Sprintf("%s:%d", epHost, epPort)
+							ep = net.JoinHostPort(epHost, strconv.Itoa(epPort))
 						}
 						args = append(args, "endpoint", ep)
 					}
@@ -540,7 +600,9 @@ func (m *WireGuardManager) SetPeerEnabled(ifaceName, pubKey string, enabled bool
 							defer os.Remove(pskFile.Name())
 						}
 					}
-					_ = exec.Command("wg", args...).Run()
+					if out, err := exec.Command("wg", args...).CombinedOutput(); err != nil {
+						return fmt.Errorf("wg command failed: %s: %w", out, err)
+					}
 					break
 				}
 			}
@@ -562,12 +624,16 @@ func (m *WireGuardManager) DeletePeer(ifaceName, pubKey string) error {
 	}
 	m.peers = updated
 
-	_ = m.save()
+	if err := m.save(); err != nil {
+		return err
+	}
 	_ = m.generateWGConfigFile(ifaceName)
 
 	// Remove from runtime
 	if _, err := netlink.LinkByName(ifaceName); err == nil {
-		_ = exec.Command("wg", "set", ifaceName, "peer", pubKey, "remove").Run()
+		if out, err := exec.Command("wg", "set", ifaceName, "peer", pubKey, "remove").CombinedOutput(); err != nil {
+			return fmt.Errorf("wg command failed: %s: %w", out, err)
+		}
 	}
 	return nil
 }
@@ -615,7 +681,7 @@ func (m *WireGuardManager) generateWGConfigFile(ifaceName string) error {
 			if epHost != "" {
 				ep := epHost
 				if epPort > 0 {
-					ep = fmt.Sprintf("%s:%d", epHost, epPort)
+					ep = net.JoinHostPort(epHost, strconv.Itoa(epPort))
 				}
 				sb.WriteString(fmt.Sprintf("Endpoint = %s\n", ep))
 			}

+ 77 - 0
internal/vpn/wireguard_integration_test.go

@@ -0,0 +1,77 @@
+package vpn
+
+import (
+	"github.com/vishvananda/netlink"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+func TestWireGuardEditAndClearingInNamespace(t *testing.T) {
+	if os.Getenv("DEBTIK_WG_INTEGRATION") != "1" {
+		t.Skip("isolated mount/network namespaces required")
+	}
+	for _, target := range []string{"/etc/proxmox-router/vpn", "/etc/wireguard"} {
+		if err := exec.Command("mount", "--bind", t.TempDir(), target).Run(); err != nil {
+			t.Fatal(err)
+		}
+	}
+	bin := t.TempDir()
+	if err := os.WriteFile(filepath.Join(bin, "systemctl"), []byte("#!/bin/sh\nexit 0\n"), 0755); err != nil {
+		t.Fatal(err)
+	}
+	t.Setenv("PATH", bin+":"+os.Getenv("PATH"))
+	m := &WireGuardManager{}
+	req := SaveWireGuardInterfaceRequest{WireGuardInterface: WireGuardInterface{Name: "wg-audit0", Address: "10.99.241.1/30", MTU: 1420}}
+	if err := m.SaveInterface(req); err != nil {
+		t.Fatal(err)
+	}
+	before, err := netlink.LinkByName("wg-audit0")
+	if err != nil {
+		t.Fatal(err)
+	}
+	key := m.interfaces[0].PrivateKey
+	req.Comment = "comment only"
+	if err := m.SaveInterface(req); err != nil {
+		t.Fatal(err)
+	}
+	after, err := netlink.LinkByName("wg-audit0")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if before.Attrs().Index != after.Attrs().Index || key != m.interfaces[0].PrivateKey {
+		t.Fatal("comment edit restarted interface or changed key")
+	}
+	psk, pub, err := m.GenerateKeyPair()
+	if err != nil {
+		t.Fatal(err)
+	}
+	peer := SaveWireGuardPeerRequest{WireGuardPeer: WireGuardPeer{Interface: "wg-audit0", PublicKey: pub, PresharedKey: psk, AllowedIPs: []string{"192.0.2.0/24"}, EndpointAddress: "192.0.2.1", EndpointPort: 51820, PersistentKeepalive: 25}}
+	if err := m.SavePeer(peer); err != nil {
+		t.Fatal(err)
+	}
+	peer.PresharedKey = ""
+	peer.AllowedIPs = nil
+	peer.EndpointAddress = ""
+	peer.EndpointPort = 0
+	peer.PersistentKeepalive = 0
+	if err := m.SavePeer(peer); err != nil {
+		t.Fatal(err)
+	}
+	for _, field := range []string{"persistent-keepalive", "endpoints", "allowed-ips", "preshared-keys"} {
+		out, err := exec.Command("wg", "show", "wg-audit0", field).CombinedOutput()
+		if err != nil {
+			t.Fatal(field, err)
+		}
+		text := string(out)
+		if field == "persistent-keepalive" {
+			if !strings.Contains(text, "off") {
+				t.Fatal("keepalive not cleared")
+			}
+		} else if !strings.Contains(text, "(none)") {
+			t.Fatal(field, "not cleared")
+		}
+	}
+}

+ 17 - 7
internal/web/static/app.js

@@ -3807,9 +3807,9 @@ async function loadFirewall(populateDropdowns = true) {
     const bGeo = document.getElementById('fw-badge-geo');
     if (bGeo) bGeo.innerText = state.geoStatus?.enabled_count || 0;
 
-    const sigFilter = (state.filterRules || []).map(r => `${r.id}:${r.chain}:${r.action}:${r.disabled}`).join(';');
-    const sigNat = (state.natRules || []).map(r => `${r.id}:${r.chain}:${r.action}:${r.disabled}`).join(';');
-    const sigMangle = (state.mangleRules || []).map(r => `${r.id}:${r.chain}:${r.action}:${r.disabled}`).join(';');
+    const sigFilter = (state.filterRules || []).map(r => JSON.stringify({...r, packets: 0, bytes: 0})).join(';');
+    const sigNat = (state.natRules || []).map(r => JSON.stringify({...r, packets: 0, bytes: 0})).join(';');
+    const sigMangle = (state.mangleRules || []).map(r => JSON.stringify({...r, packets: 0, bytes: 0})).join(';');
     const sigAddr = (state.addressLists || []).map(a => `${a.id}:${a.name}:${a.address}:${a.disabled}`).join(';');
 
     const tbodyFilter = document.getElementById('filter-table-body');
@@ -3910,7 +3910,7 @@ function renderFilterRules() {
       <td class="py-2.5 px-3 font-bold text-white">${r.chain}</td>
       <td class="py-2.5 px-3 text-slate-300">${formatTarget(r.src_address, r.src_address_list, r.src_geoip)}</td>
       <td class="py-2.5 px-3 text-slate-300">${formatTarget(r.dst_address, r.dst_address_list, r.dst_geoip)}</td>
-      <td class="py-2.5 px-3 text-cyan-400 uppercase">${r.protocol || 'all'}</td>
+      <td class="py-2.5 px-3 text-cyan-400 uppercase">${r.protocol || 'all'} <span class="text-[9px] text-slate-500">IPv${r.ip_version || 4}</span></td>
       <td class="py-2.5 px-3 text-amber-400">${ports || '-'}</td>
       <td class="py-2.5 px-3 text-emerald-400">${ifaces || '-'}</td>
       <td class="py-2.5 px-3">${renderActionBadge(r.action)}${actionExtra}</td>
@@ -4156,6 +4156,7 @@ function openAddFilterModal() {
   const badge = document.getElementById('fw-modal-badge');
   if (badge) badge.innerText = 'New...';
   document.getElementById('filter-modal-id').value = '';
+  document.getElementById('filter-modal-family').value = '4';
   document.getElementById('filter-modal-enabled').checked = true;
   document.getElementById('filter-modal-comment').value = '';
   document.getElementById('filter-modal-chain').value = 'forward';
@@ -4196,6 +4197,7 @@ function openEditFilterModal(id) {
   if (badge) badge.innerText = `#${r.order != null ? r.order : r.id}`;
 
   document.getElementById('filter-modal-id').value = r.id;
+  document.getElementById('filter-modal-family').value = String(r.ip_version || 4);
   document.getElementById('filter-modal-enabled').checked = !r.disabled;
   document.getElementById('filter-modal-comment').value = r.comment || '';
   document.getElementById('filter-modal-chain').value = r.chain || 'forward';
@@ -4453,6 +4455,7 @@ async function applyFilterRule(closeAfter = false) {
   }
 
   const payload = {
+    ip_version: Number(document.getElementById('filter-modal-family').value),
     id,
     chain,
     action,
@@ -4758,7 +4761,7 @@ function renderNATRules() {
       <td class="py-2.5 px-3 font-bold text-amber-400">${r.chain}</td>
       <td class="py-2.5 px-3 text-slate-300">${formatTarget(r.src_address, r.src_address_list, r.src_geoip)}</td>
       <td class="py-2.5 px-3 text-slate-300">${formatTarget(r.dst_address, r.dst_address_list, r.dst_geoip)}</td>
-      <td class="py-2.5 px-3 text-cyan-400 uppercase">${r.protocol || 'all'}${r.dst_port ? ':' + r.dst_port : ''}</td>
+      <td class="py-2.5 px-3 text-cyan-400 uppercase">${r.protocol || 'all'}${r.dst_port ? ':' + r.dst_port : ''} <span class="text-[9px] text-slate-500">IPv${r.ip_version || 4}</span></td>
       <td class="py-2.5 px-3 text-emerald-400">${ifaces || '-'}</td>
       <td class="py-2.5 px-3">${renderActionBadge(r.action)}</td>
       <td class="py-2.5 px-3 font-mono text-xs">${toTarget}</td>
@@ -4934,6 +4937,7 @@ function openAddNATModal() {
   const badge = document.getElementById('nat-modal-badge');
   if (badge) badge.innerText = 'New...';
   document.getElementById('nat-modal-id').value = '';
+  document.getElementById('nat-modal-family').value = '4';
   document.getElementById('nat-modal-enabled').checked = true;
   document.getElementById('nat-modal-comment').value = '';
   document.getElementById('nat-modal-chain').value = 'srcnat';
@@ -4976,6 +4980,7 @@ function openEditNATModal(id) {
   if (badge) badge.innerText = `#${r.order != null ? r.order : r.id}`;
 
   document.getElementById('nat-modal-id').value = r.id;
+  document.getElementById('nat-modal-family').value = String(r.ip_version || 4);
   document.getElementById('nat-modal-enabled').checked = !r.disabled;
   document.getElementById('nat-modal-comment').value = r.comment || '';
   document.getElementById('nat-modal-chain').value = r.chain || 'srcnat';
@@ -5148,6 +5153,7 @@ async function applyNATRule(closeAfter = false) {
   const usesPort = ['src-nat', 'dst-nat', 'netmap', 'same', 'socksify', 'endpoint-independent-nat', 'redirect', 'masquerade'].includes(action);
 
   const payload = {
+    ip_version: Number(document.getElementById('nat-modal-family').value),
     id,
     chain,
     action,
@@ -5598,6 +5604,7 @@ function openAddMangleModal() {
   const badge = document.getElementById('mangle-modal-badge');
   if (badge) badge.innerText = 'New...';
   document.getElementById('mangle-modal-id').value = '';
+  document.getElementById('mangle-modal-family').value = '4';
   document.getElementById('mangle-modal-enabled').checked = true;
   document.getElementById('mangle-modal-comment').value = '';
   document.getElementById('mangle-modal-chain').value = 'prerouting';
@@ -5642,6 +5649,7 @@ function openEditMangleModal(id) {
   if (badge) badge.innerText = `#${r.order != null ? r.order : r.id}`;
 
   document.getElementById('mangle-modal-id').value = r.id;
+  document.getElementById('mangle-modal-family').value = String(r.ip_version || 4);
   document.getElementById('mangle-modal-enabled').checked = !r.disabled;
   document.getElementById('mangle-modal-comment').value = r.comment || '';
   document.getElementById('mangle-modal-chain').value = r.chain || 'prerouting';
@@ -5862,6 +5870,7 @@ async function applyMangleRule(closeAfter = false) {
   }
 
   const payload = {
+    ip_version: Number(document.getElementById('mangle-modal-family').value),
     id,
     chain,
     action,
@@ -10859,7 +10868,7 @@ function openAddBridgeModal() {
   document.getElementById('bridge-modal-name').disabled = false;
   document.getElementById('bridge-modal-mtu').value = '1500';
   document.getElementById('bridge-modal-mac').value = '';
-  document.getElementById('bridge-modal-stp').value = 'rstp';
+  document.getElementById('bridge-modal-stp').value = 'stp';
   document.getElementById('bridge-modal-priority').value = '32768';
   document.getElementById('bridge-modal-vlanfiltering').checked = false;
   document.getElementById('bridge-modal-pvid').value = '1';
@@ -10880,7 +10889,7 @@ function openEditBridgeModal(id) {
   document.getElementById('bridge-modal-name').disabled = true;
   document.getElementById('bridge-modal-mtu').value = b.mtu || 1500;
   document.getElementById('bridge-modal-mac').value = b.mac_address || '';
-  document.getElementById('bridge-modal-stp').value = b.stp || 'rstp';
+  document.getElementById('bridge-modal-stp').value = b.stp || 'stp';
   document.getElementById('bridge-modal-priority').value = b.priority || 32768;
   document.getElementById('bridge-modal-vlanfiltering').checked = !!b.vlan_filtering;
   document.getElementById('bridge-modal-pvid').value = b.pvid || 1;
@@ -16955,6 +16964,7 @@ function renderCertificatesTable() {
         <td class="py-3 px-4 max-w-xs">${sansHtml}</td>
         <td class="py-3 px-4 whitespace-nowrap text-xs text-slate-300">
           <span>${validStr}</span> ${validBadge}
+          ${c.type === 'acme' ? `<div class="text-[10px] ${c.renewal_error ? 'text-rose-400' : 'text-slate-400'}">${c.renewal_error ? 'Renewal failed: ' + escapeHtml(c.renewal_error) : 'Automatic renewal enabled'}</div>` : ''}
           <div class="text-[10px] text-slate-500">${c.key_type || ''}</div>
         </td>
         <td class="py-3 px-4 text-right whitespace-nowrap">

+ 7 - 4
internal/web/static/index.html

@@ -5805,6 +5805,7 @@
         <!-- Form Content Container (Scrollable) -->
         <form id="fw-rule-form" onsubmit="event.preventDefault(); applyFilterRule(true);" class="flex-1 overflow-y-auto px-4 py-3 min-w-0 space-y-1">
           <input type="hidden" id="filter-modal-id">
+          <label class="block text-xs text-slate-300">IP family<select id="filter-modal-family" class="bg-slate-800 rounded p-2 ml-2"><option value="4">IPv4</option><option value="6">IPv6</option></select></label>
 
           <!-- TAB PANE: GENERAL -->
           <div id="fw-pane-general" class="fw-rule-pane space-y-1">
@@ -6783,6 +6784,7 @@
         <!-- Form Content Container (Scrollable) -->
         <form id="nat-rule-form" onsubmit="event.preventDefault(); applyNATRule(true);" class="flex-1 overflow-y-auto px-4 py-3 min-w-0 space-y-1">
           <input type="hidden" id="nat-modal-id">
+          <label class="block text-xs text-slate-300">IP family<select id="nat-modal-family" class="bg-slate-800 rounded p-2 ml-2"><option value="4">IPv4</option><option value="6">IPv6</option></select></label>
 
           <!-- TAB PANE: GENERAL (Screenshot 1) -->
           <div id="nat-pane-general" class="nat-rule-pane space-y-1">
@@ -7711,6 +7713,7 @@
         <!-- Form Content Container (Scrollable) -->
         <form id="mangle-rule-form" onsubmit="event.preventDefault(); applyMangleRule(true);" class="flex-1 overflow-y-auto px-4 py-3 min-w-0 space-y-1">
           <input type="hidden" id="mangle-modal-id">
+          <label class="block text-xs text-slate-300">IP family<select id="mangle-modal-family" class="bg-slate-800 rounded p-2 ml-2"><option value="4">IPv4</option><option value="6">IPv6</option></select></label>
 
           <!-- TAB PANE: GENERAL (Screenshot 1) -->
           <div id="mangle-pane-general" class="mangle-rule-pane space-y-1">
@@ -11389,8 +11392,8 @@
           <div>
             <label class="block font-semibold text-slate-300 mb-1.5">STP Protocol</label>
             <select id="bridge-modal-stp" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
-              <option value="rstp" selected>RSTP (Rapid STP - Recommended)</option>
-              <option value="stp">STP (IEEE 802.1D)</option>
+              <option value="rstp">RSTP (requires mstpd)</option>
+              <option value="stp" selected>STP (IEEE 802.1D)</option>
               <option value="none">none (Disable STP)</option>
             </select>
           </div>
@@ -11497,8 +11500,8 @@
             <span class="text-white font-semibold">MAC Learning</span>
           </label>
           <label class="flex items-center space-x-2 cursor-pointer">
-            <input type="checkbox" id="bridge-port-modal-ingressfiltering" class="rounded bg-slate-800 border-slate-700 text-sky-500">
-            <span class="text-white font-semibold">Ingress Filtering</span>
+            <input type="checkbox" id="bridge-port-modal-ingressfiltering" disabled class="rounded bg-slate-800 border-slate-700 text-sky-500">
+            <span class="text-white font-semibold">Ingress filtering (controlled by bridge VLAN filtering)</span>
           </label>
         </div>
 

+ 14 - 0
scripts/debtik-update

@@ -34,10 +34,15 @@ restore() {
     cp -p "$base/previous/$name" "/usr/local/bin/$name.rollback"
     mv -f "/usr/local/bin/$name.rollback" "/usr/local/bin/$name"
   done
+  for name in debtik-update debtik-release-version; do
+    if [[ -f "$base/previous/$name" ]]; then install -m 0750 "$base/previous/$name" "/usr/local/libexec/$name.rollback"; mv -f "/usr/local/libexec/$name.rollback" "/usr/local/libexec/$name"; fi
+  done
   if [[ -f "$base/previous_commit" ]]; then cp -p "$base/previous_commit" "$base/installed_commit"; fi
   if [[ -f "$base/previous_version" ]]; then cp -p "$base/previous_version" "$base/installed_version"; fi
   systemctl start proxmox-router.service
   systemctl is-active --quiet proxmox-router.service
+  if [[ -f "$base/previous/iptables.save" ]]; then iptables-restore -c < "$base/previous/iptables.save"; fi
+  if [[ -s "$base/previous/ip6tables.save" ]]; then ip6tables-restore -c < "$base/previous/ip6tables.save"; fi
   state rolled_back
 }
 if [[ ${1:-} == rollback ]]; then restore; exit; fi
@@ -66,6 +71,8 @@ GIT_TERMINAL_PROMPT=0 git -c protocol.file.allow=never "${git_opts[@]}" clone --
 cd "$work/source"
 commit=$(git rev-parse HEAD)
 [[ $(cat VERSION) == "$version" ]] || { state failed; echo 'Release tag and VERSION do not match'; exit 1; }
+bash -n scripts/debtik-update
+python3 -c 'import pathlib; compile(pathlib.Path("scripts/release-version.py").read_text(),"release-version.py","exec")'
 state building
 go_cmd=$(command -v go || true)
 if [[ -x /opt/debtik/go/bin/go ]]; then go_cmd=/opt/debtik/go/bin/go; fi
@@ -74,9 +81,12 @@ CGO_ENABLED=0 "$go_cmd" build -trimpath -ldflags "-X proxmox-router/internal/api
 CGO_ENABLED=0 "$go_cmd" build -trimpath -o "$work/debtik-cli" ./cmd/debtik-cli
 install -d -m 0700 "$base/previous"
 for name in proxmox-router debtik-cli; do cp -p "/usr/local/bin/$name" "$base/previous/$name"; done
+for name in debtik-update debtik-release-version; do cp -p "/usr/local/libexec/$name" "$base/previous/$name"; done
 cp -p "$base/installed_version" "$base/previous_version"
 if [[ -f "$base/installed_commit" ]]; then cp -p "$base/installed_commit" "$base/previous_commit"; fi
 printf '%s\n' "$commit" > "$base/candidate_commit"
+iptables-save -c > "$base/previous/iptables.save"
+ip6tables-save -c > "$base/previous/ip6tables.save"
 state installing
 rollback_on_error() { trap - ERR; restore || state failed; exit 1; }
 trap rollback_on_error ERR
@@ -86,6 +96,10 @@ for name in proxmox-router debtik-cli; do
   install -m 0755 "$work/$name" "/usr/local/bin/$name.next"
   mv -f "/usr/local/bin/$name.next" "/usr/local/bin/$name"
 done
+install -m 0750 scripts/debtik-update /usr/local/libexec/debtik-update.next
+install -m 0750 scripts/release-version.py /usr/local/libexec/debtik-release-version.next
+mv -f /usr/local/libexec/debtik-update.next /usr/local/libexec/debtik-update
+mv -f /usr/local/libexec/debtik-release-version.next /usr/local/libexec/debtik-release-version
 systemctl start proxmox-router.service
 sleep 3
 systemctl is-active --quiet proxmox-router.service

+ 1 - 1
scripts/ui-smoke.cjs

@@ -19,7 +19,7 @@ fs.mkdirSync(root,{recursive:true});
     await page.locator('#login-submit-btn').click();
     await page.waitForFunction(() => state.currentUser !== null);
     await page.evaluate(() => fetchData(false));
-    const tabs = ['dashboard','interfaces','bridge','firewall','routes','bgp','ospf','routing-filters','ppp','eoip','aerotun','pppoe','wireguard','ipsec','l2tp','pptp','dhcp-client','dhcp-server','vrrp','tools','bwtest','settings','users','certificates','services','logs'];
+    const tabs = ['dashboard','interfaces','bridge','firewall','routes','bgp','ospf','routing-filters','ppp','eoip','aerotun','pppoe','wireguard','ipsec','l2tp','pptp','dhcp-client','dhcp-server','vrrp','tools','bwtest','settings','users','certificates','services','updates','logs'];
     for(const tab of tabs) {
       await page.evaluate(async tab => { navigate(tab); await fetchData(false); }, tab);
       await page.waitForTimeout(200);

+ 61 - 0
update_helpers.go

@@ -0,0 +1,61 @@
+package debtik
+
+import (
+	_ "embed"
+	"fmt"
+	"os"
+	"path/filepath"
+)
+
+// Embed the helpers so an upgrade launched by a legacy updater also updates
+// its own tooling when the newly installed server starts.
+//
+//go:embed scripts/debtik-update
+var updateHelper []byte
+
+//go:embed scripts/release-version.py
+var versionHelper []byte
+
+// RefreshUpdateHelpers refreshes an existing installation, without creating one
+// when the server is run from a development checkout.
+func RefreshUpdateHelpers(dir string) error {
+	if _, err := os.Stat(filepath.Join(dir, "debtik-update")); os.IsNotExist(err) {
+		return nil
+	} else if err != nil {
+		return err
+	}
+	for _, helper := range []struct {
+		name string
+		data []byte
+	}{{"debtik-update", updateHelper}, {"debtik-release-version", versionHelper}} {
+		if err := writeHelper(dir, helper.name, helper.data); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func writeHelper(dir, name string, data []byte) error {
+	f, err := os.CreateTemp(dir, ".debtik-helper-*")
+	if err != nil {
+		return err
+	}
+	defer os.Remove(f.Name())
+	if err = f.Chmod(0750); err == nil {
+		_, err = f.Write(data)
+	}
+	if err == nil {
+		err = f.Sync()
+	}
+	closeErr := f.Close()
+	if err == nil {
+		err = closeErr
+	}
+	if err == nil {
+		err = os.Rename(f.Name(), filepath.Join(dir, name))
+	}
+	if err != nil {
+		return fmt.Errorf("install %s: %w", name, err)
+	}
+	return nil
+}

+ 44 - 0
update_helpers_test.go

@@ -0,0 +1,44 @@
+package debtik
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+func TestRefreshLegacyUpdater(t *testing.T) {
+	dir := t.TempDir()
+	if err := RefreshUpdateHelpers(dir); err != nil {
+		t.Fatal(err)
+	}
+	if entries, _ := os.ReadDir(dir); len(entries) != 0 {
+		t.Fatal("created helpers for an unmanaged server")
+	}
+	path := filepath.Join(dir, "debtik-update")
+	if err := os.WriteFile(path, []byte("legacy"), 0700); err != nil {
+		t.Fatal(err)
+	}
+	// An older update process may still hold the old inode open.
+	old, err := os.Open(path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer old.Close()
+	if err = RefreshUpdateHelpers(dir); err != nil {
+		t.Fatal(err)
+	}
+	for name, want := range map[string]string{"debtik-update": string(updateHelper), "debtik-release-version": string(versionHelper)} {
+		got, err := os.ReadFile(filepath.Join(dir, name))
+		if err != nil || string(got) != want {
+			t.Fatalf("%s was not refreshed: %v", name, err)
+		}
+		info, _ := os.Stat(filepath.Join(dir, name))
+		if info.Mode().Perm() != 0750 {
+			t.Fatal("wrong helper permissions")
+		}
+	}
+	buf := make([]byte, 6)
+	if _, err = old.Read(buf); err != nil || string(buf) != "legacy" {
+		t.Fatal("replacement modified the running updater inode")
+	}
+}