Browse Source

Fix routing, CLI, and management reliability

Proxmox Router 2 hours ago
parent
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 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.
 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
 ```sh
 debtik-cli -addr 127.0.0.1:8282
 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.
 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
 ## Git updates

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

@@ -2,6 +2,7 @@ package main
 
 
 import (
 import (
 	"bufio"
 	"bufio"
+	"encoding/json"
 	"errors"
 	"errors"
 	"flag"
 	"flag"
 	"fmt"
 	"fmt"
@@ -20,9 +21,32 @@ func main() {
 	addr := flag.String("addr", "127.0.0.1:8282", "DebTik CLI server address")
 	addr := flag.String("addr", "127.0.0.1:8282", "DebTik CLI server address")
 	username := flag.String("u", "", "Username for automatic authentication")
 	username := flag.String("u", "", "Username for automatic authentication")
 	password := flag.String("p", "", "Password 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")
 	cmdFlag := flag.String("c", "", "Execute a single command non-interactively and exit")
 	flag.Parse()
 	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)
 	conn, err := net.DialTimeout("tcp", *addr, 3*time.Second)
 	if err != nil {
 	if err != nil {
 		fmt.Fprintf(os.Stderr, "\033[31mError: Could not connect to DebTik CLI service on %s\033[0m\n", *addr)
 		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 one-shot command execution is requested:
 	if *cmdFlag != "" {
 	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)
 			fmt.Fprintln(os.Stderr, err)
 			os.Exit(1)
 			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)
 	reader := bufio.NewReader(conn)
 	if username == "" || password == "" {
 	if username == "" || password == "" {
 		return fmt.Errorf("single-command mode requires -u and -p")
 		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
 		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
 		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 {
 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 == "" {
 		if username == "" || password == "" {
 			return fmt.Errorf("provide both -u and -p")
 			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
 			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 {
 	if err := conn.SetDeadline(time.Now().Add(10 * time.Second)); err != nil {
 		return err
 		return err
 	}
 	}
@@ -133,7 +194,7 @@ func autoLogin(conn net.Conn, reader *bufio.Reader, username, password string) e
 			return fmt.Errorf("authentication rejected")
 			return fmt.Errorf("authentication rejected")
 		}
 		}
 		if strings.HasSuffix(text, "] > ") {
 		if strings.HasSuffix(text, "] > ") {
-			_, err := fmt.Fprint(os.Stdout, text)
+			_, err := fmt.Fprint(welcome, text)
 			return err
 			return err
 		}
 		}
 	}
 	}

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

@@ -2,9 +2,13 @@ package main
 
 
 import (
 import (
 	"bufio"
 	"bufio"
+	"encoding/json"
 	"fmt"
 	"fmt"
+	"io"
 	"net"
 	"net"
+	"os"
 	"testing"
 	"testing"
+	"time"
 )
 )
 
 
 func TestAutomaticLoginWaitsForSuccessAndPreservesBufferedData(t *testing.T) {
 func TestAutomaticLoginWaitsForSuccessAndPreservesBufferedData(t *testing.T) {
@@ -40,7 +44,7 @@ func TestAutomaticLoginWaitsForSuccessAndPreservesBufferedData(t *testing.T) {
 				done <- err
 				done <- err
 			}()
 			}()
 			r := bufio.NewReader(client)
 			r := bufio.NewReader(client)
-			err := autoLogin(client, r, "admin", "test-password")
+			err := autoLogin(client, r, "admin", "test-password", io.Discard)
 			if (err == nil) != accepted {
 			if (err == nil) != accepted {
 				t.Fatalf("accepted=%v err=%v", accepted, err)
 				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 (
 import (
 	"context"
 	"context"
 	"crypto/tls"
 	"crypto/tls"
+	release "debtik"
 	"flag"
 	"flag"
 	"fmt"
 	"fmt"
 	"log"
 	"log"
@@ -10,7 +11,6 @@ import (
 	"net/http"
 	"net/http"
 	"os"
 	"os"
 	"os/signal"
 	"os/signal"
-	release "debtik"
 	"sync"
 	"sync"
 	"syscall"
 	"syscall"
 	"time"
 	"time"
@@ -39,7 +39,7 @@ import (
 )
 )
 
 
 func main() {
 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)")
 	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")
 	metaPath := flag.String("meta", "/etc/debtik/metadata.json", "Path to interface metadata storage file")
 	flag.Parse()
 	flag.Parse()
@@ -52,6 +52,14 @@ func main() {
 	// Initialize Logging Ring Buffer
 	// Initialize Logging Ring Buffer
 	_ = logging.GetLogger()
 	_ = 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
 	// Initialize components
 	metaMgr := network.NewMetadataManager(*metaPath)
 	metaMgr := network.NewMetadataManager(*metaPath)
 	netMgr := network.NewManager(metaMgr)
 	netMgr := network.NewManager(metaMgr)
@@ -83,7 +91,6 @@ func main() {
 	proxyMgr := reverseproxy.NewManager("", certMgr)
 	proxyMgr := reverseproxy.NewManager("", certMgr)
 	defer proxyMgr.Stop()
 	defer proxyMgr.Stop()
 
 
-	backupMgr := backup.NewManager("", "", "", nil)
 	sdwanMgr := sdwan.NewManager()
 	sdwanMgr := sdwan.NewManager()
 	defer sdwanMgr.Stop()
 	defer sdwanMgr.Stop()
 
 
@@ -411,9 +418,7 @@ func main() {
 	})
 	})
 	if svc, ok := servicesMgr.GetService("www"); ok {
 	if svc, ok := servicesMgr.GetService("www"); ok {
 		httpDisabled = svc.Disabled
 		httpDisabled = svc.Disabled
-		if !explicitAddr {
-			httpPort = svc.Port
-		}
+		httpPort = effectiveHTTPPort(httpPort, svc.Port, explicitAddr)
 	}
 	}
 	if err := startHTTP(httpPort, httpDisabled); err != nil {
 	if err := startHTTP(httpPort, httpDisabled); err != nil {
 		log.Fatalf("HTTP startup failed: %v", err)
 		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")
 		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)
 	entries, err := os.ReadDir(m.networkDir)
 	if err != nil {
 	if err != nil {
-		return nil
+		return err
 	}
 	}
 
 
 	for _, entry := range entries {
 	for _, entry := range entries {
@@ -205,13 +205,16 @@ func (m *Manager) archiveNetworkFiles(zw *zip.Writer, manifest *Manifest) error
 
 
 		filePath := filepath.Join(m.networkDir, name)
 		filePath := filepath.Join(m.networkDir, name)
 		info, err := entry.Info()
 		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)
 		data, err := os.ReadFile(filePath)
 		if err != nil {
 		if err != nil {
-			continue
+			return fmt.Errorf("cannot read network config %s: %w", name, err)
 		}
 		}
 
 
 		hash := sha256.Sum256(data)
 		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
 	// 2. Create pre-restore safety backup of active config
 	safetyBackupPath, err := m.createPreRestoreBackupLocked()
 	safetyBackupPath, err := m.createPreRestoreBackupLocked()
 	if err != nil {
 	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
 	// 3. Extract to staging directory
 	stagingDir, err := os.MkdirTemp("", "debtik-restore-stage-*")
 	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{
 	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
 	// Wipe existing files in dstDir to avoid stale or orphaned configs
 	existing, err := os.ReadDir(dstDir)
 	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
 package cli
 
 
 import (
 import (
+	"os"
 	"path/filepath"
 	"path/filepath"
 	"reflect"
 	"reflect"
 	"strings"
 	"strings"
@@ -10,6 +11,7 @@ import (
 	"debtik/internal/auth"
 	"debtik/internal/auth"
 	"debtik/internal/dynamicrouting"
 	"debtik/internal/dynamicrouting"
 	"debtik/internal/sdwan"
 	"debtik/internal/sdwan"
+	"debtik/internal/services"
 	"debtik/internal/vpn"
 	"debtik/internal/vpn"
 )
 )
 
 
@@ -374,3 +376,55 @@ func TestRoutingOSPFCLI(t *testing.T) {
 		t.Fatalf("failed to remove ospf-test: %s", out)
 		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"]
 		privKey := params["private-key"]
 		port := 51820
 		port := 51820
 		if portStr != "" {
 		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 == "" {
 		if name == "" {
 			return ColorRed + "Usage: /interface wireguard add name=<name> [listen-port=<port>]\n" + ColorReset
 			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, ",")
 			allowedList = strings.Split(allowed, ",")
 		}
 		}
 
 
-		epHost := endpoint
+		epHost := ""
 		epPort := 0
 		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
 		port := 4789
 		if pStr := params["port"]; pStr != "" {
 		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
 		mtu := 1500
 		if mStr := params["mtu"]; mStr != "" {
 		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
 		learning := true
 		if lStr := params["learning"]; lStr != "" {
 		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
 				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
 			return ColorRed + fmt.Sprintf("VXLAN interface %q not found\n", pos[0]) + ColorReset
 		}
 		}
 
 
+		updated := *inst
+		inst = &updated
 		if newName := params["name"]; newName != "" {
 		if newName := params["name"]; newName != "" {
 			inst.Name = newName
 			inst.Name = newName
 		}
 		}
 		if vniStr := params["vni"]; vniStr != "" {
 		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 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 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 != "" {
 		if mac := params["mac-address"]; mac != "" {
 			inst.MACAddress = mac
 			inst.MACAddress = mac
@@ -988,6 +1011,16 @@ func (e *Executor) handleInterfaceVXLAN(parts []string, user *auth.PublicUser) s
 		if iface := params["interface"]; iface != "" {
 		if iface := params["interface"]; iface != "" {
 			inst.ParentInterface = 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 {
 		if cmt, ok := params["comment"]; ok {
 			inst.Comment = cmt
 			inst.Comment = cmt
 		}
 		}
@@ -2374,12 +2407,21 @@ func (e *Executor) handleIPService(parts []string, user *auth.PublicUser) string
 		disabled := svc.Disabled
 		disabled := svc.Disabled
 		certName := svc.Certificate
 		certName := svc.Certificate
 		if pStr, ok := params["port"]; ok {
 		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 {
 		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 {
 		if cStr, ok := params["certificate"]; ok {
 			certName = cStr
 			certName = cStr
@@ -3476,14 +3518,18 @@ func (e *Executor) handleBFDNeighbor(parts []string, user *auth.PublicUser) stri
 		for _, n := range nbrs {
 		for _, n := range nbrs {
 			if n.Address == target || n.ID == target {
 			if n.Address == target || n.ID == target {
 				n.Disabled = false
 				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
 				return ColorGreen + fmt.Sprintf("BFD peer %s enabled.\n", target) + ColorReset
 			}
 			}
 		}
 		}
 		if idx, err := strconv.Atoi(target); err == nil && idx >= 0 && idx < len(nbrs) {
 		if idx, err := strconv.Atoi(target); err == nil && idx >= 0 && idx < len(nbrs) {
 			nbr := nbrs[idx]
 			nbr := nbrs[idx]
 			nbr.Disabled = false
 			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 ColorGreen + fmt.Sprintf("BFD peer %s enabled.\n", nbr.Address) + ColorReset
 		}
 		}
 		return ColorRed + "BFD peer not found: " + target + "\n" + 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 {
 		for _, n := range nbrs {
 			if n.Address == target || n.ID == target {
 			if n.Address == target || n.ID == target {
 				n.Disabled = true
 				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
 				return ColorGreen + fmt.Sprintf("BFD peer %s disabled.\n", target) + ColorReset
 			}
 			}
 		}
 		}
 		if idx, err := strconv.Atoi(target); err == nil && idx >= 0 && idx < len(nbrs) {
 		if idx, err := strconv.Atoi(target); err == nil && idx >= 0 && idx < len(nbrs) {
 			nbr := nbrs[idx]
 			nbr := nbrs[idx]
 			nbr.Disabled = true
 			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 ColorGreen + fmt.Sprintf("BFD peer %s disabled.\n", nbr.Address) + ColorReset
 		}
 		}
 		return ColorRed + "BFD peer not found: " + target + "\n" + 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)
 	cmd := exec.Command("ping", "-c", count, target)
 	out, err := cmd.CombinedOutput()
 	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)
 	return string(out)
 }
 }

+ 12 - 0
internal/cli/server.go

@@ -183,6 +183,18 @@ func (s *Server) handleConn(rawConn net.Conn) {
 			continue
 			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
 		// Special self password change command
 		if trimmed == "password" {
 		if trimmed == "password" {
 			s.handleInteractivePasswordChange(term, authenticatedUser)
 			s.handleInteractivePasswordChange(term, authenticatedUser)

+ 66 - 61
internal/cli/telnet.go

@@ -2,6 +2,7 @@ package cli
 
 
 import (
 import (
 	"bytes"
 	"bytes"
+	"io"
 	"net"
 	"net"
 )
 )
 
 
@@ -14,11 +15,11 @@ const (
 	sb   = 250
 	sb   = 250
 	se   = 240
 	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
 // TelnetConn wraps a net.Conn and handles RFC 854 Telnet negotiations
@@ -26,6 +27,9 @@ const (
 type TelnetConn struct {
 type TelnetConn struct {
 	net.Conn
 	net.Conn
 	subnegotiation bool
 	subnegotiation bool
+	state          byte
+	optionCommand  byte
+	lastCR         bool
 }
 }
 
 
 func NewTelnetConn(conn net.Conn) *TelnetConn {
 func NewTelnetConn(conn net.Conn) *TelnetConn {
@@ -46,69 +50,63 @@ func (tc *TelnetConn) initNegotiation() {
 }
 }
 
 
 func (tc *TelnetConn) Read(p []byte) (n int, err error) {
 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')
 				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) {
 func (tc *TelnetConn) handleOption(cmd, opt byte) {
@@ -134,5 +132,12 @@ func (tc *TelnetConn) Write(p []byte) (n int, err error) {
 		}
 		}
 		out.WriteByte(b)
 		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 (
 import (
 	"encoding/json"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"fmt"
 	"log"
 	"log"
 	"net"
 	"net"
@@ -226,7 +227,25 @@ func (m *Manager) save() error {
 	if err != nil {
 	if err != nil {
 		return err
 		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_]*
 // 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()
 	m.syncKernelBlackholeRoutes()
 
 
 	// Synchronize FRR EIGRP configuration
 	// 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
 	return nil
 }
 }
@@ -3495,6 +3516,8 @@ func (m *Manager) SaveBFDNeighbor(nbr BFDNeighbor) (*BFDNeighbor, error) {
 	m.mu.Lock()
 	m.mu.Lock()
 	defer m.mu.Unlock()
 	defer m.mu.Unlock()
 
 
+	previous := append([]BFDNeighbor(nil), m.config.BFDNeighbors...)
+	previousInstance := m.config.BFDInstance
 	if nbr.ID == "" {
 	if nbr.ID == "" {
 		nbr.ID = fmt.Sprintf("bfd-nbr-%d", time.Now().UnixNano())
 		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
 		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 nil, err
 	}
 	}
 	return &nbr, nil
 	return &nbr, nil
@@ -3528,6 +3548,8 @@ func (m *Manager) DeleteBFDNeighbor(id string) error {
 	m.mu.Lock()
 	m.mu.Lock()
 	defer m.mu.Unlock()
 	defer m.mu.Unlock()
 
 
+	previous := append([]BFDNeighbor(nil), m.config.BFDNeighbors...)
+	previousInstance := m.config.BFDInstance
 	newNbrs := make([]BFDNeighbor, 0, len(m.config.BFDNeighbors))
 	newNbrs := make([]BFDNeighbor, 0, len(m.config.BFDNeighbors))
 	for _, nbr := range m.config.BFDNeighbors {
 	for _, nbr := range m.config.BFDNeighbors {
 		if nbr.ID != id && nbr.Address != id {
 		if nbr.ID != id && nbr.Address != id {
@@ -3536,10 +3558,29 @@ func (m *Manager) DeleteBFDNeighbor(id string) error {
 	}
 	}
 	m.config.BFDNeighbors = newNbrs
 	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 {
 	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 {
 func (m *Manager) GetBFDSessions() []BFDSession {
@@ -4475,13 +4516,6 @@ func (m *Manager) generateFRREigrpConfig() string {
 }
 }
 
 
 func (m *Manager) applyFRREigrpConfig() error {
 func (m *Manager) applyFRREigrpConfig() error {
-	if _, err := exec.LookPath("vtysh"); err != nil {
-		return nil
-	}
-
-	cfgText := m.generateFRREigrpConfig()
-	frrConfPath := "/etc/frr/frr.conf"
-
 	hasActiveEigrp := false
 	hasActiveEigrp := false
 	for _, inst := range m.config.EIGRPInstances {
 	for _, inst := range m.config.EIGRPInstances {
 		if !inst.Disabled {
 		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 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)
 		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()
 	_ = exec.Command("chown", "frr:frr", frrConfPath).Run()
 
 
 	return nil
 	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
 	mu            sync.RWMutex
 	samples       map[string][]probeSample // memberID -> rolling probe samples
 	samples       map[string][]probeSample // memberID -> rolling probe samples
 	cancel        context.CancelFunc
 	cancel        context.CancelFunc
+	done          chan struct{}
 	onStateChange func()
 	onStateChange func()
+	applyResults  func([]*SDWANGroup) bool
+	probe         func(string, string) (float64, bool)
 }
 }
 
 
 func NewHealthMonitor(onStateChange func()) *HealthMonitor {
 func NewHealthMonitor(onStateChange func()) *HealthMonitor {
@@ -42,8 +45,10 @@ func NewHealthMonitor(onStateChange func()) *HealthMonitor {
 func (h *HealthMonitor) Start(getGroups func() []*SDWANGroup) {
 func (h *HealthMonitor) Start(getGroups func() []*SDWANGroup) {
 	ctx, cancel := context.WithCancel(context.Background())
 	ctx, cancel := context.WithCancel(context.Background())
 	h.cancel = cancel
 	h.cancel = cancel
+	h.done = make(chan struct{})
 
 
 	go func() {
 	go func() {
+		defer close(h.done)
 		ticker := time.NewTicker(3 * time.Second)
 		ticker := time.NewTicker(3 * time.Second)
 		defer ticker.Stop()
 		defer ticker.Stop()
 
 
@@ -53,7 +58,16 @@ func (h *HealthMonitor) Start(getGroups func() []*SDWANGroup) {
 				return
 				return
 			case <-ticker.C:
 			case <-ticker.C:
 				groups := getGroups()
 				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() {
 func (h *HealthMonitor) Stop() {
 	if h.cancel != nil {
 	if h.cancel != nil {
 		h.cancel()
 		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
 			continue
 		}
 		}
-
-		for _, m := range g.Members {
-			if m.Disabled || m.Interface == "" {
-				m.Status = StatusDisabled
+		for _, member := range group.Members {
+			if member == nil {
 				continue
 				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 {
 				} 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.monitor = NewHealthMonitor(func() {
-		mgr.mu.RLock()
-		groups := mgr.listLocked()
-		mgr.mu.RUnlock()
-		_ = mgr.engine.Sync(groups)
+		mgr.SyncAll()
 		NotifyStateChange()
 		NotifyStateChange()
 	})
 	})
 
 
 	mgr.load()
 	mgr.load()
 	mgr.SyncAll()
 	mgr.SyncAll()
+	mgr.monitor.applyResults = mgr.applyProbeResults
 	mgr.monitor.Start(func() []*SDWANGroup {
 	mgr.monitor.Start(func() []*SDWANGroup {
 		return mgr.List()
 		return mgr.List()
 	})
 	})
@@ -154,11 +152,60 @@ func (m *Manager) listLocked() []*SDWANGroup {
 	return list
 	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 {
 func (m *Manager) List() []*SDWANGroup {
 	m.mu.RLock()
 	m.mu.RLock()
 	defer m.mu.RUnlock()
 	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.
 // 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()
 	defer m.mu.RUnlock()
 
 
 	if g, ok := m.groups[idOrName]; ok {
 	if g, ok := m.groups[idOrName]; ok {
-		return g, true
+		return copyGroup(g), true
 	}
 	}
 	for _, g := range m.groups {
 	for _, g := range m.groups {
 		if strings.EqualFold(g.Name, idOrName) {
 		if strings.EqualFold(g.Name, idOrName) {
-			return g, true
+			return copyGroup(g), true
 		}
 		}
 	}
 	}
 	return nil, false
 	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 existing, ok := m.groups[g.ID]; ok {
 		if g.Members == nil && existing.Members != nil {
 		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
 	// Ensure member IDs and initial statuses
 	for _, mem := range g.Members {
 	for _, mem := range g.Members {
+		if mem == nil {
+			return fmt.Errorf("sd-wan member must not be null")
+		}
 		if mem.ID == "" {
 		if mem.ID == "" {
 			mem.ID = fmt.Sprintf("mem-%d-%s", time.Now().UnixNano(), mem.Interface)
 			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 {
 	if err := m.save(); err != nil {
 		return err
 		return err
 	}
 	}
@@ -311,7 +375,8 @@ func (m *Manager) AddMember(groupIDOrName string, mem *SDWANMember) error {
 	}
 	}
 	mem.Status = StatusHealthy
 	mem.Status = StatusHealthy
 
 
-	g.Members = append(g.Members, mem)
+	memberCopy := *mem
+	g.Members = append(g.Members, &memberCopy)
 	if err := m.save(); err != nil {
 	if err := m.save(); err != nil {
 		return err
 		return err
 	}
 	}
@@ -373,8 +438,12 @@ func (m *Manager) UpdateMember(groupIDOrName string, mem *SDWANMember) error {
 	mem.PacketLoss = g.Members[idx].PacketLoss
 	mem.PacketLoss = g.Members[idx].PacketLoss
 	mem.Jitter = g.Members[idx].Jitter
 	mem.Jitter = g.Members[idx].Jitter
 	mem.ConsecutiveFails = g.Members[idx].ConsecutiveFails
 	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 {
 	if err := m.save(); err != nil {
 		return err
 		return err
 	}
 	}
@@ -644,8 +713,12 @@ func ResolveGroupForRouting(groupNameOrID string) ([]ResolvedRouteNexthop, bool)
 		return GlobalManager.ResolveGroupForRouting(groupNameOrID)
 		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 {
 	if err != nil {
 		return nil, false
 		return nil, false
 	}
 	}
@@ -653,25 +726,13 @@ func ResolveGroupForRouting(groupNameOrID string) ([]ResolvedRouteNexthop, bool)
 	if err := json.Unmarshal(data, &groups); err != nil {
 	if err := json.Unmarshal(data, &groups); err != nil {
 		return nil, false
 		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.
 // 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 (
 import (
 	"net"
 	"net"
+	"os"
 	"path/filepath"
 	"path/filepath"
 	"testing"
 	"testing"
 	"time"
 	"time"
@@ -234,3 +235,15 @@ func TestSDWANVirtualDeviceLifecycle(t *testing.T) {
 		t.Fatalf("expected sdwantest0 virtual device to be removed from kernel after group delete")
 		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"
 	"bufio"
 	"encoding/json"
 	"encoding/json"
 	"fmt"
 	"fmt"
+	"log"
 	"net"
 	"net"
 	"os"
 	"os"
 	"os/exec"
 	"os/exec"
@@ -31,10 +32,10 @@ type PPPProfile struct {
 // ======================== PPP Secrets (/ppp secret) ========================
 // ======================== PPP Secrets (/ppp secret) ========================
 type PPPSecret struct {
 type PPPSecret struct {
 	ID            string `json:"id"`
 	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"`
 	LocalAddress  string `json:"local_address,omitempty"`
 	RemoteAddress string `json:"remote_address,omitempty"`
 	RemoteAddress string `json:"remote_address,omitempty"`
 	CallerID      string `json:"caller_id,omitempty"`
 	CallerID      string `json:"caller_id,omitempty"`
