From 20b5fed0117daf31d8a989f3a47f25583efb6f28 Mon Sep 17 00:00:00 2001 From: farhadzand Date: Sat, 18 Jul 2026 13:52:09 +0330 Subject: [PATCH] Add panel backup & restore (Settings -> Backup & restore) One JSON file with every config table (admins, nodes, accounts+keys, peers, devices, api_keys, panel_settings, audit_log) plus the node-mTLS CA keypair - everything needed to rebuild the panel on a fresh server except deploy/.env. Metrics hypertables are excluded; usage totals live on accounts rows and survive. - store: DumpTables via json_agg in a repeatable-read snapshot; RestoreTables via json_populate_recordset in one atomic tx (truncate all, reinsert in FK order, resync audit_log's sequence). Schema-generic - no per-table structs to drift. Round-trip proven by a new WGPANEL_TEST_POSTGRES_DSN-gated integration test. - httpapi: GET /api/v1/backup and POST /api/v1/backup/restore, both super_admin + audit-logged. Restore validates before touching anything: format, exact applied-migrations match (409 schema_mismatch), and a key canary encrypted with ACCOUNT_KEY_ENCRYPTION_KEY (409 encryption_key_mismatch) since account keys are useless under a different key. Restored CA is written to /data/ca; restart_required flags when it differs from the running one. - frontend: Backup & restore card in Settings with a destructive-confirm restore flow, post-restore summary, and cache clear + re-login advice. - openapi: Backup tag, both endpoints, BackupFile/RestoreResult schemas (public copy synced). Co-Authored-By: Claude Opus 4.8 --- backend/cmd/api/main.go | 1 + backend/internal/httpapi/backup.go | 212 ++++++++++++++++++++++++++ backend/internal/httpapi/server.go | 10 ++ backend/internal/store/backup.go | 131 ++++++++++++++++ backend/internal/store/backup_test.go | 100 ++++++++++++ docs/openapi.yaml | 118 ++++++++++++++ frontend/public/openapi.yaml | 118 ++++++++++++++ frontend/src/pages/SettingsPage.tsx | 148 +++++++++++++++++- 8 files changed, 836 insertions(+), 2 deletions(-) create mode 100644 backend/internal/httpapi/backup.go create mode 100644 backend/internal/store/backup.go create mode 100644 backend/internal/store/backup_test.go diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 1501447..44a2cca 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -95,6 +95,7 @@ func run(logger *slog.Logger) error { CaddyAdmin: caddyadmin.New(cfg.CaddyAdminSocket), AdminACLEmail: cfg.AdminACLEmail, BootPanelDomain: cfg.PanelDomain, + CADataDir: caDataDir, } httpServer := &http.Server{ diff --git a/backend/internal/httpapi/backup.go b/backend/internal/httpapi/backup.go new file mode 100644 index 0000000..f11d05d --- /dev/null +++ b/backend/internal/httpapi/backup.go @@ -0,0 +1,212 @@ +// Backup & restore via the panel (Settings -> Backup & restore): a single JSON +// file containing every config table plus the node-mTLS CA keypair - everything +// needed to rebuild the panel on a fresh server, EXCEPT deploy/.env. The backup is +// deliberately useless without that .env: account private keys inside it are +// still encrypted with ACCOUNT_KEY_ENCRYPTION_KEY (a key-canary field lets restore +// detect a mismatch up front instead of every config download failing later). +// +// The file is as sensitive as the database itself (admin password hashes, the CA +// private key, subscription tokens) - it is only ever produced for, and accepted +// from, a super_admin, and both directions are audit-logged. +package httpapi + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "slices" + "time" + + "wgpanel-api/internal/wgkeys" +) + +const backupFormat = "wgpanel-backup/1" + +// backupCanaryPlaintext is a fixed string encrypted with ACCOUNT_KEY_ENCRYPTION_KEY +// into every backup; restore decrypts it to prove the current deployment holds the +// same key the backed-up account private keys were encrypted with. +const backupCanaryPlaintext = "wgpanel-key-canary" + +type backupCA struct { + CertPEM string `json:"cert_pem"` + KeyPEM string `json:"key_pem"` +} + +type backupFile struct { + Format string `json:"format"` + CreatedAt string `json:"created_at"` + Migrations []string `json:"migrations"` + KeyCanary string `json:"key_canary"` + CA *backupCA `json:"ca,omitempty"` + Tables map[string]json.RawMessage `json:"tables"` +} + +// handleDownloadBackup streams the full panel backup as an attachment. +// super_admin-only (wired via requireRole in server.go). +func (s *Server) handleDownloadBackup(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + migrations, err := s.Store.AppliedMigrations(ctx) + if err != nil { + s.Logger.Error("backup_migrations_failed", "error", err) + writeJSONError(w, http.StatusInternalServerError, "internal_error", "could not read schema version") + return + } + tables, err := s.Store.DumpTables(ctx) + if err != nil { + s.Logger.Error("backup_dump_failed", "error", err) + writeJSONError(w, http.StatusInternalServerError, "internal_error", "could not dump tables") + return + } + canary, err := wgkeys.Encrypt(s.AccountKeyEncryptionKey, backupCanaryPlaintext) + if err != nil { + s.Logger.Error("backup_canary_failed", "error", err) + writeJSONError(w, http.StatusInternalServerError, "internal_error", "could not build backup") + return + } + + backup := backupFile{ + Format: backupFormat, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + Migrations: migrations, + KeyCanary: canary, + CA: s.readCAForBackup(), + Tables: tables, + } + + if identity, ok := callerIdentityFromContext(ctx); ok { + if err := s.Store.InsertAuditLog(ctx, identity.AdminUsername, "backup.downloaded", "panel", nil, r.RemoteAddr); err != nil { + s.Logger.Error("audit_log_failed", "error", err) + } + } + + filename := "wgpanel-backup-" + time.Now().UTC().Format("20060102T150405Z") + ".json" + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(backup); err != nil { + s.Logger.Error("backup_write_failed", "error", err) + } +} + +// readCAForBackup best-effort reads the CA keypair off disk. A deployment without +// one (files missing, no volume) just produces a backup without a ca section - +// restoring such a backup simply leaves the current CA in place. +func (s *Server) readCAForBackup() *backupCA { + if s.CADataDir == "" { + return nil + } + cert, err := os.ReadFile(filepath.Join(s.CADataDir, "ca-cert.pem")) + if err != nil { + s.Logger.Warn("backup_ca_cert_read_failed", "error", err) + return nil + } + key, err := os.ReadFile(filepath.Join(s.CADataDir, "ca-key.pem")) + if err != nil { + s.Logger.Warn("backup_ca_key_read_failed", "error", err) + return nil + } + return &backupCA{CertPEM: string(cert), KeyPEM: string(key)} +} + +type restoreBackupResponse struct { + Restored map[string]int `json:"restored"` + // CARestored is true when the backup carried a CA keypair and it was written + // to disk. RestartRequired additionally means that keypair differs from the + // one this API loaded at startup - node-agent mTLS keeps using the old CA + // until the api container is restarted (`wgpanel restart`). + CARestored bool `json:"ca_restored"` + RestartRequired bool `json:"restart_required"` +} + +// handleRestoreBackup replaces ALL panel state with an uploaded backup file. +// super_admin-only. The three validations happen before anything is touched, in +// increasing order of specificity: file shape, schema version, encryption key. +func (s *Server) handleRestoreBackup(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Config tables without metrics history stay small; 256MB is far above any + // realistic backup while still bounding a hostile upload. + r.Body = http.MaxBytesReader(w, r.Body, 256<<20) + + var backup backupFile + if err := json.NewDecoder(r.Body).Decode(&backup); err != nil { + var maxErr *http.MaxBytesError + if errors.As(err, &maxErr) { + writeJSONError(w, http.StatusRequestEntityTooLarge, "invalid_request", "backup file exceeds the 256MB limit") + return + } + writeJSONError(w, http.StatusBadRequest, "invalid_request", "not a valid JSON backup file") + return + } + if backup.Format != backupFormat { + writeJSONError(w, http.StatusBadRequest, "invalid_request", + fmt.Sprintf("unrecognized backup format %q (expected %q)", backup.Format, backupFormat)) + return + } + + migrations, err := s.Store.AppliedMigrations(ctx) + if err != nil { + s.Logger.Error("restore_migrations_failed", "error", err) + writeJSONError(w, http.StatusInternalServerError, "internal_error", "could not read schema version") + return + } + if !slices.Equal(backup.Migrations, migrations) { + // Exact match, not prefix-compatibility: json_populate_recordset against a + // drifted schema fails in data-dependent ways (or silently NULLs columns), + // so "same panel version as the backup" is the only safe contract. + writeJSONError(w, http.StatusConflict, "schema_mismatch", + "this backup was taken on a different panel version - run the same version the backup came from, then restore") + return + } + + if plaintext, err := wgkeys.Decrypt(s.AccountKeyEncryptionKey, backup.KeyCanary); err != nil || plaintext != backupCanaryPlaintext { + writeJSONError(w, http.StatusConflict, "encryption_key_mismatch", + "this backup's account keys were encrypted with a different ACCOUNT_KEY_ENCRYPTION_KEY - set the original key in deploy/.env before restoring") + return + } + + counts, err := s.Store.RestoreTables(ctx, backup.Tables) + if err != nil { + s.Logger.Error("restore_tables_failed", "error", err) + writeJSONError(w, http.StatusInternalServerError, "internal_error", "restore failed - no changes were applied: "+err.Error()) + return + } + + resp := restoreBackupResponse{Restored: counts} + if backup.CA != nil && s.CADataDir != "" { + if err := s.writeRestoredCA(*backup.CA); err != nil { + // The database restore already committed - report the partial outcome + // honestly rather than failing the whole request after the point of no + // return. The old CA keeps working meanwhile. + s.Logger.Error("restore_ca_write_failed", "error", err) + } else { + resp.CARestored = true + resp.RestartRequired = !bytes.Equal(bytes.TrimSpace(s.CA.CertPEM), bytes.TrimSpace([]byte(backup.CA.CertPEM))) + } + } + + // Written after RestoreTables so this lands in the restored audit_log - the + // restore event must be visible in the timeline the panel now shows. + if identity, ok := callerIdentityFromContext(ctx); ok { + if err := s.Store.InsertAuditLog(ctx, identity.AdminUsername, "backup.restored", "panel", counts, r.RemoteAddr); err != nil { + s.Logger.Error("audit_log_failed", "error", err) + } + } + + writeJSON(w, http.StatusOK, resp) +} + +func (s *Server) writeRestoredCA(ca backupCA) error { + if err := os.MkdirAll(s.CADataDir, 0o700); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(s.CADataDir, "ca-cert.pem"), []byte(ca.CertPEM), 0o600); err != nil { + return err + } + return os.WriteFile(filepath.Join(s.CADataDir, "ca-key.pem"), []byte(ca.KeyPEM), 0o600) +} diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index 93e968d..2c942a9 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -35,6 +35,10 @@ type Server struct { // always include the panel site block, e.g. when only a subscription domain is // being configured). See domainConfigFromSettings. BootPanelDomain string + // CADataDir is where the node-mTLS CA keypair lives on disk (cmd/api's + // caDataDir) so backup can include it and restore can replace it. Empty + // disables the CA portion of backup/restore. + CADataDir string } // Routes builds the full handler tree: public routes (proxied by Caddy), the @@ -121,5 +125,11 @@ func (s *Server) Routes() http.Handler { mux.Handle("GET /api/v1/settings", s.requireAdmin(http.HandlerFunc(s.handleGetSettings))) mux.Handle("PATCH /api/v1/settings", s.requireAdmin(s.requireRole("super_admin", http.HandlerFunc(s.handleUpdateSettings)))) + // Backup & restore (see backup.go's doc comment) - both directions are + // super_admin-only: the file contains admin password hashes, the CA private + // key and every subscription token, and restore replaces ALL panel state. + mux.Handle("GET /api/v1/backup", s.requireAdmin(s.requireRole("super_admin", http.HandlerFunc(s.handleDownloadBackup)))) + mux.Handle("POST /api/v1/backup/restore", s.requireAdmin(s.requireRole("super_admin", http.HandlerFunc(s.handleRestoreBackup)))) + return s.loggingMiddleware(mux) } diff --git a/backend/internal/store/backup.go b/backend/internal/store/backup.go new file mode 100644 index 0000000..6df98c0 --- /dev/null +++ b/backend/internal/store/backup.go @@ -0,0 +1,131 @@ +package store + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/jackc/pgx/v5" +) + +// backupTables is every table included in a panel backup, in an order that +// satisfies FK dependencies on insert (accounts/nodes before account_peers, which +// references both). The two TimescaleDB hypertables (peer_traffic_samples, +// node_metrics) are deliberately excluded: they're high-volume rolling history +// whose durable aggregate already lives on accounts.data_used_bytes, so a backup +// stays small and restorable while losing only chart history. +var backupTables = []string{ + "admins", + "nodes", + "accounts", + "account_peers", + "account_devices", + "api_keys", + "panel_settings", + "audit_log", +} + +// AppliedMigrations returns the filenames recorded in schema_migrations, sorted. +// A backup embeds this list so a restore can refuse a file taken on a different +// schema version (json_populate_recordset would silently NULL any column the old +// schema didn't have, then fail on the first NOT NULL - or worse, not fail). +func (s *Store) AppliedMigrations(ctx context.Context) ([]string, error) { + rows, err := s.pool.Query(ctx, `SELECT filename FROM schema_migrations ORDER BY filename`) + if err != nil { + return nil, err + } + defer rows.Close() + + var names []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + names = append(names, name) + } + return names, rows.Err() +} + +// DumpTables serializes every backup table to JSON rows inside one repeatable-read +// read-only transaction, so the dump is a consistent snapshot even while agents +// are heartbeating writes into nodes/accounts. Table names are interpolated into +// SQL but only ever come from the compile-time backupTables list. +func (s *Store) DumpTables(ctx context.Context) (map[string]json.RawMessage, error) { + tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + out := make(map[string]json.RawMessage, len(backupTables)) + for _, table := range backupTables { + var rows string + err := tx.QueryRow(ctx, `SELECT COALESCE(json_agg(t), '[]'::json)::text FROM `+table+` t`).Scan(&rows) + if err != nil { + return nil, fmt.Errorf("dump %s: %w", table, err) + } + out[table] = json.RawMessage(rows) + } + return out, tx.Commit(ctx) +} + +// RestoreTables replaces the contents of every backup table with the given rows, +// atomically: one transaction truncates them all (single statement, so FK order +// doesn't matter for the delete half) and re-inserts in dependency order via +// json_populate_recordset - the exact inverse of DumpTables' json_agg, which is +// what makes this schema-generic instead of a hand-written struct per table. +// Returns per-table restored row counts. A table absent from the map is left +// empty, not skipped - the restored panel must reflect the backup, not a merge. +func (s *Store) RestoreTables(ctx context.Context, tables map[string]json.RawMessage) (map[string]int, error) { + known := make(map[string]bool, len(backupTables)) + for _, t := range backupTables { + known[t] = true + } + for name := range tables { + if !known[name] { + return nil, fmt.Errorf("backup contains unknown table %q", name) + } + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + if _, err := tx.Exec(ctx, `TRUNCATE `+strings.Join(backupTables, ", ")+` CASCADE`); err != nil { + return nil, fmt.Errorf("truncate: %w", err) + } + + counts := make(map[string]int, len(backupTables)) + for _, table := range backupTables { + rows, ok := tables[table] + if !ok { + counts[table] = 0 + continue + } + tag, err := tx.Exec(ctx, + `INSERT INTO `+table+` SELECT * FROM json_populate_recordset(NULL::`+table+`, $1::json)`, + string(rows), + ) + if err != nil { + return nil, fmt.Errorf("restore %s: %w", table, err) + } + counts[table] = int(tag.RowsAffected()) + } + + // audit_log.id is BIGSERIAL - its sequence lives outside the rows, so after + // inserting explicit ids the next nextval() would collide with a restored row. + if _, err := tx.Exec(ctx, ` + SELECT setval(pg_get_serial_sequence('audit_log', 'id'), COALESCE((SELECT MAX(id) FROM audit_log), 0) + 1, false) + `); err != nil { + return nil, fmt.Errorf("resync audit_log sequence: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return counts, nil +} diff --git a/backend/internal/store/backup_test.go b/backend/internal/store/backup_test.go new file mode 100644 index 0000000..39a535c --- /dev/null +++ b/backend/internal/store/backup_test.go @@ -0,0 +1,100 @@ +package store + +import ( + "context" + "encoding/json" + "os" + "testing" +) + +// TestBackupRoundTrip proves DumpTables -> RestoreTables is lossless against a real +// database: the json_agg/json_populate_recordset pair is the entire backup format, +// so its fidelity (timestamps, jsonb detail, nullable columns, the audit_log +// sequence) is exactly what needs a live-Postgres test rather than a mock. Ends by +// restoring the state it dumped, so it leaves the database as it found it. +// +// Skipped unless WGPANEL_TEST_POSTGRES_DSN is set - see TestStoreIntegration. +func TestBackupRoundTrip(t *testing.T) { + dsn := os.Getenv("WGPANEL_TEST_POSTGRES_DSN") + if dsn == "" { + t.Skip("set WGPANEL_TEST_POSTGRES_DSN to run store integration tests") + } + + ctx := context.Background() + s, err := Open(ctx, dsn) + if err != nil { + t.Fatalf("open store: %v", err) + } + defer s.Close() + if err := s.Migrate(ctx); err != nil { + t.Fatalf("migrate: %v", err) + } + + // Seed rows exercising the trickier column types: jsonb (audit detail), + // nullable text, timestamptz. + if _, err := s.CreateAdmin(ctx, "backup-it-admin", "x-hash-x", "operator"); err != nil { + t.Fatalf("seed admin: %v", err) + } + if err := s.InsertAuditLog(ctx, "backup-it-admin", "test.seeded", "backup-test", map[string]any{"n": 1}, "127.0.0.1"); err != nil { + t.Fatalf("seed audit: %v", err) + } + + migrations, err := s.AppliedMigrations(ctx) + if err != nil { + t.Fatalf("applied migrations: %v", err) + } + if len(migrations) == 0 { + t.Fatal("expected applied migrations to be non-empty") + } + + dump, err := s.DumpTables(ctx) + if err != nil { + t.Fatalf("dump: %v", err) + } + for _, table := range backupTables { + if _, ok := dump[table]; !ok { + t.Fatalf("dump missing table %s", table) + } + } + + adminsBefore, err := s.AdminCount(ctx) + if err != nil { + t.Fatal(err) + } + + // Mutate after the dump - restore must roll this back. + if _, err := s.CreateAdmin(ctx, "backup-it-mutation", "x-hash-x", "support"); err != nil { + t.Fatalf("mutate admin: %v", err) + } + + counts, err := s.RestoreTables(ctx, dump) + if err != nil { + t.Fatalf("restore: %v", err) + } + if counts["admins"] != adminsBefore { + t.Errorf("restored %d admins, want %d", counts["admins"], adminsBefore) + } + + adminsAfter, err := s.AdminCount(ctx) + if err != nil { + t.Fatal(err) + } + if adminsAfter != adminsBefore { + t.Errorf("admin count after restore = %d, want %d (mutation should be gone)", adminsAfter, adminsBefore) + } + + // The singleton settings row must survive (id=1 is seeded by migration and + // assumed everywhere), and the audit sequence must have been resynced - a new + // insert would otherwise collide with a restored id. + if _, err := s.GetSettings(ctx); err != nil { + t.Errorf("settings after restore: %v", err) + } + if err := s.InsertAuditLog(ctx, "backup-it-admin", "test.after_restore", "backup-test", nil, "127.0.0.1"); err != nil { + t.Errorf("audit insert after restore (sequence resync): %v", err) + } + + // Unknown table names must be rejected before anything is touched. + if _, err := s.RestoreTables(ctx, map[string]json.RawMessage{"evil; DROP TABLE admins": json.RawMessage(`[]`)}); err == nil { + t.Error("expected unknown-table restore to be rejected") + } +} diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 2c36674..0ec0681 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -107,6 +107,11 @@ tags: description: Append-only record of every mutating action. super_admin only. - name: Settings description: Panel-wide configuration and defaults. Readable by any admin, editable by super_admin only. + - name: Backup + description: >- + Whole-panel backup & restore as a single JSON file. super_admin only, both + directions audit-logged. The file contains every panel secret except + deploy/.env - treat it like the database itself. - name: Health description: Liveness/readiness probes. - name: Node Agent @@ -1359,6 +1364,72 @@ paths: "403": { $ref: "#/components/responses/Forbidden" } "500": { $ref: "#/components/responses/InternalError" } + /api/v1/backup: + get: + tags: [Backup] + summary: Download a full panel backup + description: >- + Requires role = `super_admin`. Streams a JSON attachment containing every + config table (admins, nodes, accounts, account_peers, account_devices, + api_keys, panel_settings, audit_log) plus the node-mTLS CA keypair. + Metrics history (TimescaleDB hypertables) is excluded - account usage + totals live on the accounts rows and are included. Account private keys + remain encrypted with the deployment's ACCOUNT_KEY_ENCRYPTION_KEY, so a + backup is only restorable alongside the original deploy/.env. + security: + - BearerAuth: [] + responses: + "200": + description: The backup file (Content-Disposition attachment). + content: + application/json: + schema: { $ref: "#/components/schemas/BackupFile" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "500": { $ref: "#/components/responses/InternalError" } + + /api/v1/backup/restore: + post: + tags: [Backup] + summary: Replace ALL panel state with an uploaded backup + description: >- + Requires role = `super_admin`. Atomically replaces the contents of every + backed-up table with the file's rows (a table absent from the file is + emptied, not skipped) and writes the CA keypair to disk - if that keypair + differs from the running one, node-agent mTLS needs an api restart to pick + it up (`restart_required` on the response). Validated before anything is + touched: the file's schema version must exactly match this panel's + (`schema_mismatch`) and its key canary must decrypt with the current + ACCOUNT_KEY_ENCRYPTION_KEY (`encryption_key_mismatch`). Restoring replaces + admin users too - the caller's own login may change. + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/BackupFile" } + responses: + "200": + description: Restore succeeded. + content: + application/json: + schema: { $ref: "#/components/schemas/RestoreResult" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "409": + description: "`schema_mismatch` or `encryption_key_mismatch` - nothing was changed." + content: + application/json: + schema: { $ref: "#/components/schemas/ErrorEnvelope" } + "413": + description: Backup file exceeds the 256MB limit. + content: + application/json: + schema: { $ref: "#/components/schemas/ErrorEnvelope" } + "500": { $ref: "#/components/responses/InternalError" } + components: securitySchemes: BearerAuth: @@ -1707,6 +1778,53 @@ components: the default. required: [default_node_capacity, client_dns] + BackupFile: + type: object + description: >- + The wgpanel-backup/1 format. `tables` maps each table name to its rows as + plain JSON objects (the exact json_agg of the table); `key_canary` is a + fixed string encrypted with ACCOUNT_KEY_ENCRYPTION_KEY so restore can + verify key compatibility up front; `ca` is the node-mTLS CA keypair (PEM), + omitted if the deployment has none. + required: [format, created_at, migrations, key_canary, tables] + properties: + format: { type: string, enum: [wgpanel-backup/1] } + created_at: { type: string, format: date-time } + migrations: + type: array + items: { type: string } + description: Applied migration filenames at backup time - restore requires an exact match. + key_canary: { type: string } + ca: + type: object + nullable: true + properties: + cert_pem: { type: string } + key_pem: { type: string } + required: [cert_pem, key_pem] + tables: + type: object + additionalProperties: + type: array + items: { type: object, additionalProperties: true } + + RestoreResult: + type: object + required: [restored, ca_restored, restart_required] + properties: + restored: + type: object + additionalProperties: { type: integer } + description: Rows restored per table. + ca_restored: + type: boolean + description: The backup carried a CA keypair and it was written to disk. + restart_required: + type: boolean + description: >- + The restored CA differs from the one loaded at startup - node-agent + mTLS keeps using the old CA until the api container is restarted. + SettingsUpdateResult: allOf: - $ref: "#/components/schemas/Settings" diff --git a/frontend/public/openapi.yaml b/frontend/public/openapi.yaml index 2c36674..0ec0681 100644 --- a/frontend/public/openapi.yaml +++ b/frontend/public/openapi.yaml @@ -107,6 +107,11 @@ tags: description: Append-only record of every mutating action. super_admin only. - name: Settings description: Panel-wide configuration and defaults. Readable by any admin, editable by super_admin only. + - name: Backup + description: >- + Whole-panel backup & restore as a single JSON file. super_admin only, both + directions audit-logged. The file contains every panel secret except + deploy/.env - treat it like the database itself. - name: Health description: Liveness/readiness probes. - name: Node Agent @@ -1359,6 +1364,72 @@ paths: "403": { $ref: "#/components/responses/Forbidden" } "500": { $ref: "#/components/responses/InternalError" } + /api/v1/backup: + get: + tags: [Backup] + summary: Download a full panel backup + description: >- + Requires role = `super_admin`. Streams a JSON attachment containing every + config table (admins, nodes, accounts, account_peers, account_devices, + api_keys, panel_settings, audit_log) plus the node-mTLS CA keypair. + Metrics history (TimescaleDB hypertables) is excluded - account usage + totals live on the accounts rows and are included. Account private keys + remain encrypted with the deployment's ACCOUNT_KEY_ENCRYPTION_KEY, so a + backup is only restorable alongside the original deploy/.env. + security: + - BearerAuth: [] + responses: + "200": + description: The backup file (Content-Disposition attachment). + content: + application/json: + schema: { $ref: "#/components/schemas/BackupFile" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "500": { $ref: "#/components/responses/InternalError" } + + /api/v1/backup/restore: + post: + tags: [Backup] + summary: Replace ALL panel state with an uploaded backup + description: >- + Requires role = `super_admin`. Atomically replaces the contents of every + backed-up table with the file's rows (a table absent from the file is + emptied, not skipped) and writes the CA keypair to disk - if that keypair + differs from the running one, node-agent mTLS needs an api restart to pick + it up (`restart_required` on the response). Validated before anything is + touched: the file's schema version must exactly match this panel's + (`schema_mismatch`) and its key canary must decrypt with the current + ACCOUNT_KEY_ENCRYPTION_KEY (`encryption_key_mismatch`). Restoring replaces + admin users too - the caller's own login may change. + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/BackupFile" } + responses: + "200": + description: Restore succeeded. + content: + application/json: + schema: { $ref: "#/components/schemas/RestoreResult" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "409": + description: "`schema_mismatch` or `encryption_key_mismatch` - nothing was changed." + content: + application/json: + schema: { $ref: "#/components/schemas/ErrorEnvelope" } + "413": + description: Backup file exceeds the 256MB limit. + content: + application/json: + schema: { $ref: "#/components/schemas/ErrorEnvelope" } + "500": { $ref: "#/components/responses/InternalError" } + components: securitySchemes: BearerAuth: @@ -1707,6 +1778,53 @@ components: the default. required: [default_node_capacity, client_dns] + BackupFile: + type: object + description: >- + The wgpanel-backup/1 format. `tables` maps each table name to its rows as + plain JSON objects (the exact json_agg of the table); `key_canary` is a + fixed string encrypted with ACCOUNT_KEY_ENCRYPTION_KEY so restore can + verify key compatibility up front; `ca` is the node-mTLS CA keypair (PEM), + omitted if the deployment has none. + required: [format, created_at, migrations, key_canary, tables] + properties: + format: { type: string, enum: [wgpanel-backup/1] } + created_at: { type: string, format: date-time } + migrations: + type: array + items: { type: string } + description: Applied migration filenames at backup time - restore requires an exact match. + key_canary: { type: string } + ca: + type: object + nullable: true + properties: + cert_pem: { type: string } + key_pem: { type: string } + required: [cert_pem, key_pem] + tables: + type: object + additionalProperties: + type: array + items: { type: object, additionalProperties: true } + + RestoreResult: + type: object + required: [restored, ca_restored, restart_required] + properties: + restored: + type: object + additionalProperties: { type: integer } + description: Rows restored per table. + ca_restored: + type: boolean + description: The backup carried a CA keypair and it was written to disk. + restart_required: + type: boolean + description: >- + The restored CA differs from the one loaded at startup - node-agent + mTLS keeps using the old CA until the api container is restarted. + SettingsUpdateResult: allOf: - $ref: "#/components/schemas/Settings" diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index bc3640d..5759361 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -1,8 +1,10 @@ -import { useEffect, useState, type FormEvent } from 'react' +import { useEffect, useRef, useState, type FormEvent } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { Save, ShieldCheck, SlidersHorizontal } from 'lucide-react' +import { DatabaseBackup, Download, Save, ShieldCheck, SlidersHorizontal, Upload } from 'lucide-react' import type { LucideIcon } from 'lucide-react' import { apiFetch, ApiError } from '../lib/api' +import { getAccessToken } from '../lib/tokenStore' +import { useAuth } from '../lib/auth' import { useToast } from '../lib/toast' import { PageHeader } from '../components/ui/PageHeader' import { Card } from '../components/ui/Card' @@ -10,6 +12,7 @@ import { Button } from '../components/ui/Button' import { Input } from '../components/ui/Input' import { Field } from '../components/ui/Field' import { Skeleton } from '../components/ui/Skeleton' +import { ConfirmDialog } from '../components/ui/ConfirmDialog' interface Settings { public_base_url: string | null @@ -28,6 +31,145 @@ interface UpdateSettingsResult extends Settings { domain_apply_error: string | null } +interface RestoreResult { + restored: Record + ca_restored: boolean + restart_required: boolean +} + +function BackupCard() { + const { push } = useToast() + const { logout } = useAuth() + const queryClient = useQueryClient() + const fileInputRef = useRef(null) + + const [downloading, setDownloading] = useState(false) + const [restoreFile, setRestoreFile] = useState(null) + const [restoring, setRestoring] = useState(false) + const [restoreResult, setRestoreResult] = useState(null) + + async function downloadBackup() { + setDownloading(true) + try { + const res = await fetch('/api/v1/backup', { + headers: { Authorization: `Bearer ${getAccessToken() ?? ''}` }, + }) + if (!res.ok) { + let message = 'Failed to create backup' + try { + const body = (await res.json()) as { error?: { message?: string } } + message = body?.error?.message ?? message + } catch { + // Not JSON - keep the generic message. + } + throw new Error(message) + } + const filename = + res.headers.get('Content-Disposition')?.match(/filename="?([^";]+)"?/)?.[1] ?? 'wgpanel-backup.json' + const url = URL.createObjectURL(await res.blob()) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) + push('success', 'Backup downloaded - store it somewhere safe, it contains every panel secret') + } catch (err) { + push('error', err instanceof Error ? err.message : 'Failed to create backup') + } finally { + setDownloading(false) + } + } + + async function restoreBackup() { + if (!restoreFile) return + setRestoring(true) + try { + const body = await restoreFile.text() + const result = await apiFetch('/api/v1/backup/restore', { method: 'POST', body }) + setRestoreResult(result) + // Everything cached client-side describes the pre-restore panel. + queryClient.clear() + push('success', 'Backup restored') + } catch (err) { + push('error', err instanceof ApiError ? err.message : 'Restore failed') + } finally { + setRestoring(false) + setRestoreFile(null) + if (fileInputRef.current) fileInputRef.current.value = '' + } + } + + return ( + + +
+
+

