From 144cb2982df3b7a9ca96c18e54a099f5de400cc5 Mon Sep 17 00:00:00 2001 From: sysops Date: Sun, 12 Jul 2026 00:16:47 +0200 Subject: [PATCH] Add manual, optionally-encrypted backup download (no auto-upload) New POST /backup/download (admin-only) tars the whole jsondb directory (all servers/clients/users/settings) and streams it back as a file download. If a passphrase is given, the archive is encrypted first (AES-256-GCM, scrypt-derived key, backup/encrypt.go) - a small self-contained format, not gpg/OpenPGP-compatible, to avoid shelling out to an external binary or adding a PGP dependency. Deliberately does NOT upload anywhere automatically (e.g. to Nextcloud) - the archive only ever leaves the server as this one HTTP response to the requesting admin, who is responsible for storing it themselves. New "Download Backup" button + passphrase modal on the Global Settings page. --- backup/archive.go | 61 ++++++++++++++++++++++ backup/encrypt.go | 85 +++++++++++++++++++++++++++++++ handler/routes.go | 37 ++++++++++++++ main.go | 1 + templates/global_settings.html | 93 ++++++++++++++++++++++++++++++++++ 5 files changed, 277 insertions(+) create mode 100644 backup/archive.go create mode 100644 backup/encrypt.go diff --git a/backup/archive.go b/backup/archive.go new file mode 100644 index 0000000..ef68593 --- /dev/null +++ b/backup/archive.go @@ -0,0 +1,61 @@ +// Package backup builds a downloadable, optionally encrypted snapshot of +// the jsondb directory (servers, clients, users, settings - everything +// needed to restore this installation). It never transmits data anywhere +// on its own; the archive is only ever returned to an authenticated admin +// as an HTTP download, never uploaded automatically. +package backup + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "os" + "path/filepath" +) + +// BuildArchive tars+gzips every regular file under dbPath into memory, +// preserving paths relative to dbPath so restoring means extracting into a +// fresh, empty db directory. +func BuildArchive(dbPath string) ([]byte, error) { + var buf bytes.Buffer + gzw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gzw) + + err := filepath.Walk(dbPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + relPath, err := filepath.Rel(dbPath, path) + if err != nil { + return err + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + header := &tar.Header{ + Name: relPath, + Mode: int64(info.Mode().Perm()), + Size: int64(len(data)), + ModTime: info.ModTime(), + } + if err := tw.WriteHeader(header); err != nil { + return err + } + _, err = tw.Write(data) + return err + }) + if err != nil { + return nil, err + } + if err := tw.Close(); err != nil { + return nil, err + } + if err := gzw.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} diff --git a/backup/encrypt.go b/backup/encrypt.go new file mode 100644 index 0000000..5869695 --- /dev/null +++ b/backup/encrypt.go @@ -0,0 +1,85 @@ +package backup + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "errors" + "io" + + "golang.org/x/crypto/scrypt" +) + +const ( + saltSize = 16 + keySize = 32 // AES-256 +) + +// deriveKey turns a passphrase into a 32-byte AES key via scrypt, using the +// given salt. scrypt (N=32768, r=8, p=1) is deliberately expensive to slow +// down offline brute-force of a leaked archive. +func deriveKey(passphrase string, salt []byte) ([]byte, error) { + return scrypt.Key([]byte(passphrase), salt, 32768, 8, 1, keySize) +} + +// Encrypt symmetrically encrypts data with a passphrase using scrypt key +// derivation + AES-256-GCM. Output layout: [16-byte salt][12-byte +// nonce][GCM ciphertext+tag]. This is a small self-contained format +// specific to this app - NOT OpenPGP/gpg-compatible - chosen to avoid +// shelling out to an external gpg binary or adding a full PGP dependency. +func Encrypt(plaintext []byte, passphrase string) ([]byte, error) { + salt := make([]byte, saltSize) + if _, err := io.ReadFull(rand.Reader, salt); err != nil { + return nil, err + } + key, err := deriveKey(passphrase, salt) + if err != nil { + return nil, err + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, err + } + ciphertext := gcm.Seal(nil, nonce, plaintext, nil) + + out := make([]byte, 0, saltSize+len(nonce)+len(ciphertext)) + out = append(out, salt...) + out = append(out, nonce...) + out = append(out, ciphertext...) + return out, nil +} + +// Decrypt reverses Encrypt, for manual restore/recovery. +func Decrypt(data []byte, passphrase string) ([]byte, error) { + if len(data) < saltSize { + return nil, errors.New("backup: ciphertext too short") + } + salt := data[:saltSize] + key, err := deriveKey(passphrase, salt) + if err != nil { + return nil, err + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + nonceSize := gcm.NonceSize() + if len(data) < saltSize+nonceSize { + return nil, errors.New("backup: ciphertext too short") + } + nonce := data[saltSize : saltSize+nonceSize] + ciphertext := data[saltSize+nonceSize:] + return gcm.Open(nil, nonce, ciphertext, nil) +} diff --git a/handler/routes.go b/handler/routes.go index 261ac71..1c917d8 100644 --- a/handler/routes.go +++ b/handler/routes.go @@ -23,6 +23,7 @@ import ( "golang.zx2c4.com/wireguard/wgctrl" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + "github.com/ngoduykhanh/wireguard-ui/backup" "github.com/ngoduykhanh/wireguard-ui/emailer" "github.com/ngoduykhanh/wireguard-ui/model" "github.com/ngoduykhanh/wireguard-ui/store" @@ -1587,6 +1588,42 @@ func GetHashesChanges(db store.IStore) echo.HandlerFunc { } } +// DownloadBackup handler builds a tar.gz snapshot of the entire jsondb +// directory (all servers/clients/users/settings, everything needed to +// restore this installation) and returns it as a file download. If a +// non-empty "passphrase" is given in the JSON body, the archive is +// encrypted (AES-256-GCM via backup.Encrypt) before being returned. +// Admin-only. The archive is never transmitted anywhere automatically - +// it only ever goes out as this one HTTP response to the requesting admin. +func DownloadBackup(db store.IStore) echo.HandlerFunc { + return func(c echo.Context) error { + var payload struct { + Passphrase string `json:"passphrase"` + } + // best-effort bind; an empty/absent body just means "no encryption" + c.Bind(&payload) + + archiveData, err := backup.BuildArchive(db.GetPath()) + if err != nil { + log.Error("Cannot build backup archive: ", err) + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot build backup archive"}) + } + + filename := fmt.Sprintf("wireguard-ui-multi-backup-%s.tar.gz", time.Now().UTC().Format("20060102-150405")) + if payload.Passphrase != "" { + archiveData, err = backup.Encrypt(archiveData, payload.Passphrase) + if err != nil { + log.Error("Cannot encrypt backup archive: ", err) + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot encrypt backup archive"}) + } + filename += ".enc" + } + + c.Response().Header().Set(echo.HeaderContentDisposition, fmt.Sprintf("attachment; filename=%s", filename)) + return c.Blob(http.StatusOK, "application/octet-stream", archiveData) + } +} + // AboutPage handler func AboutPage() echo.HandlerFunc { return func(c echo.Context) error { diff --git a/main.go b/main.go index 1bfcdcf..246f6fa 100644 --- a/main.go +++ b/main.go @@ -265,6 +265,7 @@ func main() { app.POST(util.BasePath+"/servers/:id/api/apply-wg-config", handler.ApplyServerConfig(db, tmplDir), handler.ValidSession, handler.ContentTypeJson, handler.RequireServerAccess(db)) app.GET(util.BasePath+"/servers/:id/settings", handler.GetServerSettings(db), handler.ValidSession, handler.RequireServerAccess(db)) app.POST(util.BasePath+"/servers/:id/settings", handler.SaveServerSettingsHandler(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin) + app.POST(util.BasePath+"/backup/download", handler.DownloadBackup(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin) app.GET(util.BasePath+"/api/clients", handler.GetClients(db), handler.ValidSession) app.GET(util.BasePath+"/api/client/:id", handler.GetClient(db), handler.ValidSession) app.GET(util.BasePath+"/api/machine-ips", handler.MachineIPAddresses(), handler.ValidSession) diff --git a/templates/global_settings.html b/templates/global_settings.html index 73b3c93..d9a6640 100644 --- a/templates/global_settings.html +++ b/templates/global_settings.html @@ -120,9 +120,57 @@ Global Settings +
+
+
+
+

Backup

+
+
+

Download a snapshot of the entire database (all servers, clients, users + and settings, including private keys). Optionally encrypt it with a + passphrase before it's downloaded - nothing is ever uploaded + automatically, this only produces a file for you to store yourself.

+ +
+
+
+
+ + +