@@ -110,6 +111,7 @@ type ActivePPPSession struct {
 type PPPServerManager struct {
 type PPPServerManager struct {
 	mu           sync.RWMutex
 	mu           sync.RWMutex
 	baseDir      string
 	baseDir      string
+	pppDir       string
 	profiles     map[string]*PPPProfile
 	profiles     map[string]*PPPProfile
 	secrets      map[string]*PPPSecret
 	secrets      map[string]*PPPSecret
 	pppoeServer  PPPoEServerConfig
 	pppoeServer  PPPoEServerConfig
@@ -122,6 +124,7 @@ type PPPServerManager struct {
 func NewPPPServerManager() *PPPServerManager {
 func NewPPPServerManager() *PPPServerManager {
 	mgr := &PPPServerManager{
 	mgr := &PPPServerManager{
 		baseDir:      "/etc/debtik/vpn",
 		baseDir:      "/etc/debtik/vpn",
+		pppDir:       "/etc/ppp",
 		profiles:     make(map[string]*PPPProfile),
 		profiles:     make(map[string]*PPPProfile),
 		secrets:      make(map[string]*PPPSecret),
 		secrets:      make(map[string]*PPPSecret),
 		pppoePIDFile: "/var/run/debtik-pppoe-server.pid",
 		pppoePIDFile: "/var/run/debtik-pppoe-server.pid",
@@ -156,7 +159,9 @@ func NewPPPServerManager() *PPPServerManager {
 
 
 	mgr.initDefaults()
 	mgr.initDefaults()
 	mgr.loadAll()
 	mgr.loadAll()
-	mgr.SyncSecretsToSystem()
+	if err := mgr.SyncSecretsToSystem(); err != nil {
+		log.Printf("[PPP] Failed to synchronize secrets: %v", err)
+	}
 	mgr.applyAll()
 	mgr.applyAll()
 	return mgr
 	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
 	// 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
 	// Re-apply servers if their configured DefaultProfile was updated
 	if m.l2tpServer.Enabled && m.l2tpServer.DefaultProfile == p.Name {
 	if m.l2tpServer.Enabled && m.l2tpServer.DefaultProfile == p.Name {
@@ -511,8 +518,15 @@ func (m *PPPServerManager) syncSecretsToSystemLocked() error {
 	}
 	}
 
 
 	content := strings.Join(lines, "\n") + "\n"
 	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
 	return nil
 }
 }
 
 

+ 10 - 1
internal/vpn/ppp_server_test.go

@@ -2,6 +2,7 @@ package vpn
 
 
 import (
 import (
 	"os"
 	"os"
+	"path/filepath"
 	"strings"
 	"strings"
 	"testing"
 	"testing"
 )
 )
@@ -34,6 +35,7 @@ func TestSyncSecretsInheritanceAndOverrides(t *testing.T) {
 
 
 	mgr := &PPPServerManager{
 	mgr := &PPPServerManager{
 		baseDir:  tmpDir,
 		baseDir:  tmpDir,
+		pppDir:   tmpDir,
 		profiles: make(map[string]*PPPProfile),
 		profiles: make(map[string]*PPPProfile),
 		secrets:  make(map[string]*PPPSecret),
 		secrets:  make(map[string]*PPPSecret),
 	}
 	}
@@ -92,7 +94,7 @@ func TestSyncSecretsInheritanceAndOverrides(t *testing.T) {
 		t.Fatalf("sync error: %v", err)
 		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 {
 	if err != nil {
 		t.Skip("skipping chap-secrets read if no root permission")
 		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)
 		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 =================
 // ================= Interceptor: Automatic Auth Bearer & 401 Handling =================
 const originalFetch = window.fetch.bind(window);
 const originalFetch = window.fetch.bind(window);
 const pendingAPIReads = new Map();
 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 coreRefreshPromise = null;
 let lastSummaryRefresh = 0;
 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() {
 function renderTelemetryHealth() {
   const el = document.getElementById('telemetry-health');
   const el = document.getElementById('telemetry-health');
   if (!el) return;
   if (!el) return;
@@ -33,6 +85,7 @@ function stopSessionPolling() {
   stopNATStatPolling();
   stopNATStatPolling();
   stopMangleStatPolling();
   stopMangleStatPolling();
   telemetryHealth.failures.clear();
   telemetryHealth.failures.clear();
+  telemetryHealth.successes.clear();
   telemetryHealth.lastSuccess = 0;
   telemetryHealth.lastSuccess = 0;
   lastSummaryRefresh = 0;
   lastSummaryRefresh = 0;
 }
 }
@@ -60,11 +113,13 @@ window.fetch = async function(resource, options = {}) {
       if (isRead) {
       if (isRead) {
         if (response.ok) {
         if (response.ok) {
           telemetryHealth.lastSuccess = Date.now();
           telemetryHealth.lastSuccess = Date.now();
+          telemetryHealth.successes.set(url.pathname, telemetryHealth.lastSuccess);
           telemetryHealth.failures.delete(url.pathname);
           telemetryHealth.failures.delete(url.pathname);
         } else {
         } else {
           telemetryHealth.failures.set(url.pathname, `HTTP ${response.status}; displayed values may be old`);
           telemetryHealth.failures.set(url.pathname, `HTTP ${response.status}; displayed values may be old`);
         }
         }
         renderTelemetryHealth();
         renderTelemetryHealth();
+        renderViewDataHealth();
       }
       }
       if (response.status === 401 && url.pathname !== '/api/auth/login' && state.currentUser && token === localStorage.getItem('debtik_token')) {
       if (response.status === 401 && url.pathname !== '/api/auth/login' && state.currentUser && token === localStorage.getItem('debtik_token')) {
         stopSessionPolling();
         stopSessionPolling();
@@ -78,6 +133,7 @@ window.fetch = async function(resource, options = {}) {
       if (isRead) {
       if (isRead) {
         telemetryHealth.failures.set(url.pathname, err.name === 'AbortError' ? 'Request timed out' : 'Connection failed; displayed values may be old');
         telemetryHealth.failures.set(url.pathname, err.name === 'AbortError' ? 'Request timed out' : 'Connection failed; displayed values may be old');
         renderTelemetryHealth();
         renderTelemetryHealth();
+        renderViewDataHealth();
       }
       }
       throw err;
       throw err;
     } finally {
     } finally {
@@ -341,6 +397,7 @@ function navigate(tabName) {
   stopNATStatPolling();
   stopNATStatPolling();
   stopMangleStatPolling();
   stopMangleStatPolling();
   state.activeTab = tabName;
   state.activeTab = tabName;
+  renderViewDataHealth();
   window.location.hash = tabName;
   window.location.hash = tabName;
 
 
   // Update nav links styling
   // Update nav links styling
@@ -564,7 +621,7 @@ async function refreshCoreData(isManual = false) {
     if (state.activeTab === 'vlans') renderVlansList();
     if (state.activeTab === 'vlans') renderVlansList();
     if (state.activeTab === 'firewall') await loadFirewall(false);
     if (state.activeTab === 'firewall') await loadFirewall(false);
     if (state.activeTab === 'routes') await loadRoutes(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 === 'ppp') await loadPPP(false);
     if (state.activeTab === 'eoip') await loadEoIP(false);
     if (state.activeTab === 'eoip') await loadEoIP(false);
     if (state.activeTab === 'vxlan') await loadVXLAN(false);
     if (state.activeTab === 'vxlan') await loadVXLAN(false);
@@ -9852,7 +9909,11 @@ function switchDynSubTab(subTab) {
 
 
 async function loadDynamicRouting(showToastMsg = false) {
 async function loadDynamicRouting(showToastMsg = false) {
   try {
   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 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 {
       try {
         const r = await fetch(url);
         const r = await fetch(url);
         return r.ok ? await r.json() : null;
         return r.ok ? await r.json() : null;
@@ -11180,6 +11241,8 @@ async function deleteCurrentOspfInstance() {
       closeOspfInstanceModal();
       closeOspfInstanceModal();
       showToast('Removed', 'OSPF instance deleted', 'success');
       showToast('Removed', 'OSPF instance deleted', 'success');
       loadDynamicRouting();
       loadDynamicRouting();
+    } else {
+      throw new Error(await responseError(res));
     }
     }
   } catch (err) {
   } catch (err) {
     showToast('Error', err.message, 'error');
     showToast('Error', err.message, 'error');
@@ -11189,22 +11252,22 @@ async function deleteCurrentOspfInstance() {
 async function enableSelectedOspfInstance() {
 async function enableSelectedOspfInstance() {
   const id = state.selectedOspfRow?.instances;
   const id = state.selectedOspfRow?.instances;
   if (!id) return;
   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',
     method: 'POST',
     headers: { 'Content-Type': 'application/json' },
     headers: { 'Content-Type': 'application/json' },
     body: JSON.stringify({ disabled: false })
     body: JSON.stringify({ disabled: false })
-  });
+  })) return;
   loadDynamicRouting();
   loadDynamicRouting();
 }
 }
 
 
 async function disableSelectedOspfInstance() {
 async function disableSelectedOspfInstance() {
   const id = state.selectedOspfRow?.instances;
   const id = state.selectedOspfRow?.instances;
   if (!id) return;
   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',
     method: 'POST',
     headers: { 'Content-Type': 'application/json' },
     headers: { 'Content-Type': 'application/json' },
     body: JSON.stringify({ disabled: true })
     body: JSON.stringify({ disabled: true })
-  });
+  })) return;
   loadDynamicRouting();
   loadDynamicRouting();
 }
 }
 
 
@@ -11212,7 +11275,7 @@ async function removeSelectedOspfInstance() {
   const id = state.selectedOspfRow?.instances;
   const id = state.selectedOspfRow?.instances;
   if (!id) return;
   if (!id) return;
   if (!confirm('Delete selected OSPF instance?')) 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();
   loadDynamicRouting();
 }
 }
 
 
@@ -11223,12 +11286,12 @@ async function promptCommentOspfInstance() {
   if (!inst) return;
   if (!inst) return;
   const comment = prompt('Enter comment for OSPF instance:', inst.comment || '');
   const comment = prompt('Enter comment for OSPF instance:', inst.comment || '');
   if (comment === null) return;
   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',
     method: 'POST',
     headers: { 'Content-Type': 'application/json' },
     headers: { 'Content-Type': 'application/json' },
-    body: JSON.stringify(inst)
-  });
+    body: JSON.stringify(updated)
+  })) return;
   loadDynamicRouting();
   loadDynamicRouting();
 }
 }
 
 
@@ -11337,6 +11400,8 @@ async function deleteCurrentOspfArea() {
       closeOspfAreaModal();
       closeOspfAreaModal();
       showToast('Removed', 'OSPF area deleted', 'success');
       showToast('Removed', 'OSPF area deleted', 'success');
       loadDynamicRouting();
       loadDynamicRouting();
+    } else {
+      throw new Error(await responseError(res));
     }
     }
   } catch (err) {
   } catch (err) {
     showToast('Error', err.message, 'error');
     showToast('Error', err.message, 'error');
@@ -11346,22 +11411,22 @@ async function deleteCurrentOspfArea() {
 async function enableSelectedOspfArea() {
 async function enableSelectedOspfArea() {
   const id = state.selectedOspfRow?.areas;
   const id = state.selectedOspfRow?.areas;
   if (!id) return;
   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',
     method: 'POST',
     headers: { 'Content-Type': 'application/json' },
     headers: { 'Content-Type': 'application/json' },
     body: JSON.stringify({ disabled: false })
     body: JSON.stringify({ disabled: false })
-  });
+  })) return;
   loadDynamicRouting();
   loadDynamicRouting();
 }
 }
 
 
 async function disableSelectedOspfArea() {
 async function disableSelectedOspfArea() {
   const id = state.selectedOspfRow?.areas;
   const id = state.selectedOspfRow?.areas;
   if (!id) return;
   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',
     method: 'POST',
     headers: { 'Content-Type': 'application/json' },
     headers: { 'Content-Type': 'application/json' },
     body: JSON.stringify({ disabled: true })
     body: JSON.stringify({ disabled: true })
-  });
+  })) return;
   loadDynamicRouting();
   loadDynamicRouting();
 }
 }
 
 
