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

Release 1.2.0: preserve host networking and fix router configuration workflows

Proxmox Router 4 дней назад
Родитель
Сommit
644c114c64
42 измененных файлов с 2173 добавлено и 556 удалено
  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.
 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
 ## Install
 
 
 On a Debian 13 host or container with systemd and root access:
 On a Debian 13 host or container with systemd and root access:
@@ -12,7 +14,7 @@ cd DebTik
 sudo ./install.sh
 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.
 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
 ## 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.
 `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"
 	"flag"
 	"fmt"
 	"fmt"
 	"log"
 	"log"
+	"net"
 	"net/http"
 	"net/http"
 	"os"
 	"os"
 	"os/signal"
 	"os/signal"
+	release "proxmox-router"
 	"sync"
 	"sync"
 	"syscall"
 	"syscall"
 	"time"
 	"time"
@@ -38,6 +40,11 @@ func main() {
 	cliAddr := flag.String("cli-addr", ":8282", "CLI service address for Telnet and debtik-cli (e.g. :8282)")
 	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")
 	metaPath := flag.String("meta", "/etc/proxmox-router/metadata.json", "Path to interface metadata storage file")
 	flag.Parse()
 	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
 	// Initialize Logging Ring Buffer
 	_ = logging.GetLogger()
 	_ = logging.GetLogger()
@@ -66,6 +73,9 @@ func main() {
 	if err != nil {
 	if err != nil {
 		log.Fatalf("Failed to initialize certificate manager: %v", err)
 		log.Fatalf("Failed to initialize certificate manager: %v", err)
 	}
 	}
+	renewCtx, stopRenewal := context.WithCancel(context.Background())
+	defer stopRenewal()
+	go certMgr.RunRenewal(renewCtx)
 	webHandler := web.Handler()
 	webHandler := web.Handler()
 
 
 	// Build Unified RouterOS CLI Executor
 	// Build Unified RouterOS CLI Executor
@@ -105,11 +115,7 @@ func main() {
 		cliTelnetServer.Stop()
 		cliTelnetServer.Stop()
 		if !disabled {
 		if !disabled {
 			cliTelnetServer.SetAddr(fmt.Sprintf(":%d", port))
 			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
 		return nil
 	})
 	})
@@ -118,11 +124,7 @@ func main() {
 		cliSSHServer.Stop()
 		cliSSHServer.Stop()
 		if !disabled {
 		if !disabled {
 			cliSSHServer.SetAddr(fmt.Sprintf(":%d", port))
 			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
 		return nil
 	})
 	})
@@ -175,7 +177,7 @@ func main() {
 		httpsRunning bool
 		httpsRunning bool
 	)
 	)
 
 
-	startOrRestartHTTPS := func(port int, disabled bool) {
+	startOrRestartHTTPS := func(port int, disabled bool) error {
 		httpsMu.Lock()
 		httpsMu.Lock()
 		defer httpsMu.Unlock()
 		defer httpsMu.Unlock()
 
 
@@ -189,19 +191,17 @@ func main() {
 
 
 		if disabled {
 		if disabled {
 			log.Printf("[HTTPS] Web SSL (www-ssl) listener stopped")
 			log.Printf("[HTTPS] Web SSL (www-ssl) listener stopped")
-			return
+			return nil
 		}
 		}
 
 
 		sslSvc, ok := servicesMgr.GetService("www-ssl")
 		sslSvc, ok := servicesMgr.GetService("www-ssl")
 		if !ok || sslSvc.Certificate == "" {
 		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)
 		tlsCert, err := certMgr.GetTLSCertificate(sslSvc.Certificate)
 		if err != nil {
 		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{
 		tlsConfig := &tls.Config{
@@ -226,27 +226,33 @@ func main() {
 			WriteTimeout: 60 * time.Second,
 			WriteTimeout: 60 * time.Second,
 			IdleTimeout:  120 * time.Second,
 			IdleTimeout:  120 * time.Second,
 		}
 		}
+		listener, err := tls.Listen("tcp", listenAddr, tlsConfig)
+		if err != nil {
+			return err
+		}
 		httpsServer = newSrv
 		httpsServer = newSrv
 		httpsRunning = true
 		httpsRunning = true
 
 
 		go func() {
 		go func() {
 			log.Printf("[HTTPS] Web SSL (www-ssl) server listening on %s with certificate %q", listenAddr, sslSvc.Certificate)
 			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)
 				log.Printf("[HTTPS] Server error: %v", err)
 				httpsMu.Lock()
 				httpsMu.Lock()
 				httpsRunning = false
 				httpsRunning = false
 				httpsMu.Unlock()
 				httpsMu.Unlock()
 			}
 			}
 		}()
 		}()
+		return nil
 	}
 	}
 
 
 	servicesMgr.RegisterHook("www-ssl", func(serviceName string, port int, disabled bool) error {
 	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 {
 	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() {
 	defer func() {
 		httpsMu.Lock()
 		httpsMu.Lock()
@@ -256,12 +262,59 @@ func main() {
 		httpsMu.Unlock()
 		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
 	// Print startup banner
@@ -287,20 +340,40 @@ func main() {
 	stopChan := make(chan os.Signal, 1)
 	stopChan := make(chan os.Signal, 1)
 	signal.Notify(stopChan, os.Interrupt, syscall.SIGTERM)
 	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
 	<-stopChan
 	fmt.Println("\nShutting down DebTik Controller gracefully...")
 	fmt.Println("\nShutting down DebTik Controller gracefully...")
 
 
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 	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.")
 	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
 if [[ $EUID -ne 0 ]]; then echo 'Run as root' >&2; exit 1; fi
 src=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
 src=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
 command -v apt-get >/dev/null || { echo 'Debian apt-get required' >&2; exit 1; }
 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
 export DEBIAN_FRONTEND=noninteractive
 apt-get update
 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")
 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 [[ -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
 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
   case $(uname -m) in x86_64) arch=amd64;; aarch64) arch=arm64;; *) echo 'Unsupported CPU' >&2; exit 1;; esac
   archive=$(mktemp)
   archive=$(mktemp)
-  trap 'rm -f "$archive"' EXIT
   curl --fail --location --retry 3 "https://go.dev/dl/go${required}.linux-${arch}.tar.gz" -o "$archive"
   curl --fail --location --retry 3 "https://go.dev/dl/go${required}.linux-${arch}.tar.gz" -o "$archive"
   install -d /opt/debtik
   install -d /opt/debtik
   rm -rf /opt/debtik/go
   rm -rf /opt/debtik/go
@@ -21,13 +35,11 @@ if ! command -v go >/dev/null || [[ $(printf '%s\n%s\n' "$required" "$(go versio
 fi
 fi
 cd "$src"
 cd "$src"
 build=$(mktemp -d)
 build=$(mktemp -d)
-trap 'rm -rf "$build"' EXIT
 commit=$(git rev-parse HEAD 2>/dev/null || printf unknown)
 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 -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
 CGO_ENABLED=0 go build -trimpath -o "$build/debtik-cli" ./cmd/debtik-cli
 install -d -m 0700 /etc/proxmox-router
 install -d -m 0700 /etc/proxmox-router
 install -d -m 0700 /var/lib/debtik/update
 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 -d -m 0755 /usr/local/libexec
 install -m 0750 scripts/debtik-update /usr/local/libexec/debtik-update
 install -m 0750 scripts/debtik-update /usr/local/libexec/debtik-update
 install -m 0750 scripts/release-version.py /usr/local/libexec/debtik-release-version
 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 enable proxmox-router.service
 systemctl restart proxmox-router.service
 systemctl restart proxmox-router.service
 systemctl is-active --quiet 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
 install -m 0600 VERSION /var/lib/debtik/update/installed_version
 echo 'DebTik installed: http://<host>/ and debtik-cli'
 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
 	// Persist to Debian network configuration
 	if s.persistMgr != nil {
 	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})
 	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
 	// Persist to Debian network configuration
 	if s.persistMgr != nil {
 	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})
 	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
 		return
 	}
 	}
 
 
+	previous := s.metaMgr.Get(name)
 	if err := s.metaMgr.Set(name, req.Role, req.Description); err != nil {
 	if err := s.metaMgr.Set(name, req.Role, req.Description); err != nil {
 		writeError(w, http.StatusInternalServerError, err.Error())
 		writeError(w, http.StatusInternalServerError, err.Error())
 		return
 		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})
 	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)
 		s.vpnMgr.RenameInterface(name, targetName)
 	}
 	}
 	if s.persistMgr != nil {
 	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{
 	writeJSON(w, http.StatusOK, map[string]any{

+ 201 - 104
internal/bridge/manager.go

@@ -3,7 +3,9 @@ package bridge
 import (
 import (
 	"bufio"
 	"bufio"
 	"encoding/json"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"fmt"
+	"log"
 	"net"
 	"net"
 	"os"
 	"os"
 	"os/exec"
 	"os/exec"
@@ -12,6 +14,7 @@ import (
 	"strconv"
 	"strconv"
 	"strings"
 	"strings"
 	"sync"
 	"sync"
+	"syscall"
 	"time"
 	"time"
 
 
 	"github.com/vishvananda/netlink"
 	"github.com/vishvananda/netlink"
@@ -103,8 +106,10 @@ func (m *BridgeManager) ListBridges() []*BridgeInstance {
 
 
 	list := make([]*BridgeInstance, 0, len(m.bridges))
 	list := make([]*BridgeInstance, 0, len(m.bridges))
 	for _, b := range 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 {
 	sort.Slice(list, func(i, j int) bool {
@@ -119,19 +124,28 @@ func (m *BridgeManager) GetBridge(idOrName string) (*BridgeInstance, bool) {
 	defer m.mu.Unlock()
 	defer m.mu.Unlock()
 
 
 	if b, ok := m.bridges[idOrName]; ok {
 	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 {
 	for _, b := range m.bridges {
 		if b.Name == idOrName {
 		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
 	return nil, false
 }
 }
 
 
 func (m *BridgeManager) SaveBridge(b BridgeInstance) error {
 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()
 	m.mu.Lock()
 	defer m.mu.Unlock()
 	defer m.mu.Unlock()
 
 
@@ -145,7 +159,7 @@ func (m *BridgeManager) SaveBridge(b BridgeInstance) error {
 		b.PVID = 1
 		b.PVID = 1
 	}
 	}
 	if b.STP == "" {
 	if b.STP == "" {
-		b.STP = "rstp"
+		b.STP = "stp"
 	}
 	}
 	if b.Priority <= 0 {
 	if b.Priority <= 0 {
 		b.Priority = 32768
 		b.Priority = 32768
@@ -156,11 +170,21 @@ func (m *BridgeManager) SaveBridge(b BridgeInstance) error {
 	}
 	}
 
 
 	// Update existing or add new
 	// 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.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 {
 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
 	// Detach any ports belonging to this bridge
 	for _, p := range m.ports {
 	for _, p := range m.ports {
 		if p.Bridge == b.Name {
 		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)
 			delete(m.ports, p.ID)
 		}
 		}
 	}
 	}
@@ -188,7 +214,9 @@ func (m *BridgeManager) DeleteBridge(id string) error {
 	}
 	}
 
 
 	// Delete bridge link in kernel
 	// 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)
 	delete(m.bridges, id)
 	return m.save()
 	return m.save()
@@ -204,12 +232,18 @@ func (m *BridgeManager) SetBridgeState(id string, disabled bool) error {
 	}
 	}
 
 
 	b.Disabled = disabled
 	b.Disabled = disabled
-	_ = m.save()
+	if err := m.save(); err != nil {
+		return err
+	}
 
 
 	if disabled {
 	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 {
 	} 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
 	return nil
 }
 }
@@ -245,10 +279,14 @@ func (m *BridgeManager) AddBridgeIP(idOrName string, ipCIDR string) error {
 	}
 	}
 
 
 	b.IPAddresses = append(b.IPAddresses, ipCIDR)
 	b.IPAddresses = append(b.IPAddresses, ipCIDR)
-	_ = m.save()
+	if err := m.save(); err != nil {
+		return err
+	}
 
 
 	// Add to kernel
 	// 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
 	return nil
 }
 }
 
 
@@ -278,13 +316,23 @@ func (m *BridgeManager) DeleteBridgeIP(idOrName string, ipCIDR string) error {
 		}
 		}
 	}
 	}
 	b.IPAddresses = updated
 	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
 	return nil
 }
 }
 
 
 func (m *BridgeManager) syncBridge(b *BridgeInstance) error {
 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)
 	link, err := netlink.LinkByName(b.Name)
 	if err != nil {
 	if err != nil {
 		// Create bridge
 		// Create bridge
@@ -306,13 +354,17 @@ func (m *BridgeManager) syncBridge(b *BridgeInstance) error {
 	if link != nil {
 	if link != nil {
 		// Set MTU
 		// Set MTU
 		if b.MTU > 0 {
 		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
 		// Set Admin MAC if specified
 		if b.MACAddress != "" {
 		if b.MACAddress != "" {
 			if hw, err := net.ParseMAC(b.MACAddress); err == nil {
 			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 {
 		if b.PVID <= 0 {
 			pvidVal = "1"
 			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
 		// Set STP
 		stpVal := "0"
 		stpVal := "0"
 		if b.STP == "rstp" || b.STP == "stp" {
 		if b.STP == "rstp" || b.STP == "stp" {
 			stpVal = "1"
 			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
 		// Set link up or down
 		if b.Disabled {
 		if b.Disabled {
-			_ = netlink.LinkSetDown(link)
+			if err := netlink.LinkSetDown(link); err != nil {
+				return err
+			}
 		} else {
 		} else {
-			_ = netlink.LinkSetUp(link)
+			if err := netlink.LinkSetUp(link); err != nil {
+				return err
+			}
 		}
 		}
 
 
 		// Sync IP addresses
 		// Sync IP addresses
 		for _, ip := range b.IPAddresses {
 		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())
 		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.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 {
 func (m *BridgeManager) DeletePort(id string) error {
@@ -453,7 +541,9 @@ func (m *BridgeManager) DeletePort(id string) error {
 	}
 	}
 
 
 	// Remove from bridge in kernel
 	// 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)
 	delete(m.ports, id)
 	return m.save()
 	return m.save()
@@ -469,19 +559,27 @@ func (m *BridgeManager) SetPortState(id string, disabled bool) error {
 	}
 	}
 
 
 	p.Disabled = disabled
 	p.Disabled = disabled
-	_ = m.save()
+	if err := m.save(); err != nil {
+		return err
+	}
 
 
 	if disabled {
 	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 {
 	} else {
-		_ = m.syncPort(p)
+		if err := m.syncPort(p); err != nil {
+			return err
+		}
 	}
 	}
 	return nil
 	return nil
 }
 }
 
 
 func (m *BridgeManager) syncPort(p *BridgePort) error {
 func (m *BridgeManager) syncPort(p *BridgePort) error {
 	if p.Disabled {
 	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
 		return nil
 	}
 	}
 
 
@@ -496,50 +594,61 @@ func (m *BridgeManager) syncPort(p *BridgePort) error {
 	}
 	}
 
 
 	// Bring interface UP
 	// 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
 	// Port isolation / Split Horizon
 	isolated := "off"
 	isolated := "off"
 	if p.Horizon > 0 {
 	if p.Horizon > 0 {
 		isolated = "on"
 		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
 	learning := "on"
 	learning := "on"
 	if !p.Learn {
 	if !p.Learn {
 		learning = "off"
 		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
 	// Priority & Path Cost
 	prioCostArgs := []string{"link", "set", "dev", p.Interface}
 	prioCostArgs := []string{"link", "set", "dev", p.Interface}
 	if p.Priority > 0 {
 	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 {
 	if p.PathCost > 0 {
 		prioCostArgs = append(prioCostArgs, "cost", strconv.Itoa(p.PathCost))
 		prioCostArgs = append(prioCostArgs, "cost", strconv.Itoa(p.PathCost))
 	}
 	}
 	if len(prioCostArgs) > 4 {
 	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
 	// VLAN filtering configuration
 	if p.PVID > 0 {
 	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
 	return nil
 }
 }
 
 
 func (m *BridgeManager) updatePortLiveStatus(p *BridgePort) {
 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 {
 	if p.Disabled {
 		p.Status = "disabled"
 		p.Status = "disabled"
 		return
 		return
@@ -587,86 +696,72 @@ func (m *BridgeManager) ListVLANs() []*BridgeVLAN {
 func (m *BridgeManager) SaveVLAN(v BridgeVLAN) error {
 func (m *BridgeManager) SaveVLAN(v BridgeVLAN) error {
 	m.mu.Lock()
 	m.mu.Lock()
 	defer m.mu.Unlock()
 	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 == "" {
 	if v.ID == "" {
 		v.ID = fmt.Sprintf("bvlan-%d", time.Now().UnixNano())
 		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.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 {
 func (m *BridgeManager) DeleteVLAN(id string) error {
 	m.mu.Lock()
 	m.mu.Lock()
 	defer m.mu.Unlock()
 	defer m.mu.Unlock()
-
-	v, ok := m.vlans[id]
+	old, ok := m.vlans[id]
 	if !ok {
 	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)
 	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 {
 func (m *BridgeManager) SetVLANState(id string, disabled bool) error {
 	m.mu.Lock()
 	m.mu.Lock()
 	defer m.mu.Unlock()
 	defer m.mu.Unlock()
-
-	v, ok := m.vlans[id]
+	old, ok := m.vlans[id]
 	if !ok {
 	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
 	return nil
 }
 }
+func (m *BridgeManager) syncVLAN(v *BridgeVLAN) error { return syncMembers(nil, v) }
 
 
 // ==================== Bridge Hosts (FDB / MAC Table) ====================
 // ==================== Bridge Hosts (FDB / MAC Table) ====================
 
 
@@ -765,7 +860,9 @@ func (m *BridgeManager) FlushHosts(bridge string) error {
 	if bridge == "" {
 	if bridge == "" {
 		return fmt.Errorf("bridge name required for flush")
 		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
 	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 {
 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 {
 type CreateCARequest struct {
@@ -90,6 +93,7 @@ type ImportCertRequest struct {
 }
 }
 
 
 type LetsEncryptRequest struct {
 type LetsEncryptRequest struct {
+	renew   bool
 	Name    string `json:"name"`
 	Name    string `json:"name"`
 	Domain  string `json:"domain"`
 	Domain  string `json:"domain"`
 	Email   string `json:"email"`
 	Email   string `json:"email"`
@@ -104,6 +108,8 @@ type CertManager struct {
 	certs       map[string]*CertificateInfo
 	certs       map[string]*CertificateInfo
 	challenges  map[string]string // token -> keyAuth
 	challenges  map[string]string // token -> keyAuth
 	challengeMu sync.RWMutex
 	challengeMu sync.RWMutex
+	renewMu     sync.Mutex
+	accountMu   sync.Mutex
 }
 }
 
 
 func NewCertManager(customDir string) (*CertManager, error) {
 func NewCertManager(customDir string) (*CertManager, error) {
@@ -665,7 +671,7 @@ func (cm *CertManager) IssueLetsEncrypt(ctx context.Context, req LetsEncryptRequ
 	}
 	}
 
 
 	cm.mu.Lock()
 	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()
 		cm.mu.Unlock()
 		return nil, fmt.Errorf("certificate %q already exists", name)
 		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)
 	logging.Log("certs,info", "Starting Let's Encrypt order for domain %s (Staging=%t)", domain, req.Staging)
 
 
 	// Generate ACME account key
 	// Generate ACME account key
-	accountKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+	accountKey, err := cm.acmeAccountKey(req.Staging)
 	if err != nil {
 	if err != nil {
 		return nil, fmt.Errorf("failed generating ACME account key: %w", err)
 		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)"
 		issuer = "Let's Encrypt (Staging)"
 	}
 	}
 
 
+	requestCopy := req
+	requestCopy.Name = name
+	requestCopy.renew = false
 	info := &CertificateInfo{
 	info := &CertificateInfo{
+		ACMERequest: &requestCopy,
 		ID:          fmt.Sprintf("cert-%d", time.Now().UnixNano()),
 		ID:          fmt.Sprintf("cert-%d", time.Now().UnixNano()),
 		Name:        name,
 		Name:        name,
 		CommonName:  domain,
 		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)
 	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
 	var sb strings.Builder
 	sb.WriteString("# ==================================================================\n")
 	sb.WriteString("# ==================================================================\n")
 	sb.WriteString("# Enterprise DHCP Server Configuration\n")
 	sb.WriteString("# Enterprise DHCP Server Configuration\n")
@@ -362,12 +379,14 @@ func (sm *ServerManager) applyDnsmasqConfigLocked() error {
 	if activeServerCount > 0 {
 	if activeServerCount > 0 {
 		out, err := exec.Command("systemctl", "restart", "dnsmasq").CombinedOutput()
 		out, err := exec.Command("systemctl", "restart", "dnsmasq").CombinedOutput()
 		if err != nil {
 		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 {
 		} else {
 			log.Printf("[DHCPServer] dnsmasq restarted successfully with %d active DHCP server(s)", activeServerCount)
 			log.Printf("[DHCPServer] dnsmasq restarted successfully with %d active DHCP server(s)", activeServerCount)
 		}
 		}
 	} else {
 	} else {
-		_ = exec.Command("systemctl", "reload", "dnsmasq").Run()
+		if err := exec.Command("systemctl", "reload", "dnsmasq").Run(); err != nil {
+			return err
+		}
 	}
 	}
 
 
 	return nil
 	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.
 // 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
 	var sb strings.Builder
 	sb.WriteString("# ==================================================================\n")
 	sb.WriteString("# ==================================================================\n")
 	sb.WriteString("# Enterprise Router DNS Forwarder & Cache Configuration\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))
 		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
 	// Restart dnsmasq to re-read all configuration files
 	if err := exec.Command("systemctl", "restart", "dnsmasq").Run(); err != nil {
 	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
 	return nil

+ 12 - 3
internal/dynamicrouting/manager.go

@@ -664,7 +664,7 @@ func (m *Manager) applyBirdConfig() error {
 	// Live reload without packet loss
 	// Live reload without packet loss
 	reloadOut, reloadErr := birdcExec("configure")
 	reloadOut, reloadErr := birdcExec("configure")
 	if reloadErr != nil {
 	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
 	return nil
@@ -957,12 +957,21 @@ func (m *Manager) ListOSPFInstances() []OSPFInstance {
 	defer m.mu.RUnlock()
 	defer m.mu.RUnlock()
 
 
 	out, err := birdcExec("show protocols")
 	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))
 	res := make([]OSPFInstance, len(m.config.OSPFInstances))
 	for i, inst := range m.config.OSPFInstances {
 	for i, inst := range m.config.OSPFInstances {
 		instCopy := inst
 		instCopy := inst
-		if birdUp && !inst.Disabled {
+		instCopy.Running = false
+		if protocols[sanitizeIdentifier("ospf", inst.Name)] && !inst.Disabled {
 			instCopy.Running = true
 			instCopy.Running = true
 		}
 		}
 		res[i] = instCopy
 		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) {
 			if _, err := os.Stat(zonePath); os.IsNotExist(err) {
 				_, _ = m.SyncCountryZone(code)
 				_, _ = m.SyncCountryZone(code)
 			} else {
 			} 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()
 	m.mu.RUnlock()
 
 
 	if enabled {
 	if enabled {
-		_ = m.ApplyCountryIPSet(code)
+		if err := m.ApplyCountryIPSet(code); err != nil {
+			return 0, err
+		}
 	}
 	}
 
 
 	return count, nil
 	return count, nil
@@ -396,26 +400,19 @@ func (m *GeoIPManager) ApplyCountryIPSet(code string) error {
 
 
 	setName := "mk_geo_" + code
 	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)
 	scanner := bufio.NewScanner(f)
 	for scanner.Scan() {
 	for scanner.Scan() {
 		line := strings.TrimSpace(scanner.Text())
 		line := strings.TrimSpace(scanner.Text())
 		if line != "" && !strings.HasPrefix(line, "#") {
 		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
 // ApplyAllEnabledSets ensures all enabled countries are present in kernel ipset
@@ -430,7 +427,9 @@ func (m *GeoIPManager) ApplyAllEnabledSets() error {
 	m.mu.RUnlock()
 	m.mu.RUnlock()
 
 
 	for _, code := range enabled {
 	for _, code := range enabled {
-		_ = m.ApplyCountryIPSet(code)
+		if err := m.ApplyCountryIPSet(code); err != nil {
+			return err
+		}
 	}
 	}
 	return nil
 	return nil
 }
 }
@@ -524,30 +523,26 @@ func (m *GeoIPManager) EnsureCountryOrPreset(code string) error {
 
 
 	if len(targetCodes) > 0 {
 	if len(targetCodes) > 0 {
 		setName := "mk_geo_" + code
 		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) {
 			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
 	// Single country

+ 21 - 22
internal/firewall/ipset.go

@@ -1,7 +1,8 @@
 package firewall
 package firewall
 
 
 import (
 import (
-	"os/exec"
+	"fmt"
+	"net"
 	"strings"
 	"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"
 	"encoding/json"
 	"fmt"
 	"fmt"
 	"hash/fnv"
 	"hash/fnv"
+	"net"
 	"os"
 	"os"
 	"os/exec"
 	"os/exec"
 	"sort"
 	"sort"
@@ -37,6 +38,10 @@ func ReadRuleCounters(filterRules []FilterRule, natRules []NATRule, mangleRules
 		return nil, err
 		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)
 	counters := make(map[string][2]uint64)
 	scanner := bufio.NewScanner(bytes.NewReader(out))
 	scanner := bufio.NewScanner(bytes.NewReader(out))
 
 
@@ -356,7 +361,7 @@ func buildNATRuleLines(r NATRule) []string {
 			if r.ToAddresses != "" {
 			if r.ToAddresses != "" {
 				target := r.ToAddresses
 				target := r.ToAddresses
 				if r.ToPorts != "" {
 				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))
 				args = append(args, fmt.Sprintf("-j SNAT --to-source %s", target))
 			} else {
 			} else {
@@ -366,7 +371,7 @@ func buildNATRuleLines(r NATRule) []string {
 			if r.ToAddresses != "" {
 			if r.ToAddresses != "" {
 				target := r.ToAddresses
 				target := r.ToAddresses
 				if r.ToPorts != "" {
 				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))
 				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
 // ApplyIPTables executes `iptables-restore` with the given rule set payload
 func ApplyIPTables(payload string) error {
 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 {
 	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
 	return nil
 }
 }

+ 159 - 62
internal/firewall/manager.go

@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"fmt"
 	"os"
 	"os"
 	"os/exec"
 	"os/exec"
+	"strings"
 	"sync"
 	"sync"
 	"time"
 	"time"
 )
 )
@@ -18,6 +19,7 @@ const (
 
 
 type FirewallManager struct {
 type FirewallManager struct {
 	mu           sync.Mutex
 	mu           sync.Mutex
+	committed    []byte
 	filterRules  []FilterRule
 	filterRules  []FilterRule
 	natRules     []NATRule
 	natRules     []NATRule
 	mangleRules  []MangleRule
 	mangleRules  []MangleRule
@@ -34,6 +36,7 @@ func NewFirewallManager() *FirewallManager {
 		geoIPMgr:     NewGeoIPManager(),
 		geoIPMgr:     NewGeoIPManager(),
 	}
 	}
 	m.load()
 	m.load()
+	m.remember()
 
 
 	// If clean installation and no rules exist, initialize default sensible router rules (like RouterOS default firewall)
 	// 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 {
 	if len(m.filterRules) == 0 || len(m.natRules) == 0 {
@@ -41,6 +44,7 @@ func NewFirewallManager() *FirewallManager {
 	}
 	}
 
 
 	_ = m.Apply()
 	_ = m.Apply()
+	m.remember()
 	return m
 	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
 	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
 // Apply re-synchronizes AddressLists & GeoIP sets with ipset and applies iptables
 func (m *FirewallManager) Apply() error {
 func (m *FirewallManager) Apply() error {
 	// Sync GeoIP country sets
 	// Sync GeoIP country sets
 	if m.geoIPMgr != nil {
 	if m.geoIPMgr != nil {
-		_ = m.geoIPMgr.ApplyAllEnabledSets()
 
 
 		// Also ensure any GeoIP lists explicitly referenced in active rules are loaded into ipset
 		// Also ensure any GeoIP lists explicitly referenced in active rules are loaded into ipset
 		for _, r := range m.filterRules {
 		for _, r := range m.filterRules {
 			if !r.Disabled {
 			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 {
 		for _, r := range m.natRules {
 			if !r.Disabled {
 			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 {
 		for _, r := range m.mangleRules {
 			if !r.Disabled {
 			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
 	var refLists []string
 	for _, r := range m.filterRules {
 	for _, r := range m.filterRules {
 		if !r.Disabled {
 		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 {
 	for _, r := range m.natRules {
 		if !r.Disabled {
 		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 {
 	for _, r := range m.mangleRules {
 		if !r.Disabled {
 		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
 	// 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 {
 func (m *FirewallManager) GeoIP() *GeoIPManager {
@@ -233,8 +323,7 @@ func (m *FirewallManager) SaveFilterRule(r FilterRule) error {
 		m.filterRules = append(m.filterRules, r)
 		m.filterRules = append(m.filterRules, r)
 	}
 	}
 
 
-	_ = m.save()
-	return m.Apply()
+	return m.commitChange()
 }
 }
 
 
 func (m *FirewallManager) DeleteFilterRule(id string) error {
 func (m *FirewallManager) DeleteFilterRule(id string) error {
@@ -252,8 +341,7 @@ func (m *FirewallManager) DeleteFilterRule(id string) error {
 	}
 	}
 	m.filterRules = updated
 	m.filterRules = updated
 
 
-	_ = m.save()
-	return m.Apply()
+	return m.commitChange()
 }
 }
 
 
 func (m *FirewallManager) MoveFilterRule(id string, dir string) error {
 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.filterRules[i].Order = i + 1
 	}
 	}
 
 
-	_ = m.save()
-	return m.Apply()
+	return m.commitChange()
 }
 }
 
 
 func (m *FirewallManager) SetFilterRuleState(id string, disabled bool) error {
 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 {
 	for i := range m.filterRules {
 		if m.filterRules[i].ID == id {
 		if m.filterRules[i].ID == id {
 			m.filterRules[i].Disabled = disabled
 			m.filterRules[i].Disabled = disabled
-			_ = m.save()
-			return m.Apply()
+			return m.commitChange()
 		}
 		}
 	}
 	}
 	return fmt.Errorf("rule not found")
 	return fmt.Errorf("rule not found")
@@ -340,8 +426,7 @@ func (m *FirewallManager) SaveNATRule(r NATRule) error {
 		m.natRules = append(m.natRules, r)
 		m.natRules = append(m.natRules, r)
 	}
 	}
 
 
-	_ = m.save()
-	return m.Apply()
+	return m.commitChange()
 }
 }
 
 
 func (m *FirewallManager) DeleteNATRule(id string) error {
 func (m *FirewallManager) DeleteNATRule(id string) error {
@@ -359,8 +444,7 @@ func (m *FirewallManager) DeleteNATRule(id string) error {
 	}
 	}
 	m.natRules = updated
 	m.natRules = updated
 
 
-	_ = m.save()
-	return m.Apply()
+	return m.commitChange()
 }
 }
 
 
 func (m *FirewallManager) MoveNATRule(id string, dir string) error {
 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.natRules[i].Order = i + 1
 	}
 	}
 
 
-	_ = m.save()
-	return m.Apply()
+	return m.commitChange()
 }
 }
 
 
 func (m *FirewallManager) SetNATRuleState(id string, disabled bool) error {
 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 {
 	for i := range m.natRules {
 		if m.natRules[i].ID == id {
 		if m.natRules[i].ID == id {
 			m.natRules[i].Disabled = disabled
 			m.natRules[i].Disabled = disabled
-			_ = m.save()
-			return m.Apply()
+			return m.commitChange()
 		}
 		}
 	}
 	}
 	return fmt.Errorf("rule not found")
 	return fmt.Errorf("rule not found")
@@ -447,8 +529,7 @@ func (m *FirewallManager) SaveMangleRule(r MangleRule) error {
 		m.mangleRules = append(m.mangleRules, r)
 		m.mangleRules = append(m.mangleRules, r)
 	}
 	}
 
 
-	_ = m.save()
-	return m.Apply()
+	return m.commitChange()
 }
 }
 
 
 func (m *FirewallManager) DeleteMangleRule(id string) error {
 func (m *FirewallManager) DeleteMangleRule(id string) error {
@@ -466,8 +547,7 @@ func (m *FirewallManager) DeleteMangleRule(id string) error {
 	}
 	}
 	m.mangleRules = updated
 	m.mangleRules = updated
 
 
-	_ = m.save()
-	return m.Apply()
+	return m.commitChange()
 }
 }
 
 
 func (m *FirewallManager) MoveMangleRule(id string, dir string) error {
 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.mangleRules[i].Order = i + 1
 	}
 	}
 
 
-	_ = m.save()
-	return m.Apply()
+	return m.commitChange()
 }
 }
 
 
 func (m *FirewallManager) SetMangleRuleState(id string, disabled bool) error {
 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 {
 	for i := range m.mangleRules {
 		if m.mangleRules[i].ID == id {
 		if m.mangleRules[i].ID == id {
 			m.mangleRules[i].Disabled = disabled
 			m.mangleRules[i].Disabled = disabled
-			_ = m.save()
-			return m.Apply()
+			return m.commitChange()
 		}
 		}
 	}
 	}
 	return fmt.Errorf("rule not found")
 	return fmt.Errorf("rule not found")
@@ -544,8 +622,7 @@ func (m *FirewallManager) SaveAddressListEntry(e AddressListEntry) error {
 		m.addressLists = append(m.addressLists, e)
 		m.addressLists = append(m.addressLists, e)
 	}
 	}
 
 
-	_ = m.save()
-	return m.Apply()
+	return m.commitChange()
 }
 }
 
 
 func (m *FirewallManager) DeleteAddressListEntry(id string) error {
 func (m *FirewallManager) DeleteAddressListEntry(id string) error {
@@ -560,8 +637,7 @@ func (m *FirewallManager) DeleteAddressListEntry(id string) error {
 	}
 	}
 	m.addressLists = updated
 	m.addressLists = updated
 
 
-	_ = m.save()
-	return m.Apply()
+	return m.commitChange()
 }
 }
 
 
 func (m *FirewallManager) SetAddressListEntryState(id string, disabled bool) error {
 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 {
 	for i := range m.addressLists {
 		if m.addressLists[i].ID == id {
 		if m.addressLists[i].ID == id {
 			m.addressLists[i].Disabled = disabled
 			m.addressLists[i].Disabled = disabled
-			_ = m.save()
-			return m.Apply()
+			return m.commitChange()
 		}
 		}
 	}
 	}
 	return fmt.Errorf("address list entry not found")
 	return fmt.Errorf("address list entry not found")
 }
 }
 
 
 func (m *FirewallManager) ResetCounters() error {
 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
 	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
 // FilterRule represents a MikroTik-style /ip firewall filter rule
 type FilterRule struct {
 type FilterRule struct {
+	IPVersion       int      `json:"ip_version,omitempty"`
 	ID              string   `json:"id"`
 	ID              string   `json:"id"`
 	Order           int      `json:"order"`
 	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.
 	RejectWith      string   `json:"reject_with,omitempty"` // icmp-network-unreachable, icmp-host-unreachable, tcp-reset, etc.
 	JumpTarget      string   `json:"jump_target,omitempty"`
 	JumpTarget      string   `json:"jump_target,omitempty"`
 	SrcAddress      string   `json:"src_address,omitempty"` // e.g. 192.168.1.0/24, !10.0.0.1
 	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"`
 	ConnectionNATState []string `json:"connection_nat_state,omitempty"`
 
 
 	// Advanced tab
 	// 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"`
 	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
 	// Extra tab
 	Time     string `json:"time,omitempty"`
 	Time     string `json:"time,omitempty"`
