瀏覽代碼

feat(backup): add full configuration backup and restore system (.zip export, safety fallbacks, API & UI, and /system backup CLI)

Proxmox Router 2 天之前
父節點
當前提交
52483bfcb5

+ 1 - 1
VERSION

@@ -1 +1 @@
-1.5.1
+1.6.0

二進制
bin/debtik


二進制
bin/debtik-cli


+ 5 - 0
cmd/server/main.go

@@ -17,6 +17,7 @@ import (
 
 	"debtik/internal/api"
 	"debtik/internal/auth"
+	"debtik/internal/backup"
 	"debtik/internal/bridge"
 	"debtik/internal/bwtest"
 	"debtik/internal/certs"
@@ -81,6 +82,8 @@ func main() {
 	proxyMgr := reverseproxy.NewManager("", certMgr)
 	defer proxyMgr.Stop()
 
+	backupMgr := backup.NewManager("", "", "", nil)
+
 	webHandler := web.Handler()
 
 	// Build Unified RouterOS CLI Executor
@@ -100,6 +103,7 @@ func main() {
 		servicesMgr,
 		certMgr,
 		proxyMgr,
+		backupMgr,
 	)
 
 	// Determine Telnet & SSH addresses from services config or flags
@@ -174,6 +178,7 @@ func main() {
 		servicesMgr,
 		certMgr,
 		proxyMgr,
+		backupMgr,
 		webHandler,
 	)
 

+ 114 - 0
docs/BackupRestore.md

@@ -0,0 +1,114 @@
+# DebTik Full Configuration Backup & Restore
+
+## Overview
+DebTik includes a native **Full Configuration Backup & Disaster Recovery** system designed to export and restore the complete router appliance state into a single, portable `.zip` archive.
+
+All router subsystem configurations, cryptographic credentials, network interfaces, and user databases are captured and restored atomically with automated fallback safety backups.
+
+---
+
+## Architecture & Archive Structure
+
+A DebTik backup archive is a standard ZIP archive containing:
+```
+manifest.json
+debtik/
+  metadata.json                  # Interface metadata & roles
+  services.json                  # Management services (SSH, Telnet, Web, etc.)
+  users.json                     # System user accounts & roles (0600)
+  reverse_proxy.json             # Reverse Proxy engine rules & upstreams
+  bwtest.json                    # Bandwidth test settings
+  bridge.json                    # Linux bridges & ports
+  dhcp_clients.json              # DHCP client WAN configurations
+  dhcp_server.json               # Local DHCP server pools & leases
+  vrrp.json                      # VRRP virtual router instances
+  ssh_host_ed25519_key           # Appliance SSH host private key (0600)
+  dns/settings.json              # DNS forwarders & static cache
+  routing/                       # Static routes, policy routing & BIRD dynamic routing
+  vpn/                           # WireGuard, AeroTUN, PPPoE, L2TP, IPsec tunnels & secrets
+  firewall/                      # Netfilter filter, NAT, mangle rules & GeoIP sets
+  certs/                         # SSL/TLS certificates, CA chains, and private keys (0600)
+network/
+  interfaces.cfg                 # Debian /etc/network/interfaces.d/ interfaces
+  vlans.cfg                      # Debian /etc/network/interfaces.d/ VLAN configurations
+```
+
+### Manifest Metadata (`manifest.json`)
+The archive contains a root `manifest.json` file recording:
+- `version`: Backup format specification version (`1.0`).
+- `debtik_version`: DebTik controller release version (e.g. `1.6.0`).
+- `hostname`: Router hostname at backup creation.
+- `created_at`: UTC RFC3339 timestamp.
+- `comment`: User-provided description or maintenance note.
+- `files`: File list with relative paths, sizes, permission modes, and SHA-256 checksums.
+
+---
+
+## Security & Safety Safeguards
+
+1. **Zip Slip Protection**:
+   Strict path canonicalization prevents directory traversal attacks. Entries containing `..`, absolute paths, or unapproved prefixes are rejected immediately before extraction.
+2. **Automated Pre-Restore Fallback Snapshot**:
+   Before any restore operation modifies active router files, the system automatically creates a timestamped safety backup in `/var/lib/debtik/backups/pre-restore-<hostname>-<timestamp>.zip`. The latest 5 pre-restore backups are retained automatically.
+3. **Permission Clamping**:
+   Sensitive files (SSH private keys, user databases, TLS private keys, PPP secrets) are guaranteed `0600` permissions upon extraction regardless of external file permissions.
+4. **Clean Synchronization**:
+   Old or obsolete configuration files in `/etc/debtik` are cleanly pruned during restoration to ensure the router exactly matches the state of the restored snapshot without orphaned artifacts.
+5. **Role-Based Access Control**:
+   Backup export, inspection, and restoration require full administrator privileges (`auth.RoleFull`).
+
+---
+
+## RouterOS CLI Commands
+
+### 1. Save Backup
+```text
+/system backup save [name=<filename.zip>] [comment="<note>"]
+```
+Example:
+```text
+/system backup save comment="Pre-upgrade snapshot"
+```
+
+### 2. Print Local Backups
+```text
+/system backup print
+```
+Example output:
+```text
+#   NAME                                       SIZE       CREATED              COMMENT
+0   pre-restore-DebTik-20261102-150221.zip     142.5 KB   2026-11-02 15:02:21  Automatic pre-restore safety fallback
+1   debtik-backup-DebTik-20261102-140000.zip   141.8 KB   2026-11-02 14:00:00  Pre-upgrade snapshot
+```
+
+### 3. Load / Restore Backup
+```text
+/system backup load name=<filename.zip>
+```
+Example:
+```text
+/system backup load name=debtik-backup-DebTik-20261102-140000.zip
+```
+Restores the configuration, creates a pre-restore fallback, and restarts the DebTik controller.
+
+---
+
+## REST API Endpoints
+
+| Method | Endpoint | Description |
+|---|---|---|
+| `GET` | `/api/system/backup/export?comment=...` | Download full backup as `.zip` attachment |
+| `POST` | `/api/system/backup/inspect` | Inspect uploaded archive and return `manifest.json` metadata |
+| `POST` | `/api/system/backup/restore` | Upload and restore archive, automatically taking safety backup and reloading DebTik |
+| `GET` | `/api/system/backups` | List local backups stored in `/var/lib/debtik/backups` |
+| `GET` | `/api/system/backups/{filename}` | Download a local backup file |
+| `POST` | `/api/system/backups/{filename}/restore` | Restore from a local backup file |
+
+---
+
+## Web UI
+
+Navigate to **Settings** (`#settings`) -> **Backup & Restore**:
+1. **Export Configuration Backup**: Enter an optional note and click **Download Backup (.zip)**.
+2. **Restore Configuration**: Drag & drop or select a `.zip` archive. The UI automatically inspects and verifies the archive metadata (hostname, version, creation date, file count) before displaying a confirmation modal. Click **Yes, Restore & Reload** to apply.
+3. **Stored Safety Fallback Backups**: View the history of pre-restore fallback snapshots, download them, or one-click restore them.

+ 17 - 1
internal/api/api.go

@@ -10,6 +10,7 @@ import (
 	"time"
 
 	"debtik/internal/auth"
+	"debtik/internal/backup"
 	"debtik/internal/bridge"
 	"debtik/internal/bwtest"
 	"debtik/internal/certs"
@@ -48,11 +49,15 @@ type Server struct {
 	servicesMgr   *services.Manager
 	certMgr       *certs.CertManager
 	proxyMgr      *reverseproxy.Manager
+	backupMgr     *backup.Manager
 	mux           *http.ServeMux
 	fsHandler     http.Handler
 }
 
-func NewServer(netMgr *network.Manager, persistMgr *network.PersistenceManager, metaMgr *network.MetadataManager, vpnMgr *vpn.Manager, fwMgr *firewall.FirewallManager, routingMgr *routing.RoutingManager, dynRoutingMgr *dynamicrouting.Manager, bridgeMgr *bridge.BridgeManager, toolsMgr *tools.Manager, dhcpMgr *dhcp.Manager, dhcpServerMgr *dhcp.ServerManager, vrrpMgr *vrrp.Manager, bwtestMgr *bwtest.Manager, dnsMgr *dns.Manager, authMgr *auth.Manager, servicesMgr *services.Manager, certMgr *certs.CertManager, proxyMgr *reverseproxy.Manager, fsHandler http.Handler) *Server {
+func NewServer(netMgr *network.Manager, persistMgr *network.PersistenceManager, metaMgr *network.MetadataManager, vpnMgr *vpn.Manager, fwMgr *firewall.FirewallManager, routingMgr *routing.RoutingManager, dynRoutingMgr *dynamicrouting.Manager, bridgeMgr *bridge.BridgeManager, toolsMgr *tools.Manager, dhcpMgr *dhcp.Manager, dhcpServerMgr *dhcp.ServerManager, vrrpMgr *vrrp.Manager, bwtestMgr *bwtest.Manager, dnsMgr *dns.Manager, authMgr *auth.Manager, servicesMgr *services.Manager, certMgr *certs.CertManager, proxyMgr *reverseproxy.Manager, backupMgr *backup.Manager, fsHandler http.Handler) *Server {
+	if backupMgr == nil {
+		backupMgr = backup.NewManager("", "", "", nil)
+	}
 	s := &Server{
 		netMgr:        netMgr,
 		persistMgr:    persistMgr,
@@ -72,6 +77,7 @@ func NewServer(netMgr *network.Manager, persistMgr *network.PersistenceManager,
 		servicesMgr:   servicesMgr,
 		certMgr:       certMgr,
 		proxyMgr:      proxyMgr,
+		backupMgr:     backupMgr,
 		mux:           http.NewServeMux(),
 		fsHandler:     fsHandler,
 	}
@@ -84,6 +90,16 @@ func (s *Server) registerRoutes() {
 	s.mux.HandleFunc("POST /api/update/config", s.handleUpdateConfig)
 	s.mux.HandleFunc("POST /api/update/check", s.handleUpdateCheck)
 	s.mux.HandleFunc("POST /api/update/{action}", s.handleUpdateAction)
+
+	// Backup & Restore Endpoints
+	s.mux.HandleFunc("GET /api/system/backup/export", s.handleBackupExport)
+	s.mux.HandleFunc("GET /api/system/backup/download", s.handleBackupExport)
+	s.mux.HandleFunc("POST /api/system/backup/inspect", s.handleBackupInspect)
+	s.mux.HandleFunc("POST /api/system/backup/restore", s.handleBackupRestore)
+	s.mux.HandleFunc("GET /api/system/backups", s.handleBackupList)
+	s.mux.HandleFunc("GET /api/system/backups/{filename}", s.handleBackupDownloadLocal)
+	s.mux.HandleFunc("POST /api/system/backups/{filename}/restore", s.handleBackupRestoreLocal)
+
 	// API Endpoints
 	s.mux.HandleFunc("GET /api/system/status", s.handleSystemStatus)
 

+ 220 - 0
internal/api/backup_handlers.go

@@ -0,0 +1,220 @@
+package api
+
+import (
+	"bytes"
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"strconv"
+	"strings"
+	"time"
+
+	"debtik/internal/auth"
+	"debtik/internal/backup"
+)
+
+func (s *Server) requireAdminRole(w http.ResponseWriter, r *http.Request) bool {
+	sess := getSessionFromContext(r.Context())
+	if sess != nil {
+		if sess.Role != auth.RoleFull {
+			writeError(w, http.StatusForbidden, "Administrator privileges required")
+			return false
+		}
+		return true
+	}
+	if s.authMgr != nil {
+		writeError(w, http.StatusUnauthorized, "Authentication required")
+		return false
+	}
+	return true
+}
+
+func (s *Server) handleBackupExport(w http.ResponseWriter, r *http.Request) {
+	if !s.requireAdminRole(w, r) {
+		return
+	}
+
+	if s.backupMgr == nil {
+		s.backupMgr = backup.NewManager("", "", "", nil)
+	}
+
+	comment := r.URL.Query().Get("comment")
+	zipBytes, manifest, err := s.backupMgr.CreateBackup(comment)
+	if err != nil {
+		writeError(w, http.StatusInternalServerError, "Failed to create backup: "+err.Error())
+		return
+	}
+
+	filename := fmt.Sprintf("debtik-backup-%s-%s.zip", manifest.Hostname, manifest.CreatedAt.Format("20060102-150405"))
+	w.Header().Set("Content-Type", "application/zip")
+	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
+	w.Header().Set("Content-Length", strconv.Itoa(len(zipBytes)))
+	w.WriteHeader(http.StatusOK)
+	_, _ = w.Write(zipBytes)
+}
+
+func (s *Server) handleBackupInspect(w http.ResponseWriter, r *http.Request) {
+	if !s.requireAdminRole(w, r) {
+		return
+	}
+
+	if s.backupMgr == nil {
+		s.backupMgr = backup.NewManager("", "", "", nil)
+	}
+
+	buf, err := readUploadedBackup(r)
+	if err != nil {
+		writeError(w, http.StatusBadRequest, err.Error())
+		return
+	}
+
+	manifest, err := s.backupMgr.InspectBackup(bytes.NewReader(buf), int64(len(buf)))
+	if err != nil {
+		writeError(w, http.StatusBadRequest, "Invalid backup archive: "+err.Error())
+		return
+	}
+
+	writeJSON(w, http.StatusOK, map[string]any{
+		"success":  true,
+		"manifest": manifest,
+	})
+}
+
+func (s *Server) handleBackupRestore(w http.ResponseWriter, r *http.Request) {
+	if !s.requireAdminRole(w, r) {
+		return
+	}
+
+	if s.backupMgr == nil {
+		s.backupMgr = backup.NewManager("", "", "", nil)
+	}
+
+	buf, err := readUploadedBackup(r)
+	if err != nil {
+		writeError(w, http.StatusBadRequest, err.Error())
+		return
+	}
+
+	res, err := s.backupMgr.RestoreBackup(bytes.NewReader(buf), int64(len(buf)))
+	if err != nil {
+		writeError(w, http.StatusBadRequest, "Restore failed: "+err.Error())
+		return
+	}
+
+	// Trigger asynchronous restart so DebTik reloads cleanly
+	s.backupMgr.TriggerRestart(1 * time.Second)
+
+	writeJSON(w, http.StatusOK, res)
+}
+
+func (s *Server) handleBackupList(w http.ResponseWriter, r *http.Request) {
+	if !s.requireAdminRole(w, r) {
+		return
+	}
+
+	if s.backupMgr == nil {
+		s.backupMgr = backup.NewManager("", "", "", nil)
+	}
+
+	list, err := s.backupMgr.ListLocalBackups()
+	if err != nil {
+		writeError(w, http.StatusInternalServerError, "Failed to list backups: "+err.Error())
+		return
+	}
+
+	writeJSON(w, http.StatusOK, list)
+}
+
+func (s *Server) handleBackupDownloadLocal(w http.ResponseWriter, r *http.Request) {
+	if !s.requireAdminRole(w, r) {
+		return
+	}
+
+	if s.backupMgr == nil {
+		s.backupMgr = backup.NewManager("", "", "", nil)
+	}
+
+	filename := r.PathValue("filename")
+	fullPath, err := s.backupMgr.GetLocalBackup(filename)
+	if err != nil {
+		writeError(w, http.StatusNotFound, err.Error())
+		return
+	}
+
+	w.Header().Set("Content-Type", "application/zip")
+	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
+	http.ServeFile(w, r, fullPath)
+}
+
+func (s *Server) handleBackupRestoreLocal(w http.ResponseWriter, r *http.Request) {
+	if !s.requireAdminRole(w, r) {
+		return
+	}
+
+	if s.backupMgr == nil {
+		s.backupMgr = backup.NewManager("", "", "", nil)
+	}
+
+	filename := r.PathValue("filename")
+	fullPath, err := s.backupMgr.GetLocalBackup(filename)
+	if err != nil {
+		writeError(w, http.StatusNotFound, err.Error())
+		return
+	}
+
+	f, err := os.Open(fullPath)
+	if err != nil {
+		writeError(w, http.StatusInternalServerError, "Cannot open backup file: "+err.Error())
+		return
+	}
+	defer f.Close()
+
+	fi, err := f.Stat()
+	if err != nil {
+		writeError(w, http.StatusInternalServerError, "Cannot stat backup file: "+err.Error())
+		return
+	}
+
+	res, err := s.backupMgr.RestoreBackup(f, fi.Size())
+	if err != nil {
+		writeError(w, http.StatusBadRequest, "Restore failed: "+err.Error())
+		return
+	}
+
+	s.backupMgr.TriggerRestart(1 * time.Second)
+	writeJSON(w, http.StatusOK, res)
+}
+
+func readUploadedBackup(r *http.Request) ([]byte, error) {
+	contentType := r.Header.Get("Content-Type")
+
+	// Multipart form upload
+	if strings.Contains(contentType, "multipart/form-data") {
+		if err := r.ParseMultipartForm(32 << 20); err != nil {
+			return nil, fmt.Errorf("failed parsing upload: %w", err)
+		}
+		var file io.Reader
+		f, _, err := r.FormFile("backup")
+		if err == nil {
+			defer f.Close()
+			file = f
+		} else {
+			f, _, err2 := r.FormFile("file")
+			if err2 == nil {
+				defer f.Close()
+				file = f
+			} else {
+				return nil, fmt.Errorf("no 'backup' or 'file' uploaded")
+			}
+		}
+		return io.ReadAll(file)
+	}
+
+	// Raw binary zip stream
+	if r.Body == nil {
+		return nil, fmt.Errorf("empty request body")
+	}
+	defer r.Body.Close()
+	return io.ReadAll(r.Body)
+}

+ 156 - 0
internal/api/backup_handlers_test.go

@@ -0,0 +1,156 @@
+package api
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"io"
+	"mime/multipart"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"debtik/internal/auth"
+	"debtik/internal/backup"
+)
+
+func TestBackupAPIHandlers(t *testing.T) {
+	tempDir := t.TempDir()
+	debtikDir := filepath.Join(tempDir, "debtik")
+	netDir := filepath.Join(tempDir, "network")
+	storeDir := filepath.Join(tempDir, "backups")
+
+	_ = os.MkdirAll(debtikDir, 0755)
+	_ = os.MkdirAll(netDir, 0755)
+
+	_ = os.WriteFile(filepath.Join(debtikDir, "metadata.json"), []byte(`{"router": "test"}`), 0644)
+	_ = os.WriteFile(filepath.Join(netDir, "interfaces.cfg"), []byte(`auto lo`), 0644)
+
+	backupMgr := backup.NewManager(debtikDir, netDir, storeDir, nil)
+
+	s := &Server{
+		backupMgr: backupMgr,
+		mux:       http.NewServeMux(),
+		fsHandler: http.NotFoundHandler(),
+	}
+	s.registerRoutes()
+
+	// 1. Test Backup Export (Download)
+	req := httptest.NewRequest(http.MethodGet, "/api/system/backup/export?comment=APITest", nil)
+	// Context with full admin role
+	ctx := context.WithValue(req.Context(), sessionContextKey, &auth.Session{
+		Username: "admin",
+		Role:     auth.RoleFull,
+	})
+	req = req.WithContext(ctx)
+	rec := httptest.NewRecorder()
+
+	s.mux.ServeHTTP(rec, req)
+
+	if rec.Code != http.StatusOK {
+		t.Fatalf("Expected 200 OK from backup export, got %d: %s", rec.Code, rec.Body.String())
+	}
+	if rec.Header().Get("Content-Type") != "application/zip" {
+		t.Errorf("Expected application/zip Content-Type, got %s", rec.Header().Get("Content-Type"))
+	}
+	zipBytes := rec.Body.Bytes()
+	if len(zipBytes) == 0 {
+		t.Fatal("Exported zip is empty")
+	}
+
+	// 2. Test Backup Inspect via multipart upload
+	bodyBuf := new(bytes.Buffer)
+	mpWriter := multipart.NewWriter(bodyBuf)
+	part, err := mpWriter.CreateFormFile("backup", "test-backup.zip")
+	if err != nil {
+		t.Fatal(err)
+	}
+	_, _ = io.Copy(part, bytes.NewReader(zipBytes))
+	_ = mpWriter.Close()
+
+	inspectReq := httptest.NewRequest(http.MethodPost, "/api/system/backup/inspect", bodyBuf)
+	inspectReq.Header.Set("Content-Type", mpWriter.FormDataContentType())
+	inspectReq = inspectReq.WithContext(ctx)
+	inspectRec := httptest.NewRecorder()
+
+	s.mux.ServeHTTP(inspectRec, inspectReq)
+
+	if inspectRec.Code != http.StatusOK {
+		t.Fatalf("Expected 200 OK from backup inspect, got %d: %s", inspectRec.Code, inspectRec.Body.String())
+	}
+
+	var inspectResp struct {
+		Success  bool             `json:"success"`
+		Manifest *backup.Manifest `json:"manifest"`
+	}
+	if err := json.Unmarshal(inspectRec.Body.Bytes(), &inspectResp); err != nil {
+		t.Fatalf("Failed unmarshaling inspect response: %v", err)
+	}
+	if !inspectResp.Success || inspectResp.Manifest == nil {
+		t.Fatal("Expected success and manifest in inspect response")
+	}
+	if inspectResp.Manifest.Comment != "APITest" {
+		t.Errorf("Manifest comment mismatch: %s != APITest", inspectResp.Manifest.Comment)
+	}
+
+	// 3. Test Backup Restore
+	restoreBuf := new(bytes.Buffer)
+	mpWriter2 := multipart.NewWriter(restoreBuf)
+	part2, _ := mpWriter2.CreateFormFile("backup", "restore-backup.zip")
+	_, _ = io.Copy(part2, bytes.NewReader(zipBytes))
+	_ = mpWriter2.Close()
+
+	restoreReq := httptest.NewRequest(http.MethodPost, "/api/system/backup/restore", restoreBuf)
+	restoreReq.Header.Set("Content-Type", mpWriter2.FormDataContentType())
+	restoreReq = restoreReq.WithContext(ctx)
+	restoreRec := httptest.NewRecorder()
+
+	s.mux.ServeHTTP(restoreRec, restoreReq)
+
+	if restoreRec.Code != http.StatusOK {
+		t.Fatalf("Expected 200 OK from backup restore, got %d: %s", restoreRec.Code, restoreRec.Body.String())
+	}
+
+	var restoreRes backup.RestoreResult
+	if err := json.Unmarshal(restoreRec.Body.Bytes(), &restoreRes); err != nil {
+		t.Fatalf("Failed unmarshaling restore response: %v", err)
+	}
+	if !restoreRes.Success {
+		t.Error("Expected restore success true")
+	}
+
+	// 4. Test Backup List (should contain pre-restore safety backup)
+	listReq := httptest.NewRequest(http.MethodGet, "/api/system/backups", nil)
+	listReq = listReq.WithContext(ctx)
+	listRec := httptest.NewRecorder()
+
+	s.mux.ServeHTTP(listRec, listReq)
+
+	if listRec.Code != http.StatusOK {
+		t.Fatalf("Expected 200 OK from backup list, got %d: %s", listRec.Code, listRec.Body.String())
+	}
+	var backupsList []backup.BackupInfo
+	if err := json.Unmarshal(listRec.Body.Bytes(), &backupsList); err != nil {
+		t.Fatalf("Failed unmarshaling backup list: %v", err)
+	}
+	if len(backupsList) == 0 {
+		t.Error("Expected at least 1 backup in list")
+	}
+
+	// 5. Test Forbidden for Read-Only user
+	roCtx := context.WithValue(req.Context(), sessionContextKey, &auth.Session{
+		Username: "readonly-user",
+		Role:     auth.RoleRead,
+	})
+	roReq := httptest.NewRequest(http.MethodGet, "/api/system/backup/export", nil)
+	roReq = roReq.WithContext(roCtx)
+	roRec := httptest.NewRecorder()
+
+	s.mux.ServeHTTP(roRec, roReq)
+
+	if roRec.Code != http.StatusForbidden {
+		t.Errorf("Expected 403 Forbidden for read-only user, got %d", roRec.Code)
+	}
+}

+ 245 - 0
internal/backup/backup_test.go

@@ -0,0 +1,245 @@
+package backup
+
+import (
+	"archive/zip"
+	"bytes"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+func TestCreateAndInspectBackup(t *testing.T) {
+	tempDir := t.TempDir()
+	debtikDir := filepath.Join(tempDir, "debtik")
+	netDir := filepath.Join(tempDir, "network")
+	storeDir := filepath.Join(tempDir, "backups")
+
+	// Create test structure
+	if err := os.MkdirAll(filepath.Join(debtikDir, "certs"), 0755); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.MkdirAll(netDir, 0755); err != nil {
+		t.Fatal(err)
+	}
+
+	metadataContent := []byte(`{"version": 1, "description": "test"}`)
+	keyContent := []byte(`super-secret-ssh-key`)
+	certContent := []byte(`fake-cert-data`)
+	ifacesContent := []byte(`auto eth0` + "\n" + `iface eth0 inet dhcp`)
+
+	if err := os.WriteFile(filepath.Join(debtikDir, "metadata.json"), metadataContent, 0644); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(debtikDir, "ssh_host_ed25519_key"), keyContent, 0600); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(debtikDir, "certs", "server.crt"), certContent, 0644); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(netDir, "interfaces.cfg"), ifacesContent, 0644); err != nil {
+		t.Fatal(err)
+	}
+
+	mgr := NewManager(debtikDir, netDir, storeDir, nil)
+
+	// Create backup
+	zipBytes, manifest, err := mgr.CreateBackup("Unit test backup")
+	if err != nil {
+		t.Fatalf("CreateBackup failed: %v", err)
+	}
+
+	if len(zipBytes) == 0 {
+		t.Fatal("Expected non-empty zip bytes")
+	}
+	if manifest.Comment != "Unit test backup" {
+		t.Errorf("Unexpected manifest comment: %s", manifest.Comment)
+	}
+	if len(manifest.Files) != 4 {
+		t.Fatalf("Expected 4 files in manifest, got %d", len(manifest.Files))
+	}
+
+	// Inspect backup
+	r := bytes.NewReader(zipBytes)
+	inspected, err := mgr.InspectBackup(r, int64(len(zipBytes)))
+	if err != nil {
+		t.Fatalf("InspectBackup failed: %v", err)
+	}
+
+	if inspected.Hostname != manifest.Hostname {
+		t.Errorf("Hostname mismatch: %s != %s", inspected.Hostname, manifest.Hostname)
+	}
+	if len(inspected.Files) != 4 {
+		t.Errorf("Expected 4 files in inspected manifest, got %d", len(inspected.Files))
+	}
+}
+
+func TestRestoreBackup(t *testing.T) {
+	tempDir := t.TempDir()
+	debtikDir := filepath.Join(tempDir, "debtik")
+	netDir := filepath.Join(tempDir, "network")
+	storeDir := filepath.Join(tempDir, "backups")
+
+	_ = os.MkdirAll(debtikDir, 0755)
+	_ = os.MkdirAll(netDir, 0755)
+
+	origMetadata := []byte(`{"version": "orig"}`)
+	origKey := []byte(`private-key-123`)
+	origIfaces := []byte(`auto eth0`)
+
+	_ = os.WriteFile(filepath.Join(debtikDir, "metadata.json"), origMetadata, 0644)
+	_ = os.WriteFile(filepath.Join(debtikDir, "ssh_host_ed25519_key"), origKey, 0600)
+	_ = os.WriteFile(filepath.Join(netDir, "interfaces.cfg"), origIfaces, 0644)
+
+	restarted := false
+	restartHook := func() error {
+		restarted = true
+		return nil
+	}
+
+	mgr := NewManager(debtikDir, netDir, storeDir, restartHook)
+	_ = restarted
+
+	// 1. Create backup
+	zipBytes, _, err := mgr.CreateBackup("Pre-modify backup")
+	if err != nil {
+		t.Fatalf("CreateBackup failed: %v", err)
+	}
+
+	// 2. Modify live files
+	_ = os.WriteFile(filepath.Join(debtikDir, "metadata.json"), []byte(`{"version": "modified"}`), 0644)
+	_ = os.WriteFile(filepath.Join(debtikDir, "rogue.json"), []byte(`rogue data`), 0644)
+	_ = os.WriteFile(filepath.Join(netDir, "interfaces.cfg"), []byte(`modified eth0`), 0644)
+
+	// 3. Restore backup
+	r := bytes.NewReader(zipBytes)
+	res, err := mgr.RestoreBackup(r, int64(len(zipBytes)))
+	if err != nil {
+		t.Fatalf("RestoreBackup failed: %v", err)
+	}
+
+	if !res.Success {
+		t.Fatal("Expected restore success true")
+	}
+
+	// 4. Verify original metadata was restored
+	restoredMeta, err := os.ReadFile(filepath.Join(debtikDir, "metadata.json"))
+	if err != nil {
+		t.Fatalf("Failed reading restored metadata: %v", err)
+	}
+	if string(restoredMeta) != string(origMetadata) {
+		t.Errorf("Metadata mismatch: got %q, want %q", string(restoredMeta), string(origMetadata))
+	}
+
+	// 5. Verify rogue file was removed during sync
+	if _, err := os.Stat(filepath.Join(debtikDir, "rogue.json")); !os.IsNotExist(err) {
+		t.Error("Expected rogue.json to be cleaned up after restore")
+	}
+
+	// 6. Verify key permissions are 0600
+	keyInfo, err := os.Stat(filepath.Join(debtikDir, "ssh_host_ed25519_key"))
+	if err != nil {
+		t.Fatalf("Failed reading restored key: %v", err)
+	}
+	if keyInfo.Mode().Perm() != 0600 {
+		t.Errorf("Expected 0600 perm on private key, got %v", keyInfo.Mode().Perm())
+	}
+
+	// 7. Verify pre-restore fallback backup exists in storeDir
+	if res.SafetyBackup == "" {
+		t.Error("Expected non-empty SafetyBackup path")
+	}
+	if _, err := os.Stat(res.SafetyBackup); err != nil {
+		t.Errorf("Safety backup file does not exist: %v", err)
+	}
+}
+
+func TestZipSlipPrevention(t *testing.T) {
+	tempDir := t.TempDir()
+	debtikDir := filepath.Join(tempDir, "debtik")
+	netDir := filepath.Join(tempDir, "network")
+	storeDir := filepath.Join(tempDir, "backups")
+
+	mgr := NewManager(debtikDir, netDir, storeDir, nil)
+
+	// Create malicious zip with traversal path
+	buf := new(bytes.Buffer)
+	zw := zip.NewWriter(buf)
+
+	// Valid manifest
+	mf, err := zw.Create("manifest.json")
+	if err != nil {
+		t.Fatal(err)
+	}
+	_, _ = mf.Write([]byte(`{"version": "1.0", "files": []}`))
+
+	// Malicious traversal file
+	malicious, err := zw.Create("../../../etc/evil.conf")
+	if err != nil {
+		t.Fatal(err)
+	}
+	_, _ = malicious.Write([]byte(`malicious payload`))
+	_ = zw.Close()
+
+	r := bytes.NewReader(buf.Bytes())
+	_, err = mgr.InspectBackup(r, int64(buf.Len()))
+	if err == nil {
+		t.Fatal("Expected InspectBackup to fail on Zip Slip traversal entry")
+	}
+	if !strings.Contains(err.Error(), "illegal entry path") {
+		t.Errorf("Expected illegal entry path error, got %v", err)
+	}
+
+	// Attempt restore
+	_, err = mgr.RestoreBackup(r, int64(buf.Len()))
+	if err == nil {
+		t.Fatal("Expected RestoreBackup to fail on Zip Slip entry")
+	}
+}
+
+func TestLocalBackupsAndPruning(t *testing.T) {
+	tempDir := t.TempDir()
+	debtikDir := filepath.Join(tempDir, "debtik")
+	netDir := filepath.Join(tempDir, "network")
+	storeDir := filepath.Join(tempDir, "backups")
+
+	_ = os.MkdirAll(debtikDir, 0755)
+	_ = os.WriteFile(filepath.Join(debtikDir, "dummy.json"), []byte("{}"), 0644)
+
+	mgr := NewManager(debtikDir, netDir, storeDir, nil)
+
+	// Create 7 pre-restore backups
+	for i := 0; i < 7; i++ {
+		_, err := mgr.CreatePreRestoreBackup()
+		if err != nil {
+			t.Fatalf("Failed creating pre-restore backup %d: %v", i, err)
+		}
+	}
+
+	// List backups
+	list, err := mgr.ListLocalBackups()
+	if err != nil {
+		t.Fatalf("ListLocalBackups failed: %v", err)
+	}
+
+	// Pre-restore backups should be pruned to keep 5
+	if len(list) != 5 {
+		t.Errorf("Expected 5 backups after pruning, got %d", len(list))
+	}
+
+	// Test GetLocalBackup
+	savedFile := list[0].Filename
+	path, err := mgr.GetLocalBackup(savedFile)
+	if err != nil {
+		t.Fatalf("GetLocalBackup failed: %v", err)
+	}
+	if _, err := os.Stat(path); err != nil {
+		t.Fatalf("File does not exist: %v", err)
+	}
+
+	// Test GetLocalBackup traversal reject
+	_, err = mgr.GetLocalBackup("../../evil.zip")
+	if err == nil {
+		t.Error("Expected error on traversal filename")
+	}
+}

+ 678 - 0
internal/backup/manager.go

@@ -0,0 +1,678 @@
+package backup
+
+import (
+	"archive/zip"
+	"bytes"
+	"crypto/sha256"
+	"encoding/hex"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"log"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"sort"
+	"strings"
+	"sync"
+	"time"
+
+	release "debtik"
+)
+
+const (
+	DefaultDebtikDir   = "/etc/debtik"
+	DefaultNetworkDir  = "/etc/network/interfaces.d"
+	DefaultBackupStore = "/var/lib/debtik/backups"
+
+	MaxArchiveEntries = 5000
+	MaxArchiveBytes   = 100 * 1024 * 1024 // 100 MB max uncompressed
+	BackupFormatVer   = "1.0"
+)
+
+// Manager handles backup generation, inspection, restoration, and local retention.
+type Manager struct {
+	debtikDir   string
+	networkDir  string
+	backupStore string
+	restartFunc func() error
+	mu          sync.Mutex
+}
+
+// NewManager creates a new backup Manager with the provided or default directories.
+func NewManager(debtikDir, networkDir, backupStore string, restartFunc func() error) *Manager {
+	if debtikDir == "" {
+		debtikDir = DefaultDebtikDir
+	}
+	if networkDir == "" {
+		networkDir = DefaultNetworkDir
+	}
+	if backupStore == "" {
+		backupStore = DefaultBackupStore
+	}
+	return &Manager{
+		debtikDir:   filepath.Clean(debtikDir),
+		networkDir:  filepath.Clean(networkDir),
+		backupStore: filepath.Clean(backupStore),
+		restartFunc: restartFunc,
+	}
+}
+
+// CreateBackup generates an in-memory zip archive containing all DebTik configurations.
+func (m *Manager) CreateBackup(comment string) ([]byte, *Manifest, error) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	return m.createBackupLocked(comment)
+}
+
+func (m *Manager) createBackupLocked(comment string) ([]byte, *Manifest, error) {
+	hostname, _ := os.Hostname()
+	if hostname == "" {
+		hostname = "DebTik"
+	}
+
+	manifest := &Manifest{
+		Version:       BackupFormatVer,
+		DebTikVersion: release.Version(),
+		Hostname:      hostname,
+		CreatedAt:     time.Now().UTC(),
+		Comment:       strings.TrimSpace(comment),
+		Files:         make([]FileInfo, 0),
+	}
+
+	buf := new(bytes.Buffer)
+	zw := zip.NewWriter(buf)
+
+	// 1. Collect /etc/debtik configuration tree
+	if err := m.archiveDebtikTree(zw, manifest); err != nil {
+		_ = zw.Close()
+		return nil, nil, fmt.Errorf("failed archiving debtik config tree: %w", err)
+	}
+
+	// 2. Collect /etc/network/interfaces.d configuration files
+	if err := m.archiveNetworkFiles(zw, manifest); err != nil {
+		_ = zw.Close()
+		return nil, nil, fmt.Errorf("failed archiving network config files: %w", err)
+	}
+
+	// 3. Write manifest.json
+	manifestBytes, err := json.MarshalIndent(manifest, "", "  ")
+	if err != nil {
+		_ = zw.Close()
+		return nil, nil, fmt.Errorf("failed serializing manifest: %w", err)
+	}
+
+	manifestHeader := &zip.FileHeader{
+		Name:     "manifest.json",
+		Method:   zip.Deflate,
+		Modified: manifest.CreatedAt,
+	}
+	manifestHeader.SetMode(0644)
+	w, err := zw.CreateHeader(manifestHeader)
+	if err != nil {
+		_ = zw.Close()
+		return nil, nil, fmt.Errorf("failed creating manifest in zip: %w", err)
+	}
+	if _, err := w.Write(manifestBytes); err != nil {
+		_ = zw.Close()
+		return nil, nil, fmt.Errorf("failed writing manifest to zip: %w", err)
+	}
+
+	if err := zw.Close(); err != nil {
+		return nil, nil, fmt.Errorf("failed finalizing zip archive: %w", err)
+	}
+
+	return buf.Bytes(), manifest, nil
+}
+
+func (m *Manager) archiveDebtikTree(zw *zip.Writer, manifest *Manifest) error {
+	if _, err := os.Stat(m.debtikDir); os.IsNotExist(err) {
+		return nil
+	}
+
+	return filepath.Walk(m.debtikDir, func(path string, info os.FileInfo, err error) error {
+		if err != nil {
+			return err
+		}
+		if info.IsDir() {
+			return nil
+		}
+		// Skip sockets, named pipes, etc.
+		if !info.Mode().IsRegular() {
+			return nil
+		}
+
+		rel, err := filepath.Rel(m.debtikDir, path)
+		if err != nil {
+			return err
+		}
+		rel = filepath.ToSlash(rel)
+		zipPath := "debtik/" + rel
+
+		data, err := os.ReadFile(path)
+		if err != nil {
+			return fmt.Errorf("cannot read file %s: %w", path, err)
+		}
+
+		hash := sha256.Sum256(data)
+		hashStr := hex.EncodeToString(hash[:])
+
+		hdr := &zip.FileHeader{
+			Name:     zipPath,
+			Method:   zip.Deflate,
+			Modified: info.ModTime().UTC(),
+		}
+		hdr.SetMode(info.Mode().Perm())
+
+		w, err := zw.CreateHeader(hdr)
+		if err != nil {
+			return err
+		}
+		if _, err := w.Write(data); err != nil {
+			return err
+		}
+
+		manifest.Files = append(manifest.Files, FileInfo{
+			Path:   zipPath,
+			Size:   int64(len(data)),
+			Mode:   uint32(info.Mode().Perm()),
+			SHA256: hashStr,
+		})
+		return nil
+	})
+}
+
+func (m *Manager) archiveNetworkFiles(zw *zip.Writer, manifest *Manifest) error {
+	if _, err := os.Stat(m.networkDir); os.IsNotExist(err) {
+		return nil
+	}
+
+	entries, err := os.ReadDir(m.networkDir)
+	if err != nil {
+		return nil
+	}
+
+	for _, entry := range entries {
+		if entry.IsDir() {
+			continue
+		}
+		name := entry.Name()
+		// Only archive .cfg files (e.g. interfaces.cfg, vlans.cfg)
+		if !strings.HasSuffix(name, ".cfg") {
+			continue
+		}
+
+		filePath := filepath.Join(m.networkDir, name)
+		info, err := entry.Info()
+		if err != nil || !info.Mode().IsRegular() {
+			continue
+		}
+
+		data, err := os.ReadFile(filePath)
+		if err != nil {
+			continue
+		}
+
+		hash := sha256.Sum256(data)
+		hashStr := hex.EncodeToString(hash[:])
+
+		zipPath := "network/" + name
+		hdr := &zip.FileHeader{
+			Name:     zipPath,
+			Method:   zip.Deflate,
+			Modified: info.ModTime().UTC(),
+		}
+		hdr.SetMode(info.Mode().Perm())
+
+		w, err := zw.CreateHeader(hdr)
+		if err != nil {
+			return err
+		}
+		if _, err := w.Write(data); err != nil {
+			return err
+		}
+
+		manifest.Files = append(manifest.Files, FileInfo{
+			Path:   zipPath,
+			Size:   int64(len(data)),
+			Mode:   uint32(info.Mode().Perm()),
+			SHA256: hashStr,
+		})
+	}
+
+	return nil
+}
+
+// SaveBackup creates a backup and saves it to the local backupStore directory.
+func (m *Manager) SaveBackup(name, comment string) (string, *Manifest, error) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	return m.saveBackupLocked(name, comment)
+}
+
+func (m *Manager) saveBackupLocked(name, comment string) (string, *Manifest, error) {
+	if err := os.MkdirAll(m.backupStore, 0700); err != nil {
+		return "", nil, fmt.Errorf("failed creating backup storage directory: %w", err)
+	}
+
+	data, manifest, err := m.createBackupLocked(comment)
+	if err != nil {
+		return "", nil, err
+	}
+
+	if name == "" {
+		name = fmt.Sprintf("debtik-backup-%s-%s.zip", sanitizeFilename(manifest.Hostname), manifest.CreatedAt.Format("20060102-150405"))
+	}
+	if !strings.HasSuffix(name, ".zip") {
+		name += ".zip"
+	}
+	name = sanitizeFilename(name)
+	targetPath := filepath.Join(m.backupStore, name)
+
+	if err := os.WriteFile(targetPath, data, 0600); err != nil {
+		return "", nil, fmt.Errorf("failed writing backup file: %w", err)
+	}
+
+	return targetPath, manifest, nil
+}
+
+// CreatePreRestoreBackup creates a safety backup before applying any restored configurations.
+// It keeps the latest 5 pre-restore backups, pruning older ones.
+func (m *Manager) CreatePreRestoreBackup() (string, error) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	return m.createPreRestoreBackupLocked()
+}
+
+func (m *Manager) createPreRestoreBackupLocked() (string, error) {
+	if err := os.MkdirAll(m.backupStore, 0700); err != nil {
+		return "", err
+	}
+
+	hostname, _ := os.Hostname()
+	if hostname == "" {
+		hostname = "DebTik"
+	}
+	fileName := fmt.Sprintf("pre-restore-%s-%s-%d.zip", sanitizeFilename(hostname), time.Now().UTC().Format("20060102-150405"), time.Now().UnixNano()%1000000)
+	savedPath, _, err := m.saveBackupLocked(fileName, "Automatic pre-restore safety fallback")
+	if err != nil {
+		return "", err
+	}
+
+	// Prune older pre-restore backups, keeping latest 5
+	m.prunePreRestoreBackups(5)
+
+	return savedPath, nil
+}
+
+func (m *Manager) prunePreRestoreBackups(keep int) {
+	entries, err := os.ReadDir(m.backupStore)
+	if err != nil {
+		return
+	}
+
+	type fileItem struct {
+		name    string
+		modTime time.Time
+	}
+	var preRestores []fileItem
+
+	for _, e := range entries {
+		if e.IsDir() || !strings.HasPrefix(e.Name(), "pre-restore-") || !strings.HasSuffix(e.Name(), ".zip") {
+			continue
+		}
+		info, err := e.Info()
+		if err == nil {
+			preRestores = append(preRestores, fileItem{name: e.Name(), modTime: info.ModTime()})
+		}
+	}
+
+	if len(preRestores) <= keep {
+		return
+	}
+
+	sort.Slice(preRestores, func(i, j int) bool {
+		return preRestores[i].modTime.After(preRestores[j].modTime)
+	})
+
+	for i := keep; i < len(preRestores); i++ {
+		_ = os.Remove(filepath.Join(m.backupStore, preRestores[i].name))
+	}
+}
+
+// InspectBackup verifies the integrity of an archive and extracts its manifest without restoring.
+func (m *Manager) InspectBackup(r io.ReaderAt, size int64) (*Manifest, error) {
+	zr, err := zip.NewReader(r, size)
+	if err != nil {
+		return nil, fmt.Errorf("invalid zip archive: %w", err)
+	}
+
+	if len(zr.File) > MaxArchiveEntries {
+		return nil, fmt.Errorf("archive contains too many entries (%d > %d)", len(zr.File), MaxArchiveEntries)
+	}
+
+	var totalUncompressed int64
+	var manifestFile *zip.File
+
+	for _, f := range zr.File {
+		cleanName := filepath.Clean(f.Name)
+
+		// Prevent Zip Slip / Path Traversal
+		if strings.HasPrefix(cleanName, "..") || strings.HasPrefix(cleanName, "/") || strings.HasPrefix(cleanName, "\\") {
+			return nil, fmt.Errorf("illegal entry path: %s", f.Name)
+		}
+		if !strings.HasPrefix(cleanName, "debtik/") &&
+			!strings.HasPrefix(cleanName, "network/") &&
+			cleanName != "manifest.json" &&
+			cleanName != "debtik" &&
+			cleanName != "network" {
+			return nil, fmt.Errorf("unrecognized file outside allowed prefixes: %s", cleanName)
+		}
+
+		totalUncompressed += int64(f.UncompressedSize64)
+		if totalUncompressed > MaxArchiveBytes {
+			return nil, fmt.Errorf("archive uncompressed size exceeds maximum allowed (%d bytes)", MaxArchiveBytes)
+		}
+
+		if cleanName == "manifest.json" {
+			manifestFile = f
+		}
+	}
+
+	if manifestFile == nil {
+		return nil, errors.New("archive is missing manifest.json")
+	}
+
+	rc, err := manifestFile.Open()
+	if err != nil {
+		return nil, fmt.Errorf("cannot read manifest.json: %w", err)
+	}
+	defer rc.Close()
+
+	var manifest Manifest
+	if err := json.NewDecoder(rc).Decode(&manifest); err != nil {
+		return nil, fmt.Errorf("invalid manifest.json format: %w", err)
+	}
+
+	if manifest.Version == "" {
+		return nil, errors.New("manifest version is missing")
+	}
+
+	return &manifest, nil
+}
+
+// RestoreBackup inspects, creates a pre-restore safety copy, stages, and restores configurations.
+func (m *Manager) RestoreBackup(r io.ReaderAt, size int64) (*RestoreResult, error) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	// 1. Inspect and validate zip structure
+	manifest, err := m.InspectBackup(r, size)
+	if err != nil {
+		return nil, fmt.Errorf("backup validation failed: %w", err)
+	}
+
+	// 2. Create pre-restore safety backup of active config
+	safetyBackupPath, err := m.createPreRestoreBackupLocked()
+	if err != nil {
+		log.Printf("[Backup] Warning: failed creating pre-restore safety backup: %v", err)
+	} else {
+		log.Printf("[Backup] Created pre-restore fallback backup: %s", safetyBackupPath)
+	}
+
+	// 3. Extract to staging directory
+	stagingDir, err := os.MkdirTemp("", "debtik-restore-stage-*")
+	if err != nil {
+		return nil, fmt.Errorf("failed creating staging directory: %w", err)
+	}
+	defer os.RemoveAll(stagingDir)
+
+	zr, err := zip.NewReader(r, size)
+	if err != nil {
+		return nil, err
+	}
+
+	for _, f := range zr.File {
+		cleanName := filepath.Clean(f.Name)
+		if cleanName == "manifest.json" {
+			continue
+		}
+		if f.FileInfo().IsDir() {
+			continue
+		}
+
+		destPath := filepath.Join(stagingDir, cleanName)
+		// Ensure destination path strictly within stagingDir
+		if !strings.HasPrefix(destPath, stagingDir+string(filepath.Separator)) {
+			return nil, fmt.Errorf("illegal path traversal detected in %s", f.Name)
+		}
+
+		if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
+			return nil, fmt.Errorf("failed creating staging subdir: %w", err)
+		}
+
+		rc, err := f.Open()
+		if err != nil {
+			return nil, fmt.Errorf("failed opening entry %s: %w", f.Name, err)
+		}
+
+		content, err := io.ReadAll(rc)
+		_ = rc.Close()
+		if err != nil {
+			return nil, fmt.Errorf("failed reading entry %s: %w", f.Name, err)
+		}
+
+		// Determine safe mode
+		mode := os.FileMode(0644)
+		lower := strings.ToLower(cleanName)
+		if strings.Contains(lower, "key") || strings.Contains(lower, "users.json") || strings.Contains(lower, "ppp_secrets.json") {
+			mode = 0600
+		} else if f.Mode().Perm() != 0 {
+			mode = f.Mode().Perm()
+		}
+
+		if err := os.WriteFile(destPath, content, mode); err != nil {
+			return nil, fmt.Errorf("failed writing staged file %s: %w", f.Name, err)
+		}
+	}
+
+	// 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)
+				}
+			}
+		}
+	}
+
+	result := &RestoreResult{
+		Success:      true,
+		Message:      "Configuration restored successfully. Reloading appliance controller...",
+		Manifest:     manifest,
+		SafetyBackup: safetyBackupPath,
+		RestoredAt:   time.Now().UTC(),
+	}
+
+	return result, nil
+}
+
+// syncDirectory copies srcDir into dstDir, cleaning obsolete files while preserving security permissions.
+func (m *Manager) syncDirectory(srcDir, dstDir string) error {
+	if err := os.MkdirAll(dstDir, 0755); err != nil {
+		return err
+	}
+
+	// Wipe existing files in dstDir to avoid stale or orphaned configs
+	existing, err := os.ReadDir(dstDir)
+	if err == nil {
+		for _, e := range existing {
+			_ = os.RemoveAll(filepath.Join(dstDir, e.Name()))
+		}
+	}
+
+	return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
+		if err != nil {
+			return err
+		}
+
+		rel, err := filepath.Rel(srcDir, path)
+		if err != nil {
+			return err
+		}
+		target := filepath.Join(dstDir, rel)
+
+		if info.IsDir() {
+			dirPerm := os.FileMode(0755)
+			if strings.Contains(rel, "certs") {
+				dirPerm = 0700
+			}
+			return os.MkdirAll(target, dirPerm)
+		}
+
+		data, err := os.ReadFile(path)
+		if err != nil {
+			return err
+		}
+
+		mode := info.Mode().Perm()
+		lower := strings.ToLower(rel)
+		if strings.Contains(lower, "key") || strings.Contains(lower, "users.json") || strings.Contains(lower, "ppp_secrets.json") {
+			mode = 0600
+		} else if mode == 0 {
+			mode = 0644
+		}
+
+		return os.WriteFile(target, data, mode)
+	})
+}
+
+// TriggerRestart initiates an asynchronous service reload or restart.
+func (m *Manager) TriggerRestart(delay time.Duration) {
+	go func() {
+		time.Sleep(delay)
+		log.Printf("[Backup] Initiating controller restart following configuration restore...")
+		if m.restartFunc != nil {
+			if err := m.restartFunc(); err != nil {
+				log.Printf("[Backup] Restart hook returned error: %v", err)
+			}
+			return
+		}
+		cmd := exec.Command("systemctl", "restart", "debtik")
+		if err := cmd.Run(); err != nil {
+			log.Printf("[Backup] systemctl restart debtik failed: %v", err)
+		}
+	}()
+}
+
+// ListLocalBackups returns a list of stored backups in the backupStore.
+func (m *Manager) ListLocalBackups() ([]BackupInfo, error) {
+	if err := os.MkdirAll(m.backupStore, 0700); err != nil {
+		return nil, err
+	}
+
+	entries, err := os.ReadDir(m.backupStore)
+	if err != nil {
+		return nil, err
+	}
+
+	var results []BackupInfo
+	for _, e := range entries {
+		if e.IsDir() || !strings.HasSuffix(e.Name(), ".zip") {
+			continue
+		}
+
+		info, err := e.Info()
+		if err != nil {
+			continue
+		}
+
+		filePath := filepath.Join(m.backupStore, e.Name())
+		bInfo := BackupInfo{
+			Filename: e.Name(),
+			Size:     info.Size(),
+			ModTime:  info.ModTime().UTC(),
+		}
+
+		// Attempt reading manifest for richer metadata
+		if f, err := os.Open(filePath); err == nil {
+			if zr, err := zip.NewReader(f, info.Size()); err == nil {
+				for _, zf := range zr.File {
+					if zf.Name == "manifest.json" {
+						if rc, err := zf.Open(); err == nil {
+							var mf Manifest
+							if err := json.NewDecoder(rc).Decode(&mf); err == nil {
+								bInfo.DebTikVersion = mf.DebTikVersion
+								bInfo.Hostname = mf.Hostname
+								bInfo.Comment = mf.Comment
+							}
+							_ = rc.Close()
+						}
+						break
+					}
+				}
+			}
+			_ = f.Close()
+		}
+
+		results = append(results, bInfo)
+	}
+
+	sort.Slice(results, func(i, j int) bool {
+		return results[i].ModTime.After(results[j].ModTime)
+	})
+
+	return results, nil
+}
+
+// GetLocalBackup returns the full path of a stored backup file after sanitization.
+func (m *Manager) GetLocalBackup(filename string) (string, error) {
+	cleanName := sanitizeFilename(filename)
+	if !strings.HasSuffix(cleanName, ".zip") {
+		return "", errors.New("backup filename must have .zip extension")
+	}
+
+	fullPath := filepath.Join(m.backupStore, cleanName)
+	if info, err := os.Stat(fullPath); err != nil || info.IsDir() {
+		return "", fmt.Errorf("backup file %q not found", cleanName)
+	}
+
+	return fullPath, nil
+}
+
+// sanitizeFilename restricts filenames to safe alphanumeric characters, dashes, underscores, and dots.
+func sanitizeFilename(name string) string {
+	name = filepath.Base(name)
+	var b strings.Builder
+	for _, r := range name {
+		if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' {
+			b.WriteRune(r)
+		} else {
+			b.WriteRune('_')
+		}
+	}
+	res := b.String()
+	if res == "" || res == "." || res == ".." {
+		return "backup.zip"
+	}
+	return res
+}