@@ -11369,7 +11434,7 @@ async function removeSelectedOspfArea() {
   const id = state.selectedOspfRow?.areas;
   const id = state.selectedOspfRow?.areas;
   if (!id) return;
   if (!id) return;
   if (!confirm('Delete selected OSPF area?')) 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();
   loadDynamicRouting();
 }
 }
 
 
@@ -11380,12 +11445,12 @@ async function promptCommentOspfArea() {
   if (!area) return;
   if (!area) return;
   const comment = prompt('Enter comment for OSPF area:', area.comment || '');
   const comment = prompt('Enter comment for OSPF area:', area.comment || '');
   if (comment === null) return;
   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',
     method: 'POST',
     headers: { 'Content-Type': 'application/json' },
     headers: { 'Content-Type': 'application/json' },
-    body: JSON.stringify(area)
-  });
+    body: JSON.stringify(updated)
+  })) return;
   loadDynamicRouting();
   loadDynamicRouting();
 }
 }
 
 
@@ -11476,7 +11541,7 @@ async function deleteCurrentOspfAreaRange() {
   if (!id) return;
   if (!id) return;
   if (!confirm('Delete this area range?')) return;
   if (!confirm('Delete this area range?')) return;
   try {
   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();
     closeOspfAreaRangeModal();
     showToast('Removed', 'Area range removed', 'success');
     showToast('Removed', 'Area range removed', 'success');
     loadDynamicRouting();
     loadDynamicRouting();
@@ -11490,8 +11555,8 @@ async function enableSelectedOspfAreaRange() {
   if (!id) return;
   if (!id) return;
   const r = (state.ospfAreaRanges || []).find(it => it.id === id);
   const r = (state.ospfAreaRanges || []).find(it => it.id === id);
   if (!r) return;
   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();
   loadDynamicRouting();
 }
 }
 
 
@@ -11500,8 +11565,8 @@ async function disableSelectedOspfAreaRange() {
   if (!id) return;
   if (!id) return;
   const r = (state.ospfAreaRanges || []).find(it => it.id === id);
   const r = (state.ospfAreaRanges || []).find(it => it.id === id);
   if (!r) return;
   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();
   loadDynamicRouting();
 }
 }
 
 
