Ver código fonte

Fix routing, CLI, and management reliability

Proxmox Router 2 horas atrás
pai
commit
604d542d03

+ 4 - 2
README.md

@@ -37,7 +37,7 @@ sudo ./install.sh --check-dependencies
 
 The dependency check exits nonzero and names missing packages, commands or an outdated Go toolchain. Existing Proxmox `ifupdown2` installations are retained. On Debian 13, where `mstpd` is not packaged, the installer builds a pinned upstream release with archive checksum verification; its daemon is enabled before DebTik starts. Go archives are checksum verified as well.
 
-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/debtik -addr :80`; `systemd/debtik.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/debtik`; `systemd/debtik.service` defines the installed unit.
 
 Configuration is JSON under `/etc/debtik/` (including `services.json` and `vpn/aerotun_instances.json`). AeroTUN secrets and private identities remain on the local appliance. Do not copy one side's private identity to the other.
 
@@ -47,9 +47,11 @@ Open `http://<appliance-ip>/` and sign in. For the appliance SSH CLI, connect to
 
 ```sh
 debtik-cli -addr 127.0.0.1:8282
-debtik-cli -addr 127.0.0.1:8282 -u admin -p '<password>' -c '/interface aerotun print'
+printf '%s\n' '<password>' | debtik-cli -addr 127.0.0.1:8282 -u admin -password-stdin -c '/interface aerotun print'
 ```
 
+Single-command mode returns a nonzero exit code on command failure, uses a 60-second timeout (adjustable with `-timeout`), and requires a server with one-shot status support. `-password-stdin` avoids exposing the password in command-line arguments. Use `-json` for a stable envelope containing `ok`, numeric `status`, and the command output text. Update the server and client together.
+
 Run `help` within the CLI for supported commands. The API is under `/api/` on the web listener and requires an authenticated session except for login. Read-only users can inspect state; configuration changes require elevated roles. AeroTUN operations require the full administrator role.
 
 ## Git updates

+ 70 - 9
cmd/debtik-cli/main.go

