#!/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}"