Compare commits

...

15 Commits

Author SHA1 Message Date
f70bffed37 misc: ignore .venv dir 2021-10-16 09:37:04 -04:00
979dfd892f backup: revert to catching fewer exceptions
We specifically don't want to catch BrokenPipeError; just list
file-related ones that we might expect to see if we hit bad
permissions, disk errors, or race conditions.
2021-10-16 09:37:04 -04:00
ab6dce0c2c borg: update binary to fix upstream bug 6009 2021-10-16 09:37:04 -04:00
aff447c1b6 notify: fix notify.sh to work with server side; adjust text 2021-10-16 09:37:03 -04:00
f7e9c3e232 borg.sh: only try ssh keys, not password authentication 2021-10-16 09:37:03 -04:00
d168c5bf54 backup: catch all OSError exceptions while accessing files
We might see these if files change during the scan, for example.
2021-10-16 09:37:03 -04:00
31d88f9345 backup: print final results and run notification script on error 2021-10-16 09:37:03 -04:00
ccf54b98d7 backup: fix archive name
Was overly quoted from when this was a shell script
2021-10-16 09:37:03 -04:00
59ad2b5b4d backup: capture borg output for later reporting 2021-10-16 09:37:03 -04:00
0c74f1676c backup: add bold option to log(); simplify logic 2021-10-16 09:37:03 -04:00
5e06ebd822 backup: change some warnings into errors 2021-10-16 09:37:03 -04:00
929a323cf0 notify: add ssh key for running remote notifications; add notify.sh 2021-10-16 09:37:03 -04:00
86bb72f201 setup: fix borg path in initial connection test 2021-10-16 09:37:03 -04:00
54437456ae prune: use new vars.sh 2021-10-16 09:37:03 -04:00
c7a6d08665 initial-setup: generate vars.sh instead of borg.sh; commit borg.sh
Put setup-time variables into a generated vars.sh, and put borg.sh
directly into the repo.
2021-10-16 09:36:56 -04:00
8 changed files with 183 additions and 50 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
.venv
*.html
cache/
config/

BIN
Borg.bin

Binary file not shown.

View File

@ -123,6 +123,8 @@ Notes
pip install -e .[llfuse]
pyinstaller --clean --noconfirm scripts/borg.exe.spec
Then see `dist/borg.exe`. Confirm the version with `dist/borg.exe --version`.
*Note:* This uses the deprecated `llfuse` instead of the newer `pyfuse3`.
`pyfuse3` doesn't work because, at minimum, it pulls in `trio` which
requires `ssl` which is explicitly excluded by

122
backup.py
View File