@@ -2,6 +2,7 @@ package main
 
 import (
 	"bufio"
+	"encoding/json"
 	"errors"
 	"flag"
 	"fmt"
@@ -20,9 +21,32 @@ func main() {
 	addr := flag.String("addr", "127.0.0.1:8282", "DebTik CLI server address")
 	username := flag.String("u", "", "Username for automatic authentication")
 	password := flag.String("p", "", "Password for automatic authentication")
+	jsonOutput := flag.Bool("json", false, "Return a JSON envelope in single-command mode")
+	passwordStdin := flag.Bool("password-stdin", false, "Read password from standard input (single-command mode)")
+	timeout := flag.Duration("timeout", 60*time.Second, "Single-command timeout")
 	cmdFlag := flag.String("c", "", "Execute a single command non-interactively and exit")
 	flag.Parse()
 
+	if *jsonOutput && *cmdFlag == "" {
+		fmt.Fprintln(os.Stderr, "-json requires -c")
+		os.Exit(1)
+	}
+	if *passwordStdin {
+		if *cmdFlag == "" || *password != "" {
+			fmt.Fprintln(os.Stderr, "-password-stdin requires -c and cannot be combined with -p")
+			os.Exit(1)
+		}
+		line, err := bufio.NewReader(os.Stdin).ReadString('\n')
+		if err != nil && !errors.Is(err, io.EOF) {
+			fmt.Fprintln(os.Stderr, err)
+			os.Exit(1)
+		}
+		*password = strings.TrimRight(line, "\r\n")
+	}
+	if *timeout <= 0 {
+		fmt.Fprintln(os.Stderr, "-timeout must be positive")
+		os.Exit(1)
+	}
 	conn, err := net.DialTimeout("tcp", *addr, 3*time.Second)
 	if err != nil {
 		fmt.Fprintf(os.Stderr, "\033[31mError: Could not connect to DebTik CLI service on %s\033[0m\n", *addr)
@@ -33,7 +57,7 @@ func main() {
 
 	// If one-shot command execution is requested:
 	if *cmdFlag != "" {
-		if err := runOneShot(conn, *username, *password, *cmdFlag); err != nil {
+		if err := runOneShot(conn, *username, *password, *cmdFlag, *timeout, *jsonOutput); err != nil {
 			fmt.Fprintln(os.Stderr, err)
 			os.Exit(1)
 		}
@@ -47,19 +71,56 @@ func main() {
 	}
 }
 
-func runOneShot(conn net.Conn, username, password, command string) error {
+func runOneShot(conn net.Conn, username, password, command string, timeout time.Duration, jsonOutput bool) error {
 	reader := bufio.NewReader(conn)
 	if username == "" || password == "" {
 		return fmt.Errorf("single-command mode requires -u and -p")
 	}
-	if err := autoLogin(conn, reader, username, password); err != nil {
+	if err := autoLogin(conn, reader, username, password, io.Discard); err != nil {
 		return err
 	}
-	if _, err := fmt.Fprintf(conn, "%s\nquit\n", command); err != nil {
+	if strings.ContainsAny(command, "\r\n") {
+		return fmt.Errorf("single-command mode accepts one line")
+	}
+	if err := conn.SetDeadline(time.Now().Add(timeout)); err != nil {
 		return err
 	}
-	_, err := io.Copy(os.Stdout, reader)
-	return err
+	if _, err := fmt.Fprintf(conn, ":exec-status %s\n", command); err != nil {
+		return err
+	}
+	data, err := io.ReadAll(reader)
+	if err != nil {
+		return fmt.Errorf("command failed or timed out: %w", err)
+	}
+	const begin = "DEBTIK-EXEC-BEGIN"
+	const marker = "DEBTIK-EXEC-STATUS:"
+	response := string(data)
+	start := strings.Index(response, begin)
+	index := strings.LastIndex(response, marker)
+	if start < 0 || index < start+len(begin) {
+		return fmt.Errorf("server did not return command status; update the server")
+	}
+	output := strings.TrimPrefix(response[start+len(begin):index], "\r\n")
+	output = strings.TrimPrefix(output, "\n")
+	status := strings.TrimSpace(response[index+len(marker):])
+	if status != "0" && status != "1" {
+		return fmt.Errorf("invalid command status")
+	}
+	if jsonOutput {
+		if err := json.NewEncoder(os.Stdout).Encode(struct {
+			Output string `json:"output"`
+			Status int    `json:"status"`
+			OK     bool   `json:"ok"`
+		}{output, int(status[0] - '0'), status == "0"}); err != nil {
+			return err
+		}
+	} else {
+		fmt.Print(output)
+	}
+	if status != "0" {
+		return fmt.Errorf("command failed (status %s)", status)
+	}
+	return nil
 }
 
 func runInteractive(conn net.Conn, username, password string) error {
@@ -68,7 +129,7 @@ func runInteractive(conn net.Conn, username, password string) error {
 		if username == "" || password == "" {
 			return fmt.Errorf("provide both -u and -p")
 		}
-		if err := autoLogin(conn, reader, username, password); err != nil {
+		if err := autoLogin(conn, reader, username, password, os.Stdout); err != nil {
 			return err
 		}
 	}
@@ -97,7 +158,7 @@ func runInteractive(conn net.Conn, username, password string) error {
 	}
 }
 
-func autoLogin(conn net.Conn, reader *bufio.Reader, username, password string) error {
+func autoLogin(conn net.Conn, reader *bufio.Reader, username, password string, welcome io.Writer) error {
 	if err := conn.SetDeadline(time.Now().Add(10 * time.Second)); err != nil {
 		return err
 	}
@@ -133,7 +194,7 @@ func autoLogin(conn net.Conn, reader *bufio.Reader, username, password string) e
 			return fmt.Errorf("authentication rejected")
 		}
 		if strings.HasSuffix(text, "] > ") {
-			_, err := fmt.Fprint(os.Stdout, text)
+			_, err := fmt.Fprint(welcome, text)
 			return err
 		}
 	}

+ 70 - 1
cmd/debtik-cli/main_test.go

@@ -2,9 +2,13 @@ package main
 
 import (
 	"bufio"
+	"encoding/json"
 	"fmt"
+	"io"
 	"net"
+	"os"
 	"testing"
+	"time"
 )
 
 func TestAutomaticLoginWaitsForSuccessAndPreservesBufferedData(t *testing.T) {
@@ -40,7 +44,7 @@ func TestAutomaticLoginWaitsForSuccessAndPreservesBufferedData(t *testing.T) {
 				done <- err
 			}()
 			r := bufio.NewReader(client)
-			err := autoLogin(client, r, "admin", "test-password")
+			err := autoLogin(client, r, "admin", "test-password", io.Discard)
 			if (err == nil) != accepted {
 				t.Fatalf("accepted=%v err=%v", accepted, err)
 			}
@@ -56,3 +60,68 @@ func TestAutomaticLoginWaitsForSuccessAndPreservesBufferedData(t *testing.T) {
 		})
 	}
 }
+
+func TestOneShotReportsCommandFailure(t *testing.T) {
+	for _, status := range []string{"0", "1", "missing"} {
+		t.Run(status, func(t *testing.T) {
+			client, server := net.Pipe()
+			defer client.Close()
+			go func() {
+				defer server.Close()
+				r := bufio.NewReader(server)
+				fmt.Fprint(server, "login:")
+				r.ReadString('\n')
+				fmt.Fprint(server, "password:")
+				r.ReadString('\n')
+				fmt.Fprint(server, "[user@router] > ")
+				r.ReadString('\n')
+				if status != "missing" {
+					fmt.Fprintf(server, "DEBTIK-EXEC-BEGIN\nresult\nDEBTIK-EXEC-STATUS:%s\n", status)
+				}
+			}()
+			err := runOneShot(client, "user", "password", "invalid", time.Second, false)
+			if (err == nil) != (status == "0") {
+				t.Fatalf("status %s: %v", status, err)
+			}
+		})
+	}
+}
+
+func TestOneShotJSONEnvelope(t *testing.T) {
+	client, server := net.Pipe()
+	defer client.Close()
+	go func() {
+		defer server.Close()
+		r := bufio.NewReader(server)
+		fmt.Fprint(server, "login:")
+		r.ReadString('\n')
+		fmt.Fprint(server, "password:")
+		r.ReadString('\n')
+		fmt.Fprint(server, "[user@router] > ")
+		r.ReadString('\n')
+		fmt.Fprint(server, "echoed command\r\nDEBTIK-EXEC-BEGIN\r\nresult\r\nDEBTIK-EXEC-STATUS:1\r\n")
+	}()
+	original := os.Stdout
+	read, write, err := os.Pipe()
+	if err != nil {
+		t.Fatal(err)
+	}
+	os.Stdout = write
+	defer func() { os.Stdout = original; read.Close() }()
+	err = runOneShot(client, "user", "password", "invalid", time.Second, true)
+	write.Close()
+	if err == nil {
+		t.Fatal("expected failure")
+	}
+	var got struct {
+		Output string `json:"output"`
+		Status int    `json:"status"`
+		OK     bool   `json:"ok"`
+	}
+	if err := json.NewDecoder(read).Decode(&got); err != nil {
+		t.Fatal(err)
+	}
+	if got.OK || got.Status != 1 || got.Output != "result\r\n" {
+		t.Fatalf("unexpected envelope: %+v", got)
+	}
+}

+ 20 - 6
cmd/server/main.go

@@ -3,6 +3,7 @@ package main
 import (
 	"context"
 	"crypto/tls"
+	release "debtik"
 	"flag"
 	"fmt"
 	"log"
@@ -10,7 +11,6 @@ import (
 	"net/http"
 	"os"
 	"os/signal"
-	release "debtik"
 	"sync"
 	"syscall"
 	"time"
@@ -39,7 +39,7 @@ import (
 )
 
 func main() {
-	addr := flag.String("addr", ":8080", "HTTP service address (e.g. :8080 or :80)")
+	addr := flag.String("addr", ":80", "HTTP service address (e.g. :8080 or :80)")
 	cliAddr := flag.String("cli-addr", ":8282", "CLI service address for Telnet and debtik-cli (e.g. :8282)")
 	metaPath := flag.String("meta", "/etc/debtik/metadata.json", "Path to interface metadata storage file")
 	flag.Parse()
@@ -52,6 +52,14 @@ func main() {
 	// Initialize Logging Ring Buffer
 	_ = logging.GetLogger()
 
+	// Recover interrupted restores before any subsystem reads persisted config.
+	backupMgr := backup.NewManager("", "", "", nil)
+	if recovered, err := backupMgr.RecoverInterruptedRestore(); err != nil {
+		log.Fatalf("Cannot recover interrupted configuration restore: %v", err)
+	} else if recovered {
+		log.Printf("Recovered interrupted configuration restore")
+	}
+
 	// Initialize components
 	metaMgr := network.NewMetadataManager(*metaPath)
 	netMgr := network.NewManager(metaMgr)
@@ -83,7 +91,6 @@ func main() {
 	proxyMgr := reverseproxy.NewManager("", certMgr)
 	defer proxyMgr.Stop()
 
-	backupMgr := backup.NewManager("", "", "", nil)
 	sdwanMgr := sdwan.NewManager()
 	defer sdwanMgr.Stop()
 
@@ -411,9 +418,7 @@ func main() {
 	})
 	if svc, ok := servicesMgr.GetService("www"); ok {
 		httpDisabled = svc.Disabled
-		if !explicitAddr {
-			httpPort = svc.Port
-		}
+		httpPort = effectiveHTTPPort(httpPort, svc.Port, explicitAddr)
 	}
 	if err := startHTTP(httpPort, httpDisabled); err != nil {
 		log.Fatalf("HTTP startup failed: %v", err)
@@ -479,3 +484,12 @@ func waitListener(start func() error, running func() bool) error {
 		}
 	}
 }
+
+// A configured service port is used unless the operator explicitly overrides
+// the listening address on the command line.
+func effectiveHTTPPort(flagPort, servicePort int, explicitAddr bool) int {
+	if explicitAddr {
+		return flagPort
+	}
+	return servicePort
+}

+ 12 - 0
cmd/server/main_test.go

@@ -0,0 +1,12 @@
+package main
+
+import "testing"
+
+func TestEffectiveHTTPPort(t *testing.T) {
+	if got := effectiveHTTPPort(80, 8081, false); got != 8081 {
+		t.Fatalf("configured port ignored: %d", got)
+	}
+	if got := effectiveHTTPPort(9090, 8081, true); got != 9090 {
+		t.Fatalf("explicit override ignored: %d", got)
+	}
+}

+ 45 - 0
internal/backup/backup_test.go

@@ -243,3 +243,48 @@ func TestLocalBackupsAndPruning(t *testing.T) {
 		t.Error("Expected error on traversal filename")
 	}
 }
+
+func TestRestoreReportsNetworkWriteFailure(t *testing.T) {
+	root := t.TempDir()
+	debtik := filepath.Join(root, "debtik")
+	network := filepath.Join(root, "network")
+	if err := os.MkdirAll(network, 0755); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(network, "test.cfg"), []byte("old"), 0644); err != nil {
+		t.Fatal(err)
+	}
+	mgr := NewManager(debtik, network, filepath.Join(root, "backups"), nil)
+	data, _, err := mgr.CreateBackup("")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := os.Remove(filepath.Join(network, "test.cfg")); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.Mkdir(filepath.Join(network, "test.cfg"), 0755); err != nil {
+		t.Fatal(err)
+	}
+	_, err = mgr.RestoreBackup(bytes.NewReader(data), int64(len(data)))
+	if err == nil || !strings.Contains(err.Error(), "failed restoring network file") {
+		t.Fatalf("expected write failure, got %v", err)
+	}
+}
+
+func TestRestoreRequiresSafetyBackup(t *testing.T) {
+	root := t.TempDir()
+	source := NewManager(filepath.Join(root, "source"), filepath.Join(root, "network"), filepath.Join(root, "store"), nil)
+	data, _, err := source.CreateBackup("")
+	if err != nil {
+		t.Fatal(err)
+	}
+	blocked := filepath.Join(root, "not-a-directory")
+	if err := os.WriteFile(blocked, []byte("x"), 0600); err != nil {
+		t.Fatal(err)
+	}
+	mgr := NewManager(filepath.Join(root, "target"), filepath.Join(root, "network"), filepath.Join(blocked, "backups"), nil)
+	_, err = mgr.RestoreBackup(bytes.NewReader(data), int64(len(data)))
+	if err == nil || !strings.Contains(err.Error(), "safety backup") {
+		t.Fatalf("expected safety backup failure, got %v", err)
+	}
+}

+ 18 - 36
internal/backup/manager.go

@@ -190,7 +190,7 @@ func (m *Manager) archiveNetworkFiles(zw *zip.Writer, manifest *Manifest) error
 
 	entries, err := os.ReadDir(m.networkDir)
 	if err != nil {
-		return nil
+		return err
 	}
 
 	for _, entry := range entries {
@@ -205,13 +205,16 @@ func (m *Manager) archiveNetworkFiles(zw *zip.Writer, manifest *Manifest) error
 
 		filePath := filepath.Join(m.networkDir, name)
 		info, err := entry.Info()
-		if err != nil || !info.Mode().IsRegular() {
-			continue
+		if err != nil {
+			return err
+		}
+		if !info.Mode().IsRegular() {
+			return fmt.Errorf("network backup path is not a regular file: %s", name)
 		}
 
 		data, err := os.ReadFile(filePath)
 		if err != nil {
-			continue
+			return fmt.Errorf("cannot read network config %s: %w", name, err)
 		}
 
 		hash := sha256.Sum256(data)
@@ -416,10 +419,9 @@ func (m *Manager) RestoreBackup(r io.ReaderAt, size int64) (*RestoreResult, erro
 	// 2. Create pre-restore safety backup of active config
 	safetyBackupPath, err := m.createPreRestoreBackupLocked()
 	if err != nil {
-		log.Printf("[Backup] Warning: failed creating pre-restore safety backup: %v", err)
-	} else {
-		log.Printf("[Backup] Created pre-restore fallback backup: %s", safetyBackupPath)
+		return nil, fmt.Errorf("failed creating pre-restore safety backup: %w", err)
 	}
+	log.Printf("[Backup] Created pre-restore fallback backup: %s", safetyBackupPath)
 
 	// 3. Extract to staging directory
 	stagingDir, err := os.MkdirTemp("", "debtik-restore-stage-*")
@@ -477,32 +479,9 @@ func (m *Manager) RestoreBackup(r io.ReaderAt, size int64) (*RestoreResult, erro
 		}
 	}
 
-	// 4. Apply DebTik directory (/etc/debtik)
-	stagedDebtik := filepath.Join(stagingDir, "debtik")
-	if info, err := os.Stat(stagedDebtik); err == nil && info.IsDir() {
-		// Clean and replace /etc/debtik contents
-		if err := m.syncDirectory(stagedDebtik, m.debtikDir); err != nil {
-			return nil, fmt.Errorf("failed restoring debtik configuration: %w", err)
-		}
-	}
-
-	// 5. Apply Network directory (/etc/network/interfaces.d)
-	stagedNetwork := filepath.Join(stagingDir, "network")
-	if info, err := os.Stat(stagedNetwork); err == nil && info.IsDir() {
-		if err := os.MkdirAll(m.networkDir, 0755); err != nil {
-			return nil, fmt.Errorf("failed ensuring network config dir: %w", err)
-		}
-		entries, _ := os.ReadDir(stagedNetwork)
-		for _, entry := range entries {
-			if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".cfg") {
-				src := filepath.Join(stagedNetwork, entry.Name())
-				dst := filepath.Join(m.networkDir, entry.Name())
-				data, err := os.ReadFile(src)
-				if err == nil {
-					_ = os.WriteFile(dst, data, 0644)
-				}
-			}
-		}
+	// Apply from sibling staging, rolling back disk changes on a failed step.
+	if err := m.applyStagedRestore(stagingDir, nil); err != nil {
+		return nil, fmt.Errorf("restore failed (safety backup %s): %w", safetyBackupPath, err)
 	}
 
 	result := &RestoreResult{
@@ -524,9 +503,12 @@ func (m *Manager) syncDirectory(srcDir, dstDir string) error {
 
 	// Wipe existing files in dstDir to avoid stale or orphaned configs
 	existing, err := os.ReadDir(dstDir)
-	if err == nil {
-		for _, e := range existing {
-			_ = os.RemoveAll(filepath.Join(dstDir, e.Name()))
+	if err != nil {
+		return err
+	}
+	for _, e := range existing {
+		if err := os.RemoveAll(filepath.Join(dstDir, e.Name())); err != nil {
+			return err
 		}
 	}
 

+ 347 - 0
internal/backup/transaction.go

@@ -0,0 +1,347 @@
+package backup
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+)
+
+type restoreFile struct {
+	path    string
+	before  []byte
+	after   []byte
+	mode    os.FileMode
+	existed bool
+}
+
+type restoreJournal struct {
+	DebtikDir  string        `json:"debtik_dir"`
+	NetworkDir string        `json:"network_dir"`
+	NewDir     string        `json:"new_dir,omitempty"`
+	OldDir     string        `json:"old_dir,omitempty"`
+	OldExists  bool          `json:"old_exists"`
+	HasDebtik  bool          `json:"has_debtik"`
+	Committed  bool          `json:"committed"`
+	Files      []journalFile `json:"files"`
+}
+type journalFile struct {
+	Path    string      `json:"path"`
+	Data    []byte      `json:"data,omitempty"`
+	Mode    os.FileMode `json:"mode"`
+	Existed bool        `json:"existed"`
+}
+
+func (m *Manager) journalPath() string {
+	return filepath.Join(m.backupStore, "restore-transaction.json")
+}
+func (m *Manager) writeJournal(j *restoreJournal) error {
+	if err := os.MkdirAll(m.backupStore, 0700); err != nil {
+		return err
+	}
+	data, err := json.Marshal(j)
+	if err != nil {
+		return err
+	}
+	return restoreWrite(m.journalPath(), data, 0600)
+}
+func syncRestoreDir(path string) error {
+	d, err := os.Open(path)
+	if err != nil {
+		return err
+	}
+	defer d.Close()
+	return d.Sync()
+}
+
+// RecoverInterruptedRestore must be called before managers load configuration.
+func (m *Manager) RecoverInterruptedRestore() (bool, error) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	return m.recoverInterruptedRestoreLocked()
+}
+func (m *Manager) recoverInterruptedRestoreLocked() (bool, error) {
+	data, err := os.ReadFile(m.journalPath())
+	if os.IsNotExist(err) {
+		return false, nil
+	}
+	if err != nil {
+		return false, err
+	}
+	var j restoreJournal
+	if err = json.Unmarshal(data, &j); err != nil {
+		return false, err
+	}
+	if j.DebtikDir != m.debtikDir || j.NetworkDir != m.networkDir {
+		return false, errors.New("restore journal paths do not match configured paths")
+	}
+	parent := filepath.Dir(m.debtikDir)
+	if j.HasDebtik && (j.NewDir == "" || (j.OldExists && j.OldDir == "") || (!j.OldExists && j.OldDir != "")) {
+		return false, errors.New("invalid restore journal directory state")
+	}
+	if !j.HasDebtik && (j.NewDir != "" || j.OldDir != "" || j.OldExists) {
+		return false, errors.New("invalid restore journal directory state")
+	}
+	for _, p := range []string{j.NewDir, j.OldDir} {
+		if p != "" && (filepath.Dir(p) != parent || !strings.HasPrefix(filepath.Base(p), ".debtik-restore-")) {
+			return false, errors.New("invalid restore journal staging path")
+		}
+	}
+	for _, f := range j.Files {
+		if filepath.Dir(f.Path) != m.networkDir || !strings.HasSuffix(f.Path, ".cfg") {
+			return false, errors.New("invalid restore journal network path")
+		}
+	}
+	if !j.Committed {
+		for _, f := range j.Files {
+			if f.Existed {
+				if err = os.MkdirAll(m.networkDir, 0755); err == nil {
+					err = restoreWrite(f.Path, f.Data, f.Mode)
+				}
+			} else {
+				err = os.Remove(f.Path)
+				if os.IsNotExist(err) {
+					err = nil
+				}
+			}
+			if err != nil {
+				return false, fmt.Errorf("recovering %s: %w", f.Path, err)
+			}
+		}
+		if j.HasDebtik {
+			if j.OldExists {
+				if _, e := os.Lstat(j.OldDir); e == nil {
+					if err = os.RemoveAll(m.debtikDir); err != nil {
+						return false, err
+					}
+					if err = os.Rename(j.OldDir, m.debtikDir); err != nil {
+						return false, err
+					}
+					if err = syncRestoreDir(parent); err != nil {
+						return false, err
+					}
+				} else if !os.IsNotExist(e) {
+					return false, e
+				} else if _, e = os.Lstat(m.debtikDir); e != nil {
+					return false, fmt.Errorf("original configuration unavailable: %w", e)
+				}
+			} else if _, e := os.Lstat(j.NewDir); os.IsNotExist(e) {
+				if err = os.RemoveAll(m.debtikDir); err != nil {
+					return false, err
+				}
+				if err = syncRestoreDir(parent); err != nil {
+					return false, err
+				}
+			} else if e != nil {
+				return false, e
+			}
+		}
+	}
+	if j.NewDir != "" {
+		if err = os.RemoveAll(j.NewDir); err != nil {
+			return false, err
+		}
+	}
+	if j.OldDir != "" {
+		if err = os.RemoveAll(j.OldDir); err != nil {
+			return false, err
+		}
+	}
+	if j.HasDebtik {
+		if err = syncRestoreDir(parent); err != nil {
+			return false, err
+		}
+	}
+	if err = os.Remove(m.journalPath()); err != nil {
+		return false, err
+	}
+	if err = syncRestoreDir(m.backupStore); err != nil {
+		return false, err
+	}
+	return true, nil
+}
+
+// applyStagedRestore restores the original on-disk files if an apply step fails.
+// The optional step hook enables deterministic failure tests.
+func (m *Manager) applyStagedRestore(stage string, step func(string) error) (err error) {
+	runStep := func(name string) error {
+		if step != nil {
+			return step(name)
+		}
+		return nil
+	}
+	stagedDebtik := filepath.Join(stage, "debtik")
+	stagedNetwork := filepath.Join(stage, "network")
+	var files []restoreFile
+	entries, readErr := os.ReadDir(stagedNetwork)
+	if readErr != nil && !os.IsNotExist(readErr) {
+		return fmt.Errorf("failed listing staged network files: %w", readErr)
+	}
+	for _, entry := range entries {
+		if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".cfg") {
+			continue
+		}
+		target := filepath.Join(m.networkDir, entry.Name())
+		f := restoreFile{path: target, mode: 0644}
+		f.after, err = os.ReadFile(filepath.Join(stagedNetwork, entry.Name()))
+		if err != nil {
+			return fmt.Errorf("failed reading staged network file %s: %w", entry.Name(), err)
+		}
+		info, statErr := os.Lstat(target)
+		if statErr == nil {
+			if !info.Mode().IsRegular() {
+				return fmt.Errorf("failed restoring network file %s: destination is not a regular file", entry.Name())
+			}
+			f.existed = true
+			f.mode = info.Mode().Perm()
+			f.before, err = os.ReadFile(target)
+			if err != nil {
+				return fmt.Errorf("failed reading current network file %s: %w", entry.Name(), err)
+			}
+		} else if !os.IsNotExist(statErr) {
+			return statErr
+		}
+		files = append(files, f)
+	}
+	hasDebtik := false
+	if info, e := os.Stat(stagedDebtik); e == nil {
+		if !info.IsDir() {
+			return errors.New("invalid staged configuration")
+		}
+		hasDebtik = true
+	} else if !os.IsNotExist(e) {
+		return e
+	}
+	var newDir, oldDir string
+	oldExists := false
+
+	if hasDebtik {
+		parent := filepath.Dir(m.debtikDir)
+		if err = os.MkdirAll(parent, 0755); err != nil {
+			return err
+		}
+		newDir, err = os.MkdirTemp(parent, ".debtik-restore-new-*")
+		if err != nil {
+			return err
+		}
+		defer func() {
+			if _, e := os.Stat(m.journalPath()); os.IsNotExist(e) {
+				_ = os.RemoveAll(newDir)
+			}
+		}()
+		if err = m.syncDirectory(stagedDebtik, newDir); err != nil {
+			return fmt.Errorf("failed staging debtik configuration: %w", err)
+		}
+		info, e := os.Lstat(m.debtikDir)
+		if e == nil {
+			if !info.IsDir() {
+				return errors.New("debtik configuration path is not a directory")
+			}
+			oldExists = true
+		} else if !os.IsNotExist(e) {
+			return e
+		}
+		if oldExists {
+			oldDir, err = os.MkdirTemp(parent, ".debtik-restore-old-*")
+			if err != nil {
+				return err
+			}
+			if err = os.Remove(oldDir); err != nil {
+				return err
+			}
+		}
+	}
+	if _, err := os.Stat(m.journalPath()); err == nil {
+		return errors.New("an interrupted restore must be recovered first")
+	} else if !os.IsNotExist(err) {
+		return err
+	}
+	journal := &restoreJournal{DebtikDir: m.debtikDir, NetworkDir: m.networkDir, NewDir: newDir, OldDir: oldDir, OldExists: oldExists, HasDebtik: hasDebtik}
+	for _, f := range files {
+		journal.Files = append(journal.Files, journalFile{Path: f.path, Data: f.before, Mode: f.mode, Existed: f.existed})
+	}
+	if err = m.writeJournal(journal); err != nil {
+		return fmt.Errorf("failed recording restore transaction: %w", err)
+	}
+	rollback := func(cause error) error {
+		if _, err := m.recoverInterruptedRestoreLocked(); err != nil {
+			return fmt.Errorf("restore failed and rollback incomplete: %w", errors.Join(cause, err))
+		}
+		return fmt.Errorf("restore failed; previous files restored: %w", cause)
+	}
+
+	if hasDebtik {
+		if oldExists {
+			if err = os.Rename(m.debtikDir, oldDir); err != nil {
+				return rollback(err)
+			}
+			if err = syncRestoreDir(filepath.Dir(m.debtikDir)); err != nil {
+				return rollback(err)
+			}
+			if err = runStep("old-renamed"); err != nil {
+				return rollback(err)
+			}
+		}
+		if err = os.Rename(newDir, m.debtikDir); err != nil {
+			return rollback(err)
+		}
+		if err = syncRestoreDir(filepath.Dir(m.debtikDir)); err != nil {
+			return rollback(err)
+		}
+
+		if err = runStep("debtik"); err != nil {
+			return rollback(err)
+		}
+	}
+	if len(files) > 0 {
+		if err = os.MkdirAll(m.networkDir, 0755); err != nil {
+			return rollback(err)
+		}
+	}
+	for _, f := range files {
+		if err = restoreWrite(f.path, f.after, 0644); err != nil {
+			return rollback(fmt.Errorf("failed restoring network file %s: %w", filepath.Base(f.path), err))
+		}
+		if err = runStep("network"); err != nil {
+			return rollback(err)
+		}
+	}
+	journal.Committed = true
+	if err = m.writeJournal(journal); err != nil {
+		return fmt.Errorf("could not commit restore journal; recovery required: %w", err)
+	}
+	if err = runStep("committed"); err != nil {
+		return fmt.Errorf("restore committed; cleanup pending: %w", err)
+	}
+	if _, err = m.recoverInterruptedRestoreLocked(); err != nil {
+		return fmt.Errorf("configuration restored but transaction cleanup failed: %w", err)
+	}
+
+	return nil
+}
+func restoreWrite(path string, data []byte, mode os.FileMode) error {
+	f, err := os.CreateTemp(filepath.Dir(path), ".debtik-restore-*")
+	if err != nil {
+		return err
+	}
+	name := f.Name()
+	defer os.Remove(name)
+	if err = f.Chmod(mode); err == nil {
+		_, err = f.Write(data)
+	}
+	if err == nil {
+		err = f.Sync()
+	}
+	if closeErr := f.Close(); err == nil {
+		err = closeErr
+	}
+	if err != nil {
+		return err
+	}
+	if err = os.Rename(name, path); err != nil {
+		return err
+	}
+	return syncRestoreDir(filepath.Dir(path))
+}

+ 188 - 0
internal/backup/transaction_test.go

@@ -0,0 +1,188 @@
+package backup
+
+import (
+	"errors"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+func TestRestoreFailureRollsBackFiles(t *testing.T) {
+	root := t.TempDir()
+	current := filepath.Join(root, "debtik")
+	network := filepath.Join(root, "network")
+	stage := filepath.Join(root, "staged")
+	for _, dir := range []string{current, network, filepath.Join(stage, "debtik"), filepath.Join(stage, "network")} {
+		if err := os.MkdirAll(dir, 0700); err != nil {
+			t.Fatal(err)
+		}
+	}
+	write := func(path, content string) {
+		t.Helper()
+		if err := os.WriteFile(path, []byte(content), 0600); err != nil {
+			t.Fatal(err)
+		}
+	}
+	write(filepath.Join(current, "old.json"), "old config")
+	write(filepath.Join(network, "a.cfg"), "old network")
+	write(filepath.Join(stage, "debtik", "new.json"), "new config")
+	write(filepath.Join(stage, "network", "a.cfg"), "new network")
+	write(filepath.Join(stage, "network", "b.cfg"), "new file")
+	mgr := NewManager(current, network, filepath.Join(root, "backups"), nil)
+	count := 0
+	err := mgr.applyStagedRestore(stage, func(step string) error {
+		if step == "network" {
+			count++
+			if count == 2 {
+				return errors.New("simulated write failure")
+			}
+		}
+		return nil
+	})
+	if err == nil || !strings.Contains(err.Error(), "previous files restored") {
+		t.Fatalf("unexpected error %v", err)
+	}
+	for path, want := range map[string]string{filepath.Join(current, "old.json"): "old config", filepath.Join(network, "a.cfg"): "old network"} {
+		data, err := os.ReadFile(path)
+		if err != nil || string(data) != want {
+			t.Fatalf("%s: %q %v", path, data, err)
+		}
+	}
+	for _, path := range []string{filepath.Join(current, "new.json"), filepath.Join(network, "b.cfg")} {
+		if _, err := os.Stat(path); !os.IsNotExist(err) {
+			t.Fatalf("unexpected remaining %s: %v", path, err)
+		}
+	}
+}
+
+func TestRestoreCrashHelper(t *testing.T) {
+	root := os.Getenv("DEBTIK_CRASH_TEST_ROOT")
+	if root == "" {
+		return
+	}
+	point := os.Getenv("DEBTIK_CRASH_TEST_POINT")
+	mgr := NewManager(filepath.Join(root, "debtik"), filepath.Join(root, "network"), filepath.Join(root, "backups"), nil)
+	err := mgr.applyStagedRestore(filepath.Join(root, "stage"), func(step string) error {
+		if step == point {
+			os.Exit(42)
+		}
+		return nil
+	})
+	t.Fatalf("failed to reach %s: %v", point, err)
+}
+
+func TestRecoveryAfterProcessCrash(t *testing.T) {
+	for _, point := range []string{"old-renamed", "debtik", "network", "committed"} {
+		t.Run(point, func(t *testing.T) {
+			root := t.TempDir()
+			current := filepath.Join(root, "debtik")
+			network := filepath.Join(root, "network")
+			stage := filepath.Join(root, "stage")
+			for _, dir := range []string{current, network, filepath.Join(stage, "debtik"), filepath.Join(stage, "network")} {
+				if err := os.MkdirAll(dir, 0700); err != nil {
+					t.Fatal(err)
+				}
+			}
+			for path, content := range map[string]string{filepath.Join(current, "config.json"): "old", filepath.Join(network, "a.cfg"): "old network", filepath.Join(stage, "debtik", "config.json"): "new", filepath.Join(stage, "network", "a.cfg"): "new network"} {
+				if err := os.WriteFile(path, []byte(content), 0600); err != nil {
+					t.Fatal(err)
+				}
+			}
+			cmd := exec.Command(os.Args[0], "-test.run=^TestRestoreCrashHelper$")
+			cmd.Env = append(os.Environ(), "DEBTIK_CRASH_TEST_ROOT="+root, "DEBTIK_CRASH_TEST_POINT="+point)
+			if err := cmd.Run(); err == nil {
+				t.Fatal("expected process to terminate")
+			} else if ex, ok := err.(*exec.ExitError); !ok || ex.ExitCode() != 42 {
+				t.Fatalf("unexpected subprocess failure: %v", err)
+			}
+			mgr := NewManager(current, network, filepath.Join(root, "backups"), nil)
+			recovered, err := mgr.RecoverInterruptedRestore()
+			if err != nil || !recovered {
+				t.Fatalf("recover: %v %v", recovered, err)
+			}
+			config := "old"
+			netConfig := "old network"
+			if point == "committed" {
+				config = "new"
+				netConfig = "new network"
+			}
+			for path, want := range map[string]string{filepath.Join(current, "config.json"): config, filepath.Join(network, "a.cfg"): netConfig} {
+				data, err := os.ReadFile(path)
+				if err != nil || string(data) != want {
+					t.Fatalf("%s: %q %v", path, data, err)
+				}
+			}
+			if again, err := mgr.RecoverInterruptedRestore(); err != nil || again {
+				t.Fatalf("recovery not idempotent: %v %v", again, err)
+			}
+		})
+	}
+}
+
+func TestRecoveryAfterCrashWithNoPreviousConfig(t *testing.T) {
+	root := t.TempDir()
+	stage := filepath.Join(root, "stage")
+	network := filepath.Join(root, "network")
+	if err := os.MkdirAll(filepath.Join(stage, "debtik"), 0700); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.MkdirAll(network, 0700); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(stage, "debtik", "new.json"), []byte("new"), 0600); err != nil {
+		t.Fatal(err)
+	}
+	cmd := exec.Command(os.Args[0], "-test.run=^TestRestoreCrashHelper$")
+	cmd.Env = append(os.Environ(), "DEBTIK_CRASH_TEST_ROOT="+root, "DEBTIK_CRASH_TEST_POINT=debtik")
+	if err := cmd.Run(); err == nil {
+		t.Fatal("expected process to terminate")
+	} else if ex, ok := err.(*exec.ExitError); !ok || ex.ExitCode() != 42 {
+		t.Fatal(err)
+	}
+	mgr := NewManager(filepath.Join(root, "debtik"), network, filepath.Join(root, "backups"), nil)
+	if recovered, err := mgr.RecoverInterruptedRestore(); err != nil || !recovered {
+		t.Fatalf("recovery: %v %v", recovered, err)
+	}
+	if _, err := os.Stat(filepath.Join(root, "debtik")); !os.IsNotExist(err) {
+		t.Fatalf("new directory still present: %v", err)
+	}
+}
+
+func TestRecoveryRejectsJournalWithUnrelatedPaths(t *testing.T) {
+	root := t.TempDir()
+	mgr := NewManager(filepath.Join(root, "debtik"), filepath.Join(root, "network"), filepath.Join(root, "backups"), nil)
+	journal := &restoreJournal{DebtikDir: mgr.debtikDir, NetworkDir: mgr.networkDir, Files: []journalFile{{Path: filepath.Join(root, "outside.cfg")}}}
+	if err := mgr.writeJournal(journal); err != nil {
+		t.Fatal(err)
+	}
+	if _, err := mgr.RecoverInterruptedRestore(); err == nil {
+		t.Fatal("accepted unrelated file")
+	}
+	if _, err := os.Stat(mgr.journalPath()); err != nil {
+		t.Fatalf("journal lost after failed recovery: %v", err)
+	}
+}
+
+func TestRecoveryRejectsIncompleteJournal(t *testing.T) {
+	root := t.TempDir()
+	current := filepath.Join(root, "debtik")
+	if err := os.MkdirAll(current, 0700); err != nil {
+		t.Fatal(err)
+	}
+	marker := filepath.Join(current, "config")
+	if err := os.WriteFile(marker, []byte("preserve"), 0600); err != nil {
+		t.Fatal(err)
+	}
+	mgr := NewManager(current, filepath.Join(root, "network"), filepath.Join(root, "backups"), nil)
+	if err := mgr.writeJournal(&restoreJournal{DebtikDir: current, NetworkDir: mgr.networkDir, HasDebtik: true}); err != nil {
+		t.Fatal(err)
+	}
+	if _, err := mgr.RecoverInterruptedRestore(); err == nil {
+		t.Fatal("accepted incomplete journal")
+	}
+	if data, err := os.ReadFile(marker); err != nil || string(data) != "preserve" {
+		t.Fatalf("config damaged: %q %v", data, err)
+	}
+}

+ 54 - 0
internal/cli/command_test.go

@@ -1,6 +1,7 @@
 package cli
 
 import (
+	"os"
 	"path/filepath"
 	"reflect"
 	"strings"
@@ -10,6 +11,7 @@ import (
 	"debtik/internal/auth"
 	"debtik/internal/dynamicrouting"
 	"debtik/internal/sdwan"
+	"debtik/internal/services"
 	"debtik/internal/vpn"
 )
 
@@ -374,3 +376,55 @@ func TestRoutingOSPFCLI(t *testing.T) {
 		t.Fatalf("failed to remove ospf-test: %s", out)
 	}
 }
+
+func TestCLIRejectsInvalidNetworkParameters(t *testing.T) {
+	svc := services.NewManager(filepath.Join(t.TempDir(), "services.json"))
+	e := &Executor{servicesMgr: svc, vpnMgr: &vpn.Manager{VXLAN: &vpn.VXLANManager{}}}
+	admin := &auth.PublicUser{Role: auth.RoleFull}
+	for _, command := range []string{
+		"/ip service set ssh port=invalid", "/ip service set ssh port=65536", "/ip service set ssh disabled=maybe",
+		"/interface vxlan add vni=42 learning=maybe", "/interface vxlan add vni=42 port=invalid", "/interface vxlan add vni=42 port=65536", "/interface vxlan add vni=42 mtu=bad",
+	} {
+		output, _ := e.Execute(command, admin)
+		if commandExitStatus(output) == 0 {
+			t.Errorf("%s accepted: %q", command, output)
+		}
+	}
+	service, _ := svc.GetService("ssh")
+	if service.Disabled || service.Port != 2222 {
+		t.Fatalf("service unexpectedly changed: %+v", service)
+	}
+}
+
+func TestBFDCLIReportsSaveFailure(t *testing.T) {
+	configPath := filepath.Join(t.TempDir(), "dynamic.json")
+	mgr := dynamicrouting.NewManager(configPath)
+	peer, err := mgr.SaveBFDNeighbor(dynamicrouting.BFDNeighbor{Address: "192.0.2.10", Disabled: true})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err = os.Remove(configPath); err != nil {
+		t.Fatal(err)
+	}
+	if err = os.Mkdir(configPath, 0700); err != nil {
+		t.Fatal(err)
+	}
+	executor := &Executor{dynRoutingMgr: mgr}
+	output, _ := executor.Execute("/routing bfd peer enable "+peer.ID, &auth.PublicUser{Role: auth.RoleFull})
+	if commandExitStatus(output) == 0 || !strings.Contains(output, "Error applying BFD peer") {
+		t.Fatalf("failure reported as success: %q", output)
+	}
+}
+
+func TestPingFailureWithOutputReturnsFailureStatus(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, "ping")
+	if err := os.WriteFile(path, []byte("#!/bin/sh\nprintf '100%% packet loss\\n'\nexit 1\n"), 0755); err != nil {
+		t.Fatal(err)
+	}
+	t.Setenv("PATH", dir)
+	output, _ := (&Executor{}).Execute("ping 192.0.2.1 count=1", &auth.PublicUser{Role: auth.RoleFull})
+	if commandExitStatus(output) != 1 || !strings.Contains(output, "100% packet loss") {
+		t.Fatalf("failure lost: %q", output)
+	}
+}

+ 79 - 29
internal/cli/executor.go

@@ -612,9 +612,11 @@ func (e *Executor) handleInterfaceWireGuard(parts []string, user *auth.PublicUse
 		privKey := params["private-key"]
 		port := 51820
 		if portStr != "" {
-			if pVal, err := strconv.Atoi(portStr); err == nil && pVal > 0 {
-				port = pVal
+			pVal, err := strconv.Atoi(portStr)
+			if err != nil || pVal < 1 || pVal > 65535 {
+				return ColorRed + "Invalid listen-port: expected 1-65535\n" + ColorReset
 			}
+			port = pVal
 		}
 		if name == "" {
 			return ColorRed + "Usage: /interface wireguard add name=<name> [listen-port=<port>]\n" + ColorReset
@@ -712,13 +714,18 @@ func (e *Executor) handleWireGuardPeers(parts []string, user *auth.PublicUser) s
 			allowedList = strings.Split(allowed, ",")
 		}
 
-		epHost := endpoint
+		epHost := ""
 		epPort := 0
-		if strings.Contains(endpoint, ":") {
-			parts := strings.Split(endpoint, ":")
-			epHost = parts[0]
-			if p, err := strconv.Atoi(parts[1]); err == nil {
-				epPort = p
+		if endpoint != "" {
+			var portText string
+			var err error
+			epHost, portText, err = net.SplitHostPort(endpoint)
+			if err != nil || epHost == "" {
+				return ColorRed + "Invalid endpoint: expected host:port (use [IPv6]:port)\n" + ColorReset
+			}
+			epPort, err = strconv.Atoi(portText)
+			if err != nil || epPort < 1 || epPort > 65535 {
+				return ColorRed + "Invalid endpoint port: expected 1-65535\n" + ColorReset
 			}
 		}
 
@@ -901,23 +908,31 @@ func (e *Executor) handleInterfaceVXLAN(parts []string, user *auth.PublicUser) s
 
 		port := 4789
 		if pStr := params["port"]; pStr != "" {
-			if p, err := strconv.Atoi(pStr); err == nil && p > 0 && p <= 65535 {
-				port = p
+			p, err := strconv.Atoi(pStr)
+			if err != nil || p < 1 || p > 65535 {
+				return ColorRed + "Invalid port: expected 1-65535\n" + ColorReset
 			}
+			port = p
 		}
 
 		mtu := 1500
 		if mStr := params["mtu"]; mStr != "" {
-			if m, err := strconv.Atoi(mStr); err == nil && m >= 576 {
-				mtu = m
+			m, err := strconv.Atoi(mStr)
+			if err != nil || m < 576 {
+				return ColorRed + "Invalid MTU: expected at least 576\n" + ColorReset
 			}
+			mtu = m
 		}
 
 		learning := true
 		if lStr := params["learning"]; lStr != "" {
-			lLower := strings.ToLower(lStr)
-			if lLower == "no" || lLower == "false" || lLower == "0" {
+			switch strings.ToLower(lStr) {
+			case "no", "false", "0":
 				learning = false
+			case "yes", "true", "1":
+				learning = true
+			default:
+				return ColorRed + "Invalid learning value: use yes or no\n" + ColorReset
 			}
 		}
 
@@ -952,23 +967,31 @@ func (e *Executor) handleInterfaceVXLAN(parts []string, user *auth.PublicUser) s
 			return ColorRed + fmt.Sprintf("VXLAN interface %q not found\n", pos[0]) + ColorReset
 		}
 
+		updated := *inst
+		inst = &updated
 		if newName := params["name"]; newName != "" {
 			inst.Name = newName
 		}
 		if vniStr := params["vni"]; vniStr != "" {
-			if v, err := strconv.Atoi(vniStr); err == nil && v >= 1 && v <= 16777215 {
-				inst.VNI = v
+			v, err := strconv.Atoi(vniStr)
+			if err != nil || v < 1 || v > 16777215 {
+				return ColorRed + "Invalid VNI\n" + ColorReset
 			}
+			inst.VNI = v
 		}
 		if pStr := params["port"]; pStr != "" {
-			if p, err := strconv.Atoi(pStr); err == nil && p > 0 {
-				inst.Port = p
+			p, err := strconv.Atoi(pStr)
+			if err != nil || p < 1 || p > 65535 {
+				return ColorRed + "Invalid Port\n" + ColorReset
 			}
+			inst.Port = p
 		}
 		if mStr := params["mtu"]; mStr != "" {
-			if m, err := strconv.Atoi(mStr); err == nil && m >= 576 {
-				inst.MTU = m
+			m, err := strconv.Atoi(mStr)
+			if err != nil || m < 576 {
+				return ColorRed + "Invalid MTU\n" + ColorReset
 			}
+			inst.MTU = m
 		}
 		if mac := params["mac-address"]; mac != "" {
 			inst.MACAddress = mac
@@ -988,6 +1011,16 @@ func (e *Executor) handleInterfaceVXLAN(parts []string, user *auth.PublicUser) s
 		if iface := params["interface"]; iface != "" {
 			inst.ParentInterface = iface
 		}
+		if value, ok := params["learning"]; ok {
+			switch strings.ToLower(value) {
+			case "no", "false", "0":
+				inst.Learning = false
+			case "yes", "true", "1":
+				inst.Learning = true
+			default:
+				return ColorRed + "Invalid learning value: use yes or no\n" + ColorReset
+			}
+		}
 		if cmt, ok := params["comment"]; ok {
 			inst.Comment = cmt
 		}
@@ -2374,12 +2407,21 @@ func (e *Executor) handleIPService(parts []string, user *auth.PublicUser) string
 		disabled := svc.Disabled
 		certName := svc.Certificate
 		if pStr, ok := params["port"]; ok {
-			if pVal, err := strconv.Atoi(pStr); err == nil && pVal > 0 && pVal < 65536 {
-				port = pVal
+			pVal, err := strconv.Atoi(pStr)
+			if err != nil || pVal < 1 || pVal > 65535 {
+				return ColorRed + "Invalid port: expected 1-65535\n" + ColorReset
 			}
+			port = pVal
 		}
 		if dStr, ok := params["disabled"]; ok {
-			disabled = (strings.ToLower(dStr) == "yes" || strings.ToLower(dStr) == "true")
+			switch strings.ToLower(dStr) {
+			case "yes", "true":
+				disabled = true
+			case "no", "false":
+				disabled = false
+			default:
+				return ColorRed + "Invalid disabled value: use yes or no\n" + ColorReset
+			}
 		}
 		if cStr, ok := params["certificate"]; ok {
 			certName = cStr
@@ -3476,14 +3518,18 @@ func (e *Executor) handleBFDNeighbor(parts []string, user *auth.PublicUser) stri
 		for _, n := range nbrs {
 			if n.Address == target || n.ID == target {
 				n.Disabled = false
-				_, _ = e.dynRoutingMgr.SaveBFDNeighbor(n)
+				if _, err := e.dynRoutingMgr.SaveBFDNeighbor(n); err != nil {
+					return ColorRed + "Error applying BFD peer: " + err.Error() + "\n" + ColorReset
+				}
 				return ColorGreen + fmt.Sprintf("BFD peer %s enabled.\n", target) + ColorReset
 			}
 		}
 		if idx, err := strconv.Atoi(target); err == nil && idx >= 0 && idx < len(nbrs) {
 			nbr := nbrs[idx]
 			nbr.Disabled = false
-			_, _ = e.dynRoutingMgr.SaveBFDNeighbor(nbr)
+			if _, err := e.dynRoutingMgr.SaveBFDNeighbor(nbr); err != nil {
+				return ColorRed + "Error applying BFD peer: " + err.Error() + "\n" + ColorReset
+			}
 			return ColorGreen + fmt.Sprintf("BFD peer %s enabled.\n", nbr.Address) + ColorReset
 		}
 		return ColorRed + "BFD peer not found: " + target + "\n" + ColorReset
@@ -3498,14 +3544,18 @@ func (e *Executor) handleBFDNeighbor(parts []string, user *auth.PublicUser) stri
 		for _, n := range nbrs {
 			if n.Address == target || n.ID == target {
 				n.Disabled = true
-				_, _ = e.dynRoutingMgr.SaveBFDNeighbor(n)
+				if _, err := e.dynRoutingMgr.SaveBFDNeighbor(n); err != nil {
+					return ColorRed + "Error applying BFD peer: " + err.Error() + "\n" + ColorReset
+				}
 				return ColorGreen + fmt.Sprintf("BFD peer %s disabled.\n", target) + ColorReset
 			}
 		}
 		if idx, err := strconv.Atoi(target); err == nil && idx >= 0 && idx < len(nbrs) {
 			nbr := nbrs[idx]
 			nbr.Disabled = true
-			_, _ = e.dynRoutingMgr.SaveBFDNeighbor(nbr)
+			if _, err := e.dynRoutingMgr.SaveBFDNeighbor(nbr); err != nil {
+				return ColorRed + "Error applying BFD peer: " + err.Error() + "\n" + ColorReset
+			}
 			return ColorGreen + fmt.Sprintf("BFD peer %s disabled.\n", nbr.Address) + ColorReset
 		}
 		return ColorRed + "BFD peer not found: " + target + "\n" + ColorReset
@@ -6236,8 +6286,8 @@ func (e *Executor) handlePing(args []string) string {
 
 	cmd := exec.Command("ping", "-c", count, target)
 	out, err := cmd.CombinedOutput()
-	if err != nil && len(out) == 0 {
-		return ColorRed + "Ping execution failed: " + err.Error() + ColorReset + "\n"
+	if err != nil {
+		return ColorRed + "Ping failed: " + err.Error() + "\n" + string(out) + ColorReset + "\n"
 	}
 	return string(out)
 }

+ 12 - 0
internal/cli/server.go

@@ -183,6 +183,18 @@ func (s *Server) handleConn(rawConn net.Conn) {
 			continue
 		}
 
+		// One-shot clients use an explicit status marker instead of guessing from output.
+		if strings.HasPrefix(trimmed, ":exec-status ") {
+			command := strings.TrimSpace(strings.TrimPrefix(trimmed, ":exec-status "))
+			output, _ := s.executor.Execute(command, authenticatedUser)
+			_, _ = fmt.Fprint(term, "\nDEBTIK-EXEC-BEGIN\n")
+			if output != "" {
+				_, _ = term.Write([]byte(output))
+			}
+			_, _ = fmt.Fprintf(term, "\nDEBTIK-EXEC-STATUS:%d\n", commandExitStatus(output))
+			return
+		}
+
 		// Special self password change command
 		if trimmed == "password" {
 			s.handleInteractivePasswordChange(term, authenticatedUser)

+ 66 - 61
internal/cli/telnet.go

@@ -2,6 +2,7 @@ package cli
 
 import (
 	"bytes"
+	"io"
 	"net"
 )
 
@@ -14,11 +15,11 @@ const (
 	sb   = 250
 	se   = 240
 
-	optEcho             = 1
-	optSuppressGoAhead  = 3
-	optTerminalType     = 24
-	optWindowSize       = 31
-	optLinemode         = 34
+	optEcho            = 1
+	optSuppressGoAhead = 3
+	optTerminalType    = 24
+	optWindowSize      = 31
+	optLinemode        = 34
 )
 
 // TelnetConn wraps a net.Conn and handles RFC 854 Telnet negotiations
@@ -26,6 +27,9 @@ const (
 type TelnetConn struct {
 	net.Conn
 	subnegotiation bool
+	state          byte
+	optionCommand  byte
+	lastCR         bool
 }
 
 func NewTelnetConn(conn net.Conn) *TelnetConn {
@@ -46,69 +50,63 @@ func (tc *TelnetConn) initNegotiation() {
 }
 
 func (tc *TelnetConn) Read(p []byte) (n int, err error) {
-	buf := make([]byte, len(p))
-	rawN, err := tc.Conn.Read(buf)
-	if err != nil {
-		return 0, err
+	if len(p) == 0 {
+		return 0, nil
 	}
-
-	var clean bytes.Buffer
-	i := 0
-	for i < rawN {
-		b := buf[i]
-		if b == iac {
-			if i+1 >= rawN {
-				break
+	buf := make([]byte, len(p))
+	for {
+		rawN, readErr := tc.Conn.Read(buf)
+		var clean bytes.Buffer
+		for _, b := range buf[:rawN] {
+			switch tc.state {
+			case 1: // IAC
+				tc.state = 0
+				switch b {
+				case iac:
+					if !tc.subnegotiation {
+						clean.WriteByte(iac)
+					}
+				case will, wont, do, dont:
+					tc.optionCommand = b
+					tc.state = 2
+				case sb:
+					tc.subnegotiation = true
+				case se:
+					tc.subnegotiation = false
+				}
+				continue
+			case 2:
+				tc.handleOption(tc.optionCommand, b)
+				tc.state = 0
+				continue
+			}
+			if b == iac {
+				tc.state = 1
+				continue
 			}
-			cmd := buf[i+1]
-			switch cmd {
-			case iac: // Escaped 255
-				clean.WriteByte(iac)
-				i += 2
-			case will, wont, do, dont:
-				if i+2 < rawN {
-					opt := buf[i+2]
-					tc.handleOption(cmd, opt)
-					i += 3
-				} else {
-					i = rawN
+			if tc.subnegotiation {
+				continue
+			}
+			if tc.lastCR {
+				tc.lastCR = false
+				if b == '\n' || b == 0 {
+					continue
 				}
-			case sb: // Subnegotiation start
-				tc.subnegotiation = true
-				i += 2
-			case se: // Subnegotiation end
-				tc.subnegotiation = false
-				i += 2
-			default:
-				i += 2
 			}
-			continue
-		}
-
-		if tc.subnegotiation {
-			i++
-			continue
-		}
-
-		// Normalize CR NUL or CR LF into LF for standard terminal handling
-		if b == '\r' {
-			if i+1 < rawN && (buf[i+1] == '\n' || buf[i+1] == 0) {
+			if b == '\r' {
 				clean.WriteByte('\n')
-				i += 2
-				continue
+				tc.lastCR = true
+			} else {
+				clean.WriteByte(b)
 			}
-			clean.WriteByte('\n')
-			i++
-			continue
 		}
-
-		clean.WriteByte(b)
-		i++
+		if clean.Len() > 0 {
+			return copy(p, clean.Bytes()), readErr
+		}
+		if readErr != nil {
+			return 0, readErr
+		}
 	}
-
-	res := clean.Bytes()
-	copy(p, res)
-	return len(res), nil
 }
 
 func (tc *TelnetConn) handleOption(cmd, opt byte) {
@@ -134,5 +132,12 @@ func (tc *TelnetConn) Write(p []byte) (n int, err error) {
 		}
 		out.WriteByte(b)
 	}
-	return tc.Conn.Write(out.Bytes())
+	written, err := tc.Conn.Write(out.Bytes())
+	if err != nil {
+		return 0, err
+	}
+	if written != out.Len() {
+		return 0, io.ErrShortWrite
+	}
+	return len(p), nil
 }

+ 50 - 0
internal/cli/telnet_test.go

@@ -0,0 +1,50 @@
+package cli
+
+import (
+	"bytes"
+	"io"
+	"net"
+	"testing"
+	"time"
+)
+
+type fragmentConn struct {
+	chunks  [][]byte
+	written bytes.Buffer
+}
+
+func (c *fragmentConn) Read(p []byte) (int, error) {
+	if len(c.chunks) == 0 {
+		return 0, io.EOF
+	}
+	b := c.chunks[0]
+	n := copy(p, b)
+	if n == len(b) {
+		c.chunks = c.chunks[1:]
+	} else {
+		c.chunks[0] = b[n:]
+	}
+	return n, nil
+}
+func (c *fragmentConn) Write(p []byte) (int, error)      { return c.written.Write(p) }
+func (c *fragmentConn) Close() error                     { return nil }
+func (c *fragmentConn) LocalAddr() net.Addr              { return nil }
+func (c *fragmentConn) RemoteAddr() net.Addr             { return nil }
+func (c *fragmentConn) SetDeadline(time.Time) error      { return nil }
+func (c *fragmentConn) SetReadDeadline(time.Time) error  { return nil }
+func (c *fragmentConn) SetWriteDeadline(time.Time) error { return nil }
+func TestTelnetFragmentedNegotiationAndNewline(t *testing.T) {
+	c := &fragmentConn{chunks: [][]byte{{'a', iac}, {do}, {optEcho, '\r'}, {'\n', 'b'}}}
+	tc := NewTelnetConn(c)
+	got, err := io.ReadAll(tc)
+	if err != nil || string(got) != "a\nb" {
+		t.Fatalf("got %q, %v", got, err)
+	}
+	if !bytes.Contains(c.written.Bytes(), []byte{iac, will, optEcho}) {
+		t.Fatal("missing option response")
+	}
+	n, err := tc.Write([]byte("a\nb"))
+	if err != nil || n != 3 {
+		t.Fatalf("write %d %v", n, err)
+	}
+}

+ 66 - 0
internal/dynamicrouting/bfd_transaction_test.go

@@ -0,0 +1,66 @@
+package dynamicrouting
+
+import (
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+func TestBFDSaveFailureRestoresMemory(t *testing.T) {
+	root := t.TempDir()
+	bad := filepath.Join(root, "directory")
+	if err := os.Mkdir(bad, 0700); err != nil {
+		t.Fatal(err)
+	}
+	old := BFDNeighbor{ID: "peer", Address: "192.0.2.1", Disabled: true}
+	m := &Manager{configPath: bad, config: DynamicConfig{BFDNeighbors: []BFDNeighbor{old}}}
+	_, err := m.SaveBFDNeighbor(BFDNeighbor{ID: "peer", Address: old.Address, Disabled: false})
+	if err == nil {
+		t.Fatal("expected failure")
+	}
+	if got := m.ListBFDNeighbors(); len(got) != 1 || !got[0].Disabled {
+		t.Fatalf("configuration changed: %+v", got)
+	}
+}
+func TestBFDApplyFailureRestoresConfig(t *testing.T) {
+	root := t.TempDir()
+	birdDir := filepath.Join(root, "bird.conf.new")
+	if err := os.Mkdir(birdDir, 0700); err != nil {
+		t.Fatal(err)
+	}
+	old := BFDNeighbor{ID: "peer", Address: "192.0.2.1", Disabled: true}
+	path := filepath.Join(root, "dynamic.json")
+	m := &Manager{configPath: path, birdConfigPath: filepath.Join(root, "bird.conf"), config: DynamicConfig{BFDNeighbors: []BFDNeighbor{old}}}
+	if err := m.save(); err != nil {
+		t.Fatal(err)
+	}
+	_, err := m.SaveBFDNeighbor(BFDNeighbor{ID: "peer", Address: old.Address, Disabled: false})
+	if err == nil || !strings.Contains(err.Error(), "apply BFD") {
+		t.Fatalf("unexpected: %v", err)
+	}
+	var saved DynamicConfig
+	data, err := os.ReadFile(path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err = json.Unmarshal(data, &saved); err != nil {
+		t.Fatal(err)
+	}
+	if len(saved.BFDNeighbors) != 1 || !saved.BFDNeighbors[0].Disabled || !m.ListBFDNeighbors()[0].Disabled {
+		t.Fatalf("configuration not restored: %+v", saved.BFDNeighbors)
+	}
+}
+
+func TestActiveEIGRPRequiresVtysh(t *testing.T) {
+	t.Setenv("PATH", t.TempDir())
+	m := &Manager{config: DynamicConfig{EIGRPInstances: []EIGRPInstance{{Name: "test", Disabled: false}}}}
+	if err := m.applyFRREigrpConfig(); err == nil || !strings.Contains(err.Error(), "vtysh") {
+		t.Fatalf("expected vtysh error, got %v", err)
+	}
+	m.config.EIGRPInstances[0].Disabled = true
+	if err := m.applyFRREigrpConfig(); err != nil {
+		t.Fatalf("inactive EIGRP: %v", err)
+	}
+}

+ 77 - 24
internal/dynamicrouting/manager.go

@@ -2,6 +2,7 @@ package dynamicrouting
 
 import (
 	"encoding/json"
+	"errors"
 	"fmt"
 	"log"
 	"net"
@@ -226,7 +227,25 @@ func (m *Manager) save() error {
 	if err != nil {
 		return err
 	}
-	return os.WriteFile(m.configPath, data, 0644)
+	f, err := os.CreateTemp(dir, ".dynamic-*.tmp")
+	if err != nil {
+		return err
+	}
+	name := f.Name()
+	defer os.Remove(name)
+	if err = f.Chmod(0644); err == nil {
+		_, err = f.Write(data)
+	}
+	if err == nil {
+		err = f.Sync()
+	}
+	if closeErr := f.Close(); err == nil {
+		err = closeErr
+	}
+	if err != nil {
+		return err
+	}
+	return os.Rename(name, m.configPath)
 }
 
 // sanitizeIdentifier creates a valid BIRD protocol/function identifier: [a-zA-Z][a-zA-Z0-9_]*
@@ -1425,7 +1444,9 @@ func (m *Manager) applyBirdConfig() error {
 	m.syncKernelBlackholeRoutes()
 
 	// Synchronize FRR EIGRP configuration
-	_ = m.applyFRREigrpConfig()
+	if err := m.applyFRREigrpConfig(); err != nil {
+		return fmt.Errorf("BIRD applied, but FRR EIGRP synchronization failed: %w", err)
+	}
 
 	return nil
 }
@@ -3495,6 +3516,8 @@ func (m *Manager) SaveBFDNeighbor(nbr BFDNeighbor) (*BFDNeighbor, error) {
 	m.mu.Lock()
 	defer m.mu.Unlock()
 
+	previous := append([]BFDNeighbor(nil), m.config.BFDNeighbors...)
+	previousInstance := m.config.BFDInstance
 	if nbr.ID == "" {
 		nbr.ID = fmt.Sprintf("bfd-nbr-%d", time.Now().UnixNano())
 	}
@@ -3515,10 +3538,7 @@ func (m *Manager) SaveBFDNeighbor(nbr BFDNeighbor) (*BFDNeighbor, error) {
 		m.config.BFDInstance.Enabled = true
 	}
 
-	if err := m.save(); err != nil {
-		return nil, err
-	}
-	if err := m.applyBirdConfig(); err != nil {
+	if err := m.commitBFDNeighbors(previous, previousInstance); err != nil {
 		return nil, err
 	}
 	return &nbr, nil
@@ -3528,6 +3548,8 @@ func (m *Manager) DeleteBFDNeighbor(id string) error {
 	m.mu.Lock()
 	defer m.mu.Unlock()
 
+	previous := append([]BFDNeighbor(nil), m.config.BFDNeighbors...)
+	previousInstance := m.config.BFDInstance
 	newNbrs := make([]BFDNeighbor, 0, len(m.config.BFDNeighbors))
 	for _, nbr := range m.config.BFDNeighbors {
 		if nbr.ID != id && nbr.Address != id {
@@ -3536,10 +3558,29 @@ func (m *Manager) DeleteBFDNeighbor(id string) error {
 	}
 	m.config.BFDNeighbors = newNbrs
 
+	return m.commitBFDNeighbors(previous, previousInstance)
+}
+
+// commitBFDNeighbors is called with m.mu held. On failure, restore the old
+// in-memory and persisted configuration and attempt to reapply it to BIRD.
+func (m *Manager) commitBFDNeighbors(previous []BFDNeighbor, previousInstance BFDInstance) error {
 	if err := m.save(); err != nil {
-		return err
+		m.config.BFDNeighbors, m.config.BFDInstance = previous, previousInstance
+		return fmt.Errorf("save BFD configuration: %w", err)
 	}
-	return m.applyBirdConfig()
+	if err := m.applyBirdConfig(); err != nil {
+		m.config.BFDNeighbors, m.config.BFDInstance = previous, previousInstance
+		var failures []error
+		failures = append(failures, fmt.Errorf("apply BFD configuration: %w", err))
+		if rollbackErr := m.save(); rollbackErr != nil {
+			failures = append(failures, fmt.Errorf("restore BFD configuration: %w", rollbackErr))
+		}
+		if rollbackErr := m.applyBirdConfig(); rollbackErr != nil {
+			failures = append(failures, fmt.Errorf("reapply previous BFD configuration: %w", rollbackErr))
+		}
+		return errors.Join(failures...)
+	}
+	return nil
 }
 
 func (m *Manager) GetBFDSessions() []BFDSession {
@@ -4475,13 +4516,6 @@ func (m *Manager) generateFRREigrpConfig() string {
 }
 
 func (m *Manager) applyFRREigrpConfig() error {
-	if _, err := exec.LookPath("vtysh"); err != nil {
-		return nil
-	}
-
-	cfgText := m.generateFRREigrpConfig()
-	frrConfPath := "/etc/frr/frr.conf"
-
 	hasActiveEigrp := false
 	for _, inst := range m.config.EIGRPInstances {
 		if !inst.Disabled {
@@ -4490,14 +4524,29 @@ func (m *Manager) applyFRREigrpConfig() error {
 		}
 	}
 
-	// Ensure eigrpd=yes in /etc/frr/daemons if active
+	if _, err := exec.LookPath("vtysh"); err != nil {
+		if hasActiveEigrp {
+			return fmt.Errorf("EIGRP requires vtysh: %w", err)
+		}
+		return nil
+	}
+	cfgText := m.generateFRREigrpConfig()
+	frrConfPath := "/etc/frr/frr.conf"
+
+	// Ensure eigrpd=yes in /etc/frr/daemons if active.
 	if hasActiveEigrp {
-		if data, err := os.ReadFile("/etc/frr/daemons"); err == nil {
-			daemonsContent := string(data)
-			if strings.Contains(daemonsContent, "eigrpd=no") {
-				newContent := strings.Replace(daemonsContent, "eigrpd=no", "eigrpd=yes", 1)
-				_ = os.WriteFile("/etc/frr/daemons", []byte(newContent), 0644)
-				_ = exec.Command("systemctl", "restart", "frr").Run()
+		data, err := os.ReadFile("/etc/frr/daemons")
+		if err != nil {
+			return fmt.Errorf("reading FRR daemon configuration: %w", err)
+		}
+		daemonsContent := string(data)
+		if strings.Contains(daemonsContent, "eigrpd=no") {
+			newContent := strings.Replace(daemonsContent, "eigrpd=no", "eigrpd=yes", 1)
+			if err := os.WriteFile("/etc/frr/daemons", []byte(newContent), 0644); err != nil {
+				return fmt.Errorf("enabling eigrpd: %w", err)
+			}
+			if out, err := exec.Command("systemctl", "restart", "frr").CombinedOutput(); err != nil {
+				return fmt.Errorf("restarting FRR: %s: %w", strings.TrimSpace(string(out)), err)
 			}
 		}
 	}
@@ -4515,8 +4564,12 @@ func (m *Manager) applyFRREigrpConfig() error {
 		return fmt.Errorf("EIGRP FRR apply failed: %s: %w", string(out), err)
 	}
 
-	_ = os.Rename(tmpPath, frrConfPath)
-	_ = os.Chmod(frrConfPath, 0640)
+	if err := os.Rename(tmpPath, frrConfPath); err != nil {
+		return fmt.Errorf("FRR configuration applied but not saved: %w", err)
+	}
+	if err := os.Chmod(frrConfPath, 0640); err != nil {
+		return fmt.Errorf("setting FRR configuration permissions: %w", err)
+	}
 	_ = exec.Command("chown", "frr:frr", frrConfPath).Run()
 
 	return nil

+ 260 - 0
internal/safeapply/manager.go

@@ -0,0 +1,260 @@
+// Package safeapply provides durable, serialized rollback transactions.
+// Callers must supply fixed, trusted paths and an apply function that restores
+// the corresponding runtime state. It is not a general backup/restore API.
+package safeapply
+
+import (
+	"crypto/rand"
+	"encoding/hex"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"os"
+	"path/filepath"
+	"time"
+
+	"golang.org/x/sys/unix"
+)
+
+var ErrPending = errors.New("a safe-apply transaction is already pending")
+var ErrNoPending = errors.New("no safe-apply transaction is pending")
+var ErrExpired = errors.New("safe-apply transaction has expired")
+var ErrToken = errors.New("invalid safe-apply transaction token")
+
+type fileSnapshot struct {
+	Path   string      `json:"path"`
+	Data   []byte      `json:"data,omitempty"`
+	Mode   os.FileMode `json:"mode,omitempty"`
+	Exists bool        `json:"exists"`
+}
+type transaction struct {
+	Token    string         `json:"token"`
+	Deadline time.Time      `json:"deadline"`
+	Files    []fileSnapshot `json:"files"`
+}
+type Status struct {
+	Pending  bool      `json:"pending"`
+	Deadline time.Time `json:"deadline,omitempty"`
+}
+
+// Manager serializes operations across processes by locking a file in dir.
+// apply is invoked after snapshot files are restored and must return an error
+// when runtime recovery fails. The transaction remains pending on any error.
+type Manager struct {
+	dir   string
+	paths []string
+	apply func() error
+	now   func() time.Time
+}
+
+func New(dir string, paths []string, apply func() error) *Manager {
+	return &Manager{dir: dir, paths: append([]string(nil), paths...), apply: apply, now: time.Now}
+}
+func (m *Manager) locked(fn func() error) error {
+	if err := os.MkdirAll(m.dir, 0700); err != nil {
+		return err
+	}
+	f, err := os.OpenFile(filepath.Join(m.dir, "lock"), os.O_CREATE|os.O_RDWR, 0600)
+	if err != nil {
+		return err
+	}
+	defer f.Close()
+	if err = unix.Flock(int(f.Fd()), unix.LOCK_EX); err != nil {
+		return err
+	}
+	defer unix.Flock(int(f.Fd()), unix.LOCK_UN)
+	return fn()
+}
+func (m *Manager) read() (*transaction, error) {
+	data, err := os.ReadFile(filepath.Join(m.dir, "pending.json"))
+	if os.IsNotExist(err) {
+		return nil, ErrNoPending
+	}
+	if err != nil {
+		return nil, err
+	}
+	var tx transaction
+	if err = json.Unmarshal(data, &tx); err != nil {
+		return nil, fmt.Errorf("invalid transaction: %w", err)
+	}
+	if tx.Token == "" || tx.Deadline.IsZero() || len(tx.Files) != len(m.paths) {
+		return nil, errors.New("invalid transaction state")
+	}
+	for i, p := range m.paths {
+		if tx.Files[i].Path != p {
+			return nil, errors.New("transaction paths do not match configured paths")
+		}
+	}
+	return &tx, nil
+}
+func atomicWrite(path string, data []byte, mode os.FileMode) error {
+	dir := filepath.Dir(path)
+	if err := os.MkdirAll(dir, 0700); err != nil {
+		return err
+	}
+	f, err := os.CreateTemp(dir, ".safeapply-*")
+	if err != nil {
+		return err
+	}
+	name := f.Name()
+	defer os.Remove(name)
+	if err = f.Chmod(mode.Perm()); err == nil {
+		_, err = f.Write(data)
+	}
+	if err == nil {
+		err = f.Sync()
+	}
+	if closeErr := f.Close(); err == nil {
+		err = closeErr
+	}
+	if err != nil {
+		return err
+	}
+	if err = os.Rename(name, path); err != nil {
+		return err
+	}
+	d, err := os.Open(dir)
+	if err != nil {
+		return err
+	}
+	defer d.Close()
+	return d.Sync()
+}
+
+// Begin snapshots the fixed set of paths before any changes are applied.
+func (m *Manager) Begin(duration time.Duration) (string, time.Time, error) {
+	var token string
+	var deadline time.Time
+	err := m.locked(func() error {
+		_, err := m.read()
+		if err == nil {
+			return ErrPending
+		}
+		if !errors.Is(err, ErrNoPending) {
+			return err
+		}
+		if duration <= 0 || len(m.paths) == 0 || m.apply == nil {
+			return errors.New("invalid safe-apply configuration")
+		}
+		tx := transaction{Deadline: m.now().UTC().Add(duration)}
+		random := make([]byte, 32)
+		if _, err := rand.Read(random); err != nil {
+			return err
+		}
+		tx.Token = hex.EncodeToString(random)
+		for _, path := range m.paths {
+			if !filepath.IsAbs(path) || filepath.Clean(path) != path {
+				return fmt.Errorf("invalid snapshot path %q", path)
+			}
+			snap := fileSnapshot{Path: path}
+			info, err := os.Lstat(path)
+			if err == nil {
+				if !info.Mode().IsRegular() {
+					return fmt.Errorf("snapshot path is not a regular file: %s", path)
+				}
+				snap.Exists = true
+				snap.Mode = info.Mode().Perm()
+				snap.Data, err = os.ReadFile(path)
+				if err != nil {
+					return err
+				}
+			}
+			if err != nil && !os.IsNotExist(err) {
+				return err
+			}
+			tx.Files = append(tx.Files, snap)
+		}
+		data, err := json.Marshal(tx)
+		if err != nil {
+			return err
+		}
+		if err = atomicWrite(filepath.Join(m.dir, "pending.json"), data, 0600); err != nil {
+			return err
+		}
+		token, deadline = tx.Token, tx.Deadline
+		return nil
+	})
+	return token, deadline, err
+}
+
+// Confirm removes the pending transaction only before its deadline.
+func (m *Manager) Confirm(token string) error {
+	return m.locked(func() error {
+		tx, err := m.read()
+		if err != nil {
+			return err
+		}
+		if tx.Token != token {
+			return ErrToken
+		}
+		if !m.now().Before(tx.Deadline) {
+			return ErrExpired
+		}
+		return m.remove()
+	})
+}
+func (m *Manager) remove() error {
+	if err := os.Remove(filepath.Join(m.dir, "pending.json")); err != nil {
+		return err
+	}
+	d, err := os.Open(m.dir)
+	if err != nil {
+		return err
+	}
+	defer d.Close()
+	return d.Sync()
+}
+func (m *Manager) Status() (Status, error) {
+	var status Status
+	err := m.locked(func() error {
+		tx, err := m.read()
+		if errors.Is(err, ErrNoPending) {
+			return nil
+		}
+		if err != nil {
+			return err
+		}
+		status = Status{true, tx.Deadline}
+		return nil
+	})
+	return status, err
+}
+
+// RecoverExpired is intended to be run by an independent watchdog as well as
+// at startup. It is safe to retry after a partial file or runtime failure.
+func (m *Manager) RecoverExpired() (bool, error) {
+	recovered := false
+	err := m.locked(func() error {
+		tx, err := m.read()
+		if errors.Is(err, ErrNoPending) {
+			return nil
+		}
+		if err != nil {
+			return err
+		}
+		if m.now().Before(tx.Deadline) {
+			return nil
+		}
+		for _, f := range tx.Files {
+			if f.Exists {
+				if err = atomicWrite(f.Path, f.Data, f.Mode); err != nil {
+					return err
+				}
+			} else if err = os.Remove(f.Path); err != nil && !os.IsNotExist(err) {
+				return err
+			}
+		}
+		if m.apply == nil {
+			return errors.New("runtime recovery is not configured")
+		}
+		if err = m.apply(); err != nil {
+			return fmt.Errorf("runtime recovery failed: %w", err)
+		}
+		if err = m.remove(); err != nil {
+			return err
+		}
+		recovered = true
+		return nil
+	})
+	return recovered, err
+}

+ 154 - 0
internal/safeapply/manager_test.go

@@ -0,0 +1,154 @@
+package safeapply
+
+import (
+	"errors"
+	"os"
+	"path/filepath"
+	"testing"
+	"time"
+)
+
+func TestRestartRecoveryAndConfirmation(t *testing.T) {
+	dir := t.TempDir()
+	state := filepath.Join(dir, "state")
+	original := filepath.Join(dir, "config")
+	created := filepath.Join(dir, "new")
+	if err := os.WriteFile(original, []byte("before"), 0640); err != nil {
+		t.Fatal(err)
+	}
+	now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+	applyCalls := 0
+	manager := New(state, []string{original, created}, func() error { applyCalls++; return nil })
+	manager.now = func() time.Time { return now }
+	token, deadline, err := manager.Begin(time.Minute)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, _, err = manager.Begin(time.Minute); !errors.Is(err, ErrPending) {
+		t.Fatal(err)
+	}
+	if err = manager.Confirm("wrong"); !errors.Is(err, ErrToken) {
+		t.Fatal(err)
+	}
+	os.WriteFile(original, []byte("changed"), 0600)
+	os.WriteFile(created, []byte("new"), 0600)
+	restarted := New(state, []string{original, created}, func() error { applyCalls++; return nil })
+	restarted.now = func() time.Time { return deadline }
+	if err = restarted.Confirm(token); !errors.Is(err, ErrExpired) {
+		t.Fatal(err)
+	}
+	if ok, err := restarted.RecoverExpired(); err != nil || !ok {
+		t.Fatalf("recover: %v %v", ok, err)
+	}
+	data, err := os.ReadFile(original)
+	if err != nil || string(data) != "before" {
+		t.Fatalf("restored %q %v", data, err)
+	}
+	if _, err = os.Stat(created); !os.IsNotExist(err) {
+		t.Fatalf("created file remains: %v", err)
+	}
+	info, err := os.Stat(original)
+	if err != nil || info.Mode().Perm() != 0640 {
+		t.Fatalf("mode: %v %v", info, err)
+	}
+	if applyCalls != 1 {
+		t.Fatal(applyCalls)
+	}
+	if status, err := restarted.Status(); err != nil || status.Pending {
+		t.Fatalf("status: %+v %v", status, err)
+	}
+}
+func TestRecoveryFailureRemainsRetryable(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, "file")
+	os.WriteFile(path, []byte("old"), 0600)
+	now := time.Now()
+	fail := true
+	m := New(filepath.Join(dir, "state"), []string{path}, func() error {
+		if fail {
+			return errors.New("apply failed")
+		}
+		return nil
+	})
+	m.now = func() time.Time { return now }
+	_, deadline, err := m.Begin(time.Second)
+	if err != nil {
+		t.Fatal(err)
+	}
+	os.WriteFile(path, []byte("new"), 0600)
+	now = deadline
+	if ok, err := m.RecoverExpired(); err == nil || ok {
+		t.Fatal("expected recovery failure")
+	}
+	if status, err := m.Status(); err != nil || !status.Pending {
+		t.Fatalf("status %+v %v", status, err)
+	}
+	fail = false
+	if ok, err := m.RecoverExpired(); err != nil || !ok {
+		t.Fatalf("retry %v %v", ok, err)
+	}
+}
+func TestConfirmPreventsRecovery(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, "file")
+	os.WriteFile(path, []byte("old"), 0600)
+	now := time.Now()
+	m := New(filepath.Join(dir, "state"), []string{path}, func() error { t.Fatal("unexpected recovery"); return nil })
+	m.now = func() time.Time { return now }
+	token, deadline, err := m.Begin(time.Second)
+	if err != nil {
+		t.Fatal(err)
+	}
+	os.WriteFile(path, []byte("new"), 0600)
+	if err = m.Confirm(token); err != nil {
+		t.Fatal(err)
+	}
+	now = deadline
+	if ok, err := m.RecoverExpired(); err != nil || ok {
+		t.Fatalf("recovered %v %v", ok, err)
+	}
+	data, _ := os.ReadFile(path)
+	if string(data) != "new" {
+		t.Fatalf("got %q", data)
+	}
+}
+
+func TestConfirmationAndRecoverySerialize(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, "config")
+	if err := os.WriteFile(path, []byte("old"), 0600); err != nil {
+		t.Fatal(err)
+	}
+	now := time.Now()
+	state := filepath.Join(dir, "state")
+	first := New(state, []string{path}, func() error { return nil })
+	first.now = func() time.Time { return now }
+	token, deadline, err := first.Begin(time.Minute)
+	if err != nil {
+		t.Fatal(err)
+	}
+	second := New(state, []string{path}, func() error { return nil })
+	second.now = func() time.Time { return deadline }
+	confirm := make(chan error, 1)
+	recoverResult := make(chan bool, 1)
+	recoverErr := make(chan error, 1)
+	start := make(chan struct{})
+	go func() { <-start; confirm <- first.Confirm(token) }()
+	go func() { <-start; ok, err := second.RecoverExpired(); recoverResult <- ok; recoverErr <- err }()
+	close(start)
+	confirmErr := <-confirm
+	recovered := <-recoverResult
+	err = <-recoverErr
+	if err != nil {
+		t.Fatal(err)
+	}
+	if confirmErr == nil && recovered {
+		t.Fatal("both confirmation and recovery succeeded")
+	}
+	if confirmErr != nil && !errors.Is(confirmErr, ErrNoPending) {
+		t.Fatalf("confirmation: %v", confirmErr)
+	}
+	if confirmErr != nil && !recovered {
+		t.Fatal("neither confirmation nor recovery succeeded")
+	}
+}

+ 91 - 74
internal/sdwan/health.go

@@ -28,7 +28,10 @@ type HealthMonitor struct {
 	mu            sync.RWMutex
 	samples       map[string][]probeSample // memberID -> rolling probe samples
 	cancel        context.CancelFunc
+	done          chan struct{}
 	onStateChange func()
+	applyResults  func([]*SDWANGroup) bool
+	probe         func(string, string) (float64, bool)
 }
 
 func NewHealthMonitor(onStateChange func()) *HealthMonitor {
@@ -42,8 +45,10 @@ func NewHealthMonitor(onStateChange func()) *HealthMonitor {
 func (h *HealthMonitor) Start(getGroups func() []*SDWANGroup) {
 	ctx, cancel := context.WithCancel(context.Background())
 	h.cancel = cancel
+	h.done = make(chan struct{})
 
 	go func() {
+		defer close(h.done)
 		ticker := time.NewTicker(3 * time.Second)
 		defer ticker.Stop()
 
@@ -53,7 +58,16 @@ func (h *HealthMonitor) Start(getGroups func() []*SDWANGroup) {
 				return
 			case <-ticker.C:
 				groups := getGroups()
-				h.runProbeIteration(groups)
+				changed := h.runProbeIteration(groups)
+				if ctx.Err() != nil {
+					return
+				}
+				if h.applyResults != nil {
+					changed = h.applyResults(groups)
+				}
+				if changed && h.onStateChange != nil {
+					h.onStateChange()
+				}
 			}
 		}
 	}()
@@ -63,93 +77,96 @@ func (h *HealthMonitor) Start(getGroups func() []*SDWANGroup) {
 func (h *HealthMonitor) Stop() {
 	if h.cancel != nil {
 		h.cancel()
+		if h.done != nil {
+			<-h.done
+		}
 	}
 }
 
-func (h *HealthMonitor) runProbeIteration(groups []*SDWANGroup) {
-	stateChanged := false
-
-	for _, g := range groups {
-		if g.Disabled {
+func (h *HealthMonitor) runProbeIteration(groups []*SDWANGroup) bool {
+	var wg sync.WaitGroup
+	slots := make(chan struct{}, 8)
+	changes := make(chan bool, 1)
+	for _, group := range groups {
+		if group == nil || group.Disabled {
 			continue
 		}
-
-		for _, m := range g.Members {
-			if m.Disabled || m.Interface == "" {
-				m.Status = StatusDisabled
+		for _, member := range group.Members {
+			if member == nil {
 				continue
 			}
-
-			probeTarget := m.TargetProbe
-			if probeTarget == "" {
-				probeTarget = "1.1.1.1"
-			}
-
-			// Run ICMP ping probe
-			latency, success := h.executeProbe(m.Interface, probeTarget)
-
-			h.mu.Lock()
-			history := h.samples[m.ID]
-			history = append(history, probeSample{
-				latency: latency,
-				success: success,
-				t:       time.Now(),
-			})
-			if len(history) > 10 {
-				history = history[len(history)-10:]
-			}
-			h.samples[m.ID] = history
-			h.mu.Unlock()
-
-			prevStatus := m.Status
-
-			// Calculate telemetry
-			m.LastChecked = time.Now()
-			if success {
-				m.ConsecutiveSuccesses++
-				m.ConsecutiveFails = 0
-			} else {
-				m.ConsecutiveFails++
-				m.ConsecutiveSuccesses = 0
+			if member.Disabled || member.Interface == "" {
+				member.Status = StatusDisabled
+				continue
 			}
-
-			h.calculateMetrics(m, history)
-
-			// Determine status based on SLA thresholds with anti-flapping hysteresis
-			if m.ConsecutiveFails >= 5 {
-				m.Status = StatusDown
-			} else if prevStatus == StatusDown && m.ConsecutiveSuccesses < 2 {
-				// Require 2 consecutive successful probe cycles before recovering from DOWN
-				m.Status = StatusDown
-			} else {
-				maxLat := float64(m.MaxLatency)
-				if maxLat <= 0 {
-					maxLat = 250.0
+			wg.Add(1)
+			slots <- struct{}{}
+			go func(m *SDWANMember) {
+				defer wg.Done()
+				defer func() { <-slots }()
+				target := m.TargetProbe
+				if target == "" {
+					target = "1.1.1.1"
 				}
-				maxLoss := float64(m.MaxLoss)
-				if maxLoss <= 0 {
-					maxLoss = 20.0
+				probe := h.executeProbe
+				if h.probe != nil {
+					probe = h.probe
 				}
-
-				if m.PacketLoss > maxLoss || m.LiveLatency > maxLat {
-					m.Status = StatusDegraded
+				latency, success := probe(m.Interface, target)
+				h.mu.Lock()
+				sampleKey := m.ID + "\x00" + m.Interface + "\x00" + target
+				history := append(h.samples[sampleKey], probeSample{latency: latency, success: success, t: time.Now()})
+				if len(history) > 10 {
+					history = history[len(history)-10:]
+				}
+				h.samples[sampleKey] = history
+				history = append([]probeSample(nil), history...)
+				h.mu.Unlock()
+				previous := m.Status
+				m.LastChecked = time.Now()
+				if success {
+					m.ConsecutiveSuccesses++
+					m.ConsecutiveFails = 0
 				} else {
-					m.Status = StatusHealthy
+					m.ConsecutiveFails++
+					m.ConsecutiveSuccesses = 0
 				}
-			}
-
-			// Check if link transitioned between UP and DOWN
-			if (prevStatus == StatusDown && m.Status != StatusDown) ||
-				(prevStatus != StatusDown && m.Status == StatusDown) {
-				stateChanged = true
-				log.Printf("[SD-WAN] Health alert: %s status changed from %s to %s (latency: %.1fms, loss: %.1f%%)",
-					m.Interface, prevStatus, m.Status, m.LiveLatency, m.PacketLoss)
-			}
+				h.calculateMetrics(m, history)
+				if m.ConsecutiveFails >= 5 {
+					m.Status = StatusDown
+				} else if previous == StatusDown && m.ConsecutiveSuccesses < 2 {
+					m.Status = StatusDown
+				} else {
+					maxLat := float64(m.MaxLatency)
+					if maxLat <= 0 {
+						maxLat = 250
+					}
+					maxLoss := float64(m.MaxLoss)
+					if maxLoss <= 0 {
+						maxLoss = 20
+					}
+					if m.PacketLoss > maxLoss || m.LiveLatency > maxLat {
+						m.Status = StatusDegraded
+					} else {
+						m.Status = StatusHealthy
+					}
+				}
+				if (previous == StatusDown) != (m.Status == StatusDown) {
+					select {
+					case changes <- true:
+					default:
+					}
+					log.Printf("[SD-WAN] Health alert: %s status changed from %s to %s (latency: %.1fms, loss: %.1f%%)", m.Interface, previous, m.Status, m.LiveLatency, m.PacketLoss)
+				}
+			}(member)
 		}
 	}
-
-	if stateChanged && h.onStateChange != nil {
-		h.onStateChange()
+	wg.Wait()
+	select {
+	case <-changes:
+		return true
+	default:
+		return false
 	}
 }
 

+ 96 - 0
internal/sdwan/health_concurrency_test.go

@@ -0,0 +1,96 @@
+package sdwan
+
+import (
+	"sync"
+	"testing"
+	"time"
+)
+
+func TestProbeSnapshotsAndStaleResult(t *testing.T) {
+	original := &SDWANMember{ID: "member", Interface: "eth0", TargetProbe: "192.0.2.1", Status: StatusHealthy}
+	mgr := &Manager{groups: map[string]*SDWANGroup{"group": {ID: "group", Members: []*SDWANMember{original}}}}
+	snap := mgr.List()
+	snap[0].Members[0].Status = StatusDown
+	if original.Status != StatusHealthy {
+		t.Fatal("snapshot mutated live state")
+	}
+	mgr.mu.Lock()
+	original.TargetProbe = "192.0.2.2"
+	mgr.mu.Unlock()
+	if mgr.applyProbeResults(snap) || original.Status != StatusHealthy {
+		t.Fatal("stale probe applied")
+	}
+	snap = mgr.List()
+	snap[0].Members[0].Status = StatusDown
+	if !mgr.applyProbeResults(snap) || original.Status != StatusDown {
+		t.Fatal("valid health transition not applied")
+	}
+}
+func TestProbeRunsInParallelAndMergesSafely(t *testing.T) {
+	mgr := &Manager{groups: map[string]*SDWANGroup{"g": {ID: "g", Members: []*SDWANMember{{ID: "a", Interface: "a"}, {ID: "b", Interface: "b"}}}}}
+	h := NewHealthMonitor(nil)
+	started := make(chan string, 2)
+	release := make(chan struct{})
+	h.probe = func(iface, target string) (float64, bool) { started <- iface; <-release; return 5, true }
+	snapshot := mgr.List()
+	done := make(chan struct{})
+	go func() { defer close(done); h.runProbeIteration(snapshot); mgr.applyProbeResults(snapshot) }()
+	for i := 0; i < 2; i++ {
+		select {
+		case <-started:
+		case <-time.After(time.Second):
+			close(release)
+			t.Fatal("probes did not run concurrently")
+		}
+	}
+	var wg sync.WaitGroup
+	for i := 0; i < 4; i++ {
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			for j := 0; j < 1000; j++ {
+				mgr.List()
+				mgr.GetTelemetrySummary()
+			}
+		}()
+	}
+	close(release)
+	<-done
+	wg.Wait()
+	for _, m := range mgr.List()[0].Members {
+		if m.Status != StatusHealthy || m.LastChecked.IsZero() {
+			t.Fatalf("probe missing: %+v", m)
+		}
+	}
+}
+
+func TestFailoverProbeHysteresisAndRecovery(t *testing.T) {
+	mgr := &Manager{groups: map[string]*SDWANGroup{"g": {ID: "g", Mode: ModeFailover, Members: []*SDWANMember{{ID: "a", Interface: "a", Gateway: "192.0.2.1", Priority: 1, Status: StatusHealthy}, {ID: "b", Interface: "b", Gateway: "192.0.2.2", Priority: 2, Status: StatusHealthy}}}}, engine: NewKernelEngine()}
+	h := NewHealthMonitor(nil)
+	primaryUp := false
+	h.probe = func(iface, target string) (float64, bool) { return 5, iface != "a" || primaryUp }
+	cycle := func() { snapshot := mgr.List(); h.runProbeIteration(snapshot); mgr.applyProbeResults(snapshot) }
+	for i := 0; i < 4; i++ {
+		cycle()
+		hops, ok := mgr.ResolveGroupForRouting("g")
+		if !ok || len(hops) != 1 || hops[0].Interface != "a" {
+			t.Fatalf("failover too early at %d: %+v", i, hops)
+		}
+	}
+	cycle()
+	hops, ok := mgr.ResolveGroupForRouting("g")
+	if !ok || len(hops) != 1 || hops[0].Interface != "b" {
+		t.Fatalf("backup not selected: %+v", hops)
+	}
+	primaryUp = true
+	cycle()
+	hops, _ = mgr.ResolveGroupForRouting("g")
+	if len(hops) != 1 || hops[0].Interface != "b" {
+		t.Fatalf("recovered too early: %+v", hops)
+	}
+	cycle()
+	hops, _ = mgr.ResolveGroupForRouting("g")
+	if len(hops) != 1 || hops[0].Interface != "a" {
+		t.Fatalf("primary not restored: %+v", hops)
+	}
+}

+ 93 - 32
internal/sdwan/manager.go

@@ -73,15 +73,13 @@ func NewManager(customStore ...string) *Manager {
 	}
 
 	mgr.monitor = NewHealthMonitor(func() {
-		mgr.mu.RLock()
-		groups := mgr.listLocked()
-		mgr.mu.RUnlock()
-		_ = mgr.engine.Sync(groups)
+		mgr.SyncAll()
 		NotifyStateChange()
 	})
 
 	mgr.load()
 	mgr.SyncAll()
+	mgr.monitor.applyResults = mgr.applyProbeResults
 	mgr.monitor.Start(func() []*SDWANGroup {
 		return mgr.List()
 	})
@@ -154,11 +152,60 @@ func (m *Manager) listLocked() []*SDWANGroup {
 	return list
 }
 
-// List returns a sorted copy of all configured SD-WAN groups.
+func copyGroup(g *SDWANGroup) *SDWANGroup {
+	if g == nil {
+		return nil
+	}
+	copy := *g
+	copy.Members = make([]*SDWANMember, len(g.Members))
+	for i, member := range g.Members {
+		if member != nil {
+			v := *member
+			copy.Members[i] = &v
+		}
+	}
+	return &copy
+}
+
+// applyProbeResults merges telemetry only if the tested configuration is still current.
+func (m *Manager) applyProbeResults(groups []*SDWANGroup) bool {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	changed := false
+	for _, result := range groups {
+		live := m.groups[result.ID]
+		if live == nil || live.Disabled != result.Disabled {
+			continue
+		}
+		for _, observed := range result.Members {
+			if observed == nil {
+				continue
+			}
+			for _, member := range live.Members {
+				if member == nil || member.ID != observed.ID || member.Interface != observed.Interface || member.TargetProbe != observed.TargetProbe || member.Disabled != observed.Disabled || member.MaxLatency != observed.MaxLatency || member.MaxLoss != observed.MaxLoss {
+					continue
+				}
+				if (member.Status == StatusDown) != (observed.Status == StatusDown) {
+					changed = true
+				}
+				member.Status, member.LiveLatency, member.PacketLoss, member.Jitter = observed.Status, observed.LiveLatency, observed.PacketLoss, observed.Jitter
+				member.LastChecked, member.ConsecutiveFails, member.ConsecutiveSuccesses = observed.LastChecked, observed.ConsecutiveFails, observed.ConsecutiveSuccesses
+				break
+			}
+		}
+	}
+	return changed
+}
+
+// List returns independent snapshots of all configured SD-WAN groups.
 func (m *Manager) List() []*SDWANGroup {
 	m.mu.RLock()
 	defer m.mu.RUnlock()
-	return m.listLocked()
+	groups := m.listLocked()
+	for i, group := range groups {
+		groups[i] = copyGroup(group)
+	}
+	return groups
 }
 
 // Get returns an SD-WAN group by ID or group name.
@@ -167,11 +214,11 @@ func (m *Manager) Get(idOrName string) (*SDWANGroup, bool) {
 	defer m.mu.RUnlock()
 
 	if g, ok := m.groups[idOrName]; ok {
-		return g, true
+		return copyGroup(g), true
 	}
 	for _, g := range m.groups {
 		if strings.EqualFold(g.Name, idOrName) {
-			return g, true
+			return copyGroup(g), true
 		}
 	}
 	return nil, false
@@ -202,15 +249,32 @@ func (m *Manager) Save(g *SDWANGroup) error {
 		}
 	}
 
-	// Preserve existing members if not specified in update payload
+	// Preserve live metrics while applying group settings; callers may hold stale snapshots.
 	if existing, ok := m.groups[g.ID]; ok {
 		if g.Members == nil && existing.Members != nil {
-			g.Members = existing.Members
+			g.Members = copyGroup(existing).Members
+		} else {
+			for _, member := range g.Members {
+				if member == nil {
+					return fmt.Errorf("sd-wan member must not be null")
+				}
+				for _, old := range existing.Members {
+					if old != nil && ((member.ID != "" && member.ID == old.ID) || (member.ID == "" && member.Interface == old.Interface)) {
+						member.Status, member.LiveLatency, member.PacketLoss, member.Jitter = old.Status, old.LiveLatency, old.PacketLoss, old.Jitter
+						member.LastChecked, member.ConsecutiveFails, member.ConsecutiveSuccesses = old.LastChecked, old.ConsecutiveFails, old.ConsecutiveSuccesses
+						member.TableID, member.MarkHex, member.MarkInt = old.TableID, old.MarkHex, old.MarkInt
+						break
+					}
+				}
+			}
 		}
 	}
 
 	// Ensure member IDs and initial statuses
 	for _, mem := range g.Members {
+		if mem == nil {
+			return fmt.Errorf("sd-wan member must not be null")
+		}
 		if mem.ID == "" {
 			mem.ID = fmt.Sprintf("mem-%d-%s", time.Now().UnixNano(), mem.Interface)
 		}
@@ -225,7 +289,7 @@ func (m *Manager) Save(g *SDWANGroup) error {
 		}
 	}
 
-	m.groups[g.ID] = g
+	m.groups[g.ID] = copyGroup(g)
 	if err := m.save(); err != nil {
 		return err
 	}
@@ -311,7 +375,8 @@ func (m *Manager) AddMember(groupIDOrName string, mem *SDWANMember) error {
 	}
 	mem.Status = StatusHealthy
 
-	g.Members = append(g.Members, mem)
+	memberCopy := *mem
+	g.Members = append(g.Members, &memberCopy)
 	if err := m.save(); err != nil {
 		return err
 	}
@@ -373,8 +438,12 @@ func (m *Manager) UpdateMember(groupIDOrName string, mem *SDWANMember) error {
 	mem.PacketLoss = g.Members[idx].PacketLoss
 	mem.Jitter = g.Members[idx].Jitter
 	mem.ConsecutiveFails = g.Members[idx].ConsecutiveFails
+	mem.ConsecutiveSuccesses = g.Members[idx].ConsecutiveSuccesses
+	mem.LastChecked = g.Members[idx].LastChecked
+	mem.TableID, mem.MarkHex, mem.MarkInt = g.Members[idx].TableID, g.Members[idx].MarkHex, g.Members[idx].MarkInt
 
-	g.Members[idx] = mem
+	memberCopy := *mem
+	g.Members[idx] = &memberCopy
 	if err := m.save(); err != nil {
 		return err
 	}
@@ -644,8 +713,12 @@ func ResolveGroupForRouting(groupNameOrID string) ([]ResolvedRouteNexthop, bool)
 		return GlobalManager.ResolveGroupForRouting(groupNameOrID)
 	}
 
-	// Fallback to reading file if GlobalManager is not yet initialized
-	data, err := os.ReadFile(DefaultStorePath)
+	// During startup use the same mode and health selection as the live manager.
+	return resolveGroupFromFile(DefaultStorePath, groupNameOrID)
+}
+
+func resolveGroupFromFile(path, groupNameOrID string) ([]ResolvedRouteNexthop, bool) {
+	data, err := os.ReadFile(path)
 	if err != nil {
 		return nil, false
 	}
@@ -653,25 +726,13 @@ func ResolveGroupForRouting(groupNameOrID string) ([]ResolvedRouteNexthop, bool)
 	if err := json.Unmarshal(data, &groups); err != nil {
 		return nil, false
 	}
-	for _, g := range groups {
-		if strings.EqualFold(g.Name, groupNameOrID) || g.ID == groupNameOrID {
-			if g.Disabled || len(g.Members) == 0 {
-				return nil, false
-			}
-			var res []ResolvedRouteNexthop
-			for _, m := range g.Members {
-				if !m.Disabled && m.Interface != "" {
-					res = append(res, ResolvedRouteNexthop{
-						Gateway:   m.Gateway,
-						Interface: m.Interface,
-						Weight:    m.Weight,
-					})
-				}
-			}
-			return res, len(res) > 0
+	mgr := &Manager{groups: make(map[string]*SDWANGroup), engine: NewKernelEngine()}
+	for _, group := range groups {
+		if group != nil {
+			mgr.groups[group.ID] = group
 		}
 	}
-	return nil, false
+	return mgr.ResolveGroupForRouting(groupNameOrID)
 }
 
 // IsSDWANGroup reports whether the given name corresponds to an active or configured SD-WAN group.

+ 13 - 0
internal/sdwan/sdwan_test.go

@@ -2,6 +2,7 @@ package sdwan
 
 import (
 	"net"
+	"os"
 	"path/filepath"
 	"testing"
 	"time"
@@ -234,3 +235,15 @@ func TestSDWANVirtualDeviceLifecycle(t *testing.T) {
 		t.Fatalf("expected sdwantest0 virtual device to be removed from kernel after group delete")
 	}
 }
+
+func TestStartupResolverHonorsFailover(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "groups.json")
+	content := `[{"id":"g1","name":"wan","mode":"failover","members":[{"interface":"primary","gateway":"192.0.2.1","priority":1,"status":"HEALTHY"},{"interface":"backup","gateway":"192.0.2.2","priority":2,"status":"HEALTHY"}]}]`
+	if err := os.WriteFile(path, []byte(content), 0600); err != nil {
+		t.Fatal(err)
+	}
+	hops, ok := resolveGroupFromFile(path, "wan")
+	if !ok || len(hops) != 1 || hops[0].Interface != "primary" || hops[0].Gateway != "192.0.2.1" {
+		t.Fatalf("startup failover resolution: %+v %v", hops, ok)
+	}
+}

+ 22 - 8
internal/vpn/ppp_server.go

@@ -4,6 +4,7 @@ import (
 	"bufio"
 	"encoding/json"
 	"fmt"
+	"log"
 	"net"
 	"os"
 	"os/exec"
@@ -31,10 +32,10 @@ type PPPProfile struct {
 // ======================== PPP Secrets (/ppp secret) ========================
 type PPPSecret struct {
 	ID            string `json:"id"`
-	Name          string `json:"name"`           // Username
-	Password      string `json:"password"`       // Password
-	Service       string `json:"service"`        // "any", "pppoe", "pptp", "l2tp"
-	Profile       string `json:"profile"`        // Profile name
+	Name          string `json:"name"`     // Username
+	Password      string `json:"password"` // Password
+	Service       string `json:"service"`  // "any", "pppoe", "pptp", "l2tp"
+	Profile       string `json:"profile"`  // Profile name
 	LocalAddress  string `json:"local_address,omitempty"`
 	RemoteAddress string `json:"remote_address,omitempty"`
 	CallerID      string `json:"caller_id,omitempty"`
@@ -110,6 +111,7 @@ type ActivePPPSession struct {
 type PPPServerManager struct {
 	mu           sync.RWMutex
 	baseDir      string
+	pppDir       string
 	profiles     map[string]*PPPProfile
 	secrets      map[string]*PPPSecret
 	pppoeServer  PPPoEServerConfig
@@ -122,6 +124,7 @@ type PPPServerManager struct {
 func NewPPPServerManager() *PPPServerManager {
 	mgr := &PPPServerManager{
 		baseDir:      "/etc/debtik/vpn",
+		pppDir:       "/etc/ppp",
 		profiles:     make(map[string]*PPPProfile),
 		secrets:      make(map[string]*PPPSecret),
 		pppoePIDFile: "/var/run/debtik-pppoe-server.pid",
@@ -156,7 +159,9 @@ func NewPPPServerManager() *PPPServerManager {
 
 	mgr.initDefaults()
 	mgr.loadAll()
-	mgr.SyncSecretsToSystem()
+	if err := mgr.SyncSecretsToSystem(); err != nil {
+		log.Printf("[PPP] Failed to synchronize secrets: %v", err)
+	}
 	mgr.applyAll()
 	return mgr
 }
@@ -285,7 +290,9 @@ func (m *PPPServerManager) SaveProfile(p *PPPProfile) error {
 	}
 
 	// Re-synchronize secrets to system since secrets may inherit IP addresses from this profile
-	_ = m.syncSecretsToSystemLocked()
+	if err := m.syncSecretsToSystemLocked(); err != nil {
+		return fmt.Errorf("profile saved, but PPP secrets could not be applied: %w", err)
+	}
 
 	// Re-apply servers if their configured DefaultProfile was updated
 	if m.l2tpServer.Enabled && m.l2tpServer.DefaultProfile == p.Name {
@@ -511,8 +518,15 @@ func (m *PPPServerManager) syncSecretsToSystemLocked() error {
 	}
 
 	content := strings.Join(lines, "\n") + "\n"
-	_ = os.WriteFile("/etc/ppp/chap-secrets", []byte(content), 0600)
-	_ = os.WriteFile("/etc/ppp/pap-secrets", []byte(content), 0600)
+	dir := m.pppDir
+	if dir == "" {
+		dir = "/etc/ppp"
+	}
+	for _, name := range []string{"chap-secrets", "pap-secrets"} {
+		if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0600); err != nil {
+			return fmt.Errorf("writing %s: %w", name, err)
+		}
+	}
 	return nil
 }
 

+ 10 - 1
internal/vpn/ppp_server_test.go

@@ -2,6 +2,7 @@ package vpn
 
 import (
 	"os"
+	"path/filepath"
 	"strings"
 	"testing"
 )
@@ -34,6 +35,7 @@ func TestSyncSecretsInheritanceAndOverrides(t *testing.T) {
 
 	mgr := &PPPServerManager{
 		baseDir:  tmpDir,
+		pppDir:   tmpDir,
 		profiles: make(map[string]*PPPProfile),
 		secrets:  make(map[string]*PPPSecret),
 	}
@@ -92,7 +94,7 @@ func TestSyncSecretsInheritanceAndOverrides(t *testing.T) {
 		t.Fatalf("sync error: %v", err)
 	}
 
-	data, err := os.ReadFile("/etc/ppp/chap-secrets")
+	data, err := os.ReadFile(filepath.Join(tmpDir, "chap-secrets"))
 	if err != nil {
 		t.Skip("skipping chap-secrets read if no root permission")
 	}
@@ -113,3 +115,10 @@ func TestSyncSecretsInheritanceAndOverrides(t *testing.T) {
 		t.Errorf("user_range_inherit expected 192.168.133.2 in chap-secrets, got:\n%s", content)
 	}
 }
+
+func TestSyncSecretsReportsWriteFailure(t *testing.T) {
+	m := &PPPServerManager{pppDir: filepath.Join(t.TempDir(), "missing"), profiles: map[string]*PPPProfile{}, secrets: map[string]*PPPSecret{}}
+	if err := m.syncSecretsToSystemLocked(); err == nil {
+		t.Fatal("expected write error")
+	}
+}

+ 201 - 77
internal/web/static/app.js

@@ -3,10 +3,62 @@
 // ================= Interceptor: Automatic Auth Bearer & 401 Handling =================
 const originalFetch = window.fetch.bind(window);
 const pendingAPIReads = new Map();
-const telemetryHealth = { lastSuccess: 0, failures: new Map() };
+const telemetryHealth = { lastSuccess: 0, failures: new Map(), successes: new Map() };
 let coreRefreshPromise = null;
 let lastSummaryRefresh = 0;
 
+// Map endpoint paths to the views that consume them, so unrelated failures do not
+// hide whether the current view has fresh data.
+function viewUsesEndpoint(tab, path) {
+  const endpoints = {
+    dashboard: ['/system/status', '/interfaces', '/vpn/status', '/firewall/summary'],
+    interfaces: ['/interfaces', '/vlans', '/roles'],
+    firewall: ['/firewall/summary', '/firewall/filter', '/firewall/nat', '/firewall/mangle', '/firewall/address-lists'],
+    routes: ['/routes', '/routing/rules', '/routing/tables'],
+    'routing-filters': ['/routing/filters', '/routing/filters/community-lists', '/routing/filters/as-lists'],
+    bgp: ['/routing/bgp/instance', '/routing/bgp/peers', '/routing/bgp/networks', '/routing/bgp/routes'],
+    ospf: ['/routing/ospf/instances', '/routing/ospf/areas', '/routing/ospf/templates', '/routing/ospf/neighbors', '/routing/ospf/lsa'],
+    rip: ['/routing/rip/instances', '/routing/rip/interfaces', '/routing/rip/neighbors-live', '/routing/rip/routes'],
+    bfd: ['/routing/bfd/instance', '/routing/bfd/interfaces', '/routing/bfd/peers', '/routing/bfd/sessions'],
+    babel: ['/routing/babel/instances', '/routing/babel/interfaces', '/routing/babel/neighbors', '/routing/babel/routes'],
+    radv: ['/routing/radv/instances', '/routing/radv/interfaces'],
+    eigrp: ['/routing/eigrp/instances', '/routing/eigrp/interfaces', '/routing/eigrp/networks', '/routing/eigrp/neighbors', '/routing/eigrp/topology'],
+    'dhcp-client': ['/dhcp/clients'], 'dhcp-server': ['/dhcp-server/servers', '/dhcp-server/networks', '/dhcp-server/leases'],
+    bridge: ['/bridge/summary'], sdwan: ['/sdwan/groups', '/sdwan/telemetry'],
+    ppp: ['/vpn/ppp/secrets', '/vpn/ppp/profiles', '/vpn/ppp/active'],
+    aerotun: ['/vpn/aerotun/tunnels', '/vpn/aerotun/stats'], wireguard: ['/vpn/wireguard/interfaces', '/vpn/wireguard/peers'],
+    eoip: ['/vpn/eoip'], vxlan: ['/vpn/vxlan', '/vpn/vxlan/vteps'], pppoe: ['/vpn/pppoe/clients'],
+    ipsec: ['/vpn/ipsec/tunnels'], l2tp: ['/vpn/l2tp/clients'], pptp: ['/vpn/pptp/clients'],
+    vrrp: ['/vrrp/summary'], logs: ['/system/logs'], users: ['/users'],
+    services: ['/system/services'], certificates: ['/certificates'], settings: ['/dns/settings', '/dns/static', '/dns/status'],
+    reverse_proxy: ['/proxy/config', '/proxy/rules'], updates: ['/update/status']
+  };
+  return (endpoints[tab] || []).some(endpoint => path === '/api' + endpoint);
+}
+
+function renderViewDataHealth() {
+  const el = document.getElementById('view-data-health');
+  if (!el) return;
+  const tab = state.activeTab;
+  el.hidden = !['dashboard','interfaces','firewall','routes','routing-filters','bgp','ospf','rip','bfd','babel','radv','eigrp','dhcp-client','dhcp-server','bridge','sdwan','ppp','aerotun','wireguard','eoip','vxlan','pppoe','ipsec','l2tp','pptp','vrrp','logs','users','services','certificates','settings','reverse_proxy','updates'].includes(tab);
+  if (el.hidden) return;
+  const failures = [...telemetryHealth.failures].filter(([path]) => viewUsesEndpoint(tab, path));
+  const successes = [...telemetryHealth.successes].filter(([path]) => viewUsesEndpoint(tab, path));
+  if (!failures.length && !successes.length) {
+    el.textContent = 'View data: waiting';
+    el.title = 'No successful data request recorded for this view';
+  } else if (failures.length) {
+    el.textContent = 'View data: incomplete or unavailable';
+    el.title = failures.map(([path, error]) => `${path}: ${error}`).join('\n');
+  } else {
+    const oldest = Math.min(...successes.map(([, time]) => time));
+    const seconds = Math.floor((Date.now() - oldest) / 1000);
+    el.textContent = `Tracked data: oldest update ${seconds}s ago`;
+    el.title = 'Oldest successful response among tracked requests for this view';
+  }
+  el.className = `text-[10px] font-mono ${failures.length || !successes.length || Date.now() - Math.min(...successes.map(([, time]) => time)) > 60000 ? 'text-amber-400' : 'text-emerald-400'}`;
+}
+
 function renderTelemetryHealth() {
   const el = document.getElementById('telemetry-health');
   if (!el) return;
@@ -33,6 +85,7 @@ function stopSessionPolling() {
   stopNATStatPolling();
   stopMangleStatPolling();
   telemetryHealth.failures.clear();
+  telemetryHealth.successes.clear();
   telemetryHealth.lastSuccess = 0;
   lastSummaryRefresh = 0;
 }
@@ -60,11 +113,13 @@ window.fetch = async function(resource, options = {}) {
       if (isRead) {
         if (response.ok) {
           telemetryHealth.lastSuccess = Date.now();
+          telemetryHealth.successes.set(url.pathname, telemetryHealth.lastSuccess);
           telemetryHealth.failures.delete(url.pathname);
         } else {
           telemetryHealth.failures.set(url.pathname, `HTTP ${response.status}; displayed values may be old`);
         }
         renderTelemetryHealth();
+        renderViewDataHealth();
       }
       if (response.status === 401 && url.pathname !== '/api/auth/login' && state.currentUser && token === localStorage.getItem('debtik_token')) {
         stopSessionPolling();
@@ -78,6 +133,7 @@ window.fetch = async function(resource, options = {}) {
       if (isRead) {
         telemetryHealth.failures.set(url.pathname, err.name === 'AbortError' ? 'Request timed out' : 'Connection failed; displayed values may be old');
         renderTelemetryHealth();
+        renderViewDataHealth();
       }
       throw err;
     } finally {
@@ -341,6 +397,7 @@ function navigate(tabName) {
   stopNATStatPolling();
   stopMangleStatPolling();
   state.activeTab = tabName;
+  renderViewDataHealth();
   window.location.hash = tabName;
 
   // Update nav links styling
@@ -564,7 +621,7 @@ async function refreshCoreData(isManual = false) {
     if (state.activeTab === 'vlans') renderVlansList();
     if (state.activeTab === 'firewall') await loadFirewall(false);
     if (state.activeTab === 'routes') await loadRoutes(false);
-    if (state.activeTab === 'dynamic-routing' || state.activeTab === 'bgp' || state.activeTab === 'ospf' || state.activeTab === 'rip' || state.activeTab === 'bfd' || state.activeTab === 'babel' || state.activeTab === 'radv') await loadDynamicRouting(false);
+    if (state.activeTab === 'dynamic-routing' || state.activeTab === 'bgp' || state.activeTab === 'ospf' || state.activeTab === 'rip' || state.activeTab === 'bfd' || state.activeTab === 'babel' || state.activeTab === 'radv' || state.activeTab === 'eigrp' || state.activeTab === 'routing-filters') await loadDynamicRouting(false);
     if (state.activeTab === 'ppp') await loadPPP(false);
     if (state.activeTab === 'eoip') await loadEoIP(false);
     if (state.activeTab === 'vxlan') await loadVXLAN(false);
@@ -9852,7 +9909,11 @@ function switchDynSubTab(subTab) {
 
 async function loadDynamicRouting(showToastMsg = false) {
   try {
+    const routingTab = state.activeTab === 'dynamic-routing' ? 'bgp' : state.activeTab;
+    const routingViews = new Set(['bgp', 'ospf', 'rip', 'bfd', 'babel', 'radv', 'eigrp', 'routing-filters']);
     const fetchSafeJson = async (url) => {
+      const match = url.match(/\/routing\/(bgp|ospf|rip|bfd|babel|radv|eigrp)\//);
+      if (routingViews.has(routingTab) && match && match[1] !== routingTab) return null;
       try {
         const r = await fetch(url);
         return r.ok ? await r.json() : null;
@@ -11180,6 +11241,8 @@ async function deleteCurrentOspfInstance() {
       closeOspfInstanceModal();
       showToast('Removed', 'OSPF instance deleted', 'success');
       loadDynamicRouting();
+    } else {
+      throw new Error(await responseError(res));
     }
   } catch (err) {
     showToast('Error', err.message, 'error');
@@ -11189,22 +11252,22 @@ async function deleteCurrentOspfInstance() {
 async function enableSelectedOspfInstance() {
   const id = state.selectedOspfRow?.instances;
   if (!id) return;
-  await fetch(`${API_BASE}/routing/ospf/instances/${encodeURIComponent(id)}/state`, {
+  if (!await routingMutation(`${API_BASE}/routing/ospf/instances/${encodeURIComponent(id)}/state`, {
     method: 'POST',
     headers: { 'Content-Type': 'application/json' },
     body: JSON.stringify({ disabled: false })
-  });
+  })) return;
   loadDynamicRouting();
 }
 
 async function disableSelectedOspfInstance() {
   const id = state.selectedOspfRow?.instances;
   if (!id) return;
-  await fetch(`${API_BASE}/routing/ospf/instances/${encodeURIComponent(id)}/state`, {
+  if (!await routingMutation(`${API_BASE}/routing/ospf/instances/${encodeURIComponent(id)}/state`, {
     method: 'POST',
     headers: { 'Content-Type': 'application/json' },
     body: JSON.stringify({ disabled: true })
-  });
+  })) return;
   loadDynamicRouting();
 }
 
@@ -11212,7 +11275,7 @@ async function removeSelectedOspfInstance() {
   const id = state.selectedOspfRow?.instances;
   if (!id) return;
   if (!confirm('Delete selected OSPF instance?')) return;
-  await fetch(`${API_BASE}/routing/ospf/instances/${encodeURIComponent(id)}`, { method: 'DELETE' });
+  if (!await routingMutation(`${API_BASE}/routing/ospf/instances/${encodeURIComponent(id)}`, { method: 'DELETE' })) return;
   loadDynamicRouting();
 }
 
@@ -11223,12 +11286,12 @@ async function promptCommentOspfInstance() {
   if (!inst) return;
   const comment = prompt('Enter comment for OSPF instance:', inst.comment || '');
   if (comment === null) return;
-  inst.comment = comment;
-  await fetch(`${API_BASE}/routing/ospf/instances`, {
+  const updated = {...inst, comment};
+  if (!await routingMutation(`${API_BASE}/routing/ospf/instances`, {
     method: 'POST',
     headers: { 'Content-Type': 'application/json' },
-    body: JSON.stringify(inst)
-  });
+    body: JSON.stringify(updated)
+  })) return;
   loadDynamicRouting();
 }
 
@@ -11337,6 +11400,8 @@ async function deleteCurrentOspfArea() {
       closeOspfAreaModal();
       showToast('Removed', 'OSPF area deleted', 'success');
       loadDynamicRouting();
+    } else {
+      throw new Error(await responseError(res));
     }
   } catch (err) {
     showToast('Error', err.message, 'error');
@@ -11346,22 +11411,22 @@ async function deleteCurrentOspfArea() {
 async function enableSelectedOspfArea() {
   const id = state.selectedOspfRow?.areas;
   if (!id) return;
-  await fetch(`${API_BASE}/routing/ospf/areas/${encodeURIComponent(id)}/state`, {
+  if (!await routingMutation(`${API_BASE}/routing/ospf/areas/${encodeURIComponent(id)}/state`, {
     method: 'POST',
     headers: { 'Content-Type': 'application/json' },
     body: JSON.stringify({ disabled: false })
-  });
+  })) return;
   loadDynamicRouting();
 }
 
 async function disableSelectedOspfArea() {
   const id = state.selectedOspfRow?.areas;
   if (!id) return;
-  await fetch(`${API_BASE}/routing/ospf/areas/${encodeURIComponent(id)}/state`, {
+  if (!await routingMutation(`${API_BASE}/routing/ospf/areas/${encodeURIComponent(id)}/state`, {
     method: 'POST',
     headers: { 'Content-Type': 'application/json' },
     body: JSON.stringify({ disabled: true })
-  });
+  })) return;
   loadDynamicRouting();
 }
 
@@ -11369,7 +11434,7 @@ async function removeSelectedOspfArea() {
   const id = state.selectedOspfRow?.areas;
   if (!id) return;
   if (!confirm('Delete selected OSPF area?')) return;
-  await fetch(`${API_BASE}/routing/ospf/areas/${encodeURIComponent(id)}`, { method: 'DELETE' });
+  if (!await routingMutation(`${API_BASE}/routing/ospf/areas/${encodeURIComponent(id)}`, { method: 'DELETE' })) return;
   loadDynamicRouting();
 }
 
@@ -11380,12 +11445,12 @@ async function promptCommentOspfArea() {
   if (!area) return;
   const comment = prompt('Enter comment for OSPF area:', area.comment || '');
   if (comment === null) return;
-  area.comment = comment;
-  await fetch(`${API_BASE}/routing/ospf/areas`, {
+  const updated = {...area, comment};
+  if (!await routingMutation(`${API_BASE}/routing/ospf/areas`, {
     method: 'POST',
     headers: { 'Content-Type': 'application/json' },
-    body: JSON.stringify(area)
-  });
+    body: JSON.stringify(updated)
+  })) return;
   loadDynamicRouting();
 }
 
@@ -11476,7 +11541,7 @@ async function deleteCurrentOspfAreaRange() {
   if (!id) return;
   if (!confirm('Delete this area range?')) return;
   try {
-    await fetch(`${API_BASE}/routing/ospf/area-ranges/${encodeURIComponent(id)}`, { method: 'DELETE' });
+    if (!await routingMutation(`${API_BASE}/routing/ospf/area-ranges/${encodeURIComponent(id)}`, { method: 'DELETE' })) return;
     closeOspfAreaRangeModal();
     showToast('Removed', 'Area range removed', 'success');
     loadDynamicRouting();
@@ -11490,8 +11555,8 @@ async function enableSelectedOspfAreaRange() {
   if (!id) return;
   const r = (state.ospfAreaRanges || []).find(it => it.id === id);
   if (!r) return;
-  r.disabled = false;
-  await fetch(`${API_BASE}/routing/ospf/area-ranges`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(r) });
+  const updated = {...r, disabled: false};
+  if (!await routingMutation(`${API_BASE}/routing/ospf/area-ranges`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updated) })) return;
   loadDynamicRouting();
 }
 
@@ -11500,8 +11565,8 @@ async function disableSelectedOspfAreaRange() {
   if (!id) return;
   const r = (state.ospfAreaRanges || []).find(it => it.id === id);
   if (!r) return;
-  r.disabled = true;
-  await fetch(`${API_BASE}/routing/ospf/area-ranges`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(r) });
+  const updated = {...r, disabled: true};
+  if (!await routingMutation(`${API_BASE}/routing/ospf/area-ranges`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updated) })) return;
   loadDynamicRouting();
 }
 
@@ -11509,7 +11574,7 @@ async function removeSelectedOspfAreaRange() {
   const id = state.selectedOspfRow?.['area-ranges'];
   if (!id) return;
   if (!confirm('Delete selected area range?')) return;
-  await fetch(`${API_BASE}/routing/ospf/area-ranges/${encodeURIComponent(id)}`, { method: 'DELETE' });
+  if (!await routingMutation(`${API_BASE}/routing/ospf/area-ranges/${encodeURIComponent(id)}`, { method: 'DELETE' })) return;
   loadDynamicRouting();
 }
 
@@ -11520,8 +11585,8 @@ async function promptCommentOspfAreaRange() {
   if (!r) return;
   const comment = prompt('Enter comment for area range:', r.comment || '');
   if (comment === null) return;
-  r.comment = comment;
-  await fetch(`${API_BASE}/routing/ospf/area-ranges`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(r) });
+  const updated = {...r, comment};
+  if (!await routingMutation(`${API_BASE}/routing/ospf/area-ranges`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updated) })) return;
   loadDynamicRouting();
 }
 
@@ -11670,7 +11735,7 @@ async function deleteCurrentOspfTemplate() {
   if (!id) return;
   if (!confirm('Delete this interface template?')) return;
   try {
-    await fetch(`${API_BASE}/routing/ospf/templates/${encodeURIComponent(id)}`, { method: 'DELETE' });
+    if (!await routingMutation(`${API_BASE}/routing/ospf/templates/${encodeURIComponent(id)}`, { method: 'DELETE' })) return;
     closeOspfTemplateModal();
     showToast('Removed', 'Interface template deleted', 'success');
     loadDynamicRouting();
@@ -11682,22 +11747,22 @@ async function deleteCurrentOspfTemplate() {
 async function enableSelectedOspfTemplate() {
   const id = state.selectedOspfRow?.templates;
   if (!id) return;
-  await fetch(`${API_BASE}/routing/ospf/templates/${encodeURIComponent(id)}/state`, {
+  if (!await routingMutation(`${API_BASE}/routing/ospf/templates/${encodeURIComponent(id)}/state`, {
     method: 'POST',
     headers: { 'Content-Type': 'application/json' },
     body: JSON.stringify({ disabled: false })
-  });
+  })) return;
   loadDynamicRouting();
 }
 
 async function disableSelectedOspfTemplate() {
   const id = state.selectedOspfRow?.templates;
   if (!id) return;
-  await fetch(`${API_BASE}/routing/ospf/templates/${encodeURIComponent(id)}/state`, {
+  if (!await routingMutation(`${API_BASE}/routing/ospf/templates/${encodeURIComponent(id)}/state`, {
     method: 'POST',
     headers: { 'Content-Type': 'application/json' },
     body: JSON.stringify({ disabled: true })
-  });
+  })) return;
   loadDynamicRouting();
 }
 
@@ -11705,7 +11770,7 @@ async function removeSelectedOspfTemplate() {
   const id = state.selectedOspfRow?.templates;
   if (!id) return;
   if (!confirm('Delete selected interface template?')) return;
-  await fetch(`${API_BASE}/routing/ospf/templates/${encodeURIComponent(id)}`, { method: 'DELETE' });
+  if (!await routingMutation(`${API_BASE}/routing/ospf/templates/${encodeURIComponent(id)}`, { method: 'DELETE' })) return;
   loadDynamicRouting();
 }
 
@@ -11716,12 +11781,12 @@ async function promptCommentOspfTemplate() {
   if (!tmpl) return;
   const comment = prompt('Enter comment for template:', tmpl.comment || '');
   if (comment === null) return;
-  tmpl.comment = comment;
-  await fetch(`${API_BASE}/routing/ospf/templates`, {
+  const updated = {...tmpl, comment};
+  if (!await routingMutation(`${API_BASE}/routing/ospf/templates`, {
     method: 'POST',
     headers: { 'Content-Type': 'application/json' },
-    body: JSON.stringify(tmpl)
-  });
+    body: JSON.stringify(updated)
+  })) return;
   loadDynamicRouting();
 }
 
@@ -11812,7 +11877,7 @@ async function deleteCurrentOspfStaticNbr() {
   if (!id) return;
   if (!confirm('Delete this static neighbor?')) return;
   try {
-    await fetch(`${API_BASE}/routing/ospf/static-neighbors/${encodeURIComponent(id)}`, { method: 'DELETE' });
+    if (!await routingMutation(`${API_BASE}/routing/ospf/static-neighbors/${encodeURIComponent(id)}`, { method: 'DELETE' })) return;
     closeOspfStaticNbrModal();
     showToast('Removed', 'Static neighbor removed', 'success');
     loadDynamicRouting();
@@ -11824,14 +11889,14 @@ async function deleteCurrentOspfStaticNbr() {
 async function enableSelectedOspfStaticNbr() {
   const id = state.selectedOspfRow?.['static-nbrs'];
   if (!id) return;
-  await fetch(`${API_BASE}/routing/ospf/static-neighbors/${encodeURIComponent(id)}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: false }) });
+  if (!await routingMutation(`${API_BASE}/routing/ospf/static-neighbors/${encodeURIComponent(id)}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: false }) })) return;
   loadDynamicRouting();
 }
 
 async function disableSelectedOspfStaticNbr() {
   const id = state.selectedOspfRow?.['static-nbrs'];
   if (!id) return;
-  await fetch(`${API_BASE}/routing/ospf/static-neighbors/${encodeURIComponent(id)}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: true }) });
+  if (!await routingMutation(`${API_BASE}/routing/ospf/static-neighbors/${encodeURIComponent(id)}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: true }) })) return;
   loadDynamicRouting();
 }
 
@@ -11839,7 +11904,7 @@ async function removeSelectedOspfStaticNbr() {
   const id = state.selectedOspfRow?.['static-nbrs'];
   if (!id) return;
   if (!confirm('Delete selected static neighbor?')) return;
-  await fetch(`${API_BASE}/routing/ospf/static-neighbors/${encodeURIComponent(id)}`, { method: 'DELETE' });
+  if (!await routingMutation(`${API_BASE}/routing/ospf/static-neighbors/${encodeURIComponent(id)}`, { method: 'DELETE' })) return;
   loadDynamicRouting();
 }
 
@@ -11850,8 +11915,8 @@ async function promptCommentOspfStaticNbr() {
   if (!n) return;
   const comment = prompt('Enter comment for static neighbor:', n.comment || '');
   if (comment === null) return;
-  n.comment = comment;
-  await fetch(`${API_BASE}/routing/ospf/static-neighbors`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(n) });
+  const updated = {...n, comment};
+  if (!await routingMutation(`${API_BASE}/routing/ospf/static-neighbors`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updated) })) return;
   loadDynamicRouting();
 }
 
@@ -11860,8 +11925,8 @@ function openAddOspfInterfaceModal() { openOspfTemplateModal(); }
 function openEditOspfInterfaceModal(id) { openEditOspfTemplateModal(id); }
 function closeOspfInterfaceModal() { closeOspfTemplateModal(); }
 function submitOspfInterface(e) { submitOspfTemplate(e); }
-function deleteOspfInterface(id) {
-  fetch(`${API_BASE}/routing/ospf/templates/${encodeURIComponent(id)}`, { method: 'DELETE' }).then(() => loadDynamicRouting());
+async function deleteOspfInterface(id) {
+  if (await routingMutation(`${API_BASE}/routing/ospf/templates/${encodeURIComponent(id)}`, { method: 'DELETE' })) await loadDynamicRouting();
 }
 function toggleOspfInterfaceState(id, disabled) {
   fetch(`${API_BASE}/routing/ospf/templates/${encodeURIComponent(id)}/state`, {
@@ -13578,7 +13643,55 @@ async function toggleASListState(id, disabled) {
 // TROUBLESHOOT & DIAGNOSTIC TOOLS (/tool)
 // ==========================================
 
+async function loadNetworkOverview() {
+  const container = document.getElementById('tools-overview');
+  const timestamp = document.getElementById('tools-overview-time');
+  if (!container || !timestamp) return;
+  timestamp.textContent = 'Refreshing overview...';
+  const current = {};
+  container._overviewRequest = current;
+  const requests = [
+    ['Interfaces', '/interfaces'], ['System', '/system/status'], ['Routes', '/routes'],
+    ['DNS', '/dns/status'], ['WireGuard peers', '/vpn/wireguard/peers'], ['AeroTUN', '/vpn/aerotun/tunnels']
+  ];
+  const results = await Promise.all(requests.map(async ([label, path]) => {
+    try {
+      const response = await fetch(API_BASE + path);
+      if (!response.ok) throw new Error(await responseError(response));
+      return {label, data: await response.json()};
+    } catch (error) { return {label, error: error.message || 'Request failed'}; }
+  }));
+  // A later refresh supersedes an earlier response.
+  if (container._overviewRequest !== current) return;
+  container.replaceChildren();
+  for (const result of results) {
+    const card = document.createElement('div');
+    card.className = 'rounded border border-slate-700 bg-slate-950/50 p-3';
+    const title = document.createElement('div');
+    title.className = 'text-xs font-bold text-slate-200 mb-1';
+    title.textContent = result.label;
+    const detail = document.createElement('div');
+    detail.className = 'text-xs text-slate-400 whitespace-pre-wrap break-words';
+    detail.textContent = result.error ? `Unavailable: ${result.error}` : formatNetworkOverview(result.label, result.data);
+    card.append(title, detail); container.append(card);
+  }
+  timestamp.textContent = `Updated ${new Date().toLocaleTimeString()} · each section reports its own errors`;
+}
+
+function formatNetworkOverview(label, data) {
+  if (label === 'System') return `Default gateway: ${data.default_gateway || 'not reported'}`;
+  if (label === 'DNS') return `Active resolvers: ${(data.active_servers || []).join(', ') || 'none reported'}\nDNS service: ${data.service_running ? 'running' : 'not running'}`;
+  if (!Array.isArray(data)) return 'Unexpected response';
+  if (label === 'Routes') return `${data.length} routes reported. Open Routing for details.`;
+  if (!data.length) return 'None reported';
+  if (label === 'Interfaces') return data.map(i => `${i.name}: ${i.operstate || i.state || 'unknown'} · ${[...(i.ipv4 || []), ...(i.ipv6 || [])].join(', ') || 'no addresses'}`).join('\n');
+  if (label === 'WireGuard peers') return data.map(p => `${p.interface || '?'} / ${p.name || p.public_key || '?'}: ${p.disabled ? 'disabled' : 'enabled'} · last handshake ${p.latest_handshake || 'not reported'}`).join('\n');
+  if (label === 'AeroTUN') return data.map(t => `${t.name || '?'}: ${t.disabled ? 'disabled' : t.connected ? 'connected' : t.running ? 'running, not connected' : 'stopped'}`).join('\n');
+  return 'Unavailable';
+}
+
 function loadToolsView() {
+  loadNetworkOverview();
   populateToolsInterfaces();
   switchToolsSubTab(state.activeToolsSubTab || 'ping');
 }
@@ -19674,6 +19787,17 @@ async function deleteSDWANMember(groupId, iface) {
 }
 
 // ==============================================================================
+async function routingMutation(url, options) {
+  try {
+    const response = await fetch(url, options);
+    if (!response.ok) throw new Error(await responseError(response));
+    return true;
+  } catch (error) {
+    showToast('Routing change failed', error.message || 'Request failed', 'error');
+    return false;
+  }
+}
+
 // DYNAMIC ROUTING: RIP MODULE
 // ==============================================================================
 state.ripInstances = [];
@@ -19923,21 +20047,21 @@ async function submitRipInstance(e) {
 async function enableSelectedRipInstance() {
   const id = state.selectedRipRow?.instances;
   if (!id) return;
-  await fetch(`${API_BASE}/routing/rip/instances/${id}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: false }) });
+  if (!await routingMutation(`${API_BASE}/routing/rip/instances/${id}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: false }) })) return;
   await loadDynamicRouting(false);
 }
 
 async function disableSelectedRipInstance() {
   const id = state.selectedRipRow?.instances;
   if (!id) return;
-  await fetch(`${API_BASE}/routing/rip/instances/${id}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: true }) });
+  if (!await routingMutation(`${API_BASE}/routing/rip/instances/${id}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: true }) })) return;
   await loadDynamicRouting(false);
 }
 
 async function removeSelectedRipInstance() {
   const id = state.selectedRipRow?.instances;
   if (!id || !confirm('Are you sure you want to delete this RIP instance?')) return;
-  await fetch(`${API_BASE}/routing/rip/instances/${id}`, { method: 'DELETE' });
+  if (!await routingMutation(`${API_BASE}/routing/rip/instances/${id}`, { method: 'DELETE' })) return;
   state.selectedRipRow.instances = null;
   await loadDynamicRouting(false);
 }
@@ -20032,21 +20156,21 @@ async function submitRipInterface(e) {
 async function enableSelectedRipInterface() {
   const id = state.selectedRipRow?.interfaces;
   if (!id) return;
-  await fetch(`${API_BASE}/routing/rip/interfaces/${id}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: false }) });
+  if (!await routingMutation(`${API_BASE}/routing/rip/interfaces/${id}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: false }) })) return;
   await loadDynamicRouting(false);
 }
 
 async function disableSelectedRipInterface() {
   const id = state.selectedRipRow?.interfaces;
   if (!id) return;
-  await fetch(`${API_BASE}/routing/rip/interfaces/${id}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: true }) });
+  if (!await routingMutation(`${API_BASE}/routing/rip/interfaces/${id}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: true }) })) return;
   await loadDynamicRouting(false);
 }
 
 async function removeSelectedRipInterface() {
   const id = state.selectedRipRow?.interfaces;
   if (!id || !confirm('Are you sure you want to delete this RIP interface template?')) return;
-  await fetch(`${API_BASE}/routing/rip/interfaces/${id}`, { method: 'DELETE' });
+  if (!await routingMutation(`${API_BASE}/routing/rip/interfaces/${id}`, { method: 'DELETE' })) return;
   state.selectedRipRow.interfaces = null;
   await loadDynamicRouting(false);
 }
@@ -20319,21 +20443,21 @@ async function submitBfdInterface(e) {
 async function enableSelectedBfdInterface() {
   const id = state.selectedBfdRow?.interfaces;
   if (!id) return;
-  await fetch(`${API_BASE}/routing/bfd/interfaces/${id}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: false }) });
+  if (!await routingMutation(`${API_BASE}/routing/bfd/interfaces/${id}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: false }) })) return;
   await loadDynamicRouting(false);
 }
 
 async function disableSelectedBfdInterface() {
   const id = state.selectedBfdRow?.interfaces;
   if (!id) return;
-  await fetch(`${API_BASE}/routing/bfd/interfaces/${id}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: true }) });
+  if (!await routingMutation(`${API_BASE}/routing/bfd/interfaces/${id}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: true }) })) return;
   await loadDynamicRouting(false);
 }
 
 async function removeSelectedBfdInterface() {
   const id = state.selectedBfdRow?.interfaces;
   if (!id || !confirm('Are you sure you want to remove this BFD interface template?')) return;
-  await fetch(`${API_BASE}/routing/bfd/interfaces/${id}`, { method: 'DELETE' });
+  if (!await routingMutation(`${API_BASE}/routing/bfd/interfaces/${id}`, { method: 'DELETE' })) return;
   state.selectedBfdRow.interfaces = null;
   await loadDynamicRouting(false);
 }
@@ -20411,7 +20535,7 @@ async function enableSelectedBfdPeer() {
   if (!id) return;
   const p = (state.bfdNeighbors || []).find(n => n.id === id);
   if (!p) return;
-  await fetch(`${API_BASE}/routing/bfd/peers`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...p, disabled: false }) });
+  if (!await routingMutation(`${API_BASE}/routing/bfd/peers`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...p, disabled: false }) })) return;
   await loadDynamicRouting(false);
 }
 
@@ -20420,14 +20544,14 @@ async function disableSelectedBfdPeer() {
   if (!id) return;
   const p = (state.bfdNeighbors || []).find(n => n.id === id);
   if (!p) return;
-  await fetch(`${API_BASE}/routing/bfd/peers`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...p, disabled: true }) });
+  if (!await routingMutation(`${API_BASE}/routing/bfd/peers`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...p, disabled: true }) })) return;
   await loadDynamicRouting(false);
 }
 
 async function removeSelectedBfdPeer() {
   const id = state.selectedBfdRow?.peers;
   if (!id || !confirm('Are you sure you want to remove this BFD peer?')) return;
-  await fetch(`${API_BASE}/routing/bfd/peers/${id}`, { method: 'DELETE' });
+  if (!await routingMutation(`${API_BASE}/routing/bfd/peers/${id}`, { method: 'DELETE' })) return;
   state.selectedBfdRow.peers = null;
   await loadDynamicRouting(false);
 }
@@ -20709,21 +20833,21 @@ async function submitBabelInstance(e) {
 async function enableSelectedBabelInstance() {
   const id = state.selectedBabelRow?.instances;
   if (!id) return;
-  await fetch(`${API_BASE}/routing/babel/instances/${id}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: false }) });
+  if (!await routingMutation(`${API_BASE}/routing/babel/instances/${id}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: false }) })) return;
   await loadDynamicRouting(false);
 }
 
 async function disableSelectedBabelInstance() {
   const id = state.selectedBabelRow?.instances;
   if (!id) return;
-  await fetch(`${API_BASE}/routing/babel/instances/${id}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: true }) });
+  if (!await routingMutation(`${API_BASE}/routing/babel/instances/${id}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: true }) })) return;
   await loadDynamicRouting(false);
 }
 
 async function removeSelectedBabelInstance() {
   const id = state.selectedBabelRow?.instances;
   if (!id || !confirm('Are you sure you want to remove this Babel instance?')) return;
-  await fetch(`${API_BASE}/routing/babel/instances/${id}`, { method: 'DELETE' });
+  if (!await routingMutation(`${API_BASE}/routing/babel/instances/${id}`, { method: 'DELETE' })) return;
   state.selectedBabelRow.instances = null;
   await loadDynamicRouting(false);
 }
@@ -20838,21 +20962,21 @@ async function submitBabelInterface(e) {
 async function enableSelectedBabelInterface() {
   const id = state.selectedBabelRow?.interfaces;
   if (!id) return;
-  await fetch(`${API_BASE}/routing/babel/interfaces/${id}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: false }) });
+  if (!await routingMutation(`${API_BASE}/routing/babel/interfaces/${id}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: false }) })) return;
   await loadDynamicRouting(false);
 }
 
 async function disableSelectedBabelInterface() {
   const id = state.selectedBabelRow?.interfaces;
   if (!id) return;
-  await fetch(`${API_BASE}/routing/babel/interfaces/${id}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: true }) });
+  if (!await routingMutation(`${API_BASE}/routing/babel/interfaces/${id}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: true }) })) return;
   await loadDynamicRouting(false);
 }
 
 async function removeSelectedBabelInterface() {
   const id = state.selectedBabelRow?.interfaces;
   if (!id || !confirm('Are you sure you want to remove this Babel interface template?')) return;
-  await fetch(`${API_BASE}/routing/babel/interfaces/${id}`, { method: 'DELETE' });
+  if (!await routingMutation(`${API_BASE}/routing/babel/interfaces/${id}`, { method: 'DELETE' })) return;
   state.selectedBabelRow.interfaces = null;
   await loadDynamicRouting(false);
 }
@@ -21022,21 +21146,21 @@ async function submitRAdvInstance(e) {
 async function enableSelectedRadvInstance() {
   const id = state.selectedRadvRow?.instances;
   if (!id) return;
-  await fetch(`${API_BASE}/routing/radv/instances/${id}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: false }) });
+  if (!await routingMutation(`${API_BASE}/routing/radv/instances/${id}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: false }) })) return;
   await loadDynamicRouting(false);
 }
 
 async function disableSelectedRadvInstance() {
   const id = state.selectedRadvRow?.instances;
   if (!id) return;
-  await fetch(`${API_BASE}/routing/radv/instances/${id}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: true }) });
+  if (!await routingMutation(`${API_BASE}/routing/radv/instances/${id}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: true }) })) return;
   await loadDynamicRouting(false);
 }
 
 async function removeSelectedRadvInstance() {
   const id = state.selectedRadvRow?.instances;
   if (!id || !confirm('Are you sure you want to remove this RAdv instance?')) return;
-  await fetch(`${API_BASE}/routing/radv/instances/${id}`, { method: 'DELETE' });
+  if (!await routingMutation(`${API_BASE}/routing/radv/instances/${id}`, { method: 'DELETE' })) return;
   state.selectedRadvRow.instances = null;
   await loadDynamicRouting(false);
 }
@@ -21143,21 +21267,21 @@ async function submitRAdvInterface(e) {
 async function enableSelectedRadvInterface() {
   const id = state.selectedRadvRow?.interfaces;
   if (!id) return;
-  await fetch(`${API_BASE}/routing/radv/interfaces/${id}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: false }) });
+  if (!await routingMutation(`${API_BASE}/routing/radv/interfaces/${id}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: false }) })) return;
   await loadDynamicRouting(false);
 }
 
 async function disableSelectedRadvInterface() {
   const id = state.selectedRadvRow?.interfaces;
   if (!id) return;
-  await fetch(`${API_BASE}/routing/radv/interfaces/${id}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: true }) });
+  if (!await routingMutation(`${API_BASE}/routing/radv/interfaces/${id}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: true }) })) return;
   await loadDynamicRouting(false);
 }
 
 async function removeSelectedRadvInterface() {
   const id = state.selectedRadvRow?.interfaces;
   if (!id || !confirm('Are you sure you want to remove this RAdv interface?')) return;
-  await fetch(`${API_BASE}/routing/radv/interfaces/${id}`, { method: 'DELETE' });
+  if (!await routingMutation(`${API_BASE}/routing/radv/interfaces/${id}`, { method: 'DELETE' })) return;
   state.selectedRadvRow.interfaces = null;
   await loadDynamicRouting(false);
 }
@@ -21365,21 +21489,21 @@ async function submitEigrpInstance(e) {
 async function enableSelectedEigrpInstance() {
   const id = state.selectedEigrpRow?.instances;
   if (!id) return;
-  await fetch(`${API_BASE}/routing/eigrp/instances/${id}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: false }) });
+  if (!await routingMutation(`${API_BASE}/routing/eigrp/instances/${id}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: false }) })) return;
   await loadDynamicRouting(false);
 }
 
 async function disableSelectedEigrpInstance() {
   const id = state.selectedEigrpRow?.instances;
   if (!id) return;
-  await fetch(`${API_BASE}/routing/eigrp/instances/${id}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: true }) });
+  if (!await routingMutation(`${API_BASE}/routing/eigrp/instances/${id}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: true }) })) return;
   await loadDynamicRouting(false);
 }
 
 async function removeSelectedEigrpInstance() {
   const id = state.selectedEigrpRow?.instances;
   if (!id || !confirm('Are you sure you want to remove this EIGRP instance?')) return;
-  await fetch(`${API_BASE}/routing/eigrp/instances/${id}`, { method: 'DELETE' });
+  if (!await routingMutation(`${API_BASE}/routing/eigrp/instances/${id}`, { method: 'DELETE' })) return;
   state.selectedEigrpRow.instances = null;
   await loadDynamicRouting(false);
 }