@@ -11509,7 +11574,7 @@ async function removeSelectedOspfAreaRange() {
   const id = state.selectedOspfRow?.['area-ranges'];
   const id = state.selectedOspfRow?.['area-ranges'];
   if (!id) return;
   if (!id) return;
   if (!confirm('Delete selected area range?')) 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();
   loadDynamicRouting();
 }
 }
 
 
@@ -11520,8 +11585,8 @@ async function promptCommentOspfAreaRange() {
   if (!r) return;
   if (!r) return;
   const comment = prompt('Enter comment for area range:', r.comment || '');
   const comment = prompt('Enter comment for area range:', r.comment || '');
   if (comment === null) return;
   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();
   loadDynamicRouting();
 }
 }
 
 
@@ -11670,7 +11735,7 @@ async function deleteCurrentOspfTemplate() {
   if (!id) return;
   if (!id) return;
   if (!confirm('Delete this interface template?')) return;
   if (!confirm('Delete this interface template?')) return;
   try {
   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();
     closeOspfTemplateModal();
     showToast('Removed', 'Interface template deleted', 'success');
     showToast('Removed', 'Interface template deleted', 'success');
     loadDynamicRouting();
     loadDynamicRouting();
@@ -11682,22 +11747,22 @@ async function deleteCurrentOspfTemplate() {
 async function enableSelectedOspfTemplate() {
 async function enableSelectedOspfTemplate() {
   const id = state.selectedOspfRow?.templates;
   const id = state.selectedOspfRow?.templates;
   if (!id) return;
   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',
     method: 'POST',
     headers: { 'Content-Type': 'application/json' },
     headers: { 'Content-Type': 'application/json' },
     body: JSON.stringify({ disabled: false })
     body: JSON.stringify({ disabled: false })
-  });
+  })) return;
   loadDynamicRouting();
   loadDynamicRouting();
 }
 }
 
 
 async function disableSelectedOspfTemplate() {
 async function disableSelectedOspfTemplate() {
   const id = state.selectedOspfRow?.templates;
   const id = state.selectedOspfRow?.templates;
   if (!id) return;
   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',
     method: 'POST',
     headers: { 'Content-Type': 'application/json' },
     headers: { 'Content-Type': 'application/json' },
     body: JSON.stringify({ disabled: true })
     body: JSON.stringify({ disabled: true })
-  });
+  })) return;
   loadDynamicRouting();
   loadDynamicRouting();
 }
 }
 
 
