From 926d98f00ddaacc71bf43cfd4e8be043f7ec2322 Mon Sep 17 00:00:00 2001 From: Phil Pluckthun Date: Mon, 6 Apr 2026 14:10:34 +0100 Subject: [PATCH] Add basic backup module --- lib/apps/default.nix | 4 + lib/apps/restoreBackup.nix | 116 +++++++++++++ machines/ramune/configuration.nix | 5 + modules/server/backup.nix | 174 +++++++++++++++++++ modules/server/default.nix | 1 + modules/server/encrypt/rclone-backup.env.age | Bin 0 -> 548 bytes modules/server/tangled.nix | 4 + modules/server/vaultwarden.nix | 6 + secrets.nix | 1 + 9 files changed, 311 insertions(+) create mode 100644 lib/apps/restoreBackup.nix create mode 100644 modules/server/backup.nix create mode 100644 modules/server/encrypt/rclone-backup.env.age diff --git a/lib/apps/default.nix b/lib/apps/default.nix index 51020b7..b46f3e3 100644 --- a/lib/apps/default.nix +++ b/lib/apps/default.nix @@ -3,4 +3,8 @@ inputs: { type = "app"; program = import ./genCerts.nix inputs; }; + restoreBackup = { + type = "app"; + program = import ./restoreBackup.nix inputs; + }; } diff --git a/lib/apps/restoreBackup.nix b/lib/apps/restoreBackup.nix new file mode 100644 index 0000000..150ad63 --- /dev/null +++ b/lib/apps/restoreBackup.nix @@ -0,0 +1,116 @@ +# Restore a backup from R2. +# +# On the server (defaults auto-detected): +# restoreBackup vaultwarden # list available backups +# restoreBackup vaultwarden 2026-04-05 # restore specific date +# +# On a fresh machine (specify paths explicitly): +# restoreBackup --config ./rclone.conf --env ./secrets.env vaultwarden 2026-04-05 +# +# Default paths (managed by NixOS): +# Config: /etc/rclone-backup.conf (modules/server/backup.nix) +# Secrets: /run/secrets/rclone-backup.env (agenix, modules/server/backup.nix) + +{ pkgs, ... }: + +let + rclone = "${pkgs.rclone}/bin/rclone"; + coreutils = pkgs.coreutils; + + defaultConf = "/etc/rclone-backup.conf"; + defaultEnv = "/run/secrets/rclone-backup.env"; +in + toString (pkgs.writers.writeBash "restoreBackup" '' + set -euo pipefail + + REMOTE="r2-encrypted" + CONFIG="${defaultConf}" + ENV_FILE="${defaultEnv}" + SERVICE="" + DATE="" + + usage() { + echo "Usage: restoreBackup [--config ] [--env ] [date]" + echo "" + echo " --config Path to rclone config (default: ${defaultConf})" + echo " --env Path to rclone secrets env file (default: ${defaultEnv})" + echo " service Name of the backup to restore (e.g. vaultwarden, tangled)" + echo " date Backup date (YYYY-MM-DD) for snapshot backups. Omit to list dates or download synced backups." + echo "" + echo "Snapshot backups (sqlite) are stored by date. Synced backups (directories) are downloaded directly." + echo "" + echo "Examples:" + echo " restoreBackup vaultwarden # list available snapshot dates" + echo " restoreBackup vaultwarden 2026-04-05 # restore specific snapshot" + echo " restoreBackup tangled # download synced backup" + echo " restoreBackup --config ./rclone.conf --env ./secrets.env tangled" + exit 1 + } + + while [[ $# -gt 0 ]]; do + case "$1" in + --config) CONFIG="$2"; shift 2 ;; + --env) ENV_FILE="$2"; shift 2 ;; + -h|--help) usage ;; + *) if [[ -z "$SERVICE" ]]; then SERVICE="$1"; else DATE="$1"; fi; shift ;; + esac + done + + if [[ -z "$SERVICE" ]]; then + usage + fi + + if [[ ! -f "$CONFIG" ]]; then + echo "Error: rclone config not found at $CONFIG. Use --config to specify one." + exit 1 + fi + + if [[ -f "$ENV_FILE" ]]; then + set -a + source "$ENV_FILE" + set +a + else + echo "Warning: No env file at $ENV_FILE. Rclone credentials must be set in environment." + fi + + RCLONE="${rclone} --config $CONFIG" + + if [[ -n "$DATE" ]]; then + # Snapshot restore (sqlite backups with date folders) + echo "Downloading $SERVICE snapshot from $DATE..." + OUT="/var/tmp/restore-$SERVICE-$DATE" + ${coreutils}/bin/rm -rf "$OUT" + ${coreutils}/bin/mkdir -p "$OUT" + $RCLONE copy "$REMOTE/$SERVICE/$DATE/" "$OUT/" + else + # Check if this is a snapshot backup (has date subdirectories) or a synced backup + SUBDIRS=$($RCLONE lsd "$REMOTE/$SERVICE/" 2>/dev/null | ${coreutils}/bin/awk '{print $NF}') + if echo "$SUBDIRS" | ${coreutils}/bin/grep -qE '^[0-9]{4}-[0-9]{2}-[0-9]{2}$'; then + echo "Available $SERVICE snapshots:" + echo "$SUBDIRS" | ${coreutils}/bin/sed 's/^/ /' + exit 0 + fi + + # Synced backup — download directly + echo "Downloading $SERVICE synced backup..." + OUT="/var/tmp/restore-$SERVICE" + ${coreutils}/bin/rm -rf "$OUT" + ${coreutils}/bin/mkdir -p "$OUT" + $RCLONE copy "$REMOTE/$SERVICE/" "$OUT/" + fi + + echo "" + echo "Downloaded to: $OUT" + echo "" + echo "Contents:" + ${coreutils}/bin/ls -la "$OUT/" + echo "" + echo "To restore, stop the service and copy the files into place. For example:" + echo "" + echo " systemctl stop $SERVICE" + for item in "$OUT"/*; do + base=$(${coreutils}/bin/basename "$item") + echo " cp -a $OUT/$base /var/lib/$SERVICE/" + done + echo " systemctl start $SERVICE" + '') diff --git a/machines/ramune/configuration.nix b/machines/ramune/configuration.nix index cc2d570..17cf741 100644 --- a/machines/ramune/configuration.nix +++ b/machines/ramune/configuration.nix @@ -54,6 +54,11 @@ caddy.enable = true; vaultwarden.enable = true; tangled.enable = true; + backup = { + enable = true; + r2AccountId = "a261b92e6b94f88e79c9c863e19accd4"; + bucket = "ramune-backup"; + }; }; }; diff --git a/modules/server/backup.nix b/modules/server/backup.nix new file mode 100644 index 0000000..c7bdadb --- /dev/null +++ b/modules/server/backup.nix @@ -0,0 +1,174 @@ +{ lib, config, pkgs, helpers, ... }: + +with lib; +let + cfg = config.modules.server; + backup = cfg.backup; + + rclone = "${pkgs.rclone}/bin/rclone"; + coreutils = pkgs.coreutils; + + rcloneFlags = "--config ${backup.configPath}"; + + pathType = types.submodule ({ name, ... }: { + options = { + name = mkOption { + default = name; + description = "Name used as the backup subdirectory in the remote. Defaults to the attribute name."; + type = types.str; + }; + + path = mkOption { + description = "Path to back up."; + type = types.str; + }; + + sqlite = mkOption { + default = null; + description = "If set, use sqlite3 .backup on this database file instead of copying the path directly."; + type = types.nullOr types.str; + }; + + extras = mkOption { + default = []; + description = "Additional files/directories within path to copy alongside a sqlite backup."; + type = types.listOf types.str; + }; + }; + }); + + mkSqliteBackup = path: let + sqlite = "${pkgs.sqlite}/bin/sqlite3"; + in '' + echo "Backing up ${path.name}..." + ${coreutils}/bin/mkdir -p "$TMP/${path.name}" + ${sqlite} "${path.path}/${path.sqlite}" ".backup $TMP/${path.name}/${path.sqlite}" + ${concatMapStringsSep "\n" (item: '' + if [ -e "${path.path}/${item}" ]; then + ${coreutils}/bin/cp -a "${path.path}/${item}" "$TMP/${path.name}/" + fi + '') path.extras} + ${rclone} ${rcloneFlags} copy "$TMP/${path.name}" "r2-encrypted:${path.name}/$DATE/" + ''; + + mkSqlitePrune = path: '' + CUTOFF=$(${coreutils}/bin/date -d '-${toString backup.retention} days' +%Y-%m-%d) + ${rclone} ${rcloneFlags} lsd "r2-encrypted:${path.name}/" 2>/dev/null | ${coreutils}/bin/awk '{print $NF}' | while read -r dir; do + if [[ "$dir" < "$CUTOFF" ]]; then + ${rclone} ${rcloneFlags} purge "r2-encrypted:${path.name}/$dir/" || true + fi + done + ''; + + mkSyncBackup = path: '' + echo "Syncing ${path.name}..." + ${rclone} ${rcloneFlags} sync "${path.path}" "r2-encrypted:${path.name}/" + ''; + +in helpers.linuxAttrs { + options.modules.server.backup = { + enable = mkOption { + default = false; + description = "Whether to enable automated backups to R2."; + type = types.bool; + }; + + r2AccountId = mkOption { + description = "Cloudflare account ID for R2 endpoint."; + type = types.str; + }; + + bucket = mkOption { + default = "backups"; + description = "R2 bucket name."; + type = types.str; + }; + + schedule = mkOption { + default = "*-*-* 04:00:00"; + description = "Systemd OnCalendar schedule for backups."; + type = types.str; + }; + + retention = mkOption { + default = 30; + description = "Number of days to retain backups."; + type = types.int; + }; + + configPath = mkOption { + default = "/etc/rclone-backup.conf"; + description = "Path to the rclone configuration file."; + type = types.str; + }; + + paths = mkOption { + default = {}; + description = "Paths to back up. Other modules can append to this attrset."; + type = types.attrsOf pathType; + }; + }; + + config = let + sqlitePaths = filter (p: p.sqlite != null) (attrValues backup.paths); + syncPaths = filter (p: p.sqlite == null) (attrValues backup.paths); + in mkIf (cfg.enable && backup.enable) { + environment.etc."rclone-backup.conf".text = '' + [r2] + type = s3 + provider = Cloudflare + endpoint = https://${backup.r2AccountId}.r2.cloudflarestorage.com + acl = private + + [r2-encrypted] + type = crypt + remote = r2:${backup.bucket} + ''; + + age.secrets."rclone-backup.env" = { + symlink = true; + path = "/run/secrets/rclone-backup.env"; + file = ./encrypt/rclone-backup.env.age; + }; + + systemd.services.backup = { + description = "Backup services to R2"; + wants = [ "network-online.target" ]; + after = [ "network-online.target" ]; + serviceConfig = { + Type = "oneshot"; + EnvironmentFile = "/run/secrets/rclone-backup.env"; + ExecStart = pkgs.writeShellScript "backup" '' + set -uo pipefail + FAILED=0 + DATE=$(${coreutils}/bin/date +%Y-%m-%d) + TMP=$(${coreutils}/bin/mktemp -d) + trap '${coreutils}/bin/rm -rf "$TMP"' EXIT + + ${concatMapStringsSep "\n" (p: '' + (set -e; ${mkSqliteBackup p}) || FAILED=1 + '') sqlitePaths} + + ${concatMapStringsSep "\n" (p: '' + (set -e; ${mkSqlitePrune p}) || true + '') sqlitePaths} + + ${concatMapStringsSep "\n" (p: '' + (set -e; ${mkSyncBackup p}) || FAILED=1 + '') syncPaths} + + exit "$FAILED" + ''; + }; + }; + + systemd.timers.backup = { + wantedBy = [ "timers.target" ]; + timerConfig = { + OnCalendar = backup.schedule; + Persistent = true; + RandomizedDelaySec = "1h"; + }; + }; + }; +} diff --git a/modules/server/default.nix b/modules/server/default.nix index a42fa67..49c87c1 100644 --- a/modules/server/default.nix +++ b/modules/server/default.nix @@ -21,5 +21,6 @@ with lib; { ./podman.nix ./macos.nix ./tangled.nix + ./backup.nix ]; } diff --git a/modules/server/encrypt/rclone-backup.env.age b/modules/server/encrypt/rclone-backup.env.age new file mode 100644 index 0000000000000000000000000000000000000000..a6ea4844512fdaba8a01ae4cbe9b13263310ff7e GIT binary patch literal 548 zcmYdHPt{G$OD?J`D9Oyv)5|YP*Do{V(zR14F3!+RO))YxHMCR+EKe#3C|3w{3vkN} z&Ga@c%M9~OPjN0P&vNoAPu13UchUAK3y2JHsR}go3aG3|&*pNra4&T&H>-$9_O-|^ z^zf*v)HliS&hRa<$no%SD)LFnH81lF%1AG;@a59g)m89J^UBwEHFvIb^v^MJF7+z6 z$jNdENVBZUh^+E8PBit+3b*i1v(&Ck^yJb?UA@4a=d;8f3w6)er;MC;?9&vuUZM41 zi^QzI_wGJiWoDACU$`xE*^RIVInEbuEYp5`*{|E=mdDE6-`UYgzYRLIjwPyzzjq6n zx!C%bvt&(PrS#24w`~(YJ=VBw6xsdYOz?^SovHV~&fxGWJ+66Pc2DBJ&+c=+p1Qj1 z1e;`3lcvz;kj)t-+>X02?BEXl^scA9{8u87sY-(xqZi`ShIP_WlDNWh`f}WuNEBzWJLm zBBD?!xv^!zgT(Bklb6eD+zpFls9L(GJ}*OH{vM;p`8|@ZFCR6#C}uC?YC7UtdT<#4 DnIH62 literal 0 HcmV?d00001 diff --git a/modules/server/tangled.nix b/modules/server/tangled.nix index c7b0377..c5dafcb 100644 --- a/modules/server/tangled.nix +++ b/modules/server/tangled.nix @@ -24,6 +24,10 @@ in helpers.linuxAttrs { }; config = mkIf (cfg.enable && cfg.tangled.enable) { + modules.server.backup.paths.tangled = { + path = "${config.services.tangled.knot.stateDir}/repos"; + }; + services = { tangled.knot = { enable = true; diff --git a/modules/server/vaultwarden.nix b/modules/server/vaultwarden.nix index ce61a39..600d5dd 100644 --- a/modules/server/vaultwarden.nix +++ b/modules/server/vaultwarden.nix @@ -27,6 +27,12 @@ in helpers.linuxAttrs { }; config = mkIf (cfg.enable && cfg.vaultwarden.enable) { + modules.server.backup.paths.vaultwarden = { + path = "/var/lib/vaultwarden"; + sqlite = "db.sqlite3"; + extras = [ "attachments" "rsa_key.pem" "rsa_key.pub.pem" ]; + }; + age.secrets."vaultwarden" = { symlink = true; path = "/run/secrets/vaultwarden.env"; diff --git a/secrets.nix b/secrets.nix index 118b299..c3f1bc6 100644 --- a/secrets.nix +++ b/secrets.nix @@ -11,6 +11,7 @@ in "./modules/server/encrypt/tangled-knot-ssh.age".publicKeys = keys; "./modules/server/encrypt/gitconfig.age".publicKeys = keys; "./modules/server/encrypt/vaultwarden.age".publicKeys = keys; + "./modules/server/encrypt/rclone-backup.env.age".publicKeys = keys; "./modules/router/encrypt/pppoe-options.age".publicKeys = keys; -- 2.51.2