Files
rune/scripts/gitea-backup.sh
Nick a9496d1b06 feat(gitea): make governance and recovery reproducible
Track branch policy, labels, milestones, and wiki content as declarative state with a read-only-by-default provisioner.

Harden off-disk backup publication and add a rootless, networkless restore drill that validates SQLite, application data, and database-declared bare repositories.

Run offline contracts in CI, pin audited dependencies, document operations, and bump Rune to 0.43.0.
2026-08-30 17:10:32 +01:00

196 lines
8.1 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
BACKUP_MOUNT=${BACKUP_MOUNT:-/media/pi/Foundry}
BACKUP_DIR=${BACKUP_DIR:-$BACKUP_MOUNT/backups/gitea}
RETENTION_DAYS=${RETENTION_DAYS:-30}
LOCK_FILE=${LOCK_FILE:-/run/lock/rune-gitea-backup.lock}
WORK_ROOT=${WORK_ROOT:-/var/lib/gitea}
BACKUP_OWNER=${BACKUP_OWNER-pi:pi}
GITEA_RUN_USER=${GITEA_RUN_USER-git}
GITEA_BIN=${GITEA_BIN:-/usr/local/bin/gitea}
GITEA_CONFIG=${GITEA_CONFIG:-/etc/gitea/app.ini}
GITEA_WORK_PATH=${GITEA_WORK_PATH:-/var/lib/gitea}
SYSTEMCTL_BIN=${SYSTEMCTL_BIN:-systemctl}
CURL_BIN=${CURL_BIN:-curl}
SHA256SUM_BIN=${SHA256SUM_BIN:-sha256sum}
REALPATH_BIN=${REALPATH_BIN:-realpath}
MOUNTPOINT_BIN=${MOUNTPOINT_BIN:-mountpoint}
FINDMNT_BIN=${FINDMNT_BIN:-findmnt}
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
ARCHIVE="gitea-dump-${STAMP}.tar.gz"
die() { printf 'Gitea backup failed: %s\n' "$*" >&2; exit 1; }
# Check both lexical containment and symlink-aware containment before touching
# the destination. The configured mount itself must be mounted on a device
# distinct from / so an absent off-disk volume can never become a local folder.
mount_lex=$("$REALPATH_BIN" -m -s -- "$BACKUP_MOUNT") || die "cannot normalize BACKUP_MOUNT"
backup_lex=$("$REALPATH_BIN" -m -s -- "$BACKUP_DIR") || die "cannot normalize BACKUP_DIR"
mount_real=$("$REALPATH_BIN" -m -- "$BACKUP_MOUNT") || die "cannot resolve BACKUP_MOUNT"
backup_real=$("$REALPATH_BIN" -m -- "$BACKUP_DIR") || die "cannot resolve BACKUP_DIR"
case "$backup_lex" in "$mount_lex"|"$mount_lex"/*) ;; *) die "BACKUP_DIR is outside BACKUP_MOUNT" ;; esac
case "$backup_real" in "$mount_real"|"$mount_real"/*) ;; *) die "BACKUP_DIR resolves outside BACKUP_MOUNT" ;; esac
"$MOUNTPOINT_BIN" -q -- "$BACKUP_MOUNT" || die "BACKUP_MOUNT is not mounted"
mount_device=$("$FINDMNT_BIN" -n -r -o MAJ:MIN --target "$BACKUP_MOUNT") \
|| die "cannot identify BACKUP_MOUNT backing device"
root_device=$("$FINDMNT_BIN" -n -r -o MAJ:MIN --target /) \
|| die "cannot identify root backing device"
[[ "$mount_device" =~ ^[0-9]+:[0-9]+$ && "$root_device" =~ ^[0-9]+:[0-9]+$ ]] \
|| die "ambiguous backing device information"
[[ "$mount_device" != "$root_device" ]] || die "BACKUP_MOUNT is backed by the root device"
mkdir -p -- "$(dirname -- "$LOCK_FILE")" "$WORK_ROOT"
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
printf 'Another Gitea backup is already running\n' >&2
exit 75
fi
WORK_DIR=$(mktemp -d "$WORK_ROOT/backup.XXXXXX")
GITEA_WAS_ACTIVE=0
SERVICE_RESTORED=0
STAGED_ARCHIVE=""
STAGED_CHECKSUM=""
STAGED_METADATA=""
restore_service() {
if ((GITEA_WAS_ACTIVE)) && ! "$SYSTEMCTL_BIN" is-active --quiet gitea; then
"$SYSTEMCTL_BIN" start gitea
fi
SERVICE_RESTORED=1
}
cleanup() {
local status=$?
if ((GITEA_WAS_ACTIVE)) && ((SERVICE_RESTORED == 0)); then
restore_service || status=$?
fi
rm -rf -- "$WORK_DIR"
# The staged checksum is the rollback marker. It remains linked until all
# three no-clobber links have succeeded, so signal handling never depends on
# a shell assignment racing an atomic ln. Only unlink a final while its inode
# still proves that it is this run's hard link; a raced replacement survives.
if [[ -n "$STAGED_CHECKSUM" && -e "$STAGED_CHECKSUM" ]]; then
if [[ -n "$STAGED_METADATA" && -e "$BACKUP_DIR/$ARCHIVE.metadata" && ! -L "$BACKUP_DIR/$ARCHIVE.metadata" && "$BACKUP_DIR/$ARCHIVE.metadata" -ef "$STAGED_METADATA" ]]; then
rm -f -- "$BACKUP_DIR/$ARCHIVE.metadata"
fi
if [[ -n "$STAGED_ARCHIVE" && -e "$BACKUP_DIR/$ARCHIVE" && ! -L "$BACKUP_DIR/$ARCHIVE" && "$BACKUP_DIR/$ARCHIVE" -ef "$STAGED_ARCHIVE" ]]; then
rm -f -- "$BACKUP_DIR/$ARCHIVE"
fi
if [[ -e "$BACKUP_DIR/$ARCHIVE.sha256" && ! -L "$BACKUP_DIR/$ARCHIVE.sha256" && "$BACKUP_DIR/$ARCHIVE.sha256" -ef "$STAGED_CHECKSUM" ]]; then
rm -f -- "$BACKUP_DIR/$ARCHIVE.sha256"
fi
fi
[[ -z "$STAGED_ARCHIVE" ]] || rm -f -- "$STAGED_ARCHIVE"
[[ -z "$STAGED_CHECKSUM" ]] || rm -f -- "$STAGED_CHECKSUM"
[[ -z "$STAGED_METADATA" ]] || rm -f -- "$STAGED_METADATA"
exit "$status"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
install -d -m 0750 "$BACKUP_DIR"
if [[ -n "$BACKUP_OWNER" ]]; then
chown "$BACKUP_OWNER" "$BACKUP_DIR"
fi
if [[ -n "$GITEA_RUN_USER" ]]; then
chown -R "$GITEA_RUN_USER:$GITEA_RUN_USER" "$WORK_DIR"
fi
if "$SYSTEMCTL_BIN" is-active --quiet gitea; then
GITEA_WAS_ACTIVE=1
"$SYSTEMCTL_BIN" stop gitea
fi
dump_command=(
"$GITEA_BIN" dump
--config "$GITEA_CONFIG"
--work-path "$GITEA_WORK_PATH"
--tempdir "$WORK_DIR"
--type tar.gz
--file "$WORK_DIR/$ARCHIVE"
--skip-log
)
if [[ -n "$GITEA_RUN_USER" ]]; then
sudo -u "$GITEA_RUN_USER" "${dump_command[@]}"
else
"${dump_command[@]}"
fi
if ((GITEA_WAS_ACTIVE)); then
restore_service
for _ in $(seq 1 30); do
"$CURL_BIN" -fsS http://127.0.0.1:3000/api/v1/version >/dev/null && break
sleep 1
done
"$CURL_BIN" -fsS http://127.0.0.1:3000/api/v1/version >/dev/null
fi
tar -tzf "$WORK_DIR/$ARCHIVE" >"$WORK_DIR/manifest.txt"
for required in app.ini data repos gitea-db.sql; do
if ! grep -Eq "^(\\./)?${required}(/|$)" "$WORK_DIR/manifest.txt"; then
printf 'Backup archive is missing mandatory member: %s\n' "$required" >&2
exit 1
fi
done
# Build every sidecar before publishing. The checksum is published last and is the
# commit marker: consumers must ignore archives without a matching sidecar.
STAGED_ARCHIVE=$(mktemp "$BACKUP_DIR/.${ARCHIVE}.archive.XXXXXX")
STAGED_CHECKSUM=$(mktemp "$BACKUP_DIR/.${ARCHIVE}.sha256.XXXXXX")
STAGED_METADATA=$(mktemp "$BACKUP_DIR/.${ARCHIVE}.metadata.XXXXXX")
install -m 0640 "$WORK_DIR/$ARCHIVE" "$STAGED_ARCHIVE"
digest=$("$SHA256SUM_BIN" "$STAGED_ARCHIVE" | awk '{print $1}')
[[ "$digest" =~ ^[[:xdigit:]]{64}$ ]] || { printf 'Invalid SHA-256 output\n' >&2; exit 1; }
printf '%s %s\n' "$digest" "$ARCHIVE" >"$STAGED_CHECKSUM"
printf 'created_utc=%s\narchive=%s\ngitea_config=%s\nformat=gitea-dump-tar.gz\n' \
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$ARCHIVE" "$GITEA_CONFIG" >"$STAGED_METADATA"
chmod 0640 "$STAGED_ARCHIVE" "$STAGED_CHECKSUM" "$STAGED_METADATA"
if [[ -n "$BACKUP_OWNER" ]]; then
chown "$BACKUP_OWNER" "$STAGED_ARCHIVE" "$STAGED_CHECKSUM" "$STAGED_METADATA"
fi
if [[ -e "$BACKUP_DIR/$ARCHIVE" || -e "$BACKUP_DIR/$ARCHIVE.sha256" || -e "$BACKUP_DIR/$ARCHIVE.metadata" ]]; then
printf 'Refusing to overwrite an existing backup publication: %s\n' "$ARCHIVE" >&2
exit 1
fi
ln -T -- "$STAGED_METADATA" "$BACKUP_DIR/$ARCHIVE.metadata"
ln -T -- "$STAGED_ARCHIVE" "$BACKUP_DIR/$ARCHIVE"
ln -T -- "$STAGED_CHECKSUM" "$BACKUP_DIR/$ARCHIVE.sha256"
# Removing the staged checksum disarms rollback only after the checksum-last
# commit has succeeded. The other staged links are then safe to unlink.
rm -f -- "$STAGED_CHECKSUM"
rm -f -- "$STAGED_ARCHIVE" "$STAGED_METADATA"
# Expiration applies to the publication as a unit: if any final artifact has
# aged out, remove the archive, checksum, and metadata together.
while IFS= read -r -d '' expired; do
archive_path=${expired%.sha256}
archive_path=${archive_path%.metadata}
rm -f -- "$archive_path" "$archive_path.sha256" "$archive_path.metadata"
done < <(find "$BACKUP_DIR" -maxdepth 1 -type f \
\( -name 'gitea-dump-*.tar.gz' -o -name 'gitea-dump-*.tar.gz.sha256' -o -name 'gitea-dump-*.tar.gz.metadata' \) \
-mtime "+$RETENTION_DAYS" -print0)
# Preserve complete legacy archive+checksum pairs that predate metadata. New
# publications are three-file sets; any lone pair member or metadata-bearing
# incomplete set is an interrupted/malformed publication and is removed as a
# unit.
while IFS= read -r -d '' artifact; do
archive_path=${artifact%.sha256}
archive_path=${archive_path%.metadata}
if [[ -f "$archive_path.metadata" ]]; then
if [[ ! -f "$archive_path" || ! -f "$archive_path.sha256" ]]; then
rm -f -- "$archive_path" "$archive_path.sha256" "$archive_path.metadata"
fi
elif [[ ! -f "$archive_path" || ! -f "$archive_path.sha256" ]]; then
rm -f -- "$archive_path" "$archive_path.sha256" "$archive_path.metadata"
fi
done < <(find "$BACKUP_DIR" -maxdepth 1 -type f \
\( -name 'gitea-dump-*.tar.gz' -o -name 'gitea-dump-*.tar.gz.sha256' -o -name 'gitea-dump-*.tar.gz.metadata' \) -print0)
printf 'Backup verified: %s\n' "$BACKUP_DIR/$ARCHIVE"