@ -10,7 +10,9 @@ import re
import sys
import stat
import time
import select
import pathlib
import threading
import subprocess
import typing
@ -105,19 +107,17 @@ class Backup:
self.dry_run = dry_run
self.root_seen: dict[bytes, bool] = {}
# All logged messages, with severity
# Saved log messages
self.logs: list[tuple[str, str]] = []
def out(self, path: bytes):
self.outfile.write(path + (b'\n' if self.dry_run else b'\0'))
def log(self, letter: str, msg: str):
def log(self, letter: str, msg: str, bold: bool=False):
colors = { 'E': 31, 'W': 33, 'I': 36 };
if letter in colors:
c = colors[letter]
else:
c = 0
sys.stderr.write(f"\033[1;{c}m{letter}:\033[22m {msg}\033[0m\n")
c = colors[letter] if letter in colors else 0
b = "" if bold else "\033[22m"
sys.stderr.write(f"\033[1;{c}m{letter}:{b} {msg}\033[0m\n")
self.logs.append((letter, msg))
def run(self, outfile: typing.IO[bytes]):
@ -132,10 +132,10 @@ class Backup:
if not stat.S_ISDIR(st.st_mode):
raise NotADirectoryError
except FileNotFoundError:
self.log('W', f"ignoring root, does not exist: {pstr(root)}")
self.log('E', f"root does not exist: {pstr(root)}")
continue
except NotADirectoryError:
self.log('W', f"ignoring root, not a directory: {pstr(root)}")
self.log('E', f"root is not a directory: {pstr(root)}")
continue
self.log('I', f"processing root {pstr(root)}")
@ -219,8 +219,11 @@ class Backup:
for entry in it:
self.scan(path=entry.path, parent_st=st)
except PermissionError as e:
self.log('E', f"can't read {pstr(path)}")
except (FileNotFoundError,
IsADirectoryError,
NotADirectoryError,
PermissionError) as e:
self.log('E', f"can't read {pstr(path)}: {str(e)}")
return
def main(argv: list[str]):
@ -229,6 +232,7 @@ def main(argv: list[str]):
def humansize(string):
return humanfriendly.parse_size(string)
# Parse args
parser = argparse.ArgumentParser(
prog=argv[0],
description="Back up the local system using borg",
@ -239,6 +243,8 @@ def main(argv: list[str]):
help="Config file", default=str(base / "config.yaml"))
parser.add_argument('-b', '--borg',
help="Borg command", default=str(base / "borg.sh"))
parser.add_argument('-N', '--notify',
help="Notify command", default=str(base / "notify.sh"))
parser.add_argument('-n', '--dry-run', action="store_true",
help="Just print log output, don't run borg")
parser.add_argument('-d', '--debug', action="store_true",
@ -247,13 +253,17 @@ def main(argv: list[str]):
args = parser.parse_args()
config = Config(args.config)
# Run backup
backup = Backup(config, args.dry_run)
captured_output: list[bytes] = []
if args.dry_run:
if args.debug:
backup.run(sys.stdout.buffer)
else:
with open(os.devnull, "wb") as out:
backup.run(out)
sys.stdout.flush()
else:
borg = subprocess.Popen([args.borg,
"create",
@ -265,13 +275,34 @@ def main(argv: list[str]):
"--compression", "zstd,3",
"--paths-from-stdin",
"--paths-delimiter", "\\0",
"::'{hostname}-{now:%Y%m%d-%H%M%S}'"],
stdin=subprocess.PIPE)
"::{hostname}-{now:%Y%m%d-%H%M%S}"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT)
if borg.stdin is None:
raise Exception("no pipe")
# Use a thread to capture output
def reader_thread(fh):
os.set_blocking(fh.fileno(), False)
while True:
ready = select.select([fh.fileno()], [], [])
if not len(ready[0]):
break
data = fh.read(8192)
if not len(data):
break
sys.stdout.buffer.write(data)
sys.stdout.flush()
captured_output.append(data)
fh.close()
reader = threading.Thread(target=reader_thread, args=(borg.stdout,))
reader.daemon = True
reader.start()
try:
# Give borg some time to start, just to clean up stdout
time.sleep(2)
time.sleep(1)
backup.run(borg.stdin)
except BrokenPipeError:
sys.stderr.write(f"broken pipe\n")
@ -281,14 +312,69 @@ def main(argv: list[str]):
except BrokenPipeError:
pass
borg.wait()
reader.join()
ret = borg.returncode
if ret < 0:
sys.stderr.write(f"error: process exited with signal {-ret}\n")
return 1
backup.log('E', f"borg exited with signal {-ret}")
elif ret != 0:
sys.stderr.write(f"error: process exited with return code {ret}\n")
return ret
backup.log('E', f"borg exited with return code {ret}")
# See if we had any errors
warnings = sum(1 for (letter, msg) in backup.logs if letter == 'W')
errors = sum(1 for (letter, msg) in backup.logs if letter == 'E')
def plural(num: int, word: str) -> str:
suffix = "" if num == 1 else "s"
return f"{num} {word}{suffix}"
warnmsg = plural(warnings, "warning") if warnings else None
errmsg = plural(errors, "error") if errors else None
if not warnings and not errors:
backup.log('I', f"backup successful", bold=True)
else:
if warnmsg:
backup.log('W', f"reported {warnmsg}", bold=True)
if errors:
backup.log('E', f"reported {errmsg}", bold=True)
# Send a notification of errors
email = backup.config.notify_email
if email and not args.dry_run:
backup.log('I', f"sending error notification to {email}")
# Show all of our warnings and errors. Use a ">" prefix
# so warnings and errors get highlighted by the mail reader.
body = [ "Logs from backup.py:" ]
for (letter, msg) in backup.logs:
if letter == "E" or letter == "W":
prefix = ">"
else:
prefix = " "
body.append(f"{prefix}{letter}: {msg}")
body_text = "\n".join(body).encode()
# Followed by borg output
body_text += b"\n\nBorg output:\n" + b"".join(captured_output)
# Subject summary
if errmsg and warnmsg:
summary = f"{errmsg}, {warnmsg}"
elif errors:
summary = errmsg
else:
summary = warnmsg
# Call notify.sh
res = subprocess.run([args.notify, summary, email], input=body_text)
if res.returncode != 0:
backup.log('E', f"failed to send notification")
errors += 1
# Exit with an error code if we had any errors
if errors:
return 1
return 0
if __name__ == "__main__":

