diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..81c5862 --- /dev/null +++ b/.env.example @@ -0,0 +1,35 @@ +# Copy to .env (./kit.sh init does it for you) and edit. .env is git-ignored. + +# How to run: "docker" (docker compose, official image) or "binary" (official binary, no Docker). +KIT_MODE=docker + +APP_NAME="My Gitea" +# Public hostname and URL (what people type in the browser). Trailing slash required. +DOMAIN=localhost +ROOT_URL=http://localhost:3000/ +HTTP_PORT=3000 +# Docker: host interface the port is published on. 127.0.0.1 = only reachable through your proxy/tunnel. +BIND_ADDR=127.0.0.1 +# Set to true when ROOT_URL is https. +COOKIE_SECURE=false + +# Sign-up policy +DISABLE_REGISTRATION=false +MAX_REPOS_PER_USER=10 +REGISTER_EMAIL_CONFIRM=false +MAILER_ENABLED=false + +# First admin (created by ./kit.sh setup). Leave ADMIN_PASSWORD empty to generate one +# (saved to secrets/admin.password, mode 0600). +ADMIN_USER=admin +ADMIN_EMAIL=admin@example.com +ADMIN_PASSWORD= + +# Default branch protection applied by ./kit.sh protect / protect-all. +PROTECT_BRANCH=main +# Approvals needed to merge a PR (0 lets a solo admin merge their own PRs; 1+ for teams). +REQUIRED_APPROVALS=0 + +# Binary mode only: where Gitea keeps its data and config. +GITEA_HOME=/var/lib/gitea +GITEA_CONF=/etc/gitea/app.ini diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..074b034 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.env +config/app.ini +data/ +secrets/ +bin/ +backups/ +*.zip diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6c2adc4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 The gitea-selfhost contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 0c708ac..f4778de 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,179 @@ # gitea-selfhost -Self-host kit: run your own Gitea with PR-only main, open sign-up behind a captcha, and per-agent tokens. Docker or binary + systemd. \ No newline at end of file +Run your own [Gitea](https://about.gitea.com/) code host, set up the way the new internet's +hosted Gitea is: public repos browsable without an account, open sign-up behind a captcha, +`main` protected so every change lands through a pull request, and one Gitea user plus one +scoped token per agent. + +Two ways to run it, same config: + +| | Docker (default) | No Docker | +|---|---|---| +| What runs | official image `gitea/gitea:28.1.0-rootless` | official binary 28.1.0 (sha256-verified download) | +| Start | `./kit.sh up` | `KIT_MODE=binary` in `.env`, then `./kit.sh up` (or a systemd service) | +| Data | `./data` | `GITEA_HOME` (default `/var/lib/gitea`) | +| Config | `./config/app.ini` | `GITEA_CONF` (default `/etc/gitea/app.ini`) | + +## One-command start + +```sh +git clone gitea-selfhost && cd gitea-selfhost +./kit.sh up +``` + +The first run creates `.env` from `.env.example`. Edit it (at least `DOMAIN` and `ROOT_URL`), then +run `./kit.sh up` again. It will: + +1. render `config/app.ini` from `config/app.ini.template` with fresh random secrets (generated on + your machine, never committed); +2. start Gitea (Docker, or the downloaded binary) and wait for `/api/healthz`; +3. create the admin user (password generated into `secrets/admin.password` unless you set + `ADMIN_PASSWORD`) and an admin API token in `secrets/admin.token` (mode 0600). + +Then open `ROOT_URL`. Needs: `bash`, `curl`, `python3` (for `protect-all`), and Docker with the +compose plugin (Docker mode). + +### No Docker: binary + systemd + +```sh +sed -i 's/^KIT_MODE=.*/KIT_MODE=binary/' .env +sudo ./kit.sh install-service +``` + +As root, that downloads and verifies the binary, creates a `git` system user, installs +`/usr/local/bin/gitea`, renders the config into `GITEA_CONF`, installs and starts +`systemd/gitea.service`, and creates the admin + token. Run later kit commands with `sudo`. + +Without systemd, `./kit.sh up` in binary mode runs Gitea in the background with a pid file under +`GITEA_HOME` (set `GITEA_HOME` / `GITEA_CONF` in `.env` to paths you can write). + +## Commands + +``` +./kit.sh up init + start + admin setup +./kit.sh protect OWNER/REPO [BRANCH] block direct pushes to BRANCH (default main); PRs only +./kit.sh protect-all protect the default branch of every non-empty repo +./kit.sh agent NAME one Gitea user + one scoped token for an agent +./kit.sh backup gitea dump into ./backups +./kit.sh status | down +``` + +## What the config does (`config/app.ini.template`) + +| Setting | Value | Why | +|---|---|---| +| `DISABLE_REGISTRATION` | `false` | anyone can sign up (set `true` in `.env` for invite-only) | +| `ENABLE_CAPTCHA`, `CAPTCHA_TYPE` | `true`, `image` | built-in image captcha on sign-up, no third party | +| `REGISTER_EMAIL_CONFIRM` | `false` | turn on only after `[mailer]` really delivers mail | +| `REQUIRE_SIGNIN_VIEW` | `false` | public repos are browsable without an account | +| `DEFAULT_ALLOW_CREATE_ORGANIZATION` | `false` | new users cannot create organizations | +| `MAX_CREATION_LIMIT` | `10` | repos per regular user (admins are exempt) | +| `DISABLE_MIGRATIONS`, `[mirror] DISABLE_NEW_PULL` | `true` | no importing or mirroring from arbitrary URLs | +| `[security] EGRESS_MODE` | `lax` | webhooks may call public hosts only, not your LAN or loopback | +| `DEFAULT_KEEP_EMAIL_PRIVATE` | `true` | new users' emails hidden by default | +| `DEFAULT_BRANCH` | `main` | | +| `DISABLE_SSH` | `true` | Git over HTTPS with a token; nothing to expose but one HTTP port | +| `SSH_CREATE_AUTHORIZED_KEYS_FILE` | `false` | Gitea never touches the host user's `~/.ssh` | + +Gitea has no instance-wide "protect main in every new repo" switch, so the kit applies the rule +per repo: `./kit.sh protect OWNER/REPO`, or `./kit.sh protect-all` from a timer +(`systemd/gitea-protect.timer` runs it every 5 minutes; with cron: +`*/5 * * * * cd /opt/gitea-selfhost && ./kit.sh protect-all`). The rule blocks direct pushes for +everyone, admins included; changes land through a PR. `REQUIRED_APPROVALS` in `.env` sets how many +approvals a PR needs (0 lets a solo admin merge their own PR; Gitea never lets you approve your own). + +## Agents and tokens + +- One Gitea user per agent: `./kit.sh agent my-agent`. The token (in `secrets/agents/my-agent.token`) + has `write:repository`, `write:issue` and `write:user`: it can create its own repos, push branches and + open PRs, but has no admin scope. +- Git remote: `https:////.git`, username = agent name, password = token. + Keep it out of URLs and shell history: `git config credential.helper store` with a 0600 file, or + `GIT_ASKPASS`. +- API: `Authorization: token `. +- Give an agent access to someone else's repo by adding it as a collaborator (write), not by sharing + tokens. Never hand out the admin token. Rotate a token by generating a new one and deleting the old + one in the user's settings (Applications). + +## Put it behind a tunnel or reverse proxy + +Keep Gitea on loopback (`BIND_ADDR=127.0.0.1`) and publish it through a proxy that terminates TLS. +Set `ROOT_URL` to the public URL and `COOKIE_SECURE=true` once it is https, then restart. + +**Caddy** (automatic HTTPS): + +``` +git.example.com { + reverse_proxy 127.0.0.1:3000 +} +``` + +**nginx**: + +```nginx +server { + server_name git.example.com; + client_max_body_size 512m; + location / { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +Under a sub-path (`https://example.com/git/`), set `ROOT_URL = https://example.com/git/` and strip +the prefix in the proxy (`location /git/ { proxy_pass http://127.0.0.1:3000/; ... }`). + +**Cloudflare Tunnel** (no open ports, no public IP needed): + +```sh +# quick, temporary URL (changes when cloudflared restarts; update ROOT_URL each time) +cloudflared tunnel --url http://127.0.0.1:3000 +# stable hostname on your own domain +cloudflared tunnel login +cloudflared tunnel create gitea +cloudflared tunnel route dns gitea git.example.com +cloudflared tunnel run --url http://127.0.0.1:3000 gitea +``` + +## Backups + +```sh +./kit.sh backup # backups/gitea-dump-YYYYmmdd-HHMMSS.zip: repos, database, config +``` + +The dump contains your `app.ini` secrets and every repo: store it encrypted and off the machine. +Also keep a copy of `.env` and `secrets/`. Restore = stop Gitea, unzip the dump, put the repos, +`gitea.db` and `app.ini` back in place (see the upstream +[backup and restore guide](https://docs.gitea.com/administration/backup-and-restore)), start Gitea. + +## Upgrades + +1. `./kit.sh backup`. +2. Read the [release notes](https://blog.gitea.com/) for breaking config changes. +3. Docker: change the image tag in `docker-compose.yml`, then `docker compose pull && docker compose up -d`. + Binary: change `GITEA_VERSION` in `kit.sh`, `rm bin/gitea && ./kit.sh up` + (systemd: `sudo install -m 0755 bin/gitea /usr/local/bin/gitea && sudo systemctl restart gitea`). +4. Gitea migrates the database on start. Check `./kit.sh status` and the log. + +## Test the kit + +```sh +tests/e2e-binary.sh [PORT] +``` + +Runs the whole flow in a throwaway directory on a spare port (default 3990): download and verify the +binary, start, create the admin, create a repo, protect `main`, check that a direct push to `main` is +rejected (even for the admin) while a PR merges, create an agent token (own repo yes, admin API no), +check the new-user defaults (no org creation, 10-repo limit), migrations off, captcha on sign-up, +backup. Then it stops Gitea and deletes everything. + +## Licenses + +- **Gitea** is free software under the MIT License, copyright the Gitea authors + (). This kit does not include or modify Gitea: it downloads the + official binary or image. +- **This kit** (scripts, templates, docs) is MIT-licensed too; see [LICENSE](LICENSE). diff --git a/config/app.ini.template b/config/app.ini.template new file mode 100644 index 0000000..f74c9cf --- /dev/null +++ b/config/app.ini.template @@ -0,0 +1,99 @@ +; Gitea app.ini template for gitea-selfhost. +; Rendered by ./kit.sh init into config/app.ini with fresh random secrets. +; Placeholders (dollar + braces) are filled from .env (see .env.example). Do not commit the rendered file. + +APP_NAME = ${APP_NAME} +RUN_MODE = prod +WORK_PATH = ${WORK_PATH} + +[server] +PROTOCOL = http +DOMAIN = ${DOMAIN} +HTTP_ADDR = ${HTTP_ADDR} +HTTP_PORT = ${HTTP_PORT} +; The public URL people type in the browser (behind your tunnel / reverse proxy). +ROOT_URL = ${ROOT_URL} +; How Gitea reaches itself (hooks, internal API). Always loopback. +LOCAL_ROOT_URL = http://127.0.0.1:${HTTP_PORT}/ +APP_DATA_PATH = ${WORK_PATH}/data +; HTTP(S) only: Git over smart HTTP with a username + access token. +DISABLE_SSH = true +START_SSH_SERVER = false +; Never touch the host user's ~/.ssh/authorized_keys. +SSH_CREATE_AUTHORIZED_KEYS_FILE = false +OFFLINE_MODE = true +LFS_START_SERVER = false + +[database] +DB_TYPE = sqlite3 +PATH = ${WORK_PATH}/data/gitea.db + +[repository] +ROOT = ${WORK_PATH}/git/repositories +DEFAULT_BRANCH = main +DEFAULT_PRIVATE = public +; Max repos per regular user (-1 = unlimited). Admins are exempt. +MAX_CREATION_LIMIT = ${MAX_REPOS_PER_USER} +; No importing / mirroring from arbitrary URLs. +DISABLE_MIGRATIONS = true +ENABLE_PUSH_CREATE_USER = false +ENABLE_PUSH_CREATE_ORG = false + +[mirror] +; No new pull mirrors either. +DISABLE_NEW_PULL = true + +[security] +INSTALL_LOCK = true +SECRET_KEY = ${SECRET_KEY} +INTERNAL_TOKEN = ${INTERNAL_TOKEN} +MIN_PASSWORD_LENGTH = 10 +PASSWORD_CHECK_PWN = false +; Outgoing requests (webhooks, OAuth2 clients) may reach public hosts only; loopback / LAN +; targets need an explicit [security] ALLOWED_HOST_LIST entry. Blocks SSRF into your network. +EGRESS_MODE = lax + +[oauth2] +JWT_SECRET = ${JWT_SECRET} + +[service] +; Public self-registration (set DISABLE_REGISTRATION=true in .env for invite-only). +DISABLE_REGISTRATION = ${DISABLE_REGISTRATION} +SHOW_REGISTRATION_BUTTON = true +ENABLE_CAPTCHA = true +CAPTCHA_TYPE = image +REQUIRE_CAPTCHA_FOR_LOGIN = false +; Only turn this on once [mailer] really delivers mail. +REGISTER_EMAIL_CONFIRM = ${REGISTER_EMAIL_CONFIRM} +REGISTER_MANUAL_CONFIRM = false +ALLOW_ONLY_EXTERNAL_REGISTRATION = false +; Public repos stay browsable without an account. +REQUIRE_SIGNIN_VIEW = false +DEFAULT_ALLOW_CREATE_ORGANIZATION = false +DEFAULT_KEEP_EMAIL_PRIVATE = true +DEFAULT_ENABLE_TIMINGS = false +ENABLE_NOTIFY_MAIL = false +NO_REPLY_ADDRESS = noreply.${DOMAIN} +ENABLE_TIMELINE = true +ENABLE_USER_HEATMAP = false +AUTO_WATCH_NEW_REPOS = false +AUTO_WATCH_ON_CHANGES = false + +[mailer] +ENABLED = ${MAILER_ENABLED} + +[openid] +ENABLE_OPENID_SIGNIN = false +ENABLE_OPENID_SIGNUP = false + +[session] +PROVIDER = file +COOKIE_SECURE = ${COOKIE_SECURE} + +[log] +MODE = console +LEVEL = Info + +[other] +SHOW_FOOTER_VERSION = false +SHOW_FOOTER_TEMPLATE_LOAD_TIME = false diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2399c84 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +# gitea-selfhost: official Gitea image, pinned. Start with ./kit.sh up (it renders config first). +services: + gitea: + image: gitea/gitea:28.1.0-rootless + container_name: gitea + restart: unless-stopped + user: "${KIT_UID:-1000}:${KIT_GID:-1000}" + environment: + - GITEA_APP_INI=/etc/gitea/app.ini + - GITEA_WORK_DIR=/var/lib/gitea + volumes: + - ./data:/var/lib/gitea + - ./config:/etc/gitea + ports: + - "${BIND_ADDR:-127.0.0.1}:${HTTP_PORT:-3000}:${HTTP_PORT:-3000}" diff --git a/kit.sh b/kit.sh new file mode 100755 index 0000000..7e13f96 --- /dev/null +++ b/kit.sh @@ -0,0 +1,221 @@ +#!/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%/}//.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 diff --git a/systemd/gitea-protect.service b/systemd/gitea-protect.service new file mode 100644 index 0000000..84990d9 --- /dev/null +++ b/systemd/gitea-protect.service @@ -0,0 +1,8 @@ +# Re-applies default branch protection to every repo (new repos included). +[Unit] +Description=gitea-selfhost: protect default branches + +[Service] +Type=oneshot +WorkingDirectory=/opt/gitea-selfhost +ExecStart=/opt/gitea-selfhost/kit.sh protect-all diff --git a/systemd/gitea-protect.timer b/systemd/gitea-protect.timer new file mode 100644 index 0000000..aaf5a2a --- /dev/null +++ b/systemd/gitea-protect.timer @@ -0,0 +1,9 @@ +[Unit] +Description=gitea-selfhost: protect default branches every 5 minutes + +[Timer] +OnBootSec=2min +OnUnitActiveSec=5min + +[Install] +WantedBy=timers.target diff --git a/systemd/gitea.service b/systemd/gitea.service new file mode 100644 index 0000000..8e066b3 --- /dev/null +++ b/systemd/gitea.service @@ -0,0 +1,24 @@ +# Binary mode: install with sudo ./kit.sh install-service +# Assumes a "git" system user, binary at /usr/local/bin/gitea, data in /var/lib/gitea, +# config in /etc/gitea/app.ini (the defaults in .env.example). +[Unit] +Description=Gitea (gitea-selfhost) +After=network.target + +[Service] +Type=simple +User=git +Group=git +WorkingDirectory=/var/lib/gitea +ExecStart=/usr/local/bin/gitea web --config /etc/gitea/app.ini --work-path /var/lib/gitea +Restart=always +RestartSec=5 +Environment=USER=git HOME=/home/git GITEA_WORK_DIR=/var/lib/gitea +# Hardening +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +ReadWritePaths=/var/lib/gitea /etc/gitea + +[Install] +WantedBy=multi-user.target diff --git a/tests/e2e-binary.sh b/tests/e2e-binary.sh new file mode 100755 index 0000000..8e150ff --- /dev/null +++ b/tests/e2e-binary.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# End-to-end check of the kit in binary mode, in a throwaway directory on a spare port. +# Usage: tests/e2e-binary.sh [PORT] (default 3990). Leaves nothing behind. +set -euo pipefail +PORT="${1:-3990}" +SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TMP="$(mktemp -d)"; WORK="$TMP/kit"; CLONE="$TMP/clone" +pass=0; fail=0 +ok() { echo "PASS $*"; pass=$((pass+1)); } +bad() { echo "FAIL $*"; fail=$((fail+1)); } +cleanup() { (cd "$WORK" 2>/dev/null && ./kit.sh down >/dev/null 2>&1) || true; rm -rf "$TMP"; } +trap cleanup EXIT + +ss -ltn 2>/dev/null | grep -q ":$PORT " && { echo "port $PORT is busy"; exit 2; } +mkdir -p "$WORK"; cp -r "$SRC"/. "$WORK"/; rm -rf "$WORK/.git" "$WORK/.env" "$WORK/secrets" "$WORK/data" +cd "$WORK" +cat > .env </dev/null && ok "admin created repo kitadmin/demo" || bad "create repo" +./kit.sh protect kitadmin/demo >/dev/null && ok "branch protection applied to main" || bad "protect" + +git -c credential.helper= clone -q "http://kitadmin:$P@127.0.0.1:$PORT/kitadmin/demo.git" "$CLONE" +cd "$CLONE"; git config user.name e2e; git config user.email e2e@example.com +echo direct > direct.txt; git add direct.txt; git commit -qm direct +if git -c credential.helper= push -q origin main 2>/dev/null; then bad "direct push to main was ACCEPTED"; else ok "direct push to main rejected (even for the admin)"; fi +git reset -q --hard origin/main; git checkout -qb feature; echo via-pr > pr.txt; git add pr.txt; git commit -qm "via PR" +git -c credential.helper= push -q origin feature 2>/dev/null && ok "push to a feature branch accepted" || bad "feature push" +cd "$WORK" +N=$(curl -fsS "${H[@]}" -X POST "$A/repos/kitadmin/demo/pulls" -d '{"head":"feature","base":"main","title":"via PR"}' | python3 -c 'import json,sys;print(json.load(sys.stdin)["number"])') +curl -fsS "${H[@]}" -X POST "$A/repos/kitadmin/demo/pulls/$N/merge" -d '{"Do":"merge"}' >/dev/null && ok "PR #$N merged into main" || bad "merge" +curl -fsS "${H[@]}" "$A/repos/kitadmin/demo/contents/pr.txt?ref=main" >/dev/null && ok "merged change is on main" || bad "merged file missing" + +./kit.sh agent e2e-agent >/dev/null && ok "agent user + scoped token created" || bad "agent" +AT="$(cat secrets/agents/e2e-agent.token)" +curl -fsS -H "Authorization: token $AT" -H "Content-Type: application/json" -X POST "$A/user/repos" -d '{"name":"agent-repo","auto_init":true}' >/dev/null && ok "agent token can create its own repo" || bad "agent create repo" +code=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: token $AT" "$A/admin/users"); [ "$code" = 403 ] && ok "agent token has no admin access (403)" || bad "agent admin access: $code" +code=$(curl -s -o /dev/null -w '%{http_code}' "${H[@]}" -H "Sudo: e2e-agent" -X POST "$A/orgs" -d '{"username":"e2e-org"}'); [ "$code" = 403 ] && ok "new users cannot create organizations (403)" || bad "org creation by new user: $code" +st=$(curl -fsS "${H[@]}" "$A/settings/repository"); grep -q '"migrations_disabled":true' <<<"$st" && ok "migrations disabled" || bad "migrations" +page=$(curl -fsS "http://127.0.0.1:$PORT/user/sign_up"); grep -q 'captcha' <<<"$page" && ok "public sign-up page shows a captcha" || bad "sign-up captcha" +./kit.sh backup >/dev/null 2>&1 && ls backups/*.zip >/dev/null && ok "backup (gitea dump) written" || bad "backup" +./kit.sh protect-all >/dev/null && ok "protect-all ran" || bad "protect-all" + +made=1; for i in $(seq 2 11); do + c=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: token $AT" -H "Content-Type: application/json" -X POST "$A/user/repos" -d "{\"name\":\"r$i\"}") + [ "$c" = 201 ] && made=$((made+1)) +done +[ "$made" = 10 ] && ok "repo limit: regular user stops at 10 repos" || bad "repo limit: user made $made repos" +if grep -q "Deprecation" "$TMP/home/log/gitea.out"; then bad "config uses deprecated keys: $(grep -o 'config option `[^`]*`' "$TMP/home/log/gitea.out" | sort -u | tr '\n' ' ')"; else ok "no deprecated config keys in the Gitea log"; fi + +echo; echo "$pass passed, $fail failed" +[ "$fail" = 0 ] +