+ 40 - 0
internal/backup/types.go

@@ -0,0 +1,40 @@
+package backup
+
+import "time"
+
+// Manifest defines the metadata stored inside the backup zip archive.
+type Manifest struct {
+	Version       string     `json:"version"`                  // Backup format specification version ("1.0")
+	DebTikVersion string     `json:"debtik_version"`           // DebTik version that created the backup
+	Hostname      string     `json:"hostname"`                 // Router hostname at backup time
+	CreatedAt     time.Time  `json:"created_at"`               // Timestamp of backup generation
+	Comment       string     `json:"comment,omitempty"`        // Optional user note or comment
+	Files         []FileInfo `json:"files"`                    // List of files included in the archive
+}
+
+// FileInfo contains metadata for an individual file in the backup.
+type FileInfo struct {
+	Path   string `json:"path"`   // Path relative to zip root (e.g. "debtik/metadata.json")
+	Size   int64  `json:"size"`   // File size in bytes
+	Mode   uint32 `json:"mode"`   // File permission bits
+	SHA256 string `json:"sha256"` // SHA-256 checksum of file content
+}
+
+// BackupInfo summarizes a stored local backup archive.
+type BackupInfo struct {
+	Filename      string    `json:"filename"`
+	Size          int64     `json:"size"`
+	ModTime       time.Time `json:"mod_time"`
+	DebTikVersion string    `json:"debtik_version,omitempty"`
+	Hostname      string    `json:"hostname,omitempty"`
+	Comment       string    `json:"comment,omitempty"`
+}
+
+// RestoreResult describes the outcome of a restoration operation.
+type RestoreResult struct {
+	Success      bool      `json:"success"`
+	Message      string    `json:"message"`
+	Manifest     *Manifest `json:"manifest,omitempty"`
+	SafetyBackup string    `json:"safety_backup,omitempty"`
+	RestoredAt   time.Time `json:"restored_at"`
+}

