Compare commits

...

3 Commits

8 changed files with 478 additions and 165 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
*.html

View File

@ -1,17 +1,35 @@
.PHONY: all
all: check
@echo "Use 'make deploy' to copy to https://psy.jim.sh/borg-setup.sh"
all:
@echo
@echo "For initial setup, run"
@echo " sudo ./initial-setup.sh"
@echo
@echo "Or run borg commands with e.g.:"
@echo " ./borg.sh info"
@echo " ./borg.sh list"
@echo
.PHONY: check
check:
shellcheck -f gcc borg-setup.sh
.PHONY: ctrl
ctrl: test-setup
.PHONY: test
test:
.venv:
mkdir .venv
pipenv install --dev
.PHONY: test-backup
test-backup: .venv
.venv/bin/mypy backup.py
./backup.py --max-size 1GiB --one-file-system /tmp | grep -a 'bigf'
.PHONY: test-setup
test-setup:
shellcheck -f gcc initial-setup.sh
rm -rf /tmp/test-borg
BORG_DIR=/tmp/test-borg ./borg-setup.sh
ls -al /tmp/test-borg
mkdir /tmp/test-borg
: "normally this would be a git clone, but we want the working tree..."
git ls-files -z | tar --null -T - -cf - | tar -C /tmp/test-borg -xvf -
/tmp/test-borg/initial-setup.sh
.PHONY: deploy
deploy:
scp borg-setup.sh psy:/www/psy
.PHONY: clean
clean:
rm -f README.html

13
Pipfile Normal file
View File

@ -0,0 +1,13 @@
[[source]]
url = "https://pypi.python.org/simple"
verify_ssl = true
name = "pypi"
[packages]
humanfriendly = "*"
[dev-packages]
mypy = "*"
[requires]
python_version = "3"

81
Pipfile.lock generated Normal file
View File

@ -0,0 +1,81 @@
{
"_meta": {
"hash": {
"sha256": "4f504c785e3ed5b203a82a5f40516507f80a01b8d1d0ad5a905f139cafc41a51"
},
"pipfile-spec": 6,
"requires": {
"python_version": "3"
},
"sources": [
{
"name": "pypi",
"url": "https://pypi.python.org/simple",
"verify_ssl": true
}
]
},
"default": {
"humanfriendly": {
"hashes": [
"sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477",
"sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc"
],
"index": "pypi",
"version": "==10.0"
}
},
"develop": {
"mypy": {
"hashes": [
"sha256:088cd9c7904b4ad80bec811053272986611b84221835e079be5bcad029e79dd9",
"sha256:0aadfb2d3935988ec3815952e44058a3100499f5be5b28c34ac9d79f002a4a9a",
"sha256:119bed3832d961f3a880787bf621634ba042cb8dc850a7429f643508eeac97b9",
"sha256:1a85e280d4d217150ce8cb1a6dddffd14e753a4e0c3cf90baabb32cefa41b59e",
"sha256:3c4b8ca36877fc75339253721f69603a9c7fdb5d4d5a95a1a1b899d8b86a4de2",
"sha256:3e382b29f8e0ccf19a2df2b29a167591245df90c0b5a2542249873b5c1d78212",
"sha256:42c266ced41b65ed40a282c575705325fa7991af370036d3f134518336636f5b",
"sha256:53fd2eb27a8ee2892614370896956af2ff61254c275aaee4c230ae771cadd885",
"sha256:704098302473cb31a218f1775a873b376b30b4c18229421e9e9dc8916fd16150",
"sha256:7df1ead20c81371ccd6091fa3e2878559b5c4d4caadaf1a484cf88d93ca06703",
"sha256:866c41f28cee548475f146aa4d39a51cf3b6a84246969f3759cb3e9c742fc072",
"sha256:a155d80ea6cee511a3694b108c4494a39f42de11ee4e61e72bc424c490e46457",
"sha256:adaeee09bfde366d2c13fe6093a7df5df83c9a2ba98638c7d76b010694db760e",
"sha256:b6fb13123aeef4a3abbcfd7e71773ff3ff1526a7d3dc538f3929a49b42be03f0",
"sha256:b94e4b785e304a04ea0828759172a15add27088520dc7e49ceade7834275bedb",
"sha256:c0df2d30ed496a08de5daed2a9ea807d07c21ae0ab23acf541ab88c24b26ab97",
"sha256:c6c2602dffb74867498f86e6129fd52a2770c48b7cd3ece77ada4fa38f94eba8",
"sha256:ceb6e0a6e27fb364fb3853389607cf7eb3a126ad335790fa1e14ed02fba50811",
"sha256:d9dd839eb0dc1bbe866a288ba3c1afc33a202015d2ad83b31e875b5905a079b6",
"sha256:e4dab234478e3bd3ce83bac4193b2ecd9cf94e720ddd95ce69840273bf44f6de",
"sha256:ec4e0cd079db280b6bdabdc807047ff3e199f334050db5cbb91ba3e959a67504",
"sha256:ecd2c3fe726758037234c93df7e98deb257fd15c24c9180dacf1ef829da5f921",
"sha256:ef565033fa5a958e62796867b1df10c40263ea9ded87164d67572834e57a174d"
],
"index": "pypi",
"version": "==0.910"
},
"mypy-extensions": {
"hashes": [
"sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d",
"sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"
],
"version": "==0.4.3"
},
"toml": {
"hashes": [
"sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b",
"sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"
],
"version": "==0.10.2"
},
"typing-extensions": {
"hashes": [
"sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e",
"sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7",
"sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34"
],
"version": "==3.10.0.2"
}
}
}

