222 lines
11 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"
local want got; want="$(curl -fsSL "$url.sha256" | awk '{print $1}')"; 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 systemctl is-enabled gitea >/dev/null 2>&1; 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() {
if [ "$KIT_MODE" = docker ]; then compose down
elif systemctl is-active gitea >/dev/null 2>&1; then sudo systemctl stop gitea
elif [ -f "$WORK_PATH/gitea.pid" ]; then kill "$(cat "$WORK_PATH/gitea.pid")" 2>/dev/null || true; rm -f "$WORK_PATH/gitea.pid"; log "stopped"
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
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