#!/usr/bin/env bash # # backup_restore.sh # # Companion to setup-git-server.sh and backup_create.sh: restores a backup zip # made by backup_create.sh (or any plain `gitea dump` archive) into a Gitea # instance that was installed by setup-git-server.sh, keeping its layout: # # binary /usr/local/bin/gitea # config /etc/gitea/app.ini # work dir /var/lib/gitea (data/, custom/, data/gitea.db) # repositories ~git/gitea-repositories # database SQLite # # What it does: # 1. unpacks the backup into a staging directory, checks that the installed # Gitea is not older than the one that made it, and imports the SQL into a # brand-new SQLite file there -- nothing on the server is touched until # all of that has worked; # 2. stops Gitea and moves the current data / custom / repositories aside # (as .bak.); # 3. puts the backup's data, custom files, repositories and database in place; # 4. copies the secrets (SECRET_KEY, JWT secrets, ...) from the backup's # app.ini into the current one, so 2FA, OAuth2 and tokens keep working, # while the domain / ports / paths of this server stay as they are; # 5. runs 'gitea migrate', regenerates the repo hooks and authorized_keys, # and starts Gitea again. # # On a fresh machine, install a Gitea version equal to (or newer than) the one # that made the backup first (backup_create.sh prints the exact command): # sudo ./setup-git-server.sh --version # # Usage: # sudo ./backup_restore.sh [options] # # Run with --help for the full list of options. set -euo pipefail # ---------------------------------------------------------------------------- # Defaults (same variables as setup-git-server.sh, overridable via env vars) # ---------------------------------------------------------------------------- GITEA_USER="${GITEA_USER:-git}" GITEA_HOME="${GITEA_HOME:-/home/${GITEA_USER}}" GITEA_WORK_DIR="${GITEA_WORK_DIR:-/var/lib/gitea}" GITEA_CONFIG_DIR="${GITEA_CONFIG_DIR:-/etc/gitea}" GITEA_CONFIG="${GITEA_CONFIG_DIR}/app.ini" GITEA_BIN="/usr/local/bin/gitea" DUMP_FILE="" ASSUME_YES="false" STAGE_BASE="${STAGE_BASE:-}" # Filled in while the script runs DISTRO_FAMILY="" GITEA_GROUP="" REPO_ROOT="" DB_PATH="" STAGE="" DUMP_ROOT="" DUMP_INI="" MANIFEST="" BACKUP_VERSION="" BACKUP_CREATED="" BACKUP_HOST="" DUMP_USERS="?" DUMP_REPOS="?" TS="" SWAPPED="false" BACKUPS=() # ---------------------------------------------------------------------------- # Small helpers # ---------------------------------------------------------------------------- C_GREEN='\033[0;32m'; C_YELLOW='\033[0;33m'; C_RED='\033[0;31m'; C_RESET='\033[0m' log() { printf '%b\n' "${C_GREEN}[+]${C_RESET} $*"; } warn() { printf '%b\n' "${C_YELLOW}[!]${C_RESET} $*" >&2; } error_exit() { printf '%b\n' "${C_RED}[x]${C_RESET} $*" >&2; exit 1; } cleanup() { local rc=$? if [[ -n "${STAGE:-}" && -d "$STAGE" ]]; then rm -rf -- "$STAGE" fi if [[ $rc -ne 0 && "$SWAPPED" == "true" ]]; then warn "The restore stopped part-way. Your previous state was kept here:" if (( ${#BACKUPS[@]} )); then printf ' %s\n' "${BACKUPS[@]}" >&2 fi warn "To roll back: stop gitea, delete the restored item and rename its .bak.${TS} copy back." fi } trap cleanup EXIT print_help() { cat <<'EOF' Usage: sudo ./backup_restore.sh [options] Restores a backup made by backup_create.sh (a .zip; plain `gitea dump` archives in .zip, .tar, .tar.gz, .tar.bz2, .tar.xz or .tar.zst work too) into a Gitea instance installed by setup-git-server.sh. Options: -y, --yes Don't ask for confirmation --tmp-dir Where the backup is unpacked (default: the Gitea work dir; needs free space of about twice the backup size) -h, --help Show this help and exit The environment variables of setup-git-server.sh (GITEA_USER, GITEA_HOME, GITEA_WORK_DIR, GITEA_CONFIG_DIR) are honoured if you used non-default ones. Only SQLite backups can be restored (that is what setup-git-server.sh uses). EOF } require_root() { if [[ "${EUID}" -ne 0 ]]; then error_exit "Please run this script as root, e.g.: sudo $0" fi command -v systemctl &>/dev/null || error_exit "This script requires systemd." [[ -d /run/systemd/system ]] \ || error_exit "systemd is not running as init here (container/chroot/WSL?). Run this on a real VM or host." } need_arg() { # usage: need_arg "$@" -- an option that takes a value must have one [[ $# -ge 2 ]] || error_exit "Option $1 needs a value (see --help)" } parse_args() { while [[ $# -gt 0 ]]; do case "$1" in -y|--yes) ASSUME_YES="true"; shift ;; --tmp-dir) need_arg "$@"; STAGE_BASE="$2"; shift 2 ;; -h|--help) print_help; exit 0 ;; -*) error_exit "Unknown option: $1 (see --help)" ;; *) [[ -z "$DUMP_FILE" ]] || error_exit "Only one backup file can be given (got '${DUMP_FILE}' and '$1')." DUMP_FILE="$1"; shift ;; esac done if [[ -z "$DUMP_FILE" ]]; then print_help >&2 exit 1 fi [[ -f "$DUMP_FILE" && -r "$DUMP_FILE" ]] || error_exit "Cannot read backup file: ${DUMP_FILE}" DUMP_FILE="$(readlink -f -- "$DUMP_FILE")" } # ---------------------------------------------------------------------------- # app.ini helpers (section-aware, values are copied verbatim) # ---------------------------------------------------------------------------- ini_get() { # usage: ini_get FILE SECTION KEY (SECTION "" = top of the file) local file="$1" section="$2" key="$3" [[ -r "$file" ]] || return 0 awk -v want="$section" -v key="$key" ' /^[[:space:]]*[;#]/ { next } /^[[:space:]]*\[/ { s = $0; gsub(/^[[:space:]]*\[|\][[:space:]]*$/, "", s); cur = s; next } tolower(cur) == tolower(want) { i = index($0, "=") if (!i) next k = substr($0, 1, i - 1); gsub(/[[:space:]]/, "", k) if (k == key) { v = substr($0, i + 1) sub(/^[[:space:]]+/, "", v); sub(/[[:space:]]+$/, "", v) print v; exit } }' "$file" } ini_set() { # usage: ini_set FILE SECTION KEY VALUE -- fails if KEY isn't in SECTION local file="$1" section="$2" key="$3" value="$4" out out="$(mktemp)" # The value goes in through the environment so '&', '\' etc. stay literal. if ! NEW_VALUE="$value" awk -v want="$section" -v key="$key" ' /^[[:space:]]*\[/ { s = $0; gsub(/^[[:space:]]*\[|\][[:space:]]*$/, "", s); cur = s } !done && !/^[[:space:]]*[;#]/ && tolower(cur) == tolower(want) { i = index($0, "=") if (i) { k = substr($0, 1, i - 1); gsub(/[[:space:]]/, "", k) if (k == key) { print key " = " ENVIRON["NEW_VALUE"]; done = 1; next } } } { print } END { exit !done }' "$file" > "$out"; then rm -f "$out" return 1 fi cat "$out" > "$file" # 'cat >' keeps the owner/mode of the existing file rm -f "$out" } # ---------------------------------------------------------------------------- # Backup info file / version helpers # ---------------------------------------------------------------------------- manifest_get() { # usage: manifest_get KEY -- read from gitea-backup-info.txt, never sourced [[ -n "$MANIFEST" && -r "$MANIFEST" ]] || return 0 sed -nE "s/^$1=(.*)\$/\\1/p" "$MANIFEST" | head -n1 || true } installed_version() { "$GITEA_BIN" --version 2>/dev/null \ | sed -nE 's/^Gitea version v?([0-9]+(\.[0-9]+)+).*/\1/p' \ | head -n1 || true } version_lt() { # usage: version_lt A B -> true if A is an older version than B [[ "$1" != "$2" && "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -n1)" == "$1" ]] } # ---------------------------------------------------------------------------- # Detection helpers / dependencies # ---------------------------------------------------------------------------- detect_distro_family() { [[ -r /etc/os-release ]] || error_exit "Cannot read /etc/os-release; unsupported system." # shellcheck disable=SC1091 . /etc/os-release case "${ID:-}" in ubuntu|debian|linuxmint|pop|raspbian) DISTRO_FAMILY="debian" ;; fedora|rhel|centos|rocky|almalinux|nobara) DISTRO_FAMILY="fedora" ;; arch|archarm|manjaro|endeavouros) DISTRO_FAMILY="arch" ;; *) case "${ID_LIKE:-}" in *debian*) DISTRO_FAMILY="debian" ;; *fedora*|*rhel*) DISTRO_FAMILY="fedora" ;; *arch*) DISTRO_FAMILY="arch" ;; *) error_exit "Unsupported distro: ${PRETTY_NAME:-${ID:-unknown}}. This script supports Arch, Debian, Ubuntu and Fedora." ;; esac ;; esac } install_dependencies() { # Only what's missing: the sqlite3 CLI (to import the SQL dump) and unzip. local pkgs=() command -v unzip &>/dev/null || pkgs+=(unzip) if ! command -v sqlite3 &>/dev/null; then detect_distro_family case "$DISTRO_FAMILY" in debian) pkgs+=(sqlite3) ;; *) pkgs+=(sqlite) ;; esac fi (( ${#pkgs[@]} )) || return 0 [[ -n "$DISTRO_FAMILY" ]] || detect_distro_family log "Installing dependencies: ${pkgs[*]}..." case "$DISTRO_FAMILY" in debian) export DEBIAN_FRONTEND=noninteractive apt-get update apt-get install -y "${pkgs[@]}" ;; fedora) dnf install -y "${pkgs[@]}" ;; # Never do a partial upgrade (-Sy without -u) on Arch. arch) pacman -Syu --noconfirm --needed "${pkgs[@]}" ;; esac } run_as_gitea() { # Run a command as the service user, from a directory it can read, with the # same work dir the service uses. ( cd "$GITEA_WORK_DIR" && sudo -H -u "$GITEA_USER" env GITEA_WORK_DIR="$GITEA_WORK_DIR" "$@" ) } # ---------------------------------------------------------------------------- # Restore steps # ---------------------------------------------------------------------------- check_installation() { [[ -x "$GITEA_BIN" ]] \ || error_exit "Gitea is not installed at ${GITEA_BIN}. Run setup-git-server.sh first." [[ -r "$GITEA_CONFIG" ]] \ || error_exit "${GITEA_CONFIG} not found. Run setup-git-server.sh first." id -u "$GITEA_USER" &>/dev/null \ || error_exit "User '${GITEA_USER}' does not exist. Run setup-git-server.sh first." command -v sudo &>/dev/null || error_exit "sudo is required (setup-git-server.sh installs it)." GITEA_HOME="$(getent passwd "$GITEA_USER" | cut -d: -f6)" GITEA_GROUP="$(id -gn "$GITEA_USER")" local db_type db_type="$(ini_get "$GITEA_CONFIG" database DB_TYPE | tr -d '"`')" [[ "${db_type,,}" == "sqlite3" ]] \ || error_exit "${GITEA_CONFIG} uses DB_TYPE '${db_type}'; this script restores into the SQLite layout of setup-git-server.sh." REPO_ROOT="$(ini_get "$GITEA_CONFIG" repository ROOT)" : "${REPO_ROOT:=${GITEA_HOME}/gitea-repositories}" DB_PATH="$(ini_get "$GITEA_CONFIG" database PATH)" : "${DB_PATH:=${GITEA_WORK_DIR}/data/gitea.db}" log "Target: $("$GITEA_BIN" --version 2>/dev/null | head -n1 || echo gitea)" } extract_dump() { : "${STAGE_BASE:=$GITEA_WORK_DIR}" mkdir -p -- "$STAGE_BASE" STAGE="$(mktemp -d -p "$STAGE_BASE" .restore.XXXXXX)" mkdir -p -- "$STAGE/dump" log "Unpacking $(basename -- "$DUMP_FILE") (this can take a while for big backups)..." case "${DUMP_FILE,,}" in *.zip) unzip -q -o "$DUMP_FILE" -d "$STAGE/dump" ;; *.tar|*.tar.gz|*.tgz|*.tar.bz2|*.tbz2|*.tar.xz|*.txz|*.tar.zst) tar -xf "$DUMP_FILE" -C "$STAGE/dump" ;; *) error_exit "Unsupported archive type: ${DUMP_FILE} (expected .zip, .tar, .tar.gz, .tar.bz2, .tar.xz or .tar.zst)." ;; esac # The archive normally has everything at its top level; tolerate one wrapper folder. local sql sql="$(find "$STAGE/dump" -maxdepth 2 -name gitea-db.sql -print -quit)" [[ -n "$sql" ]] \ || error_exit "No gitea-db.sql in the archive -- is this a backup from backup_create.sh / 'gitea dump' (made without --skip-db)?" DUMP_ROOT="$(dirname -- "$sql")" } check_backup_version() { # Gitea can migrate a database forward, never backward -- so find out now, # not after the current data has been moved aside. if [[ ! -f "$DUMP_ROOT/gitea-backup-info.txt" ]]; then warn "No gitea-backup-info.txt (not made by backup_create.sh?): can't check the Gitea version up front." return 0 fi MANIFEST="$DUMP_ROOT/gitea-backup-info.txt" BACKUP_VERSION="$(manifest_get GITEA_VERSION)" BACKUP_CREATED="$(manifest_get CREATED_UTC)" BACKUP_HOST="$(manifest_get HOSTNAME)" local current current="$(installed_version)" if [[ -z "$BACKUP_VERSION" || -z "$current" ]]; then warn "Could not determine the Gitea versions to compare (backup: '${BACKUP_VERSION:-?}', installed: '${current:-?}')." return 0 fi if version_lt "$current" "$BACKUP_VERSION"; then error_exit "This backup was made with Gitea ${BACKUP_VERSION}, but ${current} is installed, and a database can't be migrated to an older release. Install the same or a newer version first: sudo ./setup-git-server.sh --version ${BACKUP_VERSION} Nothing was changed." fi if version_lt "$BACKUP_VERSION" "$current"; then log "Backup is from Gitea ${BACKUP_VERSION}; its database will be migrated to ${current}." fi } inspect_dump() { local f for f in "$DUMP_ROOT/app.ini" "$DUMP_ROOT/data/conf/app.ini"; do if [[ -f "$f" ]]; then DUMP_INI="$f"; break; fi done if [[ -n "$DUMP_INI" ]]; then local src_db_type src_db_type="$(ini_get "$DUMP_INI" database DB_TYPE | tr -d '"`')" if [[ -n "$src_db_type" && "${src_db_type,,}" != "sqlite3" ]]; then error_exit "The backup comes from a '${src_db_type}' database. Only SQLite backups can be restored by this script." fi else warn "No app.ini in the backup: SECRET_KEY can't be carried over, so 2FA secrets and similar will not work after the restore." fi # Dumps made by old Gitea versions keep the repositories in a nested zip. if [[ ! -d "$DUMP_ROOT/repos" && -f "$DUMP_ROOT/gitea-repo.zip" ]]; then log "Unpacking the nested gitea-repo.zip..." unzip -q -o "$DUMP_ROOT/gitea-repo.zip" -d "$DUMP_ROOT/repos" fi [[ -d "$DUMP_ROOT/repos" ]] || warn "The backup contains no repositories directory." } stage_database() { log "Importing the SQL dump into a fresh SQLite file (nothing on the server is touched yet)..." sqlite3 -bail "$STAGE/gitea.db" < "$DUMP_ROOT/gitea-db.sql" \ || error_exit "Importing gitea-db.sql into SQLite failed -- see the error above. Nothing was changed." DUMP_USERS="$(sqlite3 "$STAGE/gitea.db" 'SELECT COUNT(*) FROM "user";' 2>/dev/null)" \ || error_exit "The imported database has no 'user' table -- this doesn't look like a Gitea backup. Nothing was changed." DUMP_REPOS="$(sqlite3 "$STAGE/gitea.db" 'SELECT COUNT(*) FROM repository;' 2>/dev/null || echo '?')" } confirm_restore() { local origin="" if [[ -n "$BACKUP_CREATED" || -n "$BACKUP_HOST" ]]; then origin="Made on ${BACKUP_HOST:-?} at ${BACKUP_CREATED:-?} (Gitea ${BACKUP_VERSION:-?})." fi cat <.bak.) The accounts made by setup-git-server.sh (e.g. the initial admin) are replaced by the backup's accounts -- you'll log in with the credentials from the old server. EOF if [[ "$ASSUME_YES" == "true" ]]; then return 0 fi [[ -t 0 ]] || error_exit "Not running interactively; re-run with --yes to confirm." local answer="" read -r -p "Continue? [y/N] " answer [[ "$answer" =~ ^[Yy]([Ee][Ss])?$ ]] || error_exit "Aborted; nothing was changed." } stop_service() { if systemctl is-active --quiet gitea 2>/dev/null; then log "Stopping Gitea..." systemctl stop gitea fi } replace_dir() { # usage: replace_dir local src="$1" dest="$2" if [[ -d "$dest" && -n "$(ls -A -- "$dest")" ]]; then mv -- "$dest" "${dest}.bak.${TS}" BACKUPS+=("${dest}.bak.${TS}") elif [[ -d "$dest" ]]; then rmdir -- "$dest" fi mkdir -p -- "$(dirname -- "$dest")" if [[ -d "$src" ]]; then mv -- "$src" "$dest" else mkdir -p -- "$dest" fi } swap_in_files() { TS="$(date +%s)" SWAPPED="true" log "Moving the current data aside and putting the restored data in place..." cp -p -- "$GITEA_CONFIG" "${GITEA_CONFIG}.bak.${TS}" BACKUPS+=("${GITEA_CONFIG}.bak.${TS}") # Old dumps keep their config under data/conf (the current app.ini stays in # charge). Sessions, indexes and queues are caches Gitea rebuilds itself and # can be incompatible between versions, so they are not carried over. rm -rf -- "$DUMP_ROOT/data/conf" "$DUMP_ROOT/data/sessions" \ "$DUMP_ROOT/data/indexers" "$DUMP_ROOT/data/queues" # In this layout the database lives in data/ and is moved aside with it; # if app.ini points somewhere else, set the old file aside separately. if [[ "$DB_PATH" != "${GITEA_WORK_DIR}/data/"* && -f "$DB_PATH" ]]; then mv -- "$DB_PATH" "${DB_PATH}.bak.${TS}" BACKUPS+=("${DB_PATH}.bak.${TS}") fi replace_dir "$DUMP_ROOT/data" "${GITEA_WORK_DIR}/data" replace_dir "$DUMP_ROOT/custom" "${GITEA_WORK_DIR}/custom" replace_dir "$DUMP_ROOT/repos" "$REPO_ROOT" mkdir -p -- "$(dirname -- "$DB_PATH")" rm -f -- "$DB_PATH" "${DB_PATH}-wal" "${DB_PATH}-shm" mv -- "$STAGE/gitea.db" "$DB_PATH" } restore_secrets() { [[ -n "$DUMP_INI" ]] || return 0 log "Carrying the secrets over from the backup's app.ini..." # Keep the old config around for reference (mailer, OAuth, etc. settings). install -m 640 -o root -g "$GITEA_GROUP" "$DUMP_INI" "${GITEA_CONFIG}.from-dump" # SECRET_KEY encrypts 2FA secrets and other values stored in the database, # so it must be the one the backup was made with. The JWT secrets and # INTERNAL_TOKEN are carried over too so tokens issued before keep working. local pair section key value for pair in security:SECRET_KEY security:INTERNAL_TOKEN server:LFS_JWT_SECRET oauth2:JWT_SECRET; do section="${pair%%:*}" key="${pair##*:}" value="$(ini_get "$DUMP_INI" "$section" "$key")" if [[ -z "$value" ]]; then warn "No ${key} in the backup's app.ini (maybe set through a *_URI option); keeping the current value." continue fi ini_set "$GITEA_CONFIG" "$section" "$key" "$value" \ || warn "Could not set ${key} in [${section}] of ${GITEA_CONFIG}; set it by hand from ${GITEA_CONFIG}.from-dump." done } fix_permissions() { log "Fixing ownership and permissions..." chown -R "${GITEA_USER}:${GITEA_GROUP}" "${GITEA_WORK_DIR}/data" "${GITEA_WORK_DIR}/custom" "$REPO_ROOT" chmod 750 "${GITEA_WORK_DIR}/data" "${GITEA_WORK_DIR}/custom" "$REPO_ROOT" chmod 640 "$DB_PATH" install -d -m 700 -o "$GITEA_USER" -g "$GITEA_GROUP" "${GITEA_HOME}/.ssh" if [[ ! -f "${GITEA_HOME}/.ssh/authorized_keys" ]]; then install -m 600 -o "$GITEA_USER" -g "$GITEA_GROUP" /dev/null "${GITEA_HOME}/.ssh/authorized_keys" fi if command -v restorecon &>/dev/null; then restorecon -R "$GITEA_WORK_DIR" "$GITEA_CONFIG_DIR" "$GITEA_HOME" &>/dev/null || true fi } migrate_and_regenerate() { # Brings the restored schema up to the installed Gitea version. log "Migrating the database to this Gitea version..." run_as_gitea "$GITEA_BIN" --config "$GITEA_CONFIG" migrate \ || error_exit "'gitea migrate' failed. If it says the database is newer than this Gitea, reinstall a newer version with setup-git-server.sh --version and run this restore again." # The hooks inside every repo point at the old server's binary/config paths. log "Regenerating repository hooks..." run_as_gitea "$GITEA_BIN" --config "$GITEA_CONFIG" admin regenerate hooks \ || warn "Regenerating hooks failed; pushes may not work. Retry: sudo -u ${GITEA_USER} ${GITEA_BIN} --config ${GITEA_CONFIG} admin regenerate hooks" # Rebuilds ~/.ssh/authorized_keys from the keys stored in the database. log "Regenerating authorized_keys from the restored SSH keys..." run_as_gitea "$GITEA_BIN" --config "$GITEA_CONFIG" admin regenerate keys \ || warn "Regenerating keys failed; SSH pushes may not work. Retry: sudo -u ${GITEA_USER} ${GITEA_BIN} --config ${GITEA_CONFIG} admin regenerate keys" } start_service() { log "Starting Gitea..." systemctl start gitea local port port="$(ini_get "$GITEA_CONFIG" server HTTP_PORT)" : "${port:=3000}" for _ in $(seq 1 60); do if curl -fsS -o /dev/null "http://127.0.0.1:${port}/api/healthz" 2>/dev/null; then log "Gitea is up." return 0 fi sleep 1 done warn "Gitea did not answer on port ${port} within 60s. Last log lines:" journalctl -u gitea -n 30 --no-pager >&2 || true error_exit "Gitea failed to start -- fix the problem shown above (full log: journalctl -u gitea)." } print_summary() { local domain http_port domain="$(ini_get "$GITEA_CONFIG" server DOMAIN)" http_port="$(ini_get "$GITEA_CONFIG" server HTTP_PORT)" cat </.git - Settings that are NOT carried over (domain, ports, mailer, OAuth, ...) stay as set up on this server. The old config is saved at: ${GITEA_CONFIG}.from-dump Your previous state was kept; delete it once you've verified the restore: EOF if (( ${#BACKUPS[@]} )); then printf ' %s\n' "${BACKUPS[@]}" fi echo "============================================================================" } # ---------------------------------------------------------------------------- # Main # ---------------------------------------------------------------------------- main() { require_root install_dependencies check_installation extract_dump check_backup_version inspect_dump stage_database confirm_restore stop_service swap_in_files restore_secrets fix_permissions migrate_and_regenerate start_service print_summary } # Only run when executed, not when sourced (handy for testing single functions). if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then parse_args "$@" main fi