diff --git a/git/install.sh b/gitea-host/install.sh similarity index 100% rename from git/install.sh rename to gitea-host/install.sh diff --git a/gitea-podman/gitea-podman-backup.sh b/gitea-podman/gitea-podman-backup.sh new file mode 100644 index 0000000..1019ab4 --- /dev/null +++ b/gitea-podman/gitea-podman-backup.sh @@ -0,0 +1,531 @@ +#!/usr/bin/env bash +# +# gitea-podman-backup.sh +# +# Creates a consistent, verified backup of ALL data of the Gitea container installed by +# setup-gitea-podman.sh: repositories, SQLite database, LFS objects, attachments, avatars, +# packages, SSH host keys, custom/ files and app.ini (whose secrets the encrypted data in +# the database depends on -- a database backup without them is not restorable). +# +# How it stays consistent +# The service is stopped (gracefully, so Gitea and SQLite shut down cleanly), the data +# directories are archived while nothing can write to them, and the service is started +# again. Typical downtime: seconds to a few minutes, depending on the amount of data. +# The service is ALWAYS started again if this script stopped it -- also on errors and +# on Ctrl-C/SIGTERM. +# +# What "verified" means (all of it happens before an old backup could ever be pruned) +# 1. the archive is written under a temporary name (*.partial) and only renamed into +# place after everything below succeeded, so a crash never leaves a half-written +# file that looks like a backup; +# 2. gzip integrity test + tar structure test + required members are present; +# 3. the archive is compared byte-for-byte against the live data (tar --compare) while +# the service is still stopped (skip with --fast); +# 4. a SHA-256 sidecar file is written next to the archive; +# 5. after the restart, the SQLite database inside the archive is extracted to a scratch +# directory and checked with PRAGMA integrity_check. +# +# Retention: with --keep N the newest N backups are kept and older ones deleted -- but only +# after the new backup passed every check, and only files this script named itself. +# Without --keep nothing is ever deleted. +# +# Usage: sudo gitea-podman-backup [options] +# Options: +# --dest DIR backup directory (default: BACKUP_DIR from the config file) +# --keep N keep only the newest N backups (N >= 1) after a fully successful run +# --dry-run check everything and print the plan, but stop/change nothing +# --fast skip the byte-for-byte comparison (shorter downtime, weaker check) +# --skip-db-check don't run sqlite3 integrity_check on the archived database +# --wait SECONDS how long to wait for Gitea to be healthy again (default 300) +# --quiet only print warnings and errors (for timers/cron) +# -h, --help +# +# Exit codes: 0 backup complete and verified +# 1 failed -- no new backup was created (the service was restarted) +# 2 usage error +# 3 backup file created, but a post-check needs attention (database +# integrity problem, or Gitea not healthy after the restart) -- old +# backups were NOT pruned +# 75 another backup/restore is running (lock held) +# +# Backups contain secrets (app.ini, password hashes, 2FA seeds): they are written with +# mode 0600 in a 0700 directory. Copy them off this machine -- a backup on the same disk +# does not protect against losing the disk. + +set -euo pipefail +umask 077 + +SCRIPT_VERSION="1" +CONF_FILE="${GITEA_PODMAN_CONF:-/etc/gitea-podman/gitea-podman.conf}" +LOCK_FILE="${GITEA_PODMAN_LOCK:-/run/lock/gitea-podman.lock}" +QUADLET_DIR="${GITEA_QUADLET_DIR:-/etc/containers/systemd}" + +OPT_DEST=""; OPT_KEEP=""; DRY_RUN=0; FAST=0; QUIET=0; SKIP_DB_CHECK=0; WAIT_SECS=300 + +# Filled from the config file +DATA_ROOT=""; BACKUP_DIR=""; CONTAINER_NAME=""; UNIT_NAME=""; IMAGE="" +CONTAINER_UID=""; CONTAINER_GID=""; HTTP_BIND=""; HTTP_PORT=""; HEALTH_URL="" + +# Run state, used by the cleanup trap +STOPPED_BY_US=0 +WORKDIR_TMP="" +PARTIAL="" +FINAL="" +IMAGE_ID=""; IMAGE_DIGEST=""; GITEA_VERSION_TAG="unknown" +WORK_BYTES=0; CONFIG_BYTES=0 +DOWNTIME_SECS=0 + +log() { if (( ! QUIET )); then printf '%s [+] %s\n' "$(date '+%F %T')" "$*"; fi; } +warn() { printf '%s [!] %s\n' "$(date '+%F %T')" "$*" >&2; } +die() { printf '%s [x] %s\n' "$(date '+%F %T')" "$*" >&2; exit 1; } + +usage() { sed -n '2,/^set -euo/p' "$0" | sed '$d' | sed 's/^# \{0,1\}//'; } + +# ---------------------------------------------------------------------------- +# Config file (plain KEY=value lines written by the setup script; never sourced) +# ---------------------------------------------------------------------------- +conf_get() { + [[ -r "$CONF_FILE" ]] || return 0 + sed -n "s/^${1}=//p" "$CONF_FILE" | head -n1 +} + +ini_get() { # ini_get
+ awk -v sec="$2" -v key="$3" ' + /^[[:space:]]*[;#]/ { next } + /^[[:space:]]*\[/ { cur = $0; gsub(/[][[:space:]]/, "", cur); next } + { k = $0; sub(/[[:space:]]*=.*/, "", k); gsub(/^[[:space:]]+/, "", k) + if (cur == sec && k == key) { v = $0; sub(/^[^=]*=[[:space:]]*/, "", v); sub(/[[:space:]]+$/, "", v); print v; exit } }' "$1" +} + +load_conf() { + [[ -f "$CONF_FILE" ]] || die "Config file $CONF_FILE not found -- run setup-gitea-podman.sh first." + local owner mode + owner="$(stat -c '%u' "$CONF_FILE")"; mode="$(stat -c '%a' "$CONF_FILE")" + [[ "$owner" == 0 ]] || die "$CONF_FILE must be owned by root." + (( (8#$mode & 8#022) == 0 )) || die "$CONF_FILE must not be writable by group/others." + + DATA_ROOT="$(conf_get DATA_ROOT)" + BACKUP_DIR="$(conf_get BACKUP_DIR)" + CONTAINER_NAME="$(conf_get CONTAINER_NAME)" + UNIT_NAME="$(conf_get UNIT_NAME)" + IMAGE="$(conf_get IMAGE)" + CONTAINER_UID="$(conf_get CONTAINER_UID)" + CONTAINER_GID="$(conf_get CONTAINER_GID)" + HTTP_BIND="$(conf_get HTTP_BIND)" + HTTP_PORT="$(conf_get HTTP_PORT)" + local k + for k in DATA_ROOT BACKUP_DIR CONTAINER_NAME UNIT_NAME IMAGE CONTAINER_UID CONTAINER_GID HTTP_BIND HTTP_PORT; do + [[ -n "${!k}" ]] || die "$k is missing in $CONF_FILE." + done + [[ "$DATA_ROOT" =~ ^/[A-Za-z0-9_./-]+$ ]] || die "Suspicious DATA_ROOT in $CONF_FILE." + [[ "$BACKUP_DIR" =~ ^/[A-Za-z0-9_./-]+$ ]] || die "Suspicious BACKUP_DIR in $CONF_FILE." + local host="$HTTP_BIND" + [[ "$host" != "0.0.0.0" ]] || host="127.0.0.1" + HEALTH_URL="http://${host}:${HTTP_PORT}/api/healthz" +} + +# ---------------------------------------------------------------------------- +# Service helpers +# ---------------------------------------------------------------------------- +svc_active() { + local s + s="$(systemctl is-active "$UNIT_NAME" 2>/dev/null || true)" + case "$s" in active|activating|reloading) return 0 ;; *) return 1 ;; esac +} + +container_running() { + [[ "$(podman inspect --format '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null || true)" == "true" ]] +} + +wait_healthy() { # wait_healthy + local limit="$1" i + for (( i = 0; i < limit; i += 2 )); do + if curl -fsS --max-time 5 -o /dev/null "$HEALTH_URL" 2>/dev/null; then return 0; fi + sleep 2 + done + return 1 +} + +# ---------------------------------------------------------------------------- +# Cleanup: always undo what we changed (partial files, temp dir, stopped service) +# ---------------------------------------------------------------------------- +# shellcheck disable=SC2329 # invoked via 'trap cleanup EXIT', not called directly +cleanup() { + local rc=$? + trap - EXIT INT TERM + if [[ -n "$PARTIAL" && -e "$PARTIAL" ]]; then rm -f -- "$PARTIAL"; fi + if [[ -n "$WORKDIR_TMP" && -d "$WORKDIR_TMP" && "$WORKDIR_TMP" == "${BACKUP_DIR}/.tmp."* ]]; then + rm -rf -- "$WORKDIR_TMP" + fi + if (( STOPPED_BY_US )); then + warn "Starting $UNIT_NAME again (it was stopped for the backup)..." + if systemctl start "$UNIT_NAME"; then + STOPPED_BY_US=0 + else + warn "!!! COULD NOT START $UNIT_NAME -- start it manually: systemctl start $UNIT_NAME" + (( rc != 0 )) || rc=1 + fi + fi + exit "$rc" +} + +# ---------------------------------------------------------------------------- +# Arguments +# ---------------------------------------------------------------------------- +need_arg() { [[ $# -ge 2 ]] || { warn "Option $1 needs a value (see --help)"; exit 2; }; } + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --dest) need_arg "$@"; OPT_DEST="$2"; shift 2 ;; + --keep) need_arg "$@"; OPT_KEEP="$2"; shift 2 ;; + --wait) need_arg "$@"; WAIT_SECS="$2"; shift 2 ;; + --dry-run) DRY_RUN=1; shift ;; + --fast) FAST=1; shift ;; + --skip-db-check) SKIP_DB_CHECK=1; shift ;; + --quiet) QUIET=1; shift ;; + -h|--help) usage; exit 0 ;; + *) warn "Unknown option: $1 (see --help)"; exit 2 ;; + esac + done + if [[ -n "$OPT_KEEP" ]]; then + if [[ ! "$OPT_KEEP" =~ ^[0-9]+$ ]] || (( OPT_KEEP < 1 )); then + warn "--keep needs a number >= 1"; exit 2 + fi + fi + [[ "$WAIT_SECS" =~ ^[0-9]+$ ]] || { warn "--wait needs a number of seconds"; exit 2; } +} + +# ---------------------------------------------------------------------------- +# Steps +# ---------------------------------------------------------------------------- +preflight() { + [[ $EUID -eq 0 ]] || die "Run this as root (sudo)." + local c + for c in podman systemctl tar gzip sha256sum flock curl awk sed df du stat realpath date; do + command -v "$c" >/dev/null 2>&1 || die "Required command not found: $c" + done + if (( ! SKIP_DB_CHECK )); then + command -v sqlite3 >/dev/null 2>&1 || die "sqlite3 is needed to verify the database backup (install it, or pass --skip-db-check)." + fi + load_conf + [[ -n "$OPT_DEST" ]] && BACKUP_DIR="$OPT_DEST" + [[ "$BACKUP_DIR" =~ ^/[A-Za-z0-9_./-]+$ ]] || die "--dest must be an absolute path (letters, digits, _ . / - only)." + + [[ -d "$DATA_ROOT/work" && -d "$DATA_ROOT/config" ]] \ + || die "$DATA_ROOT/work or $DATA_ROOT/config is missing -- is the data disk mounted? Refusing to back up an empty tree." + [[ -f "$DATA_ROOT/config/app.ini" ]] || die "$DATA_ROOT/config/app.ini is missing -- refusing to make a backup without the config/secrets." + [[ -e "$DATA_ROOT/work/.gitea-podman-data" ]] \ + || die "Marker file $DATA_ROOT/work/.gitea-podman-data is missing -- this does not look like the Gitea data directory." + systemctl cat "$UNIT_NAME" >/dev/null 2>&1 || die "systemd unit $UNIT_NAME not found -- run setup-gitea-podman.sh first." + + # The backup directory must be separate from the data it protects. + local rd rb + rd="$(realpath -m -- "$DATA_ROOT")"; rb="$(realpath -m -- "$BACKUP_DIR")" + case "$rb/" in "$rd/"*) die "Backup directory $rb is inside the data directory $rd." ;; esac + case "$rd/" in "$rb/"*) die "Data directory $rd is inside the backup directory $rb." ;; esac + BACKUP_DIR="$rb" + + if [[ ! -d "$BACKUP_DIR" ]]; then + if (( DRY_RUN )); then log "(dry-run) would create $BACKUP_DIR"; else mkdir -p -- "$BACKUP_DIR"; chmod 0700 -- "$BACKUP_DIR"; fi + fi + if [[ -d "$BACKUP_DIR" ]]; then + [[ -w "$BACKUP_DIR" ]] || die "$BACKUP_DIR is not writable." + if [[ "$(stat -c %d -- "$DATA_ROOT")" == "$(stat -c %d -- "$BACKUP_DIR")" ]]; then + warn "$BACKUP_DIR is on the same filesystem as the data: this does not protect against disk failure. Copy the backups elsewhere too." + fi + fi +} + +check_space() { + WORK_BYTES="$(du -sb -- "$DATA_ROOT/work" | awk '{print $1}')" + CONFIG_BYTES="$(du -sb -- "$DATA_ROOT/config" | awk '{print $1}')" + local used need avail probe="$BACKUP_DIR" + used=$(( WORK_BYTES + CONFIG_BYTES )) + need=$(( used + used / 10 + 268435456 )) # +10% and 256 MiB headroom, assuming zero compression + [[ -d "$probe" ]] || probe="$(dirname -- "$BACKUP_DIR")" + avail="$(df -B1 --output=avail -- "$probe" | tail -n1 | tr -d ' ')" + log "Data size: $(numfmt --to=iec "$used"); free space in backup dir: $(numfmt --to=iec "$avail")" + if (( avail < need )); then + die "Not enough free space for a safe backup: need about $(numfmt --to=iec "$need"), have $(numfmt --to=iec "$avail"). Nothing was stopped or changed." + fi +} + +collect_info() { # while the container still exists + IMAGE_ID="$(podman image inspect --format '{{.Id}}' "$IMAGE" 2>/dev/null || true)" + IMAGE_DIGEST="$(podman image inspect --format '{{if .RepoDigests}}{{index .RepoDigests 0}}{{end}}' "$IMAGE" 2>/dev/null || true)" + local v + v="$(sed -nE 's/.*:([0-9]+\.[0-9]+\.[0-9]+)(-rootless)?$/\1/p' <<<"$IMAGE")" + GITEA_VERSION_TAG="${v:-unknown}" +} + +warn_if_data_outside_volumes() { # data written to the container's own layer would NOT be in the backup + container_running || return 0 + local unexpected + # podman diff reports the two bind-mount points themselves (and their parent + # directory chain, created as a side effect of setting up the mount) as "added" + # even though their CONTENTS are the host volumes, not container-layer writes. + # Excluding exactly those directories (not their contents) leaves genuine + # container-layer writes elsewhere still flagged. + unexpected="$(podman diff "$CONTAINER_NAME" 2>/dev/null | awk ' + $2 == "/etc" || $2 == "/tmp" || $2 ~ /^\/tmp\// || $2 == "/run" || $2 ~ /^\/run\// || $2 ~ /^\/dev\// { next } + $2 == "/etc/gitea" || $2 == "/var" || $2 == "/var/lib" || $2 == "/var/lib/gitea" { next } + { print }' | head -n 10)" || true + if [[ -n "$unexpected" ]]; then + warn "The container has files OUTSIDE its persistent volumes; they are NOT covered by this backup:" + printf '%s\n' "$unexpected" | sed 's/^/ /' >&2 + fi +} + +stop_service() { + if svc_active; then + log "Stopping $UNIT_NAME (graceful; up to 2.5 min)..." + STOPPED_BY_US=1 # set first: an interrupted/failed stop is undone by the trap too + systemctl stop "$UNIT_NAME" || die "Could not stop $UNIT_NAME." + else + log "$UNIT_NAME is not running; backing up the data as it is." + fi + if container_running; then + die "Container $CONTAINER_NAME is still running after the stop -- refusing to back up live data." + fi +} + +write_manifest() { + local f="$WORKDIR_TMP/BACKUP-INFO.txt" app="$DATA_ROOT/config/app.ini" + mkdir -p -- "$WORKDIR_TMP/infra" + [[ -f "$CONF_FILE" ]] && cp -p -- "$CONF_FILE" "$WORKDIR_TMP/infra/gitea-podman.conf" + local q="${QUADLET_DIR}/${UNIT_NAME%.service}.container" + [[ -f "$q" ]] && cp -p -- "$q" "$WORKDIR_TMP/infra/$(basename -- "$q")" + { + echo "format=1" + echo "created_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo "hostname=$(hostname 2>/dev/null || echo unknown)" + echo "script_version=$SCRIPT_VERSION" + echo "container=$CONTAINER_NAME" + echo "image=$IMAGE" + echo "image_id=$IMAGE_ID" + echo "image_digest=$IMAGE_DIGEST" + echo "gitea_version=$GITEA_VERSION_TAG" + echo "data_root=$DATA_ROOT" + echo "container_uid=$CONTAINER_UID" + echo "container_gid=$CONTAINER_GID" + echo "work_bytes=$WORK_BYTES" + echo "config_bytes=$CONFIG_BYTES" + echo "db_type=$(ini_get "$app" database DB_TYPE)" + echo "root_url=$(ini_get "$app" server ROOT_URL)" + echo "ssh_port=$(ini_get "$app" server SSH_PORT)" + } > "$f" +} + +create_archive() { + log "Archiving $DATA_ROOT/{work,config} -> $(basename -- "$PARTIAL") ..." + local st + set +e + tar --create --file=- --numeric-owner \ + -C "$WORKDIR_TMP" BACKUP-INFO.txt infra \ + -C "$DATA_ROOT" config work \ + | gzip -c > "$PARTIAL" + st=("${PIPESTATUS[@]}") + set -e + # tar exit 1 means "a file changed while being read" -- unacceptable for a consistent snapshot. + [[ "${st[0]}" -eq 0 && "${st[1]}" -eq 0 ]] || die "Archiving failed (tar exit ${st[0]}, gzip exit ${st[1]}); no backup was created." + sync -f -- "$PARTIAL" || true +} + +verify_archive_structure() { + log "Verifying archive integrity..." + gzip -t -- "$PARTIAL" || die "gzip integrity test failed -- discarding the archive." + tar -tzf "$PARTIAL" > "$WORKDIR_TMP/listing.txt" || die "tar cannot read the archive back -- discarding it." + local m + for m in BACKUP-INFO.txt config/app.ini work/.gitea-podman-data; do + grep -qxF -- "$m" "$WORKDIR_TMP/listing.txt" || die "Archive is missing required member '$m' -- discarding it." + done + if [[ "$DB_REL" != "" ]]; then + grep -qxF -- "$DB_REL" "$WORKDIR_TMP/listing.txt" || die "Archive is missing the database file '$DB_REL' -- discarding it." + fi +} + +verify_archive_content() { + if (( FAST )); then + warn "--fast: skipping the byte-for-byte comparison of the archive with the live data." + return 0 + fi + log "Comparing the archive with the (stopped) data, byte for byte..." + tar -dzf "$PARTIAL" --numeric-owner -C "$DATA_ROOT" config work \ + || die "The archive does NOT match the data on disk -- discarding it (nothing was pruned)." + tar -dzf "$PARTIAL" --numeric-owner -C "$WORKDIR_TMP" BACKUP-INFO.txt infra \ + || die "The archive's metadata does not match -- discarding it." +} + +finalize_archive() { + local hash + hash="$(sha256sum -- "$PARTIAL" | awk '{print $1}')" + [[ -n "$hash" ]] || die "Could not compute the SHA-256 of the archive." + # Sidecar first, archive last: a crash in between leaves at most an orphan sidecar. + printf '%s %s\n' "$hash" "$(basename -- "$FINAL")" > "${FINAL}.sha256.tmp" + mv -T -- "${FINAL}.sha256.tmp" "${FINAL}.sha256" + [[ ! -e "$FINAL" ]] || die "$FINAL already exists -- refusing to overwrite a backup." + mv -T -- "$PARTIAL" "$FINAL" + PARTIAL="" + sync -f -- "$BACKUP_DIR" || true + ARCHIVE_SHA="$hash" +} + +restart_service() { + (( STOPPED_BY_US )) || return 0 + log "Starting $UNIT_NAME again..." + systemctl start "$UNIT_NAME" || die "COULD NOT START $UNIT_NAME (the backup itself is safe): start it manually." + STOPPED_BY_US=0 + DOWNTIME_SECS=$(( SECONDS - T_STOP )) +} + +post_verify() { # exit-status contract: number of problems found + local problems=0 + log "Re-checking the finished archive..." + if ! ( cd -- "$BACKUP_DIR" && sha256sum --check --status -- "$(basename -- "$FINAL").sha256" ); then + warn "SHA-256 of the finished archive does not match its sidecar file!" + problems=$(( problems + 1 )) + fi + if (( ! SKIP_DB_CHECK )) && [[ -n "$DB_REL" ]]; then + local tmp="$WORKDIR_TMP/dbcheck" res + mkdir -p -- "$tmp" + if tar -xzf "$FINAL" -C "$tmp" --numeric-owner --wildcards "${DB_REL}*" 2>/dev/null && [[ -f "$tmp/$DB_REL" ]]; then + res="$(sqlite3 "$tmp/$DB_REL" 'PRAGMA integrity_check;' 2>&1 || true)" + if [[ "$res" == "ok" ]]; then + log "SQLite integrity_check on the archived database: ok" + else + warn "SQLite integrity_check on the archived database FAILED:" + printf '%s\n' "$res" | head -n 8 | sed 's/^/ /' >&2 + problems=$(( problems + 1 )) + fi + else + warn "Could not extract the database from the archive for the integrity check." + problems=$(( problems + 1 )) + fi + fi + return "$problems" +} + +prune_old() { + [[ -n "$OPT_KEEP" ]] || return 0 + local f n=0 removed=0 + while IFS= read -r f; do + n=$(( n + 1 )) + if (( n > OPT_KEEP )); then + log "Pruning old backup: $(basename -- "$f")" + rm -f -- "$f" "${f}.sha256" + removed=$(( removed + 1 )) + fi + done < <(find "$BACKUP_DIR" -maxdepth 1 -type f -name 'gitea-backup-????????T??????Z.tar.gz' | sort -r) + log "Retention: keeping the newest $OPT_KEEP; removed $removed." +} + +cleanup_stale() { # leftovers of a crashed earlier run (we hold the lock, so nobody else is using them) + [[ -d "$BACKUP_DIR" ]] || return 0 + find "$BACKUP_DIR" -maxdepth 1 -type f -name 'gitea-backup-*.tar.gz.partial' -delete + find "$BACKUP_DIR" -maxdepth 1 -type f -name 'gitea-backup-*.sha256.tmp' -delete + find "$BACKUP_DIR" -maxdepth 1 -type d -name '.tmp.*' -exec rm -rf -- {} + +} + +# ---------------------------------------------------------------------------- +main() { + parse_args "$@" + preflight + + local app="$DATA_ROOT/config/app.ini" dbtype dbpath + dbtype="$(ini_get "$app" database DB_TYPE)" + DB_REL="" + if [[ "$dbtype" == "sqlite3" ]]; then + dbpath="$(ini_get "$app" database PATH)" + case "$dbpath" in + /var/lib/gitea/*) DB_REL="work/${dbpath#/var/lib/gitea/}" ;; + *) warn "Unexpected SQLite path '$dbpath' in app.ini; the database check will be skipped." ;; + esac + else + warn "DB_TYPE is '${dbtype:-unset}', not sqlite3 -- this script only knows how to verify SQLite." + fi + + # Locking is handled by the flock wrapper at the bottom of this file -- by + # the time main() runs, the lock is already held for this whole invocation. + check_space + local ts + ts="$(date -u +%Y%m%dT%H%M%SZ)" + FINAL="${BACKUP_DIR}/gitea-backup-${ts}.tar.gz" + + if (( DRY_RUN )); then + log "(dry-run) would stop $UNIT_NAME, write $FINAL (+ .sha256), verify it and start the service again." + [[ -n "$OPT_KEEP" ]] && log "(dry-run) would then keep only the newest $OPT_KEEP backups." + exit 0 + fi + + trap cleanup EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + + cleanup_stale + WORKDIR_TMP="$(mktemp -d "${BACKUP_DIR}/.tmp.XXXXXXXX")" + PARTIAL="${FINAL}.partial" + + warn_if_data_outside_volumes + collect_info + local was_active=0 + if svc_active; then was_active=1; fi + + if (( ! was_active )) && container_running; then + die "Container $CONTAINER_NAME is running but $UNIT_NAME is not active -- it is not managed by systemd; refusing to touch it." + fi + + stop_service + T_STOP=$SECONDS + write_manifest + create_archive + verify_archive_structure + verify_archive_content + finalize_archive + if (( was_active )); then restart_service; else DOWNTIME_SECS=$(( SECONDS - T_STOP )); fi + + local rc=0 + if (( was_active )); then + log "Waiting for Gitea to answer on $HEALTH_URL ..." + if wait_healthy "$WAIT_SECS"; then + log "Gitea is healthy again." + else + warn "Gitea did not become healthy within ${WAIT_SECS}s after the restart (the backup itself is complete). Check: journalctl -u $UNIT_NAME" + rc=3 + fi + fi + + if ! post_verify; then rc=3; fi + + local size + size="$(numfmt --to=iec "$(stat -c %s -- "$FINAL")")" + if (( rc == 0 )); then + prune_old + log "DONE: $FINAL ($size), sha256 ${ARCHIVE_SHA:0:16}..., downtime ${DOWNTIME_SECS}s." + else + warn "Backup written to $FINAL ($size) but needs attention (see above). Old backups were NOT pruned." + fi + exit "$rc" +} + +# One backup/restore at a time -- for real, this time. A plain 'exec 9>lockfile; +# flock -n 9' inside main() looks right but isn't: that fd has no close-on-exec +# set, so the instant this script starts the Gitea container, podman's conmon +# (a daemon that outlives this script entirely) inherits it via fork/exec and +# keeps the lock held forever -- every later backup would then wedge on a false +# "already running", from someone who last looked stopped the container weeks +# ago. Relaunching under the external `flock` command sidesteps this: flock +# opens the lockfile itself with O_CLOEXEC, so the fd is already gone by the +# time this script (running as flock's child) even starts, and nothing this +# script spawns can inherit what it never had. +if [[ "${GITEA_PODMAN_BACKUP_RELAUNCHED:-}" != "1" ]]; then + for a in "$@"; do [[ "$a" == "-h" || "$a" == "--help" ]] && { usage; exit 0; }; done + mkdir -p -- "$(dirname -- "$LOCK_FILE")" + export GITEA_PODMAN_BACKUP_RELAUNCHED=1 + rc=0 + flock -n -E 75 "$LOCK_FILE" "$0" "$@" || rc=$? + [[ $rc -eq 75 ]] && warn "Another gitea-podman backup/restore is running (lock: $LOCK_FILE)." + exit "$rc" +fi + +main "$@" diff --git a/gitea-podman/gitea-podman-restore.sh b/gitea-podman/gitea-podman-restore.sh new file mode 100644 index 0000000..be85657 --- /dev/null +++ b/gitea-podman/gitea-podman-restore.sh @@ -0,0 +1,418 @@ +#!/usr/bin/env bash +# +# gitea-podman-restore.sh +# +# Restores Gitea data from an archive made by gitea-podman-backup.sh. This is +# a destructive operation by nature (it replaces live data), so the defaults +# are the safe ones: +# - with no --yes, nothing is touched: the archive is verified and a plan +# is printed. --yes is required to actually restore. +# - the CURRENT data is never deleted. It is moved aside to a timestamped +# directory next to it, and stays there until YOU remove it. If the +# restore turns out to be wrong, moving it back is a straight 'mv'. +# - the archive is verified (checksum, gzip, tar structure, required +# members) BEFORE anything live is touched, and the restored database is +# integrity-checked BEFORE the service is started on top of it -- a +# restore that fails that check leaves the service stopped rather than +# start Gitea against data that might make things worse. +# - --target-root lets you do a full restore "drill" into a scratch +# directory to prove a backup actually works, without touching the live +# service or data at all. +# +# Shares gitea-podman-backup.sh's config file and lock, so a backup and a +# restore can never run at the same time. +# +# Usage: sudo gitea-podman-restore [options] +# or: sudo gitea-podman-restore --latest [options] +# Options: +# --latest Use the newest backup in BACKUP_DIR instead of a path +# --yes Actually perform a live restore (required; without +# it this verifies the archive and prints the plan) +# --target-root DIR Extract into DIR instead of the live setup -- a +# restore drill. DIR must not already exist. Never +# touches the live service, lock, or data. +# --skip-db-check Don't run sqlite3 integrity_check (not recommended) +# --keep-previous / --delete-previous What to do with the moved-aside pre- +# restore data on a FULLY successful restore (default: +# keep -- see it named in the summary, remove it +# yourself once you've confirmed the restore is good) +# --wait SECONDS How long to wait for Gitea to become healthy +# after starting it (default 300) +# --quiet Only print warnings and errors +# -h, --help +# +# Exit codes: 0 restore complete (or, with no --yes, archive verified OK) +# 1 failed -- refused before touching live data, or the archive +# itself is bad +# 2 usage error +# 3 restore extracted, but the restored database failed its +# integrity check -- the service was NOT started; your +# previous data is untouched, at the path printed in the +# summary +# 75 a backup or restore is already running (lock held) + +set -euo pipefail +umask 077 + +CONF_FILE="${GITEA_PODMAN_CONF:-/etc/gitea-podman/gitea-podman.conf}" +LOCK_FILE="${GITEA_PODMAN_LOCK:-/run/lock/gitea-podman.lock}" + +ARCHIVE=""; USE_LATEST=0; CONFIRM=0; TARGET_ROOT=""; SKIP_DB_CHECK=0 +KEEP_PREVIOUS=1; WAIT_SECS=300; QUIET=0 + +DATA_ROOT=""; BACKUP_DIR=""; CONTAINER_NAME=""; UNIT_NAME="" +CONTAINER_UID=""; CONTAINER_GID=""; HTTP_BIND=""; HTTP_PORT=""; HEALTH_URL="" + +STOPPED_BY_US=0 +WORKDIR_TMP="" +ASIDE_WORK=""; ASIDE_CONFIG="" +DB_REL="" + +log() { if (( ! QUIET )); then printf '%s [+] %s\n' "$(date '+%F %T')" "$*"; fi; } +warn() { printf '%s [!] %s\n' "$(date '+%F %T')" "$*" >&2; } +die() { printf '%s [x] %s\n' "$(date '+%F %T')" "$*" >&2; exit 1; } +usage() { sed -n '2,/^set -euo/p' "$0" | sed '$d' | sed 's/^# \{0,1\}//'; } + +conf_get() { [[ -r "$CONF_FILE" ]] || return 0; sed -n "s/^${1}=//p" "$CONF_FILE" | head -n1; } + +ini_get() { # ini_get
+ awk -v sec="$2" -v key="$3" ' + /^[[:space:]]*[;#]/ { next } + /^[[:space:]]*\[/ { cur = $0; gsub(/[][[:space:]]/, "", cur); next } + { k = $0; sub(/[[:space:]]*=.*/, "", k); gsub(/^[[:space:]]+/, "", k) + if (cur == sec && k == key) { v = $0; sub(/^[^=]*=[[:space:]]*/, "", v); sub(/[[:space:]]+$/, "", v); print v; exit } }' "$1" 2>/dev/null +} + +load_conf() { + [[ -f "$CONF_FILE" ]] || die "Config file $CONF_FILE not found -- run setup-gitea-podman.sh first (on a fresh machine, run it BEFORE this script, so there is a service and a UID/GID to restore into)." + local owner mode + owner="$(stat -c '%u' "$CONF_FILE")"; mode="$(stat -c '%a' "$CONF_FILE")" + [[ "$owner" == 0 ]] || die "$CONF_FILE must be owned by root." + (( (8#$mode & 8#022) == 0 )) || die "$CONF_FILE must not be writable by group/others." + DATA_ROOT="$(conf_get DATA_ROOT)"; BACKUP_DIR="$(conf_get BACKUP_DIR)" + CONTAINER_NAME="$(conf_get CONTAINER_NAME)"; UNIT_NAME="$(conf_get UNIT_NAME)" + CONTAINER_UID="$(conf_get CONTAINER_UID)"; CONTAINER_GID="$(conf_get CONTAINER_GID)" + HTTP_BIND="$(conf_get HTTP_BIND)"; HTTP_PORT="$(conf_get HTTP_PORT)" + local k + for k in DATA_ROOT BACKUP_DIR CONTAINER_NAME UNIT_NAME CONTAINER_UID CONTAINER_GID HTTP_BIND HTTP_PORT; do + [[ -n "${!k}" ]] || die "$k is missing in $CONF_FILE." + done + [[ "$DATA_ROOT" =~ ^/[A-Za-z0-9_./-]+$ ]] || die "Suspicious DATA_ROOT in $CONF_FILE." + local host="$HTTP_BIND"; [[ "$host" != "0.0.0.0" ]] || host="127.0.0.1" + HEALTH_URL="http://${host}:${HTTP_PORT}/api/healthz" +} + +svc_active() { + local s; s="$(systemctl is-active "$UNIT_NAME" 2>/dev/null || true)" + case "$s" in active|activating|reloading) return 0 ;; *) return 1 ;; esac +} +container_running() { [[ "$(podman inspect --format '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null || true)" == "true" ]]; } +wait_healthy() { + local limit="$1" i + for (( i = 0; i < limit; i += 2 )); do + curl -fsS --max-time 5 -o /dev/null "$HEALTH_URL" 2>/dev/null && return 0 + sleep 2 + done + return 1 +} + +# shellcheck disable=SC2329 # invoked via 'trap cleanup EXIT' in main(), not called directly +cleanup() { + local rc=$? + trap - EXIT INT TERM + if [[ -n "$WORKDIR_TMP" && -d "$WORKDIR_TMP" ]]; then rm -rf -- "$WORKDIR_TMP"; fi + # main() explicitly clears STOPPED_BY_US at every well-handled exit point + # (success, the DB-integrity-failure case where staying stopped is the + # point, and right after a successful restart) -- so if it's still 1 here, + # something unexpected happened between stopping the service and one of + # those points, and the safest recovery is to try bringing it back up. + if (( STOPPED_BY_US )); then + warn "Starting $UNIT_NAME again (best-effort, after an unexpected error)..." + if systemctl start "$UNIT_NAME" 2>/dev/null; then + STOPPED_BY_US=0 + else + warn "!!! COULD NOT START $UNIT_NAME -- start it manually: systemctl start $UNIT_NAME" + fi + fi + exit "$rc" +} + +need_arg() { [[ $# -ge 2 ]] || { warn "Option $1 needs a value (see --help)"; exit 2; }; } + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --latest) USE_LATEST=1; shift ;; + --yes) CONFIRM=1; shift ;; + --target-root) need_arg "$@"; TARGET_ROOT="$2"; shift 2 ;; + --skip-db-check) SKIP_DB_CHECK=1; shift ;; + --keep-previous) KEEP_PREVIOUS=1; shift ;; + --delete-previous) KEEP_PREVIOUS=0; shift ;; + --wait) need_arg "$@"; WAIT_SECS="$2"; shift 2 ;; + --quiet) QUIET=1; shift ;; + -h|--help) usage; exit 0 ;; + --) shift; [[ -n "${1:-}" ]] && { ARCHIVE="$1"; shift; }; ;; + -*) warn "Unknown option: $1 (see --help)"; exit 2 ;; + *) + [[ -z "$ARCHIVE" ]] || { warn "Only one backup file may be given (see --help)"; exit 2; } + ARCHIVE="$1"; shift ;; + esac + done + [[ "$WAIT_SECS" =~ ^[0-9]+$ ]] || { warn "--wait needs a number of seconds"; exit 2; } + if (( USE_LATEST )) && [[ -n "$ARCHIVE" ]]; then warn "Give either a backup file or --latest, not both."; exit 2; fi + if (( ! USE_LATEST )) && [[ -z "$ARCHIVE" ]]; then warn "Which backup? Give a path, or --latest (see --help)"; exit 2; fi +} + +preflight() { + [[ $EUID -eq 0 ]] || die "Run this as root (sudo)." + local c + for c in podman systemctl tar gzip sha256sum flock curl awk sed stat realpath date mktemp; do + command -v "$c" >/dev/null 2>&1 || die "Required command not found: $c" + done + (( SKIP_DB_CHECK )) || command -v sqlite3 >/dev/null 2>&1 \ + || die "sqlite3 is needed to verify the restored database (install it, or pass --skip-db-check)." + load_conf + + if (( USE_LATEST )); then + ARCHIVE="$(find "$BACKUP_DIR" -maxdepth 1 -type f -name 'gitea-backup-????????T??????Z.tar.gz' 2>/dev/null | sort -r | head -n1)" + [[ -n "$ARCHIVE" ]] || die "No backups matching gitea-backup-*.tar.gz found in $BACKUP_DIR." + log "Using the newest backup: $(basename -- "$ARCHIVE")" + fi + ARCHIVE="$(realpath -e -- "$ARCHIVE" 2>/dev/null)" || die "Backup file not found: $ARCHIVE" + [[ -r "$ARCHIVE" ]] || die "Cannot read $ARCHIVE." + + if [[ -n "$TARGET_ROOT" ]]; then + TARGET_ROOT="$(realpath -m -- "$TARGET_ROOT")" + [[ ! -e "$TARGET_ROOT" ]] || die "--target-root $TARGET_ROOT already exists -- a drill restore refuses to reuse or overwrite an existing path. Pick an empty/new path." + fi + + systemctl cat "$UNIT_NAME" >/dev/null 2>&1 || [[ -n "$TARGET_ROOT" ]] \ + || die "systemd unit $UNIT_NAME not found -- run setup-gitea-podman.sh first." +} + +verify_checksum() { + local sidecar="${ARCHIVE}.sha256" + if [[ -f "$sidecar" ]]; then + log "Verifying checksum against $(basename -- "$sidecar")..." + ( cd -- "$(dirname -- "$ARCHIVE")" && sha256sum --check --status -- "$(basename -- "$sidecar")" ) \ + || die "Checksum does NOT match $(basename -- "$sidecar") -- refusing to restore from a file that doesn't match its own sidecar. Nothing was touched." + else + warn "No .sha256 sidecar next to this archive -- skipping checksum verification (structure/gzip checks below still run)." + fi +} + +verify_structure() { + log "Verifying archive integrity (gzip + tar structure)..." + gzip -t -- "$ARCHIVE" || die "gzip integrity test failed on $ARCHIVE -- refusing to restore from it. Nothing was touched." + WORKDIR_TMP="$(mktemp -d)" + tar -tzf "$ARCHIVE" > "${WORKDIR_TMP}/listing.txt" || die "tar cannot read $ARCHIVE -- refusing to restore from it. Nothing was touched." + local m + for m in BACKUP-INFO.txt config/app.ini work/.gitea-podman-data; do + grep -qxF -- "$m" "${WORKDIR_TMP}/listing.txt" \ + || die "$ARCHIVE is missing required member '$m' -- this doesn't look like a complete gitea-podman-backup.sh archive. Nothing was touched." + done + tar -xzf "$ARCHIVE" -C "$WORKDIR_TMP" --numeric-owner BACKUP-INFO.txt infra config/app.ini 2>/dev/null || true + # Same technique the backup script itself uses on the live app.ini: read + # [database] straight from the archived one rather than guess a filename + # pattern, so this stays correct even if Gitea ever changes the default. + local app="${WORKDIR_TMP}/config/app.ini" dbtype dbpath + if [[ -f "$app" ]]; then + dbtype="$(ini_get "$app" database DB_TYPE)" + if [[ "$dbtype" == "sqlite3" ]]; then + dbpath="$(ini_get "$app" database PATH)" + case "$dbpath" in + /var/lib/gitea/*) DB_REL="work/${dbpath#/var/lib/gitea/}" ;; + *) warn "Unexpected SQLite path '$dbpath' in the archived app.ini; the database check will be skipped." ;; + esac + fi + fi +} + +print_plan() { + local info="${WORKDIR_TMP}/BACKUP-INFO.txt" dest="$DATA_ROOT" + [[ -n "$TARGET_ROOT" ]] && dest="$TARGET_ROOT (drill -- live data and service are not touched)" + cat <&1 || true)" + if [[ "$res" == "ok" ]]; then + log "SQLite integrity_check: ok" + else + warn "SQLite integrity_check FAILED on the extracted database:" + printf '%s\n' "$res" | head -n 8 | sed 's/^/ /' >&2 + rc=3 + fi + fi + log "Drill extraction complete: $TARGET_ROOT" + log "Nothing on the live system was touched. Remove $TARGET_ROOT yourself when done inspecting it." + exit "$rc" +} + +stop_service() { + if svc_active; then + log "Stopping $UNIT_NAME (graceful; up to 2.5 min)..." + STOPPED_BY_US=1 + systemctl stop "$UNIT_NAME" || die "Could not stop $UNIT_NAME. Nothing was moved or overwritten." + else + log "$UNIT_NAME is not running; proceeding." + fi + if container_running; then + die "Container $CONTAINER_NAME is still running after the stop -- refusing to touch live data." + fi +} + +move_aside_current_data() { + local ts; ts="$(date -u +%Y%m%dT%H%M%SZ)" + ASIDE_WORK="${DATA_ROOT}/work.before-restore-${ts}" + ASIDE_CONFIG="${DATA_ROOT}/config.before-restore-${ts}" + log "Moving current data aside (not deleting): $(basename -- "$ASIDE_WORK"), $(basename -- "$ASIDE_CONFIG")" + if [[ -d "${DATA_ROOT}/work" ]]; then + mv -T -- "${DATA_ROOT}/work" "$ASIDE_WORK" || die "Could not move ${DATA_ROOT}/work aside -- stopping before anything is overwritten." + fi + if [[ -d "${DATA_ROOT}/config" ]]; then + mv -T -- "${DATA_ROOT}/config" "$ASIDE_CONFIG" || die "Could not move ${DATA_ROOT}/config aside -- stopping before anything is overwritten." + fi +} + +extract_data() { + log "Extracting archive into $DATA_ROOT..." + mkdir -p -- "$DATA_ROOT" + tar -xzf "$ARCHIVE" -C "$DATA_ROOT" --numeric-owner config work \ + || die "Extraction failed. Your previous data is untouched at $(basename -- "$ASIDE_WORK") / $(basename -- "$ASIDE_CONFIG")." + chown -R "${CONTAINER_UID}:${CONTAINER_GID}" "${DATA_ROOT}/work" "${DATA_ROOT}/config" + chmod 750 "${DATA_ROOT}/work" + chmod 700 "${DATA_ROOT}/config" + find "${DATA_ROOT}/config" -type f -exec chmod 600 {} + +} + +check_restored_db() { + (( ! SKIP_DB_CHECK )) || { warn "--skip-db-check: not verifying the restored database before starting Gitea."; return 0; } + [[ -n "$DB_REL" ]] || { warn "Could not identify the database file in the archive; skipping the integrity check."; return 0; } + local path="${DATA_ROOT}/${DB_REL}" + [[ -f "$path" ]] || { warn "Expected database at $path was not found after extraction; skipping the integrity check."; return 0; } + log "Checking the restored database before starting Gitea..." + local res; res="$(sqlite3 "$path" 'PRAGMA integrity_check;' 2>&1 || true)" + if [[ "$res" == "ok" ]]; then + log "SQLite integrity_check: ok" + return 0 + fi + warn "SQLite integrity_check FAILED on the restored database:" + printf '%s\n' "$res" | head -n 8 | sed 's/^/ /' >&2 + return 1 +} + +# ---------------------------------------------------------------------------- +main() { + parse_args "$@" + preflight + trap cleanup EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + verify_checksum + verify_structure + + if [[ -n "$TARGET_ROOT" ]]; then + print_plan + do_drill + fi + + print_plan + if (( ! CONFIRM )); then + log "Archive verified OK. Add --yes to actually restore." + exit 0 + fi + + stop_service + move_aside_current_data + extract_data + + if ! check_restored_db; then + warn "NOT starting $UNIT_NAME on top of a database that failed its integrity check." + warn "Your previous data is intact at:" + warn " $ASIDE_WORK" + warn " $ASIDE_CONFIG" + warn "To go back: stop here, remove what was just extracted, and move those two back into place." + STOPPED_BY_US=0 # leaving it stopped is the point; the cleanup trap should not try to start it + exit 3 + fi + + log "Starting $UNIT_NAME..." + systemctl start "$UNIT_NAME" || { + warn "COULD NOT START $UNIT_NAME after the restore. The restored data is in place at $DATA_ROOT;" + warn "your previous data is intact at $ASIDE_WORK / $ASIDE_CONFIG if you need to revert." + exit 1 + } + STOPPED_BY_US=0 + + local rc=0 + log "Waiting for Gitea to answer on $HEALTH_URL ..." + if wait_healthy "$WAIT_SECS"; then + log "Gitea is healthy again." + else + warn "Gitea did not become healthy within ${WAIT_SECS}s after the restore. Check: journalctl -u $UNIT_NAME" + rc=3 + fi + + if (( rc == 0 )); then + if (( KEEP_PREVIOUS )); then + log "DONE. Your data before this restore is kept at:" + log " $ASIDE_WORK" + log " $ASIDE_CONFIG" + log "Remove those yourself once you've confirmed the restore looks right." + else + log "Restore succeeded and Gitea is healthy; removing the pre-restore copy (--delete-previous)..." + rm -rf -- "$ASIDE_WORK" "$ASIDE_CONFIG" + log "DONE." + fi + else + warn "Restore extracted and started, but needs attention (see above). Your data before this" + warn "restore is still kept at $ASIDE_WORK / $ASIDE_CONFIG regardless of --delete-previous." + fi + exit "$rc" +} + +if [[ "${GITEA_PODMAN_RESTORE_RELAUNCHED:-}" != "1" ]]; then + for a in "$@"; do [[ "$a" == "-h" || "$a" == "--help" ]] && { usage; exit 0; }; done + mkdir -p -- "$(dirname -- "$LOCK_FILE")" + export GITEA_PODMAN_RESTORE_RELAUNCHED=1 + rc=0 + flock -n -E 75 "$LOCK_FILE" "$0" "$@" || rc=$? + [[ $rc -eq 75 ]] && warn "A backup or restore is already running (lock: $LOCK_FILE)." + exit "$rc" +fi + +main "$@" diff --git a/gitea-podman/setup-gitea-podman.sh b/gitea-podman/setup-gitea-podman.sh new file mode 100644 index 0000000..8be7f3e --- /dev/null +++ b/gitea-podman/setup-gitea-podman.sh @@ -0,0 +1,659 @@ +#!/usr/bin/env bash +# +# setup-gitea-podman.sh +# +# Runs Gitea (https://about.gitea.com) as a rootless-IMAGE container under +# ROOTFUL Podman + systemd (Quadlet), on a fresh Linux box. This is the +# Podman counterpart to setup-git-server.sh; read that script's header for +# the overall goals (GitHub-style SSH access, push-to-create). The container +# architecture differs in one important way: there is no host sshd involved. +# Gitea's OWN built-in SSH server runs inside the container and is published +# to the host, so there are no OS user accounts to manage -- SSH keys are +# tied to Gitea accounts in its database, exactly like GitHub. +# +# All persistent data lives OUTSIDE the container, under --data-root +# (default /srv/gitea), in two directories bind-mounted in: +# /work -> /var/lib/gitea (repos, sqlite db, LFS, avatars, SSH host keys) +# /config -> /etc/gitea (app.ini -- also holds the secrets the +# database's encrypted columns depend on) +# The container itself is disposable: `podman rm` it, or even reinstall this +# script against the same --data-root, and nothing is lost. Use +# gitea-podman-backup.sh / gitea-podman-restore.sh to back up and restore +# that data; this script only sets the container up. +# +# Requires: a Podman new enough to generate systemd units from Quadlet +# (.container) files (Podman >= 4.4) -- this is checked, not assumed. +# +# Supported distros: Arch, Debian, Ubuntu, Fedora (and close derivatives). +# Must be run as root on a machine where systemd is actually running (a VM +# or bare metal -- not a plain Docker container). Written for a first-time +# install; re-running it over an existing --data-root reuses the existing +# secrets and accounts and just brings the container up to date. +# +# Usage: +# sudo ./setup-gitea-podman.sh [options] +# +# Run with --help for the full list of options. + +set -euo pipefail +umask 022 + +# ---------------------------------------------------------------------------- +# Defaults (overridable via env vars, then via flags below) +# ---------------------------------------------------------------------------- +GITEA_DATA_ROOT="${GITEA_DATA_ROOT:-/srv/gitea}" +GITEA_BACKUP_DIR="${GITEA_BACKUP_DIR:-/srv/gitea-backups}" +GITEA_HTTP_PORT="${GITEA_HTTP_PORT:-3000}" +GITEA_SSH_PORT="${GITEA_SSH_PORT:-22}" +GITEA_DOMAIN="${GITEA_DOMAIN:-}" +GITEA_VERSION="${GITEA_VERSION:-latest}" +GITEA_DISABLE_REGISTRATION="${GITEA_DISABLE_REGISTRATION:-true}" +GITEA_ADMIN_USER="${GITEA_ADMIN_USER:-admin}" +GITEA_ADMIN_EMAIL="${GITEA_ADMIN_EMAIL:-admin@example.com}" +GITEA_ADMIN_PASSWORD="${GITEA_ADMIN_PASSWORD:-}" +GITEA_CONTAINER_UID="${GITEA_CONTAINER_UID:-1000}" +GITEA_CONTAINER_GID="${GITEA_CONTAINER_GID:-1000}" + +# Fixed by the image; not meant to be tuned per-install. +readonly SSH_LISTEN_PORT=2222 +readonly CONTAINER_NAME="gitea" +readonly UNIT_NAME="gitea.service" +readonly QUADLET_DIR="/etc/containers/systemd" +readonly QUADLET_FILE="${QUADLET_DIR}/gitea.container" +readonly CONF_DIR="/etc/gitea-podman" +readonly CONF_FILE="${CONF_DIR}/gitea-podman.conf" + +# Filled in while the script runs +DISTRO_FAMILY="" +IMAGE="" +GITEA_INSTALLED_VERSION="" +ADMIN_CREATED="false" + +# ---------------------------------------------------------------------------- +# 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; } + +print_help() { + cat <<'EOF' +Usage: sudo ./setup-gitea-podman.sh [options] + +Runs Gitea under rootful Podman + systemd (Quadlet), with all data kept +in a host directory separate from the container. + +Options: + --data-root Where repos/db/config live (default: /srv/gitea) + --backup-dir Recorded for the backup script (default: /srv/gitea-backups) + --domain Hostname/IP used in clone URLs (default: auto-detected) + --http-port Web UI port on the host (default: 3000) + --ssh-port SSH port on the host (default: 22) + --version Gitea version, e.g. 1.27.3 (default: latest release) + --admin-user Initial admin username (default: admin) + --admin-email Initial admin email (default: admin@example.com) + --admin-password Initial admin password, 8+ chars (default: randomly generated) + --allow-registration Let anyone sign up from the web UI (default: off) + --uid --gid UID:GID the container runs as (default: 1000:1000, + matches the official rootless image -- only change this + if you know the image has changed) + -h, --help Show this help and exit + +Every option can also be set as an environment variable of the same +name in caps with a GITEA_ prefix, e.g.: + GITEA_DOMAIN=git.example.com sudo -E ./setup-gitea-podman.sh +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." +} + +# ---------------------------------------------------------------------------- +# Argument parsing + validation +# ---------------------------------------------------------------------------- +need_arg() { [[ $# -ge 2 ]] || error_exit "Option $1 needs a value (see --help)"; } + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --data-root) need_arg "$@"; GITEA_DATA_ROOT="$2"; shift 2 ;; + --backup-dir) need_arg "$@"; GITEA_BACKUP_DIR="$2"; shift 2 ;; + --domain) need_arg "$@"; GITEA_DOMAIN="$2"; shift 2 ;; + --http-port) need_arg "$@"; GITEA_HTTP_PORT="$2"; shift 2 ;; + --ssh-port) need_arg "$@"; GITEA_SSH_PORT="$2"; shift 2 ;; + --version) need_arg "$@"; GITEA_VERSION="$2"; shift 2 ;; + --admin-user) need_arg "$@"; GITEA_ADMIN_USER="$2"; shift 2 ;; + --admin-email) need_arg "$@"; GITEA_ADMIN_EMAIL="$2"; shift 2 ;; + --admin-password) need_arg "$@"; GITEA_ADMIN_PASSWORD="$2"; shift 2 ;; + --allow-registration) GITEA_DISABLE_REGISTRATION="false"; shift ;; + --uid) need_arg "$@"; GITEA_CONTAINER_UID="$2"; shift 2 ;; + --gid) need_arg "$@"; GITEA_CONTAINER_GID="$2"; shift 2 ;; + -h|--help) print_help; exit 0 ;; + *) error_exit "Unknown option: $1 (see --help)" ;; + esac + done +} + +validate_port() { + if [[ ! "$1" =~ ^[0-9]+$ ]] || (( 10#$1 < 1 || 10#$1 > 65535 )); then + error_exit "Invalid $2: '$1' (expected a port number 1-65535)" + fi +} + +validate_settings() { + validate_port "$GITEA_HTTP_PORT" "HTTP port" + validate_port "$GITEA_SSH_PORT" "SSH port" + [[ "$GITEA_HTTP_PORT" != "$SSH_LISTEN_PORT" ]] || true # no conflict possible: different sockets + [[ "$GITEA_ADMIN_USER" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] \ + || error_exit "Invalid admin username '${GITEA_ADMIN_USER}' (letters, digits, '.', '_' and '-' only)." + [[ "$GITEA_ADMIN_EMAIL" == ?*@?* ]] \ + || error_exit "Invalid admin email '${GITEA_ADMIN_EMAIL}'." + (( ${#GITEA_ADMIN_PASSWORD} >= 8 )) \ + || error_exit "The admin password must be at least 8 characters (Gitea's default minimum)." + [[ "$GITEA_CONTAINER_UID" =~ ^[0-9]+$ && "$GITEA_CONTAINER_GID" =~ ^[0-9]+$ ]] \ + || error_exit "--uid/--gid must be numeric." + [[ "$GITEA_DATA_ROOT" == /* ]] || error_exit "--data-root must be an absolute path." + [[ "$GITEA_BACKUP_DIR" == /* ]] || error_exit "--backup-dir must be an absolute path." + local rd rb + rd="$(realpath -m -- "$GITEA_DATA_ROOT")"; rb="$(realpath -m -- "$GITEA_BACKUP_DIR")" + case "$rb/" in "$rd/"*) error_exit "--backup-dir is inside --data-root; they must be separate." ;; esac + case "$rd/" in "$rb/"*) error_exit "--data-root is inside --backup-dir; they must be separate." ;; esac + GITEA_DATA_ROOT="$rd"; GITEA_BACKUP_DIR="$rb" +} + +# ---------------------------------------------------------------------------- +# Detection helpers +# ---------------------------------------------------------------------------- +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 + log "Detected ${PRETTY_NAME:-${ID:-unknown}} -> using ${DISTRO_FAMILY} package family." +} + +detect_domain() { + local ip="" + ip=$(ip -4 route get 1.1.1.1 2>/dev/null | awk '/src/{for(i=1;i<=NF;i++) if ($i=="src") print $(i+1)}') || true + if [[ -z "$ip" ]]; then ip=$(hostname -I 2>/dev/null | awk '{print $1}') || true; fi + if [[ -n "$ip" ]]; then echo "$ip"; return; fi + if [[ -r /etc/hostname ]]; then local h=""; h=$(< /etc/hostname); [[ -n "$h" ]] && { echo "$h"; return; }; fi + echo "localhost" +} + +generate_password() { tr -dc 'A-Za-z0-9' /dev/null | head -c 20 || true; } + +get_latest_version() { + local latest="" json="" + json=$(curl -fsSL --max-time 20 https://dl.gitea.com/gitea/version.json 2>/dev/null) || true + latest=$(printf '%s' "$json" | tr -d '\n' | awk ' + { i = index($0, "\"latest\"") + if (i && match(substr($0, i), /"version"[[:space:]]*:[[:space:]]*"v?[0-9][^"]*"/)) { + v = substr($0, i + RSTART - 1, RLENGTH) + sub(/^"version"[[:space:]]*:[[:space:]]*"v?/, "", v); sub(/"$/, "", v); print v } }') || true + if [[ -z "$latest" ]]; then + latest=$(curl -fsSL --max-time 20 https://api.github.com/repos/go-gitea/gitea/releases/latest 2>/dev/null \ + | sed -nE 's/.*"tag_name"[[:space:]]*:[[:space:]]*"v?([^"]+)".*/\1/p' | head -n1) || true + fi + echo "$latest" +} + +# ---------------------------------------------------------------------------- +# Install steps +# ---------------------------------------------------------------------------- +install_podman() { + log "Installing Podman..." + case "$DISTRO_FAMILY" in + debian) + export DEBIAN_FRONTEND=noninteractive + apt-get update + apt-get install -y podman curl ca-certificates + ;; + fedora) + dnf install -y podman curl ca-certificates + ;; + arch) + pacman -Syu --noconfirm --needed podman curl ca-certificates + ;; + esac +} + +require_quadlet() { + log "Checking for Quadlet support (Podman >= 4.4)..." + command -v podman &>/dev/null || error_exit "podman was not found on PATH after installation." + local pv gen="" + pv="$(podman --version 2>/dev/null | awk '{print $3}')" + for gen in /usr/lib/systemd/system-generators/podman-system-generator \ + /usr/libexec/podman/podman-system-generator; do + [[ -x "$gen" ]] && break + gen="" + done + if [[ -z "$gen" ]]; then + error_exit "Podman ${pv:-unknown} does not ship the Quadlet systemd generator (needs Podman >= 4.4). \ +On Debian this usually means bookworm's default repo is too old: enable bookworm-backports \ +(echo 'deb http://deb.debian.org/debian bookworm-backports main' > /etc/apt/sources.list.d/backports.list \ +&& apt-get update && apt-get install -y -t bookworm-backports podman), then re-run this script." + fi + # A dry run smoke-tests the generator itself, not just its presence. + local tmp; tmp="$(mktemp -d)" + cat > "${tmp}/quadlet-smoketest.container" <<'EOF' +[Container] +Image=localhost/nonexistent:latest +EOF + if ! QUADLET_UNIT_DIRS="$tmp" "$gen" --dryrun "${tmp}/out" &>/dev/null; then + rm -rf -- "$tmp" + error_exit "The Quadlet generator (${gen}) did not run successfully. Try 'apt/dnf/pacman upgrade podman'." + fi + rm -rf -- "$tmp" + log "Podman ${pv:-?} with a working Quadlet generator found." +} + +resolve_image() { + local version + if [[ "$GITEA_VERSION" == "latest" ]]; then + log "Looking up the latest Gitea release..." + version="$(get_latest_version)" + [[ -n "$version" ]] || error_exit "Could not determine the latest Gitea version (dl.gitea.com and the GitHub API were both unreachable or rate-limited). Re-run with --version ." + else + version="${GITEA_VERSION#v}" + fi + IMAGE="docker.io/gitea/gitea:${version}-rootless" + GITEA_INSTALLED_VERSION="$version" + log "Using image ${IMAGE}" +} + +pull_image() { + log "Pulling ${IMAGE} (this can take a while on first run)..." + podman pull -q "$IMAGE" \ + || error_exit "Could not pull ${IMAGE}. Check network access to docker.io, and that --version is a real Gitea release (rootless images have shipped since Gitea 1.14)." + podman run --rm --user "${GITEA_CONTAINER_UID}:${GITEA_CONTAINER_GID}" --entrypoint gitea "$IMAGE" --version >/dev/null 2>&1 \ + || error_exit "The pulled image did not run 'gitea --version' successfully." +} + +setup_directories() { + log "Setting up ${GITEA_DATA_ROOT} ..." + local work="${GITEA_DATA_ROOT}/work" conf="${GITEA_DATA_ROOT}/config" + mkdir -p -- "$work" "$conf" + # The container runs as this UID with ALL capabilities dropped (no DAC_OVERRIDE), + # so it is subject to ordinary Unix permission checks like anyone else: these + # directories must actually be owned by that UID, not just root-writable. + chown -R "${GITEA_CONTAINER_UID}:${GITEA_CONTAINER_GID}" "$work" + chmod 750 "$work" + chown "${GITEA_CONTAINER_UID}:${GITEA_CONTAINER_GID}" "$conf" + chmod 700 "$conf" + : > "${work}/.gitea-podman-data" # marker the backup/restore scripts check for + chown "${GITEA_CONTAINER_UID}:${GITEA_CONTAINER_GID}" "${work}/.gitea-podman-data" + + if [[ ! -d "$GITEA_BACKUP_DIR" ]]; then + mkdir -p -- "$GITEA_BACKUP_DIR" + chmod 0700 -- "$GITEA_BACKUP_DIR" + log "Created backup directory ${GITEA_BACKUP_DIR} (nothing is written there by this script)." + fi +} + +# --- app.ini ----------------------------------------------------------------- + +config_value() { + local key="$1" file="${GITEA_DATA_ROOT}/config/app.ini" + [[ -r "$file" ]] || return 0 + sed -nE "s/^[[:space:]]*${key}[[:space:]]*=[[:space:]]*(.*[^[:space:]])[[:space:]]*\$/\\1/p" "$file" | head -n1 +} + +secret_for() { # usage: secret_for <'gitea generate secret' type> + local value="" + value="$(config_value "$1")" + if [[ -z "$value" ]]; then + value="$(podman run --rm --user "${GITEA_CONTAINER_UID}:${GITEA_CONTAINER_GID}" --entrypoint gitea "$IMAGE" generate secret "$2")" \ + || error_exit "'gitea generate secret $2' failed." + fi + [[ -n "$value" ]] || error_exit "Got an empty value for $1." + printf '%s' "$value" +} + +write_config() { + local conf="${GITEA_DATA_ROOT}/config" + local app="${conf}/app.ini" + local secret_key internal_token lfs_jwt_secret oauth2_jwt_secret + + if [[ -f "$app" ]]; then + cp -p -- "$app" "${app}.bak.$(date +%s)" + warn "Existing ${app} found; backed it up (its secrets are kept, the rest is rewritten)." + fi + + log "Generating secrets and writing ${app}..." + # Same reasoning as the host script: the running container is not allowed to + # write back to app.ini (it is not even its own file -- see setup_directories), + # so every secret Gitea would otherwise generate itself on first start has to + # already be here, or startup aborts trying to save them. + secret_key="$(secret_for SECRET_KEY SECRET_KEY)" + internal_token="$(secret_for INTERNAL_TOKEN INTERNAL_TOKEN)" + lfs_jwt_secret="$(secret_for LFS_JWT_SECRET JWT_SECRET)" + oauth2_jwt_secret="$(secret_for JWT_SECRET JWT_SECRET)" + + : > "$app" + chown "${GITEA_CONTAINER_UID}:${GITEA_CONTAINER_GID}" "$app" + chmod 600 "$app" + + cat > "$app" </dev/null; then + systemctl stop "$UNIT_NAME" + fi + log "Initialising the database..." + # /usr/local/bin/gitea in this image is a wrapper that injects "-c + # $GITEA_APP_INI" for us, so the bare subcommand is all that's needed. + run_oneshot gitea migrate || error_exit "'gitea migrate' failed -- see the error above." +} + +admin_user_exists() { + run_oneshot gitea admin user list 2>/dev/null \ + | awk -v u="$GITEA_ADMIN_USER" 'NR > 1 && tolower($2) == tolower(u) { found = 1 } END { exit !found }' +} + +create_admin_user() { + log "Creating admin user '${GITEA_ADMIN_USER}'..." + if run_oneshot gitea admin user create \ + --username "$GITEA_ADMIN_USER" \ + --password "$GITEA_ADMIN_PASSWORD" \ + --email "$GITEA_ADMIN_EMAIL" \ + --admin --must-change-password=false; then + ADMIN_CREATED="true" + log "Admin user created." + elif admin_user_exists; then + warn "User '${GITEA_ADMIN_USER}' already exists (previous run?); leaving it and its password unchanged." + else + error_exit "Could not create the admin user -- see the error above." + fi +} + +# --- Quadlet / systemd --------------------------------------------------------- + +write_quadlet_unit() { + log "Writing Quadlet unit ${QUADLET_FILE}..." + mkdir -p -- "$QUADLET_DIR" + cat > "$QUADLET_FILE" </dev/null; then + log "Gitea is up." + return 0 + fi + sleep 2 + done + warn "Gitea did not answer within 2 minutes. Last log lines:" + journalctl -u "$UNIT_NAME" -n 40 --no-pager >&2 || true + error_exit "Gitea failed to start -- fix the problem shown above (full log: journalctl -u $UNIT_NAME)." +} + +write_adduser_helper() { + cat > /usr/local/bin/gitea-podman-adduser </dev/null && [[ "$(ufw status 2>/dev/null)" == *"Status: active"* ]]; then + log "Opening ports in ufw..." + ufw allow "${GITEA_SSH_PORT}/tcp" || true + ufw allow "${GITEA_HTTP_PORT}/tcp" || true + elif command -v firewall-cmd &>/dev/null && systemctl is-active --quiet firewalld 2>/dev/null; then + log "Opening ports in firewalld..." + firewall-cmd --permanent --add-port="${GITEA_SSH_PORT}/tcp" || true + firewall-cmd --permanent --add-port="${GITEA_HTTP_PORT}/tcp" || true + firewall-cmd --reload || true + else + log "No active firewall manager (ufw/firewalld) found; nothing to configure there." + fi +} + +write_conf_file() { + # The single source of truth gitea-podman-backup.sh / -restore.sh read from -- + # keeps them from duplicating (and drifting from) what this script set up. + mkdir -p -- "$CONF_DIR" + chmod 0700 -- "$CONF_DIR" + cat > "$CONF_FILE" < +SSH / GPG Keys) and Gitea's own SSH server (inside the container) handles +the rest. + + git@${GITEA_DOMAIN}:/.git $( [[ "$GITEA_SSH_PORT" != 22 ]] && echo "(add: -p ${GITEA_SSH_PORT}, or set it in your SSH config)" ) + +A repo doesn't need to be created first -- the first push creates it +(as a private repo by default): + + mkdir myproject && cd myproject && git init + git commit --allow-empty -m "init" + git remote add origin git@${GITEA_DOMAIN}:${GITEA_ADMIN_USER}/myproject.git + git push -u origin main + +Add more accounts any time with: + + sudo gitea-podman-adduser --username --email --password '' + +Data lives OUTSIDE the container and survives 'podman rm'/reinstalls: + Work dir (repos, db, LFS, SSH host keys): ${GITEA_DATA_ROOT}/work + Config (app.ini + secrets): ${GITEA_DATA_ROOT}/config + Recorded backup directory: ${GITEA_BACKUP_DIR} (nothing written yet -- + run gitea-podman-backup.sh to take a backup) + +Useful commands: + systemctl status ${UNIT_NAME} + systemctl restart ${UNIT_NAME} + journalctl -u ${UNIT_NAME} -f + podman exec -it ${CONTAINER_NAME} sh + +Public sign-up from the web UI: ${registration} (see [service] DISABLE_REGISTRATION in app.ini) + +This is plain HTTP -- fine on a LAN or over a VPN. For anything reachable +from the open internet: put it behind a reverse proxy (nginx/Caddy) with a +real TLS certificate, and double check any cloud provider firewall/security +group rules in addition to the local firewall this script already configured. + +Back this up regularly -- see gitea-podman-backup.sh. Data loss protection +only works if you actually run it (a systemd timer is the easiest way). +============================================================================ +EOF +} + +# ---------------------------------------------------------------------------- +# Main +# ---------------------------------------------------------------------------- +main() { + require_root + : "${GITEA_ADMIN_PASSWORD:=$(generate_password)}" + validate_settings + detect_distro_family + + install_podman + require_quadlet + : "${GITEA_DOMAIN:=$(detect_domain)}" + + resolve_image + pull_image + setup_directories + write_config + init_database + create_admin_user + write_quadlet_unit + start_service + wait_for_gitea + write_adduser_helper + configure_firewall + write_conf_file + print_summary +} + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + parse_args "$@" + main +fi