21
borg.sh Executable file
View File

@ -0,0 +1,21 @@
#!/bin/bash
set -e
. "$(dirname "$0")"/vars.sh
export BORG_PASSCOMMAND="cat ${BORG_DIR}/passphrase"
export BORG_BASE_DIR=${BORG_DIR}
export BORG_CACHE_DIR=${BORG_DIR}/cache
export BORG_CONFIG_DIR=${BORG_DIR}/config
if [ "$1" = "--rw" ] ; then
if [ "$BORG_RW_KEY_ADDED" != "1" ] ; then
echo "=== Need SSH key passphrase. Check Bitwarden for:"
echo "=== borg $HOSTNAME / read-write SSH key"
fi
export BORG_RSH="ssh -F $SSH/config -o BatchMode=no -o PreferredAuthentication=publickey -i $SSH/id_ecdsa"
shift
else
export BORG_RSH="ssh -F $SSH/config -i $SSH/id_ecdsa_appendonly"
fi
exec "${BORG_BIN}" "$@"

View File

@ -72,38 +72,32 @@ setup_venv()
pipenv install
}
# Create wrapper to execute borg
create_borg_wrapper()
# Create shell script with environment variables
create_borg_vars()
{
BORG=${BORG_DIR}/borg.sh
VARS=${BORG_DIR}/vars.sh
# These variables are used elsewhere in this script
BORG_REPO="ssh://${BACKUP_USER}@${BACKUP_HOST}/./${BACKUP_REPO}"
BORG=${BORG_DIR}/borg.sh
SSH=$BORG_DIR/ssh
cat >"$BORG" <<EOF
#!/bin/sh
cat >"$VARS" <<EOF
export BACKUP_USER=${BACKUP_USER}
export BACKUP_HOST=${BACKUP_HOST}
export BACKUP_REPO=${BACKUP_REPO}
export HOSTNAME=$(hostname)
export BORG_REPO=${BORG_REPO}
export BORG_HOST_ID=${HOSTID}
export BORG_PASSCOMMAND="cat ${BORG_DIR}/passphrase"
export BORG_HOST_ID=${HOSTID}
export BORG_BASE_DIR=${BORG_DIR}
export BORG_CACHE_DIR=${BORG_DIR}/cache
export BORG_CONFIG_DIR=${BORG_DIR}/config
if [ "\$1" = "--rw" ] ; then
if [ "$BORG_RW_KEY_ADDED" != "1" ] ; then
echo "=== Need SSH key passphrase. Check Bitwarden for:"
echo "=== borg $(hostname) / read-write SSH key"
fi
export BORG_RSH="ssh -F $SSH/config -o BatchMode=no -i $SSH/id_ecdsa"
shift
else
export BORG_RSH="ssh -F $SSH/config -i $SSH/id_ecdsa_appendonly"
fi
exec "${BORG_BIN}" "\$@"
export BORG_DIR=${BORG_DIR}
export SSH=${SSH}
export BORG=${BORG}
export BORG_BIN=${BORG_BIN}
EOF
chmod +x "$BORG"
if ! "$BORG" -h >/dev/null ; then
error "Can't run the new borg wrapper; does borg work?"
error "Can't run the borg wrapper; does borg work?"
fi
}
@ -136,6 +130,8 @@ configure_ssh()
log "Creating SSH keys"
ssh-keygen -N "" -t ecdsa \
-C "backup-appendonly@$HOSTID" -f "$SSH/id_ecdsa_appendonly"
ssh-keygen -N "" -t ecdsa \
-C "backup-notify@$HOSTID" -f "$SSH/id_ecdsa_notify"
ssh-keygen -N "$PASS_SSH" -t ecdsa \
-C "backup@$HOSTID" -f "$SSH/id_ecdsa"
@ -173,7 +169,8 @@ EOF
# Copy SSH keys to the server's authorized_keys file, removing any
# existing keys with this HOSTID.
log "Setting up SSH keys on remote host"
cmd="borg/borg serve --restrict-to-repository ~/$BACKUP_REPO"
REMOTE_BORG="borg/borg"
cmd="$REMOTE_BORG serve --restrict-to-repository ~/$BACKUP_REPO"
keys=".ssh/authorized_keys"
backup="${keys}.old-$(date +%Y%m%d-%H%M%S)"
@ -182,14 +179,14 @@ EOF
run_ssh_command "if cmp -s $backup $keys; then rm $backup ; fi"
run_ssh_command "cat >> .ssh/authorized_keys" <<EOF
command="$cmd --append-only",restrict $(cat "$SSH/id_ecdsa_appendonly.pub")
command="borg/notify.sh",restrict $(cat "$SSH/id_ecdsa_appendonly.pub")
command="borg/notify.sh",restrict $(cat "$SSH/id_ecdsa_notify.pub")
command="$cmd",restrict $(cat "$SSH/id_ecdsa.pub")
EOF
# Test that everything worked
log "Testing SSH login with new key"
if ! ssh -F "$SSH/config" -i "$SSH/id_ecdsa_appendonly" -T \
"${BACKUP_USER}@${BACKUP_HOST}" borg --version </dev/null ; then
"${BACKUP_USER}@${BACKUP_HOST}" "$REMOTE_BORG" --version </dev/null ; then
error "Logging in with a key failed -- is server set up correctly?"
fi
log "Remote connection OK!"
@ -297,7 +294,7 @@ git_setup()
fi
log "Committing local changes to git"
git add README.md borg-backup.service borg-backup.timer borg.sh
git add README.md borg-backup.service borg-backup.timer vars.sh
git commit -a -m "autocommit after initial setup on $(hostname)"
}
@ -307,7 +304,7 @@ log " Backup server user: ${BACKUP_USER}"
log " Repository path: ${BACKUP_REPO}"
setup_venv
create_borg_wrapper
create_borg_vars
generate_keys
configure_ssh
create_repo

