Compare commits

..
48 Commits
Author SHA256 Message Date
spacefreak 873601019a always install config.example if available 2026-08-27 23:44:08 +02:00
spacefreak 0ade18796f cleanup source 2026-08-27 23:39:46 +02:00
spacefreak 0f61e177f8 allow installation of a specific tag/branch by exporting PBC_GIT_TAG 2026-08-27 23:29:43 +02:00
spacefreak 2fdf065017 document encryption key handling in README.md 2026-08-27 23:14:49 +02:00
spacefreak 6239fe5ca6 add support for encryption keys 2026-08-27 23:14:18 +02:00
spacefreak bc8287a28c only look for version tags 2026-08-24 01:11:01 +02:00
spacefreak 04d351a859 add WSL section to README.md 2026-08-24 01:07:11 +02:00
spacefreak d5d4ea2430 change README.md 2026-08-24 00:53:49 +02:00
spacefreak e423b97c8a add README.md 2026-08-24 00:40:09 +02:00
spacefreak 01c299e242 fail instead of looping when no mount target can be read 2026-08-22 07:45:56 +02:00
spacefreak 56b619372d report PBC query failures instead of an empty datastore 2026-08-22 07:39:16 +02:00
spacefreak 73202f5e3b extract shared snapshot and archive selection logic 2026-08-22 07:36:30 +02:00
spacefreak d243ea1c89 resolve symlinks for SCRIPT_DIR lookup 2026-08-22 06:58:45 +02:00
spacefreak 0623c6a985 remove duplicate ETC_DIR assignment 2026-08-22 06:54:35 +02:00
spacefreak 4fb949c26e declare variable full as local in usage 2026-08-22 06:51:56 +02:00
spacefreak e065ab2cd7 use dedicated variable update_pbc for the install flag 2026-08-22 06:48:59 +02:00
spacefreak 7421c8f43e remove redundant $ inside arithmetic contexts 2026-08-22 06:43:04 +02:00
spacefreak e184d1c92c test may_select directly instead of $? 2026-08-22 06:41:05 +02:00
spacefreak ee86603dcd forward extra arguments to proxmox-backup-client backup 2026-08-22 06:37:37 +02:00
spacefreak 1eee4b2d1d remove no-op return from the pbc-* wrappers 2026-08-22 06:35:53 +02:00
spacefreak 7555745704 install pbc with an explicit mode and owner 2026-08-22 06:26:20 +02:00
spacefreak 49d2e35106 parse gitea tags without jq 2026-08-22 06:24:26 +02:00
spacefreak 9722741bfd check EUID instead of whoami for root 2026-08-22 06:21:31 +02:00
spacefreak 22abaf6431 rename TMPDIR to WORK_DIR 2026-08-22 06:19:51 +02:00
spacefreak 4e24ee218c send usage to stderr on error paths 2026-08-22 06:17:51 +02:00
spacefreak acac5b685b show usage when no command is given 2026-08-22 06:15:59 +02:00
spacefreak 3869ca5a95 install example config with a restrictive mode 2026-08-22 06:12:38 +02:00
spacefreak 3d5757effc check config file permissions before sourcing 2026-08-22 06:10:50 +02:00
spacefreak 6c7db396b2 make apt-get install non-interactive 2026-08-22 06:07:40 +02:00
spacefreak eeb0c9cb47 install client binary instead of cp 2026-08-22 06:06:29 +02:00
spacefreak 44cc111b3d fix version sorting when determining debian pkg to download 2026-08-22 06:03:06 +02:00
spacefreak f0610091bb fail curl on http errors 2026-08-22 06:00:44 +02:00
spacefreak f93f647504 add guard to may_select in case stdin is not a terminal 2026-08-22 05:54:50 +02:00
spacefreak 32e88beedc fix argument iteration 2026-08-22 05:48:23 +02:00
spacefreak 6b2847fec7 make full_usage aware of possible PBC setting in config 2026-08-22 05:46:16 +02:00
spacefreak 127fb83567 fix executable checks 2026-08-22 05:37:25 +02:00
spacefreak bfa15561d8 use local keywords only once for version variable 2026-08-22 05:34:26 +02:00
spacefreak 22675d1be3 fix error handling 2026-08-22 05:31:27 +02:00
spacefreak 62f6865a06 fix pkg install status check with apt-cache and mention pbs-client repos 2026-08-22 05:20:23 +02:00
spacefreak 331f75a943 replace whish -s with command -v 2026-08-22 05:15:25 +02:00
spacefreak d1791cfcc8 fix exit code and output to stderr in backup-cron command 2026-08-22 05:09:15 +02:00
spacefreak 8f83861965 fix bugs in config.example and install.sh 2026-08-22 02:45:04 +02:00
spacefreak e613532322 change help message 2026-08-22 02:37:32 +02:00
spacefreak d54fe81dc5 fix log messages 2026-08-22 02:33:09 +02:00
spacefreak bf54eab7c7 use safe file mode for example config 2026-08-22 02:19:17 +02:00
spacefreak 6a8b4b86f3 improve log messages 2026-08-22 02:12:13 +02:00
spacefreak 37dbf61f07 change error message 2026-08-22 02:05:17 +02:00
spacefreak c245c4fa1e mark variable rc as local 2026-08-22 02:03:58 +02:00
4 changed files with 494 additions and 165 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).
+2 -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')
+19 -18
View File
@@ -1,51 +1,52 @@
#!/usr/bin/env bash #!/usr/bin/env bash
[ "$(whoami)" != "root" ] && echo "Error: installation only works as root!" >&2 && exit 1 (( EUID != 0 )) && echo "Error: installation only works as root!" >&2 && exit 1
API_ENDPOINT="https://git.ccc-rheintal.ch/api/v1/repos/spacefreak/pbc" API_ENDPOINT="https://git.ccc-rheintal.ch/api/v1/repos/spacefreak/pbc"
TMPDIR='' WORK_DIR=''
function cleanup_tmpdir() { function cleanup_work_dir() {
[ -n "$TMPDIR" -a -d "$TMPDIR" ] && rm -r "$TMPDIR" [ -n "$WORK_DIR" ] && [ -d "$WORK_DIR" ] && rm -r "$WORK_DIR"
trap - EXIT trap - EXIT
} }
function download_files() { function download_files() {
local path 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 [ -n "$1" ]; do while (( $# > 0 )); do
path=$1 path=$1
shift shift
curl -s --output-dir "$TMPDIR/" --remote-name "$API_ENDPOINT/raw/$path$query_params" curl -fsSL --output-dir "$WORK_DIR/" --remote-name "$API_ENDPOINT/raw/$path$query_params"
rc=$? rc=$?
if (( $rc != 0 )); then if (( rc != 0 )); then
return $rc return $rc
fi fi
done done
} }
GIT_TAG=$(curl -s "$API_ENDPOINT/tags" | jq -r '.[] | .name' | 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
TMPDIR=$(mktemp -d) WORK_DIR=$(mktemp -d)
trap cleanup_tmpdir EXIT trap cleanup_work_dir EXIT
! download_files config.example pbc && echo "Error downloading files!" >&2 && exit 10 ! download_files config.example pbc && echo "Error downloading files!" >&2 && exit 10
chmod +x "$TMPDIR/pbc" chmod +x "$WORK_DIR/pbc"
chmod o= "$WORK_DIR/config.example"
"$TMPDIR/pbc" install "$WORK_DIR/pbc" install
rc=$? rc=$?
if (( $? != 0 )); then if (( rc != 0 )); then
echo "Error installing pbc requirements!" >&2 echo "Error installing pbc requirements!" >&2
exit $rc exit $rc
fi fi
! mv "$TMPDIR/pbc" /usr/local/bin/pbc && echo 'Error moving pbc to /usr/local/bin/pbc !' >&2 && exit 20 ! install -m 0755 -o root -g root "$WORK_DIR/pbc" /usr/local/bin/pbc && echo 'Error installing file!' >&2 && exit 20
echo "Success!" echo "Success!"
+158 -146
View File
@@ -1,7 +1,9 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -o pipefail
function usage() { function usage() {
full=${1:-false} local full=${1:-false}
cat <<EOF cat <<EOF
Usage: $SCRIPT [-h] [-H] [-c config] command [OPTIONS] Usage: $SCRIPT [-h] [-H] [-c config] command [OPTIONS]
@@ -22,8 +24,7 @@ snapshot-list [group] list snapshots, sizes and archives
catalog-shell [snapshot [archive]] run interactive shell to explore arhive contents catalog-shell [snapshot [archive]] run interactive shell to explore arhive contents
and restore files/dirs selectively and restore files/dirs selectively
mount [snapshot [archive [target]]] mount snapshot archive locally mount [snapshot [archive [target]]] mount snapshot archive locally
install [--update] install proxmox-backup-client binary and dependencies install [--update] install/update proxmox-backup-client and pdb dependencies
use --update to update the currently installed binary
EOF EOF
! $full && cat <<EOF ! $full && cat <<EOF
@@ -35,53 +36,34 @@ EOF
function full_usage() { function full_usage() {
usage true usage true
[ ! -e "$PBC" ] && echo "Error: $PBC is not executable!" >&2 && return 254 [ ! -x "$PBC" ] && echo "Error: $PBC is not executable!" >&2 && return 254
echo "Commands of proxmox-backup-client:" echo "Commands of proxmox-backup-client:"
echo echo
"$PBC" help | tail -n +3 "$PBC" help | tail -n +3
echo
return $?
} }
function pbc-version() { function pbc-version() {
local vers_info=$("$PBC" version) local vers_info rc
local rc=$? vers_info=$("$PBC" version)
(( $rc != 0 )) && echo "Error running $PBC version ... exited with rc=$rc" >&2 && return $rc rc=$?
(( rc != 0 )) && echo "Error running $PBC version ... exited with rc=$rc" >&2 && return $rc
echo "$vers_info" | grep client echo "$vers_info" | grep client
rc=$? rc=$?
(( $rc != 0 )) && echo "Error reading client version, output was:" >&2 && echo "$vers_info" >&2 (( rc != 0 )) && echo "Error reading client version, output was:" >&2 && echo "$vers_info" >&2
return $rc return $rc
} }
function pbc-backup() { function pbc-backup() { "$PBC" backup "${PBC_ARGS[@]}" "${KEY_ARGS[@]}" "${BACKUP[@]}" "$@"; }
"$PBC" backup --ns "$PBS_NAMESPACE" "${BACKUP[@]}"
return $?
}
function pbc-list() { function pbc-list() { "$PBC" list "${PBC_ARGS[@]}" "$@"; }
"$PBC" list --ns "$PBS_NAMESPACE" "$@"
return $?
}
function pbc-snapshot-list() { function pbc-snapshot-list() { "$PBC" snapshot list "${PBC_ARGS[@]}" "$@"; }
"$PBC" snapshot list --ns "$PBS_NAMESPACE" "$@"
return $?
}
function pbc-files() { function pbc-files() { "$PBC" files "${PBC_ARGS[@]}" "$@"; }
"$PBC" files --ns "$PBS_NAMESPACE" "$@"
return $?
}
function pbc-catalog-shell() { function pbc-catalog-shell() { "$PBC" catalog shell "${PBC_ARGS[@]}" "${KEY_ARGS[@]}" "$@"; }
"$PBC" catalog shell --ns "$PBS_NAMESPACE" "$@"
return $?
}
function pbc-mount() { function pbc-mount() { "$PBC" mount "${PBC_ARGS[@]}" "${KEY_ARGS[@]}" "$@"; }
"$PBC" mount --ns "$PBS_NAMESPACE" "$@"
return $?
}
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"])"'
@@ -98,72 +80,73 @@ function get_snapshot_files() {
function apt_get_install() { function apt_get_install() {
local package=$1 local package=$1
echo "Installing $package using package manager ..." echo "Installing $package using package manager ..."
apt-get install "$package" >/dev/null DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "$package" >/dev/null
local rc=$? local rc=$?
(( $rc != 0 )) && echo "Error installing package!" >&2 (( rc != 0 )) && echo "Error installing package!" >&2
return $rc return $rc
} }
TMPDIR='' function cleanup_work_dir() {
function cleanup_tmpdir() { [ -n "$WORK_DIR" ] && [ -d "$WORK_DIR" ] && rm -r "$WORK_DIR"
[ -n "$TMPDIR" -a -d "$TMPDIR" ] && rm -r "$TMPDIR"
trap - EXIT trap - EXIT
} }
function install_proxmox_backup_client() { function install_proxmox_backup_client() {
local update=${1:-false} local update=${1:-false}
local version
[ -f "$PBC" ] && local installed=true || local installed=false [ -f "$PBC" ] && local installed=true || local installed=false
! $installed && update=false ! $installed && update=false
if $installed && ! $update; then if $installed && ! $update; then
local version=$("$PBC" version | head -n 1) version=$(pbc-version) || return 10
(( $? != 0 )) && echo "Error running $PBC version: exited with rc=$?" >&2 && return 10 echo "Info: skip installation of proxmox-backup-client, already installed ($version)"
echo "Skip installation of proxmox-backup-client, already installed ($version)"
return 0 return 0
fi fi
if $update && dpkg -S "$PBC" &>/dev/null; then if $update && dpkg -S "$PBC" &>/dev/null; then
echo "Skip update of proxmox-backup-client, package is managed by the package manager" echo "Info: skip update of proxmox-backup-client, package is managed by the package manager"
return 0 return 0
fi fi
if apt-get search proxmox-backup-client &>/dev/null; then if apt-cache show proxmox-backup-client &>/dev/null; then
apt_get_install proxmox-backup-client apt_get_install proxmox-backup-client
return $? return $?
fi fi
echo "Info: proxmox-backup-client is not available from any configured apt repository"
echo "Info: add the Proxmox pbs-client repository to install a signed, apt-managed package"
$update && local action='Updating' || local action='Installing' $update && local action='Updating' || local action='Installing'
echo "$action proxmox-backup-client-static binary ..." echo "$action proxmox-backup-client-static binary ..."
local pkgfile=$(curl -s "$PBC_DOWNLOAD_URL/" | grep proxmox-backup-client-static_ | tail -n 1 | sed 's#.*href="##g;s#".*##g;') local pkgfile=$(curl -fsSL "$PBC_DOWNLOAD_URL/" | grep proxmox-backup-client-static_ | sed 's#.*href="##g;s#".*##g;' | sort --version-sort | tail -n 1)
[ -z "$pkgfile" ] && echo "Unable to determine current package file!" >&2 && return 30 [ -z "$pkgfile" ] && echo "Unable to determine current package file!" >&2 && return 30
TMPDIR=$(mktemp -d) WORK_DIR=$(mktemp -d)
trap cleanup_tmpdir EXIT trap cleanup_work_dir EXIT
echo "Downloading $PBC_DOWNLOAD_URL/$pkgfile ..." echo "Info: downloading $PBC_DOWNLOAD_URL/$pkgfile ..."
! curl -s -o "$TMPDIR/$pkgfile" "$PBC_DOWNLOAD_URL/$pkgfile" && echo "Error downloading file!" >&2 && return 40 ! curl -fsSL -o "$WORK_DIR/$pkgfile" "$PBC_DOWNLOAD_URL/$pkgfile" && echo "Error downloading file!" >&2 && return 40
echo "Extracting $pkgfile ..." echo "Info: extracting $pkgfile ..."
! dpkg-deb -x "$TMPDIR/$pkgfile" "$TMPDIR/" && echo "Error extracting file!" >&2 && return 50 ! dpkg-deb -x "$WORK_DIR/$pkgfile" "$WORK_DIR/" && echo "Error extracting file!" >&2 && return 50
echo "Copying proxmox-backup-client static binary to $PBC ..." echo "Info: installing proxmox-backup-client static binary to $PBC ..."
! cp "$TMPDIR/usr/bin/proxmox-backup-client" "$PBC" && echo "Error copying binary!" >&2 && return 60 ! install -m 0755 -D "$WORK_DIR/usr/bin/proxmox-backup-client" "$PBC" && echo "Error installing binary!" >&2 && return 60
local version=$(pbc-version) || return $? version=$(pbc-version) || return $?
$update && action='updated' || action='installed' $update && action='updated' || action='installed'
echo "Successfully $action proxmox-backup-$version" echo "Info: successfully $action proxmox-backup-$version"
cleanup_tmpdir cleanup_work_dir
} }
function arr_contains() { function arr_contains() {
local search=$1 local search=$1
shift shift
[ -z "$search" ] && return 1 [ -z "$search" ] && return 1
while [ -n "$1" ]; do while (( $# > 0 )); do
[ "$1" == "$search" ] && return 0 [ "$1" == "$search" ] && return 0
shift shift
done done
@@ -179,8 +162,12 @@ function may_select() {
if [ -z "$value" ]; then if [ -z "$value" ]; then
if (( $# == 1 )); then if (( $# == 1 )); then
value=$1 value=$1
elif [ ! -t 0 ]; then
echo "Error: no value given and stdin is not a terminal" >&2
return 1
else else
local sorted=($(printf "%s\n" "$@" | sort)) local sorted
readarray -t sorted < <(printf '%s\n' "$@" | sort)
select value in "${sorted[@]}"; do select value in "${sorted[@]}"; do
[ -n "$value" ] && break [ -n "$value" ] && break
done done
@@ -189,24 +176,54 @@ function may_select() {
return 1 return 1
fi fi
[ -z "$value" ] && return 1
echo "$value" echo "$value"
return 0 }
function select_group() {
local groups raw
raw=$(get_group_names) || { echo "Error: failed to query PBS!" >&2; return 1; }
[ -z "$raw" ] && return 2
readarray -t groups <<< "$raw"
PS3="Snapshot group [1-${#groups[@]}]: "
GROUP=$(may_select "$1" "${groups[@]}") || { echo "Error: invalid group" >&2; return 1; }
}
function select_snapshot_and_archive() {
local snapshots archives raw
raw=$(get_snapshot_names) || { echo "Error: failed to query PBS!" >&2; return 1; }
[ -z "$raw" ] && return 2
readarray -t snapshots <<< "$raw"
PS3="Snapshot [1-${#snapshots[@]}]: "
SNAPSHOT=$(may_select "$1" "${snapshots[@]}") || { echo "Error: invalid snapshot" >&2; return 1; }
raw=$(get_snapshot_files "$SNAPSHOT") || { echo "Error: failed to query PBS!" >&2; return 1; }
[ -z "$raw" ] && return 3
readarray -t archives <<< "$raw"
PS3="Archive [1-${#archives[@]}]: "
ARCHIVE=$(may_select "$2" "${archives[@]}") || { echo "Error: invalid archive" >&2; return 1; }
} }
SCRIPT_PATH=$(realpath -s "$0") SCRIPT_PATH=$(realpath -s "$0")
SCRIPT_DIR=$(dirname "$SCRIPT_PATH") SCRIPT_DIR=$(dirname "$(realpath "$0")")
SCRIPT=$(basename "$SCRIPT_PATH") SCRIPT=$(basename "$SCRIPT_PATH")
PBC=$(which proxmox-backup-client) PBC=$(command -v proxmox-backup-client)
PBC_DOWNLOAD_URL='http://download.proxmox.com/debian/pbs-client/dists/trixie/main/binary-amd64' PBC_DOWNLOAD_URL='http://download.proxmox.com/debian/pbs-client/dists/trixie/main/binary-amd64'
ETC_DIR="/etc/$SCRIPT" ETC_DIR="/etc/$SCRIPT"
full_help=false
update_pbc=false
action='' action=''
cfgfile="$ETC_DIR/config" cfgfile="$ETC_DIR/config"
args=() args=()
while [ -n "$1" ]; do while (( $# > 0 )); do
opt=$1 opt=$1
shift shift
if [ -z "$action" ]; then if [ -z "$action" ]; then
@@ -214,20 +231,19 @@ while [ -n "$1" ]; do
-c|--config) -c|--config)
cfgfile=$1 cfgfile=$1
shift shift
[ -z "$cfgfile" ] && usage && exit 255 [ -z "$cfgfile" ] && echo "Error: missing argument for $opt" >&2 && usage >&2 && exit 255
;; ;;
-h|--help) -h|--help)
usage usage
exit 0 exit 0
;; ;;
-H|--full-help) -H|--full-help)
full_usage full_help=true
exit 0
;; ;;
-*) -*)
echo "Error: invalid option: $opt" >&2 echo "Error: invalid option: $opt" >&2
echo echo >&2
usage usage >&2
exit 255 exit 255
;; ;;
*) *)
@@ -237,18 +253,18 @@ while [ -n "$1" ]; do
elif [ "$action" == "install" ]; then elif [ "$action" == "install" ]; then
case "$opt" in case "$opt" in
-u|--update) -u|--update)
args+=('true') update_pbc=true
;; ;;
-*) -*)
echo "Error: invalid option: $opt" >&2 echo "Error: invalid option: $opt" >&2
echo echo >&2
usage usage >&2
exit 255 exit 255
;; ;;
*) *)
echo "Error: invalid argument: $opt" >&2 echo "Error: invalid argument: $opt" >&2
echo echo >&2
usage usage >&2
exit 255 exit 255
;; ;;
esac esac
@@ -257,104 +273,106 @@ while [ -n "$1" ]; do
fi fi
done done
if [ -f "$cfgfile" ]; then
perms=$(stat -c '%a' "$cfgfile")
(( 8#$perms & 0022 )) && echo "Error: $cfgfile is writable by group or others!" >&2 && exit 202
(( 8#$perms & 0007 )) && echo "Warning: $cfgfile is readable by others!" >&2
fi
if $full_help; then
[ -f "$cfgfile" ] && source "$cfgfile"
PBC=${PBC:-/usr/local/bin/proxmox-backup-client}
full_usage
exit $?
fi
[ -z "$action" ] && usage >&2 && exit 255
if [ "$action" == 'install' ]; then if [ "$action" == 'install' ]; then
if [ -f "$cfgfile" ]; then if [ -f "$cfgfile" ]; then
source "$cfgfile" || exit $? source "$cfgfile" || exit $?
fi fi
PBC=${PBC:-/usr/local/bin/proxmox-backup-client} PBC=${PBC:-/usr/local/bin/proxmox-backup-client}
for exe in apt-get dpkg dpkg-deb curl; do for exe in apt-cache apt-get dpkg dpkg-deb curl; do
! which -s "$exe" && echo "Error: $exe executable not found!" >&2 && exit 200 ! command -v "$exe" >/dev/null && echo "Error: $exe executable not found!" >&2 && exit 200
done done
else else
[ ! -f "$cfgfile" ] && echo "Error: $cfgfile: no such file" >&2 && exit 201
[ ! -r "$cfgfile" ] && echo "Error: $cfgfile: not readable" >&2 && exit 201
source "$cfgfile" || exit $? source "$cfgfile" || exit $?
PBC=${PBC:-/usr/local/bin/proxmox-backup-client} PBC=${PBC:-/usr/local/bin/proxmox-backup-client}
[ ! -f "$PBC" ] && echo "Error: $PBC: no such file" >&2 && exit 200 [ ! -f "$PBC" ] && echo "Error: $PBC: no such file" >&2 && exit 200
[ ! -e "$PBC" ] && echo "Error: $PBC: not executable" >&2 && exit 200 [ ! -x "$PBC" ] && echo "Error: $PBC: not executable" >&2 && exit 200
! which -s jq && 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
export PBS_REPOSITORY="$PBS_USER@${PBS_SERVER:-8007}:$PBS_DATASTORE" 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_PASSWORD="$PBS_PASSWORD" export PBS_PASSWORD="$PBS_PASSWORD"
case "$action" in case "$action" in
backup) backup)
pbc-backup || exit $? pbc-backup "${args[@]}" || exit $?
;; ;;
backup-cron) backup-cron)
output=$(pbc-backup 2>&1) output=$(pbc-backup "${args[@]}" 2>&1)
rc=$? rc=$?
(( $rc != 0 )) && echo "$output" (( rc != 0 )) && echo "$output" >&2
exit $rc
;; ;;
list) list)
pbc-list "${args[@]}" || exit $? pbc-list "${args[@]}" || exit $?
;; ;;
snapshot-list) snapshot-list)
readarray -t groups < <(get_group_names) select_group "${args[0]}"
(( ${#groups[@]} == 0 )) && echo "no snapshot groups found" && exit 0 case $? in
0) ;;
group=${args[0]} 2) echo "no snapshot groups found"; exit 0 ;;
args=("${args[@]:1}") *) exit 1 ;;
esac
PS3="Snapshot group [1-${#groups[@]}]: "
group=$(may_select "$group" "${groups[@]}")
(( $? != 0 )) && echo "Error: invalid group" >&2 && exit 1
echo echo
pbc-snapshot-list "$group" "${args[@]}" || exit $? pbc-snapshot-list "$GROUP" "${args[@]:1}" || exit $?
;; ;;
catalog-shell) catalog-shell)
readarray -t snapshots < <(get_snapshot_names) select_snapshot_and_archive "${args[0]}" "${args[1]}"
(( ${#snapshots[@]} == 0 )) && echo "no snapshots found" && exit 0 case $? in
0) ;;
snapshot=${args[0]} 2) echo "no snapshots found"; exit 0 ;;
args=("${args[@]:1}") 3) echo "no archives found"; exit 0 ;;
*) exit 1 ;;
PS3="Snapshot [1-${#snapshots[@]}]: " esac
snapshot=$(may_select "$snapshot" "${snapshots[@]}")
(( $? != 0 )) && echo "Error: invalid snapshot" >&2 && exit 1
readarray -t archives < <(get_snapshot_files "$snapshot")
(( ${#archives[@]} == 0 )) && echo "no archives found" && exit 0
archive=${args[0]}
args=("${args[@]:1}")
PS3="Archive [1-${#archives[@]}]: "
archive=$(may_select "$archive" "${archives[@]}")
(( $? != 0 )) && echo "Error: invalid archive" >&2 && exit 1
echo echo
pbc-catalog-shell "$snapshot" "$archive" || exit $? pbc-catalog-shell "$SNAPSHOT" "$ARCHIVE" || exit $?
;; ;;
mount) mount)
readarray -t snapshots < <(get_snapshot_names) select_snapshot_and_archive "${args[0]}" "${args[1]}"
(( ${#snapshots[@]} == 0 )) && echo "no snapshots found" && exit 0 case $? in
0) ;;
2) echo "no snapshots found"; exit 0 ;;
3) echo "no archives found"; exit 0 ;;
*) exit 1 ;;
esac
snapshot=${args[0]} target=${args[2]}
args=("${args[@]:1}")
PS3="Snapshot [1-${#snapshots[@]}]: "
snapshot=$(may_select "$snapshot" "${snapshots[@]}")
(( $? != 0 )) && echo "Error: invalid snapshot" >&2 && exit 1
readarray -t archives < <(get_snapshot_files "$snapshot")
(( ${#archives[@]} == 0 )) && echo "no archives found" && exit 0
archive=${args[0]}
args=("${args[@]:1}")
PS3="Archive [1-${#archives[@]}]: "
archive=$(may_select "$archive" "${archives[@]}")
(( $? != 0 )) && echo "Error: invalid archive" >&2 && exit 1
target=${args[0]}
args=("${args[@]:1}")
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=''
@@ -365,36 +383,30 @@ case "$action" in
fi fi
echo echo
pbc-mount "$snapshot" "$archive" "$target" || exit $? pbc-mount "$SNAPSHOT" "$ARCHIVE" "$target" || exit $?
;; ;;
install) install)
update=${args[0]:-false} (( EUID != 0 )) && echo "Error: installation only works as root!" >&2 && exit 1
[ "$(whoami)" != "root" ] && echo "Error: installation only works as root!" >&2 && exit 1 install_proxmox_backup_client "$update_pbc" || exit $?
install_proxmox_backup_client "$update" || exit $?
if dpkg -s jq &>/dev/null; then if dpkg -s jq &>/dev/null; then
$update && echo "Skip update of jq, package is managed by package manager" || echo "Skip installation of jq, already installed" $update_pbc && echo "Info: skip update of jq, package is managed by package manager" || echo "Info: jq is already installed"
else else
apt_get_install jq || exit $? apt_get_install jq || exit $?
fi fi
ETC_DIR="/etc/$SCRIPT"
if [ ! -d "$ETC_DIR" ]; then if [ ! -d "$ETC_DIR" ]; then
echo "Creating config directory $ETC_DIR ..." echo "Creating config directory $ETC_DIR ..."
! mkdir -p "$ETC_DIR" && echo "Error creating directory!" >&2 && exit 3 ! mkdir -p "$ETC_DIR" && echo "Error creating directory!" >&2 && exit 3
fi fi
if [ -f "$SCRIPT_DIR/config.example" ]; then if [ -f "$SCRIPT_DIR/config.example" ]; then
if $update || [ ! -f "$ETC_DIR/config.example" ]; then echo "Install example config to $ETC_DIR/config.example ..."
echo "Copy 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
! cp "$SCRIPT_DIR/config.example" "$ETC_DIR/config.example" && echo "Error copying file!" >&2 && exit 4
fi
fi fi
echo "Success!" echo "Info: successfully installed pbc dependencies"
;; ;;
*) *)
"$PBC" "$action" "${args[@]}" || exit $? "$PBC" "$action" "${args[@]}" || exit $?