@@ -11705,7 +11770,7 @@ async function removeSelectedOspfTemplate() {
   const id = state.selectedOspfRow?.templates;
   const id = state.selectedOspfRow?.templates;
   if (!id) return;
   if (!id) return;
   if (!confirm('Delete selected interface template?')) 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();
   loadDynamicRouting();
 }
 }
 
 
@@ -11716,12 +11781,12 @@ async function promptCommentOspfTemplate() {
   if (!tmpl) return;
   if (!tmpl) return;
   const comment = prompt('Enter comment for template:', tmpl.comment || '');
   const comment = prompt('Enter comment for template:', tmpl.comment || '');
   if (comment === null) return;
   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',
     method: 'POST',
     headers: { 'Content-Type': 'application/json' },
     headers: { 'Content-Type': 'application/json' },
-    body: JSON.stringify(tmpl)
-  });
+    body: JSON.stringify(updated)
+  })) return;
   loadDynamicRouting();
   loadDynamicRouting();
 }
 }
 
 
@@ -11812,7 +11877,7 @@ async function deleteCurrentOspfStaticNbr() {
   if (!id) return;
   if (!id) return;
   if (!confirm('Delete this static neighbor?')) return;
   if (!confirm('Delete this static neighbor?')) return;
   try {
   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();
     closeOspfStaticNbrModal();
     showToast('Removed', 'Static neighbor removed', 'success');
     showToast('Removed', 'Static neighbor removed', 'success');
     loadDynamicRouting();
     loadDynamicRouting();
@@ -11824,14 +11889,14 @@ async function deleteCurrentOspfStaticNbr() {
 async function enableSelectedOspfStaticNbr() {
 async function enableSelectedOspfStaticNbr() {
   const id = state.selectedOspfRow?.['static-nbrs'];
   const id = state.selectedOspfRow?.['static-nbrs'];
   if (!id) return;
   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();
   loadDynamicRouting();
 }
 }
 
 
 async function disableSelectedOspfStaticNbr() {
 async function disableSelectedOspfStaticNbr() {
   const id = state.selectedOspfRow?.['static-nbrs'];
   const id = state.selectedOspfRow?.['static-nbrs'];
   if (!id) return;
   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();
   loadDynamicRouting();
 }
 }
 
 
@@ -11839,7 +11904,7 @@ async function removeSelectedOspfStaticNbr() {
   const id = state.selectedOspfRow?.['static-nbrs'];
   const id = state.selectedOspfRow?.['static-nbrs'];
   if (!id) return;
   if (!id) return;
   if (!confirm('Delete selected static neighbor?')) 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();
   loadDynamicRouting();
 }
 }
 
 
@@ -11850,8 +11915,8 @@ async function promptCommentOspfStaticNbr() {
   if (!n) return;
   if (!n) return;
   const comment = prompt('Enter comment for static neighbor:', n.comment || '');
   const comment = prompt('Enter comment for static neighbor:', n.comment || '');
   if (comment === null) return;
   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();
   loadDynamicRouting();
 }
 }
 
 
@@ -11860,8 +11925,8 @@ function openAddOspfInterfaceModal() { openOspfTemplateModal(); }
 function openEditOspfInterfaceModal(id) { openEditOspfTemplateModal(id); }
 function openEditOspfInterfaceModal(id) { openEditOspfTemplateModal(id); }
 function closeOspfInterfaceModal() { closeOspfTemplateModal(); }
 function closeOspfInterfaceModal() { closeOspfTemplateModal(); }
 function submitOspfInterface(e) { submitOspfTemplate(e); }
 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) {
 function toggleOspfInterfaceState(id, disabled) {
   fetch(`${API_BASE}/routing/ospf/templates/${encodeURIComponent(id)}/state`, {
   fetch(`${API_BASE}/routing/ospf/templates/${encodeURIComponent(id)}/state`, {
@@ -13578,7 +13643,55 @@ async function toggleASListState(id, disabled) {
 // TROUBLESHOOT & DIAGNOSTIC TOOLS (/tool)
 // 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() {
 function loadToolsView() {
+  loadNetworkOverview();
   populateToolsInterfaces();
   populateToolsInterfaces();
   switchToolsSubTab(state.activeToolsSubTab || 'ping');
   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
 // DYNAMIC ROUTING: RIP MODULE
 // ==============================================================================
 // ==============================================================================
 state.ripInstances = [];
 state.ripInstances = [];
@@ -19923,21 +20047,21 @@ async function submitRipInstance(e) {
 async function enableSelectedRipInstance() {
 async function enableSelectedRipInstance() {
   const id = state.selectedRipRow?.instances;
   const id = state.selectedRipRow?.instances;
   if (!id) return;
   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);
   await loadDynamicRouting(false);
 }
 }
 
 
 async function disableSelectedRipInstance() {
 async function disableSelectedRipInstance() {
   const id = state.selectedRipRow?.instances;
   const id = state.selectedRipRow?.instances;
   if (!id) return;
   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);
   await loadDynamicRouting(false);
 }
 }
 
 
 async function removeSelectedRipInstance() {
 async function removeSelectedRipInstance() {
   const id = state.selectedRipRow?.instances;
   const id = state.selectedRipRow?.instances;
   if (!id || !confirm('Are you sure you want to delete this RIP instance?')) return;
   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;
   state.selectedRipRow.instances = null;
   await loadDynamicRouting(false);
   await loadDynamicRouting(false);
 }
 }
@@ -20032,21 +20156,21 @@ async function submitRipInterface(e) {
 async function enableSelectedRipInterface() {
 async function enableSelectedRipInterface() {
   const id = state.selectedRipRow?.interfaces;
   const id = state.selectedRipRow?.interfaces;
   if (!id) return;
   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);
   await loadDynamicRouting(false);
 }
 }
 
 
 async function disableSelectedRipInterface() {
 async function disableSelectedRipInterface() {
   const id = state.selectedRipRow?.interfaces;
   const id = state.selectedRipRow?.interfaces;
   if (!id) return;
   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);
   await loadDynamicRouting(false);
 }
 }
 
 
 async function removeSelectedRipInterface() {
 async function removeSelectedRipInterface() {
   const id = state.selectedRipRow?.interfaces;
   const id = state.selectedRipRow?.interfaces;
   if (!id || !confirm('Are you sure you want to delete this RIP interface template?')) return;
   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;
   state.selectedRipRow.interfaces = null;
   await loadDynamicRouting(false);
   await loadDynamicRouting(false);
 }
 }
@@ -20319,21 +20443,21 @@ async function submitBfdInterface(e) {
 async function enableSelectedBfdInterface() {
 async function enableSelectedBfdInterface() {
   const id = state.selectedBfdRow?.interfaces;
   const id = state.selectedBfdRow?.interfaces;
   if (!id) return;
   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);
   await loadDynamicRouting(false);
 }
 }
 
 
 async function disableSelectedBfdInterface() {
 async function disableSelectedBfdInterface() {
   const id = state.selectedBfdRow?.interfaces;
   const id = state.selectedBfdRow?.interfaces;
   if (!id) return;
   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);
   await loadDynamicRouting(false);
 }
 }
 
 
 async function removeSelectedBfdInterface() {
 async function removeSelectedBfdInterface() {
   const id = state.selectedBfdRow?.interfaces;
   const id = state.selectedBfdRow?.interfaces;
   if (!id || !confirm('Are you sure you want to remove this BFD interface template?')) return;
   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;
   state.selectedBfdRow.interfaces = null;
   await loadDynamicRouting(false);
   await loadDynamicRouting(false);
 }
 }
@@ -20411,7 +20535,7 @@ async function enableSelectedBfdPeer() {
   if (!id) return;
   if (!id) return;
   const p = (state.bfdNeighbors || []).find(n => n.id === id);
   const p = (state.bfdNeighbors || []).find(n => n.id === id);
   if (!p) return;
   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);
   await loadDynamicRouting(false);
 }
 }
 
 
@@ -20420,14 +20544,14 @@ async function disableSelectedBfdPeer() {
   if (!id) return;
   if (!id) return;
   const p = (state.bfdNeighbors || []).find(n => n.id === id);
   const p = (state.bfdNeighbors || []).find(n => n.id === id);
   if (!p) return;
   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);
   await loadDynamicRouting(false);
 }
 }
 
 
 async function removeSelectedBfdPeer() {
 async function removeSelectedBfdPeer() {
   const id = state.selectedBfdRow?.peers;
   const id = state.selectedBfdRow?.peers;
   if (!id || !confirm('Are you sure you want to remove this BFD peer?')) return;
   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;
   state.selectedBfdRow.peers = null;
   await loadDynamicRouting(false);
   await loadDynamicRouting(false);
 }
 }
@@ -20709,21 +20833,21 @@ async function submitBabelInstance(e) {
 async function enableSelectedBabelInstance() {
 async function enableSelectedBabelInstance() {
   const id = state.selectedBabelRow?.instances;
   const id = state.selectedBabelRow?.instances;
   if (!id) return;
   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);
   await loadDynamicRouting(false);
 }
 }
 
 
 async function disableSelectedBabelInstance() {
 async function disableSelectedBabelInstance() {
   const id = state.selectedBabelRow?.instances;
   const id = state.selectedBabelRow?.instances;
   if (!id) return;
   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);
   await loadDynamicRouting(false);
 }
 }
 
 
 async function removeSelectedBabelInstance() {
 async function removeSelectedBabelInstance() {
   const id = state.selectedBabelRow?.instances;
   const id = state.selectedBabelRow?.instances;
   if (!id || !confirm('Are you sure you want to remove this Babel instance?')) return;
   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;
   state.selectedBabelRow.instances = null;
   await loadDynamicRouting(false);
   await loadDynamicRouting(false);
 }
 }