@@ -78,12 +79,13 @@ type FilterRule struct {
 
 
 // NATRule represents a MikroTik-style /ip firewall nat rule
 // NATRule represents a MikroTik-style /ip firewall nat rule
 type NATRule struct {
 type NATRule struct {
+	IPVersion      int    `json:"ip_version,omitempty"`
 	ID             string `json:"id"`
 	ID             string `json:"id"`
 	Order          int    `json:"order"`
 	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
 	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"`
 	SrcAddress     string `json:"src_address,omitempty"`
 	DstAddress     string `json:"dst_address,omitempty"`
 	DstAddress     string `json:"dst_address,omitempty"`
 	Protocol       string `json:"protocol,omitempty"`
 	Protocol       string `json:"protocol,omitempty"`
@@ -149,38 +151,39 @@ type NATRule struct {
 
 
 // MangleRule represents a MikroTik-style /ip firewall mangle rule
 // MangleRule represents a MikroTik-style /ip firewall mangle rule
 type MangleRule struct {
 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
 	// Extended WebFig / WinBox v4 properties
 	AnyPort            string   `json:"any_port,omitempty"`
 	AnyPort            string   `json:"any_port,omitempty"`
@@ -246,33 +249,33 @@ type AddressListEntry struct {
 
 
 // GeoCountryInfo represents a Geo-Location country entry in the firewall
 // GeoCountryInfo represents a Geo-Location country entry in the firewall
 type GeoCountryInfo struct {
 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"`
 	LastUpdated time.Time `json:"last_updated,omitempty"`
 }
 }
 
 
 // ActiveConnection represents an active flow from /proc/net/nf_conntrack / conntrack table
 // ActiveConnection represents an active flow from /proc/net/nf_conntrack / conntrack table
 type ActiveConnection struct {
 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
 // FirewallSummary contains total rule and connection counts

+ 114 - 22
internal/network/persistence.go

@@ -2,6 +2,7 @@ package network
 
 
 import (
 import (
 	"fmt"
 	"fmt"
+	"net"
 	"os"
 	"os"
 	"path/filepath"
 	"path/filepath"
 	"strings"
 	"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")
 		sb.WriteString("\n")
 	}
 	}
-
 	return sb.String(), nil
 	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 {
 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 {
 func isDynamicOrVirtualInterface(name string) bool {
@@ -98,7 +120,7 @@ func isDynamicOrVirtualInterface(name string) bool {
 	return strings.HasPrefix(lower, "eoip-") ||
 	return strings.HasPrefix(lower, "eoip-") ||
 		strings.HasPrefix(lower, "wgx-") ||
 		strings.HasPrefix(lower, "wgx-") ||
 		strings.HasPrefix(lower, "wg") ||
 		strings.HasPrefix(lower, "wg") ||
-		strings.HasPrefix(lower, "aerotun-") ||
+		strings.HasPrefix(lower, "aerotun") ||
 		strings.HasPrefix(lower, "ppp") ||
 		strings.HasPrefix(lower, "ppp") ||
 		strings.HasPrefix(lower, "gre")
 		strings.HasPrefix(lower, "gre")
 }
 }
@@ -106,6 +128,8 @@ func isDynamicOrVirtualInterface(name string) bool {
 // GenerateInterfacesConfig creates /etc/network/interfaces.d/interfaces.cfg for regular interfaces
 // GenerateInterfacesConfig creates /etc/network/interfaces.d/interfaces.cfg for regular interfaces
 func (p *PersistenceManager) GenerateInterfacesConfig() (string, error) {
 func (p *PersistenceManager) GenerateInterfacesConfig() (string, error) {
 	ifacesWithIPs := p.netMgr.metadata.GetAllWithIPs()
 	ifacesWithIPs := p.netMgr.metadata.GetAllWithIPs()
+	mainData, _ := os.ReadFile(DebianInterfacesPath)
+	existing := declaredInterfaces(string(mainData))
 	var sb strings.Builder
 	var sb strings.Builder
 	sb.WriteString("# ====================================================\n")
 	sb.WriteString("# ====================================================\n")
 	sb.WriteString("# Proxmox Router Appliance - Interface IP Configuration\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 {
 	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
 			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("# --- Interface %s ---\n", ifaceName))
 		sb.WriteString(fmt.Sprintf("auto %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")
 		sb.WriteString("\n")
 	}
 	}
 
 
@@ -153,10 +191,64 @@ func (p *PersistenceManager) SaveDebianConfig() (string, error) {
 	}
 	}
 
 
 	ifaceCfg, err := p.GenerateInterfacesConfig()
 	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
 	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 {
 func (m *RoutingManager) save() error {
 	_ = os.MkdirAll("/etc/proxmox-router/routing", 0755)
 	_ = 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
 	return nil
 }
 }
@@ -193,7 +199,7 @@ func (m *RoutingManager) syncAllRoutesWorker() {
 	m.mu.Lock()
 	m.mu.Lock()
 	defer m.mu.Unlock()
 	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)
 	liveRoutes, err := netlink.RouteList(nil, netlink.FAMILY_V4)
 	if err != nil {
 	if err != nil {
@@ -232,14 +238,16 @@ func (m *RoutingManager) syncAllRoutesWorker() {
 			seen[k] = true
 			seen[k] = true
 			if !kernelRouteGroups[k] {
 			if !kernelRouteGroups[k] {
 				log.Printf("[RouteSync] Missing kernel route %s table %s (id %d) metric %d -> Restoring", normDst, r.RoutingTable, tableID, r.Distance)
 				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() {
 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
 	// Apply all static routes grouped by (dst, table, distance) for ECMP
 	type groupKey struct {
 	type groupKey struct {
@@ -512,9 +520,21 @@ func (m *RoutingManager) ListRoutes() []RouteItem {
 	return liveRoutes
 	return liveRoutes
 }
 }
 
 
-func (m *RoutingManager) SaveRoute(r RouteItem) error {
+func (m *RoutingManager) SaveRoute(r RouteItem) (err error) {
 	m.mu.Lock()
 	m.mu.Lock()
 	defer m.mu.Unlock()
 	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 == "" {
 	if r.DstAddress == "" {
 		return fmt.Errorf("destination address is required")
 		return fmt.Errorf("destination address is required")
@@ -525,6 +545,10 @@ func (m *RoutingManager) SaveRoute(r RouteItem) error {
 	if r.RoutingTable == "" {
 	if r.RoutingTable == "" {
 		r.RoutingTable = "main"
 		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
 	r.Static = true
 
 
 	// 1. If old route details are provided from edit modal, remove old route first
 	// 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 != "" {
 	if r.OldDstAddress != "" {
 		oldDist := 0
 		oldDist := 0
 		if r.OldDistance != nil {
 		if r.OldDistance != nil {
 			oldDist = *r.OldDistance
 			oldDist = *r.OldDistance
 		}
 		}
 		if r.OldDstAddress != r.DstAddress || r.OldRoutingTable != r.RoutingTable || oldDist != r.Distance {
 		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 {
 type Manager struct {
 	mu       sync.RWMutex
 	mu       sync.RWMutex
+	applyMu  sync.Mutex
 	filePath string
 	filePath string
 	services map[string]Service
 	services map[string]Service
 	hooks    map[string]ServiceHook
 	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 {
 func (m *Manager) SetServiceState(name string, disabled bool) error {
+	m.applyMu.Lock()
+	defer m.applyMu.Unlock()
 	m.mu.Lock()
 	m.mu.Lock()
-	defer m.mu.Unlock()
+	locked := true
+	defer func() {
+		if locked {
+			m.mu.Unlock()
+		}
+	}()
 
 
 	key := normalizeServiceName(name)
 	key := normalizeServiceName(name)
 	svc, ok := m.services[key]
 	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)
 		return fmt.Errorf("service %q not found", name)
 	}
 	}
 
 
+	previous := svc
 	svc.Disabled = disabled
 	svc.Disabled = disabled
 	m.services[key] = svc
 	m.services[key] = svc
 
 
 	if err := m.saveLocked(); err != nil {
 	if err := m.saveLocked(); err != nil {
+		m.services[key] = previous
 		return err
 		return err
 	}
 	}
 
 
 	logging.Log("system,info", "Service %s state changed: disabled=%t", key, disabled)
 	logging.Log("system,info", "Service %s state changed: disabled=%t", key, disabled)
 
 
 	// Trigger hook if registered
 	// 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
 	return nil
 }
 }
 
 
 func (m *Manager) SetService(name string, port int, disabled bool, certificate ...string) error {
 func (m *Manager) SetService(name string, port int, disabled bool, certificate ...string) error {
+	m.applyMu.Lock()
+	defer m.applyMu.Unlock()
 	m.mu.Lock()
 	m.mu.Lock()
-	defer m.mu.Unlock()
+	locked := true
+	defer func() {
+		if locked {
+			m.mu.Unlock()
+		}
+	}()
 
 
 	key := normalizeServiceName(name)
 	key := normalizeServiceName(name)
 	svc, ok := m.services[key]
 	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)
 		return fmt.Errorf("service %q not found", name)
 	}
 	}
 
 
+	previous := svc
 	if port > 0 && port < 65536 {
 	if port > 0 && port < 65536 {
 		svc.Port = port
 		svc.Port = port
 	}
 	}
@@ -237,44 +266,36 @@ func (m *Manager) SetService(name string, port int, disabled bool, certificate .
 	m.services[key] = svc
 	m.services[key] = svc
 
 
 	if err := m.saveLocked(); err != nil {
 	if err := m.saveLocked(); err != nil {
+		m.services[key] = previous
 		return err
 		return err
 	}
 	}
 
 
 	logging.Log("system,info", "Service %s updated: port=%d, disabled=%t, certificate=%s", key, svc.Port, svc.Disabled, svc.Certificate)
 	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
 	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 {
 	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
 		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
 	return nil
 }
 }
@@ -334,9 +338,16 @@ func sanitizeConnName(raw string) string {
 func (m *IPSecManager) StartTunnel(name string) error {
 func (m *IPSecManager) StartTunnel(name string) error {
 	m.mu.Lock()
 	m.mu.Lock()
 	defer m.mu.Unlock()
 	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
 	var p2Name string
 	for _, c := range m.config.Connections {
 	for _, c := range m.config.Connections {
@@ -362,6 +373,9 @@ func (m *IPSecManager) StartTunnel(name string) error {
 func (m *IPSecManager) StopTunnel(name string) error {
 func (m *IPSecManager) StopTunnel(name string) error {
 	m.mu.Lock()
 	m.mu.Lock()
 	defer m.mu.Unlock()
 	defer m.mu.Unlock()
+	return m.stopTunnelLocked(name)
+}
+func (m *IPSecManager) stopTunnelLocked(name string) error {
 
 
 	var p2Name string
 	var p2Name string
 	for _, c := range m.config.Connections {
 	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 {
 	for i, c := range m.config.Connections {
 		if c.Name == name {
 		if c.Name == name {
 			m.config.Connections[i].Disabled = !enabled
 			m.config.Connections[i].Disabled = !enabled
-			_ = m.save()
+			if err := m.save(); err != nil {
+				return err
+			}
 			if enabled {
 			if enabled {
-				go m.StartTunnel(name)
+				return m.startTunnelLocked(name)
 			} else {
 			} else {
-				go m.StopTunnel(name)
+				return m.stopTunnelLocked(name)
 			}
 			}
-			return nil
 		}
 		}
 	}
 	}
 	return fmt.Errorf("connection %q not found", name)
 	return fmt.Errorf("connection %q not found", name)

+ 22 - 30
internal/vpn/l2tp.go

@@ -9,7 +9,7 @@ import (
 	"path/filepath"
 	"path/filepath"
 	"strings"
 	"strings"
 	"sync"
 	"sync"
-	"time"
+	"syscall"
 
 
 	"github.com/vishvananda/netlink"
 	"github.com/vishvananda/netlink"
 )
 )
@@ -103,12 +103,10 @@ func (m *L2TPManager) Save(inst L2TPInstance) error {
 	}
 	}
 
 
 	if !inst.Disabled {
 	if !inst.Disabled {
-		go m.connectInstance(inst.Name)
+		return m.connectInstance(inst.Name)
 	} else {
 	} else {
-		go m.disconnectInstance(inst.Name)
+		return m.disconnectInstance(inst.Name)
 	}
 	}
-
-	return nil
 }
 }
 
 
 func (m *L2TPManager) Delete(name string) error {
 func (m *L2TPManager) Delete(name string) error {
@@ -125,7 +123,9 @@ func (m *L2TPManager) Delete(name string) error {
 	}
 	}
 	m.instances = updated
 	m.instances = updated
 
 
-	_ = m.save()
+	if err := m.save(); err != nil {
+		return err
+	}
 	return m.regenerateXl2tpdConf()
 	return m.regenerateXl2tpdConf()
 }
 }
 
 
@@ -136,13 +136,14 @@ func (m *L2TPManager) SetEnabled(name string, enabled bool) error {
 	for i, inst := range m.instances {
 	for i, inst := range m.instances {
 		if inst.Name == name {
 		if inst.Name == name {
 			m.instances[i].Disabled = !enabled
 			m.instances[i].Disabled = !enabled
-			_ = m.save()
+			if err := m.save(); err != nil {
+				return err
+			}
 			if enabled {
 			if enabled {
-				go m.connectInstance(name)
+				return m.connectInstance(name)
 			} else {
 			} else {
-				go m.disconnectInstance(name)
+				return m.disconnectInstance(name)
 			}
 			}
-			return nil
 		}
 		}
 	}
 	}
 	return fmt.Errorf("instance %q not found", name)
 	return fmt.Errorf("instance %q not found", name)
@@ -246,32 +247,23 @@ func (m *L2TPManager) regenerateXl2tpdConf() error {
 		return err
 		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
 	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() {
 func (m *L2TPManager) refreshRuntimeStatus() {
 	psOut, _ := exec.Command("ps", "-ef").Output()
 	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
 	// Auto-connect if enabled and not disabled
 	if !inst.Disabled {
 	if !inst.Disabled {
-		go m.connectInstance(inst.Name)
+		return m.connectInstance(inst.Name)
 	} else {
 	} else {
-		go m.disconnectInstance(inst.Name)
+		return m.disconnectInstance(inst.Name)
 	}
 	}
-
-	return nil
 }
 }
 
 
 func (m *PPPoEManager) Delete(name string) error {
 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 {
 	for i, inst := range m.instances {
 		if inst.Name == name {
 		if inst.Name == name {
 			m.instances[i].Disabled = !enabled
 			m.instances[i].Disabled = !enabled
-			_ = m.save()
+			if err := m.save(); err != nil {
+				return err
+			}
 			if enabled {
 			if enabled {
-				go m.connectInstance(name)
+				return m.connectInstance(name)
 			} else {
 			} else {
-				go m.disconnectInstance(name)
+				return m.disconnectInstance(name)
 			}
 			}
-			return nil
 		}
 		}
 	}
 	}
 	return fmt.Errorf("instance %q not found", name)
 	return fmt.Errorf("instance %q not found", name)
@@ -275,7 +274,9 @@ func (m *PPPoEManager) RenameInterface(oldName, newName string) {
 		}
 		}
 	}
 	}
 	if changed {
 	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 {
 	if !inst.Disabled {
-		go m.connectInstance(inst.Name)
+		return m.connectInstance(inst.Name)
 	} else {
 	} else {
-		go m.disconnectInstance(inst.Name)
+		return m.disconnectInstance(inst.Name)
 	}
 	}
-
-	return nil
 }
 }
 
 
 func (m *PPTPManager) Delete(name string) error {
 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 {
 	for i, inst := range m.instances {
 		if inst.Name == name {
 		if inst.Name == name {
 			m.instances[i].Disabled = !enabled
 			m.instances[i].Disabled = !enabled
-			_ = m.save()
+			if err := m.save(); err != nil {
+				return err
+			}
 			if enabled {
 			if enabled {
-				go m.connectInstance(name)
+				return m.connectInstance(name)
 			} else {
 			} else {
-				go m.disconnectInstance(name)
+				return m.disconnectInstance(name)
 			}
 			}
-			return nil
 		}
 		}
 	}
 	}
 	return fmt.Errorf("instance %q not found", name)
 	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
 	// Autostart all enabled WireGuard interfaces
 	for _, iface := range m.interfaces {
 	for _, iface := range m.interfaces {
 		if !iface.Disabled {
 		if !iface.Disabled {
+			if link, err := netlink.LinkByName(iface.Name); err == nil && link.Type() == "wireguard" {
+				continue
+			}
 			name := iface.Name
 			name := iface.Name
 			go func(n string) {
 			go func(n string) {
 				_ = m.startWGInterface(n)
 				_ = m.startWGInterface(n)
@@ -100,11 +103,15 @@ func (m *WireGuardManager) load() {
 
 
 func (m *WireGuardManager) save() error {
 func (m *WireGuardManager) save() error {
 	_ = os.MkdirAll(filepath.Dir(WGInterfacesStorePath), 0755)
 	_ = 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
 	return nil
 }
 }
@@ -176,18 +183,35 @@ func (m *WireGuardManager) SaveInterface(req SaveWireGuardInterfaceRequest) erro
 		req.MTU = 1420
 		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
 	// Auto generate or derive public key
 	if req.PrivateKey != "" {
 	if req.PrivateKey != "" {
 		pub, err := m.CalcPublicKey(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 {
 	} else {
 		priv, pub, err := m.GenerateKeyPair()
 		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
 	// 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.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 {
 	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 {
 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 {
 	for i, iface := range m.interfaces {
 		if iface.Name == name {
 		if iface.Name == name {
 			m.interfaces[i].Disabled = !enabled
 			m.interfaces[i].Disabled = !enabled
-			_ = m.save()
+			if err := m.save(); err != nil {
+				return err
+			}
 			if enabled {
 			if enabled {
-				go m.startWGInterface(name)
+				return m.startWGInterface(name)
 			} else {
 			} else {
-				go m.stopWGInterface(name)
+				return m.stopWGInterface(name)
 			}
 			}
-			return nil
 		}
 		}
 	}
 	}
 	return fmt.Errorf("interface %q not found", name)
 	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 {
 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()
 	_ = exec.Command("wg-quick", "down", name).Run()
 
 
 	cmd := exec.Command("wg-quick", "up", name)
 	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 {
 func (m *WireGuardManager) stopWGInterface(name string) error {
 	_ = exec.Command("systemctl", "disable", fmt.Sprintf("wg-quick@%s", name)).Run()
 	_ = 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 := 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
 	return nil
 }
 }
 
 
@@ -420,7 +459,9 @@ func (m *WireGuardManager) SavePeer(req SaveWireGuardPeerRequest) error {
 		}
 		}
 		// Remove old peer from runtime
 		// Remove old peer from runtime
 		if _, err := netlink.LinkByName(oldIface); err == nil {
 		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
 		// Remove old peer entry
@@ -441,6 +482,12 @@ func (m *WireGuardManager) SavePeer(req SaveWireGuardPeerRequest) error {
 	req.WireGuardPeer.EndpointAddress = req.EndpointAddress
 	req.WireGuardPeer.EndpointAddress = req.EndpointAddress
 	req.WireGuardPeer.EndpointPort = req.EndpointPort
 	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
 	found := false
 	for i, existing := range m.peers {
 	for i, existing := range m.peers {
 		if existing.PublicKey == req.PublicKey && existing.Interface == req.Interface {
 		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.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
 	// Apply dynamically to kernel if running
 	if _, err := netlink.LinkByName(req.Interface); err == nil {
 	if _, err := netlink.LinkByName(req.Interface); err == nil {
 		if req.Disabled {
 		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 {
 		} 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 != "" {
 			if req.EndpointAddress != "" {
 				ep := req.EndpointAddress
 				ep := req.EndpointAddress
 				if req.EndpointPort > 0 {
 				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)
 				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-*")
 				pskFile, err := os.CreateTemp("", "wg-psk-*")
 				if err == nil {
 				if err == nil {
 					_, _ = pskFile.WriteString(req.PresharedKey)
 					_, _ = pskFile.WriteString(req.PresharedKey)
@@ -484,7 +538,9 @@ func (m *WireGuardManager) SavePeer(req SaveWireGuardPeerRequest) error {
 					defer os.Remove(pskFile.Name())
 					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)
 		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)
 	_ = m.generateWGConfigFile(ifaceName)
 
 
 	if _, err := netlink.LinkByName(ifaceName); err == nil {
 	if _, err := netlink.LinkByName(ifaceName); err == nil {
 		if !enabled {
 		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 {
 		} else {
 			for _, p := range m.peers {
 			for _, p := range m.peers {
 				if p.Interface == ifaceName && p.PublicKey == pubKey {
 				if p.Interface == ifaceName && p.PublicKey == pubKey {
@@ -524,7 +584,7 @@ func (m *WireGuardManager) SetPeerEnabled(ifaceName, pubKey string, enabled bool
 					if epHost != "" {
 					if epHost != "" {
 						ep := epHost
 						ep := epHost
 						if epPort > 0 {
 						if epPort > 0 {
-							ep = fmt.Sprintf("%s:%d", epHost, epPort)
+							ep = net.JoinHostPort(epHost, strconv.Itoa(epPort))
 						}
 						}
 						args = append(args, "endpoint", ep)
 						args = append(args, "endpoint", ep)
 					}
 					}
@@ -540,7 +600,9 @@ func (m *WireGuardManager) SetPeerEnabled(ifaceName, pubKey string, enabled bool
 							defer os.Remove(pskFile.Name())
 							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
 					break
 				}
 				}
 			}
 			}
@@ -562,12 +624,16 @@ func (m *WireGuardManager) DeletePeer(ifaceName, pubKey string) error {
 	}
 	}
 	m.peers = updated
 	m.peers = updated
 
 
-	_ = m.save()
+	if err := m.save(); err != nil {
+		return err
+	}
 	_ = m.generateWGConfigFile(ifaceName)
 	_ = m.generateWGConfigFile(ifaceName)
 
 
 	// Remove from runtime
 	// Remove from runtime
 	if _, err := netlink.LinkByName(ifaceName); err == nil {
 	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
 	return nil
 }
 }
@@ -615,7 +681,7 @@ func (m *WireGuardManager) generateWGConfigFile(ifaceName string) error {
 			if epHost != "" {
 			if epHost != "" {
 				ep := epHost
 				ep := epHost
 				if epPort > 0 {
 				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))
 				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');
     const bGeo = document.getElementById('fw-badge-geo');
     if (bGeo) bGeo.innerText = state.geoStatus?.enabled_count || 0;
     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 sigAddr = (state.addressLists || []).map(a => `${a.id}:${a.name}:${a.address}:${a.disabled}`).join(';');
 
 
     const tbodyFilter = document.getElementById('filter-table-body');
     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 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.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-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-amber-400">${ports || '-'}</td>
       <td class="py-2.5 px-3 text-emerald-400">${ifaces || '-'}</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>
       <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');
   const badge = document.getElementById('fw-modal-badge');
   if (badge) badge.innerText = 'New...';
   if (badge) badge.innerText = 'New...';
   document.getElementById('filter-modal-id').value = '';
   document.getElementById('filter-modal-id').value = '';
+  document.getElementById('filter-modal-family').value = '4';
   document.getElementById('filter-modal-enabled').checked = true;
   document.getElementById('filter-modal-enabled').checked = true;
   document.getElementById('filter-modal-comment').value = '';
   document.getElementById('filter-modal-comment').value = '';
   document.getElementById('filter-modal-chain').value = 'forward';
   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}`;
   if (badge) badge.innerText = `#${r.order != null ? r.order : r.id}`;
 
 
   document.getElementById('filter-modal-id').value = 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-enabled').checked = !r.disabled;
   document.getElementById('filter-modal-comment').value = r.comment || '';
   document.getElementById('filter-modal-comment').value = r.comment || '';
   document.getElementById('filter-modal-chain').value = r.chain || 'forward';
   document.getElementById('filter-modal-chain').value = r.chain || 'forward';
@@ -4453,6 +4455,7 @@ async function applyFilterRule(closeAfter = false) {
   }
   }
 
 
   const payload = {
   const payload = {
+    ip_version: Number(document.getElementById('filter-modal-family').value),
     id,
     id,
     chain,
     chain,
     action,
     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 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.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-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 text-emerald-400">${ifaces || '-'}</td>
       <td class="py-2.5 px-3">${renderActionBadge(r.action)}</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>
       <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');
   const badge = document.getElementById('nat-modal-badge');
   if (badge) badge.innerText = 'New...';
   if (badge) badge.innerText = 'New...';
   document.getElementById('nat-modal-id').value = '';
   document.getElementById('nat-modal-id').value = '';
+  document.getElementById('nat-modal-family').value = '4';
   document.getElementById('nat-modal-enabled').checked = true;
   document.getElementById('nat-modal-enabled').checked = true;
   document.getElementById('nat-modal-comment').value = '';
   document.getElementById('nat-modal-comment').value = '';
   document.getElementById('nat-modal-chain').value = 'srcnat';
   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}`;
   if (badge) badge.innerText = `#${r.order != null ? r.order : r.id}`;
 
 
   document.getElementById('nat-modal-id').value = 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-enabled').checked = !r.disabled;
   document.getElementById('nat-modal-comment').value = r.comment || '';
   document.getElementById('nat-modal-comment').value = r.comment || '';
   document.getElementById('nat-modal-chain').value = r.chain || 'srcnat';
   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 usesPort = ['src-nat', 'dst-nat', 'netmap', 'same', 'socksify', 'endpoint-independent-nat', 'redirect', 'masquerade'].includes(action);
 
 
   const payload = {
   const payload = {
+    ip_version: Number(document.getElementById('nat-modal-family').value),
     id,
     id,
     chain,
     chain,
     action,
     action,
@@ -5598,6 +5604,7 @@ function openAddMangleModal() {
   const badge = document.getElementById('mangle-modal-badge');
   const badge = document.getElementById('mangle-modal-badge');
   if (badge) badge.innerText = 'New...';
   if (badge) badge.innerText = 'New...';
   document.getElementById('mangle-modal-id').value = '';
   document.getElementById('mangle-modal-id').value = '';
+  document.getElementById('mangle-modal-family').value = '4';
   document.getElementById('mangle-modal-enabled').checked = true;
   document.getElementById('mangle-modal-enabled').checked = true;
   document.getElementById('mangle-modal-comment').value = '';
   document.getElementById('mangle-modal-comment').value = '';
   document.getElementById('mangle-modal-chain').value = 'prerouting';
   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}`;
   if (badge) badge.innerText = `#${r.order != null ? r.order : r.id}`;
 
 
   document.getElementById('mangle-modal-id').value = 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-enabled').checked = !r.disabled;
   document.getElementById('mangle-modal-comment').value = r.comment || '';
   document.getElementById('mangle-modal-comment').value = r.comment || '';
   document.getElementById('mangle-modal-chain').value = r.chain || 'prerouting';
   document.getElementById('mangle-modal-chain').value = r.chain || 'prerouting';
@@ -5862,6 +5870,7 @@ async function applyMangleRule(closeAfter = false) {
   }
   }
 
 
   const payload = {
   const payload = {
+    ip_version: Number(document.getElementById('mangle-modal-family').value),
     id,
     id,
     chain,
     chain,
     action,
     action,
@@ -10859,7 +10868,7 @@ function openAddBridgeModal() {
   document.getElementById('bridge-modal-name').disabled = false;
   document.getElementById('bridge-modal-name').disabled = false;
   document.getElementById('bridge-modal-mtu').value = '1500';
   document.getElementById('bridge-modal-mtu').value = '1500';
   document.getElementById('bridge-modal-mac').value = '';
   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-priority').value = '32768';
   document.getElementById('bridge-modal-vlanfiltering').checked = false;
   document.getElementById('bridge-modal-vlanfiltering').checked = false;
   document.getElementById('bridge-modal-pvid').value = '1';
   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-name').disabled = true;
   document.getElementById('bridge-modal-mtu').value = b.mtu || 1500;
   document.getElementById('bridge-modal-mtu').value = b.mtu || 1500;
   document.getElementById('bridge-modal-mac').value = b.mac_address || '';
   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-priority').value = b.priority || 32768;
   document.getElementById('bridge-modal-vlanfiltering').checked = !!b.vlan_filtering;
   document.getElementById('bridge-modal-vlanfiltering').checked = !!b.vlan_filtering;
   document.getElementById('bridge-modal-pvid').value = b.pvid || 1;
   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 max-w-xs">${sansHtml}</td>
         <td class="py-3 px-4 whitespace-nowrap text-xs text-slate-300">
         <td class="py-3 px-4 whitespace-nowrap text-xs text-slate-300">
           <span>${validStr}</span> ${validBadge}
           <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>
           <div class="text-[10px] text-slate-500">${c.key_type || ''}</div>
         </td>
         </td>
         <td class="py-3 px-4 text-right whitespace-nowrap">
         <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 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">
         <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">
           <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 -->
           <!-- TAB PANE: GENERAL -->
           <div id="fw-pane-general" class="fw-rule-pane space-y-1">
           <div id="fw-pane-general" class="fw-rule-pane space-y-1">
@@ -6783,6 +6784,7 @@
         <!-- Form Content Container (Scrollable) -->
         <!-- 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">
         <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">
           <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) -->
           <!-- TAB PANE: GENERAL (Screenshot 1) -->
           <div id="nat-pane-general" class="nat-rule-pane space-y-1">
           <div id="nat-pane-general" class="nat-rule-pane space-y-1">
@@ -7711,6 +7713,7 @@
         <!-- Form Content Container (Scrollable) -->
         <!-- 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">
         <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">
           <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) -->
           <!-- TAB PANE: GENERAL (Screenshot 1) -->
           <div id="mangle-pane-general" class="mangle-rule-pane space-y-1">
           <div id="mangle-pane-general" class="mangle-rule-pane space-y-1">
@@ -11389,8 +11392,8 @@
           <div>
           <div>
             <label class="block font-semibold text-slate-300 mb-1.5">STP Protocol</label>
             <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">
             <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>
               <option value="none">none (Disable STP)</option>
             </select>
             </select>
           </div>
           </div>
@@ -11497,8 +11500,8 @@
             <span class="text-white font-semibold">MAC Learning</span>
             <span class="text-white font-semibold">MAC Learning</span>
           </label>
           </label>
           <label class="flex items-center space-x-2 cursor-pointer">
           <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>
           </label>
         </div>
         </div>
 
 

+ 14 - 0
scripts/debtik-update

@@ -34,10 +34,15 @@ restore() {
     cp -p "$base/previous/$name" "/usr/local/bin/$name.rollback"
     cp -p "$base/previous/$name" "/usr/local/bin/$name.rollback"
     mv -f "/usr/local/bin/$name.rollback" "/usr/local/bin/$name"
     mv -f "/usr/local/bin/$name.rollback" "/usr/local/bin/$name"
   done
   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_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
   if [[ -f "$base/previous_version" ]]; then cp -p "$base/previous_version" "$base/installed_version"; fi
   systemctl start proxmox-router.service
   systemctl start proxmox-router.service
   systemctl is-active --quiet 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
   state rolled_back
 }
 }
 if [[ ${1:-} == rollback ]]; then restore; exit; fi
 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"
 cd "$work/source"
 commit=$(git rev-parse HEAD)
 commit=$(git rev-parse HEAD)
 [[ $(cat VERSION) == "$version" ]] || { state failed; echo 'Release tag and VERSION do not match'; exit 1; }
 [[ $(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
 state building
 go_cmd=$(command -v go || true)
 go_cmd=$(command -v go || true)
 if [[ -x /opt/debtik/go/bin/go ]]; then go_cmd=/opt/debtik/go/bin/go; fi
 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
 CGO_ENABLED=0 "$go_cmd" build -trimpath -o "$work/debtik-cli" ./cmd/debtik-cli
 install -d -m 0700 "$base/previous"
 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 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"
 cp -p "$base/installed_version" "$base/previous_version"
 if [[ -f "$base/installed_commit" ]]; then cp -p "$base/installed_commit" "$base/previous_commit"; fi
 if [[ -f "$base/installed_commit" ]]; then cp -p "$base/installed_commit" "$base/previous_commit"; fi
 printf '%s\n' "$commit" > "$base/candidate_commit"
 printf '%s\n' "$commit" > "$base/candidate_commit"
+iptables-save -c > "$base/previous/iptables.save"
+ip6tables-save -c > "$base/previous/ip6tables.save"
 state installing
 state installing
 rollback_on_error() { trap - ERR; restore || state failed; exit 1; }
 rollback_on_error() { trap - ERR; restore || state failed; exit 1; }
 trap rollback_on_error ERR
 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"
   install -m 0755 "$work/$name" "/usr/local/bin/$name.next"
   mv -f "/usr/local/bin/$name.next" "/usr/local/bin/$name"
   mv -f "/usr/local/bin/$name.next" "/usr/local/bin/$name"
 done
 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
 systemctl start proxmox-router.service
 sleep 3
 sleep 3
 systemctl is-active --quiet proxmox-router.service
 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.locator('#login-submit-btn').click();
     await page.waitForFunction(() => state.currentUser !== null);
     await page.waitForFunction(() => state.currentUser !== null);
     await page.evaluate(() => fetchData(false));
     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) {
     for(const tab of tabs) {
       await page.evaluate(async tab => { navigate(tab); await fetchData(false); }, tab);
       await page.evaluate(async tab => { navigate(tab); await fetchData(false); }, tab);
       await page.waitForTimeout(200);
       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")
+	}
+}