119
README.md
View File

@ -1,22 +1,103 @@
# Design
- On bucket, we have a separate user account "jim-backups". Password
for this account is in bitwarden.
- Repository keys are repokeys, with passphrases saved on clients
and in bitwarden.
- Each client has two SSH keys: one for append-only operation (no
pass) and one for read-write (password in bitwarden)
- Pruning requires the password and is a manual operation (run `sudo
/opt/borg/prune.sh`)
- Systemd timers start daily backups
# Usage
Initial setup
=============
Run on client:
wget https://psy.jim.sh/borg-setup.sh
sudo ./borg-setup.sh
sudo git clone https://git.jim.sh/jim/borg-setup.git /opt/borg
sudo /opt/borg/initial-setup.sh
Customize `/opt/borg/backup.yaml` as desired.
Cheat sheet
===========
*The copy of this file left on the client will have the variables
in this section filled in automatically*
### Configuration
Hostname: ${HOSTNAME}
Base directory: ${BORG_DIR}
Destination: ${BACKUP_USER}@${BACKUP_HOST}
Repository: ${BACKUP_REPO}
### Commands
See when next backup is scheduled:
systemctl list-timers borg-backup.timer
See progress of most recent backup:
systemctl status -l -n 99999 borg-backup
Start backup now:
sudo systemctl start borg-backup
Interrupt backup in progress:
sudo systemctl stop borg-backup
Show backups and related info:
sudo ${BORG_DIR}/borg.sh info
sudo ${BORG_DIR}/borg.sh list
Run Borg using the read-write SSH key:
sudo ${BORG_DIR}/borg.sh --rw list
Mount and look at files:
mkdir mnt
sudo ${BORG_DIR}/borg.sh mount :: mnt
sudo -s # to explore as root
sudo umount mnt
Prune old backups. Only run if sure local system was never compromised,
as object deletion could have been queued during append-only operations.
Requires SSH key password from bitwarden.
sudo ${BORG_DIR}/prune.sh
Design
======
- On server, we have a separate user account "jim-backups". Password
for this account is in bitwarden in the "Backups" folder, under `ssh
backup.jim.sh`.
- Repository keys are repokeys, which get stored on the server, inside
the repo. Passphrases are stored:
- on clients (in `/opt/borg/passphrase`, for making backups)
- in bitwarden (under `borg <hostname>`, user `repo key`)
- Each client has two SSH keys for connecting to the server:
- `/opt/borg/ssh/id_ecdsa_appendonly`
- configured on server for append-only operation
- used for making backups
- no password
- `/opt/borg/ssh/id_ecdsa`
- configured on server for read-write operation
- used for manual recovery, management, pruning
- password in bitwarden (under `borg [hostname]`, user `read-write ssh key`)
- Pruning requires the password and is a manual operation, and should only
be run when the client has not been compromised.
sudo /opt/borg/prune.sh
- Systemd timers start daily backups:
/etc/systemd/system/borg-backup.service -> /opt/borg/borg-backup.service
/etc/systemd/system/borg-backup.timer -> /opt/borg/borg-backup.timer
- Backup script `/opt/borg/backup.py` uses configuration in
`/opt/borg/backup.yaml` to generate our own list of files, excluding
anything that's too large by default. This requires borg 1.2.0b1
or newer, which is why the setup scripts download a specific version.

180
backup.py Executable file
View File