@@ -20838,21 +20962,21 @@ async function submitBabelInterface(e) {
 async function enableSelectedBabelInterface() {
 async function enableSelectedBabelInterface() {
   const id = state.selectedBabelRow?.interfaces;
   const id = state.selectedBabelRow?.interfaces;
   if (!id) return;
   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);
   await loadDynamicRouting(false);
 }
 }
 
 
 async function disableSelectedBabelInterface() {
 async function disableSelectedBabelInterface() {
   const id = state.selectedBabelRow?.interfaces;
   const id = state.selectedBabelRow?.interfaces;
   if (!id) return;
   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);
   await loadDynamicRouting(false);
 }
 }
 
 
 async function removeSelectedBabelInterface() {
 async function removeSelectedBabelInterface() {
   const id = state.selectedBabelRow?.interfaces;
   const id = state.selectedBabelRow?.interfaces;
   if (!id || !confirm('Are you sure you want to remove this Babel interface template?')) return;
   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;
   state.selectedBabelRow.interfaces = null;
   await loadDynamicRouting(false);
   await loadDynamicRouting(false);
 }
 }
@@ -21022,21 +21146,21 @@ async function submitRAdvInstance(e) {
 async function enableSelectedRadvInstance() {
 async function enableSelectedRadvInstance() {
   const id = state.selectedRadvRow?.instances;
   const id = state.selectedRadvRow?.instances;
   if (!id) return;
   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);
   await loadDynamicRouting(false);
 }
 }
 
 
 async function disableSelectedRadvInstance() {
 async function disableSelectedRadvInstance() {
   const id = state.selectedRadvRow?.instances;
   const id = state.selectedRadvRow?.instances;
   if (!id) return;
   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);
   await loadDynamicRouting(false);
 }
 }
 
 
 async function removeSelectedRadvInstance() {
 async function removeSelectedRadvInstance() {
   const id = state.selectedRadvRow?.instances;
   const id = state.selectedRadvRow?.instances;
   if (!id || !confirm('Are you sure you want to remove this RAdv instance?')) return;
   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;
   state.selectedRadvRow.instances = null;
   await loadDynamicRouting(false);
   await loadDynamicRouting(false);
 }
 }