@@ -21453,7 +21577,7 @@ async function submitEigrpNetwork(e) {
 async function removeSelectedEigrpNetwork() {
   const id = state.selectedEigrpRow?.networks;
   if (!id || !confirm('Are you sure you want to remove this EIGRP network?')) return;
-  await fetch(`${API_BASE}/routing/eigrp/networks/${id}`, { method: 'DELETE' });
+  if (!await routingMutation(`${API_BASE}/routing/eigrp/networks/${id}`, { method: 'DELETE' })) return;
   state.selectedEigrpRow.networks = null;
   await loadDynamicRouting(false);
 }
@@ -21590,21 +21714,21 @@ async function submitEigrpInterface(e) {
 async function enableSelectedEigrpInterface() {
   const id = state.selectedEigrpRow?.interfaces;
   if (!id) return;
-  await fetch(`${API_BASE}/routing/eigrp/interfaces/${id}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: false }) });
+  if (!await routingMutation(`${API_BASE}/routing/eigrp/interfaces/${id}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: false }) })) return;
   await loadDynamicRouting(false);
 }
 
 async function disableSelectedEigrpInterface() {
   const id = state.selectedEigrpRow?.interfaces;
   if (!id) return;
-  await fetch(`${API_BASE}/routing/eigrp/interfaces/${id}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: true }) });
+  if (!await routingMutation(`${API_BASE}/routing/eigrp/interfaces/${id}/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled: true }) })) return;
   await loadDynamicRouting(false);
 }
 
 async function removeSelectedEigrpInterface() {
   const id = state.selectedEigrpRow?.interfaces;
   if (!id || !confirm('Are you sure you want to remove this EIGRP interface?')) return;
-  await fetch(`${API_BASE}/routing/eigrp/interfaces/${id}`, { method: 'DELETE' });
+  if (!await routingMutation(`${API_BASE}/routing/eigrp/interfaces/${id}`, { method: 'DELETE' })) return;
   state.selectedEigrpRow.interfaces = null;
   await loadDynamicRouting(false);
 }

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

