OSVauco/gitea-backup-candidate.sh

401 lines
11 KiB
Bash

#!/usr/bin/env bash
# Gitea application-consistent backup script.
#
# Operator-attestation basis:
# - No intentionally required nested mount boundaries exist beneath the approved
# source roots.
# - The SQLite database is captured with SQLite's online .backup mechanism.
# - Filesystem-tree copies are best effort while Gitea remains online.
# - This script does not perform runtime mount-boundary or nested-symlink scans.
#
# The script archives only a staged SQLite backup, not the live SQLite DB,
# WAL, SHM, or journal files.
set -euo pipefail
IFS=$'\n\t'
umask 077
readonly APP_INI_PATH="/opt/gitea/data/custom/conf/app.ini"
readonly SQLITE_DB_PATH="/opt/gitea/data/data/gitea.db"
readonly REPO_PATH="/opt/gitea/data/repositories"
readonly LFS_PATH="/opt/gitea/data/lfs"
readonly ATTACHMENTS_PATH="/opt/gitea/data/data/attachments"
readonly AVATARS_PATH="/opt/gitea/data/data/avatars"
readonly PACKAGES_PATH="/opt/gitea/data/data/packages"
readonly REPO_ARCHIVE_PATH="/opt/gitea/data/data/repo-archive"
readonly REPO_AVATARS_PATH="/opt/gitea/data/data/repo-avatars"
readonly GCS_BUCKET="gs://vauco-gitea-backups-20260901"
readonly GCS_PREFIX="manual"
readonly STAGING_DIR="/var/backups/gitea"
readonly LOCK_FILE="/var/run/gitea-backup.lock"
readonly LOG_FILE="/var/log/gitea-backup.log"
readonly REQUIRED_FREE_KB=646368
workspace=""
workspace_preserved=0
log_ready=0
log_status() {
local phase="$1"
local status="$2"
local category="$3"
if [ "${log_ready}" -eq 1 ]; then
printf '%s phase=%s status=%s category=%s\n' \
"$(date -u --iso-8601=seconds)" \
"${phase}" \
"${status}" \
"${category}" >> "${LOG_FILE}"
else
printf '%s\n' \
"gitea-backup phase=${phase} status=${status} category=${category}" >&2
fi
}
fail_without_workspace() {
log_status "$1" "FAILURE" "$2"
exit 1
}
workspace_is_safe() {
[ -n "${workspace}" ] &&
[ -d "${workspace}" ] &&
[ ! -L "${workspace}" ] &&
[ "$(dirname -- "${workspace}")" = "${STAGING_DIR}" ] &&
[ "$(stat -c '%u:%g:%a' -- "${workspace}")" = "0:0:700" ]
}
remove_workspace() {
workspace_is_safe || return 1
rm -rf -- "${workspace}"
}
preserve_workspace() {
workspace_is_safe || return 1
if find -- "${workspace}" -xdev -type l -print -quit 2>/dev/null |
grep -q . >/dev/null 2>&1; then
return 1
fi
if find -- "${workspace}" -xdev \
! -type d ! -type f ! -type l \
-print -quit 2>/dev/null |
grep -q . >/dev/null 2>&1; then
return 1
fi
if ! find -- "${workspace}" -xdev -type d \
-exec chmod 700 -- {} + >/dev/null 2>&1; then
return 1
fi
if ! find -- "${workspace}" -xdev -type f \
-exec chmod 600 -- {} + >/dev/null 2>&1; then
return 1
fi
workspace_preserved=1
trap - EXIT
return 0
}
fail_after_workspace() {
log_status "$1" "FAILURE" "$2"
if ! preserve_workspace; then
log_status "PRESERVATION" "FAILURE" "WorkspacePreservationFailed"
fi
exit 1
}
cleanup_on_exit() {
local rc=$?
if [ "${workspace_preserved}" -eq 0 ]; then
remove_workspace >/dev/null 2>&1 || true
fi
exit "${rc}"
}
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
fail_without_workspace "PREFLIGHT" "RequiredCommandMissing"
fi
}
validate_regular() {
if ! [ -f "$1" ] || [ -L "$1" ]; then
fail_without_workspace "PREFLIGHT" "$2"
fi
}
validate_directory() {
if ! [ -d "$1" ] || [ -L "$1" ]; then
fail_without_workspace "PREFLIGHT" "$2"
fi
}
validate_archive_manifest() {
local archive_path="$1"
tar -tzf "${archive_path}" >/dev/null 2>&1 || return 1
tar -tf "${archive_path}" 2>/dev/null |
awk '
BEGIN {
allowed["database"] = 1
allowed["config"] = 1
allowed["repositories"] = 1
allowed["lfs"] = 1
allowed["data"] = 1
required["database/"] = 0
required["config/"] = 0
required["repositories/"] = 0
required["lfs/"] = 0
required["data/"] = 0
}
/^\// { bad = 1; exit 1 }
/(^|\/)\.\.(\/|$)/ { bad = 1; exit 1 }
{
member = $0
split(member, parts, "/")
root = parts[1]
if (!(root in allowed)) {
bad = 1
exit 1
}
if (member in required) {
required[member] = 1
}
}
END {
if (bad) {
exit 1
}
for (entry in required) {
if (required[entry] != 1) {
exit 1
}
}
}'
}
copy_source() {
if ! cp -a -- "$1" "$2"; then
fail_after_workspace "STAGING" "$3"
fi
}
main() {
local required_command=""
local archive_root=""
local run_id=""
local archive_name=""
local archive_path=""
local checksum_path=""
local archive_object=""
local checksum_object=""
for required_command in \
flock df awk mktemp mkdir sqlite3 cp tar sha256sum gcloud \
stat find chmod rm dirname grep date printf; do
require_command "${required_command}"
done
if ! [ -f "${LOG_FILE}" ] || [ -L "${LOG_FILE}" ]; then
printf '%s\n' \
"gitea-backup phase=PREFLIGHT status=FAILURE category=LogFileInvalid" >&2
exit 1
fi
if [ "$(stat -c '%u:%g:%a' -- "${LOG_FILE}")" != "0:0:600" ]; then
printf '%s\n' \
"gitea-backup phase=PREFLIGHT status=FAILURE category=LogFilePermissionsInvalid" >&2
exit 1
fi
if ! [ -w "${LOG_FILE}" ]; then
printf '%s\n' \
"gitea-backup phase=PREFLIGHT status=FAILURE category=LogFileUnavailable" >&2
exit 1
fi
log_ready=1
log_status "PREFLIGHT" "START" "BackupJob"
if ! [ -d "${STAGING_DIR}" ] || [ -L "${STAGING_DIR}" ]; then
fail_without_workspace "PREFLIGHT" "StagingDirectoryInvalid"
fi
if [ "$(stat -c '%u:%g:%a' -- "${STAGING_DIR}")" != "0:0:700" ]; then
fail_without_workspace "PREFLIGHT" "StagingDirectoryPermissionsInvalid"
fi
if ! [ -w "${STAGING_DIR}" ]; then
fail_without_workspace "PREFLIGHT" "StagingDirectoryUnavailable"
fi
if ! df --output=avail -- "${STAGING_DIR}" 2>/dev/null |
awk -v required_kb="${REQUIRED_FREE_KB}" '
NR == 2 {
checked = 1
exit !($1 > required_kb)
}
END {
if (!checked) {
exit 1
}
}'; then
fail_without_workspace "PREFLIGHT" "InsufficientStagingCapacity"
fi
validate_regular "${APP_INI_PATH}" "AppIniInvalid"
validate_regular "${SQLITE_DB_PATH}" "SQLiteDatabaseInvalid"
validate_directory "${REPO_PATH}" "RepositoriesInvalid"
validate_directory "${LFS_PATH}" "LfsInvalid"
validate_directory "${ATTACHMENTS_PATH}" "AttachmentsInvalid"
validate_directory "${AVATARS_PATH}" "AvatarsInvalid"
validate_directory "${PACKAGES_PATH}" "PackagesInvalid"
validate_directory "${REPO_ARCHIVE_PATH}" "RepositoryArchivesInvalid"
validate_directory "${REPO_AVATARS_PATH}" "RepositoryAvatarsInvalid"
if ! workspace="$(mktemp -d -p "${STAGING_DIR}" "backup.XXXXXX")"; then
fail_without_workspace "PREFLIGHT" "WorkspaceCreationFailed"
fi
if ! workspace_is_safe; then
fail_after_workspace "PREFLIGHT" "WorkspaceValidationFailed"
fi
trap cleanup_on_exit EXIT
archive_root="${workspace}/archive_root"
if ! mkdir -p -- \
"${archive_root}/database" \
"${archive_root}/config" \
"${archive_root}/data"; then
fail_after_workspace "STAGING" "ArchiveRootCreationFailed"
fi
log_status "DB_COPY" "START" "SQLiteOnlineBackup"
if ! sqlite3 "${SQLITE_DB_PATH}" \
".backup '${archive_root}/database/gitea.db'"; then
fail_after_workspace "DB_COPY" "SQLiteBackupFailed"
fi
log_status "DB_COPY" "SUCCESS" "SQLiteOnlineBackupComplete"
log_status "STAGING" "START" "CopyingData"
copy_source "${APP_INI_PATH}" \
"${archive_root}/config/app.ini" \
"CopyAppIniFailed"
copy_source "${REPO_PATH}" \
"${archive_root}/repositories" \
"CopyRepositoriesFailed"
copy_source "${LFS_PATH}" \
"${archive_root}/lfs" \
"CopyLfsFailed"
copy_source "${ATTACHMENTS_PATH}" \
"${archive_root}/data/attachments" \
"CopyAttachmentsFailed"
copy_source "${AVATARS_PATH}" \
"${archive_root}/data/avatars" \
"CopyAvatarsFailed"
copy_source "${PACKAGES_PATH}" \
"${archive_root}/data/packages" \
"CopyPackagesFailed"
copy_source "${REPO_ARCHIVE_PATH}" \
"${archive_root}/data/repo-archive" \
"CopyRepositoryArchivesFailed"
copy_source "${REPO_AVATARS_PATH}" \
"${archive_root}/data/repo-avatars" \
"CopyRepositoryAvatarsFailed"
log_status "STAGING" "SUCCESS" "CopyComplete"
run_id="${workspace##*/}"
archive_name="gitea-backup-${run_id}.tar.gz"
archive_path="${workspace}/${archive_name}"
checksum_path="${archive_path}.sha256"
archive_object="${GCS_BUCKET}/${GCS_PREFIX}/${archive_name}"
checksum_object="${GCS_BUCKET}/${GCS_PREFIX}/${archive_name}.sha256"
log_status "ARCHIVE" "START" "TarCreation"
if ! tar -czf "${archive_path}" \
-C "${archive_root}" \
database config repositories lfs data; then
fail_after_workspace "ARCHIVE" "TarCreationFailed"
fi
if ! validate_archive_manifest "${archive_path}"; then
fail_after_workspace "ARCHIVE" "ManifestInvalid"
fi
log_status "ARCHIVE" "SUCCESS" "ManifestValidated"
if ! (
cd -- "${workspace}"
sha256sum -b -- "${archive_name}" > "${checksum_path}"
); then
fail_after_workspace "CHECKSUM" "ChecksumCreationFailed"
fi
log_status "CHECKSUM" "SUCCESS" "ChecksumGenerated"
log_status "UPLOAD_ARCHIVE" "START" "GCS"
if ! gcloud storage cp \
--if-generation-match=0 \
--quiet \
"${archive_path}" \
"${archive_object}" >/dev/null 2>&1; then
fail_after_workspace "UPLOAD_ARCHIVE" "ArchiveUploadFailed"
fi
log_status "UPLOAD_ARCHIVE" "SUCCESS" "ArchiveUploadComplete"
log_status "UPLOAD_CHECKSUM" "START" "GCS"
if ! gcloud storage cp \
--if-generation-match=0 \
--quiet \
"${checksum_path}" \
"${checksum_object}" >/dev/null 2>&1; then
fail_after_workspace "UPLOAD_CHECKSUM" "ChecksumUploadFailed"
fi
log_status "UPLOAD_CHECKSUM" "SUCCESS" "ChecksumUploadComplete"
if ! remove_workspace; then
fail_after_workspace "CLEANUP" "WorkspaceRemovalFailed"
fi
workspace=""
trap - EXIT
log_status "JOB" "SUCCESS" "BackupComplete"
}
(
flock -n 200 || {
printf '%s\n' \
"gitea-backup phase=PREFLIGHT status=FAILURE category=LockHeld" >&2
exit 1
}
main
) 200>"${LOCK_FILE}"