Skip to content

Backup

The backup subsystem ships two distinct export paths plus one plaintext-only import path. Worth understanding the difference before you script around either.

Path What it returns Use it when
POST /api/v1/backup/export (no body) Plaintext JSON, sanitized. Sanitize() strips every credential — bridge PSKs, tunnel private_key, secrets table values, all sensitive kv_settings rows. Response header X-Gateway-Backup-Sanitized: true. Inspection, audit, sharing with reviewers who don’t need secrets.
POST /api/v1/backup/export-encrypted body: {passphrase} AES-256-GCM ciphertext with PBKDF2-HMAC-SHA256 key (100 000 iterations, per-export salt). Includes secrets. GCM tag detects tampering / wrong passphrase on decrypt. The real “lossless backup of everything” path. Use this for off-box archives.
POST /api/v1/backup/import body: plaintext JSON Restores from a plaintext JSON payload. Transactional — wrong shape or schema mismatch rolls back the whole thing. Restoring a sanitized export, or a plaintext blob produced by manually decrypting an encrypted export.

There is no encrypted-import endpoint. To restore from an encrypted export, decrypt it yourself first (via the backup.Decrypt package helper or any tool that can do AES-256-GCM + PBKDF2-HMAC-SHA256 with the magic-V2 header) and POST the resulting plaintext JSON to /backup/import. This asymmetry is real and unfixed today.

Both export paths cover the same runtime configuration scope:

  • Routing policies and route profiles
  • Exception manager state (active exceptions with remaining TTL)
  • Devices and device groups
  • Privacy classes and trust classes
  • DNS rules, zones, forwarders, presets
  • Bridge definitions (endpoints, transport, domain mappings)
  • Alert rules
  • Federation peer rows
  • kv_settings

The difference is Sanitize() — applied to /backup/export but not to /backup/export-encrypted. Sanitize strips: tunnel private_key, bridge psk, secrets-table values, telegram_bot_token, webhook_url, secrets_passphrase, admin_password_hash, mesh / node API keys, plus every kv row matching the _token / _secret / _password / _api_key / _psk suffix list per internal/backup/backup.go:273-281. Restoring a sanitized backup on a clean box re-prompts the operator for the admin password and re-issues secrets that have to be re-entered manually.

gwctl backup export calls the plaintext sanitized endpoint — output is JSON with secrets stripped, no encryption, no passphrase:

Terminal window
gwctl backup export > /var/backups/gateway-$(date +%F).json

For the encrypted-with-secrets variant, go through the REST API:

Terminal window
curl -X POST https://gateway.lan:8080/api/v1/backup/export-encrypted \
-H "Authorization: Bearer $API_KEY" \
-d '{"passphrase":"REDACTED"}' \
--output /var/backups/gateway-$(date +%F).enc

The daemon never logs or persists the passphrase. Lose it, lose the backup.

Scheduling, retention and remote push are managed at runtime via PUT /api/v1/backup/config ({enabled, interval_hours, remote_target} — see recipes). The scheduler produces sanitized JSON, not encrypted blobs.

gwctl backup import <file.json> reads a plaintext JSON file as a positional argument (not stdin), POSTs to /backup/import, and applies inside a single SQLite transaction. If anything fails (schema mismatch, unique-key conflict, dangling reference), the entire restore is rolled back and the daemon continues on its prior state:

Terminal window
gwctl backup import /var/backups/gateway-2026-06-26.json

For an encrypted backup, decrypt first and pipe the plaintext through:

Terminal window
# (decrypt with whichever tool wraps backup.Decrypt for you) > /tmp/decrypted.json
gwctl backup import /tmp/decrypted.json

Selective restores (single config kind) are done from the Backup → Restore UI page, which exposes per-kind toggles.

The daemon exposes a second, narrower export path under /api/v1/config/* — useful for sharing inspection-safe configuration with another operator, or for migrating a specific gateway’s settings without the full audit / log / live-state baggage that the backup format carries.

Path Format Includes sensitive kv_settings? Use it for
POST /api/v1/backup/export encrypted blob Yes (under your passphrase) The canonical “full daemon state” snapshot.
GET /api/v1/config/export plaintext JSON No (filtered via IsSensitiveKVKey) Inspection, sharing, source-control friendly diffs.
POST /api/v1/config/export-encrypted encrypted blob Yes (everything except jwt_*) Moving a curated config between gateways with full secrets.
POST /api/v1/config/import plaintext JSON only Apply a /config/export payload. Encrypted import is via /backup/import.

The encrypted config export uses the same AES-256-GCM + PBKDF2-HMAC-SHA256 (100k iter) helper as backups, so a gateway-config.enc file produced by /config/export-encrypted is structurally compatible with backup.Decrypt — pre-shared knowledge worth having when scripting recovery.

The plaintext /config/export is safe to share only if you trust the IsSensitiveKVKey filter. It redacts every key matching the suffixes _token, _secret, _password, _api_key, _psk, plus the explicit names telegram_bot_token, webhook_url, secrets_passphrase, mesh_api_key, node_api_key, and admin_password_hash. The encrypted variant additionally filters jwt_* keys (session-signing keys are per-host and not portable). When in doubt, use the encrypted variant.

GCM’s authentication tag is what verifies integrity. An attacker who flips a byte in the ciphertext fails the tag check on decrypt; an attacker who supplies the wrong passphrase fails the same check. There is no separate hash sidecar.

On import the daemon:

  1. Reads the salt header, derives the AES key with PBKDF2.
  2. Decrypts; GCM-authenticates in the same step.
  3. Parses the JSON, validates each object against the schema.
  4. Applies in a single SQLite transaction.

A failure at any step aborts with a non-zero exit code and an audit log entry. There’s no “partially restored” state.

Method Path Purpose
POST /api/v1/backup/export Encrypted full-state export (returns ciphertext)
POST /api/v1/backup/import Decrypt + apply
GET /api/v1/backup/history Past backup runs (local + remote)
PUT /api/v1/backup/schedule Edit the schedule
GET /api/v1/config/export Plaintext JSON export, sensitive kv redacted
POST /api/v1/config/export-encrypted Encrypted curated export with secrets
POST /api/v1/config/import Apply a plaintext config export

After your first export lands on disk:

  1. Verify the restore. Pull the file to a throwaway VM and gwctl backup import it. The worst time to discover a corrupted backup is during an actual incident.
  2. Push remotely. Configure SCP/SFTP via PUT /api/v1/backup/config so a single-disk failure doesn’t take both daemon and backup with it.
  3. Wire the backup_failed alert to a paging channel. A silent failure for a week is the same as no backup at all.
  4. Rotate the passphrase quarterly. Run a new export with the new passphrase, keep one cycle of old-passphrase backups for safety, verify the restore under the new passphrase, retire the old one.
  • Configuration — bootstrap vs runtime; restore only restores runtime.
  • Federation — federation is not a backup; secondaries can be wiped together with the primary if the underlying config is bad.
  • Alerts & monitoring — the built-in backup-failed rule fires when a scheduled run fails.
  • Recipes → Backup — schedule + restore snippets.