@@ -620,6 +620,7 @@
             <div>
               <h1 id="page-title" class="text-lg font-bold text-white tracking-tight">Dashboard Overview</h1>
               <div id="telemetry-health" role="status" aria-live="polite" class="text-[10px] text-amber-400">Waiting for fresh data</div>
+              <div id="view-data-health" role="status" class="text-[10px] text-amber-400">View data: waiting</div>
               <p id="page-subtitle" class="text-xs text-slate-400">DebTik - Debian Linux Router appliance management</p>
             </div>
           </div>
@@ -5032,6 +5033,15 @@
             </div>
           </div>
 
+          <div class="bg-slate-900/90 border border-slate-800 rounded-xl p-5 space-y-3">
+            <div class="flex items-center justify-between gap-3 flex-wrap">
+              <div><h3 class="text-sm font-bold text-white">Network overview</h3><p class="text-xs text-slate-400">Current configuration and reported state; this does not test reachability.</p></div>
+              <button type="button" onclick="loadNetworkOverview()" class="px-3 py-2 text-xs rounded bg-slate-800 text-slate-200 hover:bg-slate-700">Refresh overview</button>
+            </div>
+            <div id="tools-overview-time" role="status" class="text-xs text-slate-400">Loading...</div>
+            <div id="tools-overview" class="grid grid-cols-1 md:grid-cols-2 gap-3"></div>
+          </div>
+
           <!-- Tools Subtabs Navigation -->
           <div class="flex items-center gap-1 border-b border-slate-800 pb-0 overflow-x-auto">
             <button id="tools-subtab-btn-ping" onclick="switchToolsSubTab('ping')" class="px-4 py-2.5 text-xs font-bold border-b-2 border-amber-400 text-amber-300 transition flex items-center gap-2 whitespace-nowrap">

