Files
ubuntu-post-install/services/gitea.sh
T
Claude bec9228c55 Back up existing files before every service overwrites them
Confirmed live: install_frigate()'s fresh-install path overwrote a
working, hand-crafted docker-compose.yml (Frigate + mosquitto +
frigate-notify) with zero backup, because that file's shape didn't match
what frigate.sh's own "existing install" detection knew how to recognize.
Every service's own detection is a judgment call about what counts as
"already installed" and can miss a real setup built outside this repo's
conventions.

lib/common.sh gains backup_if_exists(FILE) — copies FILE to
FILE.bak.<timestamp> if it exists, no-ops otherwise (including DRY_RUN).
Applied before every service's own `cat > docker-compose.yml`/`cat > .env`
write across all 60 services that do one (115 call sites), plus a matching
standalone-mode stub added to every service's own bootstrap block, same
convention already used for port_in_use/find_free_port. This doesn't
replace a service's own update/fresh-reinstall detection — it's the safety
net underneath it, so a wrong detection costs a .bak file to restore from
instead of the original silently disappearing.

Also fixes the actual gap that surfaced this: services/frigate.sh's
Authelia offer only checked for Authelia installed locally on Frigate's
own box, which is never true for a dedicated NVR box with no local Caddy
either (the common shape — Caddy lives elsewhere, snippet-generation mode
already handles that). Now offers Authelia protection unconditionally and,
when Authelia isn't local, asks whether it lives on the same machine as
Caddy (still "import authelia", since that's local to wherever Caddy ends
up) or on a genuinely separate third machine (the explicit
header-pinned forward_auth form, per CLAUDE.md's "forward_auth to a remote
Authelia" note, needed because a bare authelia:9091 shortcut only works
one hop).
2026-08-26 17:26:17 +00:00

832 lines
38 KiB
Bash