@ -0,0 +1,180 @@
#!.venv/bin/python
import os
import sys
import stat
from typing import Optional, Tuple
import humanfriendly # type: ignore
import wcmatch.glob # type: ignore
import re
import dataclasses
import enum
class MatchResult(enum.Enum):
INCLUDE_IF_SIZE_OK = 0
INCLUDE_ALWAYS = 1
EXCLUDE_ALWAYS = 2
@dataclasses.dataclass
class PatternRule:
re_inc: list[re.Pattern]
re_exc: list[re.Pattern]
def match(self, path: str) -> Tuple[bool, bool]:
if "big" in path:
print(self, file=sys.stderr)
for inc in self.re_inc:
if inc.match(path):
break
else:
return
for exc in self.re_exc:
if exc.match(path):
return False
return True
class Lister:
def __init__(self, one_file_system: bool, max_size: bool):
self.one_file_system = one_file_system
self.max_size = max_size
if max_size is None:
max_size = float('inf')
self.stdout = os.fdopen(sys.stdout.fileno(), "wb", closefd=False)
# Remember files we've skipped because they were too big, so that
# we can warn again at the end.
self.skipped_size: set[bytes] = set()
# Remember errors
self.skipped_error: set[bytes] = set()
def __del__(self):
self.stdout.close()
def out(self, path: bytes):
# Use '\0\n' as a separator, so that we can both separate it
# cleanly in Borg, and also view it on stdout.
self.stdout.write(path + b'\0\n')
def log(self, letter: str, msg: str):
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")
def scan(self, path: bytes,
parent_st: os.stat_result=None,
rules: list[PatternRule]=[]):
"""If the given path should be backed up, print it. If it's
a directory and its contents should be included, recurse."""
# Copy the path in string form, for logging and pathspec
# parsing. Otherwise, we use bytes directly.
pathstr = path.decode(errors='backslashreplace')
try:
# See if we match any rules
for r in rules:
if r.match(pathstr):
self.log('I', f"ignore {pathstr}")
return
# Stat the path
st = os.lstat(path)
is_dir = stat.S_ISDIR(st.st_mode)
if is_dir:
# Skip if it crosses a mount point
if self.one_file_system:
if parent_st is not None and st.st_dev != parent_st.st_dev:
self.log('I', f"skipping {pathstr}: "
"on different filesystem")
return
# Add contents of any .nobackup file to our
# parser rules
child_rules = rules
try:
def prepend_base(regex):
if regex[0] != '^':
raise Exception(f'bad regex: {regex}')
return '^' + os.path.join(pathstr, '') + regex[1:]
with open(os.path.join(path, b".nobackup")) as f:
rule = PatternRule([], [])
for line in f:
if line[0] == '#':
continue
(inc, exc) = wcmatch.glob.translate(
[ line.rstrip('\r\n') ],
flags=(wcmatch.glob.NEGATE |
wcmatch.glob.GLOBSTAR |
wcmatch.glob.DOTGLOB |
wcmatch.glob.EXTGLOB |
wcmatch.glob.BRACE))
for x in inc:
rule.re_inc.append(re.compile(prepend_base(x)))
for x in exc:
rule.re_exc.append(re.compile(prepend_base(x)))
child_rules.append(rule)
except FileNotFoundError:
pass
# Recurse and process each entry
with os.scandir(path) as it:
for entry in it:
self.scan(entry.path, st, child_rules)
else:
# For regular files, ensure they're not too big
if stat.S_ISREG(st.st_mode) and st.st_size > self.max_size:
def format_size(n):
return humanfriendly.format_size(
n, keep_width=True, binary=True)
a = format_size(st.st_size)
b = format_size(self.max_size)
self.log('W', f"skipping {pathstr}: "
+ f"file size {a} exceeds limit {b}")
self.skipped_size.add(path)
return
# Every other filename gets printed; devices, symlinks, etc
# will get handled by Borg
self.out(path)
except PermissionError as e:
self.log('E', f"can't read {pathstr}")
self.skipped_error.add(path)
return
def main(argv):
import argparse
def humansize(string):
return humanfriendly.parse_size(string)
parser = argparse.ArgumentParser(
prog=argv[0],
description="Build up a directory and file list for backups")
parser.add_argument('-s', '--max-size', type=humansize,
help="Ignore files bigger than this, by default")
parser.add_argument('-x', '--one-file-system', action='store_true',
help="Don't cross mount points when recursing")
parser.add_argument('dirs', metavar='DIR', nargs='+',
help="Root directories to scan recursively")
args = parser.parse_args()
lister = Lister(one_file_system=args.one_file_system,
max_size=args.max_size)
for p in args.dirs:
lister.scan(os.fsencode(p))
if __name__ == "__main__":
import sys
main(sys.argv)

