leora dcfeb12c68 Re-review fixes: EGRESS_MODE=lax on Gitea 28, basic-auth prod gate, pinned sha256, kit-owned down
- app.ini.template: [security] EGRESS_MODE = lax (ALLOWED_HOST_LIST=external is invalid on 28.1.0)
- README: egress rows for 28 vs 1.23; rollout gate while password basic auth is on (edge 429 on
  Authorization: Basic to /api/v1/ and git HTTP, or tokens first)
- kit.sh: pinned sha256 per arch for 28.1.0; down stops only its own pid or the unit it installed
- e2e: package upload must be 404; 'denied by egress' polled from the Gitea log; 4 MB attachment
  cap; Actions off; fail on [E] / 'invalid entry'; listener cleaned up in trap
2026-10-15 20:57:10 +08:00

236 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
# gitea-selfhost: run your own Gitea with PR-only main branches and per-agent tokens.
# Usage: ./kit.sh up | init | setup | protect OWNER/REPO [BRANCH] | protect-all | agent NAME
# | backup | upgrade | status | down | install-service
set -euo pipefail
KIT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$KIT_DIR"
GITEA_VERSION="28.1.0" # keep in step with docker-compose.yml
SECRETS="$KIT_DIR/secrets"
die() { echo "error: $*" >&2; exit 1; }
log() { echo "==> $*"; }
load_env() {
[ -f .env ] || { cp .env.example .env; log "created .env from .env.example (edit it, then re-run)"; }
set -a; . ./.env; set +a
KIT_MODE="${KIT_MODE:-docker}"
HTTP_PORT="${HTTP_PORT:-3000}"
PROTECT_BRANCH="${PROTECT_BRANCH:-main}"
REQUIRED_APPROVALS="${REQUIRED_APPROVALS:-0}"
case "$KIT_MODE" in
docker) WORK_PATH=/var/lib/gitea; CONF_HOST="$KIT_DIR/config/app.ini" ;;
binary) WORK_PATH="${GITEA_HOME:-/var/lib/gitea}"; CONF_HOST="${GITEA_CONF:-/etc/gitea/app.ini}" ;;
*) die "KIT_MODE must be docker or binary" ;;
esac
API="http://127.0.0.1:${HTTP_PORT}/api/v1"
export KIT_UID="$(id -u)" KIT_GID="$(id -g)" WORK_PATH HTTP_ADDR="${HTTP_ADDR:-$([ "$KIT_MODE" = docker ] && echo 0.0.0.0 || echo 127.0.0.1)}"
}
compose() {
if docker compose version >/dev/null 2>&1; then docker compose "$@"
elif command -v docker-compose >/dev/null 2>&1; then docker-compose "$@"
else die "docker compose not found (or set KIT_MODE=binary)"; fi
}
bin_path() { if [ -x "$KIT_DIR/bin/gitea" ]; then echo "$KIT_DIR/bin/gitea"; else command -v gitea || echo /usr/local/bin/gitea; fi; }
# Run the gitea CLI against this instance.
gitea_cli() {
if [ "$KIT_MODE" = docker ]; then
compose exec -T gitea gitea --config /etc/gitea/app.ini --work-path /var/lib/gitea "$@"
elif [ "$(id -u)" = 0 ]; then
# systemd install: Gitea refuses to run as root, so act as the service user.
runuser -u git -- "$(bin_path)" --config "$CONF_HOST" --work-path "$WORK_PATH" "$@"
else
"$(bin_path)" --config "$CONF_HOST" --work-path "$WORK_PATH" "$@"
fi
}
rand() { head -c 64 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c "${1:-32}"; }
render_config() {
# Fresh random secrets on first render; an existing config keeps its secrets.
if [ -f "$CONF_HOST" ]; then log "config exists: $CONF_HOST (not overwritten)"; return; fi
local s; s="$(cat config/app.ini.template)"
SECRET_KEY="$(rand 64)"; INTERNAL_TOKEN="$(rand 105)"; JWT_SECRET="$(head -c 32 /dev/urandom | base64 | tr '+/' '-_' | tr -d '=')"
local v
for v in APP_NAME WORK_PATH DOMAIN HTTP_ADDR HTTP_PORT ROOT_URL MAX_REPOS_PER_USER DISABLE_REGISTRATION \
REGISTER_EMAIL_CONFIRM MAILER_ENABLED COOKIE_SECURE SECRET_KEY INTERNAL_TOKEN JWT_SECRET; do
s="${s//\$\{$v\}/${!v-}}"
done
case "$s" in *'${'*) die "unrendered placeholder in template: $(grep -o '\${[A-Z_]*}' <<<"$s" | head -1)";; esac
mkdir -p "$(dirname "$CONF_HOST")"
umask 077; printf '%s\n' "$s" > "$CONF_HOST"; umask 022
log "wrote $CONF_HOST (secrets generated locally, never commit it)"
}
install_binary() {
[ -x "$KIT_DIR/bin/gitea" ] && "$KIT_DIR/bin/gitea" --version 2>/dev/null | grep -q " $GITEA_VERSION " && return
local arch; case "$(uname -m)" in x86_64) arch=amd64;; aarch64|arm64) arch=arm64;; armv7l) arch=arm-6;; *) die "unsupported arch $(uname -m)";; esac
local url="https://dl.gitea.com/gitea/${GITEA_VERSION}/gitea-${GITEA_VERSION}-linux-${arch}"
mkdir -p bin; log "downloading $url"
curl -fsSL -o bin/gitea.tmp "$url"
# sha256 pinned per release (from dl.gitea.com/<ver>/*.sha256); update with GITEA_VERSION.
local want got; case "$GITEA_VERSION-$arch" in
28.1.0-amd64) want=ea93cfa4a300564ac457d3903469fea49ec5a4da37d0cebaa435f8e329ea64d0 ;;
28.1.0-arm64) want=47bf18a8e4602b17910cd660a902541e2c13b67c732f2607da8326fa3cd46805 ;;
28.1.0-arm-6) want=2bf4d222be997103b6668cdf241a83bb26ec365ee6d182faa67ec9f7f021ef39 ;;
*) rm -f bin/gitea.tmp; die "no pinned sha256 for gitea $GITEA_VERSION/$arch; add it to install_binary" ;;
esac; got="$(sha256sum bin/gitea.tmp | awk '{print $1}')"
[ -n "$want" ] && [ "$want" = "$got" ] || { rm -f bin/gitea.tmp; die "sha256 mismatch for gitea binary"; }
chmod +x bin/gitea.tmp; mv bin/gitea.tmp bin/gitea; log "gitea $GITEA_VERSION verified (sha256 $got)"
}
wait_healthy() {
local i; for i in $(seq 1 60); do
curl -fsS "http://127.0.0.1:${HTTP_PORT}/api/healthz" >/dev/null 2>&1 && { log "gitea is up on 127.0.0.1:${HTTP_PORT}"; return; }
sleep 1; done
die "gitea did not become healthy on port ${HTTP_PORT}"
}
cmd_init() {
mkdir -p "$SECRETS"; chmod 700 "$SECRETS"
if [ "$KIT_MODE" = docker ]; then mkdir -p data config; else install_binary; mkdir -p "$WORK_PATH"; fi
render_config
}
start() {
if [ "$KIT_MODE" = docker ]; then compose up -d
elif [ -f "$KIT_DIR/.kit-systemd" ]; then sudo systemctl start gitea
else
mkdir -p "$WORK_PATH/log"
if [ -f "$WORK_PATH/gitea.pid" ] && kill -0 "$(cat "$WORK_PATH/gitea.pid")" 2>/dev/null; then log "already running"; return; fi
nohup "$(bin_path)" web --config "$CONF_HOST" --work-path "$WORK_PATH" >> "$WORK_PATH/log/gitea.out" 2>&1 < /dev/null &
echo $! > "$WORK_PATH/gitea.pid"
fi
}
api() { # api METHOD PATH [JSON]
local tok; tok="$(cat "$SECRETS/admin.token")"
curl -fsS -X "$1" -H "Authorization: token $tok" -H "Content-Type: application/json" "$API$2" ${3:+-d "$3"}
}
cmd_setup() {
mkdir -p "$SECRETS"; chmod 700 "$SECRETS"
local user="${ADMIN_USER:-admin}" pass="${ADMIN_PASSWORD:-}"
if gitea_cli admin user list --admin 2>/dev/null | awk 'NR>1{print $2}' | grep -qx "$user"; then
log "admin '$user' already exists"
else
if [ -z "$pass" ]; then pass="$(rand 24)"; (umask 077; printf '%s\n' "$pass" > "$SECRETS/admin.password"); fi
gitea_cli admin user create --admin --username "$user" --password "$pass" \
--email "${ADMIN_EMAIL:-admin@example.com}" --must-change-password=false >/dev/null
log "created admin '$user' (password in secrets/admin.password if generated)"
fi
if [ ! -s "$SECRETS/admin.token" ]; then
gitea_cli admin user generate-access-token --username "$user" --token-name "kit-admin-$(date +%s)" \
--scopes "write:admin,write:repository,write:user,write:organization,write:issue" --raw \
| tail -n1 | (umask 077; cat > "$SECRETS/admin.token")
log "admin API token saved to secrets/admin.token (0600)"
fi
api GET /user >/dev/null && log "admin token works"
}
cmd_protect() { # protect OWNER/REPO [BRANCH]
local repo="$1" branch="${2:-$PROTECT_BRANCH}"
local body; body=$(printf '{"rule_name":"%s","enable_push":false,"enable_merge_whitelist":false,"required_approvals":%s,"block_on_rejected_reviews":true,"dismiss_stale_approvals":true,"block_admin_merge_override":false}' "$branch" "$REQUIRED_APPROVALS")
if api GET "/repos/$repo/branch_protections/$branch" >/dev/null 2>&1; then
api PATCH "/repos/$repo/branch_protections/$branch" "$body" >/dev/null
else
api POST "/repos/$repo/branch_protections" "$body" >/dev/null
fi
echo "protected $repo:$branch (no direct pushes; merge through a PR; approvals=$REQUIRED_APPROVALS)"
}
cmd_protect_all() {
local page=1 names
while :; do
names=$(api GET "/repos/search?limit=50&page=$page" | python3 -c 'import json,sys; [print(r["full_name"], r["default_branch"] or "main", r["empty"]) for r in json.load(sys.stdin)["data"]]')
[ -n "$names" ] || break
while read -r full branch empty; do
[ "$empty" = "True" ] && { echo "skip $full (empty)"; continue; }
cmd_protect "$full" "$branch"
done <<<"$names"
page=$((page+1))
done
}
cmd_agent() { # agent NAME : one Gitea user + one scoped token per agent
local name="$1"; [[ "$name" =~ ^[a-z0-9][a-z0-9-]{1,38}$ ]] || die "agent name: lowercase letters, digits, dashes"
mkdir -p "$SECRETS/agents"; chmod 700 "$SECRETS/agents"
if ! gitea_cli admin user list 2>/dev/null | awk 'NR>1{print $2}' | grep -qx "$name"; then
gitea_cli admin user create --username "$name" --password "$(rand 32)" --email "$name@agents.invalid" \
--must-change-password=false >/dev/null
fi
gitea_cli admin user generate-access-token --username "$name" --token-name "agent-$(date +%s)" \
--scopes "write:repository,write:issue,write:user" --raw | tail -n1 | (umask 077; cat > "$SECRETS/agents/$name.token")
echo "agent '$name' ready: token in secrets/agents/$name.token (scopes: write:repository, write:issue, write:user; no admin)"
echo "git remote: ${ROOT_URL%/}/<owner>/<repo>.git (username $name, token as password)"
}
cmd_backup() {
mkdir -p backups
local f="gitea-dump-$(date +%Y%m%d-%H%M%S).zip"
if [ "$KIT_MODE" = docker ]; then
compose exec -T gitea sh -c "cd /tmp && gitea dump --config /etc/gitea/app.ini --work-path /var/lib/gitea --file /tmp/$f >/dev/null && cat /tmp/$f && rm -f /tmp/$f" > "backups/$f"
else
gitea_cli dump --file "$KIT_DIR/backups/$f" >/dev/null
fi
chmod 600 "backups/$f"; log "backup: backups/$f (contains secrets; store it encrypted)"
}
cmd_down() { # stops only what this kit started: its compose project, its own pid, or the unit it installed
if [ "$KIT_MODE" = docker ]; then compose down
elif [ -f "$WORK_PATH/gitea.pid" ]; then
local p; p="$(cat "$WORK_PATH/gitea.pid")"
local cmd=""; cmd="$(tr '\0' ' ' < "/proc/$p/cmdline" 2>/dev/null || true)"
if kill -0 "$p" 2>/dev/null && [[ "$cmd" == *"--work-path $WORK_PATH "* ]]; then
kill "$p"; log "stopped pid $p"
else log "pid $p is not this kit's gitea; not touching it"; fi
rm -f "$WORK_PATH/gitea.pid"
elif [ -f "$KIT_DIR/.kit-systemd" ]; then sudo systemctl stop gitea
else log "nothing started by this kit is running"
fi
}
cmd_status() {
curl -fsS "http://127.0.0.1:${HTTP_PORT}/api/v1/version" && echo || echo "not reachable on 127.0.0.1:${HTTP_PORT}"
}
cmd_install_service() { # binary mode, as root: git user, binary, config, systemd unit, admin
[ "$KIT_MODE" = binary ] || die "install-service is for KIT_MODE=binary"
[ "$(id -u)" = 0 ] || die "run with sudo"
install_binary
id git >/dev/null 2>&1 || useradd --system --create-home --home-dir /home/git --shell /bin/bash git
install -m 0755 "$KIT_DIR/bin/gitea" /usr/local/bin/gitea
mkdir -p "$WORK_PATH" "$(dirname "$CONF_HOST")" "$SECRETS"; chmod 700 "$SECRETS"
render_config
chown -R git:git "$WORK_PATH" "$(dirname "$CONF_HOST")"; chmod 750 "$(dirname "$CONF_HOST")"; chmod 640 "$CONF_HOST"
sed -e "s#/var/lib/gitea#$WORK_PATH#g" -e "s#/etc/gitea/app.ini#$CONF_HOST#g" -e "s#/etc/gitea#$(dirname "$CONF_HOST")#g" \
systemd/gitea.service > /etc/systemd/system/gitea.service
systemctl daemon-reload; systemctl enable --now gitea
echo /etc/systemd/system/gitea.service > "$KIT_DIR/.kit-systemd"
wait_healthy; cmd_setup
log "systemd service 'gitea' enabled; run later kit commands with sudo"
}
load_env
case "${1:-help}" in
init) cmd_init ;;
up) cmd_init; start; wait_healthy; cmd_setup
log "open ${ROOT_URL} (admin: ${ADMIN_USER:-admin})"
log "protect a repo: ./kit.sh protect OWNER/REPO (or protect-all)" ;;
setup) cmd_setup ;;
protect) [ $# -ge 2 ] || die "usage: ./kit.sh protect OWNER/REPO [BRANCH]"; cmd_protect "$2" "${3:-}" ;;
protect-all) cmd_protect_all ;;
agent) [ $# -ge 2 ] || die "usage: ./kit.sh agent NAME"; cmd_agent "$2" ;;
backup) cmd_backup ;;
upgrade) die "edit GITEA_VERSION here and the image tag in docker-compose.yml, run ./kit.sh backup, then: docker compose pull && docker compose up -d (binary: rm bin/gitea && ./kit.sh up). See README." ;;
status) cmd_status ;;
down) cmd_down ;;
install-service) cmd_install_service ;;
*) sed -n '2,4p' "$0" ;;
esac