+ 6 - 0
scripts/debtik-update

@@ -48,6 +48,10 @@ restore() {
   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/go.mod" ]]; then cp -p "$base/previous/go.mod" "$base/go.mod"; fi
+  if [[ -f "$base/previous/debtik.service" ]]; then
+    install -m 0644 "$base/previous/debtik.service" /etc/systemd/system/debtik.service
+    systemctl daemon-reload
+  fi
   systemctl start debtik.service
   systemctl is-active --quiet debtik.service
   if [[ -f "$base/previous/iptables.save" ]]; then iptables-restore -c < "$base/previous/iptables.save"; fi
@@ -93,6 +97,8 @@ if [[ -x /opt/debtik/go/bin/go ]]; then go_cmd=/opt/debtik/go/bin/go; fi
 CGO_ENABLED=0 "$go_cmd" build -trimpath -ldflags "-X debtik/internal/api.BuildCommit=$commit" -o "$work/debtik" ./cmd/server
 CGO_ENABLED=0 "$go_cmd" build -trimpath -o "$work/debtik-cli" ./cmd/debtik-cli
 install -d -m 0700 "$base/previous"
+rm -f "$base/previous/debtik.service"
+if [[ -f /etc/systemd/system/debtik.service ]]; then cp -p /etc/systemd/system/debtik.service "$base/previous/debtik.service"; fi
 for name in debtik debtik-cli; do cp -p "/usr/local/bin/$name" "$base/previous/$name"; done
 for name in debtik-update debtik-release-version debtik-install-dependencies debtik-ifnames; do
   if [[ -f "/usr/local/libexec/$name" ]]; then cp -p "/usr/local/libexec/$name" "$base/previous/$name"; fi

+ 68 - 0
scripts/ui-regression.test.cjs

@@ -47,3 +47,71 @@ test('HTML contains no duplicate IDs',()=>{
   const ids=[...html.matchAll(/\bid="([^"]+)"/g)].map(match=>match[1]);
   assert.equal(new Set(ids).size,ids.length);
 });
+
+test('routing delete keeps selection and reports a rejected change',async()=>{
+  const messages=[];
+  let refreshes=0;
+  const context={API_BASE:'/api',state:{selectedRipRow:{instances:'r1'}},confirm:()=>true,
+    fetch:async()=>new Response('{"message":"Cannot delete active instance"}',{status:409}),
+    showToast:(...args)=>messages.push(args),loadDynamicRouting:async()=>refreshes++,Response};
+  vm.createContext(context);
+  vm.runInContext(source.slice(source.indexOf('async function responseError('),source.indexOf('\n}',source.indexOf('async function responseError('))+2),context);
+  vm.runInContext(source.slice(source.indexOf('async function routingMutation('),source.indexOf('\n}',source.indexOf('async function routingMutation('))+2),context);
+  vm.runInContext(source.slice(source.indexOf('async function removeSelectedRipInstance('),source.indexOf('\n}',source.indexOf('async function removeSelectedRipInstance('))+2),context);
+  await context.removeSelectedRipInstance();
+  assert.equal(context.state.selectedRipRow.instances,'r1');
+  assert.equal(refreshes,0);
+  assert.match(messages[0][1],/Cannot delete active instance/);
+});
+
+test('view health filters unrelated requests and reports failures',()=>{
+  const element={};
+  const context={document:{getElementById:()=>element},state:{activeTab:'eigrp'},Date,
+    telemetryHealth:{failures:new Map([['/api/routing/eigrp/neighbors','HTTP 500'],['/api/firewall/filter','HTTP 500']]),successes:new Map([['/api/routing/eigrp/instances',Date.now()]])}};
+  vm.createContext(context);
+  vm.runInContext(source.slice(source.indexOf('function viewUsesEndpoint('),source.indexOf('function renderTelemetryHealth(')),context);
+  context.renderViewDataHealth();
+  assert.match(element.textContent,/incomplete/);
+  assert.match(element.title,/eigrp/);
+  assert.doesNotMatch(element.title,/firewall/);
+  context.telemetryHealth.failures.delete('/api/routing/eigrp/neighbors');
+  context.renderViewDataHealth();
+  assert.match(element.textContent,/oldest update/);
+});
+
+test('network overview displays reported state without treating it as reachability',()=>{
+ const context={};vm.createContext(context);
+ vm.runInContext(source.slice(source.indexOf('function formatNetworkOverview('),source.indexOf('\nfunction loadToolsView(')),context);
+ assert.match(context.formatNetworkOverview('Interfaces',[{name:'eth0',operstate:'up',ipv4:['192.0.2.1/24'],ipv6:[]}]),/eth0: up.*192\.0\.2\.1/);
+ assert.match(context.formatNetworkOverview('WireGuard peers',[{interface:'wg0',name:'site',disabled:false}]),/last handshake not reported/);
+ assert.match(context.formatNetworkOverview('AeroTUN',[{name:'a0',running:true,connected:false}]),/not connected/);
+ assert.equal(context.formatNetworkOverview('Routes',{}),'Unexpected response');
+});
+
+test('OSPF rejected delete keeps modal open and reports API error',async()=>{
+  const events=[];
+  const context={API_BASE:'/api',document:{getElementById:()=>({value:'r1'})},confirm:()=>true,
+    fetch:async()=>new Response('{"message":"In use"}',{status:409}),
+    closeOspfAreaRangeModal:()=>events.push('closed'),showToast:(...x)=>events.push(x),loadDynamicRouting:()=>events.push('refreshed'),Response};
+  vm.createContext(context);
+  for(const name of ['responseError','routingMutation','deleteCurrentOspfAreaRange']) {
+    const start=source.indexOf('async function '+name+'('); const end=source.indexOf('\n}',start)+2;
+    vm.runInContext(source.slice(start,end),context);
+  }
+  await context.deleteCurrentOspfAreaRange();
+  assert.equal(events.some(e=>e==='closed' || e==='refreshed'),false);
+  assert.ok(events.some(e=>Array.isArray(e) && e[1]==='In use' && e[2]==='error'));
+});
+
+test('OSPF rejected toggle does not alter cached range',async()=>{
+  const range={id:'r1',disabled:true};const messages=[];
+  const context={API_BASE:'/api',state:{selectedOspfRow:{'area-ranges':'r1'},ospfAreaRanges:[range]},
+    fetch:async()=>new Response('Rejected',{status:400}),showToast:(...x)=>messages.push(x),loadDynamicRouting:()=>{throw Error('unexpected refresh')},Response};
+  vm.createContext(context);
+  for(const name of ['responseError','routingMutation','enableSelectedOspfAreaRange']) {
+    const start=source.indexOf('async function '+name+'('); const end=source.indexOf('\n}',start)+2;
+    vm.runInContext(source.slice(start,end),context);
+  }
+  await context.enableSelectedOspfAreaRange();
+  assert.equal(range.disabled,true);assert.equal(messages[0][1],'Rejected');
+});

+ 1 - 1
systemd/debtik.service

@@ -6,7 +6,7 @@ Wants=network-online.target
 [Service]
 Type=simple
 User=root
-ExecStart=/usr/local/bin/debtik -addr :80
+ExecStart=/usr/local/bin/debtik
 Restart=always
 RestartSec=3s
 LimitNOFILE=65535