+ 97 - 0
internal/cli/executor.go

@@ -4,13 +4,16 @@ import (
 	"context"
 	"fmt"
 	"net"
+	"os"
 	"os/exec"
+	"path/filepath"
 	"sort"
 	"strconv"
 	"strings"
 	"time"
 
 	"debtik/internal/auth"
+	"debtik/internal/backup"
 	"debtik/internal/bridge"
 	"debtik/internal/bwtest"
 	"debtik/internal/certs"
@@ -44,6 +47,7 @@ type Executor struct {
 	servicesMgr   *services.Manager
 	certMgr       *certs.CertManager
 	proxyMgr      *reverseproxy.Manager
+	backupMgr     *backup.Manager
 }
 
 func NewExecutor(
@@ -62,7 +66,11 @@ func NewExecutor(
 	servicesMgr *services.Manager,
 	certMgr *certs.CertManager,
 	proxyMgr *reverseproxy.Manager,
+	backupMgr *backup.Manager,
 ) *Executor {
+	if backupMgr == nil {
+		backupMgr = backup.NewManager("", "", "", nil)
+	}
 	return &Executor{
 		authMgr:       authMgr,
 		netMgr:        netMgr,
@@ -79,6 +87,7 @@ func NewExecutor(
 		servicesMgr:   servicesMgr,
 		certMgr:       certMgr,
 		proxyMgr:      proxyMgr,
+		backupMgr:     backupMgr,
 	}
 }
 
@@ -2327,6 +2336,93 @@ func (e *Executor) handleSystem(parts []string, user *auth.PublicUser) string {
 		}()
 		return ColorYellow + "System reboot initiated! Connection will terminate.\n" + ColorReset
 
+	case "backup":
+		if e.backupMgr == nil {
+			e.backupMgr = backup.NewManager("", "", "", nil)
+		}
+		if len(parts) < 2 {
+			return ColorYellow + "Usage: /system backup [save [name=...] [comment=...]] | [load name=...] | [print]\n" + ColorReset
+		}
+		action := parts[1]
+		switch action {
+		case "save":
+			if user.Role != auth.RoleFull {
+				return ColorRed + "Permission denied: admin role required\n" + ColorReset
+			}
+			_, params := parseArgs(parts[2:])
+			name := params["name"]
+			comment := params["comment"]
+			savedPath, manifest, err := e.backupMgr.SaveBackup(name, comment)
+			if err != nil {
+				return ColorRed + "Error creating backup: " + err.Error() + ColorReset + "\n"
+			}
+			fi, _ := os.Stat(savedPath)
+			size := int64(0)
+			if fi != nil {
+				size = fi.Size()
+			}
+			return ColorGreen + fmt.Sprintf("Configuration backup saved successfully:\n  File: %s\n  Size: %d bytes\n  Hostname: %s\n", filepath.Base(savedPath), size, manifest.Hostname) + ColorReset
+
+		case "print":
+			backups, err := e.backupMgr.ListLocalBackups()
+			if err != nil {
+				return ColorRed + "Error listing backups: " + err.Error() + ColorReset + "\n"
+			}
+			if len(backups) == 0 {
+				return "No backup archives found.\n"
+			}
+			b := strings.Builder{}
+			b.WriteString(ColorCyan + fmt.Sprintf("%-3s %-42s %-10s %-20s %s\n", "#", "NAME", "SIZE", "CREATED", "COMMENT") + ColorReset)
+			for i, bk := range backups {
+				sizeStr := fmt.Sprintf("%d B", bk.Size)
+				if bk.Size >= 1024*1024 {
+					sizeStr = fmt.Sprintf("%.1f MB", float64(bk.Size)/(1024*1024))
+				} else if bk.Size >= 1024 {
+					sizeStr = fmt.Sprintf("%.1f KB", float64(bk.Size)/1024)
+				}
+				b.WriteString(fmt.Sprintf("%-3d %-42s %-10s %-20s %s\n",
+					i,
+					bk.Filename,
+					sizeStr,
+					bk.ModTime.Format("2006-01-02 15:04:05"),
+					bk.Comment,
+				))
+			}
+			return b.String()
+
+		case "load":
+			if user.Role != auth.RoleFull {
+				return ColorRed + "Permission denied: admin role required to restore configuration\n" + ColorReset
+			}
+			_, params := parseArgs(parts[2:])
+			name := params["name"]
+			if name == "" {
+				return ColorRed + "Usage: /system backup load name=<backup-filename.zip>\n" + ColorReset
+			}
+			fullPath, err := e.backupMgr.GetLocalBackup(name)
+			if err != nil {
+				return ColorRed + "Backup file not found: " + err.Error() + ColorReset + "\n"
+			}
+			f, err := os.Open(fullPath)
+			if err != nil {
+				return ColorRed + "Cannot read backup file: " + err.Error() + ColorReset + "\n"
+			}
+			defer f.Close()
+			fi, err := f.Stat()
+			if err != nil {
+				return ColorRed + "Cannot stat backup file: " + err.Error() + ColorReset + "\n"
+			}
+			res, err := e.backupMgr.RestoreBackup(f, fi.Size())
+			if err != nil {
+				return ColorRed + "Restore failed: " + err.Error() + ColorReset + "\n"
+			}
+			e.backupMgr.TriggerRestart(1 * time.Second)
+			return ColorGreen + fmt.Sprintf("Configuration restored successfully from %s.\nAutomatic fallback saved: %s\nDebTik controller is reloading now...\n", name, filepath.Base(res.SafetyBackup)) + ColorReset
+
+		default:
+			return ColorRed + "Unknown backup action: " + action + ". Use save, load, or print.\n" + ColorReset
+		}
+
 	default:
 		return ColorRed + "Unknown system subcommand: " + sub + "\n" + ColorReset
 	}
@@ -2368,6 +2464,7 @@ func (e *Executor) showHelp() string {
 	b.WriteString("  /system                        - Router hardware and core controls\n")
 	b.WriteString("    /system resource print       - CPU, RAM, disk, uptime metrics\n")
 	b.WriteString("    /system identity             - Hostname identity (print, set name=...)\n")
+	b.WriteString("    /system backup               - Configuration backups (save, load, print)\n")
 	b.WriteString("    /system reboot               - Safe system restart\n")
 	b.WriteString("  quit / exit                    - Disconnect session\n")
 	return b.String()

+ 345 - 15
internal/web/static/app.js

@@ -16086,35 +16086,42 @@ function switchSettingsSubTab(sub) {
   state.settingsSubTab = sub;
   const btnDns = document.getElementById('btn-settings-subtab-dns');
   const btnSys = document.getElementById('btn-settings-subtab-system');
+  const btnBak = document.getElementById('btn-settings-subtab-backup');
   const tabDns = document.getElementById('settings-subtab-dns');
   const tabSys = document.getElementById('settings-subtab-system');
+  const tabBak = document.getElementById('settings-subtab-backup');
+
+  const activeClass = 'px-3.5 py-1.5 rounded-lg text-xs font-semibold bg-cyan-600 text-white transition flex items-center gap-1.5 cursor-pointer';
+  const inactiveClass = 'px-3.5 py-1.5 rounded-lg text-xs font-semibold bg-slate-800 text-slate-300 hover:text-white transition flex items-center gap-1.5 cursor-pointer';
+
+  if (btnDns) btnDns.className = inactiveClass;
+  if (btnSys) btnSys.className = inactiveClass;
+  if (btnBak) btnBak.className = inactiveClass;
+
+  tabDns?.classList.add('hidden');
+  tabSys?.classList.add('hidden');
+  tabBak?.classList.add('hidden');
 
   if (sub === 'dns') {
-    if (btnDns) {
-      btnDns.className = 'px-3.5 py-1.5 rounded-lg text-xs font-semibold bg-cyan-600 text-white transition flex items-center gap-1.5';
-    }
-    if (btnSys) {
-      btnSys.className = 'px-3.5 py-1.5 rounded-lg text-xs font-semibold bg-slate-800 text-slate-300 hover:text-white transition flex items-center gap-1.5';
-    }
+    if (btnDns) btnDns.className = activeClass;
     tabDns?.classList.remove('hidden');
-    tabSys?.classList.add('hidden');
     loadDNSSettings();
-  } else {
-    if (btnSys) {
-      btnSys.className = 'px-3.5 py-1.5 rounded-lg text-xs font-semibold bg-cyan-600 text-white transition flex items-center gap-1.5';
-    }
-    if (btnDns) {
-      btnDns.className = 'px-3.5 py-1.5 rounded-lg text-xs font-semibold bg-slate-800 text-slate-300 hover:text-white transition flex items-center gap-1.5';
-    }
+  } else if (sub === 'system') {
+    if (btnSys) btnSys.className = activeClass;
     tabSys?.classList.remove('hidden');
-    tabDns?.classList.add('hidden');
     loadSystemSettings();
+  } else if (sub === 'backup') {
+    if (btnBak) btnBak.className = activeClass;
+    tabBak?.classList.remove('hidden');
+    loadBackupList();
   }
 }
 
 async function loadSettingsData(showLoading = false) {
   if (state.settingsSubTab === 'system') {
     await loadSystemSettings();
+  } else if (state.settingsSubTab === 'backup') {
+    await loadBackupList();
   } else {
     await loadDNSSettings();
   }
@@ -16454,6 +16461,329 @@ async function saveSystemSettings(e) {
   }
 }
 
+// =============================================================================
+//                       BACKUP & RESTORE MANAGER
+// =============================================================================
+
+state.selectedBackupFile = null;
+state.inspectedBackupManifest = null;
+state.pendingRestoreTarget = null;
+state.backupsList = [];
+
+async function exportBackupDownload() {
+  const btn = document.getElementById('btn-export-backup');
+  const commentInput = document.getElementById('backup-export-comment');
+  const comment = (commentInput?.value || '').trim();
+
+  const originalHtml = btn ? btn.innerHTML : '';
+  if (btn) {
+    btn.disabled = true;
+    btn.innerHTML = `<span class="w-3.5 h-3.5 rounded-full border-2 border-white border-t-transparent animate-spin inline-block"></span> Generating Backup...`;
+  }
+
+  try {
+    const res = await fetch(`${API_BASE}/system/backup/export?comment=${encodeURIComponent(comment)}`);
+    if (!res.ok) {
+      const err = await res.json().catch(() => ({}));
+      showToast('Export Failed', err.message || 'Failed to generate backup archive', 'error');
+      return;
+    }
+
+    const blob = await res.blob();
+    const disposition = res.headers.get('Content-Disposition') || '';
+    let filename = 'debtik-backup.zip';
+    const match = disposition.match(/filename="?([^";]+)"?/);
+    if (match && match[1]) {
+      filename = match[1];
+    }
+
+    const blobUrl = window.URL.createObjectURL(blob);
+    const a = document.createElement('a');
+    a.href = blobUrl;
+    a.download = filename;
+    document.body.appendChild(a);
+    a.click();
+    setTimeout(() => {
+      window.URL.revokeObjectURL(blobUrl);
+      a.remove();
+    }, 1000);
+
+    showToast('Backup Created', `Full configuration archive saved as ${filename}`, 'success');
+  } catch (err) {
+    showToast('Export Error', err.message || 'Network error during download', 'error');
+  } finally {
+    if (btn) {
+      btn.disabled = false;
+      btn.innerHTML = originalHtml;
+    }
+  }
+}
+
+async function onBackupFileSelected(e) {
+  const file = e.target.files?.[0];
+  if (!file) return;
+
+  const label = document.getElementById('backup-file-label');
+  if (label) {
+    label.innerText = `Selected: ${file.name} (${formatBytes(file.size)})`;
+  }
+
+  await inspectBackupFile(file);
+}
+
+async function inspectBackupFile(file) {
+  const preview = document.getElementById('backup-inspect-preview');
+  const restoreBtn = document.getElementById('btn-restore-backup');
+
+  const formData = new FormData();
+  formData.append('backup', file);
+
+  try {
+    const res = await fetch(`${API_BASE}/system/backup/inspect`, {
+      method: 'POST',
+      body: formData
+    });
+
+    const data = await res.json();
+    if (!res.ok || !data.success || !data.manifest) {
+      if (preview) preview.classList.add('hidden');
+      if (restoreBtn) restoreBtn.disabled = true;
+      showToast('Validation Error', data.message || 'Invalid or corrupted DebTik backup archive', 'error');
+      return;
+    }
+
+    const mf = data.manifest;
+    state.selectedBackupFile = file;
+    state.inspectedBackupManifest = mf;
+
+    const vEl = document.getElementById('backup-inspect-version');
+    const hEl = document.getElementById('backup-inspect-hostname');
+    const fEl = document.getElementById('backup-inspect-files');
+    const dEl = document.getElementById('backup-inspect-date');
+    const cEl = document.getElementById('backup-inspect-comment');
+
+    if (vEl) vEl.innerText = `DebTik v${escapeHtml(mf.debtik_version || '1.0')}`;
+    if (hEl) hEl.innerText = escapeHtml(mf.hostname || 'DebTik');
+    if (fEl) fEl.innerText = `${mf.files?.length || 0} items`;
+    if (dEl) dEl.innerText = mf.created_at ? new Date(mf.created_at).toLocaleString() : 'N/A';
+    if (cEl) cEl.innerText = mf.comment ? `"${escapeHtml(mf.comment)}"` : '';
+
+    if (preview) preview.classList.remove('hidden');
+    if (restoreBtn) restoreBtn.disabled = false;
+  } catch (err) {
+    if (preview) preview.classList.add('hidden');
+    if (restoreBtn) restoreBtn.disabled = true;
+    showToast('Inspection Failed', err.message || 'Failed to inspect backup file', 'error');
+  }
+}
+
+function confirmRestoreBackup(localFilename = null) {
+  const modal = document.getElementById('modal-restore-confirm');
+  const details = document.getElementById('modal-restore-details');
+  const progress = document.getElementById('modal-restore-progress');
+  const cancelBtn = document.getElementById('btn-cancel-restore');
+  const execBtn = document.getElementById('btn-execute-restore');
+
+  if (progress) progress.classList.add('hidden');
+  if (cancelBtn) cancelBtn.disabled = false;
+  if (execBtn) {
+    execBtn.disabled = false;
+    execBtn.innerText = 'Yes, Restore & Reload';
+  }
+
+  if (localFilename) {
+    state.pendingRestoreTarget = { type: 'local', filename: localFilename };
+    if (details) {
+      details.innerHTML = `<div>Source: <span class="text-white font-bold">Local Safety Backup</span></div>
+<div>File: <span class="text-cyan-400 font-bold">${escapeHtml(localFilename)}</span></div>`;
+    }
+  } else {
+    if (!state.selectedBackupFile || !state.inspectedBackupManifest) {
+      showToast('Error', 'Please select a valid backup archive first', 'error');
+      return;
+    }
+    state.pendingRestoreTarget = { type: 'file', file: state.selectedBackupFile };
+    const mf = state.inspectedBackupManifest;
+    if (details) {
+      details.innerHTML = `<div>Archive: <span class="text-white font-bold">${escapeHtml(state.selectedBackupFile.name)}</span> (${formatBytes(state.selectedBackupFile.size)})</div>
+<div>Origin Hostname: <span class="text-cyan-400 font-bold">${escapeHtml(mf.hostname)}</span></div>
+<div>Created: <span class="text-slate-300">${mf.created_at ? new Date(mf.created_at).toLocaleString() : 'N/A'}</span></div>
+<div>Files: <span class="text-emerald-400">${mf.files?.length || 0} configuration files</span></div>`;
+    }
+  }
+
+  if (modal) modal.classList.remove('hidden');
+}
+
+function closeRestoreModal() {
+  const modal = document.getElementById('modal-restore-confirm');
+  if (modal) modal.classList.add('hidden');
+}
+
+async function executeRestoreBackup() {
+  const target = state.pendingRestoreTarget;
+  if (!target) return;
+
+  const progress = document.getElementById('modal-restore-progress');
+  const progressText = document.getElementById('modal-restore-progress-text');
+  const cancelBtn = document.getElementById('btn-cancel-restore');
+  const execBtn = document.getElementById('btn-execute-restore');
+
+  if (cancelBtn) cancelBtn.disabled = true;
+  if (execBtn) execBtn.disabled = true;
+  if (progress) progress.classList.remove('hidden');
+  if (progressText) progressText.innerText = 'Restoring configuration files into appliance...';
+
+  try {
+    let res;
+    if (target.type === 'file') {
+      const formData = new FormData();
+      formData.append('backup', target.file);
+      res = await fetch(`${API_BASE}/system/backup/restore`, {
+        method: 'POST',
+        body: formData
+      });
+    } else {
+      res = await fetch(`${API_BASE}/system/backups/${encodeURIComponent(target.filename)}/restore`, {
+        method: 'POST'
+      });
+    }
+
+    const data = await res.json();
+    if (!res.ok || !data.success) {
+      showToast('Restore Failed', data.message || 'Error occurred while restoring configuration', 'error');
+      if (cancelBtn) cancelBtn.disabled = false;
+      if (execBtn) execBtn.disabled = false;
+      if (progress) progress.classList.add('hidden');
+      return;
+    }
+
+    if (progressText) {
+      progressText.innerText = 'Configuration applied! DebTik appliance is reloading services, reconnecting...';
+    }
+
+    pollAfterRestoreReconnect();
+  } catch (err) {
+    showToast('Restore Error', err.message || 'Network error during restore', 'error');
+    if (cancelBtn) cancelBtn.disabled = false;
+    if (execBtn) execBtn.disabled = false;
+    if (progress) progress.classList.add('hidden');
+  }
+}
+
+function pollAfterRestoreReconnect() {
+  let attempts = 0;
+  const maxAttempts = 25;
+
+  const timer = setInterval(async () => {
+    attempts++;
+    try {
+      const res = await fetch(`${API_BASE}/system/status`, { cache: 'no-store' });
+      if (res.ok) {
+        clearInterval(timer);
+        closeRestoreModal();
+        showToast('Restoration Complete', 'DebTik configuration restored and appliance is back online!', 'success');
+        setTimeout(() => {
+          window.location.reload();
+        }, 1200);
+      }
+    } catch (_) {
+      // Still restarting, retry
+    }
+
+    if (attempts >= maxAttempts) {
+      clearInterval(timer);
+      closeRestoreModal();
+      showToast('Reload Notice', 'Restore applied. Please refresh your browser to re-authenticate.', 'info');
+    }
+  }, 1500);
+}
+
+async function loadBackupList() {
+  const tbody = document.getElementById('backups-table-tbody');
+  const empty = document.getElementById('backups-empty-state');
+  if (!tbody) return;
+
+  try {
+    const res = await fetch(`${API_BASE}/system/backups`);
+    if (!res.ok) return;
+
+    const list = await res.json();
+    state.backupsList = list || [];
+    renderBackupsTable(state.backupsList);
+  } catch (err) {
+    console.error('Failed to load backup list:', err);
+  }
+}
+
+function renderBackupsTable(backups) {
+  const tbody = document.getElementById('backups-table-tbody');
+  const empty = document.getElementById('backups-empty-state');
+  if (!tbody) return;
+
+  if (!backups || backups.length === 0) {
+    tbody.innerHTML = '';
+    if (empty) empty.classList.remove('hidden');
+    return;
+  }
+
+  if (empty) empty.classList.add('hidden');
+
+  tbody.innerHTML = backups.map(bk => {
+    const isAuto = bk.filename && bk.filename.startsWith('pre-restore-');
+    const badge = isAuto
+      ? `<span class="px-2 py-0.5 rounded text-[9px] font-mono bg-amber-500/10 text-amber-300 border border-amber-500/20">PRE-RESTORE</span>`
+      : `<span class="px-2 py-0.5 rounded text-[9px] font-mono bg-cyan-500/10 text-cyan-300 border border-cyan-500/20">MANUAL</span>`;
+
+    const dateStr = bk.mod_time ? new Date(bk.mod_time).toLocaleString() : 'N/A';
+
+    return `<tr class="hover:bg-slate-800/40 transition">
+      <td class="py-3 px-4 font-bold text-white flex items-center gap-2">
+        ${badge}
+        <span>${escapeHtml(bk.filename)}</span>
+      </td>
+      <td class="py-3 px-4 text-slate-300">${escapeHtml(bk.debtik_version || '1.0')}</td>
+      <td class="py-3 px-4 text-cyan-400 font-bold">${escapeHtml(bk.hostname || 'DebTik')}</td>
+      <td class="py-3 px-4 text-slate-400">${dateStr}</td>
+      <td class="py-3 px-4 text-slate-300">${formatBytes(bk.size)}</td>
+      <td class="py-3 px-4 text-slate-400 italic">${escapeHtml(bk.comment || '—')}</td>
+      <td class="py-3 px-4 text-right space-x-2">
+        <button type="button" onclick="downloadLocalBackup('${escapeHtml(bk.filename)}')"
+                class="px-2.5 py-1 rounded-lg text-[11px] font-semibold bg-slate-800 hover:bg-slate-700 text-cyan-400 border border-slate-700 transition cursor-pointer">
+          Download
+        </button>
+        <button type="button" onclick="confirmRestoreBackup('${escapeHtml(bk.filename)}')"
+                class="px-2.5 py-1 rounded-lg text-[11px] font-semibold bg-amber-600/20 hover:bg-amber-600/30 text-amber-300 border border-amber-500/30 transition cursor-pointer">
+          Restore
+        </button>
+      </td>
+    </tr>`;
+  }).join('');
+}
+
+async function downloadLocalBackup(filename) {
+  try {
+    const res = await fetch(`${API_BASE}/system/backups/${encodeURIComponent(filename)}`);
+    if (!res.ok) {
+      showToast('Download Error', 'Failed to retrieve backup file', 'error');
+      return;
+    }
+    const blob = await res.blob();
+    const blobUrl = window.URL.createObjectURL(blob);
+    const a = document.createElement('a');
+    a.href = blobUrl;
+    a.download = filename;
+    document.body.appendChild(a);
+    a.click();
+    setTimeout(() => {
+      window.URL.revokeObjectURL(blobUrl);
+      a.remove();
+    }, 1000);
+  } catch (err) {
+    showToast('Download Error', err.message, 'error');
+  }
+}
+
 // =============================================================================
 //                       AUTHENTICATION & USER MANAGER
 // =============================================================================

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

@@ -4296,6 +4296,11 @@
                 <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
                 <span>System & Hostname</span>
               </button>
+              <button onclick="switchSettingsSubTab('backup')" id="btn-settings-subtab-backup"
+                      class="px-3.5 py-1.5 rounded-lg text-xs font-semibold bg-slate-800 text-slate-300 hover:text-white transition flex items-center gap-1.5 cursor-pointer">
+                <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 4H6a2 2 0 00-2 2v12a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-2m-4-1v8m0 0l3-3m-3 3L9 8m-5 5h2.586a1 1 0 01.707.293l2.414 2.414a1 1 0 00.707.293h3.172a1 1 0 00.707-.293l2.414-2.414a1 1 0 01.707-.293H20"/></svg>
+                <span>Backup & Restore</span>
+              </button>
             </div>
             <div class="flex items-center gap-2">
               <span id="dns-live-status-pill" class="px-2.5 py-1 rounded-full text-[11px] font-mono bg-emerald-500/10 text-emerald-400 border border-emerald-500/30 flex items-center gap-1.5">
@@ -4460,6 +4465,168 @@
               </form>
             </div>
           </div>
+
+          <!-- Sub-Tab 3: Backup & Restore -->
+          <div id="settings-subtab-backup" class="hidden space-y-6">
+            <!-- Header Explanatory Card -->
+            <div class="bg-slate-900/90 border border-slate-800 rounded-2xl p-5 shadow-lg flex flex-col md:flex-row md:items-center justify-between gap-4">
+              <div>
+                <h3 class="text-base font-bold text-white flex items-center gap-2">
+                  <span class="w-2.5 h-2.5 rounded-full bg-cyan-400"></span>
+                  <span>Full Configuration Backup & Disaster Recovery</span>
+                </h3>
+                <p class="text-xs text-slate-400 mt-1 max-w-3xl leading-relaxed">
+                  Export the complete DebTik router appliance configuration—including routing policies, firewall rules, NAT, WireGuard & AeroTUN tunnels, SSL/TLS certificates & private keys, Reverse Proxy routes, DHCP, DNS, and user credentials—into a single downloadable <span class="font-mono text-cyan-400">.zip</span> archive. Restorations automatically generate a pre-restore fallback safety copy.
+                </p>
+              </div>
+              <div class="flex items-center gap-2">
+                <button type="button" onclick="loadBackupList()" class="px-3 py-1.5 rounded-xl bg-slate-800 hover:bg-slate-700 text-xs text-slate-300 transition flex items-center gap-1.5 cursor-pointer">
+                  <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
+                  <span>Refresh</span>
+                </button>
+              </div>
+            </div>
+
+            <!-- Two-Column Grid: Export & Import -->
+            <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
+              <!-- Card 1: Export Backup -->
+              <div class="bg-slate-900/90 border border-slate-800 rounded-2xl p-6 shadow-xl flex flex-col justify-between">
+                <div class="space-y-4">
+                  <div class="flex items-center justify-between pb-3 border-b border-slate-800">
+                    <div class="flex items-center gap-2.5">
+                      <div class="w-9 h-9 rounded-xl bg-cyan-500/10 border border-cyan-500/20 text-cyan-400 flex items-center justify-center">
+                        <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
+                      </div>
+                      <div>
+                        <h4 class="text-sm font-bold text-white">Export Configuration Backup</h4>
+                        <p class="text-[11px] text-slate-400">Generate and download complete router archive (.zip)</p>
+                      </div>
+                    </div>
+                    <span class="px-2 py-0.5 rounded text-[10px] font-mono bg-cyan-500/10 text-cyan-300 border border-cyan-500/20">LIVE SNAPSHOT</span>
+                  </div>
+
+                  <div>
+                    <label class="block text-xs font-semibold text-slate-300 mb-1.5">Backup Note / Comment (Optional)</label>
+                    <input type="text" id="backup-export-comment" placeholder="e.g. Pre-maintenance backup or stable baseline"
+                           class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2.5 text-xs text-white placeholder-slate-600 focus:outline-none focus:border-cyan-500 font-sans">
+                  </div>
+
+                  <div class="rounded-xl bg-slate-950/60 border border-slate-800/80 p-3.5 space-y-2 text-xs">
+                    <div class="text-[11px] font-semibold text-slate-300 uppercase tracking-wider">Archive Contents:</div>
+                    <ul class="text-[11px] text-slate-400 space-y-1 font-mono">
+                      <li class="flex items-center gap-2"><span class="text-emerald-400">✔</span> /etc/debtik/* (Firewall, Routing, VPN, Certs, DNS, Users)</li>
+                      <li class="flex items-center gap-2"><span class="text-emerald-400">✔</span> /etc/network/interfaces.d/* (Interfaces, VLANs)</li>
+                      <li class="flex items-center gap-2"><span class="text-emerald-400">✔</span> manifest.json (SHA-256 integrity verification)</li>
+                    </ul>
+                  </div>
+                </div>
+
+                <div class="pt-5 mt-4 border-t border-slate-800 flex justify-end">
+                  <button type="button" id="btn-export-backup" onclick="exportBackupDownload()"
+                          class="px-5 py-2.5 rounded-xl text-xs font-bold bg-cyan-600 hover:bg-cyan-500 text-white shadow-lg shadow-cyan-600/25 transition flex items-center gap-2 cursor-pointer">
+                    <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
+                    <span>Download Backup (.zip)</span>
+                  </button>
+                </div>
+              </div>
+
+              <!-- Card 2: Restore Backup -->
+              <div class="bg-slate-900/90 border border-slate-800 rounded-2xl p-6 shadow-xl flex flex-col justify-between">
+                <div class="space-y-4">
+                  <div class="flex items-center justify-between pb-3 border-b border-slate-800">
+                    <div class="flex items-center gap-2.5">
+                      <div class="w-9 h-9 rounded-xl bg-amber-500/10 border border-amber-500/20 text-amber-400 flex items-center justify-center">
+                        <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/></svg>
+                      </div>
+                      <div>
+                        <h4 class="text-sm font-bold text-white">Restore Configuration</h4>
+                        <p class="text-[11px] text-slate-400">Upload a backup archive to restore router state</p>
+                      </div>
+                    </div>
+                    <span class="px-2 py-0.5 rounded text-[10px] font-mono bg-amber-500/10 text-amber-300 border border-amber-500/20">REPLACE & RELOAD</span>
+                  </div>
+
+                  <!-- File Drop / Select Area -->
+                  <div>
+                    <input type="file" id="backup-file-input" accept=".zip" class="hidden" onchange="onBackupFileSelected(event)">
+                    <div onclick="document.getElementById('backup-file-input').click()"
+                         class="border-2 border-dashed border-slate-700 hover:border-cyan-500 rounded-xl p-5 text-center cursor-pointer transition bg-slate-950/40 hover:bg-slate-950/80">
+                      <svg class="w-8 h-8 text-slate-500 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/></svg>
+                      <div class="text-xs font-semibold text-slate-200" id="backup-file-label">Click to choose or drag & drop a .zip archive</div>
+                      <div class="text-[10px] text-slate-500 mt-1 font-mono">Max archive size: 100 MB</div>
+                    </div>
+                  </div>
+
+                  <!-- Inspection details preview card (hidden initially) -->
+                  <div id="backup-inspect-preview" class="hidden rounded-xl bg-slate-950/90 border border-cyan-500/30 p-3.5 space-y-2 text-xs">
+                    <div class="flex items-center justify-between">
+                      <span class="font-bold text-cyan-400 flex items-center gap-1.5">
+                        <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
+                        Archive Verified
+                      </span>
+                      <span id="backup-inspect-version" class="text-[10px] font-mono text-slate-400"></span>
+                    </div>
+                    <div class="grid grid-cols-2 gap-2 text-[11px] font-mono text-slate-300">
+                      <div>Hostname: <span id="backup-inspect-hostname" class="text-white font-bold"></span></div>
+                      <div>Files: <span id="backup-inspect-files" class="text-white font-bold"></span></div>
+                      <div class="col-span-2">Created: <span id="backup-inspect-date" class="text-slate-400"></span></div>
+                      <div class="col-span-2 text-slate-400 italic" id="backup-inspect-comment"></div>
+                    </div>
+                  </div>
+
+                  <!-- Warning banner -->
+                  <div class="p-3 rounded-xl bg-amber-500/10 border border-amber-500/20 text-amber-300/90 text-[11px] flex items-start gap-2">
+                    <svg class="w-4 h-4 text-amber-400 shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
+                    <span>Restoring replaces active configuration. DebTik will take an automated fallback safety backup and reload services.</span>
+                  </div>
+                </div>
+
+                <div class="pt-5 mt-4 border-t border-slate-800 flex justify-end gap-2">
+                  <button type="button" id="btn-restore-backup" onclick="confirmRestoreBackup()" disabled
+                          class="px-5 py-2.5 rounded-xl text-xs font-bold bg-amber-600 hover:bg-amber-500 disabled:bg-slate-800 disabled:text-slate-600 disabled:cursor-not-allowed text-white shadow-lg shadow-amber-600/25 transition flex items-center gap-2 cursor-pointer">
+                    <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
+                    <span>Restore Configuration</span>
+                  </button>
+                </div>
+              </div>
+            </div>
+
+            <!-- Card 3: Stored Safety Fallback Backups History -->
+            <div class="bg-slate-900/90 border border-slate-800 rounded-2xl overflow-hidden shadow-xl">
+              <div class="p-4 border-b border-slate-800 bg-slate-950/40 flex items-center justify-between">
+                <div>
+                  <h4 class="text-sm font-bold text-white flex items-center gap-2">
+                    <svg class="w-4 h-4 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
+                    <span>Stored Safety Fallback Backups</span>
+                  </h4>
+                  <p class="text-[11px] text-slate-400">Automated pre-restore backups retained in <span class="font-mono text-slate-300">/var/lib/debtik/backups</span></p>
+                </div>
+                <button type="button" onclick="loadBackupList()" class="px-2.5 py-1 rounded-lg text-xs bg-slate-800 hover:bg-slate-700 text-slate-300 transition cursor-pointer">Refresh List</button>
+              </div>
+
+              <div class="overflow-x-auto">
+                <table class="w-full text-left text-xs">
+                  <thead>
+                    <tr class="text-slate-400 border-b border-slate-800/80 bg-slate-950/60 font-semibold uppercase tracking-wider text-[10px]">
+                      <th class="py-3 px-4">Filename</th>
+                      <th class="py-3 px-4">DebTik Version</th>
+                      <th class="py-3 px-4">Hostname</th>
+                      <th class="py-3 px-4">Created</th>
+                      <th class="py-3 px-4">Size</th>
+                      <th class="py-3 px-4">Comment</th>
+                      <th class="py-3 px-4 text-right">Actions</th>
+                    </tr>
+                  </thead>
+                  <tbody id="backups-table-tbody" class="divide-y divide-slate-800/60 font-mono">
+                    <!-- Rendered dynamically -->
+                  </tbody>
+                </table>
+                <div id="backups-empty-state" class="hidden p-8 text-center text-slate-500 text-xs">
+                  No local backup archives currently stored on disk.
+                </div>
+              </div>
+            </div>
+          </div>
         </section>
 
         <!-- ================= SECTION: USERS & SECURITY ================= -->
@@ -11546,6 +11713,37 @@
     </div>
   </div>
 
+  <!-- ================= MODAL: RESTORE BACKUP CONFIRMATION ================= -->
+  <div id="modal-restore-confirm" class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
+    <div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-md shadow-2xl overflow-hidden">
+      <div class="px-5 py-4 border-b border-slate-800 flex items-center justify-between bg-slate-950/50">
+        <h3 class="text-sm font-bold text-amber-400 flex items-center gap-2">
+          <svg class="w-5 h-5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
+          <span>Confirm Configuration Restore</span>
+        </h3>
+        <button type="button" onclick="closeRestoreModal()" class="text-slate-400 hover:text-white p-1 rounded-lg">✕</button>
+      </div>
+      <div class="p-5 space-y-4">
+        <p class="text-xs text-slate-300 leading-relaxed">
+          Are you sure you want to restore this configuration? All current routing, firewall, tunnel, certificate, and service settings will be replaced.
+        </p>
+        <div id="modal-restore-details" class="p-3 bg-slate-950 rounded-xl border border-slate-800 text-xs font-mono text-slate-400 space-y-1">
+        </div>
+        <div class="p-3 bg-amber-500/10 border border-amber-500/20 rounded-xl text-amber-300 text-[11px]">
+          DebTik will create an automated fallback backup and reload immediately. Any active management sessions will briefly reconnect.
+        </div>
+        <div id="modal-restore-progress" class="hidden p-3 bg-cyan-500/10 border border-cyan-500/30 rounded-xl text-cyan-300 text-xs flex items-center gap-2">
+          <span class="w-3.5 h-3.5 rounded-full border-2 border-cyan-400 border-t-transparent animate-spin"></span>
+          <span id="modal-restore-progress-text">Restoring configuration and reloading DebTik...</span>
+        </div>
+      </div>
+      <div class="flex justify-end gap-2 px-5 py-4 border-t border-slate-800 bg-slate-950/30">
+        <button type="button" id="btn-cancel-restore" onclick="closeRestoreModal()" class="px-4 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-xs font-medium text-slate-300 transition">Cancel</button>
+        <button type="button" id="btn-execute-restore" onclick="executeRestoreBackup()" class="px-4 py-2 rounded-lg bg-amber-600 hover:bg-amber-500 text-xs font-semibold text-white shadow-lg shadow-amber-600/30 transition">Yes, Restore & Reload</button>
+      </div>
+    </div>
+  </div>
+
   <datalist id="fw-addrlist-datalist"></datalist>
   <script src="app.js?v=21.0"></script>
 </body>