222 lines
11 KiB
Bash
Executable File
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
|