first
This commit is contained in:
commit
cb516ee979
5 changed files with 402 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Secrets — never commit the real env file
|
||||||
|
backup.env
|
||||||
152
README.backup.md
Normal file
152
README.backup.md
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
# PBS Backup helper
|
||||||
|
|
||||||
|
Client-side encrypted backups of a host to a Proxmox Backup Server (PBS)
|
||||||
|
that is **outside our control**.
|
||||||
|
|
||||||
|
Everything lives in `/opt/backup/`. The connection details and credentials
|
||||||
|
(server, datastore, namespace, API token, encryption key) are kept in
|
||||||
|
`backup.env` (git-ignored); see `backup.env.sample` for the format.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `backup.env` | Shared config: server, datastore, namespace, token, key (sourced by both scripts) |
|
||||||
|
| `backup.env.sample` | Template — copy to `backup.env` and fill in real values |
|
||||||
|
| `backup.sh` | Full backup of `/` with client-side encryption |
|
||||||
|
| `restore.sh` | FUSE-mount the encrypted backup to browse/restore files |
|
||||||
|
| `/etc/proxmox-backup/encryption-key.pem` | Client-side encryption key (store a copy off-host!) |
|
||||||
|
| `/etc/proxmox-backup/defaults.exclude` | Paths excluded from the backup |
|
||||||
|
|
||||||
|
## How `backup.sh` works
|
||||||
|
|
||||||
|
1. Sources `backup.env`, which exports the connection/auth settings (server,
|
||||||
|
datastore, port, API token) as separate `PBS_*` component variables. This
|
||||||
|
matters: `PBS_AUTH_ID` is only honoured when `PBS_REPOSITORY` is *not* set.
|
||||||
|
2. Builds `--exclude` flags from `/etc/proxmox-backup/defaults.exclude`
|
||||||
|
(`/proc`, `/sys`, `/dev`, `/run`, `/tmp`, …).
|
||||||
|
3. Runs:
|
||||||
|
|
||||||
|
```
|
||||||
|
proxmox-backup-client backup "${HOSTNAME}-root.pxar:/" \
|
||||||
|
--keyfile /etc/proxmox-backup/encryption-key.pem \
|
||||||
|
--ns "$BACKUP_NS" \
|
||||||
|
--exclude /proc/* ...
|
||||||
|
```
|
||||||
|
|
||||||
|
4. The client encrypts everything locally before uploading. The PBS only ever
|
||||||
|
stores ciphertext and cannot read the data.
|
||||||
|
|
||||||
|
Encryption is client-side. The key file has no password (`kdf: none`), so
|
||||||
|
**possession of the key file alone is enough to decrypt** — protect it.
|
||||||
|
|
||||||
|
## What to store elsewhere (critical)
|
||||||
|
|
||||||
|
The **encryption key is the only thing that makes the backups readable**. If
|
||||||
|
this host and the key are both lost, the backup is unrecoverable — no one at
|
||||||
|
the PBS provider can help.
|
||||||
|
|
||||||
|
- File: `/etc/proxmox-backup/encryption-key.pem`
|
||||||
|
- Fingerprint: `proxmox-backup-client key show /etc/proxmox-backup/encryption-key.pem`
|
||||||
|
|
||||||
|
Keep a copy somewhere safe that is **not this machine**:
|
||||||
|
- offline USB stick / printed paperkey
|
||||||
|
- password manager
|
||||||
|
- another trusted host
|
||||||
|
|
||||||
|
Also record (or be able to re-issue) the API token secret, so you can reach the
|
||||||
|
datastore at all. Losing the token costs you access; losing the key costs you
|
||||||
|
the data.
|
||||||
|
|
||||||
|
## How to restore files
|
||||||
|
|
||||||
|
`restore.sh` has three modes. Pick based on how much you need back.
|
||||||
|
|
||||||
|
Unless you pass a snapshot explicitly, each mode first lists the available
|
||||||
|
snapshots and prompts you to pick one (default: the most recent). You can skip
|
||||||
|
the prompt with `--snapshot <ref>` (or the positional snapshot on `mount`).
|
||||||
|
|
||||||
|
For a few files, the FUSE mount is the fastest option (random access, only
|
||||||
|
reads the path you touch). `restore --pattern` is the slowest (streams the
|
||||||
|
whole archive); `shell` + `restore-selected` sits in between.
|
||||||
|
|
||||||
|
### FUSE mount — browse, or restore a few files
|
||||||
|
|
||||||
|
```
|
||||||
|
./restore.sh # mount latest snapshot at /mnt/pbs-restore
|
||||||
|
./restore.sh /mnt/restore <snapshot> # mount a specific snapshot
|
||||||
|
```
|
||||||
|
|
||||||
|
Then browse `/mnt/pbs-restore` and copy out what you need. Unmount with:
|
||||||
|
|
||||||
|
```
|
||||||
|
umount /mnt/pbs-restore
|
||||||
|
```
|
||||||
|
|
||||||
|
The mount does random access, so it only downloads the chunks for the files
|
||||||
|
you actually read. That's cheap for a handful of files, but slow to walk a
|
||||||
|
large tree (each file is a round-trip).
|
||||||
|
|
||||||
|
### Bulk restore — a whole directory or many files
|
||||||
|
|
||||||
|
```
|
||||||
|
./restore.sh restore /restore/var/vmail "var/vmail/example.com/*"
|
||||||
|
```
|
||||||
|
|
||||||
|
Streams the archive sequentially and extracts the matching paths — the fast
|
||||||
|
way to get a large directory or many files back. Patterns are globs against
|
||||||
|
paths inside the archive and may be repeated; with none, the whole archive is
|
||||||
|
extracted:
|
||||||
|
|
||||||
|
```
|
||||||
|
./restore.sh restore /mnt/full-restore
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: pattern restore always reads the whole archive (it streams and filters),
|
||||||
|
so it's wasteful for one or two files — use the mount for those.
|
||||||
|
|
||||||
|
### Interactive shell — browse and selectively restore
|
||||||
|
|
||||||
|
```
|
||||||
|
./restore.sh shell
|
||||||
|
```
|
||||||
|
|
||||||
|
Drops into an interactive shell that navigates the backup via the catalog
|
||||||
|
metadata, so browsing is fast (it doesn't read the archive). Useful commands:
|
||||||
|
|
||||||
|
```
|
||||||
|
ls [path] list the current directory
|
||||||
|
cd [path] change directory
|
||||||
|
pwd print current directory
|
||||||
|
find <pattern> search entries
|
||||||
|
select <path> mark an entry for restore
|
||||||
|
deselect <path> unmark an entry
|
||||||
|
list-selected show what is marked
|
||||||
|
restore-selected <target> restore everything marked
|
||||||
|
exit quit
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `restore-selected` is catalog-driven — it reads only matched files'
|
||||||
|
data, not the whole archive — but it still walks the entire catalog and fetches
|
||||||
|
per-directory pxar metadata from the archive, so it's ~a minute on a large
|
||||||
|
tree, not instant. For one or two files, `mount` + `cp` is much faster.
|
||||||
|
|
||||||
|
### Manual `restore` (advanced)
|
||||||
|
|
||||||
|
First source the shared config (`source /opt/backup/backup.env`), then:
|
||||||
|
|
||||||
|
```
|
||||||
|
proxmox-backup-client restore host/<hostname>/<time> <hostname>-root.pxar /restore/dir \
|
||||||
|
--ns <namespace> \
|
||||||
|
--keyfile /etc/proxmox-backup/encryption-key.pem \
|
||||||
|
--pattern "etc/postfix/main.cf"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Snapshots
|
||||||
|
|
||||||
|
The snapshot reference has the form `<type>/<id>/<UTC-time>`, e.g.
|
||||||
|
`host/<hostname>/2026-09-19T10:36:14Z`. List available snapshots with:
|
||||||
|
|
||||||
|
```
|
||||||
|
proxmox-backup-client snapshot list host/<hostname> --ns <namespace>
|
||||||
|
```
|
||||||
29
backup.env.sample
Normal file
29
backup.env.sample
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
# ==============================================================================
|
||||||
|
# Shared configuration for backup.sh and restore.sh.
|
||||||
|
#
|
||||||
|
# This is a TEMPLATE — copy it to backup.env and fill in real values:
|
||||||
|
#
|
||||||
|
# cp backup.env.sample backup.env
|
||||||
|
# chmod 600 backup.env
|
||||||
|
#
|
||||||
|
# backup.env is git-ignored and holds the live credentials. Both scripts
|
||||||
|
# source it, so it is the single source of truth for the PBS connection/auth.
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# --- Proxmox Backup Server ---
|
||||||
|
export PBS_SERVER="backup.example.com"
|
||||||
|
export PBS_DATASTORE="datastore-name"
|
||||||
|
export PBS_PORT="8007"
|
||||||
|
|
||||||
|
# --- Namespace within the datastore ---
|
||||||
|
BACKUP_NS="namespace-name"
|
||||||
|
|
||||||
|
# --- Authentication (API token: <user>@<realm>!<tokenname>) ---
|
||||||
|
export PBS_AUTH_ID="user@pbs!tokenname"
|
||||||
|
export PBS_PASSWORD="paste-token-secret-here"
|
||||||
|
|
||||||
|
# --- Client-side encryption key (passed via --keyfile) ---
|
||||||
|
ENCRYPTION_KEY_FILE="/etc/proxmox-backup/encryption-key.pem"
|
||||||
|
|
||||||
|
# --- Local hostname (used as backup-id and archive-name prefix) ---
|
||||||
|
HOSTNAME="$(hostname)"
|
||||||
47
backup.sh
Executable file
47
backup.sh
Executable file
|
|
@ -0,0 +1,47 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=backup.env
|
||||||
|
source "$SCRIPT_DIR/backup.env"
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# BACKUP-SPECIFIC CONFIGURATION
|
||||||
|
# ==============================================================================
|
||||||
|
BACKUP_LABEL="${HOSTNAME}-root"
|
||||||
|
EXCLUDE_FILE="/etc/proxmox-backup/defaults.exclude"
|
||||||
|
|
||||||
|
# Create a default exclude file if it doesn't exist
|
||||||
|
if [ ! -f "$EXCLUDE_FILE" ]; then
|
||||||
|
mkdir -p "$(dirname "$EXCLUDE_FILE")"
|
||||||
|
cat << 'EOF' > "$EXCLUDE_FILE"
|
||||||
|
/proc/*
|
||||||
|
/sys/*
|
||||||
|
/dev/*
|
||||||
|
/run/*
|
||||||
|
/tmp/*
|
||||||
|
/mnt/*
|
||||||
|
/media/*
|
||||||
|
/lost+found
|
||||||
|
/var/tmp/*
|
||||||
|
/var/cache/*
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# EXECUTION
|
||||||
|
# ==============================================================================
|
||||||
|
echo "Starting Proxmox Backup for ${HOSTNAME}..."
|
||||||
|
|
||||||
|
EXCLUDE_ARGS=()
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[ -z "$line" ] && continue
|
||||||
|
EXCLUDE_ARGS+=(--exclude "$line")
|
||||||
|
done < "$EXCLUDE_FILE"
|
||||||
|
|
||||||
|
proxmox-backup-client backup "${BACKUP_LABEL}.pxar:/" \
|
||||||
|
--keyfile "$ENCRYPTION_KEY_FILE" \
|
||||||
|
--ns "$BACKUP_NS" \
|
||||||
|
"${EXCLUDE_ARGS[@]}"
|
||||||
|
|
||||||
|
echo "Backup completed successfully!"
|
||||||
172
restore.sh
Executable file
172
restore.sh
Executable file
|
|
@ -0,0 +1,172 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=backup.env
|
||||||
|
source "$SCRIPT_DIR/backup.env"
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# RESTORE-SPECIFIC CONFIGURATION
|
||||||
|
# ==============================================================================
|
||||||
|
BACKUP_GROUP="host/${HOSTNAME}" # <backup-type>/<backup-id>
|
||||||
|
ARCHIVE_NAME="${HOSTNAME}-root.pxar" # archive created by backup.sh
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat >&2 <<'EOF'
|
||||||
|
Usage:
|
||||||
|
restore.sh [mount] [MOUNTPOINT] [SNAPSHOT]
|
||||||
|
FUSE-mount a snapshot for browsing or restoring single files.
|
||||||
|
|
||||||
|
restore.sh restore TARGET [PATTERN ...] [--snapshot SNAPSHOT]
|
||||||
|
Bulk-restore files/directories into TARGET. Faster than the FUSE
|
||||||
|
mount for many files. PATTERN is a glob matched against paths inside
|
||||||
|
the archive (e.g. "var/vmail/example.com/*"); repeatable. With no
|
||||||
|
PATTERN the whole archive is extracted.
|
||||||
|
|
||||||
|
restore.sh shell [--snapshot SNAPSHOT]
|
||||||
|
Interactive shell to browse the backup and selectively restore files.
|
||||||
|
|
||||||
|
Without SNAPSHOT (or --snapshot), you are prompted to pick one from a
|
||||||
|
list of available snapshots (default: the most recent).
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prints a snapshot reference on stdout. When stdin is a TTY and there is more
|
||||||
|
# than one snapshot, shows a numbered menu (newest first) and prompts.
|
||||||
|
select_snapshot() {
|
||||||
|
local -a snaps
|
||||||
|
mapfile -t snaps < <(
|
||||||
|
proxmox-backup-client snapshot list "$BACKUP_GROUP" \
|
||||||
|
--ns "$BACKUP_NS" --output-format json \
|
||||||
|
| jq -r 'sort_by(."backup-time") | reverse | .[] | "\(."backup-type")/\(."backup-id")/\(."backup-time" | todateiso8601)"'
|
||||||
|
)
|
||||||
|
|
||||||
|
if [ "${#snaps[@]}" -eq 0 ]; then
|
||||||
|
echo "error: no snapshots found in group $BACKUP_GROUP" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Non-interactive (piped/cron): use the latest without prompting.
|
||||||
|
if [ ! -t 0 ]; then
|
||||||
|
printf '%s\n' "${snaps[0]}"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Available snapshots:" >&2
|
||||||
|
local i
|
||||||
|
for i in "${!snaps[@]}"; do
|
||||||
|
printf ' %d) %s\n' "$((i + 1))" "${snaps[$i]}" >&2
|
||||||
|
done
|
||||||
|
|
||||||
|
local choice=""
|
||||||
|
read -r -p "Select a snapshot [1-${#snaps[@]}] (default 1): " choice
|
||||||
|
|
||||||
|
choice="${choice:-1}"
|
||||||
|
case "$choice" in
|
||||||
|
*[!0-9]*) echo "error: invalid selection: $choice" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
local idx=$((choice - 1))
|
||||||
|
if [ "$idx" -lt 0 ] || [ "$idx" -ge "${#snaps[@]}" ]; then
|
||||||
|
echo "error: selection out of range: $choice" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\n' "${snaps[$idx]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
CMD="mount"
|
||||||
|
case "${1:-}" in
|
||||||
|
mount|restore|shell) CMD="$1"; shift ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$CMD" in
|
||||||
|
mount)
|
||||||
|
MOUNTPOINT="${1:-/mnt/pbs-restore}"
|
||||||
|
SNAPSHOT="${2:-}"
|
||||||
|
[ -z "$SNAPSHOT" ] && SNAPSHOT="$(select_snapshot)"
|
||||||
|
|
||||||
|
if mountpoint -q "$MOUNTPOINT" 2>/dev/null; then
|
||||||
|
echo "error: already mounted at $MOUNTPOINT" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$MOUNTPOINT"
|
||||||
|
|
||||||
|
echo "Snapshot: $SNAPSHOT"
|
||||||
|
echo "Archive: $ARCHIVE_NAME"
|
||||||
|
echo "Mountpoint: $MOUNTPOINT"
|
||||||
|
|
||||||
|
# The client daemonizes by default, so its exit code only tells us
|
||||||
|
# whether it started; verify the FUSE mount actually appears.
|
||||||
|
proxmox-backup-client mount "$SNAPSHOT" "$ARCHIVE_NAME" "$MOUNTPOINT" \
|
||||||
|
--ns "$BACKUP_NS" \
|
||||||
|
--keyfile "$ENCRYPTION_KEY_FILE"
|
||||||
|
|
||||||
|
for _ in $(seq 1 100); do
|
||||||
|
mountpoint -q "$MOUNTPOINT" 2>/dev/null && break
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
mountpoint -q "$MOUNTPOINT" 2>/dev/null \
|
||||||
|
|| { echo "error: mount did not appear" >&2; exit 1; }
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Mounted at $MOUNTPOINT. Copy out the files you need, then unmount:"
|
||||||
|
echo " umount $MOUNTPOINT"
|
||||||
|
;;
|
||||||
|
|
||||||
|
restore)
|
||||||
|
TARGET="${1:-}"
|
||||||
|
[ -n "$TARGET" ] || usage
|
||||||
|
shift
|
||||||
|
|
||||||
|
PATTERNS=()
|
||||||
|
SNAPSHOT=""
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--snapshot) SNAPSHOT="${2:-}"; [ -n "$SNAPSHOT" ] || usage; shift 2 ;;
|
||||||
|
*) PATTERNS+=("$1"); shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[ -z "$SNAPSHOT" ] && SNAPSHOT="$(select_snapshot)"
|
||||||
|
|
||||||
|
mkdir -p "$TARGET"
|
||||||
|
|
||||||
|
echo "Snapshot: $SNAPSHOT"
|
||||||
|
echo "Archive: $ARCHIVE_NAME"
|
||||||
|
echo "Target: $TARGET"
|
||||||
|
[ "${#PATTERNS[@]}" -gt 0 ] && printf 'Patterns: %s\n' "${PATTERNS[*]}"
|
||||||
|
|
||||||
|
ARGS=(restore "$SNAPSHOT" "$ARCHIVE_NAME" "$TARGET" --ns "$BACKUP_NS" --keyfile "$ENCRYPTION_KEY_FILE")
|
||||||
|
for p in "${PATTERNS[@]}"; do
|
||||||
|
ARGS+=(--pattern "$p")
|
||||||
|
done
|
||||||
|
|
||||||
|
proxmox-backup-client "${ARGS[@]}"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Restored into $TARGET"
|
||||||
|
;;
|
||||||
|
|
||||||
|
shell)
|
||||||
|
SNAPSHOT=""
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--snapshot) SNAPSHOT="${2:-}"; [ -n "$SNAPSHOT" ] || usage; shift 2 ;;
|
||||||
|
*) usage ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[ -z "$SNAPSHOT" ] && SNAPSHOT="$(select_snapshot)"
|
||||||
|
|
||||||
|
exec proxmox-backup-client catalog shell "$SNAPSHOT" "$ARCHIVE_NAME" \
|
||||||
|
--ns "$BACKUP_NS" \
|
||||||
|
--keyfile "$ENCRYPTION_KEY_FILE"
|
||||||
|
;;
|
||||||
|
|
||||||
|
*)
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
esac
|
||||||
Loading…
Add table
Add a link
Reference in a new issue