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.
This commit is contained in:
sysops
2026-07-12 00:16:47 +02:00
parent 31f504e61b
commit 144cb2982d
5 changed files with 277 additions and 0 deletions
+37
View File
@@ -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 {