@@ -21143,21 +21267,21 @@ async function submitRAdvInterface(e) {
 async function enableSelectedRadvInterface() {
 async function enableSelectedRadvInterface() {
   const id = state.selectedRadvRow?.interfaces;
   const id = state.selectedRadvRow?.interfaces;
   if (!id) return;
   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);
   await loadDynamicRouting(false);
 }
 }
 
 
 async function disableSelectedRadvInterface() {
 async function disableSelectedRadvInterface() {
   const id = state.selectedRadvRow?.interfaces;
   const id = state.selectedRadvRow?.interfaces;
   if (!id) return;
   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);
   await loadDynamicRouting(false);
 }
 }
 
 
 async function removeSelectedRadvInterface() {
 async function removeSelectedRadvInterface() {
   const id = state.selectedRadvRow?.interfaces;
   const id = state.selectedRadvRow?.interfaces;
   if (!id || !confirm('Are you sure you want to remove this RAdv interface?')) return;
   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;
   state.selectedRadvRow.interfaces = null;
   await loadDynamicRouting(false);
   await loadDynamicRouting(false);
 }
 }
@@ -21365,21 +21489,21 @@ async function submitEigrpInstance(e) {
 async function enableSelectedEigrpInstance() {
 async function enableSelectedEigrpInstance() {
   const id = state.selectedEigrpRow?.instances;
   const id = state.selectedEigrpRow?.instances;
   if (!id) return;
   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);
   await loadDynamicRouting(false);
 }
 }
 
 
 async function disableSelectedEigrpInstance() {
 async function disableSelectedEigrpInstance() {
   const id = state.selectedEigrpRow?.instances;
   const id = state.selectedEigrpRow?.instances;
   if (!id) return;
   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);
   await loadDynamicRouting(false);
 }
 }
 
 
 async function removeSelectedEigrpInstance() {
 async function removeSelectedEigrpInstance() {
   const id = state.selectedEigrpRow?.instances;
   const id = state.selectedEigrpRow?.instances;
   if (!id || !confirm('Are you sure you want to remove this EIGRP instance?')) return;
   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;
   state.selectedEigrpRow.instances = null;
   await loadDynamicRouting(false);
   await loadDynamicRouting(false);
 }
 }