View File

@ -1,10 +1,21 @@
#!/bin/bash
BORG_DIR=${BORG_DIR:-/opt/borg}
# These can be overridden when running this script
BACKUP_HOST=${BACKUP_HOST:-backup.jim.sh}
BACKUP_USER=${BACKUP_USER:-jim-backups}
BACKUP_REPO=${BACKUP_REPO:-borg/$(hostname)}
# Borg binary and hash
BORG_URL="https://github.com/borgbackup/borg/releases/download/1.2.0b3/borg-linux64"
BORG_SHA256=8dd6c2769d9bf3ca7a65ebf6781302029fc3b15105aff63d33195c007f897360
# Main dir is where this repo was checked out
BORG_DIR="$(realpath "$(dirname "$0")")"
# This is named with uppercase so that it doesn't tab-complete for
# "./b<tab>", which should give us "./borg.sh"
BORG_BIN="${BORG_DIR}/Borg.bin"
# Use stable host ID in case MAC address changes
HOSTID="$(hostname -f)@$(python -c 'import uuid;print(uuid.getnode())')"
@ -18,13 +29,13 @@ trap 'error_handler ${BASH_SOURCE} ${LINENO} $?' ERR
set -o errexit
set -o errtrace
if [ -e "$BORG_DIR" ]; then
echo "Error: BORG_DIR $BORG_DIR already exists; giving up"
exit 1
if [ -e "$BORG_DIR/.setup-complete" ]; then
echo "Error: BORG_DIR $BORG_DIR was already set up; giving up."
echo "Use \"git clean\" to return it to original state if desired"
exit 1
fi
# Make a temp dir to work in
mkdir "$BORG_DIR"
TMP=$(mktemp -d --tmpdir="$BORG_DIR")
# Install some cleanup handlers
@ -57,18 +68,20 @@ notice() { msg 32 "$@" ; }
warn() { msg 31 "$@" ; }
error() { msg 31 "Error:" "$@" ; exit 1 ; }
# Install required packages
install_dependencies()
# Create pip environment
setup_venv()
{
NEED=
check() {
command -v "$1" >/dev/null || NEED+=" $2"
}
check borg borgbackup
if [ -n "${NEED:+x}" ]; then
log "Need to install packages: $NEED"
apt install --no-upgrade $NEED
( cd "${BORG_DIR}" && mkdir .venv && pipenv install )
}
# Install borg
install_borg()
{
curl -L --progress-bar -o "${BORG_BIN}" "${BORG_URL}"
if ! echo "${BORG_SHA256} ${BORG_BIN}" | sha256sum -c ; then
error "hash error"
fi
chmod +x "${BORG_BIN}"
}
# Create wrapper to execute borg
@ -87,8 +100,16 @@ 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
export BORG_RSH="ssh -F $SSH/config -i $SSH/id_ecdsa_appendonly"
exec borg "\$@"
if [ "\$1" = "--rw" ] ; then
echo "=== Need SSH key passphrase. Check Bitwarden for:"
echo "=== borg $(hostname) / read-write SSH key"
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}" "\$@"
EOF
chmod +x "$BORG"
if ! "$BORG" -h >/dev/null ; then
@ -162,7 +183,7 @@ 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 serve --restrict-to-repository ~/$BACKUP_REPO"
cmd="borg/borg serve --restrict-to-repository ~/$BACKUP_REPO"
keys=".ssh/authorized_keys"
backup="${keys}.old-$(date +%Y%m%d-%H%M%S)"
@ -204,68 +225,6 @@ Passphrase: ${PASS_REPOKEY}
EOF
}
# Create helper scripts to backup, prune, and mount
create_scripts()
{
cat > "${BORG_DIR}/backup.sh" <<EOF
#!/bin/bash
BORG=$BORG_DIR/borg.sh
set -e
# Explicitly list a bunch of directories to back up, in case they come
# from different filesystems. If not, duplicates have no effect.
DIRS="/"
for DIR in /usr /var /home /boot /efi ; do
if [ -e "\$DIR" ] ; then
DIRS="\$DIRS \$DIR"
fi
done
# Allow dirs to be overridden
BORG_BACKUP_DIRS=\${BORG_BACKUP_DIRS:-\$DIRS}
echo "Backing up: \$BORG_BACKUP_DIRS"
\$BORG create \\
--verbose \\
--list \\
--filter E \\
--stats \\
--exclude-caches \\
--one-file-system \\
--checkpoint-interval 900 \\
--compression zstd,3 \\
::'{hostname}-{now:%Y%m%d-%H%M%S}' \\
\$BORG_BACKUP_DIRS
\$BORG check \\
--verbose \\
--last 10
EOF
cat > "${BORG_DIR}/prune.sh" <<EOF
#!/bin/bash
BORG=$BORG_DIR/borg.sh
set -e
echo "=== Need SSH key passphrase. Check Bitwarden for:"
echo "=== borg $(hostname) / read-write SSH key"
\$BORG prune \\
--rsh="ssh -F $SSH/config -o BatchMode=no -i $SSH/id_ecdsa" \\
--verbose \\
--stats \\
--keep-within=7d \\
--keep-daily=14 \\
--keep-weekly=8 \\
--keep-monthly=-1
EOF
chmod 755 "${BORG_DIR}/backup.sh"
chmod 755 "${BORG_DIR}/prune.sh"
}
configure_systemd()
{
TIMER=borg-backup.timer
@ -295,7 +254,7 @@ Description=Borg backup to ${BACKUP_HOST}
[Service]
Type=simple
ExecStart=${BORG_DIR}/backup.sh
ExecStart=${BORG_DIR}/backup.py
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=6
@ -324,52 +283,15 @@ EOF
fi
}
make_readme()
update_readme()
{
cat > "${BORG_DIR}/README" <<EOF
Backup Configuration
--------------------
Hostname: $(hostname)
Destination: ${BACKUP_USER}@${BACKUP_HOST}
Repository: ${BACKUP_REPO}
Cheat sheet
-----------
See when next backup is scheduled:
systemctl list-timers borg-backup.timer
See progress of most recent backup:
systemctl status -l -n 99999 borg-backup
Start backup now:
sudo systemctl start borg-backup
Interrupt backup in progress:
sudo systemctl stop borg-backup
Show backups and related info:
sudo ${BORG_DIR}/borg.sh info
sudo ${BORG_DIR}/borg.sh list
Mount and look at files:
mkdir mnt
sudo ${BORG_DIR}/borg.sh mount :: mnt
sudo -s # to explore as root
sudo umount mnt
Prune old backups. Only run if sure local system was never compromised,
as object deletion could have been queued during append-only operations.
Requires SSH key password from bitwarden.
sudo ${BORG_DIR}/prune.sh
EOF
sed -i \
-e "s!\${HOSTNAME}!$(hostname)!g" \
-e "s!\${BORG_DIR}!${BORG_DIR}!g" \
-e "s!\${BACKUP_USER}!${BACKUP_USER}!g" \
-e "s!\${BACKUP_HOST}!${BACKUP_HOST}!g" \
-e "s!\${BACKUP_REPO}!${BACKUP_REPO}!g" \
"${BORG_DIR}/README.md"
}
log "Configuration:"
@ -377,28 +299,31 @@ log " Backup server host: ${BACKUP_HOST}"
log " Backup server user: ${BACKUP_USER}"
log " Repository path: ${BACKUP_REPO}"
install_dependencies
setup_venv
install_borg
create_borg_wrapper
generate_keys
configure_ssh
create_repo
export_keys
create_scripts
configure_systemd
make_readme
update_readme
echo
notice "Add these two passwords to Bitwarden:"
notice ""
notice " Name: borg $(hostname)"
notice " Username: repo key"
notice " Password: $PASS_REPOKEY"
notice ""
notice " Name: borg $(hostname)"
notice " Username: read-write ssh key"
notice " Password: $PASS_SSH"
notice ""
notice "You should also print out the full repo key: ${BORG_DIR}/key.txt"
notice " Name: borg $(hostname)"
notice " Username: repo key"
notice " Password: $PASS_REPOKEY"
notice " Notes: (paste the following key)"
sed -ne '/BORG/,/^$/{/./p}' "${BORG_DIR}/key.txt"
notice ""
notice ""
echo
echo "All done"

12
prune.sh Executable file
View File

@ -0,0 +1,12 @@
#!/bin/bash
BORG="$(dirname "$0")/borg.sh --rw"
set -e
$BORG prune \
--verbose \
--stats \
--keep-within=7d \
--keep-daily=14 \
--keep-weekly=8 \
--keep-monthly=-1