+ Metrics history (usage charts) is not included; account usage totals are. Restoring on a new server also + needs the original deploy/.env - account keys are encrypted with its{' '} + ACCOUNT_KEY_ENCRYPTION_KEY. +

+ +
+ +
+

+ Restore replaces all current panel data with the file's + contents - including admin users, so your own login may change. +

+ setRestoreFile(e.target.files?.[0] ?? null)} + /> + +
+ + {restoreResult && ( +
+

+ Restored {restoreResult.restored.accounts ?? 0} accounts, {restoreResult.restored.nodes ?? 0} nodes,{' '} + {restoreResult.restored.admins ?? 0} admins and {restoreResult.restored.api_keys ?? 0} API keys. + {restoreResult.restart_required && + ' The node CA changed - restart the api container (wgpanel restart) so agents can reconnect.'}{' '} + Log in again with the restored credentials. +

+ +
+ )} +
+ + { + setRestoreFile(null) + if (fileInputRef.current) fileInputRef.current.value = '' + }} + onConfirm={restoreBackup} + title="Replace all panel data?" + description={`Everything currently in this panel - accounts, nodes, admins, API keys, settings, audit log - will be replaced by "${restoreFile?.name ?? ''}". This cannot be undone. Download a backup of the current state first if you might need it.`} + confirmLabel="Replace everything" + danger + submitting={restoring} + /> +
+ ) +} + function SectionHeader({ icon: Icon, title, description }: { icon: LucideIcon; title: string; description: string }) { return (
@@ -291,6 +433,8 @@ export function SettingsPage() { )} + +
)