@@ -21453,7 +21577,7 @@ async function submitEigrpNetwork(e) {
 async function removeSelectedEigrpNetwork() {
 async function removeSelectedEigrpNetwork() {
   const id = state.selectedEigrpRow?.networks;
   const id = state.selectedEigrpRow?.networks;
   if (!id || !confirm('Are you sure you want to remove this EIGRP network?')) return;
   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;
   state.selectedEigrpRow.networks = null;
   await loadDynamicRouting(false);
   await loadDynamicRouting(false);
 }
 }
@@ -21590,21 +21714,21 @@ async function submitEigrpInterface(e) {
 async function enableSelectedEigrpInterface() {
 async function enableSelectedEigrpInterface() {
   const id = state.selectedEigrpRow?.interfaces;
   const id = state.selectedEigrpRow?.interfaces;
   if (!id) return;
   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);
   await loadDynamicRouting(false);
 }
 }
 
 
 async function disableSelectedEigrpInterface() {
 async function disableSelectedEigrpInterface() {
   const id = state.selectedEigrpRow?.interfaces;
   const id = state.selectedEigrpRow?.interfaces;
   if (!id) return;
   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);
   await loadDynamicRouting(false);
 }
 }
 
 
 async function removeSelectedEigrpInterface() {
 async function removeSelectedEigrpInterface() {
   const id = state.selectedEigrpRow?.interfaces;
   const id = state.selectedEigrpRow?.interfaces;
   if (!id || !confirm('Are you sure you want to remove this EIGRP interface?')) return;
   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;
   state.selectedEigrpRow.interfaces = null;
   await loadDynamicRouting(false);
   await loadDynamicRouting(false);
 }
 }

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

@@ -620,6 +620,7 @@
             <div>
             <div>
               <h1 id="page-title" class="text-lg font-bold text-white tracking-tight">Dashboard Overview</h1>
               <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="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>
               <p id="page-subtitle" class="text-xs text-slate-400">DebTik - Debian Linux Router appliance management</p>
             </div>
             </div>
           </div>
           </div>
@@ -5032,6 +5033,15 @@
             </div>
             </div>
           </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 -->
           <!-- Tools Subtabs Navigation -->
           <div class="flex items-center gap-1 border-b border-slate-800 pb-0 overflow-x-auto">
           <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">
             <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_commit" ]]; then cp -p "$base/previous_commit" "$base/installed_commit"; fi
   if [[ -f "$base/previous_version" ]]; then cp -p "$base/previous_version" "$base/installed_version"; fi
   if [[ -f "$base/previous_version" ]]; then cp -p "$base/previous_version" "$base/installed_version"; fi
   if [[ -f "$base/previous/go.mod" ]]; then cp -p "$base/previous/go.mod" "$base/go.mod"; 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 start debtik.service
   systemctl is-active --quiet debtik.service
   systemctl is-active --quiet debtik.service
   if [[ -f "$base/previous/iptables.save" ]]; then iptables-restore -c < "$base/previous/iptables.save"; fi
   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 -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
 CGO_ENABLED=0 "$go_cmd" build -trimpath -o "$work/debtik-cli" ./cmd/debtik-cli
 install -d -m 0700 "$base/previous"
 install -d -m 0700 "$base/previous"
+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 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
 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
   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]);
   const ids=[...html.matchAll(/\bid="([^"]+)"/g)].map(match=>match[1]);
   assert.equal(new Set(ids).size,ids.length);
   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]
 [Service]
 Type=simple
 Type=simple
 User=root
 User=root
-ExecStart=/usr/local/bin/debtik -addr :80
+ExecStart=/usr/local/bin/debtik
 Restart=always
 Restart=always
 RestartSec=3s
 RestartSec=3s
 LimitNOFILE=65535
 LimitNOFILE=65535