diff --git a/gitea-host/restore_dump.sh b/gitea-host/restore_dump.sh new file mode 100644 index 0000000..1b3eb48 --- /dev/null +++ b/gitea-host/restore_dump.sh @@ -0,0 +1,531 @@ +#!/usr/bin/env bash +# +# restore-gitea-dump.sh +# +# Companion to setup-git-server.sh: restores a backup made with `gitea dump` +# into a Gitea instance that was installed by that script, 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 dump into a staging directory and imports its SQL into a +# brand-new SQLite file there -- nothing on the server is touched until +# that has worked; +# 2. stops Gitea and moves the current data / custom / repositories aside +# (as .bak.); +# 3. puts the dump's data, custom files, repositories and database in place; +# 4. copies the secrets (SECRET_KEY, JWT secrets, ...) from the dump'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. +# +# Install a Gitea version equal to (or newer than) the one that made the dump: +# sudo ./setup-git-server.sh --version +# +# Usage: +# sudo ./restore-gitea-dump.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="" +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 ./restore-gitea-dump.sh [options] + +Restores a `gitea dump` archive (.zip, .tar, .tar.gz, .tar.bz2, .tar.xz, +.tar.zst) into a Gitea instance installed by setup-git-server.sh. + +Options: + -y, --yes Don't ask for confirmation + --tmp-dir Where the dump is unpacked (default: the Gitea work + dir; needs free space of about twice the dump 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 dumps 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 dump 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 dump 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" +} + +# ---------------------------------------------------------------------------- +# 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 dumps)..." + 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 'gitea dump' file (made without --skip-db)?" + DUMP_ROOT="$(dirname -- "$sql")" +} + +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 dump comes from a '${src_db_type}' database. Only SQLite dumps can be restored by this script." + fi + else + warn "No app.ini in the dump: 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 dump 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 dump. Nothing was changed." + DUMP_REPOS="$(sqlite3 "$STAGE/gitea.db" 'SELECT COUNT(*) FROM repository;' 2>/dev/null || echo '?')" +} + +confirm_restore() { + cat <.bak.) + +The accounts made by setup-git-server.sh (e.g. the initial admin) are replaced +by the dump'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 dump'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 dump 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 dump'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 + 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