Files
rune/scripts/gitea-restore-verify.sh
T
Nick a9496d1b06
CI Pipeline / lint (push) Failing after 55s
CI Pipeline / canary (push) Successful in 6s
CI Pipeline / best-practices (push) Successful in 5s
CI Pipeline / documentation (push) Successful in 5s
CI Pipeline / gitea-offline (push) Successful in 39s
CI Pipeline / required-checks (push) Skipped
Auto-label / auto-label (pull_request) Successful in 2s
CI Pipeline / lint (pull_request) Failing after 22s
CI Pipeline / canary (pull_request) Successful in 5s
CI Pipeline / best-practices (pull_request) Successful in 5s
CI Pipeline / documentation (pull_request) Successful in 5s
CI Pipeline / gitea-offline (pull_request) Successful in 39s
CI Pipeline / required-checks (pull_request) Skipped
feat(gitea): make governance and recovery reproducible
Track branch policy, labels, milestones, and wiki content as declarative state with a read-only-by-default provisioner.

Harden off-disk backup publication and add a rootless, networkless restore drill that validates SQLite, application data, and database-declared bare repositories.

Run offline contracts in CI, pin audited dependencies, document operations, and bump Rune to 0.43.0.
2026-08-30 17:10:32 +01:00

312 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
# Docker Registry multi-arch manifest digest for tag 1.27.0-rootless.
readonly DEFAULT_IMAGE="docker.io/gitea/gitea@sha256:414ba5b2b1163480e9ed4213a989cd798579cfa88582a2359303273009b2b852"
IMAGE=${GITEA_RESTORE_IMAGE:-$DEFAULT_IMAGE}
PODMAN_BIN=${PODMAN_BIN:-podman}
PYTHON_BIN=${PYTHON_BIN:-python3}
die() { printf 'Restore verification failed: %s\n' "$*" >&2; exit 1; }
[[ ${RUNE_GITEA_RESTORE_VERIFY:-} == 1 ]] || die "set RUNE_GITEA_RESTORE_VERIFY=1 to acknowledge the disposable drill"
((EUID != 0)) || die "must run rootless (never run this verifier as root)"
[[ $# -eq 1 ]] || die "usage: $0 ARCHIVE.tar.gz"
ARCHIVE=$(realpath -- "$1")
[[ -f "$ARCHIVE" ]] || die "archive does not exist: $ARCHIVE"
CHECKSUM="$ARCHIVE.sha256"
[[ -f "$CHECKSUM" ]] || die "checksum sidecar does not exist: $CHECKSUM"
[[ "$IMAGE" == "$DEFAULT_IMAGE" ]] || die "Gitea restore image must match the audited immutable digest"
command -v "$PODMAN_BIN" >/dev/null || die "rootless Podman is required"
GIT_BIN=$(command -v git) || die "git is required"
command -v "$PYTHON_BIN" >/dev/null || die "Python 3 is required"
"$PODMAN_BIN" image exists "$IMAGE" || die "required local image is unavailable; refusing registry contact"
WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/gitea-restore-verify.XXXXXX")
CONTAINER_ID=""
cleanup() {
local status=$?
if [[ -n "$CONTAINER_ID" ]]; then
"$PODMAN_BIN" rm -f "$CONTAINER_ID" >/dev/null 2>&1 || true
fi
rm -rf -- "$WORK_DIR"
exit "$status"
}
trap cleanup EXIT INT TERM
# Git validation must not inherit object-store or configuration overrides from
# the caller. An isolated HOME plus an otherwise-empty environment prevents
# fsck from succeeding via host objects or hostile Git config.
mkdir -m 0700 "$WORK_DIR/git-home"
safe_git() {
env -i PATH="$PATH" HOME="$WORK_DIR/git-home" GIT_CONFIG_NOSYSTEM=1 \
"$GIT_BIN" "$@"
}
# Parse the sidecar ourselves: sha256sum -c would accept arbitrary filenames
# from an attacker-controlled sidecar.
"$PYTHON_BIN" - "$ARCHIVE" "$CHECKSUM" <<'PY' || die "checksum validation failed"
from pathlib import Path, PurePosixPath
import hashlib, re, sys
archive, sidecar = map(Path, sys.argv[1:])
contents = sidecar.read_text()
# Accept exactly one record, with either no terminator or one POSIX LF. Do not
# strip whitespace: that would hide blank/extra records or CRLF ambiguity.
line = contents[:-1] if contents.endswith("\n") else contents
if "\n" in line or "\r" in line:
raise SystemExit("invalid checksum sidecar")
match = re.fullmatch(r"([0-9a-fA-F]{64}) (.+)", line)
if not match:
raise SystemExit("invalid checksum sidecar")
# The recorded path is an inert lexical claim. Never open, stat, or resolve it;
# only compare its final POSIX component with the CLI-supplied archive name.
recorded = match.group(2)
if "\\" in recorded:
raise SystemExit("invalid checksum sidecar")
if "/" not in recorded:
valid_name = recorded == archive.name
else:
path = PurePosixPath(recorded)
components = recorded.split("/")
valid_name = (
path.is_absolute()
and not recorded.startswith("//")
and all(component not in {"", ".", ".."} for component in components[1:])
and path.name == archive.name
)
if not valid_name:
raise SystemExit("invalid checksum sidecar")
digest = hashlib.sha256()
with archive.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
actual = digest.hexdigest()
if actual.lower() != match.group(1).lower():
raise SystemExit("checksum mismatch")
PY
# Validate every name and link before extracting, then require each top-level
# Gitea dump member independently. No production path is ever accepted here.
"$PYTHON_BIN" - "$ARCHIVE" "$WORK_DIR/extracted" <<'PY'
from pathlib import Path, PurePosixPath
import sys, tarfile
archive, destination = Path(sys.argv[1]), Path(sys.argv[2])
required = {"app.ini", "data", "repos", "gitea-db.sql"}
with tarfile.open(archive, "r:gz") as source:
members = source.getmembers()
present = set()
for member in members:
name = member.name.removeprefix("./")
path = PurePosixPath(name)
if not name or path.is_absolute() or ".." in path.parts:
raise SystemExit(f"unsafe archive path: {member.name}")
if not (member.isfile() or member.isdir()):
raise SystemExit(f"unsafe archive member type: {member.name}")
present.add(path.parts[0])
missing = sorted(required - present)
if missing:
raise SystemExit("missing mandatory member: " + ", ".join(missing))
destination.mkdir(mode=0o700)
source.extractall(destination)
PY
RESTORE_ROOT="$WORK_DIR/extracted"
APP_INI="$RESTORE_ROOT/app.ini"
"$PYTHON_BIN" - "$APP_INI" <<'PY'
from pathlib import Path
import configparser
import sys
def fail(message):
print(f"Restore verification failed: {message}", file=sys.stderr)
raise SystemExit(1)
try:
contents = Path(sys.argv[1]).read_text(encoding="utf-8")
except (OSError, UnicodeError):
fail("cannot read app.ini safely")
if "\x00" in contents:
fail("malformed app.ini")
# Gitea permits global keys such as WORK_PATH before the first named section.
# Give those keys an in-memory section so strict stdlib INI parsing can still
# validate the original bytes. The extracted backup is never rewritten.
config = configparser.RawConfigParser(interpolation=None, strict=True)
try:
config.read_string("[__gitea_top_level__]\n" + contents)
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as error:
if error.section.casefold() in {"database", "default"}:
fail("ambiguous database configuration in app.ini")
fail("malformed app.ini")
except configparser.Error:
fail("malformed app.ini")
database_sections = [
section for section in config.sections() if section.casefold() == "database"
]
if len(database_sections) > 1:
fail("ambiguous database configuration in app.ini")
if not database_sections:
fail("missing database section in app.ini")
database_section = database_sections[0]
if "db_type" in config.defaults():
fail("ambiguous database configuration in app.ini")
if not config.has_option(database_section, "db_type"):
fail("missing database DB_TYPE in app.ini")
database_type = config.get(database_section, "db_type", raw=True).strip().casefold()
if database_type not in {"sqlite", "sqlite3"}:
fail("unsupported database DB_TYPE; only sqlite/sqlite3 are supported")
PY
DB="$WORK_DIR/gitea.db"
"$PYTHON_BIN" - "$RESTORE_ROOT/gitea-db.sql" "$DB" <<'PY'
from pathlib import Path
import sqlite3, sys
sql, database = Path(sys.argv[1]), Path(sys.argv[2])
connection = sqlite3.connect(database)
try:
denied_actions = {sqlite3.SQLITE_ATTACH, sqlite3.SQLITE_DETACH}
denied_functions = {"load_extension", "readfile", "writefile"}
denied_pragmas = {"data_store_directory", "temp_store_directory"}
def authorize(action, argument1, argument2, _database_name, _trigger_name):
if action in denied_actions:
return sqlite3.SQLITE_DENY
if action == sqlite3.SQLITE_FUNCTION and (argument2 or "").lower() in denied_functions:
return sqlite3.SQLITE_DENY
if action == sqlite3.SQLITE_PRAGMA and (argument1 or "").lower() in denied_pragmas:
return sqlite3.SQLITE_DENY
return sqlite3.SQLITE_OK
# The dump is untrusted input. Keep SQLite's semantic authorizer active for
# the entire import so file/extension operations cannot escape WORK_DIR.
connection.set_authorizer(authorize)
connection.executescript(sql.read_text())
connection.set_authorizer(None)
result = connection.execute("PRAGMA integrity_check").fetchone()[0]
if result != "ok":
raise SystemExit(f"SQLite integrity_check returned {result!r}")
finally:
connection.close()
PY
# Tie the filesystem inventory back to Gitea's authoritative database rows.
# Exact schema/column names and safe canonical lower-name components are
# required; orphaned owners and missing canonical repositories fail closed.
"$PYTHON_BIN" - "$DB" "$RESTORE_ROOT/repos" <<'PY'
from pathlib import Path
import sqlite3, sys
database, repos = Path(sys.argv[1]), Path(sys.argv[2])
connection = sqlite3.connect(database)
try:
objects = connection.execute(
"SELECT name, type FROM sqlite_master WHERE lower(name) IN ('repository', 'user')"
).fetchall()
if sorted(objects) != [("repository", "table"), ("user", "table")]:
raise SystemExit("absent or ambiguous repository ownership schema")
expected_columns = {
"repository": {"owner_id", "lower_name"},
"user": {"id", "lower_name"},
}
for table, required in expected_columns.items():
columns = {row[1] for row in connection.execute(f'PRAGMA table_info("{table}")')}
if not required <= columns:
raise SystemExit("absent or ambiguous repository ownership schema")
rows = connection.execute(
'SELECT u.lower_name, r.lower_name FROM "repository" AS r '
'LEFT JOIN "user" AS u ON u.id = r.owner_id'
).fetchall()
for owner, name in rows:
components = (owner, name)
if any(
not isinstance(component, str)
or not component
or component in {".", ".."}
or component != component.casefold()
or "/" in component
or "\\" in component
or "\x00" in component
for component in components
):
raise SystemExit("unsafe database repository path component")
canonical = repos / owner / f"{name}.git"
if not canonical.is_dir():
raise SystemExit("database-declared repository is missing")
except sqlite3.Error:
raise SystemExit("absent or ambiguous repository ownership schema") from None
finally:
connection.close()
PY
repo_count=0
while IFS= read -r -d '' repo; do
repo_count=$((repo_count + 1))
for alternates in "$repo/objects/info/alternates" "$repo/objects/info/http-alternates"; do
[[ ! -e "$alternates" ]] || die "repository alternates are forbidden: $alternates"
done
bare_repository=$(safe_git --git-dir="$repo" rev-parse --is-bare-repository 2>/dev/null) \
|| die "repository bare-state validation failed"
[[ "$bare_repository" == true ]] || die "repository is not bare"
safe_git --git-dir="$repo" fsck --full --strict >/dev/null
default_ref=$(safe_git --git-dir="$repo" symbolic-ref HEAD) || die "repository has no symbolic default branch: $repo"
safe_git --git-dir="$repo" show-ref --verify --quiet "$default_ref" || die "repository default branch has no ref: $repo ($default_ref)"
done < <(find "$RESTORE_ROOT/repos" -type d -name '*.git' -print0)
((repo_count > 0)) || die "no Git repositories found in backup"
INSTALL_ROOT="$WORK_DIR/isolated-gitea"
mkdir -p "$INSTALL_ROOT/gitea" "$INSTALL_ROOT/git/repositories"
cp -a -- "$RESTORE_ROOT/data/." "$INSTALL_ROOT/gitea/"
mkdir -p "$INSTALL_ROOT/gitea/conf"
cp "$DB" "$INSTALL_ROOT/gitea/gitea.db"
cp -a "$RESTORE_ROOT/repos/." "$INSTALL_ROOT/git/repositories/"
cat >"$INSTALL_ROOT/gitea/conf/app.ini" <<'EOF'
APP_NAME = Rune restore verification
RUN_MODE = prod
[server]
PROTOCOL = http
HTTP_ADDR = 127.0.0.1
HTTP_PORT = 3000
OFFLINE_MODE = true
[database]
DB_TYPE = sqlite3
PATH = /data/gitea/gitea.db
[repository]
ROOT = /data/git/repositories
[security]
INSTALL_LOCK = true
EOF
# The container has no external network, no published ports, a read-only root,
# and only the disposable restore tree mounted. Probe over its loopback only.
CONTAINER_ID=$("$PODMAN_BIN" run --pull=never -d --rm --network none --read-only \
--userns=keep-id:uid=1000,gid=1000 \
--tmpfs /tmp:rw,noexec,nosuid,nodev --tmpfs /run:rw,nosuid,nodev \
-v "$INSTALL_ROOT:/data:Z" "$IMAGE" \
gitea web --config /data/gitea/conf/app.ini)
[[ -n "$CONTAINER_ID" ]] || die "isolated Gitea container did not start"
booted=0
for _ in $(seq 1 30); do
if "$PODMAN_BIN" exec "$CONTAINER_ID" wget -q -O /dev/null \
http://127.0.0.1:3000/api/v1/version >/dev/null 2>&1; then
booted=1
break
fi
running=$("$PODMAN_BIN" inspect --format '{{.State.Running}}' "$CONTAINER_ID" 2>/dev/null) \
|| die "isolated Gitea container exited before becoming healthy"
[[ "$running" == true ]] \
|| die "isolated Gitea container exited before becoming healthy"
sleep 1
done
((booted)) || die "isolated Gitea did not become healthy"
"$PODMAN_BIN" rm -f "$CONTAINER_ID" >/dev/null
CONTAINER_ID=""
printf 'Disposable restore verified: checksum, archive, SQLite, %d Git repositories, isolated Gitea boot\n' "$repo_count"