Compare commits

..
11 Commits
4 changed files with 355 additions and 26 deletions
+315
View File
@@ -0,0 +1,315 @@
# pbc
A Bash wrapper around Proxmox's `proxmox-backup-client` for Proxmox Backup Server (PBS).
It keeps the repository, credentials and the list of paths to back up in a single config
file, injects the configured namespace (`--ns`) and encryption key (`--keyfile`) into every
command that needs them, and lets you pick a snapshot group, snapshot and archive from an
interactive menu instead of typing them out. Any command it does not implement itself is
passed straight through to `proxmox-backup-client`.
## Requirements
- A Debian-based host with `bash` and `curl`
- `jq` and `proxmox-backup-client` — both installed by `pbc install`
- A reachable PBS with a datastore and an API token
## Installation
```bash
curl -fsSL https://git.ccc-rheintal.ch/spacefreak/pbc/raw/branch/master/install.sh | sudo bash
```
This installs the newest tagged version to `/usr/local/bin/pbc`, installs the
dependencies, and puts an example config at `/etc/pbc/config.example`.
### Installing a specific version
Set `PBC_GIT_TAG` to install that tag instead of the newest one:
```bash
curl -fsSL https://git.ccc-rheintal.ch/spacefreak/pbc/raw/branch/master/install.sh | sudo PBC_GIT_TAG=v1.0.0 bash
```
`sudo` drops the environment, so set the variable on the `sudo` command itself (or use
`sudo -E`), not before `curl`. Any Git ref works, e.g. a branch name for testing.
### Updating
Run the same command again to update `pbc` itself:
```bash
curl -fsSL https://git.ccc-rheintal.ch/spacefreak/pbc/raw/branch/master/install.sh | sudo bash
```
It fetches the newest tag and overwrites `/usr/local/bin/pbc`. Your `/etc/pbc/config` is
left untouched. `PBC_GIT_TAG` works here too, to pin or roll back to a given version.
To update only the `proxmox-backup-client` binary:
```bash
sudo pbc install --update
```
This is skipped when the client is managed by the package manager — use `apt` in that case.
## Configuration
```bash
sudo cp /etc/pbc/config.example /etc/pbc/config
sudo chmod 640 /etc/pbc/config
sudo editor /etc/pbc/config
```
| Key | Description |
|---|---|
| `PBS_SERVER` | PBS host and port |
| `PBS_USER` | User and API token name, `user@realm:token-name` |
| `PBS_PASSWORD` | The API token secret |
| `PBS_DATASTORE` | Datastore to back up to |
| `PBS_NAMESPACE` | Optional, namespace inside the datastore |
| `BACKUP` | Array of `archive-name.pxar:/path` entries to back up |
| `ENCRYPTION_KEYFILE` | Optional, path to the client encryption key — see [Encryption](#encryption) |
| `PBC` | Optional, path to `proxmox-backup-client` (default `/usr/local/bin/proxmox-backup-client`) |
`pbc` never creates the config for you — copy the example and edit it yourself. The file
has to be readable for the user that runs `pbc`, so if that is not root, hand
it over:
```bash
sudo chown youruser /etc/pbc/config
```
The config is sourced as Bash and contains your token secret. `pbc` refuses to run if the
file is writable by group or others, and warns if it is readable by others.
Check that it works:
```bash
pbc list
```
Use a different config with `-c`:
```bash
pbc -c ./myconfig list
```
## Encryption
Backups can be encrypted client-side, so PBS only ever sees ciphertext. Create a key and
point `ENCRYPTION_KEYFILE` at it:
```bash
sudo proxmox-backup-client key create /etc/pbc/backup.key --kdf none
sudo chmod 600 /etc/pbc/backup.key
```
`pbc` errors out if the key is missing or unreadable, and warns if it is readable by
others. As with the config, the file has to be readable for the user that runs `pbc`
`sudo chown youruser /etc/pbc/backup.key` if that is not root.
**Back the key up somewhere else.** Without it the backups are unrecoverable, and a key
stored only on the machine you are backing up is gone exactly when you need it. Print a
recovery sheet and keep it off-host:
```bash
sudo proxmox-backup-client key paperkey /etc/pbc/backup.key
```
`--kdf none` leaves the key unprotected on disk, which is what makes unattended backups
possible. With a passphrase-protected key (`--kdf scrypt`) the client prompts on every run
and `backup-cron` hangs; export `PBS_ENCRYPTION_PASSWORD` in that case.
The key is passed to `backup`, `backup-cron`, `mount` and `catalog-shell`. It is *not*
passed to commands that fall through to `proxmox-backup-client``pbc restore` needs both
flags spelled out:
```bash
pbc restore --ns MyBackups --keyfile /etc/pbc/backup.key \
host/myhost/2026-08-24T01:00:00Z root.pxar /mnt/restore
```
Setting `ENCRYPTION_KEYFILE` only affects snapshots made from then on. Older unencrypted
snapshots stay readable, and listing works without the key either way — only reading
archive contents needs it.
## Usage
Run `pbc --help` for the synopsis and the list of options, or `pbc -H` to additionally
list every command of `proxmox-backup-client`. The commands `pbc` implements itself are
described below.
### backup
Back up everything listed in `BACKUP` and print the client output:
```bash
pbc backup
```
Extra arguments are forwarded to the client:
```bash
pbc backup --exclude '/mnt/data/cache'
```
### backup-cron
Same backup, but output is suppressed unless it fails — so cron only mails you on errors:
```cron
0 3 * * * /usr/local/bin/pbc backup-cron
```
### list
List backup groups with their latest snapshot and archives:
```bash
pbc list
```
### snapshot-list
List snapshots of a group with sizes and archives. Without an argument you get a menu of
the available groups:
```bash
pbc snapshot-list
pbc snapshot-list host/myhost
```
### mount
Mount a single archive of a snapshot on a local directory. Anything you leave out is
asked for interactively:
```bash
pbc mount
pbc mount host/myhost/2026-08-24T01:00:00Z root.pxar /mnt/restore
```
The target directory is not created for you — create it beforehand and make sure it is
writable for the user that runs `pbc`. Encrypted archives are decrypted transparently when
`ENCRYPTION_KEYFILE` is configured. Unmount when done:
```bash
umount /mnt/restore
```
### catalog-shell
Open an interactive shell to browse an archive and restore selected files — see below.
Encrypted archives are decrypted transparently when `ENCRYPTION_KEYFILE` is configured.
```bash
pbc catalog-shell
pbc catalog-shell host/myhost/2026-08-24T01:00:00Z root.pxar
```
### install
Install or update the dependencies. Requires root:
```bash
sudo pbc install
sudo pbc install --update
```
## Restoring files
### Selectively, with `catalog-shell`
```bash
pbc catalog-shell
```
Pick a snapshot and an archive from the menu, then browse the archive as if it were a
filesystem and mark what you want back:
```
pxar:/ > cd etc
pxar:/etc > ls
pxar:/etc > select hosts
pxar:/etc > find etc/nginx/** --select
pxar:/etc > list-selected
pxar:/etc > restore-selected /mnt/restore
pxar:/etc > exit
```
- `ls`, `cd`, `pwd`, `stat` — browse the archive
- `select <path>` — mark a single file, relative to the current directory
- `find <pattern> --select` — mark everything matching a glob
- `deselect <path>` / `clear-selected` — drop one entry or all of them
- `list-selected` — show what is currently marked
- `restore-selected <target>` — restore only the marked entries
- `restore <target> [pattern]` — restore the current directory and everything below it
- `help` — list all available shell commands
- `exit` — leave the shell
#### Selecting a directory and everything beneath it
`select` marks exactly the entry you give it and nothing else. Marking a directory
therefore restores an empty directory — its contents are *not* included. Use a glob with
`find --select` instead:
```
pxar:/ > find etc/nginx/** --select
pxar:/ > select etc/nginx
pxar:/ > restore-selected /mnt/restore
```
The `find` line picks up everything below `etc/nginx`; the `select` line adds the
directory itself, so its own permissions and ownership are restored as well.
Note that `find` patterns are always matched against paths relative to the archive root,
no matter which directory you are in, and that `find` scans the whole archive — expect it
to take a while on large backups.
The target path must not exist yet; the restore creates it. Its parent directory has to be
writable for the user that runs `pbc`.
### Everything, or with normal tools
To copy files out with `cp`, `rsync` or a file manager, mount the archive instead:
```bash
mkdir -p /mnt/restore # must exist and be writable for the user running pbc
pbc mount
cp -a /mnt/restore/etc/nginx /etc/nginx
umount /mnt/restore
```
## Running in WSL
`pbc` also works inside a Debian-based WSL instance, which makes it a way to back up
Windows directories: the Windows drives show up under `/mnt`, so they can be listed in
`BACKUP` like any other path.
```bash
BACKUP=('users.pxar:/mnt/c/Users' 'projects.pxar:/mnt/d/projects')
```
A few things to keep in mind:
- Access to files under `/mnt` is governed by Windows, not by the user you are inside the
WSL instance — `sudo` does not help there. To back up paths your Windows user cannot
read, start the WSL instance itself as administrator (run the terminal or `wsl.exe` via
*Run as administrator*), then run `pbc` in it.
- The owner and permission metadata stored in the archive is the one WSL synthesizes for
Windows files, not the original Windows ACLs.
- Cron is not running in a WSL instance by default, so `backup-cron` only fires if you
enable it — either by starting `cron` yourself, or by triggering `pbc backup-cron` from
the Windows Task Scheduler with `wsl.exe`:
```
wsl.exe -d Debian -u root /usr/local/bin/pbc backup-cron
```
For the reason above, such a task has to run with highest privileges to reach files that
are not accessible to your Windows user.
## License
GPLv3 — see [LICENSE](LICENSE).
+1
View File
@@ -3,5 +3,6 @@ PBS_USER='backup@pam:token-name'
PBS_PASSWORD='XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX' PBS_PASSWORD='XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX'
PBS_DATASTORE='backup' PBS_DATASTORE='backup'
PBS_NAMESPACE='MyBackups' PBS_NAMESPACE='MyBackups'
#ENCRYPTION_KEYFILE='/etc/pbc/backup.key'
BACKUP=('root.pxar:/' 'data.pxar:/mnt/data') BACKUP=('root.pxar:/' 'data.pxar:/mnt/data')
+4 -4
View File
@@ -13,7 +13,7 @@ function cleanup_work_dir() {
function download_files() { function download_files() {
local path rc local path rc
local query_params='' local query_params=''
[ -n "$GIT_TAG" ] && query_params="?ref=$GIT_TAG" [ -n "$PBC_GIT_TAG" ] && query_params="?ref=$PBC_GIT_TAG"
while (( $# > 0 )); do while (( $# > 0 )); do
path=$1 path=$1
@@ -26,9 +26,9 @@ function download_files() {
done done
} }
GIT_TAG=$(curl -fsSL "$API_ENDPOINT/tags" | grep -o '{"name":"[^"]*"' | sed 's/^{"name":"//;s/"$//' | sort --version-sort | tail -n 1) [ -z "$PBC_GIT_TAG" ] && PBC_GIT_TAG=$(curl -fsSL "$API_ENDPOINT/tags" | grep -o '{"name":"[^"]*"' | sed 's/^{"name":"//;s/"$//' | grep -E '^v[0-9]+' | sort --version-sort | tail -n 1)
if [ -n "$GIT_TAG" ]; then if [ -n "$PBC_GIT_TAG" ]; then
echo "Info: installing pbc version $GIT_TAG" echo "Info: installing pbc version $PBC_GIT_TAG"
else else
echo "Warning: no Git tag found, installing from repository default branch" >&2 echo "Warning: no Git tag found, installing from repository default branch" >&2
fi fi
+33 -20
View File
@@ -1,5 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -o pipefail
function usage() { function usage() {
local full=${1:-false} local full=${1:-false}
cat <<EOF cat <<EOF
@@ -51,17 +53,17 @@ function pbc-version() {
return $rc return $rc
} }
function pbc-backup() { "$PBC" backup --ns "$PBS_NAMESPACE" "${BACKUP[@]}" "$@"; } function pbc-backup() { "$PBC" backup "${PBC_ARGS[@]}" "${KEY_ARGS[@]}" "${BACKUP[@]}" "$@"; }
function pbc-list() { "$PBC" list --ns "$PBS_NAMESPACE" "$@"; } function pbc-list() { "$PBC" list "${PBC_ARGS[@]}" "$@"; }
function pbc-snapshot-list() { "$PBC" snapshot list --ns "$PBS_NAMESPACE" "$@"; } function pbc-snapshot-list() { "$PBC" snapshot list "${PBC_ARGS[@]}" "$@"; }
function pbc-files() { "$PBC" files --ns "$PBS_NAMESPACE" "$@"; } function pbc-files() { "$PBC" files "${PBC_ARGS[@]}" "$@"; }
function pbc-catalog-shell() { "$PBC" catalog shell --ns "$PBS_NAMESPACE" "$@"; } function pbc-catalog-shell() { "$PBC" catalog shell "${PBC_ARGS[@]}" "${KEY_ARGS[@]}" "$@"; }
function pbc-mount() { "$PBC" mount --ns "$PBS_NAMESPACE" "$@"; } function pbc-mount() { "$PBC" mount "${PBC_ARGS[@]}" "${KEY_ARGS[@]}" "$@"; }
function get_group_names() { function get_group_names() {
pbc-list --output-format json | jq -r '.[] | "\(.["backup-type"])/\(.["backup-id"])"' pbc-list --output-format json | jq -r '.[] | "\(.["backup-type"])/\(.["backup-id"])"'
@@ -84,7 +86,6 @@ function apt_get_install() {
return $rc return $rc
} }
WORK_DIR=''
function cleanup_work_dir() { function cleanup_work_dir() {
[ -n "$WORK_DIR" ] && [ -d "$WORK_DIR" ] && rm -r "$WORK_DIR" [ -n "$WORK_DIR" ] && [ -d "$WORK_DIR" ] && rm -r "$WORK_DIR"
trap - EXIT trap - EXIT
@@ -141,7 +142,6 @@ function install_proxmox_backup_client() {
cleanup_work_dir cleanup_work_dir
} }
function arr_contains() { function arr_contains() {
local search=$1 local search=$1
shift shift
@@ -181,24 +181,27 @@ function may_select() {
} }
function select_group() { function select_group() {
local groups local groups raw
readarray -t groups < <(get_group_names) raw=$(get_group_names) || { echo "Error: failed to query PBS!" >&2; return 1; }
(( ${#groups[@]} == 0 )) && return 2 [ -z "$raw" ] && return 2
readarray -t groups <<< "$raw"
PS3="Snapshot group [1-${#groups[@]}]: " PS3="Snapshot group [1-${#groups[@]}]: "
GROUP=$(may_select "$1" "${groups[@]}") || { echo "Error: invalid group" >&2; return 1; } GROUP=$(may_select "$1" "${groups[@]}") || { echo "Error: invalid group" >&2; return 1; }
} }
function select_snapshot_and_archive() { function select_snapshot_and_archive() {
local snapshots archives local snapshots archives raw
readarray -t snapshots < <(get_snapshot_names) raw=$(get_snapshot_names) || { echo "Error: failed to query PBS!" >&2; return 1; }
(( ${#snapshots[@]} == 0 )) && return 2 [ -z "$raw" ] && return 2
readarray -t snapshots <<< "$raw"
PS3="Snapshot [1-${#snapshots[@]}]: " PS3="Snapshot [1-${#snapshots[@]}]: "
SNAPSHOT=$(may_select "$1" "${snapshots[@]}") || { echo "Error: invalid snapshot" >&2; return 1; } SNAPSHOT=$(may_select "$1" "${snapshots[@]}") || { echo "Error: invalid snapshot" >&2; return 1; }
readarray -t archives < <(get_snapshot_files "$SNAPSHOT") raw=$(get_snapshot_files "$SNAPSHOT") || { echo "Error: failed to query PBS!" >&2; return 1; }
(( ${#archives[@]} == 0 )) && return 3 [ -z "$raw" ] && return 3
readarray -t archives <<< "$raw"
PS3="Archive [1-${#archives[@]}]: " PS3="Archive [1-${#archives[@]}]: "
ARCHIVE=$(may_select "$2" "${archives[@]}") || { echo "Error: invalid archive" >&2; return 1; } ARCHIVE=$(may_select "$2" "${archives[@]}") || { echo "Error: invalid archive" >&2; return 1; }
@@ -270,7 +273,6 @@ while (( $# > 0 )); do
fi fi
done done
if [ -f "$cfgfile" ]; then if [ -f "$cfgfile" ]; then
perms=$(stat -c '%a' "$cfgfile") perms=$(stat -c '%a' "$cfgfile")
(( 8#$perms & 0022 )) && echo "Error: $cfgfile is writable by group or others!" >&2 && exit 202 (( 8#$perms & 0022 )) && echo "Error: $cfgfile is writable by group or others!" >&2 && exit 202
@@ -302,8 +304,20 @@ else
[ ! -f "$PBC" ] && echo "Error: $PBC: no such file" >&2 && exit 200 [ ! -f "$PBC" ] && echo "Error: $PBC: no such file" >&2 && exit 200
[ ! -x "$PBC" ] && echo "Error: $PBC: not executable" >&2 && exit 200 [ ! -x "$PBC" ] && echo "Error: $PBC: not executable" >&2 && exit 200
! command -v jq >/dev/null && echo "Error: jq executable not found!" >&2 && exit 200 ! command -v jq >/dev/null && echo "Error: jq executable not found!" >&2 && exit 200
if [ -n "$ENCRYPTION_KEYFILE" ]; then
[ ! -f "$ENCRYPTION_KEYFILE" ] && echo "Error: $ENCRYPTION_KEYFILE: no such file" >&2 && exit 203
[ ! -r "$ENCRYPTION_KEYFILE" ] && echo "Error: $ENCRYPTION_KEYFILE: not readable" >&2 && exit 203
perms=$(stat -c '%a' "$ENCRYPTION_KEYFILE")
(( 8#$perms & 0007 )) && echo "Warning: $ENCRYPTION_KEYFILE is readable by others!" >&2
fi
fi fi
PBC_ARGS=()
KEY_ARGS=()
[ -n "$PBS_NAMESPACE" ] && PBC_ARGS+=('--ns' "$PBS_NAMESPACE")
[ -n "$ENCRYPTION_KEYFILE" ] && KEY_ARGS+=('--keyfile' "$ENCRYPTION_KEYFILE")
export PBS_REPOSITORY="$PBS_USER@$PBS_SERVER:$PBS_DATASTORE" export PBS_REPOSITORY="$PBS_USER@$PBS_SERVER:$PBS_DATASTORE"
export PBS_PASSWORD="$PBS_PASSWORD" export PBS_PASSWORD="$PBS_PASSWORD"
@@ -355,9 +369,10 @@ case "$action" in
target=${args[2]} target=${args[2]}
if [ -z "$target" ]; then if [ -z "$target" ]; then
[ ! -t 0 ] && echo "Error: no target path given and stdin is not a terminal" >&2 && exit 1
while [ -z "$target" ]; do while [ -z "$target" ]; do
echo -n "Target path: " echo -n "Target path: "
read -r target ! read -r target && [ -z "$target" ] && echo "Error: no target path given" >&2 && exit 1
[ -d "$target" ] && break [ -d "$target" ] && break
echo -e "\nError: $target: no such directory\n" >&2 echo -e "\nError: $target: no such directory\n" >&2
target='' target=''
@@ -387,11 +402,9 @@ case "$action" in
fi fi
if [ -f "$SCRIPT_DIR/config.example" ]; then if [ -f "$SCRIPT_DIR/config.example" ]; then
if $update_pbc || [ ! -f "$ETC_DIR/config.example" ]; then
echo "Install example config to $ETC_DIR/config.example ..." echo "Install example config to $ETC_DIR/config.example ..."
! install -m 0640 "$SCRIPT_DIR/config.example" "$ETC_DIR/config.example" && echo "Error installing file!" >&2 && exit 4 ! install -m 0640 "$SCRIPT_DIR/config.example" "$ETC_DIR/config.example" && echo "Error installing file!" >&2 && exit 4
fi fi
fi
echo "Info: successfully installed pbc dependencies" echo "Info: successfully installed pbc dependencies"
;; ;;