#!/usr/bin/env bash # ---------------------------------------------------------------- APP INFO ---- # Edit these lines to rename the tool or publish a new release. APP_NAME="NDM Linux Backup" APP_DESCRIPTION="System backup and disaster recovery for Debian/GNU Linux: saves the \ parts of a machine most backup tools leave out (hardware, boot, partitions, LUKS, LVM, \ packages, databases, containers)." APP_AUTHOR="Nicola Davide Mannarelli (@nidamanx)" APP_VERSION="3.0.1" APP_RELEASE_DATE="2026-10-29" APP_COPYRIGHT="Copyright (C) 2020-2026 Nicola Davide Mannarelli (@nidamanx)" APP_LICENSE="AGPL-3.0-or-later" # ------------------------------------------------------------------------------ # # Copyright (C) 2020-2026 Nicola Davide Mannarelli (@nidamanx) # SPDX-License-Identifier: AGPL-3.0-or-later # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published # by the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . # The full license text is in the LICENSE file distributed with this script. # # DISCLAIMER # This software is provided "as is", without warranty of any kind, express or # implied. In no event shall the author be liable for any claim, damages or other # liability, including loss of data, backups that are incomplete or cannot be # restored, and systems that no longer boot after a restore, arising from the use # or misuse of this software. You alone are responsible for how you use it, for # keeping and protecting the archives it creates, and for checking that they can # be restored. # ------------------------------------------------------------------------------ # HELP-BEGIN # Usage: sudo ./ndm_linux-backup.sh --start [-n] [-q [-k]] [--secrets] back up # sudo ./ndm_linux-backup.sh --analyze [--secrets] show what a run would # back up, then exit # ./ndm_linux-backup.sh --requirements show the needed tools # # Saves the parts of a Debian/GNU Linux machine needed to rebuild or diagnose # it (hardware, boot, partitions, LUKS, LVM, packages, modified /etc files, # crontabs, database schemas, containers, chosen directories) into a settings # archive; optionally a data archive and a secrets archive. Needs root. # # Options: -s, --start run the backup # -a, --analyze report what a run would back up, skip or fail with the # current settings, then exit: nothing is written and no # backup command runs; wins over --start # -n, --dry-run with --start: run every step reading for real, but # write nothing anywhere # -q, --quiet with --start: ask nothing and show no progress (for # cron); the log file is still complete # -k, --keep with --quiet: keep the working directory afterwards # --secrets also make the secrets archive for this run, even if # v_enable_secrets_backup=false (never turns it off) # # --requirements show the needed tools and how to install them (no root) # -u, --usage show a short usage line # -v, --version show name, version, author, license and disclaimer # -h, --help show this help # # Settings: in the CONFIGURE section below, or in ndm_linux-backup.conf next to this # script (start from ndm_linux-backup.conf.example), which wins over the script. The # file is only read, never executed: one KEY=value per line, lists as # KEY=( "a" "b" ), no $variables or $(commands). Every setting is described in the # CONFIGURE section and in the README. # # Output: in v_dest (default: backup/ next to this script, its real location): # __-.tar.xz[.gpg] settings archive # _DATA__-.tar.xz[.gpg] data archive (opt-in) # _SECRETS__-.tar.xz[.gpg] secrets (opt-in) # .sha256 next to each archive, __<...>.log per run # is v_backup_name (default ndm_linux-backup), the hostname. # # Examples: # sudo ./ndm_linux-backup.sh --analyze # sudo ./ndm_linux-backup.sh --start --dry-run # sudo ./ndm_linux-backup.sh --start # sudo ./ndm_linux-backup.sh --start --quiet (cron) # sudo ./ndm_linux-backup.sh --start --secrets # # Needs: Debian GNU/Linux (tested on Debian 13), bash, root privileges, and the # tools of the backup modules switched on (always tar, fdisk, sha256sum). # Missing ones are listed at every run, with an offer to install them; # --requirements shows them without root. # # Exit codes: # 0 backup completed; also --analyze, --requirements, --help, --usage, # --version, no arguments, and a prompt answered No or Cancel # 1 an error: not root, missing required tool, missing database credentials or # GPG key, unsafe working directory, failed archive or encryption step # 2 invalid command-line usage (unknown option, unexpected argument) or an # invalid setting in ndm_linux-backup.conf # 130 interrupted (Ctrl-C); 143 terminated (SIGTERM): collected files are kept # HELP-END # No -e: backup steps are best-effort, a missing optional tool must not # abort the whole run (see K_SOFT_COMMANDS). pipefail backstops any pipe # that skips the f_check_pipe convention used elsewhere. set -uo pipefail readonly APP_NAME APP_DESCRIPTION APP_AUTHOR APP_VERSION APP_RELEASE_DATE APP_COPYRIGHT APP_LICENSE ################################################################## # START EDITING BELOW THIS LINE # ################################################################## # Prefer not to edit this file in place? Every setting below can instead # be overridden from ndm_linux-backup.conf, next to this script: copy # ndm_linux-backup.conf.example to get started, or create it by hand with # just the lines you want to change, e.g.: # v_enable_mysql=true # v_enable_secrets_backup=true # v_retention_days=30 # The file is only read, never executed (see f_load_config further down). # Keeps a future script update from clobbering your per-machine settings. ######################## # CONFIGURE ######################## # Everything below is a plain variable/array declaration, safe to edit -- # except the "EXTERNAL SETTINGS FILE" and "DERIVED DEFAULTS" blocks # near the end of this section: those are code, leave them alone. # Hostname to use as backup name suffix. ${HOSTNAME} is bash's own builtin # (set via gethostname() at shell startup, not an external command), so no # subprocess is spawned just to read it. # Example: v_hostname="${HOSTNAME}" # Example: v_hostname="my-custom-name" v_hostname="${HOSTNAME}" # Username used to store the backup # Example: v_username="${SUDO_USER:-$(logname 2>/dev/null || id -un)}" v_username="${SUDO_USER:-$(logname 2>/dev/null || id -un)}" # Backup name # Example: v_backup_name='ndm_linux-backup' v_backup_name='ndm_linux-backup' # Where to backup. Defaults next to the script itself, so a normal run # never writes outside of it and v_tempdir (see Safety in README). # Example: v_dest="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/backup" # Example: v_dest="/home/${v_username}/backup" v_dest="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/backup" # Working directory to save files before tar. Not /tmp: a predictable path # under a world-writable directory is a symlink-attack target (see # f_secure_dir). Defaults under /home/ndm_linux-backup rather than /root since it # needs room for the uncompressed data-pass staging tree. Must also stay # outside every a_source_extra default entry (/root, /etc, the script's own # dir): nesting it there would either duplicate its content in the archive # or, if excluded, drop it from its own direct tar inclusion too. # Example: v_tempdir="/home/ndm_linux-backup/${v_backup_name}" # Example: v_tempdir="/root/${v_backup_name}_tmp" # Example: v_tempdir="$(dirname "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")")/${v_backup_name}_tmp" # Example: v_tempdir="/tmp/${v_backup_name}" # Left empty here on purpose: its real default (derived from v_backup_name) # is computed in "DERIVED DEFAULTS" below, after ndm_linux-backup.conf is read -- # so overriding v_backup_name without also setting v_tempdir still produces # the right path instead of silently keeping the pre-override default. v_tempdir="" # List of storages. Set to ( "auto" ) to auto-detect every physical disk # on the system instead of listing them by hand. # Example: a_storage=( "auto" ) # Example: a_storage=( "sda" "sdb" ) a_storage=( "auto" ) # List of encrypted LUKS partitions. Set to ( "auto" ) to auto-detect every # LUKS partition on the system instead of listing them by hand. # Example: a_luks=( "auto" ) # Example: a_luks=( "sda3" "sdb3" ) a_luks=( "auto" ) # ===== SETTINGS MODULES ===== # Enable/disable individual backup steps for the settings archive. Set # any to false to skip that step entirely: it is not attempted, and # nothing related is read. # Example: v_enable_hardware=false v_enable_hardware=true # lshw hardware list v_enable_devices=true # lspci + lsusb device lists v_enable_lsblk=true # lsblk block device list v_enable_blkid=true # blkid device IDs v_enable_inxi=true # inxi full system report v_enable_efi=true # EFI boot entries + /boot/efi (UEFI systems only) v_enable_mbr=true # raw MBR sector dump (BIOS/MBR disks only) v_enable_luks=false # LUKS header/UUID dump (only if a_luks is non-empty) v_enable_lvm=false # LVM pv/vg/lv display + lvmdump v_enable_os_release=true # lsb_release output v_enable_kernel=true # uname output v_enable_partitions=true # fdisk/sfdisk + fstab v_enable_grub=true # /boot/grub + /etc/default/grub v_enable_packages=true # dpkg/apt-mark installed package lists v_enable_flatpak=false # flatpak installed app list v_enable_appimages=false # AppImage files found on the system (size + path list, not copied) v_enable_modified_files=true # files whose content differs from what their package shipped (dpkg -V), copied as-is v_enable_unpackaged_files=true # /etc files owned by no package at all (dpkg -S blind spot), copied as-is v_enable_cron=true # root's crontab v_enable_mysql=false # MySQL/MariaDB: schema always here; actual row data too if v_enable_data_backup=true v_enable_postgresql=false # PostgreSQL: schema always here; actual row data too if v_enable_data_backup=true v_enable_docker=false # Docker images/containers/volumes/networks info v_enable_docker_compose=false # Compose files found on the system: Docker Compose / podman-compose (copied as-is) v_enable_podman=false # Podman images/containers/volumes/networks info + config/Quadlet files v_enable_source_extra=true # a_source_extra (extra directories, see further down) # ===== DATA MODULES (require v_enable_data_backup=true below) ===== # Enable a second, separate pass and archive for actual data (not just # setup/metadata): MySQL/PostgreSQL row data, real Docker/Podman volume # contents, and any directories in a_data_source_extra. Default false: # this script is meant to complement your existing data backup solution, # not replace it. Volume copies are read via Mountpoint (docker/podman # volume inspect, "local" driver only); a volume actively written by a # running container can end up copied inconsistently -- for database # volumes prefer the MySQL/PostgreSQL data dumps instead. # Example: v_enable_data_backup=false v_enable_data_backup=false # master switch for the whole data pass/archive v_enable_docker_volumes_data=false # actual content of Docker volumes (not just the list) v_enable_podman_volumes_data=false # actual content of Podman volumes (not just the list) v_enable_data_source_extra=false # a_data_source_extra (extra directories, see further down) # ===== SECRETS MODULE (separate archive, opt-in) ===== # A third, separate pass and archive for sensitive credential/key # material (SSH host keys, TLS private keys, /etc/shadow, network and # cloud credentials, this script's own .my.cnf/.pgpass -- see # a_secrets_source). The settings and data archives never include this # material regardless of this switch. Off by default; protect the # resulting archive at least as well as /root and /etc themselves. # Example: v_enable_secrets_backup=false v_enable_secrets_backup=false # master switch for the whole secrets pass/archive # Not a settings/data archive step: applies after both are built. # v_dest_rsync is empty by default, so this switch alone does nothing # until a real destination is set below (see f_module_enabled). # Example: v_enable_rsync=false v_enable_rsync=false # rsync v_dest to v_dest_rsync once the backup is done # Not backup steps either: run your own command before/after everything # else. Off by default even once v_pre_command/v_post_command below are # set, so a hook can be prepared without immediately going live. # Example: v_enable_pre_command=true v_enable_pre_command=false # run v_pre_command before the backup starts v_enable_post_command=false # run v_post_command after the backup ends v_enable_on_failure_command=false # run v_on_failure_command if the run fails (see below) # Change settings/data archive ownership to current user after creation # (they hold no secret material by default, so there's no reason to make # them root-only). Does NOT cover the secrets archive -- see # v_tar_owner_secrets below, deliberately separate. # Example: v_tar_owner=false # Example: v_tar_owner=true v_tar_owner=true # Same, for the secrets archive specifically -- deliberately does NOT # mirror v_tar_owner above (unlike v_enable_gpg_data/_secrets): defaults # to false regardless of the switch above, so turning on the convenience # default for settings/data never also hands the secrets archive # (/etc/shadow, SSH host keys...) to a non-root user without a separate, # deliberate choice to do so. # Example: v_tar_owner_secrets=true v_tar_owner_secrets=false # Pre and post command (enabled via v_enable_pre_command/v_enable_post_command above) # Optionally add here your own command (e.g. stop/start services, send a # notification...). ${NDM_HOSTNAME}/${NDM_ARCHIVE_PREFIX}/${NDM_LOG_PATH}/ # ${NDM_USER} are exported for both to use. v_post_command additionally # gets ${NDM_EXIT_CODE} (always 0: a failed run never reaches # v_post_command, see v_on_failure_command instead) and, for each archive # actually produced, its path and size: ${NDM_SETTINGS_ARCHIVE}/ # ${NDM_SETTINGS_ARCHIVE_SIZE}, ${NDM_DATA_ARCHIVE}/${NDM_DATA_ARCHIVE_SIZE}, # ${NDM_SECRETS_ARCHIVE}/${NDM_SECRETS_ARCHIVE_SIZE} -- empty for a pass # that did not run this session. # Single quotes below on purpose: these variables are set later, when the # command actually runs, not when this line is read. In # ndm_linux-backup.conf the value is kept as written, quotes or not. # An unreserved ntfy.sh topic is public: anyone who knows or guesses its # name can read it. Pick a long, random topic name, or self-host. # Example: v_pre_command='curl -fsS -H "Title: NDM Linux Backup" -d "$(date "+%F %T") - Starting backup ${NDM_USER}@${NDM_HOSTNAME}" https://ntfy.sh/REPLACE_WITH_YOUR_OWN_RANDOM_TOPIC' # Example: v_post_command='curl -fsS -H "Title: NDM Linux Backup" -d "$(date "+%F %T") - Backup done ${NDM_USER}@${NDM_HOSTNAME}: settings ${NDM_SETTINGS_ARCHIVE_SIZE}${NDM_DATA_ARCHIVE:+, data ${NDM_DATA_ARCHIVE_SIZE}}${NDM_SECRETS_ARCHIVE:+, secrets ${NDM_SECRETS_ARCHIVE_SIZE}}" https://ntfy.sh/REPLACE_WITH_YOUR_OWN_RANDOM_TOPIC' v_pre_command="" v_post_command="" # Run your own command if the backup run fails (enabled via # v_enable_on_failure_command above) -- e.g. send an email, hit a webhook, # page someone. Fires via a single EXIT trap set at the top of main(), so # it covers every hard-exit from that point on, plus Ctrl-C (exit 130) and # SIGTERM (exit 143). Does NOT cover failures before main() starts (invalid # setting in ndm_linux-backup.conf, invalid CLI flag): those are setup-time problems, not # what this hook is for. ${NDM_EXIT_CODE}, ${NDM_LOG_PATH}, ${NDM_ARCHIVE_PREFIX}, # ${NDM_HOSTNAME}, ${NDM_USER} are exported for it to use. # Single quotes below on purpose, same reasoning as v_pre_command/ # v_post_command above. # Example: v_on_failure_command='curl -fsS -d "NDM Linux Backup failed on ${NDM_USER}@${NDM_HOSTNAME} (exit ${NDM_EXIT_CODE})" https://example.invalid/notify' v_on_failure_command="" # Optionally, rsync between $dest and a local or remote directory # (enabled via v_enable_rsync above) # Example: v_dest_rsync="/mnt/device/backup" v_dest_rsync="" # Retention: delete old archives (settings/data/secrets counted # separately) once there are more than v_retention_min_keep of that type # AND the one being considered is older than v_retention_days. # v_retention_min_keep is a floor, not a target: always keeps at least # that many of the newest regardless of age, so a machine left off for a # while never wakes up to zero backups. Off by default. Runs right after # each archive is created, before rsync (so --delete mirrors the pruned # state), matching only this script's own filename pattern -- see # f_prune_old_archives. # Example: v_retention_days=30 # Example: v_retention_min_keep=3 v_retention_days=0 v_retention_min_keep=1 # Encrypt each archive with GPG right after it's created, deleting the # plaintext once the encrypted copy exists. Off by default. # v_enable_gpg_data/_secrets default to this same switch but can be # overridden independently -- e.g. set only v_enable_gpg_secrets=true to # encrypt just the secrets archive. A missing recipient file hard-exits # before any backup work starts (see f_check_gpg_keys(), README # "Encryption"); a failed encryption also hard-exits, never leaving a # plaintext archive behind when encryption was requested. # Example: v_enable_gpg_settings=true v_enable_gpg_settings=false # Left empty here on purpose: default is this same switch, computed in # "DERIVED DEFAULTS" below (after ndm_linux-backup.conf is read) so it picks up # an override of v_enable_gpg_settings above. v_enable_gpg_data="" v_enable_gpg_secrets="" # Points to an ASCII-armored public key file, used directly via # --recipient-file: no keyring import, no trust-model prompts. The # matching private key is only ever needed on the restore machine, never # on this one. v_gpg_recipient_file_data/_secrets default to the same key # but can be overridden independently, e.g. different keys per archive. # Example: v_gpg_recipient_file="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/pubkey.asc" v_gpg_recipient_file="${HOME}/pubkey.asc" # Left empty here on purpose, same reasoning as v_enable_gpg_data/_secrets # above: default computed in "DERIVED DEFAULTS" below. v_gpg_recipient_file_data="" v_gpg_recipient_file_secrets="" # Optionally, add extra sources to include in the settings archive # (enabled via v_enable_source_extra above). /opt, /srv, /usr/local/etc # are typical config/data locations for third-party software; # /var/spool/cron/crontabs holds non-root crontabs (root's own is # covered by v_enable_cron); the script's own directory captures how # this deployment is set up. No per-user home directory here besides # /root: that needs a case-by-case decision, and blanket-including # ~/.ssh private keys is a bad idea. # Example: a_source_extra=( "/root" "/etc" "/usr/local/bin" ) a_source_extra=( "/root" "/etc" "/usr/local/bin" "/opt" "/srv" "/usr/local/etc" \ "/var/spool/cron/crontabs" "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" ) # Extra directories to search for AppImages, in addition to the built-in # defaults (/home, /root, /opt, /usr/local/bin) # Example: a_appimages_extra_paths=( "/srv/apps" "/mnt/data/AppImages" ) a_appimages_extra_paths=( ) # Extra directories to search for Compose files (Docker Compose / # podman-compose, same file naming convention), in addition to the # built-in defaults (/home, /root, /opt, /srv). There is no fixed # convention for where compose files live, so add your own project # directories here if they are outside the defaults. # Example: a_docker_compose_extra_paths=( "/data/stacks" "/mnt/nas/docker" ) a_docker_compose_extra_paths=( ) # Language for datetime suffix # Example (no locale): export LC_TIME=C # Example (locale German): export LC_TIME="de_DE.UTF-8" # Example (locale UK English): export LC_TIME="en_GB.UTF-8" # Example (locale Spanish): export LC_TIME="es_ES.UTF-8" # Example (locale French): export LC_TIME="fr_FR.UTF-8" # Example (locale Italian): export LC_TIME="it_IT.UTF-8" export LC_TIME=C # Date and time at the end of the archive names: a date(1) format, local # time (default YYYYMMDD-HHMMSS). Only letters, digits, ".", "_" and "-" # may come out of it. # Example default: v_datetime_format='%Y%m%d-%H%M%S' # Example ISO with separators: v_datetime_format='%Y-%m-%d_%H-%M-%S' # Example WeekDay only (one archive per weekday, overwritten weekly): v_datetime_format='%A' # Example WeekDay + date: v_datetime_format='%w-%a_%Y%m%d-%H%M%S' v_datetime_format='%Y%m%d-%H%M%S' # MySQL/MariaDB # Backup all in one single file # Example: v_mysql_single_dump=false v_mysql_single_dump=false # Get user and pass from .my.cnf file. Defaults to the same path the mysql/ # mysqldump client tools themselves read automatically -- see # f_check_db_credentials() for what happens if it is missing. # Example: v_mysql_cnf_path="${HOME}/.my.cnf" # Example: v_mysql_cnf_path="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.my.cnf" # Example: v_mysql_cnf_path="your_custom_path/.my.cnf" v_mysql_cnf_path="${HOME}/.my.cnf" # PostgreSQL # Backup all in one single file # Example: v_postgresql_single_dump=false v_postgresql_single_dump=false # User to connect as (password is looked up from .pgpass by host/port/database/user) # Example: v_postgresql_user="postgres" v_postgresql_user="postgres" # Get password from .pgpass file (format: hostname:port:database:username:password) # Must be chmod 600 or PostgreSQL client tools silently refuse to read it. # If missing, NDM Linux Backup also tries local peer/ident auth over the Unix # socket before giving up (see README "PostgreSQL backup") -- if that # works, .pgpass is never required at all. See f_check_db_credentials(). # Example: v_postgresql_pgpass_path="${HOME}/.pgpass" # Example: v_postgresql_pgpass_path="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.pgpass" v_postgresql_pgpass_path="${HOME}/.pgpass" # Podman # Podman is normally used rootless, per-user: running it as root only # shows root's own state, not another user's containers/images. Set to # the user whose rootless Podman instance to inspect (defaults to # v_username above); set to "root" to use root's own Podman instead. A # non-root user needs `loginctl enable-linger ` (one-time, as root) # so their runtime directory exists without an active login session. # Example: v_podman_user="${v_username}" # Example: v_podman_user="root" # Left empty here on purpose: default (mirroring v_username) is computed # in "DERIVED DEFAULTS" below, after ndm_linux-backup.conf is read. v_podman_user="" # Secrets (enabled via v_enable_secrets_backup above) # Raw LUKS header backup (cryptsetup luksHeaderBackup), in addition to # the human-readable luksDump/luksUUID text already in the settings # archive: this one can restore a corrupted header byte-for-byte, since # it contains the real keyslots. Off by default even with secrets on: # unlike everything else in a_secrets_source, having this backup defeats # crypto-shredding (destroying only the on-disk header to make a LUKS # volume permanently unrecoverable) for anyone who gets hold of it -- turn # it on only if you want header-corruption recovery and accept that # trade-off. Only ever written into the secrets archive. # Example: v_enable_luks_header_backup=false v_enable_luks_header_backup=false # raw cryptsetup luksHeaderBackup per a_luks entry (secrets archive only) # Built-in paths always swept into the secrets archive when the module # is on. Includes this script's own MySQL/PostgreSQL credential files: # unlike the settings and data archives, which always exclude them, this # is where they belong. A missing path is skipped with a notice, not an # error -- not every machine has all of these. Override this array # entirely in ndm_linux-backup.conf to trim or extend it. # Example: a_secrets_source=( "/etc/shadow" "/root/.ssh" ) a_secrets_source=( "/etc/shadow" "/etc/gshadow" "/etc/shadow-" "/etc/gshadow-" "/etc/ssh/ssh_host_rsa_key" "/etc/ssh/ssh_host_ecdsa_key" "/etc/ssh/ssh_host_ed25519_key" "/etc/ssl/private" "/etc/letsencrypt/live" "/etc/letsencrypt/archive" "/etc/mysql/debian.cnf" "/etc/NetworkManager/system-connections" "/etc/wpa_supplicant" "/etc/wireguard" "/etc/exim4/passwd.client" "/etc/postfix/sasl_passwd" "/etc/postfix/sasl_passwd.db" "/etc/ppp/chap-secrets" "/etc/ppp/pap-secrets" "/root/.ssh" "/root/.docker/config.json" "/root/.aws" "/root/.config/gcloud" "/root/.kube" "/root/.azure" "/root/.netrc" "/root/.gnupg" "/root/.bash_history" "${v_mysql_cnf_path}" "${v_postgresql_pgpass_path}" ) # Extra paths to sweep into the secrets archive, in addition to # a_secrets_source above. # Example: a_secrets_source_extra=( "/etc/openvpn/client/secret.key" ) a_secrets_source_extra=( ) # Where the secrets archive is written and its own working directory. # Default next to the settings ones. # Example: v_dest_secrets="${v_dest}" # Example: v_tempdir_secrets="${v_tempdir}_secrets" # Left empty here on purpose: default (mirroring v_dest/v_tempdir) is # computed in "DERIVED DEFAULTS" below, after ndm_linux-backup.conf is read. v_dest_secrets="" v_tempdir_secrets="" # Extra directories with real data to include in the data archive (e.g. # personal documents, media, self-hosted app data not in a container). # Enabled via v_enable_data_source_extra above. # Example: a_data_source_extra=( "/home/user/Documents" "/srv/nextcloud/data" ) a_data_source_extra=( ) # Where the data archive is written and its own working directory. # Default next to the settings ones. # Example: v_dest_data="${v_dest}" # Example: v_tempdir_data="${v_tempdir}_data" # Left empty here on purpose: default (mirroring v_dest/v_tempdir) is # computed in "DERIVED DEFAULTS" below, after ndm_linux-backup.conf is read. v_dest_data="" v_tempdir_data="" # ===== EXTERNAL SETTINGS FILE (code, leave it alone) ===== # ndm_linux-backup.conf next to this script (its real location), optional. # Read as KEY=value lines, never executed: this script runs as root, so the # file must not be able to run anything. Only the settings above are # accepted; values are literal (no $variables, no $(commands)), each one is # checked against its type, and lists are written as KEY=( "a" "b" ), # possibly over several lines. The environment is not read: sudo resets it # anyway, and a stray exported variable must never change what is backed up. # A problem is reported (exit 2) only once the command line has been read, # so --help and --version still work with a broken file. v_config_path="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/ndm_linux-backup.conf" v_config_error="" # Type of every setting the file may change. bool: true/false; int: whole # number; name: letters, digits, "." "_" "-"; path: absolute path; text: any # literal value not starting with "-"; cmd: a command run later by the hooks (only these may # contain "$"); format: a date(1) format; devs/paths: lists of device names # or absolute paths. A setting whose built-in default is empty may be empty. declare -A K_CONFIG_KEYS=( [v_hostname]=name [v_username]=name [v_backup_name]=name [v_dest]=path [v_tempdir]=path [v_dest_data]=path [v_tempdir_data]=path [v_dest_secrets]=path [v_tempdir_secrets]=path [a_storage]=devs [a_luks]=devs [v_enable_hardware]=bool [v_enable_devices]=bool [v_enable_lsblk]=bool [v_enable_blkid]=bool [v_enable_inxi]=bool [v_enable_efi]=bool [v_enable_mbr]=bool [v_enable_luks]=bool [v_enable_lvm]=bool [v_enable_os_release]=bool [v_enable_kernel]=bool [v_enable_partitions]=bool [v_enable_grub]=bool [v_enable_packages]=bool [v_enable_flatpak]=bool [v_enable_appimages]=bool [v_enable_modified_files]=bool [v_enable_unpackaged_files]=bool [v_enable_cron]=bool [v_enable_mysql]=bool [v_enable_postgresql]=bool [v_enable_docker]=bool [v_enable_docker_compose]=bool [v_enable_podman]=bool [v_enable_source_extra]=bool [v_enable_data_backup]=bool [v_enable_docker_volumes_data]=bool [v_enable_podman_volumes_data]=bool [v_enable_data_source_extra]=bool [v_enable_secrets_backup]=bool [v_enable_luks_header_backup]=bool [v_enable_rsync]=bool [v_enable_pre_command]=bool [v_enable_post_command]=bool [v_enable_on_failure_command]=bool [v_tar_owner]=bool [v_tar_owner_secrets]=bool [v_pre_command]=cmd [v_post_command]=cmd [v_on_failure_command]=cmd [v_dest_rsync]=text [v_retention_days]=int [v_retention_min_keep]=int [v_enable_gpg_settings]=bool [v_enable_gpg_data]=bool [v_enable_gpg_secrets]=bool [v_gpg_recipient_file]=path [v_gpg_recipient_file_data]=path [v_gpg_recipient_file_secrets]=path [a_source_extra]=paths [a_appimages_extra_paths]=paths [a_docker_compose_extra_paths]=paths [v_datetime_format]=format [v_mysql_single_dump]=bool [v_mysql_cnf_path]=path [v_postgresql_single_dump]=bool [v_postgresql_user]=name [v_postgresql_pgpass_path]=path [v_podman_user]=name [a_secrets_source]=paths [a_secrets_source_extra]=paths [a_data_source_extra]=paths ) readonly K_CONFIG_KEYS # Split ${1} into words like the shell would, without expanding anything: # "..." (with \" \\ \$ \` escapes), '...', \x, and # comments to the end of # the line. One unquoted "(" before the first word and one ")" after the # last mark a list. Result in a_config_words and v_config_list (none, open, # closed). Returns 1 on a syntax error (unclosed quote, misplaced paren). function f_config_words() { local v_text="${1}" v_char v_quote="" v_word="" v_in_word=false i a_config_words=() v_config_list=none for ((i = 0; i < ${#v_text}; i++)); do v_char="${v_text:i:1}" if [[ -n "${v_quote}" ]]; then if [[ "${v_char}" == "${v_quote}" ]]; then v_quote="" elif [[ "${v_quote}" == '"' ]] && [[ "${v_char}" == "\\" ]] && [[ "${v_text:i+1:1}" == [\"\\\$\`] ]]; then v_word+="${v_text:i+1:1}" i=$((i + 1)) else v_word+="${v_char}" fi continue fi [[ "${v_config_list}" != closed ]] || [[ "${v_char}" == [[:space:]] ]] || [[ "${v_char}" == '#' ]] \ || return 1 case "${v_char}" in [[:space:]]) if [[ "${v_in_word}" == true ]]; then a_config_words+=("${v_word}") v_word="" v_in_word=false fi ;; '#') if [[ "${v_in_word}" == true ]]; then v_word+="${v_char}" else while ((i + 1 < ${#v_text})) && [[ "${v_text:i+1:1}" != $'\n' ]]; do i=$((i + 1)) done fi ;; '"'|"'") v_quote="${v_char}" v_in_word=true ;; "\\") i=$((i + 1)) v_word+="${v_text:i:1}" v_in_word=true ;; '(') [[ "${v_config_list}" == none ]] && [[ "${v_in_word}" == false ]] \ && ((${#a_config_words[@]} == 0)) || return 1 v_config_list=open ;; ')') [[ "${v_config_list}" == open ]] || return 1 if [[ "${v_in_word}" == true ]]; then a_config_words+=("${v_word}") v_word="" v_in_word=false fi v_config_list=closed ;; *) v_word+="${v_char}" v_in_word=true ;; esac done [[ -z "${v_quote}" ]] || return 1 if [[ "${v_in_word}" == true ]]; then a_config_words+=("${v_word}") fi return 0 } # Check value(s) ${3}... of setting ${1} (type ${2}); print the reason if invalid. function f_config_check() { local v_key="${1}" v_type="${2}" v_value v_out shift 2 for v_value in "${@}"; do if [[ "${v_type}" != cmd ]] && [[ "${v_value}" == *[\$\`]* ]]; then printf '%s: "$" and "`" are not expanded here, write the value itself' "${v_key}" return 1 fi if [[ -z "${v_value}" ]]; then case "${v_type}" in devs|paths) printf '%s: empty item in the list' "${v_key}"; return 1 ;; cmd|text) ;; *) [[ -z "${!v_key}" ]] || { printf '%s: empty value' "${v_key}"; return 1; } ;; esac continue fi case "${v_type}" in bool) [[ "${v_value}" =~ ^(true|false)$ ]] \ || { printf '%s=%s (true or false)' "${v_key}" "${v_value}"; return 1; } ;; int) # No leading zero: bash would read "08" as a wrong octal number [[ "${v_value}" =~ ^(0|[1-9][0-9]{0,5})$ ]] \ || { printf '%s=%s (a whole number, 0 or more)' "${v_key}" "${v_value}"; return 1; } ;; name) [[ "${v_value}" =~ ^[A-Za-z0-9_][A-Za-z0-9._-]*$ ]] \ || { printf '%s=%s (only letters, digits, ".", "_" and "-", not first)' "${v_key}" "${v_value}"; return 1; } ;; text) # Passed to other commands as an argument: never as one of their options [[ "${v_value}" != -* ]] \ || { printf '%s=%s (must not start with "-")' "${v_key}" "${v_value}"; return 1; } ;; path|paths) if [[ "${v_value}" != /* ]] || [[ "${v_value}" == *$'\n'* ]]; then printf '%s: %s is not an absolute path' "${v_key}" "${v_value}" return 1 fi ;; devs) # A name under /dev, such as sda or nvme0n1p3: it also becomes part of # file names in the working directory, so no "/" and no leading "." [[ "${v_value}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] \ || { printf '%s: %s is not a device name (e.g. sda, nvme0n1p3)' "${v_key}" "${v_value}"; return 1; } ;; format) v_out="$(LC_TIME=C date "+${v_value}" 2>/dev/null)" [[ "${v_out}" =~ ^[A-Za-z0-9._-]+$ ]] \ || { printf '%s=%s (a date format giving only letters, digits, ".", "_" and "-")' "${v_key}" "${v_value}"; return 1; } ;; esac done } function f_load_config() { local v_line v_key v_raw v_type v_more v_lineno=0 v_first v_why [[ -e "${v_config_path}" ]] || return 0 if [[ ! -f "${v_config_path}" ]] || [[ ! -r "${v_config_path}" ]]; then v_config_error="not a readable file" return fi while IFS= read -r v_line || [[ -n "${v_line}" ]]; do v_lineno=$((v_lineno + 1)) v_line="${v_line%$'\r'}" [[ "${v_line}" =~ ^[[:space:]]*(#.*)?$ ]] && continue if ! [[ "${v_line}" =~ ^[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then v_config_error="line ${v_lineno}: not a KEY=value line" return fi v_key="${BASH_REMATCH[1]}" v_raw="${BASH_REMATCH[2]}" v_type="${K_CONFIG_KEYS[${v_key}]:-}" if [[ -z "${v_type}" ]]; then if [[ "${v_key}" == v_datetime_suffix ]]; then v_config_error="line ${v_lineno}: v_datetime_suffix is now v_datetime_format, a date format such as '%Y%m%d-%H%M%S'" else v_config_error="line ${v_lineno}: unknown setting ${v_key}" fi return fi v_first="${v_lineno}" while true; do if ! f_config_words "${v_raw}"; then v_config_error="line ${v_first}: ${v_key}: unclosed quote or misplaced parenthesis" return fi [[ "${v_config_list}" == open ]] || break if ! IFS= read -r v_more; then v_config_error="line ${v_first}: ${v_key}: the list is not closed with \")\"" return fi v_lineno=$((v_lineno + 1)) v_raw+=$'\n'"${v_more%$'\r'}" done case "${v_type}" in devs|paths) if [[ "${v_config_list}" != closed ]]; then v_config_error="line ${v_first}: ${v_key} is a list, write it as ${v_key}=( \"...\" \"...\" )" return fi if ! v_why="$(f_config_check "${v_key}" "${v_type}" "${a_config_words[@]}")"; then v_config_error="line ${v_first}: ${v_why}" return fi local -n v_list_ref="${v_key}" # shellcheck disable=SC2034 # nameref: assigns the list named by v_key v_list_ref=("${a_config_words[@]}") unset -n v_list_ref ;; *) if [[ "${v_config_list}" != none ]] || ((${#a_config_words[@]} > 1)); then v_config_error="line ${v_first}: ${v_key} takes one value (quote it if it contains spaces)" return fi if ! v_why="$(f_config_check "${v_key}" "${v_type}" "${a_config_words[0]:-}")"; then v_config_error="line ${v_first}: ${v_why}" return fi printf -v "${v_key}" '%s' "${a_config_words[0]:-}" ;; esac done < "${v_config_path}" } f_load_config # ===== DERIVED DEFAULTS ===== # Each variable below defaults to another CONFIGURE variable's value (see # that variable's own comment further up). Computed here, after # ndm_linux-backup.conf is read, so overriding the "parent" via # ndm_linux-backup.conf is correctly picked up: computing these earlier would # freeze the child at the built-in default even when the parent got # overridden. ":-" still honors an explicit override of the child itself. v_tempdir="${v_tempdir:-/home/ndm_linux-backup/${v_backup_name}}" v_enable_gpg_data="${v_enable_gpg_data:-${v_enable_gpg_settings}}" v_enable_gpg_secrets="${v_enable_gpg_secrets:-${v_enable_gpg_settings}}" v_gpg_recipient_file_data="${v_gpg_recipient_file_data:-${v_gpg_recipient_file}}" v_gpg_recipient_file_secrets="${v_gpg_recipient_file_secrets:-${v_gpg_recipient_file}}" v_podman_user="${v_podman_user:-${v_username}}" v_dest_secrets="${v_dest_secrets:-${v_dest}}" v_tempdir_secrets="${v_tempdir_secrets:-${v_tempdir}_secrets}" v_dest_data="${v_dest_data:-${v_dest}}" v_tempdir_data="${v_tempdir_data:-${v_tempdir}_data}" v_datetime_suffix="$(date "+${v_datetime_format}")" ################################################################## # DO NOT EDIT BELOW THIS LINE UNLESS YOU KNOW WHAT YOU ARE DOING # ################################################################## # Resolve a_storage=( "auto" ) into the actual list of physical disks # (lsblk TYPE=disk, which already excludes partitions/loop/rom devices), # so the rest of the script only ever deals with concrete device names. if [[ "${#a_storage[@]}" -eq 1 ]] && [[ "${a_storage[0]}" == 'auto' ]]; then if command -v lsblk >/dev/null 2>&1; then mapfile -t a_storage < <(lsblk -dn -o NAME,TYPE | awk '$2=="disk"{print $1}') else printf "\na_storage is set to \"auto\" but lsblk is not installed; cannot detect disks.\n\n" >&2 exit 1 fi fi # Resolve a_luks=( "auto" ) into the actual list of LUKS partitions # (lsblk FSTYPE=crypto_LUKS), so the rest of the script only ever deals # with concrete device names. if [[ "${#a_luks[@]}" -eq 1 ]] && [[ "${a_luks[0]}" == 'auto' ]]; then if command -v lsblk >/dev/null 2>&1; then mapfile -t a_luks < <(lsblk -rno NAME,FSTYPE | awk '$2=="crypto_LUKS"{print $1}') else printf "\na_luks is set to \"auto\" but lsblk is not installed; cannot detect LUKS partitions.\n\n" >&2 exit 1 fi fi # Set vars and arrays as readonly to be safe readonly v_hostname readonly v_username readonly v_backup_name readonly v_dest readonly v_tempdir readonly a_storage readonly a_luks readonly v_enable_hardware readonly v_enable_devices readonly v_enable_lsblk readonly v_enable_blkid readonly v_enable_inxi readonly v_enable_efi readonly v_enable_mbr readonly v_enable_luks readonly v_enable_lvm readonly v_enable_os_release readonly v_enable_kernel readonly v_enable_partitions readonly v_enable_grub readonly v_enable_packages readonly v_enable_flatpak readonly v_enable_appimages readonly v_enable_modified_files readonly v_enable_unpackaged_files readonly v_enable_cron readonly v_enable_mysql readonly v_enable_postgresql readonly v_enable_docker readonly v_enable_docker_compose readonly v_enable_podman readonly v_tar_owner readonly v_tar_owner_secrets readonly v_pre_command readonly v_post_command readonly v_on_failure_command readonly v_dest_rsync readonly v_retention_days readonly v_retention_min_keep readonly v_enable_gpg_settings readonly v_enable_gpg_data readonly v_enable_gpg_secrets readonly v_gpg_recipient_file readonly v_gpg_recipient_file_data readonly v_gpg_recipient_file_secrets readonly v_enable_source_extra # shellcheck disable=SC2034 # passed by name to f_filter_existing_sources's `local -n`, not referenced directly readonly a_source_extra readonly a_appimages_extra_paths readonly a_docker_compose_extra_paths readonly v_datetime_format readonly v_datetime_suffix readonly v_mysql_single_dump readonly v_mysql_cnf_path readonly v_postgresql_single_dump readonly v_postgresql_user readonly v_postgresql_pgpass_path readonly v_podman_user readonly v_enable_data_backup readonly v_enable_docker_volumes_data readonly v_enable_podman_volumes_data readonly v_enable_data_source_extra readonly v_enable_secrets_backup readonly v_enable_luks_header_backup readonly a_secrets_source readonly a_secrets_source_extra readonly v_dest_secrets readonly v_tempdir_secrets readonly v_enable_rsync readonly v_enable_pre_command readonly v_enable_post_command readonly v_enable_on_failure_command # shellcheck disable=SC2034 # passed by name to f_filter_existing_sources's `local -n`, not referenced directly readonly a_data_source_extra readonly v_dest_data readonly v_tempdir_data readonly v_config_path readonly v_config_error # Every directory this script may write into or delete from outside # a_source_extra/a_secrets_source. Checked by f_assert_within_whitelist(). readonly a_write_whitelist=( "${v_dest}" "${v_dest_data}" "${v_dest_secrets}" "${v_tempdir}" "${v_tempdir_data}" "${v_tempdir_secrets}" ) # For debug purpose readonly K_DEBUG=false # Terminal colors for the dependency report (OK/MISSING/OPTIONAL). Left # empty when stdout isn't a terminal or NO_COLOR is set (see # https://no-color.org), so redirected output and log files stay free of # escape sequences. if [[ -t 1 ]] && [[ -z "${NO_COLOR:-}" ]]; then readonly K_COLOR_GREEN=$'\033[32m' readonly K_COLOR_RED=$'\033[31m' readonly K_COLOR_YELLOW=$'\033[33m' readonly K_COLOR_RESET=$'\033[0m' else readonly K_COLOR_GREEN='' readonly K_COLOR_RED='' readonly K_COLOR_YELLOW='' readonly K_COLOR_RESET='' fi # Every external command the script relies on, mapped to the Debian # package that provides it. Used by f_check_dependencies() to report # what is missing and, if approved, install it via apt. declare -A K_PACKAGE_FOR_COMMAND=( ["fdisk"]="fdisk" ["sfdisk"]="fdisk" ["lshw"]="lshw" ["lsblk"]="util-linux" ["blkid"]="util-linux" ["inxi"]="inxi" ["lspci"]="pciutils" ["lsusb"]="usbutils" ["efibootmgr"]="efibootmgr" ["dd"]="coreutils" ["cryptsetup"]="cryptsetup" ["lvmdump"]="lvm2" ["pvdisplay"]="lvm2" ["vgdisplay"]="lvm2" ["lvdisplay"]="lvm2" ["lsb_release"]="lsb-release" ["uname"]="coreutils" ["tar"]="tar" ["dpkg"]="dpkg" ["apt-mark"]="apt" ["crontab"]="cron" ["chown"]="coreutils" ["sha256sum"]="coreutils" ["gpg"]="gnupg" ["mysql"]="default-mysql-client" ["mysqldump"]="default-mysql-client" ["rsync"]="rsync" ["flatpak"]="flatpak" ["psql"]="postgresql-client" ["pg_dump"]="postgresql-client" ["pg_dumpall"]="postgresql-client" ["docker"]="docker.io" ["podman"]="podman" ["sudo"]="sudo" ) readonly K_PACKAGE_FOR_COMMAND readonly K_ALL_COMMANDS=( "${!K_PACKAGE_FOR_COMMAND[@]}" ) # Which backup-module switch (v_enable_*) each command belongs to. Lets # f_check_dependencies() tell "needed by your enabled modules" apart from # "everything the script could ever use". v_enable_grub has no entry: it # only uses f_copy, no external command to track. declare -A K_COMMANDS_FOR_MODULE=( ["v_enable_hardware"]="lshw" ["v_enable_devices"]="lspci lsusb" ["v_enable_lsblk"]="lsblk" ["v_enable_blkid"]="blkid" ["v_enable_inxi"]="inxi" ["v_enable_efi"]="efibootmgr" ["v_enable_mbr"]="dd" ["v_enable_luks"]="cryptsetup" ["v_enable_lvm"]="lvmdump pvdisplay vgdisplay lvdisplay" ["v_enable_os_release"]="lsb_release" ["v_enable_kernel"]="uname" ["v_enable_partitions"]="sfdisk" ["v_enable_packages"]="dpkg apt-mark" ["v_enable_flatpak"]="flatpak" ["v_enable_modified_files"]="dpkg" ["v_enable_cron"]="crontab" ["v_enable_mysql"]="mysql mysqldump" ["v_enable_postgresql"]="psql pg_dump pg_dumpall" ["v_enable_docker"]="docker" # sudo is NOT listed here even though f_podman may use it: it's only # needed when v_podman_user != root, so f_commands_for_enabled_modules() # adds it conditionally instead -- keeps sudo-less servers from being # flagged for it when v_podman_user=root. ["v_enable_podman"]="podman" ["v_enable_docker_volumes_data"]="docker" ["v_enable_podman_volumes_data"]="podman" ["v_enable_rsync"]="rsync" ["v_enable_luks_header_backup"]="cryptsetup" ) readonly K_COMMANDS_FOR_MODULE # Commands needed no matter which backup modules are enabled: fdisk # classifies every configured disk as GPT/MBR, tar builds the final # archive, sha256sum writes its checksum sidecar. chown/gpg are NOT here # (not always needed, see v_tar_owner/v_enable_gpg_* in # f_commands_for_enabled_modules() instead). readonly K_ALWAYS_NEEDED_COMMANDS=( "fdisk" "tar" "sha256sum" ) # Patterns (grep -E, matched against the full path from `find /etc -type # f`) excluded by default from v_enable_unpackaged_files: auto-generated # state (adjtime...), tool/editor backup copies (the `-`/`~` suffixed # variants, .pwd.lock...), compiled caches (ld.so.cache), and # PAM/resolvconf fragments meant to be edited through their own tooling, # not by hand -- none of these is something an admin plausibly # customized directly, so they'd just be noise every run. fstab is # excluded only because it already has a dedicated, unconditional # capture elsewhere (partitions/fstab.txt, see v_enable_partitions), not # because it's noise -- unlike fstab, crypttab is deliberately NOT # excluded here even though it also has a dedicated capture # (luks/crypttab.txt): that one only runs if v_enable_luks=true and # a_luks is non-empty, so leaving crypttab unfiltered here is the # fallback that keeps it from silently missing entirely when that # switch is off; the tiny duplication when both do run is immaterial. # Anchored to each file's exact canonical path rather than a bare # filename: an unanchored pattern would also match a genuinely # unpackaged custom file sharing that basename elsewhere under /etc. # Also excludes private key material under /etc (SSH host keys, TLS # certs, Let's Encrypt...): that lives in the opt-in secrets archive # instead (see a_secrets_source). Deliberately does NOT exclude files an # admin plausibly edited by hand (motd, hosts, network/interfaces, # environment, nsswitch.conf, locale.gen, hostname, machine-id): those # are meant to actually surface here. readonly a_unpackaged_files_ignore_regex=( '^/etc/adjtime$' '^/etc/fstab$' '^/etc/passwd-?$' '^/etc/group-?$' '^/etc/shadow-?$' '^/etc/gshadow-?$' '^/etc/subuid-?$' '^/etc/subgid-?$' '^/etc/networks$' '^/etc/shells$' '^/etc/modules$' '^/etc/inputrc$' '^/etc/kernel-img\.conf$' '^/etc/ld\.so\.cache$' '^/etc/locale\.conf$' '^/etc/security/opasswd$' '^/etc/\.pwd\.lock$' '^/etc/\.updated$' '^/etc/apt/sources\.list~$' '^/etc/default/keyboard$' '^/etc/default/console-setup$' '^/etc/console-setup/cached_' '^/etc/pam\.d/common-' '^/etc/resolvconf/resolv\.conf\.d/' '^/etc/ssh/ssh_host_(rsa|ecdsa|ed25519)_key$' '^/etc/ssl/private/' '^/etc/letsencrypt/(live|archive)/' '^/etc/mysql/debian\.cnf$' '^/etc/NetworkManager/system-connections/' '^/etc/wpa_supplicant/' '^/etc/wireguard/' '^/etc/exim4/passwd\.client$' '^/etc/postfix/sasl_passwd' '^/etc/ppp/(chap|pap)-secrets$' ) # Commands whose own backup step already skips itself gracefully via a # `command -v` guard when missing (Flatpak, MySQL, PostgreSQL, Docker, # Podman in main()), or whose module is conditional on real hardware # presence via f_module_enabled() (EFI, LUKS, LVM). Excluded from the # fail-fast pre-flight check even when enabled; still reported/offered by # f_check_dependencies whenever the module applies to this machine. readonly K_SOFT_COMMANDS=( "lvmdump" "pvdisplay" "vgdisplay" "lvdisplay" "mysql" "mysqldump" "flatpak" "psql" "pg_dump" "pg_dumpall" "docker" "podman" "efibootmgr" ) # Every backup-module switch, in the same order as CONFIGURE. Used by # f_check_dependencies() to report which modules are enabled/disabled. # Some entries have no K_COMMANDS_FOR_MODULE counterpart because they use # only f_copy/find (grub, appimages, docker_compose, source_extra) or are # just outer gates (data_backup, secrets_backup, see f_module_enabled()). readonly K_ALL_MODULES=( "v_enable_hardware" "v_enable_devices" "v_enable_lsblk" "v_enable_blkid" "v_enable_inxi" "v_enable_efi" "v_enable_mbr" "v_enable_luks" "v_enable_lvm" "v_enable_os_release" "v_enable_kernel" "v_enable_partitions" "v_enable_grub" "v_enable_packages" "v_enable_flatpak" "v_enable_appimages" "v_enable_modified_files" "v_enable_unpackaged_files" "v_enable_cron" "v_enable_mysql" "v_enable_postgresql" "v_enable_docker" "v_enable_docker_compose" "v_enable_podman" "v_enable_source_extra" "v_enable_data_backup" "v_enable_docker_volumes_data" "v_enable_podman_volumes_data" "v_enable_data_source_extra" "v_enable_secrets_backup" "v_enable_luks_header_backup" \ "v_enable_rsync" "v_enable_pre_command" "v_enable_post_command" "v_enable_on_failure_command" ) # The same modules as K_ALL_MODULES, split by which archive they belong # to, for f_analyze_report()'s per-archive grouping. K_OTHER_MODULES is # cross-cutting (not archive-specific). Every K_ALL_MODULES entry appears # in exactly one of these four -- kept in sync by hand, same as # K_ALL_MODULES itself. readonly K_SETTINGS_MODULES=( "v_enable_hardware" "v_enable_devices" "v_enable_lsblk" "v_enable_blkid" "v_enable_inxi" "v_enable_efi" "v_enable_mbr" "v_enable_luks" "v_enable_lvm" "v_enable_os_release" "v_enable_kernel" "v_enable_partitions" "v_enable_grub" "v_enable_packages" "v_enable_flatpak" "v_enable_appimages" "v_enable_modified_files" "v_enable_unpackaged_files" "v_enable_cron" "v_enable_mysql" "v_enable_postgresql" "v_enable_docker" "v_enable_docker_compose" "v_enable_podman" "v_enable_source_extra" ) readonly K_DATA_MODULES=( "v_enable_data_backup" "v_enable_docker_volumes_data" "v_enable_podman_volumes_data" "v_enable_data_source_extra" ) readonly K_SECRETS_MODULES=( "v_enable_secrets_backup" "v_enable_luks_header_backup" ) readonly K_OTHER_MODULES=( "v_enable_rsync" "v_enable_pre_command" "v_enable_post_command" "v_enable_on_failure_command" ) ######################## # FUNCTIONS ######################## # Name, version, release date, description and author (used by --version and --help). function f_version() { printf '%s %s (released %s)\n' "${APP_NAME}" "${APP_VERSION}" "${APP_RELEASE_DATE}" printf '%s\n' "${APP_DESCRIPTION}" | fold -s -w 88 | sed 's/ *$//' printf 'Author: %s\n' "${APP_AUTHOR}" echo printf '%s\n' "${APP_COPYRIGHT}" printf 'License %s: GNU AGPL version 3 or later \n' \ "${APP_LICENSE}" echo 'This is free software: you are free to change and redistribute it.' echo 'Provided "as is", WITHOUT ANY WARRANTY: the author is not liable for any damage' echo 'arising from its use. See the LICENSE file and the header of this script.' } function f_usage() { printf 'Usage: sudo %s --start [-n] [-q [-k]] [--secrets] | --analyze [--secrets]\n' "${0##*/}" printf ' %s --requirements | -h | -u | -v\n' "${0##*/}" } function f_help() { f_version echo # The help text is the comment block between the HELP-BEGIN and HELP-END markers. awk '/^# HELP-BEGIN/ {f = 1; next} /^# HELP-END/ {exit} f {sub(/^# ?/, ""); print}' "${BASH_SOURCE[0]}" } # Invalid command-line usage: message on stderr, exit code 2 function f_bad_usage() { printf '%s (see --help)\n' "${1}" >&2 exit 2 } # --requirements: the tools of the modules switched on in the settings (script and # ndm_linux-backup.conf), which are missing, and the apt-get command to install them. # Needs no root: the hardware-dependent checks (UEFI, MBR, LUKS) are left to # --analyze, so a module switched on counts here even if this machine lacks it. function f_requirements() { local -a a_needed=() a_missing=() a_other_missing=() local -A a_is_needed=() a_pkg_seen=() local v_module v_cmd v_pkg local -a a_missing_pkgs=() a_other_pkgs=() # Run without root, the sbin directories are often not in PATH: root's are local PATH="${PATH}:/usr/local/sbin:/usr/sbin:/sbin" for v_cmd in "${K_ALWAYS_NEEDED_COMMANDS[@]}"; do a_is_needed["${v_cmd}"]=1 done for v_module in "${!K_COMMANDS_FOR_MODULE[@]}"; do [[ "${!v_module}" == true ]] || continue case "${v_module}" in v_enable_docker_volumes_data|v_enable_podman_volumes_data) [[ "${v_enable_data_backup}" == true ]] || continue ;; v_enable_luks_header_backup) [[ "${v_enable_secrets_backup}" == true ]] || continue ;; esac for v_cmd in ${K_COMMANDS_FOR_MODULE[${v_module}]}; do a_is_needed["${v_cmd}"]=1 done done if [[ "${v_enable_gpg_settings}" == true ]] || [[ "${v_enable_gpg_data}" == true ]] \ || [[ "${v_enable_gpg_secrets}" == true ]]; then a_is_needed["gpg"]=1 fi mapfile -t a_needed < <(printf '%s\n' "${!a_is_needed[@]}" | sort) local -A a_is_soft=() local -a a_soft_missing=() for v_cmd in "${K_SOFT_COMMANDS[@]}"; do a_is_soft["${v_cmd}"]=1 done for v_cmd in "${a_needed[@]}"; do command -v "${v_cmd}" >/dev/null 2>&1 && continue if [[ -n "${a_is_soft[${v_cmd}]:-}" ]]; then a_soft_missing+=("${v_cmd}") else a_missing+=("${v_cmd}") fi v_pkg="${K_PACKAGE_FOR_COMMAND[${v_cmd}]}" [[ -n "${a_pkg_seen[${v_pkg}]:-}" ]] || { a_pkg_seen["${v_pkg}"]=1; a_missing_pkgs+=("${v_pkg}"); } done while IFS= read -r v_cmd; do [[ -n "${a_is_needed[${v_cmd}]:-}" ]] && continue command -v "${v_cmd}" >/dev/null 2>&1 && continue a_other_missing+=("${v_cmd}") v_pkg="${K_PACKAGE_FOR_COMMAND[${v_cmd}]}" [[ -n "${a_pkg_seen[${v_pkg}]:-}" ]] || { a_pkg_seen["${v_pkg}"]=1; a_other_pkgs+=("${v_pkg}"); } done < <(printf '%s\n' "${K_ALL_COMMANDS[@]}" | sort) echo "Requirements" echo " system: Debian GNU/Linux (tested on Debian 13), bash, root privileges" printf ' needed: %s\n' "${a_needed[*]}" | fold -s -w 88 | sed -e 's/ *$//' -e '2,$s/^/ /' echo " (always tar, fdisk, sha256sum; the rest for the modules switched on)" echo if ((${#a_missing[@]} + ${#a_soft_missing[@]} == 0)); then echo "Everything needed by your settings is installed." else ((${#a_missing[@]} == 0)) || printf ' MISSING: %s\n' "${a_missing[*]}" ((${#a_soft_missing[@]} == 0)) \ || printf ' missing, optional (only their own piece is skipped): %s\n' "${a_soft_missing[*]}" echo echo "Install them (Debian):" printf ' sudo apt-get install %s\n' "${a_missing_pkgs[*]}" fi if ((${#a_other_missing[@]})); then echo printf 'Not installed, used only by modules switched off: %s\n' "${a_other_missing[*]}" \ | fold -s -w 88 | sed 's/ *$//' printf ' (packages: %s)\n' "${a_other_pkgs[*]}" | fold -s -w 88 | sed 's/ *$//' fi } # Hard-exit if command ${1} is not installed. function f_check_command() { command -v "${1}" >/dev/null 2>&1 || { printf "\nCommand '%s' is required but not installed.\n\n" "${1}" >&2 exit 1 } } # Whether a v_enable_* module actually runs. Normally just its own switch; # a few modules also need a runtime precondition true (real hardware # present, a companion path/command set) since the switch alone doesn't # guarantee that -- see each case arm below. Keeps the dependency report # and the enabled/disabled modules list reflecting what will actually run, # not just which switches are on. MySQL/PostgreSQL are not special-cased: # their schema dump always runs on their own switch alone (see main()); # only the row-data dump checks v_enable_data_backup, inline there. function f_module_enabled() { local v_module="${1}" case "${v_module}" in v_enable_docker_volumes_data|v_enable_podman_volumes_data|v_enable_data_source_extra) [[ "${v_enable_data_backup}" == true ]] && [[ "${!v_module}" == true ]] ;; v_enable_efi) [[ "${v_uefi_bios}" == 'uefi' ]] && [[ "${!v_module}" == true ]] ;; v_enable_mbr) [[ "${#a_mbr[@]}" != 0 ]] && [[ "${!v_module}" == true ]] ;; v_enable_luks) [[ "${#a_luks[@]}" != 0 ]] && [[ "${!v_module}" == true ]] ;; v_enable_luks_header_backup) [[ "${#a_luks[@]}" != 0 ]] && [[ "${v_secrets_active}" == true ]] && [[ "${!v_module}" == true ]] ;; v_enable_lvm) [[ "${v_lvm_present}" == true ]] && [[ "${!v_module}" == true ]] ;; v_enable_rsync) [[ -n "${v_dest_rsync}" ]] && [[ "${!v_module}" == true ]] ;; v_enable_secrets_backup) # OR, not AND: --secrets (v_secrets_override, set in f_parse_arguments) # forces this on for the run even if CONFIGURE left it false; it # never turns it off if CONFIGURE already set it true. [[ "${!v_module}" == true ]] || [[ "${v_secrets_override}" == true ]] ;; v_enable_pre_command) [[ -n "${v_pre_command}" ]] && [[ "${!v_module}" == true ]] ;; v_enable_post_command) [[ -n "${v_post_command}" ]] && [[ "${!v_module}" == true ]] ;; v_enable_on_failure_command) [[ -n "${v_on_failure_command}" ]] && [[ "${!v_module}" == true ]] ;; *) [[ "${!v_module}" == true ]] ;; esac } # All commands used by the currently enabled backup modules (v_enable_*), # plus the always-needed core commands, one per line, sorted. Includes # commands whose step can gracefully skip itself when missing (see # K_SOFT_COMMANDS) - from the user's point of view they are still needed # to actually get that module's data. function f_commands_for_enabled_modules() { local -A a_seen=() local v_module v_cmd for v_cmd in "${K_ALWAYS_NEEDED_COMMANDS[@]}"; do a_seen["${v_cmd}"]=1 done for v_module in "${!K_COMMANDS_FOR_MODULE[@]}"; do if f_module_enabled "${v_module}"; then for v_cmd in ${K_COMMANDS_FOR_MODULE[${v_module}]}; do a_seen["${v_cmd}"]=1 done fi done # sudo is only needed by f_podman's own `sudo -u` user-switch, when # v_podman_user != root -- never to run NDM Linux Backup itself (already root). if [[ "${v_podman_user}" != 'root' ]] \ && { f_module_enabled "v_enable_podman" || f_module_enabled "v_enable_podman_volumes_data"; }; then a_seen["sudo"]=1 fi # chown: needed if v_tar_owner=true (settings/data ENDING ownership # change), or if v_tar_owner_secrets=true and the secrets pass actually # runs this session (same AND as docker_volumes_data/luks_header_backup # above). Not a v_enable_* module, so K_COMMANDS_FOR_MODULE can't # express it. if [[ "${v_tar_owner}" == true ]] \ || { [[ "${v_tar_owner_secrets}" == true ]] && [[ "${v_secrets_active}" == true ]]; }; then a_seen["chown"]=1 fi # gpg: needed if any archive that actually runs this session gets # encrypted -- settings always runs; data/secrets only count if that # pass itself runs (same AND as docker_volumes_data/luks_header_backup # in f_module_enabled()). if [[ "${v_enable_gpg_settings}" == true ]] \ || { [[ "${v_enable_gpg_data}" == true ]] && [[ "${v_enable_data_backup}" == true ]]; } \ || { [[ "${v_enable_gpg_secrets}" == true ]] && [[ "${v_secrets_active}" == true ]]; }; then a_seen["gpg"]=1 fi printf '%s\n' "${!a_seen[@]}" | sort } # Same set as above, minus K_SOFT_COMMANDS. This is the strict set used # for the fail-fast pre-flight check in main(): a step that already # tolerates its own tool being missing must not be forced to hard-exit. function f_hard_required_commands() { local -A a_soft=() local v_cmd for v_cmd in "${K_SOFT_COMMANDS[@]}"; do a_soft["${v_cmd}"]=1 done while IFS= read -r v_cmd; do [[ -n "${a_soft[${v_cmd}]:-}" ]] && continue printf '%s\n' "${v_cmd}" done < <(f_commands_for_enabled_modules) } # Prints (and logs, if a log file is open) whether each archive actually # produced this session ends up GPG-encrypted or stays plain. Shared # between f_check_dependencies() and f_analyze_report() so both show the # same thing. data/secrets are only reported if that pass actually runs. function f_print_encryption_status() { local -a a_gpg_encrypted=() a_gpg_plain=() [[ "${v_enable_gpg_settings}" == true ]] && a_gpg_encrypted+=("settings") || a_gpg_plain+=("settings") if [[ "${v_enable_data_backup}" == true ]]; then [[ "${v_enable_gpg_data}" == true ]] && a_gpg_encrypted+=("data") || a_gpg_plain+=("data") fi if [[ "${v_secrets_active}" == true ]]; then [[ "${v_enable_gpg_secrets}" == true ]] && a_gpg_encrypted+=("secrets") || a_gpg_plain+=("secrets") fi if [[ "${#a_gpg_encrypted[@]}" -gt 0 ]]; then printf "\nEncryption: ENABLED for: %s -- the plaintext .tar.xz is deleted once the GPG-encrypted copy exists.\n" "${a_gpg_encrypted[*]}" >&2 f_log_msg "Encryption: ENABLED for: ${a_gpg_encrypted[*]} -- the plaintext .tar.xz is deleted once the GPG-encrypted copy exists" fi if [[ "${#a_gpg_plain[@]}" -gt 0 ]]; then printf 'Encryption: disabled for: %s -- %s %s\n' "${a_gpg_plain[*]}" \ "those archives stay plain, unencrypted .tar.xz. Set v_enable_gpg_settings/_data/_secrets=true" \ "in CONFIGURE or ndm_linux-backup.conf to enable (see Encryption in README)." >&2 f_log_msg "Encryption: disabled for: ${a_gpg_plain[*]} -- those archives stay plain, unencrypted .tar.xz" fi } # List every external command the script can use, report which ones are # missing, and offer to install the corresponding packages via apt - # either only those needed by the currently enabled backup modules # (default), or every dependency the script could ever use. Never # installs anything in --quiet or --dry-run mode: it only reports. function f_check_dependencies() { local -a a_sorted_commands a_needed_now local -a a_missing_all=() a_missing_needed=() local -a a_missing_all_packages=() a_missing_needed_packages=() local -A a_seen_all_packages=() a_seen_needed_packages=() a_is_needed=() local i v_package mapfile -t a_sorted_commands < <(printf '%s\n' "${K_ALL_COMMANDS[@]}" | sort) mapfile -t a_needed_now < <(f_commands_for_enabled_modules) for i in "${a_needed_now[@]}"; do a_is_needed["${i}"]=1 done local -a a_enabled_modules=() a_disabled_modules=() local v_module for v_module in "${K_ALL_MODULES[@]}"; do if f_module_enabled "${v_module}"; then a_enabled_modules+=("${v_module#v_enable_}") else a_disabled_modules+=("${v_module#v_enable_}") fi done f_print_msg "$(printf '\nEnabled backup modules: %s' "${a_enabled_modules[*]:-(none)}")" f_print_msg "Disabled backup modules: ${a_disabled_modules[*]:-(none)}" # Unconditional, unlike the missing-packages notice below: whether # secrets end up in this run's output is safety-critical, never to be # silently skipped in --quiet/cron runs. if [[ "${v_secrets_active}" == true ]]; then if [[ "${v_enable_secrets_backup}" == true ]]; then v_secrets_reason="v_enable_secrets_backup=true in the settings" else v_secrets_reason="the --secrets override for this run (v_enable_secrets_backup is false in the settings)" fi printf '\nSecrets backup: ENABLED (%s) -- %s %s\n' "${v_secrets_reason}" \ "this run's secrets archive WILL contain cleartext private keys (SSH host, TLS) and password" \ "hashes (/etc/shadow); protect it at least as well as /root and /etc/shadow themselves." >&2 f_log_msg "Secrets backup: ENABLED (${v_secrets_reason}) --" \ "this run's secrets archive WILL contain cleartext private keys (SSH host, TLS) and password" \ "hashes (/etc/shadow)" else printf '\n%s %s %s\n' \ "Secrets backup: disabled -- no SSH/TLS private keys, /etc/shadow, or other credentials will" \ "be included in this run. Set v_enable_secrets_backup=true in CONFIGURE or ndm_linux-backup.conf," \ "or pass --secrets for this run only, to include them." >&2 f_log_msg "Secrets backup: disabled -- no SSH/TLS private keys, /etc/shadow, or other credentials were included in this run" fi # Unconditional for the same reason: a separate, materially different # risk (defeats crypto-shredding on every disk in a_luks), worth its # own run-time reminder. if f_module_enabled "v_enable_luks_header_backup"; then printf '\n%s %s\n' \ "LUKS header backup: ENABLED -- this run's secrets archive WILL contain a raw, restorable copy" \ "of every LUKS header in a_luks, which defeats crypto-shredding for anyone who also gets hold of it." >&2 f_log_msg "LUKS header backup: ENABLED -- this run's secrets archive contains a raw, restorable copy of every LUKS header in a_luks" fi # Unconditional for the same reason: retention deletes old archives. if [[ "${v_retention_days}" -gt 0 ]]; then printf '\n%s %s\n' \ "Retention: ENABLED -- archives older than ${v_retention_days} day(s) will be deleted" \ "(keeping at least ${v_retention_min_keep} of each type regardless of age)." >&2 f_log_msg "Retention: ENABLED -- archives older than ${v_retention_days} day(s) deleted" \ "(keeping at least ${v_retention_min_keep} of each type regardless of age)" fi # Unconditional for the same reason: encryption status is safety-relevant. f_print_encryption_status # A custom recipient key without any v_enable_gpg_* switch on most likely # means encryption was expected (older settings turned it on by setting # the key path alone): say so instead of silently writing plaintext. # Flagged only when the path was actually customized. if [[ "${v_gpg_recipient_file}" != "${HOME}/pubkey.asc" ]] \ && [[ "${v_enable_gpg_settings}" != true ]] && [[ "${v_enable_gpg_data}" != true ]] \ && [[ "${v_enable_gpg_secrets}" != true ]]; then printf 'NOTICE: v_gpg_recipient_file is set to a custom path (%s) %s %s %s\n' "${v_gpg_recipient_file}" \ "but no v_enable_gpg_settings/_data/_secrets switch is on, so nothing is actually being encrypted" \ "-- setting the recipient-file path alone does not turn encryption on." \ "Set the matching v_enable_gpg_* switch(es) to true." >&2 f_log_msg "NOTICE: v_gpg_recipient_file is set to a custom path (${v_gpg_recipient_file})" \ "but no v_enable_gpg_settings/_data/_secrets switch is on, so nothing is actually being encrypted" fi # Pad the tags to a fixed width before adding color codes: printf's # %-10s counts the invisible ANSI escape bytes too, so padding after # colorizing would throw off the column alignment. local v_tag_ok v_tag_missing v_tag_optional v_tag_ok="$(printf '%-10s' '[OK]')" v_tag_missing="$(printf '%-10s' '[MISSING]')" v_tag_optional="$(printf '%-10s' '[OPTIONAL]')" f_print_msg $'\nChecking required software' local v_installed for i in "${a_sorted_commands[@]}"; do v_package="${K_PACKAGE_FOR_COMMAND[${i}]}" v_installed=false command -v "${i}" >/dev/null 2>&1 && v_installed=true if [[ -z "${a_is_needed[${i}]:-}" ]]; then # Not needed by any enabled module: OPTIONAL regardless of whether # it happens to be installed, since your current config doesn't # care either way. f_print_msg " ${K_COLOR_YELLOW}${v_tag_optional}${K_COLOR_RESET} ${i} (package: ${v_package})" if [[ "${v_installed}" == false ]]; then a_missing_all+=("${i}") if [[ -z "${a_seen_all_packages[${v_package}]:-}" ]]; then a_seen_all_packages["${v_package}"]=1 a_missing_all_packages+=("${v_package}") fi fi continue fi if [[ "${v_installed}" == true ]]; then f_print_msg " ${K_COLOR_GREEN}${v_tag_ok}${K_COLOR_RESET} ${i}" else f_print_msg " ${K_COLOR_RED}${v_tag_missing}${K_COLOR_RESET} ${i} (package: ${v_package})" a_missing_all+=("${i}") a_missing_needed+=("${i}") if [[ -z "${a_seen_all_packages[${v_package}]:-}" ]]; then a_seen_all_packages["${v_package}"]=1 a_missing_all_packages+=("${v_package}") fi if [[ -z "${a_seen_needed_packages[${v_package}]:-}" ]]; then a_seen_needed_packages["${v_package}"]=1 a_missing_needed_packages+=("${v_package}") fi fi done if [[ "${#a_missing_all[@]}" == 0 ]]; then f_print_msg "All required software is installed." return fi if [[ "${v_dry_run}" == true ]] || [[ "${v_quiet}" == true ]]; then printf '\nMissing packages: %s %s %s\n' "${a_missing_all_packages[*]}" \ "(dependency check skipped in --dry-run/--quiet: a normal run always asks before installing" \ "anything, never installs on its own)" >&2 f_log_msg "Missing packages: ${a_missing_all_packages[*]}" \ "(dependency check skipped in --dry-run/--quiet: a normal run always asks before installing" \ "anything, never installs on its own)" return fi f_check_command "apt-get" printf "\nAll of NDM Linux Backup's dependencies that are missing: %s\n" "${a_missing_all_packages[*]}" printf "Missing dependencies needed by your enabled backup modules: %s\n" "${a_missing_needed_packages[*]:-(none)}" printf "\nTo use NDM Linux Backup, some dependencies need to be downloaded.\n" printf "Do you want to install all of NDM Linux Backup's dependencies, or only the ones needed by the backup modules you selected?\n" local v_choice while true; do printf "\n[N]eeded only (default) / [A]ll / [M]anual / [S]kip / [C]ancel? " read -r v_choice case "${v_choice}" in ''|N|n ) if [[ "${#a_missing_needed_packages[@]}" == 0 ]]; then printf "Nothing needed by your enabled backup modules is missing.\n" return fi printf "\nWill run:\n apt-get update\n apt-get install -y %s\n\n" "${a_missing_needed_packages[*]}" DEBIAN_FRONTEND=noninteractive apt-get update DEBIAN_FRONTEND=noninteractive apt-get install -y "${a_missing_needed_packages[@]}" return ;; A|a ) printf "\nWill run:\n apt-get update\n apt-get install -y %s\n\n" "${a_missing_all_packages[*]}" DEBIAN_FRONTEND=noninteractive apt-get update DEBIAN_FRONTEND=noninteractive apt-get install -y "${a_missing_all_packages[@]}" return ;; M|m ) if [[ "${#a_missing_needed_packages[@]}" == 0 ]]; then printf "Nothing needed by your enabled backup modules is missing.\n" return fi printf "\nTo install manually, run:\n apt-get update\n apt-get install -y %s\n" "${a_missing_needed_packages[*]}" printf "Exiting so you can install it yourself; re-run NDM Linux Backup once done.\n\n" exit 0 ;; S|s ) printf "\nContinuing without installing; steps needing the missing software will be skipped or fail.\n\n" >&2 return ;; C|c ) printf "Script aborted\n\n" exit 0 ;; * ) printf "Invalid answer (choose N, A, M, S or C)\n" >&2 ;; esac done } # Hard requirement, checked once per enabled+applicable DB module: the # credentials file must exist before any backup work starts. Never # creates, copies, or edits it -- too risky to automate, only reports and # exits (see README). Both modules are checked together in a single exit # so a machine missing both isn't told about one, fixed, then the other. function f_check_db_credentials() { local v_script_dir v_script_dir="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" local -a a_missing=() if [[ "${v_enable_mysql}" == true ]] && [[ "$(command -v mysqldump)" ]] \ && [[ ! -f "${v_mysql_cnf_path}" ]] && [[ "${v_mysql_socket_auth}" != true ]]; then a_missing+=("$(cat <&2 f_log_msg "Database backup enabled, but the following credentials are missing:" for v_msg in "${a_missing[@]}"; do printf "\n%s\n" "${v_msg}" >&2 f_log_msg "${v_msg}" done printf "\nFix the above, then re-run NDM Linux Backup.\n\n" >&2 exit 1 fi # Advisory, not a hard-exit: a group/other-readable credentials file is # a confidentiality problem, not blocking otherwise. PostgreSQL's own # tools already refuse a non-600 .pgpass; MySQL's --defaults-file has # no such self-protection, so this is the only thing that catches it. local v_cred_mode if [[ "${v_enable_mysql}" == true ]] && [[ "$(command -v mysqldump)" ]] && [[ -f "${v_mysql_cnf_path}" ]]; then v_cred_mode="$(stat -c '%a' "${v_mysql_cnf_path}")" if (( (10#${v_cred_mode: -2:1} & 4) != 0 )) || (( (10#${v_cred_mode: -1} & 4) != 0 )); then printf '\nWARNING: %s is readable by group/other (mode %s) -- %s\n' \ "${v_mysql_cnf_path}" "${v_cred_mode}" \ "it contains a plaintext MySQL/MariaDB password. chmod 600 it." >&2 f_log_msg "WARNING: ${v_mysql_cnf_path} is readable by group/other (mode ${v_cred_mode}) -- it contains a plaintext MySQL/MariaDB password" fi fi if [[ "${v_enable_postgresql}" == true ]] && [[ "$(command -v pg_dump)" ]] && [[ -f "${v_postgresql_pgpass_path}" ]]; then v_cred_mode="$(stat -c '%a' "${v_postgresql_pgpass_path}")" if (( (10#${v_cred_mode: -2:1} & 4) != 0 )) || (( (10#${v_cred_mode: -1} & 4) != 0 )); then printf '\nWARNING: %s is readable by group/other (mode %s) -- %s\n' \ "${v_postgresql_pgpass_path}" "${v_cred_mode}" \ "it contains a plaintext PostgreSQL password. chmod 600 it." >&2 f_log_msg "WARNING: ${v_postgresql_pgpass_path} is readable by group/other (mode ${v_cred_mode}) -- it contains a plaintext PostgreSQL password" fi fi } # Advisory (never blocks): warn if ${1} (a tar source directory passed # directly to the settings/data tar command, e.g. v_tempdir) resolves # under, or itself contains, any entry of ${3} (a source-extra array by # name, e.g. a_source_extra). Both end up as separate source arguments on # the same tar command line, so an overlap either duplicates that content # in the archive or, if the overlapping entry gets excluded instead, # drops it from ${1}'s own direct inclusion too -- see the v_tempdir # comment in CONFIGURE. Symlinks/missing paths resolve to empty and are # skipped: this is a hygiene check, not a security boundary (that's # f_assert_within_whitelist). function f_check_source_overlap() { local v_dir_path="${1}" v_dir_label="${2}" v_extra_label="${4}" local -n v_ref_extra="${3}" local v_real_dir v_real_extra i # readlink -f requires every component but the last to already exist, # which v_tempdir/v_tempdir_data never do this early (they're only # created later, by f_prepare_tempdir) -- fall back to the literal # configured path instead of silently skipping the check. Symlinks # along an as-yet-uncreated path can't be resolved anyway. v_real_dir="$(readlink -f "${v_dir_path}" 2>/dev/null)" [[ -n "${v_real_dir}" ]] || v_real_dir="${v_dir_path}" for i in "${v_ref_extra[@]}"; do v_real_extra="$(readlink -f "${i}" 2>/dev/null)" [[ -n "${v_real_extra}" ]] || v_real_extra="${i}" if [[ "${v_real_dir}" == "${v_real_extra}" ]] \ || [[ "${v_real_dir}" == "${v_real_extra}"/* ]] \ || [[ "${v_real_extra}" == "${v_real_dir}"/* ]]; then printf '\nWARNING: %s (%s) overlaps with %s entry %s -- %s %s %s\n' \ "${v_dir_label}" "${v_real_dir}" "${v_extra_label}" "${i}" \ "its content will be duplicated in the archive (or dropped from its own direct inclusion," \ "if you exclude the overlap instead)." \ "Move ${v_dir_label} outside every ${v_extra_label} path." >&2 f_log_msg "WARNING: ${v_dir_label} (${v_real_dir}) overlaps with ${v_extra_label} entry ${i}" \ "-- its content will be duplicated in the archive" fi done } # Hard requirement (unlike f_check_source_overlap above): ${1} (a v_tempdir*) # must never equal, or sit under, ${2} (its own v_dest*). The ENDING tar step # always excludes ${2} from ${1}'s archive (--exclude="${v_dest}") so v_dest # does not end up inside its own archive when it falls under a_source_extra # -- but GNU tar's --exclude also matches an ancestor directory of a path # being archived, not just entries found while walking it. If v_tempdir sits # at or under v_dest, that same exclude silently drops the entire tempdir # source too: every backup step still runs and reports "Done", but the # archive comes out empty (exit 0, "All done"). Checked once per archive # that actually runs this session, before any backup step starts. function f_check_dest_overlap() { local v_tempdir_path="${1}" v_dest_path="${2}" v_label="${3}" local v_real_tempdir v_real_dest # Same readlink -f / fallback-to-literal reasoning as f_check_source_overlap # above: v_tempdir does not exist yet this early in main(). v_real_tempdir="$(readlink -f "${v_tempdir_path}" 2>/dev/null)" [[ -n "${v_real_tempdir}" ]] || v_real_tempdir="${v_tempdir_path}" v_real_dest="$(readlink -f "${v_dest_path}" 2>/dev/null)" [[ -n "${v_real_dest}" ]] || v_real_dest="${v_dest_path}" if [[ "${v_real_tempdir}" == "${v_real_dest}" ]] || [[ "${v_real_tempdir}" == "${v_real_dest}"/* ]]; then printf '\n%s: v_tempdir (%s) is the same as, or nested under, its own v_dest (%s). %s %s %s\n\n' \ "${v_label}" "${v_tempdir_path}" "${v_dest_path}" \ "The ENDING tar step excludes v_dest from the archive so it doesn't swallow itself --" \ "since v_tempdir sits inside that excluded tree, the archive would be produced empty, silently" \ "(exit 0, \"All done\"). Move v_tempdir outside v_dest and re-run." >&2 f_log_msg "SECURITY: ${v_label}: v_tempdir (${v_tempdir_path}) is the same as, or nested under," \ "its own v_dest (${v_dest_path}) -- refusing to run, the archive would come out silently empty" exit 1 fi } # Hard requirement, checked once per enabled GPG switch: the matching # recipient public-key file must exist before any backup work starts. # Never generates a keypair itself -- too risky to automate -- only # reports and exits (see README "Encryption"). v_enable_gpg_data/_secrets # are only enforced when that pass actually runs this session. function f_check_gpg_keys() { local -a a_missing=() if [[ "${v_enable_gpg_settings}" == true ]] && { [[ -z "${v_gpg_recipient_file}" ]] || [[ ! -f "${v_gpg_recipient_file}" ]]; }; then a_missing+=("$(cat < > "${v_gpg_recipient_file:-${HOME}/pubkey.asc}" Already have a public key (e.g. from whoever will restore this backup)? Copy it there instead: cp /path/to/their/pubkey.asc "${v_gpg_recipient_file:-${HOME}/pubkey.asc}" Or point v_gpg_recipient_file at wherever it already lives. EOF )") fi if [[ "${v_enable_gpg_data}" == true ]] && [[ "${v_enable_data_backup}" == true ]] \ && { [[ -z "${v_gpg_recipient_file_data}" ]] || [[ ! -f "${v_gpg_recipient_file_data}" ]]; }; then a_missing+=("$(cat < > "${v_gpg_recipient_file_data:-${HOME}/pubkey.asc}" Already have a public key (e.g. from whoever will restore this backup)? Copy it there instead: cp /path/to/their/pubkey.asc "${v_gpg_recipient_file_data:-${HOME}/pubkey.asc}" Or point v_gpg_recipient_file_data at wherever it already lives. EOF )") fi if [[ "${v_enable_gpg_secrets}" == true ]] && [[ "${v_secrets_active}" == true ]] \ && { [[ -z "${v_gpg_recipient_file_secrets}" ]] || [[ ! -f "${v_gpg_recipient_file_secrets}" ]]; }; then a_missing+=("$(cat < > "${v_gpg_recipient_file_secrets:-${HOME}/pubkey.asc}" Already have a public key (e.g. from whoever will restore this backup)? Copy it there instead: cp /path/to/their/pubkey.asc "${v_gpg_recipient_file_secrets:-${HOME}/pubkey.asc}" Or point v_gpg_recipient_file_secrets at wherever it already lives. EOF )") fi if [[ "${#a_missing[@]}" -gt 0 ]]; then local v_msg printf "\nGPG encryption enabled, but the following recipient public keys are missing:\n" >&2 f_log_msg "GPG encryption enabled, but the following recipient public keys are missing:" for v_msg in "${a_missing[@]}"; do printf "\n%s\n" "${v_msg}" >&2 f_log_msg "${v_msg}" done printf "\nFix the above, then re-run NDM Linux Backup.\n\n" >&2 exit 1 fi } # Read-only companion to f_module_enabled(): for a module whose switch is # already true, explain in one line why it would NOT actually run, or # print nothing if it would. Mirrors f_module_enabled()'s own # preconditions by hand, plus K_SOFT_COMMANDS presence and the DB # credentials/podman sudo checks. Never decides whether a module runs -- # only explains a "true" switch that f_module_enabled() would reject. function f_analyze_reason() { local v_module="${1}" v_cmd case "${v_module}" in v_enable_docker_volumes_data|v_enable_podman_volumes_data|v_enable_data_source_extra) [[ "${v_enable_data_backup}" == true ]] || { printf 'v_enable_data_backup=false -- the whole data archive is off, turn it on first'; return; } ;; v_enable_efi) [[ "${v_uefi_bios}" == 'uefi' ]] || { printf 'not a UEFI system'; return; } ;; v_enable_mbr) [[ "${#a_mbr[@]}" != 0 ]] || { printf 'no MBR-partitioned disks found'; return; } ;; v_enable_luks) [[ "${#a_luks[@]}" != 0 ]] || { printf 'no LUKS partitions found'; return; } ;; v_enable_luks_header_backup) [[ "${#a_luks[@]}" != 0 ]] || { printf 'no LUKS partitions found'; return; } [[ "${v_secrets_active}" == true ]] || { printf '%s %s' "v_enable_secrets_backup=false and no --secrets --" \ "the whole secrets archive is off, turn it on first (or pass --secrets)" return } ;; v_enable_lvm) [[ "${v_lvm_present}" == true ]] || { printf 'no LVM volumes detected'; return; } ;; v_enable_rsync) [[ -n "${v_dest_rsync}" ]] || { printf 'v_dest_rsync is not set'; return; } ;; v_enable_pre_command) [[ -n "${v_pre_command}" ]] || { printf 'v_pre_command is not set'; return; } ;; v_enable_post_command) [[ -n "${v_post_command}" ]] || { printf 'v_post_command is not set'; return; } ;; v_enable_on_failure_command) [[ -n "${v_on_failure_command}" ]] || { printf 'v_on_failure_command is not set'; return; } ;; esac for v_cmd in ${K_COMMANDS_FOR_MODULE[${v_module}]:-}; do if [[ " ${K_SOFT_COMMANDS[*]} " == *" ${v_cmd} "* ]] && ! command -v "${v_cmd}" >/dev/null 2>&1; then printf '%s not installed' "${v_cmd}" return fi done case "${v_module}" in v_enable_mysql) [[ -f "${v_mysql_cnf_path}" ]] || [[ "${v_mysql_socket_auth}" == true ]] || { printf 'credentials file not found at %s -- %s %s' "${v_mysql_cnf_path}" \ "root@localhost socket auth not probed by --analyze (read-only, no live connection attempted)," \ "a real run may still succeed via it, see README" return } ;; v_enable_postgresql) [[ -f "${v_postgresql_pgpass_path}" ]] || [[ "${v_postgresql_peer_auth}" == true ]] || { printf 'credentials file not found at %s -- %s %s' "${v_postgresql_pgpass_path}" \ "peer/ident auth not probed by --analyze (read-only, no live connection attempted)," \ "a real run may still succeed via it, see README" return } ;; v_enable_podman|v_enable_podman_volumes_data) if [[ "${v_podman_user}" != 'root' ]] && ! command -v sudo >/dev/null 2>&1; then printf 'sudo not installed (needed to run podman as %s)' "${v_podman_user}" return fi ;; esac } # Companion to f_analyze_reason(), for a module whose switch is OFF: true # only if turning it on would still be blocked by hardware/firmware this # machine doesn't have, not by a missing config value/credential/tool -- # those stay "could be turned on in CONFIGURE". Mirrors the same UEFI/ # MBR/LUKS/LVM checks in f_analyze_reason(), kept in sync by hand for the # same reason that function is. function f_analyze_hardware_reason() { local v_module="${1}" case "${v_module}" in v_enable_efi) [[ "${v_uefi_bios}" == 'uefi' ]] || printf 'not a UEFI system' ;; v_enable_mbr) [[ "${#a_mbr[@]}" != 0 ]] || printf 'no MBR-partitioned disks found' ;; v_enable_luks|v_enable_luks_header_backup) [[ "${#a_luks[@]}" != 0 ]] || printf 'no LUKS partitions found' ;; v_enable_lvm) [[ "${v_lvm_present}" == true ]] || printf 'no LVM volumes detected' ;; esac } # Classify one module (${1}) into exactly one of the four nameref arrays # (${2}-${5}: will-run/blocked/disabled/not-applicable), same logic # f_analyze_report() used inline before it started grouping by archive. function f_analyze_classify() { local v_module="${1}" # shellcheck disable=SC2178 # local -n aliases the caller's array, not a string local -n v_ref_run="${2}" v_ref_blocked="${3}" v_ref_disabled="${4}" v_ref_na="${5}" local v_short="${v_module#v_enable_}" v_switch_on v_reason v_hw_reason # v_enable_secrets_backup is the one module OR'd with --secrets (see # f_module_enabled()): the raw CONFIGURE value alone isn't authoritative. if [[ "${v_module}" == 'v_enable_secrets_backup' ]]; then v_switch_on=false { [[ "${v_enable_secrets_backup}" == true ]] || [[ "${v_secrets_override}" == true ]]; } && v_switch_on=true else v_switch_on="${!v_module}" fi if [[ "${v_switch_on}" != true ]]; then v_hw_reason="$(f_analyze_hardware_reason "${v_module}")" if [[ -n "${v_hw_reason}" ]]; then v_ref_na+=("$(printf ' %-20s -- %s' "${v_short}" "${v_hw_reason}")") else v_ref_disabled+=("${v_short}") fi return fi v_reason="$(f_analyze_reason "${v_module}")" if [[ -z "${v_reason}" ]]; then v_ref_run+=("${v_short}") else v_ref_blocked+=("$(printf ' %-20s -- %s' "${v_short}" "${v_reason}")") fi } # Print the enabled/blocked/disabled/not-applicable lines for one group # of already-classified modules (${1}-${4}, the same nameref arrays # f_analyze_classify() filled). No section header or master-switch line # -- the caller prints those, since DATA/SECRETS also show their own # archive-level switch before this, which isn't itself in these lists # (see f_analyze_report()). function f_analyze_print_modules() { # shellcheck disable=SC2178 # local -n aliases the caller's array, not a string local -n v_ref_run="${1}" v_ref_blocked="${2}" v_ref_disabled="${3}" v_ref_na="${4}" local v_joined if [[ "${#v_ref_run[@]}" -gt 0 ]]; then v_joined="$(printf '%s, ' "${v_ref_run[@]}")" printf "Enabled: %s\n" "${v_joined%, }" else printf "Enabled: (none)\n" fi if [[ "${#v_ref_blocked[@]}" -gt 0 ]]; then printf "Enabled but blocked:\n" printf '%s\n' "${v_ref_blocked[@]}" fi if [[ "${#v_ref_disabled[@]}" -gt 0 ]]; then v_joined="$(printf '%s, ' "${v_ref_disabled[@]}")" printf "Disabled (could enable in the settings): %s\n" "${v_joined%, }" fi if [[ "${#v_ref_na[@]}" -gt 0 ]]; then printf "Not applicable to this machine:\n" printf '%s\n' "${v_ref_na[@]}" fi } # Report, grouped by archive (settings/data/secrets, plus a cross-cutting # section for rsync/hooks), without running a single backup command: # which enabled modules will actually produce something, which are # blocked (see f_analyze_reason()), which are disabled but would work if # enabled, and which are disabled and hardware-blocked regardless (see # f_analyze_hardware_reason()). Triggered by -a/--analyze: main() calls # this right after UEFI/LVM detection and exits, before anything else # runs -- purely read-only. function f_analyze_report() { # shellcheck disable=SC2034 # passed by name to f_analyze_classify's/f_analyze_print_section's `local -n`, not referenced directly local -a a_settings_run=() a_settings_blocked=() a_settings_disabled=() a_settings_na=() # shellcheck disable=SC2034 # passed by name to f_analyze_classify's/f_analyze_print_section's `local -n`, not referenced directly local -a a_data_run=() a_data_blocked=() a_data_disabled=() a_data_na=() # shellcheck disable=SC2034 # passed by name to f_analyze_classify's/f_analyze_print_section's `local -n`, not referenced directly local -a a_secrets_run=() a_secrets_blocked=() a_secrets_disabled=() a_secrets_na=() # shellcheck disable=SC2034 # passed by name to f_analyze_classify's/f_analyze_print_section's `local -n`, not referenced directly local -a a_other_run=() a_other_blocked=() a_other_disabled=() a_other_na=() local v_module for v_module in "${K_SETTINGS_MODULES[@]}"; do f_analyze_classify "${v_module}" a_settings_run a_settings_blocked a_settings_disabled a_settings_na done # v_enable_data_backup/v_enable_secrets_backup are each their own # archive's master switch, not a sibling of the modules they gate -- # skipped here and shown as their own line below instead, so they # don't read as just another item in the same Enabled/Disabled list. for v_module in "${K_DATA_MODULES[@]}"; do [[ "${v_module}" == 'v_enable_data_backup' ]] && continue f_analyze_classify "${v_module}" a_data_run a_data_blocked a_data_disabled a_data_na done for v_module in "${K_SECRETS_MODULES[@]}"; do [[ "${v_module}" == 'v_enable_secrets_backup' ]] && continue f_analyze_classify "${v_module}" a_secrets_run a_secrets_blocked a_secrets_disabled a_secrets_na done for v_module in "${K_OTHER_MODULES[@]}"; do f_analyze_classify "${v_module}" a_other_run a_other_blocked a_other_disabled a_other_na done printf "\n%s %s -- analysis for %s@%s (run --start to actually back up)\n" "${APP_NAME}" "${APP_VERSION}" "${USER}" "${v_hostname}" printf "\n=== SETTINGS ARCHIVE (always produced) ===\n" printf "Working directory (src): %s\n" "${v_tempdir}" printf "Destination (dest): %s\n" "${v_dest}" f_analyze_print_modules a_settings_run a_settings_blocked a_settings_disabled a_settings_na printf "\n=== DATA ARCHIVE (opt-in) ===\n" printf "Working directory (src): %s\n" "${v_tempdir_data}" printf "Destination (dest): %s\n" "${v_dest_data}" if [[ "${v_enable_data_backup}" == true ]]; then printf "Master switch (v_enable_data_backup): enabled\n" else printf "Master switch (v_enable_data_backup): disabled\n" fi f_analyze_print_modules a_data_run a_data_blocked a_data_disabled a_data_na printf "\n=== SECRETS ARCHIVE (opt-in) ===\n" printf "Working directory (src): %s\n" "${v_tempdir_secrets}" printf "Destination (dest): %s\n" "${v_dest_secrets}" if [[ "${v_enable_secrets_backup}" == true ]]; then printf "Master switch (v_enable_secrets_backup): enabled\n" elif [[ "${v_secrets_override}" == true ]]; then printf "Master switch (v_enable_secrets_backup): enabled (--secrets override for this run)\n" else printf "Master switch (v_enable_secrets_backup): disabled (pass --secrets to include it for this run)\n" fi f_analyze_print_modules a_secrets_run a_secrets_blocked a_secrets_disabled a_secrets_na f_print_encryption_status printf "\n=== OTHER (cross-cutting) ===\n" f_analyze_print_modules a_other_run a_other_blocked a_other_disabled a_other_na printf "\n" } # Run a podman command as v_podman_user, with that user's rootless # runtime directory set up, so a rootless user's containers/images are # visible even when this script itself runs as root. When v_podman_user # is "root", runs podman directly (root's own, usually rootful, state). # Called indirectly as "${v_runtime}" from f_container_metadata/f_container_volumes_data. # shellcheck disable=SC2317,SC2329 # no literal call site function f_podman() { if [[ "${v_podman_user}" == 'root' ]]; then podman "${@}" else sudo -u "${v_podman_user}" XDG_RUNTIME_DIR="/run/user/$(id -u "${v_podman_user}")" podman "${@}" fi } # Metadata collection shared by the Docker and Podman info steps: images/ # containers/volumes/networks lists plus daemon info. ${1} is the runtime # command to run (`docker` or `f_podman`), ${2} the destination directory. function f_container_metadata() { # v_out_dir, NOT v_dest: v_dest is already a readonly global (the # backup destination) by the time this runs -- `local v_dest=...` here # would silently fail to shadow it (bash refuses and just warns), and # every write below would go to v_dest instead of the caller's actual # ${2} (v_temp_path). local v_runtime="${1}" v_out_dir="${2}" "${v_runtime}" images | f_out "${v_out_dir}"/images.txt f_check_pipe "${v_runtime} images" "${v_out_dir}/images.txt" "${v_runtime}" ps -a | f_out "${v_out_dir}"/containers.txt f_check_pipe "${v_runtime} ps -a" "${v_out_dir}/containers.txt" "${v_runtime}" volume ls | f_out "${v_out_dir}"/volumes.txt f_check_pipe "${v_runtime} volume ls" "${v_out_dir}/volumes.txt" "${v_runtime}" network ls | f_out "${v_out_dir}"/networks.txt f_check_pipe "${v_runtime} network ls" "${v_out_dir}/networks.txt" "${v_runtime}" info | f_out "${v_out_dir}"/info.txt f_check_pipe "${v_runtime} info" "${v_out_dir}/info.txt" } # Volume content copy shared by the Docker and Podman data-pass steps: # reads each volume's real Mountpoint (works only for the default # "local" driver). ${1} is the runtime command (`docker` or `f_podman`), # ${2} the destination directory. function f_container_volumes_data() { # v_out_dir, NOT v_dest -- same readonly-shadowing reason as # f_container_metadata() above. local v_runtime="${1}" v_out_dir="${2}" local v_vol v_mountpoint while IFS= read -r v_vol; do v_mountpoint="$("${v_runtime}" volume inspect --format '{{ .Mountpoint }}' "${v_vol}" 2>/dev/null)" if [[ -n "${v_mountpoint}" ]] && [[ -d "${v_mountpoint}" ]]; then f_copy "${v_mountpoint}" "${v_out_dir}/${v_vol}" fi done < <("${v_runtime}" volume ls -q) } # Filter a_source_extra/a_data_source_extra (by nameref, ${1}) down to # entries that exist on disk into ${2} (also by nameref), printing a skip # notice (labeled ${3}) for missing ones. Shared by the settings and data # archive ENDING blocks below. function f_filter_existing_sources() { local -n v_ref_src="${1}" local -n v_ref_dst="${2}" local v_label="${3}" i v_ref_dst=() for i in "${v_ref_src[@]}"; do if [[ -e "${i}" ]]; then v_ref_dst+=("${i}") else f_print_msg "${v_label}: ${i} does not exist, skipping it" fi done } # Refuse to (re)use a path as a staging directory unless it is either # absent or a real directory owned by the current euid. Defends against a # symlink pre-planted under a predictable, world-writable path (e.g. the # default /tmp tempdirs) that would otherwise make mkdir -p silently write # through it into attacker-controlled storage. Must be called before any # f_mkdir on a tempdir path; hard-exits instead of prompting, since a # compromised path must never be offered as a normal "reuse?" choice. function f_secure_dir() { local v_path="${1}" if [[ -L "${v_path}" ]]; then printf "\n%s is a symlink, refusing to use it as a working directory (possible symlink attack). Remove it and re-run.\n\n" "${v_path}" >&2 exit 1 fi if [[ -e "${v_path}" ]]; then if [[ ! -d "${v_path}" ]]; then printf "\n%s exists but is not a directory. Remove it and re-run.\n\n" "${v_path}" >&2 exit 1 fi if [[ "$(stat -c '%u' "${v_path}")" != "${EUID}" ]]; then printf "\n%s exists but is not owned by the current user (euid %s). Remove it and re-run.\n\n" "${v_path}" "${EUID}" >&2 exit 1 fi fi } # Hard-exit unless ${1} resolves under one of a_write_whitelist's roots. # Defense-in-depth against a corrupted/empty variable pointing tar/chmod/ # chown/sha256sum/gpg/rm somewhere unintended. readlink -f only requires # every component but the last to exist, so an about-to-be-created # archive path still resolves correctly. function f_assert_within_whitelist() { local v_path="${1}" v_real_path v_root v_real_root v_real_path="$(readlink -f "${v_path}" 2>/dev/null)" if [[ -z "${v_real_path}" ]]; then printf "\nSECURITY: refusing to operate on %s: cannot resolve path.\n\n" "${v_path}" >&2 exit 1 fi for v_root in "${a_write_whitelist[@]}"; do v_real_root="$(readlink -f "${v_root}" 2>/dev/null)" [[ -n "${v_real_root}" ]] || continue if [[ "${v_real_path}" == "${v_real_root}" ]] || [[ "${v_real_path}" == "${v_real_root}"/* ]]; then return 0 fi done printf "\nSECURITY: refusing to operate on %s: outside the write whitelist (%s).\n\n" "${v_path}" "${a_write_whitelist[*]}" >&2 exit 1 } # Create a directory, unless in dry-run mode. An optional mode (${2}) is # chmod'd onto it afterwards -- used for staging directories that must stay # restricted regardless of umask or of already existing from a run predating # this hardening. function f_mkdir() { local v_path="${1}" v_mode="${2:-}" if [[ "${v_dry_run}" == true ]]; then f_print_msg "[DRY-RUN] would create directory: ${v_path}" else mkdir -p "${v_path}" if [[ -n "${v_mode}" ]]; then chmod "${v_mode}" "${v_path}" fi fi } # Validate and, if needed, create a staging directory (${1}, one of # v_tempdir/v_tempdir_data/v_tempdir_secrets) AND its immediate parent # (the shared container directory, e.g. /home/tmp) at mode 700, via # f_secure_dir/f_mkdir. The parent is only chmod'd if it doesn't already # exist (an existing shared system directory is never retroactively # touched); the leaf is always re-chmod'd to 700, even on reuse. function f_prepare_tempdir() { local v_path="${1}" v_parent v_parent="$(dirname "${v_path}")" f_secure_dir "${v_parent}" if [[ ! -d "${v_parent}" ]]; then f_mkdir "${v_parent}" 700 fi f_secure_dir "${v_path}" f_mkdir "${v_path}" 700 } # Delete old archives of one type (${1}: destination directory, ${2}: # exact filename prefix, e.g. "${v_backup_name}_${v_hostname}" for # settings), plus their .sha256 sidecar and, for the settings prefix # only, the run's shared .log. No-op unless v_retention_days > 0. Always # keeps the newest v_retention_min_keep regardless of age (see # CONFIGURE), deletes the rest once strictly older than v_retention_days. # Matches only ${2}_*.tar.xz, never a blanket sweep of ${1}. function f_prune_old_archives() { local v_dir="${1}" v_prefix="${2}" local -a a_candidates=() local i v_count=0 v_base [[ "${v_retention_days}" -gt 0 ]] || return 0 f_assert_within_whitelist "${v_dir}" # Newest first, so min-keep protects the most recent archives. Two # -name clauses (plain + .gpg) so a mixed pre/post-encryption history # is still counted correctly, and the .sha256 sidecar is never # mistaken for an archive of its own. mapfile -t a_candidates < <(find "${v_dir}" -maxdepth 1 \( -name "${v_prefix}_*.tar.xz" -o \ -name "${v_prefix}_*.tar.xz.gpg" \) -printf '%T@ %p\n' 2>/dev/null | sort -rn | cut -d' ' -f2-) for i in "${a_candidates[@]}"; do v_count=$((v_count + 1)) [[ "${v_count}" -le "${v_retention_min_keep}" ]] && continue [[ -z "$(find "${i}" -maxdepth 0 -mtime "+${v_retention_days}" 2>/dev/null)" ]] && continue if [[ "${i}" == *.tar.xz.gpg ]]; then v_base="${i%.tar.xz.gpg}" else v_base="${i%.tar.xz}" fi if [[ "${v_dry_run}" == true ]]; then f_print_msg "[DRY-RUN] would delete old archive: ${i}" else f_print_msg "Deleting old archive: ${i}" rm -f "${i}" "${i}.sha256" "${v_base}.log" fi done } # Encrypt an archive (${2}/${1}'s current value, e.g. v_filename) with # GPG, if its type is enabled (${4}, e.g. v_enable_gpg_settings) -- writes # ${2}/${1}.gpg via --recipient-file ${3}, deletes the plaintext once the # encrypted copy exists, and updates the caller's filename variable # (${1}, by name) to the new .gpg name so every downstream step operates # on the real final artifact. No-op if ${4} is false. ${3} missing while # ${4} is true is already caught by f_check_gpg_keys() before this runs. # Unlike a checksum failure, a failed encryption hard-exits the run. function f_encrypt_archive() { local -n v_ref_filename="${1}" local v_dir="${2}" v_recipient_file="${3}" v_enabled="${4}" v_gpg_err [[ "${v_enabled}" == true ]] || return 0 if [[ "${v_dry_run}" == true ]]; then f_print_msg "[DRY-RUN] would encrypt: ${v_ref_filename} -> ${v_ref_filename}.gpg" v_ref_filename="${v_ref_filename}.gpg" return 0 fi f_check_command "gpg" f_assert_within_whitelist "${v_dir}/${v_ref_filename}.gpg" if v_gpg_err=$(gpg --batch --yes --output "${v_dir}/${v_ref_filename}.gpg" --encrypt \ --recipient-file "${v_recipient_file}" "${v_dir}/${v_ref_filename}" 2>&1 >/dev/null); then f_assert_within_whitelist "${v_dir}/${v_ref_filename}" rm -f "${v_dir}/${v_ref_filename}" v_ref_filename="${v_ref_filename}.gpg" f_print_msg "Archive encrypted: ${v_ref_filename}" else printf '\nFailed to encrypt archive %s:\n%s\n\n%s %s -- %s\n\n' "${v_ref_filename}" "${v_gpg_err}" \ "The plaintext archive was NOT deleted and still exists, unencrypted, at" \ "${v_dir}/${v_ref_filename}" "secure or remove it manually." >&2 f_log_msg "Failed to encrypt archive ${v_ref_filename}: ${v_gpg_err} --" \ "plaintext archive left in place, unencrypted, at ${v_dir}/${v_ref_filename}" exit 1 fi } # Write stdin to a file, unless in dry-run mode (the producing command # upstream still runs for real, its stdout just gets discarded). Cannot # see whether that upstream command failed -- see f_check_pipe, called # as the very next statement after every `cmd | f_out ...` pipe. function f_out() { if [[ "${v_dry_run}" == true ]]; then cat > /dev/null f_print_msg "[DRY-RUN] would write: ${1}" else cat > "${1}" fi } # Warn (without aborting) if the command feeding the f_out pipe above # this call failed, so a failure doesn't silently produce an empty file # later treated as "already collected" by the resumable-skip check. Must # be the literal next statement after the pipe: PIPESTATUS survives only # until bash runs another pipeline, and anything with its own `|` in # between would clobber it first. function f_check_pipe() { local v_rc="${PIPESTATUS[0]}" if [[ "${v_rc}" -ne 0 ]]; then f_print_msg "WARNING: ${1} failed (exit ${v_rc}), ${2} may be empty or incomplete" fi } # Copy a file or directory tree, unless in dry-run mode function f_copy() { if [[ "${v_dry_run}" == true ]]; then f_print_msg "[DRY-RUN] would copy ${1} to ${2}" else cp -a "${1}" "${2}" fi } # Read raw bytes from a block device, unless in dry-run mode. In dry-run # the same read is performed against /dev/null, proving read access to # the source device without writing anything. function f_dd() { local v_src="${1}" v_dst="${2}" shift 2 if [[ "${v_dry_run}" == true ]]; then if dd if="${v_src}" of=/dev/null "${@}" >/dev/null 2>&1; then f_print_msg "[DRY-RUN] would write ${v_dst} (read of ${v_src} OK)" else f_print_msg "[DRY-RUN] WARNING: cannot read ${v_src}" fi else if ! dd if="${v_src}" of="${v_dst}" "${@}" >/dev/null 2>&1; then f_print_msg "WARNING: reading ${v_src} failed, ${v_dst} may be empty or incomplete" fi fi } # Check confirmations # ${2} action: "start" | "reuse" | "delete" (omitted defaults to "start" behavior) function f_ask_confirmation() { local v_action="${2:-}" while true; do printf "\n%s" "${1}" if ! read -r v_choice; then printf "\nNo input available (stdin closed) -- cannot confirm interactively. Use --quiet for unattended runs.\n\n" >&2 exit 1 fi case "${v_choice}" in Y|y|Yes|yes ) if [[ "${v_action}" == 'delete' ]]; then printf "Deleting directory %s\n" "${v_tempdir}" f_assert_within_whitelist "${v_tempdir}" rm -rf "${v_tempdir}" if [[ "${v_enable_data_backup}" == true ]]; then printf "Deleting directory %s\n" "${v_tempdir_data}" f_assert_within_whitelist "${v_tempdir_data}" rm -rf "${v_tempdir_data}" fi if [[ "${v_secrets_active}" == true ]]; then printf "Deleting directory %s\n" "${v_tempdir_secrets}" f_assert_within_whitelist "${v_tempdir_secrets}" rm -rf "${v_tempdir_secrets}" fi printf "Done\n" else printf "OK, preparing...\n\n" fi break ;; N|n|No|no ) printf "Nothing is done\n" if [[ "${v_action}" == 'reuse' ]]; then printf "You need to move %s in another location\n\n" "${v_tempdir}" exit 0 elif [[ "${v_action}" != 'delete' ]]; then exit 0 fi break ;; C|c|Cancel|cancel ) printf "Script aborted\n\n" exit 0 ;; * ) printf "Invalid answer\n\n" >&2 ;; esac done } # Write a line to the run's log file (v_log_path, set in main()), if # logging is active (never in --dry-run). Independent of --quiet: the log # is the complete record of a run regardless of terminal output, which # matters most for unattended runs. Strips ANSI color codes. Opens, # appends, and closes on every call instead of keeping a long-lived fd: # a persistent fd gets inherited by every child process spawned # afterward (mysqldump, docker, tar, lvm tools...), and some warn about it. function f_log_msg() { if [[ -n "${v_log_path:-}" ]]; then printf '%s\n' "${*}" | sed -E 's/\x1b\[[0-9;]*m//g' >> "${v_log_path}" fi } function f_print_msg() { f_log_msg "${*}" if [[ "${v_quiet}" == false ]]; then printf "%s\n" "${*}" fi } # Run v_on_failure_command (${1}: the exit code that triggered it), if # configured -- see CONFIGURE. Bound to an EXIT trap set at the top of # main(), so it fires for whatever hard-exit produced ${1}, without a # call at every exit site by hand. No-op for a clean exit (${1} == 0). # Exports NDM_EXIT_CODE/NDM_LOG_PATH/NDM_ARCHIVE_PREFIX/NDM_HOSTNAME/NDM_USER, # same eval-a-user-string mechanism as v_pre_command/v_post_command. # shellcheck disable=SC2317,SC2329 # called indirectly via the EXIT trap set in main(), not by a literal call site function f_run_on_failure_command() { local v_exit_code="${1}" if [[ "${v_exit_code}" -eq 0 ]] || [[ "${v_enable_on_failure_command}" != true ]] || [[ -z "${v_on_failure_command}" ]]; then return 0 fi export NDM_EXIT_CODE="${v_exit_code}" export NDM_LOG_PATH="${v_log_path:-}" export NDM_ARCHIVE_PREFIX="${v_backup_name}" export NDM_HOSTNAME="${v_hostname}" export NDM_USER="${v_username}" if [[ "${v_dry_run}" == true ]]; then printf "[DRY-RUN] would execute on-failure command (exit %s): %s\n" "${v_exit_code}" "${v_on_failure_command}" >&2 else eval "${v_on_failure_command}" fi } # Human-readable size (IEC units, e.g. "1.2G") of file ${1}, or empty if # it does not exist -- used for NDM_*_ARCHIVE_SIZE in v_post_command. # Empty rather than an error: in --dry-run, or for a pass that did not # run this session, there is genuinely no file to size. function f_human_size() { local v_path="${1}" v_bytes [[ -f "${v_path}" ]] || return 0 v_bytes="$(stat -c%s "${v_path}" 2>/dev/null)" || return 0 numfmt --to=iec --suffix=B --format='%.1f' "${v_bytes}" 2>/dev/null } function f_backup_done_msg() { local v_line if [[ "${v_dry_run}" == true ]]; then v_line="$(printf "%s\nSimulated (dry-run)" "${1}")" else v_line="$(printf "%s\nDone" "${1}")" fi f_log_msg "${v_line}" if [[ "${v_quiet}" == false ]]; then printf "%s\n" "${v_line}" fi } function f_backup_skipped_msg() { local v_line v_line="$(printf "%s already exists\nSkipped" "${1}")" f_log_msg "${v_line}" if [[ "${v_quiet}" == false ]]; then printf "%s\n" "${v_line}" fi } # Parse CLI options into the v_start/v_quiet/v_keep_tempdir/v_dry_run/ # v_secrets_override/v_analyze/v_requirements globals that the entry point # reads. --help/--usage/--version print and return with neither v_start nor # v_analyze set; invalid usage exits 2 (f_bad_usage). function f_parse_arguments() { local v_arguments v_error local -r v_short='huvsqkna' local -r v_long='help,usage,version,requirements,start,quiet,keep,dry-run,secrets,analyze' # Always initialize before any early return, so callers never see these # unset (main() and the entry point read them as globals under set -u). v_start=false v_quiet=false v_keep_tempdir=false v_dry_run=false v_secrets_override=false v_analyze=false v_requirements=false if [[ "${#}" -lt 1 ]]; then f_help return fi if ! v_arguments=$(getopt -q -n "${0##*/}" -o "${v_short}" -l "${v_long}" -- "${@}"); then # Run again only to get getopt's own message (e.g. unrecognized option '--x') v_error="$(getopt -n "${0##*/}" -o "${v_short}" -l "${v_long}" -- "${@}" 2>&1 >/dev/null)" f_bad_usage "${v_error%%$'\n'*}" fi eval set -- "${v_arguments}" while true; do case "${1}" in -h|--help) f_help; return ;; -u|--usage) f_usage; return ;; -v|--version) f_version; return ;; --requirements) v_requirements=true; return ;; -k|--keep) v_keep_tempdir=true ;; -n|--dry-run) v_dry_run=true ;; -q|--quiet) v_quiet=true ;; -s|--start) v_start=true ;; -a|--analyze) v_analyze=true ;; --secrets) v_secrets_override=true ;; --) shift; break ;; esac shift done [[ "${#}" -eq 0 ]] || f_bad_usage "Unexpected argument: ${1}" # Only modifiers (-n/-q/-k/--secrets) without -s/-a: a cron line that lost # -s must fail visibly instead of silently doing nothing. if [[ "${v_start}" != true ]] && [[ "${v_analyze}" != true ]]; then f_bad_usage "Options -n, -q, -k and --secrets need --start or --analyze" fi } ######################## ### MAIN ### ######################## function main() { # Notify on interruption and actually stop (a trap that does not exit # suppresses the default terminate action, leaving Ctrl-C/SIGTERM with # no effect); already collected files are kept for the next run trap 'printf "\nInterrupted. Re-run the script to resume; already collected files in %s are kept.\n\n" "${v_tempdir}" >&2; exit 130' INT trap 'printf "\nTerminated. Re-run the script to resume; already collected files in %s are kept.\n\n" "${v_tempdir}" >&2; exit 143' TERM # Run v_on_failure_command for any hard-exit from here on, interruption # above included: an EXIT trap fires on every process exit regardless # of the cause. See f_run_on_failure_command/CONFIGURE. trap 'f_run_on_failure_command "${?}"' EXIT ### BASIC CHECKS ### # Check SuperUser: this script must be run as root -- see Safety/Usage # in README. Deliberately does NOT re-exec itself via sudo: the caller # decides explicitly whether to escalate (typing sudo themselves), the # script never does it on their behalf. if [[ "${USER}" != 'root' ]]; then printf "\nThis script must be run as root. Re-run it with: sudo %s %s\n\n" "${0}" "${*}" >&2 exit 1 fi if [[ "${v_dry_run}" == true ]]; then printf "\nDRY-RUN mode: reading system information is allowed, but nothing will be created, modified, or deleted anywhere.\n" fi # Plain-text, world-readable log of this run, alongside the settings # archive -- see f_log_msg. Skipped in --dry-run (nothing is created) # and in --analyze (a pure report, must never create v_dest). Created # here, earlier than "Directories creation" further down, so logging # can start before f_check_dependencies; that later step's own check # just finds v_dest already there. if [[ "${v_analyze}" == true ]]; then : elif [[ "${v_dry_run}" == true ]]; then f_print_msg "[DRY-RUN] would write a log file into ${v_dest}" else f_assert_within_whitelist "${v_dest}" mkdir -p "${v_dest}" v_log_path="${v_dest}/${v_backup_name}_${v_hostname}_${v_datetime_suffix}.log" f_assert_within_whitelist "${v_log_path}" touch "${v_log_path}" chmod 644 "${v_log_path}" fi # Get if UEFI or BIOS setup. Computed ahead of f_check_dependencies so # f_module_enabled() can tell whether v_enable_efi applies to this # machine's real firmware, not just whether the switch is on. if [[ -d /sys/firmware/efi/ ]]; then v_uefi_bios='uefi' else v_uefi_bios='bios' fi f_print_msg "Your system is based on: ${v_uefi_bios}" # Whether this machine has any LVM physical/logical volumes, detected # via lsblk (no lvm2 tools required). Same reasoning/spot as v_uefi_bios # above. TYPE=lvm catches active logical volumes, FSTYPE=LVM2_member # catches a physical volume not yet assembled into an LV. v_lvm_present=false if command -v lsblk >/dev/null 2>&1; then if lsblk -rno TYPE,FSTYPE 2>/dev/null | awk '$1=="lvm" || $2=="LVM2_member"{f=1} END{exit !f}'; then v_lvm_present=true fi fi # Partition table type (gpt or dos/mbr) per disk in a_storage -- same # reasoning/spot as v_uefi_bios/v_lvm_present above: computed ahead of # the --analyze branch so f_module_enabled() can tell whether # v_enable_mbr applies to this machine's real disks, not just whether # the switch is on. Read-only, safe in every mode. Explicit =() (not # just `declare -a`) so an array that ends up with zero elements is # still "set" under `set -u`, instead of crashing as unbound. declare -A a_disklabel_type declare -a a_gpt=() declare -a a_mbr=() for i in "${a_storage[@]}"; do a_disklabel_type["${i}"]="$(fdisk -l "/dev/${i}" | grep Disklabel | awk '{print $3}')" f_print_msg "Your partition type for /dev/${i} is: ${a_disklabel_type[${i}]}" if [[ "${a_disklabel_type[${i}]}" == 'dos' ]]; then a_mbr+=("${i}") elif [[ "${a_disklabel_type[${i}]}" == 'gpt' ]]; then a_gpt+=("${i}") fi done readonly a_gpt readonly a_mbr # Whether the secrets pass runs this run: v_enable_secrets_backup OR'd # with the --secrets CLI override (see f_module_enabled()). Used # everywhere else in main() instead of the readonly CONFIGURE variable, # which the CLI flag cannot change directly. v_secrets_active=false if f_module_enabled "v_enable_secrets_backup"; then v_secrets_active=true fi # Whether PostgreSQL can authenticate locally via peer/ident (Unix # socket, no password) instead of .pgpass -- initialized false here # (read by f_analyze_reason() even in --analyze, which never runs the # live probe) and possibly flipped true by the real probe further down. v_postgresql_peer_auth=false a_pg_host=(-h localhost) # Same idea as v_postgresql_peer_auth below, for MySQL/MariaDB's own # unix_socket/auth_socket plugin (see README "MySQL/MariaDB backup", on avoiding a # password entirely). a_mysql_defaults, read by every mysql/mysqldump # call below: --defaults-file normally, --no-defaults -u root when # socket auth is in use instead. v_mysql_socket_auth=false a_mysql_defaults=(--defaults-file="${v_mysql_cnf_path}") # -a/--analyze: print the report and exit here, before anything else in # main() runs. Reuses the UEFI/LVM detection above. if [[ "${v_analyze}" == true ]]; then f_analyze_report exit 0 fi # Report every dependency the script can use and, if approved, install # what is missing before the hard requirement check below runs. f_check_dependencies # Hard requirement check: only what your enabled backup modules (plus # the always-needed core commands) actually use, regardless of what # f_check_dependencies above did (declined install, apt failure, # --quiet, --dry-run, ...). mapfile -t a_needed_commands < <(f_hard_required_commands) readonly a_needed_commands for i in "${a_needed_commands[@]}"; do f_check_command "${i}" done # Live probe for the peer-auth detection above, only when .pgpass is # actually missing. Read-only "SELECT 1", safe even in --dry-run, and # time-capped so an unreachable PostgreSQL can't hang an unattended # run. -d postgres is explicit: with no -d, psql implicitly targets a # database named after v_postgresql_user, which usually doesn't exist, # and the probe would misreport peer auth as unavailable. # -w/--no-password (here and every psql/pg_dump/pg_dumpall call below) # forbids an interactive password prompt, so a credentials mismatch # fails immediately instead of blocking on stdin. # a_pg_host, read by every call below: emptied when peer auth is in # use, "-h localhost" otherwise so .pgpass auth applies as before. if [[ "${v_enable_postgresql}" == true ]] && [[ "$(command -v psql)" ]] && [[ ! -f "${v_postgresql_pgpass_path}" ]]; then if timeout 5 psql -U "${v_postgresql_user}" -w -d postgres -tAc 'SELECT 1' >/dev/null 2>&1; then v_postgresql_peer_auth=true a_pg_host=() fi fi # Same probe, for MySQL/MariaDB's unix_socket/auth_socket plugin # (root@localhost only -- see README). --no-defaults so no other # credentials file is picked up by accident during the probe itself. if [[ "${v_enable_mysql}" == true ]] && [[ "$(command -v mysql)" ]] && [[ ! -f "${v_mysql_cnf_path}" ]]; then if timeout 5 mysql --no-defaults -u root -e 'SELECT 1' >/dev/null 2>&1; then v_mysql_socket_auth=true a_mysql_defaults=(--no-defaults -u root) fi fi # Hard requirement check for MySQL/PostgreSQL credentials -- see # f_check_db_credentials(). Runs regardless of --dry-run/--quiet. f_check_db_credentials # Advisory: v_tempdir/v_tempdir_data nested under (or containing) an # a_source_extra/a_data_source_extra entry silently duplicates that # content in the archive -- see f_check_source_overlap(). Warns only, # never blocks: the default paths never overlap, this only fires on a # deliberate CONFIGURE override. if f_module_enabled "v_enable_source_extra"; then f_check_source_overlap "${v_tempdir}" "v_tempdir" a_source_extra "a_source_extra" fi if f_module_enabled "v_enable_data_source_extra"; then f_check_source_overlap "${v_tempdir_data}" "v_tempdir_data" a_data_source_extra "a_data_source_extra" fi # Hard requirement: v_tempdir must never equal or nest under its own # v_dest -- see f_check_dest_overlap(). Unlike the advisory overlap check # above, this one blocks: the failure mode is a silently empty archive, # not just duplicated content. Checked once per archive that actually # runs this session. f_check_dest_overlap "${v_tempdir}" "${v_dest}" "Settings archive" if [[ "${v_enable_data_backup}" == true ]]; then f_check_dest_overlap "${v_tempdir_data}" "${v_dest_data}" "Data archive" fi if [[ "${v_secrets_active}" == true ]]; then f_check_dest_overlap "${v_tempdir_secrets}" "${v_dest_secrets}" "Secrets archive" fi # Hard requirement check for GPG recipient keys -- see f_check_gpg_keys(). f_check_gpg_keys # Ask for confirmation to use this script if [[ "${v_dry_run}" == false ]] && [[ "${v_quiet}" == false ]]; then v_msg="Do you really want to execute this script? ([Y]es/[N]o/[C]ancel, no default, type it out)? " f_ask_confirmation "${v_msg}" "start" fi # Ask for confirmation to use an existing temp directory. f_secure_dir # runs first and unconditionally exits on a symlink or wrong-owner # path, so a compromised directory is never offered as a "reuse?" choice. if [[ "${v_dry_run}" == false ]]; then f_secure_dir "${v_tempdir}" if [[ "${v_quiet}" == false ]] && [[ -d "${v_tempdir}" ]]; then printf "Directory '%s' already exists" "${v_tempdir}" v_msg="Do you really want to use it? ([Y]es/[N]o/[C]ancel, no default, type it out)? " f_ask_confirmation "${v_msg}" "reuse" fi fi # Unconditional printf, same reasoning as the final summary below: from # here on, real work starts and can take a while (lshw, inxi, dpkg -V # over the whole system...) with nothing else printed until it's done # in --quiet -- long enough to look hung without this. if [[ "${v_dry_run}" == true ]]; then printf "\n[DRY-RUN] Backup simulation started -- this can take a while.\n" else printf "\nBackup started -- this can take a while. Watch progress live: tail -f %s\n" "${v_log_path}" f_log_msg "Backup started" fi ### START BACKUP ### # Execute pre command (skipped in dry-run: arbitrary user command, not # guaranteed to be read-only) if [[ "${v_enable_pre_command}" != true ]]; then f_print_msg $'\nv_enable_pre_command: disabled, skipping' elif [[ -n "${v_pre_command}" ]]; then export NDM_HOSTNAME="${v_hostname}" export NDM_ARCHIVE_PREFIX="${v_backup_name}" export NDM_LOG_PATH="${v_log_path:-}" export NDM_USER="${v_username}" if [[ "${v_dry_run}" == true ]]; then f_print_msg $'\n[DRY-RUN] would execute pre command: '"${v_pre_command}" else f_print_msg $'\nExecuting pre command' eval "${v_pre_command}" fi fi # Directories creation. Staging trees (v_tempdir/v_tempdir_data/ # v_tempdir_secrets, as opposed to the v_dest* destinations) go through # f_prepare_tempdir: a symlink/ownership check via f_secure_dir plus a # hard 700 mode via f_mkdir's mode argument, applied to both the leaf # and its shared container directory. # umask 077 covers every file/directory created from here on so staged # content stays owner-only even without an explicit mode on each call. umask 077 if [[ ! -d "${v_dest}" ]]; then f_mkdir "${v_dest}" fi f_prepare_tempdir "${v_tempdir}" if [[ "${v_enable_rsync}" == true ]] && [[ -n "${v_dest_rsync}" ]] && [[ ! -d "${v_dest_rsync}" ]]; then f_mkdir "${v_dest_rsync}" fi if [[ "${v_enable_data_backup}" == true ]]; then if [[ ! -d "${v_dest_data}" ]]; then f_mkdir "${v_dest_data}" fi f_prepare_tempdir "${v_tempdir_data}" fi if [[ "${v_secrets_active}" == true ]]; then if [[ ! -d "${v_dest_secrets}" ]]; then f_mkdir "${v_dest_secrets}" fi f_prepare_tempdir "${v_tempdir_secrets}" fi # Hardware info if [[ "${v_enable_hardware}" == true ]]; then f_print_msg $'\nBacking up Hardware list with lshw' v_temp_path="${v_tempdir}"/lshw.txt if [[ ! -f "${v_temp_path}" ]]; then lshw | f_out "${v_temp_path}" f_check_pipe "lshw" "${v_temp_path}" f_backup_done_msg "${v_temp_path}" else f_backup_skipped_msg "${v_temp_path}" fi else f_print_msg $'\nBacking up Hardware list with lshw: disabled, skipping' fi # List PCI and USB Devices if [[ "${v_enable_devices}" == true ]]; then f_print_msg $'\nBacking up list of PCI and USB devices' v_temp_path="${v_tempdir}"/devices if [[ ! -d "${v_temp_path}" ]]; then f_mkdir "${v_temp_path}" lspci -nn | f_out "${v_temp_path}"/lspci.txt f_check_pipe "lspci -nn" "${v_temp_path}/lspci.txt" lsusb | f_out "${v_temp_path}"/lsusb.txt f_check_pipe "lsusb" "${v_temp_path}/lsusb.txt" f_backup_done_msg "${v_temp_path}" else f_backup_skipped_msg "${v_temp_path}" fi else f_print_msg $'\nBacking up list of PCI and USB devices: disabled, skipping' fi # List Block Devices if [[ "${v_enable_lsblk}" == true ]]; then f_print_msg $'\nBacking up lsblk' v_temp_path="${v_tempdir}"/lsblk.txt if [[ ! -f "${v_temp_path}" ]]; then lsblk --all --fs | f_out "${v_temp_path}" f_check_pipe "lsblk --all --fs" "${v_temp_path}" f_backup_done_msg "${v_temp_path}" else f_backup_skipped_msg "${v_temp_path}" fi else f_print_msg $'\nBacking up lsblk: disabled, skipping' fi # List of all installed devices ID if [[ "${v_enable_blkid}" == true ]]; then f_print_msg $'\nBacking up installed devices ID list' v_temp_path="${v_tempdir}"/blkid.txt if [[ ! -f "${v_temp_path}" ]]; then blkid | f_out "${v_temp_path}" f_check_pipe "blkid" "${v_temp_path}" f_backup_done_msg "${v_temp_path}" else f_backup_skipped_msg "${v_temp_path}" fi else f_print_msg $'\nBacking up installed devices ID list: disabled, skipping' fi # HW/SW info if [[ "${v_enable_inxi}" == true ]]; then f_print_msg $'\nBacking up infos with inxi' v_temp_path="${v_tempdir}"/inxi.txt if [[ ! -f "${v_temp_path}" ]]; then inxi --full | f_out "${v_temp_path}" f_check_pipe "inxi --full" "${v_temp_path}" f_backup_done_msg "${v_temp_path}" else f_backup_skipped_msg "${v_temp_path}" fi else f_print_msg $'\nBacking up infos with inxi: disabled, skipping' fi # UEFI if [[ "${v_enable_efi}" == true ]]; then f_print_msg $'\nBacking up EFI' v_temp_path="${v_tempdir}"/efi # efibootmgr is a K_SOFT_COMMANDS entry (see there): missing it should # skip this one optional module, not hard-exit the whole run. if [[ "${v_uefi_bios}" == 'uefi' ]] && [[ "$(command -v efibootmgr)" ]] && [[ ! -d "${v_temp_path}" ]]; then f_mkdir "${v_temp_path}" efibootmgr --verbose | f_out "${v_temp_path}"/efibootmgr.txt f_check_pipe "efibootmgr --verbose" "${v_temp_path}/efibootmgr.txt" # /boot/efi is the conventional Debian ESP mountpoint, not # guaranteed: could be mounted elsewhere or absent (rescue/live # environment). Checked for real even in --dry-run; without this, # f_copy's "cp -a" would fail raw while the step was still "done". if [[ -d /boot/efi ]]; then f_copy /boot/efi "${v_temp_path}"/boot_efi else printf "\nWARNING: /boot/efi not found -- EFI System Partition content not backed up, only the NVRAM boot entries above.\n" >&2 f_log_msg "WARNING: /boot/efi not found -- EFI System Partition content not backed up, only the NVRAM boot entries above" fi f_backup_done_msg "${v_temp_path}" else f_backup_skipped_msg "No UEFI, no efibootmgr, or ${v_temp_path}" fi else f_print_msg $'\nBacking up EFI: disabled, skipping' fi # MBR if [[ "${v_enable_mbr}" == true ]]; then f_print_msg $'\nBacking up MBR BIOS' v_temp_path="${v_tempdir}"/mbr if [[ "${#a_mbr[@]}" != 0 ]] && [[ ! -d "${v_temp_path}" ]]; then f_check_command "dd" f_mkdir "${v_temp_path}" for i in "${a_mbr[@]}"; do f_print_msg "Backing up MBR for /dev/${i}" # First 1MiB, not just the 512-byte boot sector: covers the GRUB # embedding gap between the MBR and the first partition (where # grub-pc/BIOS-GRUB stores its stage-1.5 code), needed for a full # boot sector restore. f_dd "/dev/$i" "${v_temp_path}/${i}_mbr.bin" bs=1M count=1 done f_backup_done_msg "${v_temp_path}" else f_backup_skipped_msg "No BIOS or ${v_temp_path}" fi else f_print_msg $'\nBacking up MBR BIOS: disabled, skipping' fi # LUKS if [[ "${v_enable_luks}" == true ]]; then f_print_msg $'\nBacking up LUKS info' v_temp_path="${v_tempdir}"/luks if [[ "${#a_luks[@]}" != 0 ]] && [[ ! -d "${v_temp_path}" ]]; then f_check_command "cryptsetup" f_mkdir "${v_temp_path}" for i in "${a_luks[@]}"; do cryptsetup luksDump "/dev/$i" | f_out "${v_temp_path}/luksDump_${i}.txt" f_check_pipe "cryptsetup luksDump /dev/$i" "${v_temp_path}/luksDump_${i}.txt" cryptsetup luksUUID "/dev/$i" | f_out "${v_temp_path}/luksUUID_${i}.txt" f_check_pipe "cryptsetup luksUUID /dev/$i" "${v_temp_path}/luksUUID_${i}.txt" done f_out "${v_temp_path}"/crypttab.txt < /etc/crypttab f_backup_done_msg "${v_temp_path}" else f_backup_skipped_msg "No LUKS or ${v_temp_path}" fi else f_print_msg $'\nBacking up LUKS info: disabled, skipping' fi # LVM if [[ "${v_enable_lvm}" == true ]]; then f_print_msg $'\nBacking up LVM info' v_temp_path="${v_tempdir}"/lvm if [[ "$(command -v lvmdump)" ]] && [[ ! -d "${v_temp_path}" ]]; then f_mkdir "${v_temp_path}" if [[ "${v_dry_run}" == true ]]; then f_print_msg "[DRY-RUN] would run lvmdump into ${v_temp_path}/lvmdump" else lvmdump -d "${v_temp_path}"/lvmdump >/dev/null 2>&1 fi pvdisplay --verbose | f_out "${v_temp_path}"/pvdisplay.txt f_check_pipe "pvdisplay --verbose" "${v_temp_path}/pvdisplay.txt" vgdisplay --verbose | f_out "${v_temp_path}"/vgdisplay.txt f_check_pipe "vgdisplay --verbose" "${v_temp_path}/vgdisplay.txt" lvdisplay --verbose | f_out "${v_temp_path}"/lvdisplay.txt f_check_pipe "lvdisplay --verbose" "${v_temp_path}/lvdisplay.txt" f_backup_done_msg "${v_temp_path}" else f_backup_skipped_msg "No LVM or ${v_temp_path}" fi else f_print_msg $'\nBacking up LVM info: disabled, skipping' fi # OS Release if [[ "${v_enable_os_release}" == true ]]; then f_print_msg $'\nBacking up OS release' v_temp_path="${v_tempdir}"/lsb_release.txt if [[ ! -f "${v_temp_path}" ]]; then lsb_release --all 2>/dev/null | grep -v "No LSB modules" | f_out "${v_temp_path}" f_check_pipe "lsb_release --all" "${v_temp_path}" f_backup_done_msg "${v_temp_path}" else f_backup_skipped_msg "${v_temp_path}" fi else f_print_msg $'\nBacking up OS release: disabled, skipping' fi # Kernel Release if [[ "${v_enable_kernel}" == true ]]; then f_print_msg $'\nBacking up Kernel release' v_temp_path="${v_tempdir}"/uname.txt if [[ ! -f "${v_temp_path}" ]]; then uname --all | f_out "${v_temp_path}" f_check_pipe "uname --all" "${v_temp_path}" f_backup_done_msg "${v_temp_path}" else f_backup_skipped_msg "${v_temp_path}" fi else f_print_msg $'\nBacking up Kernel release: disabled, skipping' fi # Partitions if [[ "${v_enable_partitions}" == true ]]; then f_print_msg $'\nBacking up Partition Table' v_temp_path="${v_tempdir}"/partitions if [[ ! -d "${v_temp_path}" ]]; then f_mkdir "${v_temp_path}" for i in "${a_storage[@]}"; do fdisk -l "/dev/${i}" | f_out "${v_temp_path}/fdisk_${i}.txt" f_check_pipe "fdisk -l /dev/${i}" "${v_temp_path}/fdisk_${i}.txt" sfdisk -d "/dev/${i}" | f_out "${v_temp_path}/sfdisk_${i}.txt" f_check_pipe "sfdisk -d /dev/${i}" "${v_temp_path}/sfdisk_${i}.txt" done f_copy /etc/fstab "${v_temp_path}"/fstab.txt f_backup_done_msg "${v_temp_path}" else f_backup_skipped_msg "${v_temp_path}" fi else f_print_msg $'\nBacking up Partition Table: disabled, skipping' fi # Grub if [[ "${v_enable_grub}" == true ]]; then f_print_msg $'\nBacking up Grub' v_temp_path="${v_tempdir}"/grub if [[ ! -d "${v_temp_path}" ]]; then f_mkdir "${v_temp_path}" f_copy /boot/grub "${v_temp_path}"/boot_grub f_copy /etc/default/grub "${v_temp_path}"/default_grub.txt f_backup_done_msg "${v_temp_path}" else f_backup_skipped_msg "${v_temp_path}" fi else f_print_msg $'\nBacking up Grub: disabled, skipping' fi # Installed packages if [[ "${v_enable_packages}" == true ]]; then f_print_msg $'\nBacking up installed software' v_temp_path="${v_tempdir}"/software if [[ ! -d "${v_temp_path}" ]]; then f_mkdir "${v_temp_path}" dpkg -l | grep '^ii' | f_out "${v_temp_path}"/dpkg_installed.txt f_check_pipe "dpkg -l" "${v_temp_path}/dpkg_installed.txt" apt-mark showmanual | f_out "${v_temp_path}"/apt_manual.txt f_check_pipe "apt-mark showmanual" "${v_temp_path}/apt_manual.txt" apt-mark showauto | f_out "${v_temp_path}"/apt_auto.txt f_check_pipe "apt-mark showauto" "${v_temp_path}/apt_auto.txt" f_backup_done_msg "${v_temp_path}" else f_backup_skipped_msg "${v_temp_path}" fi else f_print_msg $'\nBacking up installed software: disabled, skipping' fi # Flatpak if [[ "${v_enable_flatpak}" == true ]]; then f_print_msg $'\nBacking up Flatpak app list' v_temp_path="${v_tempdir}"/flatpak.txt if [[ "$(command -v flatpak)" ]] && [[ ! -f "${v_temp_path}" ]]; then flatpak list --app | f_out "${v_temp_path}" f_check_pipe "flatpak list --app" "${v_temp_path}" f_backup_done_msg "${v_temp_path}" else f_backup_skipped_msg "No Flatpak or ${v_temp_path}" fi else f_print_msg $'\nBacking up Flatpak app list: disabled, skipping' fi # AppImages: no central registry for these (that is the point of the # format), so this only lists what is found (size + path); it does not # copy the files themselves (add the directory to a_source_extra for # that). Searches /home, /root, /opt, /usr/local/bin by default, plus # any extra paths configured in a_appimages_extra_paths. if [[ "${v_enable_appimages}" == true ]]; then f_print_msg $'\nBacking up AppImages list' v_temp_path="${v_tempdir}"/appimages.txt if [[ ! -f "${v_temp_path}" ]]; then find /home /root /opt /usr/local/bin "${a_appimages_extra_paths[@]}" -maxdepth 6 \ -iname '*.appimage' -type f -printf '%s\t%p\n' 2>/dev/null | f_out "${v_temp_path}" f_check_pipe "find (AppImages)" "${v_temp_path}" f_backup_done_msg "${v_temp_path}" else f_backup_skipped_msg "${v_temp_path}" fi else f_print_msg $'\nBacking up AppImages list: disabled, skipping' fi # Modified system files: files whose content no longer matches what their # package originally installed (dpkg -V checksum mismatch), typically # manually-edited config files. Only catches files already tracked by a # package; brand new, unpackaged files are not detected. Copies the files # themselves (not just a list), preserving their original path under # modified_files/. if [[ "${v_enable_modified_files}" == true ]]; then f_print_msg $'\nBacking up manually modified system files' v_temp_path="${v_tempdir}"/modified_files if [[ ! -d "${v_temp_path}" ]]; then f_mkdir "${v_temp_path}" while IFS= read -r v_file; do [[ -f "${v_file}" ]] || continue f_mkdir "${v_temp_path}$(dirname "${v_file}")" f_copy "${v_file}" "${v_temp_path}${v_file}" done < <(dpkg -V 2>/dev/null | awk '{print $NF}') f_backup_done_msg "${v_temp_path}" else f_backup_skipped_msg "${v_temp_path}" fi else f_print_msg $'\nBacking up manually modified system files: disabled, skipping' fi # Unpackaged /etc files: the complement of the step above -- files # under /etc no installed package's manifest lists at all (dpkg -V only # flags files it already knows about), a blind spot that misses e.g. # postgresql.conf, generated by postinst scripts rather than shipped. # a_unpackaged_files_ignore_regex filters out stock Debian install # artifacts that would otherwise be noise every run. if [[ "${v_enable_unpackaged_files}" == true ]]; then f_print_msg $'\nBacking up unpackaged /etc files' v_temp_path="${v_tempdir}"/unpackaged_files if [[ ! -d "${v_temp_path}" ]]; then f_mkdir "${v_temp_path}" v_ignore_regex="$(IFS='|'; echo "${a_unpackaged_files_ignore_regex[*]}")" while IFS= read -r v_file; do [[ -f "${v_file}" ]] || continue f_mkdir "${v_temp_path}$(dirname "${v_file}")" f_copy "${v_file}" "${v_temp_path}${v_file}" done < <(comm -23 <(find /etc -type f 2>/dev/null | sort) <(cat /var/lib/dpkg/info/*.list 2>/dev/null | sort -u) | grep -vE "${v_ignore_regex}") f_backup_done_msg "${v_temp_path}" else f_backup_skipped_msg "${v_temp_path}" fi else f_print_msg $'\nBacking up unpackaged /etc files: disabled, skipping' fi # CRON if [[ "${v_enable_cron}" == true ]]; then f_print_msg $'\nBacking up root crons' v_temp_path="${v_tempdir}"/crontab.txt if [[ ! -f "${v_temp_path}" ]]; then # No f_check_pipe here on purpose: `crontab -l` exits 1 whenever # root simply has no crontab configured at all, which is a normal, # common state, not a failure -- an empty crontab.txt correctly # reflects it. crontab -l | f_out "${v_temp_path}" f_backup_done_msg "${v_temp_path}" else f_backup_skipped_msg "${v_temp_path}" fi else f_print_msg $'\nBacking up root crons: disabled, skipping' fi # Declared here (even when unused this run) so the data-pass reuse # check further down is safe under `set -u` even when the schema pass # is itself skipped (e.g. resuming a run where *_schema/ already # existed). a_databases=() a_pg_databases=() # MySQL/MariaDB: table/view/routine structure only (no rows) -- this is # setup, not data, so it always lives in the settings archive whenever # v_enable_mysql is on. Actual row data is dumped separately in the DATA # BACKUP section below, only if v_enable_data_backup is also on. if [[ "${v_enable_mysql}" == true ]]; then f_print_msg $'\nBacking up MySQL/MariaDB schema' v_temp_path="${v_tempdir}"/mysql_schema if [[ "$(command -v mysqldump)" ]] && [[ ! -d "${v_temp_path}" ]] \ && { [[ -f "${v_mysql_cnf_path}" ]] || [[ "${v_mysql_socket_auth}" == true ]]; }; then f_mkdir "${v_temp_path}" if [[ "${v_mysql_single_dump}" == false ]]; then # Database listing is read-only: run for real even in dry-run. # sys excluded too: diagnostic schema with no data of its own, # its views need near-root privileges to dump cleanly. mapfile -t a_databases < <(mysql "${a_mysql_defaults[@]}" -e "SHOW DATABASES;" \ | grep -Ev '^(Database|information_schema|performance_schema|sys)$') for v_db in "${a_databases[@]}"; do if [[ "${v_dry_run}" == true ]]; then f_print_msg "[DRY-RUN] would dump schema of ${v_db} to ${v_temp_path}/schema_${v_db}.sql" else mysqldump "${a_mysql_defaults[@]}" --force --opt --no-data --databases "${v_db}" > "${v_temp_path}/schema_${v_db}.sql" fi done else if [[ "${v_dry_run}" == true ]]; then f_print_msg "[DRY-RUN] would dump all schemas to ${v_temp_path}/schema_all.sql" else mysqldump "${a_mysql_defaults[@]}" --force --opt --no-data --all-databases > "${v_temp_path}/schema_all.sql" fi fi f_backup_done_msg "${v_temp_path}" else f_backup_skipped_msg "No MySQL/MariaDB or missed ${v_mysql_cnf_path} (and no socket auth) or ${v_temp_path}" fi else f_print_msg $'\nBacking up MySQL/MariaDB schema: disabled, skipping' fi # PostgreSQL: schema only, same reasoning as MySQL/MariaDB above. Actual # row data is dumped separately in the DATA BACKUP section below, only # if v_enable_data_backup is also on. if [[ "${v_enable_postgresql}" == true ]]; then f_print_msg $'\nBacking up PostgreSQL schema' v_temp_path="${v_tempdir}"/postgresql_schema if [[ "$(command -v pg_dump)" ]] && [[ ! -d "${v_temp_path}" ]] \ && { [[ -f "${v_postgresql_pgpass_path}" ]] || [[ "${v_postgresql_peer_auth}" == true ]]; }; then f_mkdir "${v_temp_path}" if [[ "${v_postgresql_single_dump}" == false ]]; then # Database listing is read-only: run for real even in dry-run. # a_pg_host/-d postgres: same reasoning as the peer-auth probe in # main(). Exit status is checked and hard-exits on failure: an # empty a_pg_databases here would otherwise silently produce a # schema dump missing every database, reported as "done". if ! v_pg_databases_raw=$(PGPASSFILE="${v_postgresql_pgpass_path}" psql \ -U "${v_postgresql_user}" -w -d postgres "${a_pg_host[@]}" \ -Atc "SELECT datname FROM pg_database WHERE datistemplate = false;" 2>&1); then printf "\nFailed to list PostgreSQL databases:\n%s\n\n" "${v_pg_databases_raw}" >&2 f_log_msg "Failed to list PostgreSQL databases: ${v_pg_databases_raw}" exit 1 fi # "<<<" always appends a newline, so mapfile-ing an empty string # directly would produce one empty-string element instead of a # genuinely empty array -- guarded here for a cluster with no # non-template databases. if [[ -n "${v_pg_databases_raw}" ]]; then mapfile -t a_pg_databases <<< "${v_pg_databases_raw}" else a_pg_databases=() fi for v_db in "${a_pg_databases[@]}"; do if [[ "${v_dry_run}" == true ]]; then f_print_msg "[DRY-RUN] would dump schema of ${v_db} to ${v_temp_path}/schema_${v_db}.sql" else # Not a hard-exit like the listing query above: one database # failing must not abort the others still queued, but is # still surfaced loudly instead of a silent "done". "2>&1 # >file" captures only stderr into v_pg_dump_err, dump content # still goes straight to the file. if ! v_pg_dump_err=$(PGPASSFILE="${v_postgresql_pgpass_path}" pg_dump \ -U "${v_postgresql_user}" -w "${a_pg_host[@]}" --schema-only "${v_db}" \ 2>&1 >"${v_temp_path}/schema_${v_db}.sql"); then printf "\nWARNING: pg_dump failed for database %s (schema):\n%s\n" "${v_db}" "${v_pg_dump_err}" >&2 f_log_msg "WARNING: pg_dump failed for database ${v_db} (schema): ${v_pg_dump_err}" fi fi done else if [[ "${v_dry_run}" == true ]]; then f_print_msg "[DRY-RUN] would dump all schemas to ${v_temp_path}/schema_all.sql" else PGPASSFILE="${v_postgresql_pgpass_path}" pg_dumpall -U "${v_postgresql_user}" \ -w "${a_pg_host[@]}" --schema-only > "${v_temp_path}/schema_all.sql" fi fi f_backup_done_msg "${v_temp_path}" else f_backup_skipped_msg "No PostgreSQL or missed ${v_postgresql_pgpass_path} or ${v_temp_path}" fi else f_print_msg $'\nBacking up PostgreSQL schema: disabled, skipping' fi # Docker: metadata only (images/containers/volumes/networks lists and # daemon info), not the actual container/volume data if [[ "${v_enable_docker}" == true ]]; then f_print_msg $'\nBacking up Docker info' v_temp_path="${v_tempdir}"/docker if [[ "$(command -v docker)" ]] && [[ ! -d "${v_temp_path}" ]]; then f_mkdir "${v_temp_path}" f_container_metadata docker "${v_temp_path}" f_backup_done_msg "${v_temp_path}" else f_backup_skipped_msg "No Docker or ${v_temp_path}" fi else f_print_msg $'\nBacking up Docker info: disabled, skipping' fi # Compose files: same file naming convention for Docker Compose and # podman-compose, so one search covers both. Unlike Podman's Quadlet # units below, Compose has no fixed location (every project keeps its # own), so this searches common locations and copies what it finds. if [[ "${v_enable_docker_compose}" == true ]]; then f_print_msg $'\nBacking up Compose files (Docker Compose / podman-compose)' v_temp_path="${v_tempdir}"/docker_compose_files if [[ ! -d "${v_temp_path}" ]]; then f_mkdir "${v_temp_path}" while IFS= read -r v_file; do f_mkdir "${v_temp_path}$(dirname "${v_file}")" f_copy "${v_file}" "${v_temp_path}${v_file}" done < <(find /home /root /opt /srv "${a_docker_compose_extra_paths[@]}" -maxdepth 6 \( \ -iname 'docker-compose*.yml' -o -iname 'docker-compose*.yaml' -o -iname 'compose.yml' -o \ -iname 'compose.yaml' \) -type f 2>/dev/null) f_backup_done_msg "${v_temp_path}" else f_backup_skipped_msg "${v_temp_path}" fi else f_print_msg $'\nBacking up Compose files (Docker Compose / podman-compose): disabled, skipping' fi # Podman: metadata plus config and Quadlet (systemd) unit files, which # unlike Docker Compose have fixed, well-known locations: /etc/containers # (system-wide) and ~/.config/containers for v_podman_user (rootless). # Runs metadata commands as v_podman_user to see that user's state. if [[ "${v_enable_podman}" == true ]]; then f_print_msg $'\nBacking up Podman info' v_temp_path="${v_tempdir}"/podman if [[ "$(command -v podman)" ]] && [[ ! -d "${v_temp_path}" ]]; then f_mkdir "${v_temp_path}" f_container_metadata f_podman "${v_temp_path}" if [[ -d /etc/containers ]]; then f_copy /etc/containers "${v_temp_path}"/etc_containers fi v_podman_home="$(getent passwd "${v_podman_user}" | cut -d: -f6)" if [[ -n "${v_podman_home}" ]] && [[ -d "${v_podman_home}/.config/containers" ]]; then f_copy "${v_podman_home}/.config/containers" "${v_temp_path}/user_containers_config" fi f_backup_done_msg "${v_temp_path}" else f_backup_skipped_msg "No Podman or ${v_temp_path}" fi else f_print_msg $'\nBacking up Podman info: disabled, skipping' fi ### DATA BACKUP ### (separate archive, opt-in: see v_enable_data_backup) if [[ "${v_enable_data_backup}" == true ]]; then # MySQL/MariaDB: actual row data. Schema is backed up separately # above into the settings archive whenever v_enable_mysql is on; this # needs v_enable_mysql on too, since there is no separate # "data-only" switch. if [[ "${v_enable_mysql}" == true ]]; then f_print_msg $'\nBacking up MySQL/MariaDB data' v_temp_path="${v_tempdir_data}"/mysql if [[ "$(command -v mysqldump)" ]] && [[ ! -d "${v_temp_path}" ]] \ && { [[ -f "${v_mysql_cnf_path}" ]] || [[ "${v_mysql_socket_auth}" == true ]]; }; then f_mkdir "${v_temp_path}" if [[ "${v_mysql_single_dump}" == false ]]; then # Reuse the database list already fetched in the schema pass # above (same run); only re-query here if that step was itself # skipped this run (e.g. resuming where mysql_schema/ already # existed from a previous run). if [[ "${#a_databases[@]}" -eq 0 ]]; then mapfile -t a_databases < <(mysql "${a_mysql_defaults[@]}" -e "SHOW DATABASES;" \ | grep -Ev '^(Database|information_schema|performance_schema|sys)$') fi for v_db in "${a_databases[@]}"; do if [[ "${v_dry_run}" == true ]]; then f_print_msg "[DRY-RUN] would dump data of ${v_db} to ${v_temp_path}/data_${v_db}.sql" else mysqldump "${a_mysql_defaults[@]}" --force --opt --no-create-info --databases "${v_db}" > "${v_temp_path}/data_${v_db}.sql" fi done else if [[ "${v_dry_run}" == true ]]; then f_print_msg "[DRY-RUN] would dump all data to ${v_temp_path}/data_all.sql" else mysqldump "${a_mysql_defaults[@]}" --force --opt --no-create-info --all-databases > "${v_temp_path}/data_all.sql" fi fi f_backup_done_msg "${v_temp_path}" else f_backup_skipped_msg "No MySQL/MariaDB or missed ${v_mysql_cnf_path} (and no socket auth) or ${v_temp_path}" fi else f_print_msg $'\nBacking up MySQL/MariaDB data: disabled, skipping' fi # PostgreSQL: actual row data, same reasoning as MySQL/MariaDB above. if [[ "${v_enable_postgresql}" == true ]]; then f_print_msg $'\nBacking up PostgreSQL data' v_temp_path="${v_tempdir_data}"/postgresql if [[ "$(command -v pg_dump)" ]] && [[ ! -d "${v_temp_path}" ]] \ && { [[ -f "${v_postgresql_pgpass_path}" ]] || [[ "${v_postgresql_peer_auth}" == true ]]; }; then f_mkdir "${v_temp_path}" if [[ "${v_postgresql_single_dump}" == false ]]; then # Same reuse-if-already-fetched reasoning as the MySQL data # block above; same -d postgres / exit-status-checked / empty- # array handling as the schema pass above. if [[ "${#a_pg_databases[@]}" -eq 0 ]]; then if ! v_pg_databases_raw=$(PGPASSFILE="${v_postgresql_pgpass_path}" psql \ -U "${v_postgresql_user}" -w -d postgres "${a_pg_host[@]}" \ -Atc "SELECT datname FROM pg_database WHERE datistemplate = false;" 2>&1); then printf "\nFailed to list PostgreSQL databases:\n%s\n\n" "${v_pg_databases_raw}" >&2 f_log_msg "Failed to list PostgreSQL databases: ${v_pg_databases_raw}" exit 1 fi if [[ -n "${v_pg_databases_raw}" ]]; then mapfile -t a_pg_databases <<< "${v_pg_databases_raw}" else a_pg_databases=() fi fi for v_db in "${a_pg_databases[@]}"; do if [[ "${v_dry_run}" == true ]]; then f_print_msg "[DRY-RUN] would dump data of ${v_db} to ${v_temp_path}/data_${v_db}.sql" else # Same reasoning as the schema pass above: one database's # failure is surfaced, not silently swallowed, without # aborting the rest of the loop. if ! v_pg_dump_err=$(PGPASSFILE="${v_postgresql_pgpass_path}" pg_dump \ -U "${v_postgresql_user}" -w "${a_pg_host[@]}" --data-only "${v_db}" \ 2>&1 >"${v_temp_path}/data_${v_db}.sql"); then printf "\nWARNING: pg_dump failed for database %s (data):\n%s\n" "${v_db}" "${v_pg_dump_err}" >&2 f_log_msg "WARNING: pg_dump failed for database ${v_db} (data): ${v_pg_dump_err}" fi fi done else if [[ "${v_dry_run}" == true ]]; then f_print_msg "[DRY-RUN] would dump all data to ${v_temp_path}/data_all.sql" else PGPASSFILE="${v_postgresql_pgpass_path}" pg_dumpall -U "${v_postgresql_user}" \ -w "${a_pg_host[@]}" --data-only > "${v_temp_path}/data_all.sql" fi fi f_backup_done_msg "${v_temp_path}" else f_backup_skipped_msg "No PostgreSQL or missed ${v_postgresql_pgpass_path} or ${v_temp_path}" fi else f_print_msg $'\nBacking up PostgreSQL data: disabled, skipping' fi # Docker volumes: real content (not just docker volume ls from the # metadata step above), one folder per volume, read via each # volume's Mountpoint (works only for the default "local" driver). # Copying a volume actively written by a running container can # produce an inconsistent copy -- same caveat as copying a live # database data directory directly; for database volumes prefer the # MySQL/PostgreSQL data dumps above instead. if [[ "${v_enable_docker_volumes_data}" == true ]]; then f_print_msg $'\nBacking up Docker volumes data' v_temp_path="${v_tempdir_data}"/docker_volumes if [[ "$(command -v docker)" ]] && [[ ! -d "${v_temp_path}" ]]; then f_mkdir "${v_temp_path}" f_container_volumes_data docker "${v_temp_path}" f_backup_done_msg "${v_temp_path}" else f_backup_skipped_msg "No Docker or ${v_temp_path}" fi else f_print_msg $'\nBacking up Docker volumes data: disabled, skipping' fi # Podman volumes: same approach as Docker above, read as # v_podman_user via f_podman so rootless volumes are found too. if [[ "${v_enable_podman_volumes_data}" == true ]]; then f_print_msg $'\nBacking up Podman volumes data' v_temp_path="${v_tempdir_data}"/podman_volumes if [[ "$(command -v podman)" ]] && [[ ! -d "${v_temp_path}" ]]; then f_mkdir "${v_temp_path}" f_container_volumes_data f_podman "${v_temp_path}" f_backup_done_msg "${v_temp_path}" else f_backup_skipped_msg "No Podman or ${v_temp_path}" fi else f_print_msg $'\nBacking up Podman volumes data: disabled, skipping' fi else f_print_msg $'\nData backup: disabled, skipping' fi ### SECRETS BACKUP ### (separate archive, opt-in: see v_enable_secrets_backup / --secrets) if [[ "${v_secrets_active}" == true ]]; then f_print_msg $'\nBacking up secrets' # A subdirectory, not v_tempdir_secrets itself: "Directories creation" # earlier in main() already pre-creates v_tempdir_secrets # unconditionally, so checking it directly for "already done" would # always skip. v_temp_path="${v_tempdir_secrets}"/files if [[ ! -d "${v_temp_path}" ]]; then f_mkdir "${v_temp_path}" # shellcheck disable=SC2034 # passed by name to f_filter_existing_sources's `local -n`, not referenced directly a_secrets_all_source=( "${a_secrets_source[@]}" "${a_secrets_source_extra[@]}" ) f_filter_existing_sources a_secrets_all_source a_secrets_existing "a_secrets_source" # shellcheck disable=SC2154 # assigned by f_filter_existing_sources's `local -n` output param, not directly here for i in "${a_secrets_existing[@]}"; do f_mkdir "${v_temp_path}$(dirname "${i}")" f_copy "${i}" "${v_temp_path}${i}" done f_backup_done_msg "${v_temp_path}" else f_backup_skipped_msg "${v_temp_path}" fi # Raw LUKS header backup: opt-in on top of the secrets pass itself # (see CONFIGURE). Unlike luks/luksDump_*.txt in the settings archive # (text only), this is a real restorable image with the keyslots # themselves, so it never belongs anywhere but here. if [[ "${v_enable_luks_header_backup}" == true ]]; then f_print_msg $'\nBacking up LUKS headers (raw, restorable)' v_temp_path="${v_tempdir_secrets}"/luks_headers if [[ "${#a_luks[@]}" != 0 ]] && [[ ! -d "${v_temp_path}" ]]; then f_check_command "cryptsetup" f_mkdir "${v_temp_path}" for i in "${a_luks[@]}"; do if [[ "${v_dry_run}" == true ]]; then f_print_msg "[DRY-RUN] would back up LUKS header of /dev/${i} into ${v_temp_path}/${i}_luksHeaderBackup.img" else cryptsetup luksHeaderBackup "/dev/${i}" --header-backup-file "${v_temp_path}/${i}_luksHeaderBackup.img" fi done f_backup_done_msg "${v_temp_path}" else f_backup_skipped_msg "No LUKS or ${v_temp_path}" fi else f_print_msg $'\nBacking up LUKS headers (raw, restorable): disabled, skipping' fi else f_print_msg $'\nSecrets backup: disabled, skipping' fi ### ENDING ### if [[ "${K_DEBUG}" != true ]]; then # Create tar archive v_filename="${v_backup_name}_${v_hostname}_${v_datetime_suffix}.tar.xz" a_tar_extra_sources=() if [[ "${v_enable_source_extra}" == true ]]; then f_filter_existing_sources a_source_extra a_tar_extra_sources "a_source_extra" else f_print_msg $'\na_source_extra: disabled, skipping' fi if [[ "${v_dry_run}" == true ]]; then f_print_msg "$(printf '\n[DRY-RUN] would create TAR archive %s in %s' "${v_filename}" "${v_dest}")" f_encrypt_archive v_filename "${v_dest}" "${v_gpg_recipient_file}" "${v_enable_gpg_settings}" if [[ "${v_tar_owner}" == true ]]; then f_print_msg "[DRY-RUN] would change tar archive ownership to user: ${v_username}" fi f_print_msg "[DRY-RUN] would write checksum: ${v_filename}.sha256" f_prune_old_archives "${v_dest}" "${v_backup_name}_${v_hostname}" else f_print_msg $'\nCreating TAR archive' # Exclude the MySQL/PostgreSQL credential files even if they fall # inside a_source_extra. Exclude .git too: already on the remote, # no need to duplicate it. Also exclude every a_secrets_source/ # a_secrets_source_extra path regardless of v_enable_secrets_backup: # that material only ever belongs in the opt-in secrets archive. # Also exclude v_tempdir_secrets/v_dest_secrets themselves # unconditionally, in case either falls under a_source_extra. a_secrets_exclude_args=() for i in "${a_secrets_source[@]}" "${a_secrets_source_extra[@]}"; do a_secrets_exclude_args+=(--exclude="${i}") done # Captures tar's stderr instead of discarding it (2>&1 >/dev/null, # in that order, latches stderr onto the command-substitution pipe # before stdout is separately discarded): a failure otherwise gave # no indication at all of why (permission denied, disk full...). f_assert_within_whitelist "${v_dest}/${v_filename}" if v_tar_err=$(tar --acls --xattrs --transform="s,^,${v_filename%.tar.xz}/," \ -cpJf "${v_dest}/${v_filename}" --exclude="${v_dest}" --exclude="${v_mysql_cnf_path}" \ --exclude="${v_postgresql_pgpass_path}" --exclude="${v_tempdir_secrets}" \ --exclude="${v_dest_secrets}" \ --exclude='.git' "${a_secrets_exclude_args[@]}" "${v_tempdir}" "${a_tar_extra_sources[@]}" \ 2>&1 >/dev/null); then f_encrypt_archive v_filename "${v_dest}" "${v_gpg_recipient_file}" "${v_enable_gpg_settings}" f_assert_within_whitelist "${v_dest}/${v_filename}" chmod 600 "${v_dest}/${v_filename}" f_print_msg "TAR archive ${v_filename} created in ${v_dest}" # Change tar archive ownership to current user if [[ "${v_tar_owner}" == true ]]; then f_check_command "chown" f_print_msg "Changing tar archive ownership to user: ${v_username}" f_assert_within_whitelist "${v_dest}/${v_filename}" chown "${v_username}":"${v_username}" "${v_dest}/${v_filename}" fi # SHA-256 checksum sidecar, so integrity can be checked before a # real restore. Best-effort: a checksum failure only warns, same # philosophy as f_check_pipe. cd runs in the command substitution's # own subshell, so the checksum file records just the archive's # filename, keeping `sha256sum -c` usable straight from v_dest. f_assert_within_whitelist "${v_dest}/${v_filename}.sha256" if v_sum_err=$(cd "${v_dest}" && sha256sum "${v_filename}" 2>&1 > "${v_filename}.sha256"); then if [[ "${v_tar_owner}" == true ]]; then chown "${v_username}":"${v_username}" "${v_dest}/${v_filename}.sha256" fi else f_print_msg "WARNING: failed to write checksum for ${v_filename}: ${v_sum_err}" fi f_print_msg "Done" f_prune_old_archives "${v_dest}" "${v_backup_name}_${v_hostname}" else printf "\nFailed to create TAR archive %s:\n%s\n\n" "${v_filename}" "${v_tar_err}" >&2 f_log_msg "Failed to create TAR archive ${v_filename}: ${v_tar_err}" exit 1 fi fi # Create data TAR archive, if the data-backup pass is enabled if [[ "${v_enable_data_backup}" == true ]]; then v_filename_data="${v_backup_name}_DATA_${v_hostname}_${v_datetime_suffix}.tar.xz" a_tar_extra_data_sources=() if [[ "${v_enable_data_source_extra}" == true ]]; then f_filter_existing_sources a_data_source_extra a_tar_extra_data_sources "a_data_source_extra" else f_print_msg $'\na_data_source_extra: disabled, skipping' fi if [[ "${v_dry_run}" == true ]]; then f_print_msg "$(printf '\n[DRY-RUN] would create data TAR archive %s in %s' "${v_filename_data}" "${v_dest_data}")" f_encrypt_archive v_filename_data "${v_dest_data}" "${v_gpg_recipient_file_data}" "${v_enable_gpg_data}" if [[ "${v_tar_owner}" == true ]]; then f_print_msg "[DRY-RUN] would change data tar archive ownership to user: ${v_username}" fi f_print_msg "[DRY-RUN] would write checksum: ${v_filename_data}.sha256" f_prune_old_archives "${v_dest_data}" "${v_backup_name}_DATA_${v_hostname}" else f_print_msg $'\nCreating data TAR archive' # Same secrets exclusion reasoning as the settings archive above: # a_data_source_extra could in principle point at /etc or /root too. a_secrets_exclude_args=() for i in "${a_secrets_source[@]}" "${a_secrets_source_extra[@]}"; do a_secrets_exclude_args+=(--exclude="${i}") done f_assert_within_whitelist "${v_dest_data}/${v_filename_data}" if v_tar_err=$(tar --acls --xattrs --transform="s,^,${v_filename_data%.tar.xz}/," \ -cpJf "${v_dest_data}/${v_filename_data}" --exclude="${v_dest_data}" \ --exclude="${v_mysql_cnf_path}" --exclude="${v_postgresql_pgpass_path}" \ --exclude="${v_tempdir_secrets}" \ --exclude="${v_dest_secrets}" "${a_secrets_exclude_args[@]}" "${v_tempdir_data}" \ "${a_tar_extra_data_sources[@]}" 2>&1 >/dev/null); then f_encrypt_archive v_filename_data "${v_dest_data}" "${v_gpg_recipient_file_data}" "${v_enable_gpg_data}" f_assert_within_whitelist "${v_dest_data}/${v_filename_data}" chmod 600 "${v_dest_data}/${v_filename_data}" f_print_msg "Data TAR archive ${v_filename_data} created in ${v_dest_data}" if [[ "${v_tar_owner}" == true ]]; then f_check_command "chown" f_print_msg "Changing data tar archive ownership to user: ${v_username}" f_assert_within_whitelist "${v_dest_data}/${v_filename_data}" chown "${v_username}":"${v_username}" "${v_dest_data}/${v_filename_data}" fi # Same checksum reasoning as the settings archive above. f_assert_within_whitelist "${v_dest_data}/${v_filename_data}.sha256" if v_sum_err=$(cd "${v_dest_data}" && sha256sum "${v_filename_data}" 2>&1 > "${v_filename_data}.sha256"); then if [[ "${v_tar_owner}" == true ]]; then chown "${v_username}":"${v_username}" "${v_dest_data}/${v_filename_data}.sha256" fi else f_print_msg "WARNING: failed to write checksum for ${v_filename_data}: ${v_sum_err}" fi f_print_msg "Done" f_prune_old_archives "${v_dest_data}" "${v_backup_name}_DATA_${v_hostname}" else printf "\nFailed to create data TAR archive %s:\n%s\n\n" "${v_filename_data}" "${v_tar_err}" >&2 f_log_msg "Failed to create data TAR archive ${v_filename_data}: ${v_tar_err}" exit 1 fi fi fi # Create secrets TAR archive, if the secrets pass is enabled. Unlike # the settings/data archives, a_secrets_source/a_secrets_source_extra # entries are already copied into v_tempdir_secrets by the SECRETS # BACKUP step above, so no separate "extra sources" list here, and # deliberately no --exclude for v_mysql_cnf_path/v_postgresql_pgpass_path: # this archive exists specifically to hold restore-time credentials. if [[ "${v_secrets_active}" == true ]]; then v_filename_secrets="${v_backup_name}_SECRETS_${v_hostname}_${v_datetime_suffix}.tar.xz" if [[ "${v_dry_run}" == true ]]; then f_print_msg "$(printf '\n[DRY-RUN] would create secrets TAR archive %s in %s' "${v_filename_secrets}" "${v_dest_secrets}")" f_encrypt_archive v_filename_secrets "${v_dest_secrets}" "${v_gpg_recipient_file_secrets}" "${v_enable_gpg_secrets}" if [[ "${v_tar_owner_secrets}" == true ]]; then f_print_msg "[DRY-RUN] would change secrets tar archive ownership to user: ${v_username}" fi f_print_msg "[DRY-RUN] would write checksum: ${v_filename_secrets}.sha256" f_prune_old_archives "${v_dest_secrets}" "${v_backup_name}_SECRETS_${v_hostname}" else f_print_msg $'\nCreating secrets TAR archive' f_assert_within_whitelist "${v_dest_secrets}/${v_filename_secrets}" if v_tar_err=$(tar --acls --xattrs --transform="s,^,${v_filename_secrets%.tar.xz}/," \ -cpJf "${v_dest_secrets}/${v_filename_secrets}" \ --exclude="${v_dest_secrets}" "${v_tempdir_secrets}" 2>&1 >/dev/null); then f_encrypt_archive v_filename_secrets "${v_dest_secrets}" "${v_gpg_recipient_file_secrets}" "${v_enable_gpg_secrets}" f_assert_within_whitelist "${v_dest_secrets}/${v_filename_secrets}" chmod 600 "${v_dest_secrets}/${v_filename_secrets}" f_print_msg "Secrets TAR archive ${v_filename_secrets} created in ${v_dest_secrets}" if [[ "${v_tar_owner_secrets}" == true ]]; then f_check_command "chown" f_print_msg "Changing secrets tar archive ownership to user: ${v_username}" f_assert_within_whitelist "${v_dest_secrets}/${v_filename_secrets}" chown "${v_username}":"${v_username}" "${v_dest_secrets}/${v_filename_secrets}" fi # Same checksum reasoning as the settings archive above. f_assert_within_whitelist "${v_dest_secrets}/${v_filename_secrets}.sha256" if v_sum_err=$(cd "${v_dest_secrets}" && sha256sum "${v_filename_secrets}" 2>&1 > "${v_filename_secrets}.sha256"); then if [[ "${v_tar_owner_secrets}" == true ]]; then chown "${v_username}":"${v_username}" "${v_dest_secrets}/${v_filename_secrets}.sha256" fi else f_print_msg "WARNING: failed to write checksum for ${v_filename_secrets}: ${v_sum_err}" fi f_print_msg "Done" f_prune_old_archives "${v_dest_secrets}" "${v_backup_name}_SECRETS_${v_hostname}" else printf "\nFailed to create secrets TAR archive %s:\n%s\n\n" "${v_filename_secrets}" "${v_tar_err}" >&2 f_log_msg "Failed to create secrets TAR archive ${v_filename_secrets}: ${v_tar_err}" exit 1 fi fi fi # Remote rsync $dest directory f_print_msg $'\nSynchronizing backup directory' if [[ "${v_enable_rsync}" != true ]]; then f_print_msg "v_enable_rsync: disabled, skipping" elif [[ -n "${v_dest_rsync}" ]]; then if [[ "${v_dry_run}" == true ]]; then f_print_msg "[DRY-RUN] would rsync ${v_dest} with ${v_dest_rsync}" else f_check_command "rsync" f_print_msg "rsync ${v_dest} with ${v_dest_rsync}" # Only the source is checked: v_dest_rsync can be a remote spec # (user@host:/path), which readlink -f cannot meaningfully resolve. f_assert_within_whitelist "${v_dest}" if rsync -arq -AX --delete --force -- "${v_dest}" "${v_dest_rsync}"; then f_print_msg "Done" else printf "\nFailed to synchronize %s with %s\n\n" "${v_dest}" "${v_dest_rsync}" >&2 f_log_msg "Failed to synchronize ${v_dest} with ${v_dest_rsync}" fi fi else f_print_msg "No rsync destination configured" f_print_msg "Skipped" fi # Remove $workdir directory (and $workdir_data/$workdir_secrets, if # the data/secrets passes are enabled) if [[ "${v_dry_run}" == true ]]; then f_print_msg "[DRY-RUN] nothing to delete: ${v_tempdir} was never created" elif [[ "${v_quiet}" == false ]]; then printf "\nYou can now delete the temporary directory %s" "${v_tempdir}" if [[ "${v_enable_data_backup}" == true ]]; then printf " (and the data workdir %s)" "${v_tempdir_data}" fi if [[ "${v_secrets_active}" == true ]]; then printf " (and the secrets workdir %s)" "${v_tempdir_secrets}" fi v_msg="Do you really want to delete it? ([Y]es/[N]o/[C]ancel, no default, type it out)? " f_ask_confirmation "${v_msg}" "delete" elif [[ "${v_quiet}" == true ]] && [[ "${v_keep_tempdir}" == false ]]; then f_assert_within_whitelist "${v_tempdir}" rm -rf "${v_tempdir}" if [[ "${v_enable_data_backup}" == true ]]; then f_assert_within_whitelist "${v_tempdir_data}" rm -rf "${v_tempdir_data}" fi if [[ "${v_secrets_active}" == true ]]; then f_assert_within_whitelist "${v_tempdir_secrets}" rm -rf "${v_tempdir_secrets}" fi fi fi # Execute post command (skipped in dry-run: arbitrary user command, not # guaranteed to be read-only) if [[ "${v_enable_post_command}" != true ]]; then f_print_msg $'\nv_enable_post_command: disabled, skipping' elif [[ -n "${v_post_command}" ]]; then export NDM_HOSTNAME="${v_hostname}" export NDM_ARCHIVE_PREFIX="${v_backup_name}" export NDM_LOG_PATH="${v_log_path:-}" export NDM_USER="${v_username}" export NDM_EXIT_CODE=0 export NDM_SETTINGS_ARCHIVE="" NDM_SETTINGS_ARCHIVE_SIZE="" export NDM_DATA_ARCHIVE="" NDM_DATA_ARCHIVE_SIZE="" export NDM_SECRETS_ARCHIVE="" NDM_SECRETS_ARCHIVE_SIZE="" if [[ -n "${v_filename:-}" ]]; then NDM_SETTINGS_ARCHIVE="${v_dest}/${v_filename}" NDM_SETTINGS_ARCHIVE_SIZE="$(f_human_size "${NDM_SETTINGS_ARCHIVE}")" fi if [[ -n "${v_filename_data:-}" ]]; then NDM_DATA_ARCHIVE="${v_dest_data}/${v_filename_data}" NDM_DATA_ARCHIVE_SIZE="$(f_human_size "${NDM_DATA_ARCHIVE}")" fi if [[ -n "${v_filename_secrets:-}" ]]; then NDM_SECRETS_ARCHIVE="${v_dest_secrets}/${v_filename_secrets}" NDM_SECRETS_ARCHIVE_SIZE="$(f_human_size "${NDM_SECRETS_ARCHIVE}")" fi if [[ "${v_dry_run}" == true ]]; then f_print_msg $'\n[DRY-RUN] would execute post command: '"${v_post_command}" else f_print_msg $'\nExecuting post command' eval "${v_post_command}" fi fi # All Done. Unconditional printf (not f_print_msg): the final result # must stay visible even in --quiet, same reasoning as the # missing-packages notice above -- quiet means no chatter, not no idea # whether it worked or where the output landed. if [[ "${v_dry_run}" == true ]]; then v_msg="Dry run completed. Nothing was created, modified, or deleted on this system." printf "\n%s\n" "${v_msg}" f_log_msg "${v_msg}" else printf "\nAll done.\n" f_log_msg "All done." if [[ -n "${v_filename:-}" ]]; then v_msg=" Settings archive: ${v_dest}/${v_filename} ($(f_human_size "${v_dest}/${v_filename}"))" printf "%s\n" "${v_msg}" f_log_msg "${v_msg}" fi if [[ -n "${v_filename_data:-}" ]]; then v_msg=" Data archive: ${v_dest_data}/${v_filename_data} ($(f_human_size "${v_dest_data}/${v_filename_data}"))" printf "%s\n" "${v_msg}" f_log_msg "${v_msg}" fi if [[ -n "${v_filename_secrets:-}" ]]; then v_msg=" Secrets archive: ${v_dest_secrets}/${v_filename_secrets} ($(f_human_size "${v_dest_secrets}/${v_filename_secrets}"))" printf "%s\n" "${v_msg}" f_log_msg "${v_msg}" fi fi printf "\n" exit 0 } ######################## # ENTRY POINT ######################## f_parse_arguments "${@}" if [[ "${v_start}" == true ]] || [[ "${v_analyze}" == true ]] || [[ "${v_requirements}" == true ]]; then if [[ -n "${v_config_error}" ]]; then printf 'Invalid setting in %s: %s ' "${v_config_path}" "${v_config_error}" >&2 exit 2 fi if [[ "${v_requirements}" == true ]]; then f_requirements else main "${@}" fi fi exit 0