26
notify.sh Executable file
View File

@ -0,0 +1,26 @@
#!/bin/bash
set -e
. "$(dirname "$0")"/vars.sh
# Send notification email using a script on the backup host
# First argument is our hostname, second argument is destination;
# mail body is provided on stdin.
if tty -s ; then
echo 'Refusing to read mail body from terminal'
exit 1
fi
SUMMARY="$1"
EMAIL="$2"
# Remote notify.sh wants subject as first line, not as an argument,
# since it's a bit messy to pass complex strings through ssh command
# lines.
( echo "backup $HOSTNAME: $SUMMARY" ; cat ) | \
ssh \
-F "$SSH/config" \
-i "$SSH/id_ecdsa_notify" \
"$BACKUP_USER@$BACKUP_HOST" \
borg/notify.sh "$EMAIL"

View File

@ -1,7 +1,7 @@
#!/bin/bash
BORG="$(dirname "$0")/borg.sh --rw"
set -e
. "$(dirname "$0")"/vars.sh
if [ "$BORG_RW_KEY_ADDED" != "1" ] ; then
echo "Re-executing under a new ssh agent"
@ -9,10 +9,10 @@ if [ "$BORG_RW_KEY_ADDED" != "1" ] ; then
fi
echo "=== Please enter SSH key passphrase. Check Bitwarden for:"
echo "=== borg basis / read-write SSH key"
echo "=== borg $HOSTNAME / read-write SSH key"
ssh-add -v "$(realpath "$(dirname "$0")")/ssh/id_ecdsa"
$BORG prune \
$BORG --rw prune \
--verbose \
--progress \
--stats \
@ -21,6 +21,6 @@ $BORG prune \
--keep-weekly=8 \
--keep-monthly=-1
$BORG compact \
$BORG --rw compact \
--verbose \
--progress