Adds Option B (a personal GitHub App installed with "All repositories" access) alongside the existing per-repo webhook instructions, and clarifies that Homepage URL and Webhook URL are separate fields on the App creation form -- a real point of confusion when setting one up. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016oxpDzv7qfV7RDvKHp1sPD
1170 lines
53 KiB
Bash
1170 lines
53 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).
|
|
#
|
|
# Sets _GITEA_SYNC_FLAG as an out-param (not `local` — read it after the
|
|
# call returns, same convention as CADDY_SERVICE_CONFIGURED) so the caller
|
|
# can decide whether the real-time webhook offer even makes sense for the
|
|
# direction just chosen.
|
|
_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 DIR_DESC=""
|
|
case "$_DIR_CHOICE" in
|
|
2) _GITEA_SYNC_FLAG="--push-only"; DIR_DESC="Gitea -> GitHub only" ;;
|
|
3) _GITEA_SYNC_FLAG=""; DIR_DESC="both directions" ;;
|
|
*) _GITEA_SYNC_FLAG="--pull-only"; DIR_DESC="GitHub -> Gitea only" ;;
|
|
esac
|
|
local FLAG="$_GITEA_SYNC_FLAG"
|
|
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
|
|
}
|
|
|
|
|
|
# ── Real-time sync: a GitHub webhook receiver, not just the timer above ────
|
|
# The timer above polls on a fixed schedule (default 6h) — fine for a slow
|
|
# backup cadence, but a genuine "GitHub -> Gitea in real time" ask needs
|
|
# GitHub to tell Gitea the moment something changes instead of Gitea finding
|
|
# out up to one interval late. GitHub's own webhook (repo Settings ->
|
|
# Webhooks) is the standard way to do that: it POSTs a JSON payload the
|
|
# instant someone pushes. This writes a tiny stdlib-only Python HTTP server
|
|
# to receive it — python3 is already a hard dependency of this directory's
|
|
# gitea-github-sync.sh itself (used there for JSON parsing), so this adds
|
|
# no new dependency — running under its own persistent systemd service,
|
|
# and wires it up to Caddy the same way every other web-facing piece of
|
|
# this install does.
|
|
#
|
|
# Deliberately NOT a Docker container: it just shells out to the existing
|
|
# gitea-github-sync.sh sitting right next to it in $DIR, the same way the
|
|
# timer's own systemd service does — no image to build/pull for what's
|
|
# fundamentally a few lines of stdlib HTTP handling.
|
|
_gitea_write_webhook_receiver() {
|
|
local DIR="$1"
|
|
cat > "$DIR/gitea-github-webhook.py" << 'PYEOF'
|
|
#!/usr/bin/env python3
|
|
"""Gitea <-> GitHub webhook receiver — triggers an immediate, single-repo
|
|
mirror sync (gitea-github-sync.sh --repo owner/name --pull-only) the moment
|
|
GitHub POSTs a push event, instead of waiting for the scheduled timer.
|
|
|
|
Written by services/gitea.sh — re-run 'sudo ./setup.sh gitea' (Update mode
|
|
is fine) to regenerate this file rather than hand-editing it; a hand edit
|
|
survives until the next Update-mode rerun overwrites it again.
|
|
|
|
WEBHOOK_SECRET is read from .env in this same directory at every request,
|
|
never taken from the environment/systemd unit — /etc/systemd/system/*.service
|
|
files are world-readable, and .env (chmod 600) is already where every other
|
|
token in this directory lives.
|
|
"""
|
|
import hashlib
|
|
import hmac
|
|
import http.server
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
|
|
SYNC_DIR = os.environ.get("GITEA_SYNC_DIR", os.path.dirname(os.path.abspath(__file__)))
|
|
ENV_PATH = os.path.join(SYNC_DIR, ".env")
|
|
PORT = int(os.environ.get("WEBHOOK_PORT", "3020"))
|
|
|
|
|
|
def _load_env_value(key):
|
|
try:
|
|
with open(ENV_PATH, "r") as f:
|
|
for line in f:
|
|
line = line.split("#", 1)[0].strip()
|
|
if not line.startswith(key + "="):
|
|
continue
|
|
return line[len(key) + 1:].strip().strip("'").strip('"')
|
|
except OSError:
|
|
pass
|
|
return ""
|
|
|
|
|
|
class Handler(http.server.BaseHTTPRequestHandler):
|
|
def log_message(self, fmt, *args):
|
|
sys.stderr.write("%s - %s\n" % (self.address_string(), fmt % args))
|
|
|
|
def _reply(self, code, body=b""):
|
|
self.send_response(code)
|
|
self.end_headers()
|
|
if body:
|
|
self.wfile.write(body)
|
|
|
|
def do_GET(self):
|
|
self._reply(200, b"gitea-github-webhook: listening\n")
|
|
|
|
def do_POST(self):
|
|
secret = _load_env_value("WEBHOOK_SECRET").encode()
|
|
if not secret:
|
|
self._reply(503, b"WEBHOOK_SECRET not configured")
|
|
return
|
|
|
|
length = int(self.headers.get("Content-Length", 0) or 0)
|
|
body = self.rfile.read(length) if length else b""
|
|
|
|
sig = self.headers.get("X-Hub-Signature-256", "")
|
|
expected = "sha256=" + hmac.new(secret, body, hashlib.sha256).hexdigest()
|
|
if not sig or not hmac.compare_digest(sig, expected):
|
|
self._reply(401, b"bad signature")
|
|
return
|
|
|
|
event = self.headers.get("X-GitHub-Event", "")
|
|
if event == "ping":
|
|
self._reply(200, b"pong")
|
|
return
|
|
if event != "push":
|
|
self._reply(204)
|
|
return
|
|
|
|
try:
|
|
payload = json.loads(body or b"{}")
|
|
full_name = payload["repository"]["full_name"]
|
|
except (json.JSONDecodeError, KeyError, TypeError):
|
|
self._reply(400, b"couldn't find repository.full_name in payload")
|
|
return
|
|
|
|
self._reply(202, b"sync queued\n")
|
|
sync_script = os.path.join(SYNC_DIR, "gitea-github-sync.sh")
|
|
sync_env = dict(os.environ, SYNC_ENV=ENV_PATH)
|
|
subprocess.Popen(
|
|
["bash", sync_script, "--repo", full_name, "--pull-only"],
|
|
cwd=SYNC_DIR,
|
|
env=sync_env,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
server = http.server.ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
|
|
server.serve_forever()
|
|
PYEOF
|
|
chmod +x "$DIR/gitea-github-webhook.py"
|
|
chown "$ACTUAL_USER:$ACTUAL_USER" "$DIR/gitea-github-webhook.py"
|
|
}
|
|
|
|
_gitea_write_webhook_service() {
|
|
local DIR="$1" RUN_USER="$2" RUN_HOME="$3" PORT="$4"
|
|
local _service="/etc/systemd/system/gitea-github-webhook.service"
|
|
|
|
cat > "$_service" << UNIT
|
|
[Unit]
|
|
Description=Gitea-GitHub Webhook Receiver (real-time mirror sync trigger)
|
|
After=network-online.target docker.service
|
|
Wants=network-online.target
|
|
|
|
[Service]
|
|
Type=simple
|
|
User=${RUN_USER}
|
|
Environment=HOME=${RUN_HOME}
|
|
Environment=GITEA_SYNC_DIR=${DIR}
|
|
Environment=WEBHOOK_PORT=${PORT}
|
|
ExecStart=/usr/bin/python3 ${DIR}/gitea-github-webhook.py
|
|
Restart=on-failure
|
|
RestartSec=5
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
UNIT
|
|
|
|
systemctl daemon-reload
|
|
systemctl enable --now gitea-github-webhook.service
|
|
}
|
|
|
|
_gitea_remove_webhook_service() {
|
|
systemctl disable --now gitea-github-webhook.service 2>/dev/null || true
|
|
rm -f /etc/systemd/system/gitea-github-webhook.service
|
|
systemctl daemon-reload 2>/dev/null || true
|
|
}
|
|
|
|
# Offers the webhook receiver above as an addition to (not a replacement
|
|
# for) the timer set up in _gitea_run_sync_direction_step — the timer keeps
|
|
# covering the Gitea -> GitHub direction (and acts as a safety net for any
|
|
# push GitHub's webhook delivery ever misses), the webhook just gets the
|
|
# GitHub -> Gitea direction down from "up to one interval late" to seconds.
|
|
# Always asked on every install/reconfigure, same "live-editable extra"
|
|
# pattern as the direction+autosync step itself — see that function's own
|
|
# comment. Skipped (and any existing webhook torn down) outright when the
|
|
# chosen direction is push-only, since GitHub has nothing to notify about
|
|
# in that direction.
|
|
_gitea_offer_realtime_webhook() {
|
|
local DIR="$1" SYNC_FLAG="$2"
|
|
|
|
if [[ "$SYNC_FLAG" == "--push-only" ]]; then
|
|
_gitea_remove_webhook_service
|
|
return 0
|
|
fi
|
|
|
|
echo ""
|
|
local USE_WEBHOOK=""
|
|
prompt_yn " Also add a GitHub webhook for near-instant sync (push on GitHub -> synced here in seconds, instead of waiting for the timer above)? (y/n):" "n" USE_WEBHOOK
|
|
if [[ ! "$USE_WEBHOOK" =~ ^[Yy]$ ]]; then
|
|
_gitea_remove_webhook_service
|
|
return 0
|
|
fi
|
|
|
|
# Reuse an existing secret/port across reruns — rotating either one
|
|
# silently breaks a webhook GitHub already has configured against the
|
|
# old value, the same reasoning services/asterisk.sh's TURN port-range
|
|
# persistence follows for a live coturn install.
|
|
local WEBHOOK_SECRET WEBHOOK_PORT
|
|
WEBHOOK_SECRET="$(grep '^WEBHOOK_SECRET=' "$DIR/.env" 2>/dev/null | cut -d= -f2- | tr -d "'\"")"
|
|
WEBHOOK_PORT="$(grep '^WEBHOOK_PORT=' "$DIR/.env" 2>/dev/null | cut -d= -f2- | tr -d "'\"")"
|
|
[[ -z "$WEBHOOK_SECRET" ]] && WEBHOOK_SECRET="$(generate_password 40)"
|
|
if [[ -z "$WEBHOOK_PORT" ]]; then
|
|
WEBHOOK_PORT=3020
|
|
find_free_port WEBHOOK_PORT "$WEBHOOK_PORT"
|
|
fi
|
|
|
|
if grep -q '^WEBHOOK_SECRET=' "$DIR/.env" 2>/dev/null; then
|
|
sed -i "s|^WEBHOOK_SECRET=.*|WEBHOOK_SECRET='${WEBHOOK_SECRET}'|" "$DIR/.env"
|
|
else
|
|
echo "WEBHOOK_SECRET='${WEBHOOK_SECRET}'" >> "$DIR/.env"
|
|
fi
|
|
if grep -q '^WEBHOOK_PORT=' "$DIR/.env" 2>/dev/null; then
|
|
sed -i "s|^WEBHOOK_PORT=.*|WEBHOOK_PORT='${WEBHOOK_PORT}'|" "$DIR/.env"
|
|
else
|
|
echo "WEBHOOK_PORT='${WEBHOOK_PORT}'" >> "$DIR/.env"
|
|
fi
|
|
chmod 600 "$DIR/.env"
|
|
chown "$ACTUAL_USER:$ACTUAL_USER" "$DIR/.env"
|
|
|
|
_gitea_write_webhook_receiver "$DIR"
|
|
_gitea_write_webhook_service "$DIR" "$ACTUAL_USER" "$ACTUAL_HOME" "$WEBHOOK_PORT"
|
|
log_success "Webhook receiver running on port ${WEBHOOK_PORT} (systemctl status gitea-github-webhook)."
|
|
|
|
# Bare port -> host.docker.internal:PORT, same convention as every other
|
|
# host-process (non-container) upstream in this repo — see the
|
|
# configure_caddy_for_service usage note in CLAUDE.md.
|
|
configure_caddy_for_service "Gitea GitHub Webhook" "$WEBHOOK_PORT" "gitea-webhook"
|
|
if [[ "$CADDY_SERVICE_CONFIGURED" == true ]]; then
|
|
if command -v ufw &>/dev/null; then
|
|
if [[ "$CADDY_SERVICE_MODE" == "local" ]]; then
|
|
ufw delete allow "${WEBHOOK_PORT}/tcp" 2>/dev/null || true
|
|
ufw_allow_from_caddy_net "${WEBHOOK_PORT}"
|
|
else
|
|
ufw allow "${WEBHOOK_PORT}/tcp" comment "Gitea GitHub webhook" >/dev/null 2>&1 || true
|
|
ensure_ufw_enabled
|
|
fi
|
|
fi
|
|
echo ""
|
|
log_success "Now add the webhook on GitHub, for every repo you want instant sync from:"
|
|
log_info " Repo -> Settings -> Webhooks -> Add webhook"
|
|
log_info " Payload URL: https://${CADDY_SERVICE_DOMAIN}/"
|
|
log_info " Content type: application/json"
|
|
log_info " Secret: ${WEBHOOK_SECRET}"
|
|
log_info " Events: Just the push event"
|
|
log_info "The timer above still covers every other repo, and this one too, on its"
|
|
log_info "own schedule — the webhook is an addition, not a replacement for it."
|
|
else
|
|
log_warning "Webhook receiver is running (0.0.0.0:${WEBHOOK_PORT}) but nothing is exposing"
|
|
log_warning "it to the internet, so GitHub can't reach it yet — re-run this installer and"
|
|
log_warning "configure Caddy for it, or point your own reverse proxy at"
|
|
log_warning "127.0.0.1:${WEBHOOK_PORT} (or the container-reachable host IP) by hand."
|
|
log_info " Secret (for whenever you do expose it): ${WEBHOOK_SECRET}"
|
|
fi
|
|
}
|
|
|
|
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 open the SSH clone port in UFW (web port too, or scoped to caddy_net"
|
|
echo "[DRY-RUN] if Caddy ends up fronting it locally)"
|
|
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 a GitHub webhook receiver for near-instant GitHub->Gitea sync"
|
|
echo "[DRY-RUN] (systemd service + Caddy front door), unless direction is push-only"
|
|
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_realtime_webhook "$DIR" "$_GITEA_SYNC_FLAG"
|
|
_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"
|
|
_gitea_offer_realtime_webhook "$DIR" "$_GITEA_SYNC_FLAG"
|
|
|
|
# ── 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"
|
|
|
|
# ── Firewall ─────────────────────────────────────────────────────────────
|
|
# SSH clone (SSH_PORT->22) is a different protocol than the web UI — Caddy
|
|
# can't front it no matter what CADDY_SERVICE_MODE came back as, so it
|
|
# always needs its own direct rule or `git clone ssh://...` hangs forever
|
|
# (a dropped SYN with UFW active, not a fast connection-refused).
|
|
if command -v ufw &>/dev/null; then
|
|
if [[ "$CADDY_SERVICE_CONFIGURED" == true && "$CADDY_SERVICE_MODE" == "local" ]]; then
|
|
ufw delete allow "${WEB_PORT}/tcp" 2>/dev/null || true
|
|
ufw_allow_from_caddy_net "${WEB_PORT}"
|
|
else
|
|
ufw allow "${WEB_PORT}/tcp" comment "Gitea web UI" >/dev/null 2>&1 || true
|
|
fi
|
|
ufw allow "${SSH_PORT}/tcp" comment "Gitea SSH clone" >/dev/null 2>&1 || true
|
|
ensure_ufw_enabled
|
|
log_success "UFW: opened SSH clone port ${SSH_PORT}/tcp"
|
|
else
|
|
log_warning "ufw not installed — if you use a firewall, open TCP ${SSH_PORT} for SSH clones."
|
|
fi
|
|
|
|
_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.
|
|
|
|
## Real-time sync via GitHub webhook (optional)
|
|
|
|
The setup above only covers the GitHub -> Gitea direction; it doesn't apply
|
|
if you chose Gitea -> GitHub only (GitHub has nothing to notify about in
|
|
that direction). Adds a small Python HTTP server
|
|
(\`gitea-github-webhook.py\`, in this directory) run as its own systemd
|
|
service (\`gitea-github-webhook.service\`) that GitHub POSTs to the instant
|
|
someone pushes — it verifies the request's HMAC signature against
|
|
\`WEBHOOK_SECRET\` in \`.env\`, then runs \`gitea-github-sync.sh --repo
|
|
owner/name --pull-only\` for just that one repo. The scheduled timer above
|
|
still runs on its own interval regardless — the webhook is an addition
|
|
that gets the GitHub -> Gitea direction down to seconds, not a replacement
|
|
for it (and still catches anything a missed webhook delivery would have
|
|
picked up next interval anyway).
|
|
|
|
Not set up yet, or want to change the port/secret? Re-run
|
|
\`sudo ./setup.sh gitea\` (Update mode is fine) and answer yes to "Also add
|
|
a GitHub webhook...". That only stands up the *receiver* on this box — you
|
|
still add the actual webhook on GitHub's side afterward, using the payload
|
|
URL and secret the installer printed (also readable back from \`.env\` as
|
|
\`WEBHOOK_PORT\` / \`WEBHOOK_SECRET\` if you need them again).
|
|
|
|
**Option A — one repo at a time.** Fastest, but only covers repos you do
|
|
this for individually:
|
|
repo -> Settings -> Webhooks -> Add webhook
|
|
- Payload URL: the URL the installer printed
|
|
- Content type: \`application/json\`
|
|
- Secret: your \`WEBHOOK_SECRET\`
|
|
- Events: "Just the push event"
|
|
|
|
**Option B — every repo on your account, current AND future, from one
|
|
setup.** A plain repo webhook (Option A) is always per-repo, no way around
|
|
that — but a personal GitHub App installed with "All repositories" access
|
|
covers every repo automatically, including ones you create afterward. No
|
|
receiver/code change needed for this: an App's webhook uses the exact same
|
|
HMAC-secret mechanism as a repo webhook, so the same \`WEBHOOK_SECRET\`
|
|
works for both.
|
|
|
|
1. GitHub -> Settings -> Developer settings -> GitHub Apps -> New GitHub App
|
|
2. Webhook URL: same payload URL as Option A. Webhook secret: your
|
|
\`WEBHOOK_SECRET\`. (Homepage URL is a separate, purely cosmetic field —
|
|
point it at anything, e.g. your GitHub profile; GitHub never sends
|
|
anything there, unlike Webhook URL.)
|
|
3. Permissions -> Repository permissions -> Contents: Read-only (required
|
|
to unlock the Push event checkbox)
|
|
4. Subscribe to events: Push only
|
|
5. Where can this GitHub App be installed: "Only on this account"
|
|
6. Create it, then Install App -> choose "All repositories" -> Install
|
|
|
|
If you'd already added Option A webhooks on a few repos, they're now
|
|
redundant (not harmful, just two triggers per push) — remove them once
|
|
the App is confirmed working.
|
|
|
|
**Verify either option** — push to a repo, then watch it arrive:
|
|
|
|
\`\`\`bash
|
|
systemctl status gitea-github-webhook # is it running?
|
|
journalctl -u gitea-github-webhook -f # watch it receive + trigger syncs
|
|
\`\`\`
|
|
|
|
GitHub also shows delivery attempts and response codes: repo (or App) ->
|
|
Settings -> Webhooks -> the webhook -> Recent Deliveries.
|
|
|
|
## 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
|