#!/bin/bash
# services/gitea.sh — Self-hosted Gitea (lightweight Git server), with an
# optional two-way GitHub mirror sync (gitea-github-sync.sh, vendored from
# the ai-stack bundle but genuinely standalone here — this does NOT pull in
# Ollama/ComfyUI/InvokeAI/any of the rest of that stack, just the one
# Gitea container + the sync script).
# Part of the modular post-install system (sourced by setup.sh).
#
# Can also be run standalone on any machine:
# sudo bash gitea.sh
# (Docker must already be installed when run standalone)
# ── Standalone bootstrap ──────────────────────────────────────────────────────
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
_SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_COMMON="$_SELF_DIR/../lib/common.sh"
if [[ -f "$_COMMON" ]]; then
# shellcheck source=../lib/common.sh
source "$_COMMON"
else
log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; }
log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; }
log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }
require_docker() {
command -v docker &>/dev/null || {
log_error "Docker not found. Install it first:"
log_error " curl -fsSL https://get.docker.com | sudo sh"
return 1
}
docker compose version &>/dev/null || {
log_error "Docker Compose plugin missing:"
log_error " sudo apt-get install -y docker-compose-plugin"
return 1
}
}
ensure_docker_dir_ownership() {
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
generate_password() {
local _len="${1:-32}"
tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len"
}
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
read -r -p " $_q " _r
eval "$_var='${_r:-$_def}'"
}
prompt_yn() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
read -r -p " $_q " _r
eval "$_var='${_r:-$_def}'"
}
prompt_reinstall_mode() {
local _var="$1" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='cancel'"; return; }
echo ""
echo " 1) Update — refresh the image only, leave config/data as-is"
echo " 2) Full reinstall — wipe and reconfigure from scratch"
echo " 3) Cancel — leave the existing install untouched"
read -r -p " Choice [3]: " _r
case "$_r" in
1) eval "$_var='update'" ;;
2) eval "$_var='fresh'" ;;
*) eval "$_var='cancel'" ;;
esac
}
configure_caddy_for_service() {
local _name="$1" _upstream="$2" _subdomain="$3"
local _display_port="${_upstream##*:}"
log_info "Access $_name directly on port $_display_port (no Caddy in standalone mode)."
CADDY_SERVICE_CONFIGURED=false
}
write_readme() {
local _dir="$1"; shift
mkdir -p "$_dir"
cat > "$_dir/README.md"
}
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")"
DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}"
DRY_RUN="${DRY_RUN:-false}"
UNATTENDED="${UNATTENDED:-false}"
SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
SITE_DOMAIN="${SITE_DOMAIN:-example.com}"
register_service() { :; }
_RUN_STANDALONE=1
fi
# ─────────────────────────────────────────────────────────────────────────────
register_service gitea utilities "Self-hosted Git server (Gitea) — raw local clones plus optional two-way GitHub mirror sync" 3001
_gitea_sync_vendor_src() {
local _self_dir
_self_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "$(cd "$_self_dir/.." && pwd)/vendor/ai-stack/gitea-github-sync.sh"
}
# Prompt for a token with retries — pasted tokens over SSH sometimes race the
# prompt (the terminal delivers the paste a beat after an already-submitted
# empty line, so the token shows up echoed on the *next* line instead of
# being read). A single empty answer used to be taken as "no token", silently
# — this gives it up to 3 tries before actually giving up, and strips
# whitespace in case the paste carried a stray leading/trailing newline.
_gitea_prompt_token() {
local _question="$1" _varname="$2"
local _max=1 _tries=0 _val=""
[ "$UNATTENDED" != true ] && _max=3
while [[ $_tries -lt $_max ]]; do
prompt_text "$_question" "" _val
_val="$(printf '%s' "$_val" | tr -d '[:space:]')"
[[ -n "$_val" ]] && break
_tries=$((_tries + 1))
[[ $_tries -lt $_max ]] && log_warning " Nothing came through — if you pasted it, try again (a paste can race the prompt over SSH)."
done
eval "$_varname='$_val'"
}
# Gitea's container always runs internally as UID 1000 (USER_UID/USER_GID in
# the compose file below are fixed, independent of whoever's running this
# installer) — the official image chowns /data to that UID on its own at
# startup. A plain ensure_docker_dir_ownership call fights that: it's a
# recursive chown of the whole service directory to $ACTUAL_USER, which on
# a box where the installer runs as root directly (ACTUAL_USER=root) resets
# the live data/ back to UID 0. If the container doesn't happen to restart
# right after (e.g. Update mode against an already-running container, which
# just no-ops), nothing ever re-fixes it, and every write to Gitea's own
# SQLite DB then fails with "attempt to write a readonly database" — the
# directory holding the DB file is owned by root, not the UID 1000 process
# trying to write it. Confirmed live. Chown everything else as normal in
# $DIR; leave data/ (and the Actions runner's own runner-data/, same reason)
# for their respective containers to manage.
_gitea_fix_ownership() {
local _dir="$1"
[ "$DRY_RUN" = true ] && return 0
chown "$ACTUAL_USER:$ACTUAL_USER" "$_dir" 2>/dev/null || true
local _entry
for _entry in "$_dir"/*; do
[ -e "$_entry" ] || continue
case "$(basename "$_entry")" in
data|runner-data) continue ;;
esac
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$_entry" 2>/dev/null || true
done
}
# Offers to add "Sign in with Authelia" (OpenID Connect) to Gitea's own
# login page. This is a different thing from Caddy forward_auth, which this
# service deliberately skips (see the Caddy call below) since Gitea already
# has a solid built-in login — this adds Authelia as an *additional* OAuth2
# login option on the same page, on top of that built-in login, not a
# replacement for it. Nothing about local admin login changes.
#
# Fully automated on both sides, matching how the rest of this installer
# avoids manual web wizards: registers Gitea as an OIDC client in Authelia
# (services/authelia.sh's _authelia_provision_oidc_client), then adds the
# resulting client as an authentication source in Gitea via its own CLI. If
# either side isn't available (Authelia not installed, or the Gitea CLI
# call fails for some reason — e.g. an older image without `admin auth
# add-oauth`), falls back to printing the values for a two-minute manual
# add in Gitea's Site Administration UI instead of losing the setup.
_gitea_offer_authelia_sso() {
local DIR="$1"
[ -d "$DOCKER_DIR/authelia" ] || return 0
declare -F _authelia_provision_oidc_client >/dev/null 2>&1 || return 0
echo ""
local USE_SSO=""
prompt_yn " Add \"Sign in with Authelia\" (OpenID Connect) to Gitea's login page? (y/n):" "n" USE_SSO
[[ "$USE_SSO" =~ ^[Yy]$ ]] || return 0
local _default_domain=""
[ -n "${SITE_DOMAIN:-}" ] && [ "$SITE_DOMAIN" != "example.com" ] && _default_domain="git.${SITE_DOMAIN}"
local GITEA_OIDC_DOMAIN=""
prompt_text " Domain Gitea is reachable at [${_default_domain:-required}]:" "$_default_domain" GITEA_OIDC_DOMAIN
if [ -z "$GITEA_OIDC_DOMAIN" ]; then
log_warning "No domain entered — skipping Authelia SSO for Gitea."
return 0
fi
local _2fa="" AUTH_POLICY="two_factor"
prompt_yn " Require two-factor for Gitea logins via Authelia too? (y/n):" "y" _2fa
[[ "$_2fa" =~ ^[Yy]$ ]] || AUTH_POLICY="one_factor"
if ! _authelia_provision_oidc_client "Gitea" "gitea" "$AUTH_POLICY" "y" "n" \
"https://${GITEA_OIDC_DOMAIN}/user/oauth2/authelia/callback"; then
log_warning "Couldn't register Gitea as an OIDC client in Authelia — skipping SSO setup."
return 0
fi
local _discovery_url="${OIDC_AUTHELIA_PORTAL_URL}/.well-known/openid-configuration"
log_info "Adding Authelia as an authentication source in Gitea..."
if docker exec -u git gitea gitea admin auth add-oauth \
--name authelia --provider openidConnect \
--key gitea --secret "$OIDC_CLIENT_SECRET_PLAIN" \
--auto-discover-url "$_discovery_url" &>/dev/null; then
log_success "\"Sign in with Authelia\" added to Gitea's login page — local admin login still works too."
else
log_warning "Couldn't add the auth source automatically. Add it by hand:"
log_warning " Gitea -> Site Administration -> Authentication Sources -> Add Authentication Source"
log_warning " Type: OAuth2, Provider: OpenID Connect, Name: authelia"
log_warning " Client ID: gitea"
log_warning " Client Secret: $OIDC_CLIENT_SECRET_PLAIN"
log_warning " Discovery URL: $_discovery_url"
log_warning " (The Client Secret above is shown once — it isn't stored in plaintext anywhere.)"
fi
declare -F _authelia_scope_access >/dev/null 2>&1 && _authelia_scope_access "gitea" "$GITEA_OIDC_DOMAIN"
}
# Offers Gitea's OTHER Authelia integration — not the OIDC button above, but
# ENABLE_REVERSE_PROXY_AUTHENTICATION: Gitea auto-logs in as whatever user
# name arrives in a trusted header, no click and no separate Gitea session
# to expire on its own schedule. This is genuinely stronger than the OIDC
# button (which still shows a login page, just with an extra option on it)
# and matches the pattern services/frigate.sh uses — except Gitea's own
# login form stays available as a fallback for anyone NOT arriving from a
# trusted source, so there's no "native login disabled with nothing gating
# it" failure mode to guard against here the way Frigate's had.
#
# The security boundary is REVERSE_PROXY_TRUSTED_PROXIES, not a shared
# secret: Gitea only honors the identity header from source IPs inside that
# range. Gitea's own Docker image shipped this wildcarded (GHSA-f75j-4cw6-
# rmx4 — any IP could set X-WEBAUTH-USER and log in as anyone), so this is
# always computed from caddy_net's real subnet (same lookup
# ufw_allow_from_caddy_net uses) and refuses to enable the feature at all if
# that can't be determined — never falls back to a permissive default.
#
# Requires Gitea to actually be reachable from an address inside that range,
# which means joining caddy_net like every other locally-Caddy-fronted
# service in this repo (Gitea currently reaches Caddy via its published
# host port instead — host.docker.internal upstream — because it predates
# this feature). Local Caddy only: a remote Caddy machine's source address
# isn't a stable, narrowly-scopeable range the way caddy_net's bridge subnet
# is, so this skips remote mode rather than guess at a trust range worth
# getting wrong.
_gitea_offer_reverse_proxy_auth() {
local DIR="$1"
[ -d "$DOCKER_DIR/authelia" ] || return 0
[ -d "$DOCKER_DIR/caddy" ] || return 0
if grep -q 'ENABLE_REVERSE_PROXY_AUTHENTICATION=true' "$DIR/docker-compose.yml" 2>/dev/null; then
log_info "Gitea's zero-click Authelia login (reverse-proxy auth) is already enabled — skipping."
return 0
fi
echo ""
local USE_RP=""
prompt_yn " Skip Gitea's own login entirely for anyone arriving via Authelia — fully transparent, no click, no separate Gitea session to re-expire? Rewires Gitea onto Caddy's internal network (Caddy must be on this same machine). (y/n):" "n" USE_RP
[[ "$USE_RP" =~ ^[Yy]$ ]] || return 0
local _subnet
_subnet="$(docker network inspect "${SITE_CADDY_NET:-caddy_net}" \
--format '{{range .IPAM.Config}}{{.Subnet}}{{end}}' 2>/dev/null)"
if [ -z "$_subnet" ]; then
log_warning "Couldn't determine ${SITE_CADDY_NET:-caddy_net}'s subnet — refusing to enable"
log_warning "reverse-proxy auth without a scoped trust range. An unscoped default lets ANY"
log_warning "client impersonate ANY Gitea user via a spoofed header (this was a real Gitea"
log_warning "CVE — GHSA-f75j-4cw6-rmx4). Skipping."
return 1
fi
log_info "Wiring Gitea onto caddy_net and enabling reverse-proxy authentication..."
sed -i "/GITEA__security__INSTALL_LOCK=true/a\\ - GITEA__service__ENABLE_REVERSE_PROXY_AUTHENTICATION=true\\n - GITEA__service__ENABLE_REVERSE_PROXY_AUTO_REGISTRATION=true\\n - GITEA__service__ENABLE_REVERSE_PROXY_EMAIL=true\\n - GITEA__security__REVERSE_PROXY_AUTHENTICATION_USER=Remote-User\\n - GITEA__security__REVERSE_PROXY_AUTHENTICATION_EMAIL=Remote-Email\\n - GITEA__security__REVERSE_PROXY_TRUSTED_PROXIES=${_subnet}" \
"$DIR/docker-compose.yml"
cat >> "$DIR/docker-compose.yml" << EOF
networks:
- caddy_net
networks:
caddy_net:
external: true
name: ${SITE_CADDY_NET:-caddy_net}
EOF
_gitea_fix_ownership "$DIR"
(cd "$DIR" && docker compose up -d) \
&& log_success "Gitea restarted on caddy_net (trusted range: ${_subnet})." \
|| { log_warning "Restart failed — check: docker compose -f $DIR/docker-compose.yml logs"; return 1; }
# Re-point Caddy at the container (gitea:3000, now reachable over
# caddy_net) instead of the host-published port, with the auth gate in
# front. This replaces the plain block set up earlier in this install —
# configure_caddy_for_service's own "already exists — overwrite?" prompt
# covers that; nothing here bypasses it.
configure_caddy_for_service "Gitea" "gitea:3000" "git" " import authelia"
if [ "${CADDY_SERVICE_CONFIGURED:-false}" = true ]; then
log_success "Gitea now signs in transparently via Authelia at https://${CADDY_SERVICE_DOMAIN} — its own login page is still there for anyone reaching it another way."
declare -F _authelia_scope_access >/dev/null 2>&1 && _authelia_scope_access "gitea" "$CADDY_SERVICE_DOMAIN"
else
log_warning "Caddy wasn't reconfigured — env vars are set, but nothing is routing Gitea through Authelia yet."
log_warning "Point Gitea's Caddy entry at gitea:3000 (not the old host.docker.internal upstream) with 'import authelia' in front, or just re-run this offer."
fi
}
# Offers to enable Gitea Actions (Gitea's own CI, largely GitHub-Actions-
# workflow-compatible) with a local runner — mainly useful as a fallback so
# .gitea/workflows/*.yml can still run something like a GitHub Actions build
# if GitHub itself is ever unreachable, since this Gitea is otherwise just a
# passive pull mirror. Off by default; opt-in on fresh installs and Update
# reruns alike (idempotent — a rerun after it's already set up just no-ops).
#
# The runner (gitea/act_runner) polls Gitea for jobs and needs the host's
# Docker socket to launch a fresh container per job — same pattern this repo
# already uses for portainer/watchtower/uptimekuma/beszel/traccar's autoheal,
# not something new to this file. Worth knowing: that's root-equivalent
# access to this host, standard for any CI runner, not unique to Gitea's.
_gitea_offer_actions_runner() {
local DIR="$1"
if grep -q '^ act_runner:$' "$DIR/docker-compose.yml" 2>/dev/null; then
log_info "Gitea Actions runner is already set up (act_runner service already in docker-compose.yml) — skipping."
log_info "Check its status: docker compose -f $DIR/docker-compose.yml ps act_runner"
return 0
fi
echo ""
local USE_ACTIONS=""
prompt_yn " Enable Gitea Actions (CI) with a local runner — runs .gitea/workflows/*.yml the same way GitHub Actions runs .github/workflows/*.yml, useful as a fallback if GitHub is ever unreachable? (y/n):" "n" USE_ACTIONS
[[ "$USE_ACTIONS" =~ ^[Yy]$ ]] || return 0
if ! grep -q 'GITEA__actions__ENABLED' "$DIR/docker-compose.yml"; then
log_info "Enabling Gitea Actions..."
sed -i '/GITEA__security__INSTALL_LOCK=true/a\ - GITEA__actions__ENABLED=true\n - GITEA__actions__DEFAULT_ACTIONS_URL=github' "$DIR/docker-compose.yml"
(cd "$DIR" && docker compose up -d) \
&& log_success "Actions enabled — Gitea restarted to apply." \
|| { log_warning "Restart failed — check: docker compose -f $DIR/docker-compose.yml logs"; return 1; }
fi
log_info "Generating a runner registration token..."
local RUNNER_TOKEN=""
RUNNER_TOKEN="$(docker exec -u git gitea gitea actions generate-runner-token 2>/dev/null | tail -1)"
if [[ -z "$RUNNER_TOKEN" ]]; then
log_warning "Couldn't generate a runner token automatically (older Gitea image?). Generate one by hand:"
log_warning " Gitea -> Site Administration -> Actions -> Runners -> Create new Runner"
log_warning " then add an act_runner container yourself using that token — see"
log_warning " https://docs.gitea.com/usage/actions/quickstart for the compose snippet."
return 1
fi
mkdir -p "$DIR/runner-data"
cat >> "$DIR/docker-compose.yml" << EOF
act_runner:
image: gitea/act_runner:latest
container_name: gitea-runner
restart: unless-stopped
environment:
- GITEA_INSTANCE_URL=http://gitea:3000
- GITEA_RUNNER_REGISTRATION_TOKEN=${RUNNER_TOKEN}
- GITEA_RUNNER_NAME=gitea-runner
volumes:
- ./runner-data:/data
- /var/run/docker.sock:/var/run/docker.sock
depends_on:
- gitea
EOF
_gitea_fix_ownership "$DIR"
(cd "$DIR" && docker compose up -d act_runner) \
&& log_success "Actions runner started — .gitea/workflows/*.yml will now run automatically on push." \
|| log_warning "Runner failed to start — check: docker compose -f $DIR/docker-compose.yml logs act_runner"
}
# ── Own systemd timer, not gitea-github-sync.sh's built-in --install-timer ──
# The vendor script's own timer installer always runs the script bare (no
# --pull-only/--push-only), i.e. always both directions — there's no way to
# hand it a fixed sync direction. Since this installer asks up front which
# direction to run automatically, the timer's ExecStart bakes that flag in
# directly instead of delegating to the vendor script's own (less flexible)
# --install-timer/--remove-timer modes.
_gitea_write_sync_timer() {
local DIR="$1" RUN_USER="$2" RUN_HOME="$3" FLAG="$4" INTERVAL="$5"
local _service="/etc/systemd/system/gitea-github-sync.service"
local _timer="/etc/systemd/system/gitea-github-sync.timer"
cat > "$_service" << UNIT
[Unit]
Description=Gitea-GitHub Mirror Sync
After=network-online.target docker.service
Wants=network-online.target
[Service]
Type=oneshot
User=${RUN_USER}
Environment=HOME=${RUN_HOME}
Environment=SYNC_ENV=${DIR}/.env
ExecStart=/bin/bash ${DIR}/gitea-github-sync.sh ${FLAG}
UNIT
cat > "$_timer" << UNIT
[Unit]
Description=Gitea-GitHub Sync Timer
[Timer]
OnBootSec=5min
OnUnitActiveSec=${INTERVAL}
Persistent=true
[Install]
WantedBy=timers.target
UNIT
systemctl daemon-reload
systemctl enable --now gitea-github-sync.timer
}
_gitea_remove_sync_timer() {
systemctl disable --now gitea-github-sync.timer 2>/dev/null || true
rm -f /etc/systemd/system/gitea-github-sync.service /etc/systemd/system/gitea-github-sync.timer
systemctl daemon-reload 2>/dev/null || true
}
# Ask sync direction + autosync, apply to either a fresh setup or a
# reconfigure of an existing one. Always asked (matches pstn-trunk.sh's
# international-calling step reasoning: a live-editable extra, not a
# structural setting tied exclusively to fresh installs).
_gitea_run_sync_direction_step() {
local DIR="$1"
echo ""
echo " Sync direction:"
echo " 1) GitHub -> Gitea only (cloud to local — backup your GitHub repos here)"
echo " 2) Gitea -> GitHub only (local to cloud — push repos created here up to GitHub)"
echo " 3) Both directions"
local _DIR_CHOICE=""
prompt_text " Choice [1]:" "1" _DIR_CHOICE
local FLAG="" DIR_DESC=""
case "$_DIR_CHOICE" in
2) FLAG="--push-only"; DIR_DESC="Gitea -> GitHub only" ;;
3) FLAG=""; DIR_DESC="both directions" ;;
*) FLAG="--pull-only"; DIR_DESC="GitHub -> Gitea only" ;;
esac
log_info "Sync direction: $DIR_DESC"
_gitea_remove_sync_timer
echo ""
local AUTOSYNC=""
prompt_yn "Enable automatic sync on a schedule? (y/n):" "y" AUTOSYNC
if [[ "$AUTOSYNC" =~ ^[Yy]$ ]]; then
local INTERVAL=""
prompt_text " Sync interval (e.g. 1h, 6h, 1d) [6h]:" "6h" INTERVAL
_gitea_write_sync_timer "$DIR" "$ACTUAL_USER" "$ACTUAL_HOME" "$FLAG" "$INTERVAL"
log_success "Timer installed: syncs every $INTERVAL ($DIR_DESC)."
log_info "Check status: systemctl status gitea-github-sync.timer"
log_info "Run now: sudo systemctl start gitea-github-sync.service"
log_info "Logs: ~/.config/gitea-github-sync/sync.log"
else
log_info "Automatic sync not enabled. Run it yourself whenever you want:"
log_info " cd $DIR && bash gitea-github-sync.sh $FLAG"
[[ -z "$FLAG" ]] && log_info " (no flag needed for both directions)"
fi
# ── Run it now, off the timer — lets you confirm tokens/config are
# actually correct right here instead of waiting for the first
# scheduled run (or a manual invocation later) to find out.
echo ""
echo " Run a sync now?"
echo " 1) Dry-run preview only (--list) — shows what would sync, no changes"
echo " 2) Run for real now ($DIR_DESC)"
echo " 3) Skip — don't run anything now"
local _RUN_DEFAULT="1"
[ "$UNATTENDED" = true ] && _RUN_DEFAULT="3"
local _RUN_NOW=""
prompt_text " Choice [$_RUN_DEFAULT]:" "$_RUN_DEFAULT" _RUN_NOW
case "$_RUN_NOW" in
2)
log_info "Running sync now ($DIR_DESC)..."
sudo -u "$ACTUAL_USER" env HOME="$ACTUAL_HOME" SYNC_ENV="$DIR/.env" \
bash "$DIR/gitea-github-sync.sh" $FLAG \
&& log_success "Sync run complete." \
|| log_warning "Sync run failed — check the output above, or ~/.config/gitea-github-sync/sync.log"
;;
3) log_info "Skipped — run it later with the commands above." ;;
*)
log_info "Dry-run preview (--list)..."
sudo -u "$ACTUAL_USER" env HOME="$ACTUAL_HOME" SYNC_ENV="$DIR/.env" \
bash "$DIR/gitea-github-sync.sh" --list
;;
esac
}
install_gitea() {
log_info "Setting up self-hosted Gitea..."
local DIR="$DOCKER_DIR/gitea"
local SYNC_SRC
SYNC_SRC="$(_gitea_sync_vendor_src)"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $DIR with docker-compose.yml (gitea/gitea:latest)"
echo "[DRY-RUN] Would scan for free host ports (web + SSH) to avoid collisions"
echo "[DRY-RUN] Would prompt for a Gitea admin username/password, then create that account"
echo "[DRY-RUN] and an API token once the container is ready (no manual web wizard)"
echo "[DRY-RUN] Would prompt for a GitHub token and copy in gitea-github-sync.sh"
echo "[DRY-RUN] Would ask sync direction (GitHub->Gitea / Gitea->GitHub / both) and whether"
echo "[DRY-RUN] to install a systemd timer for automatic sync, or print manual instructions"
echo "[DRY-RUN] Would offer to run a sync now (dry-run preview or for real), off-schedule"
echo "[DRY-RUN] Would offer \"Sign in with Authelia\" (OIDC) if Authelia is installed"
echo "[DRY-RUN] Would offer zero-click Authelia login (reverse-proxy auth) if Authelia"
echo "[DRY-RUN] and local Caddy are both installed — rewires Gitea onto caddy_net"
echo "[DRY-RUN] Would offer to enable Gitea Actions (CI) with a local act_runner container"
echo "[DRY-RUN] Would write $DIR/README.md"
return 0
fi
if [[ ! -f "$SYNC_SRC" ]]; then
log_error "Vendored gitea-github-sync.sh not found at $SYNC_SRC"
return 1
fi
require_docker || return 1
# ── Existing install? ───────────────────────────────────────────────────
if [[ -f "$DIR/docker-compose.yml" && -f "$DIR/.env" ]]; then
echo ""
log_info "Existing Gitea install found at $DIR."
local MODE=""
prompt_reinstall_mode MODE
case "$MODE" in
update)
cp -f "$SYNC_SRC" "$DIR/gitea-github-sync.sh"
chmod +x "$DIR/gitea-github-sync.sh"
_gitea_fix_ownership "$DIR"
(cd "$DIR" && docker compose up -d) \
&& log_success "Gitea refreshed and restarted." \
|| log_warning "Restart failed — check: docker compose -f $DIR/docker-compose.yml logs"
_gitea_run_sync_direction_step "$DIR"
_gitea_offer_authelia_sso "$DIR"
_gitea_offer_reverse_proxy_auth "$DIR"
_gitea_offer_actions_runner "$DIR"
log_success "Existing .env (tokens) and web/SSH ports were left untouched."
return 0
;;
cancel)
log_info "Leaving the existing install as-is."
return 0
;;
fresh) log_info "Reconfiguring from scratch — every prompt below runs again." ;;
esac
fi
mkdir -p "$DIR"
_gitea_fix_ownership "$DIR"
cd "$DIR" || return 1
# ── Port scan — web (default 3001->3000) and SSH (default 2222->22) ────
local WEB_PORT=3001 SSH_PORT=2222
find_free_port WEB_PORT "$WEB_PORT"
find_free_port SSH_PORT "$SSH_PORT"
[[ "$WEB_PORT" != 3001 ]] && log_info "Port 3001 was taken — Gitea's web UI will use ${WEB_PORT}."
[[ "$SSH_PORT" != 2222 ]] && log_info "Port 2222 was taken — Gitea's SSH clone port will use ${SSH_PORT}."
backup_if_exists docker-compose.yml
cat > docker-compose.yml << EOF
name: gitea
services:
gitea:
image: gitea/gitea:latest
container_name: gitea
restart: unless-stopped
ports:
- "${WEB_PORT}:3000"
- "${SSH_PORT}:22"
volumes:
- ./data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
environment:
- USER_UID=1000
- USER_GID=1000
- GITEA__database__DB_TYPE=sqlite3
- GITEA__database__PATH=/data/gitea/gitea.db
- GITEA__security__INSTALL_LOCK=true
EOF
_gitea_fix_ownership "$DIR"
docker compose up -d \
&& log_success "Gitea container started." \
|| { log_error "docker compose up failed — check: docker compose -f $DIR/docker-compose.yml logs"; return 1; }
# ── Admin credentials — asked up front so a slow first boot doesn't need
# a second manual pass; these get used the moment Gitea's CLI is ready.
echo ""
local GITEA_ADMIN_USER=""
prompt_text " Gitea admin username [$ACTUAL_USER]:" "$ACTUAL_USER" GITEA_ADMIN_USER
local _GEN_PASS GITEA_ADMIN_PASS=""
_GEN_PASS="$(generate_password 24)"
prompt_text " Gitea admin password [$_GEN_PASS]:" "$_GEN_PASS" GITEA_ADMIN_PASS
# ── Wait for Gitea to actually be ready, then create the account. One
# retry loop instead of a separate readiness probe: first boot (SQLite
# init) can take well over a minute on slower disks, and folding account
# creation into the same loop means a slow-but-eventually-successful boot
# doesn't dead-end the install the way a fixed 60s probe used to.
log_info "Waiting for Gitea to finish starting (first boot can take a minute or two)..."
local _tries=0 _created=false _exists=false
while [[ $_tries -lt 60 ]]; do
if docker exec -u git gitea gitea admin user create --admin \
--username "$GITEA_ADMIN_USER" --password "$GITEA_ADMIN_PASS" \
--email "${GITEA_ADMIN_USER}@localhost" --must-change-password=false \
&>/dev/null; then
_created=true
break
fi
# Gitea is up but this username already exists (e.g. retry after an
# earlier partial run) — treat as success and sync the password to
# what was just entered rather than failing the whole install.
if docker exec -u git gitea gitea admin user list 2>/dev/null | awk '{print $2}' | grep -qx "$GITEA_ADMIN_USER"; then
_exists=true
# --must-change-password=false matters here: change-password
# defaults to setting that flag TRUE, which then makes Gitea
# reject every API call (including this script's own token-based
# calls) with 403 "You must change your password" until someone
# logs into the web UI and clears it by hand. Confirmed live —
# this silently broke the sync script on every retry against an
# already-existing account.
docker exec -u git gitea gitea admin user change-password \
--username "$GITEA_ADMIN_USER" --password "$GITEA_ADMIN_PASS" \
--must-change-password=false &>/dev/null
_created=true
break
fi
sleep 2
_tries=$((_tries + 1))
done
if [[ "$_created" != true ]]; then
log_error "Gitea didn't come up in time — check: docker compose -f $DIR/docker-compose.yml logs"
log_error "Once it's healthy, just re-run 'sudo ./setup.sh gitea' to pick up from here."
return 1
fi
if [[ "$_exists" == true ]]; then
log_success "Admin account already existed: $GITEA_ADMIN_USER (password updated to what you just entered)"
else
log_success "Admin account created: $GITEA_ADMIN_USER"
fi
# Token name includes a timestamp so a retry against an account that
# already has a "sync" token from an earlier partial run (see the
# already-exists branch above) never collides — Gitea rejects a second
# token with a name that's already taken for that user, which used to
# silently fall through to the manual-paste prompt below on every retry.
local GITEA_TOKEN=""
GITEA_TOKEN="$(docker exec -u git gitea gitea admin user generate-access-token \
--username "$GITEA_ADMIN_USER" --token-name "sync-$(date +%s)" \
--scopes write:repository,write:user --raw 2>/dev/null)"
if [[ -z "$GITEA_TOKEN" ]]; then
log_warning "Automatic token generation didn't work (older Gitea image?) — generate one"
log_warning "by hand: log into http://localhost:${WEB_PORT} as $GITEA_ADMIN_USER, then"
log_warning "Settings -> Applications -> Generate New Token (repo + user write access)."
_gitea_prompt_token " Paste the GITEA token here (not the GitHub one — that's next):" GITEA_TOKEN
fi
# ── GitHub token ─────────────────────────────────────────────────────────
echo ""
log_info "Needs a GitHub Personal Access Token (not an SSH key — this talks to GitHub's"
log_info "REST API too, which SSH can't do). Generate one at https://github.com/settings/tokens"
log_info "with 'repo' scope if you don't already have one handy."
local GITHUB_TOKEN=""
_gitea_prompt_token " GitHub token:" GITHUB_TOKEN
if [[ -z "$GITHUB_TOKEN" ]]; then
log_warning "No GitHub token entered — Gitea itself is still up, but the sync script won't"
log_warning "work until you add one to $DIR/.env and re-run this installer (update mode)."
fi
cp -f "$SYNC_SRC" "$DIR/gitea-github-sync.sh"
chmod +x "$DIR/gitea-github-sync.sh"
backup_if_exists "$DIR/.env"
cat > "$DIR/.env" << ENV
# Written by services/gitea.sh — re-run that (update mode) to change any of this.
GITEA_URL='http://localhost:${WEB_PORT}'
GITEA_TOKEN='${GITEA_TOKEN}'
GITHUB_TOKEN='${GITHUB_TOKEN}'
ENV
chmod 600 "$DIR/.env"
chown "$ACTUAL_USER:$ACTUAL_USER" "$DIR/.env" "$DIR/gitea-github-sync.sh"
# ── Discover GitHub/Gitea usernames + scope prefs (the sync script's own
# first-time setup) — runs as the real user, not root, so its config
# lands under the real user's home, not /root.
if [[ -n "$GITHUB_TOKEN" && -n "$GITEA_TOKEN" ]]; then
echo ""
if [ "$UNATTENDED" = true ]; then
log_info "Unattended mode — skipping the interactive sync setup. Run it yourself later:"
log_info " cd $DIR && sudo -u $ACTUAL_USER bash gitea-github-sync.sh --init"
else
sudo -u "$ACTUAL_USER" env HOME="$ACTUAL_HOME" SYNC_ENV="$DIR/.env" \
bash "$DIR/gitea-github-sync.sh" --init
fi
fi
_gitea_run_sync_direction_step "$DIR"
# ── Caddy — no forward_auth gate here. Gitea has its own built-in login,
# unlike the no-auth-at-all apps elsewhere in this repo that need Caddy
# to gate them via Authelia. Optional "Sign in with Authelia" (OIDC) is
# offered separately below, as an addition to Gitea's own login, not a
# replacement requiring Caddy involvement. ─────────────────────────────
configure_caddy_for_service "Gitea" "host.docker.internal:${WEB_PORT}" "git"
_gitea_offer_authelia_sso "$DIR"
_gitea_offer_reverse_proxy_auth "$DIR"
_gitea_offer_actions_runner "$DIR"
write_readme "$DIR" << MD
# Gitea
Self-hosted Git server. Raw, real working-copy clones are just normal
\`git clone\` commands against it (or against GitHub directly) — Gitea's own
storage is separate from that, used for the web UI and the mirror sync
below.
- Web UI: http://localhost:${WEB_PORT}
- Admin login: \`${GITEA_ADMIN_USER}\` / see \`.env\` if you need the generated
password again (\`docker exec -u git gitea gitea admin user change-password\`
to rotate it)
- SSH clone port: ${SSH_PORT} (e.g. \`git clone ssh://git@localhost:${SSH_PORT}/user/repo.git\`)
## GitHub mirror sync
\`gitea-github-sync.sh\` (in this directory) mirrors repos between this Gitea
and GitHub. Tokens live in \`.env\` (chmod 600) — treat them like passwords.
\`\`\`bash
cd $DIR
bash gitea-github-sync.sh --list # preview what would sync, no changes
bash gitea-github-sync.sh --pull-only # GitHub -> Gitea only
bash gitea-github-sync.sh --push-only # Gitea -> GitHub only
bash gitea-github-sync.sh # both directions
\`\`\`
Config (which repos, private/forks handling) lives at
\`~/.config/gitea-github-sync/config\` — edit directly, or re-run
\`bash gitea-github-sync.sh --init\` to redo it interactively.
## Sign in with Authelia (optional)
If Authelia is installed, re-run \`sudo ./setup.sh gitea\` (Update mode is
fine — this doesn't touch tokens or anything else) and answer yes to
"Add \"Sign in with Authelia\"?" to add it as an extra OAuth2 login option
on Gitea's own login page. Local admin login keeps working exactly as
before — this is additive, not a replacement. Managed in Gitea under
Site Administration -> Authentication Sources (source name: \`authelia\`).
## Zero-click Authelia login (optional, stronger)
A second, separate Authelia integration: instead of an extra button on
Gitea's login page, Gitea auto-logs in as whoever Authelia says you are —
no click, and no separate Gitea session that can expire on its own and
force a re-login later. Re-run \`sudo ./setup.sh gitea\` (Update mode) and
answer yes to the "Skip Gitea's own login entirely..." prompt. Requires
Authelia and Caddy on this same machine — it moves Gitea onto Caddy's
internal Docker network (\`caddy_net\`) and Gitea only trusts the identity
header from that network's address range, not from the internet or from
its own host-published port. Gitea's own login page keeps working for
anyone who reaches it any other way (e.g. directly on its port). New
users arriving this way get an ordinary (non-admin) Gitea account created
automatically the first time they show up.
## Gitea Actions (CI) — optional local runner
Re-run \`sudo ./setup.sh gitea\` (Update mode is fine) and answer yes to
"Enable Gitea Actions?" to run \`.gitea/workflows/*.yml\` here the same way
GitHub Actions runs \`.github/workflows/*.yml\` — mainly useful as a fallback
so builds still work if GitHub is ever unreachable. Adds an \`act_runner\`
container (\`docker compose ps\` will show \`gitea-runner\`) that polls this
Gitea instance for jobs and launches a fresh container per job using this
host's own Docker socket — same pattern already used by this repo's
portainer/watchtower/uptimekuma services, not something new. Manage runners
under Site Administration -> Actions -> Runners.
## Manage
\`\`\`bash
docker compose up -d
docker compose down
docker compose logs -f
docker compose pull && docker compose up -d
sudo ./setup.sh gitea # re-run to change sync direction/schedule, or refresh
\`\`\`
MD
echo ""
log_success "Gitea installed at $DIR"
echo " Web UI: http://localhost:${WEB_PORT} (login: ${GITEA_ADMIN_USER})"
echo " Details, sync commands: $DIR/README.md"
echo ""
}
# Run immediately when executed directly (deferred until after function definition)
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_gitea