diff --git a/README.md b/README.md index c712543..5f264a3 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ These scripts automate the deployment of personal infrastructure components. The | [`proxy/install.sh`](proxy/) | Reverse proxy with Tailscale + Nginx Proxy Manager | `curl -fsSL https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/main/proxy/install.sh` \| `bash` | | [`netlab/install.sh`](netlab/) | Network lab with ContainerLab | `curl -fsSL https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/main/netlab/install.sh` \| `bash` | | [`gitea-runner/install.sh`](gitea-runner/) | Gitea Act Runner on Alpine LXC (Proxmox) | `bash -c "$(curl -fsSL https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/main/gitea-runner/install.sh)"` | +| [`gitea/install.sh`](gitea/) | Gitea Git service on Alpine LXC (Proxmox) | `bash -c "$(curl -fsSL https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/main/gitea/install.sh)"` | | [`openbao/install.sh`](openbao/) | OpenBao secrets manager on Alpine LXC (Proxmox) | `bash -c "$(curl -fsSL https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/main/openbao/install.sh)"` | | [`komodo/install.sh`](komodo/) | Komodo (Docker + MongoDB) on Alpine VM | `bash -c "$(curl -fsSL https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/main/komodo/install.sh)"` | diff --git a/gitea/README.md b/gitea/README.md new file mode 100644 index 0000000..db59443 --- /dev/null +++ b/gitea/README.md @@ -0,0 +1,181 @@ +# Gitea + +Automated installation and update script for a [Gitea](https://about.gitea.com) instance +running inside an Alpine LXC on Proxmox. Replaces a previous deployment via +community-scripts. Migrating the existing instance's data is out of scope — +this script produces a fresh instance. + +### Features + +Single script, automatic mode selection: + +| Context | Action | +| --------------------------------------------------- | -------------------------------------------------------------------------------- | +| From Proxmox host, no existing Gitea container | Detects newest Alpine template, creates LXC, installs Gitea via `apk` | +| From Proxmox host, Gitea container already present | Reuses the existing LXC, refreshes packages, reapplies `app.ini` | +| From inside an LXC, no `gitea` binary | Installs Gitea from scratch | +| From inside an LXC, `gitea` already present | Refreshes packages and reapplies `app.ini` (no data changes) | + +The container is identified by hostname **and** the `gitea` tag, so it is +re-found across reruns even if the CTID was auto-allocated the first time. + +### Why `apk`, not a downloaded binary + +Unlike `openbao/install.sh` and `gitea-runner/install.sh`, which fetch a +GitHub release, Gitea is installed from the Alpine package repositories: + +```bash +apk add --no-cache gitea gitea-openrc +``` + +The official binaries on `dl.gitea.com` are glibc/CGO-linked (SQLite via +CGO), a bad fit for musl. Alpine packages a native musl build of Gitea in +`community`, with a `gitea-openrc` sub-package providing the service. This +script re-verifies on every install that `gitea` is available via `community` +(not `edge`) on the Alpine release in use, and fails loudly instead of +silently pinning `edge/community` if it isn't. + +Consequences for the usual pattern: + +| | openbao / gitea-runner | gitea | +| ------- | ------------------------------ | ---------------------------------------- | +| Install | curl release from GitHub | `apk add gitea gitea-openrc` | +| Service | OpenRC unit written by the script | shipped by the package, used as-is | +| Update | swap binary + backup | `refresh_os_packages` (`apk upgrade`) | +| Version | tracked in `/opt/*_version.txt` | read from `apk list -I` | + +### Configuration (`app.ini`) + +Managed exclusively via `ini_set` (see `lib/common.sh`, #18) — never a +heredoc overwrite. The script owns and merges only the keys listed below; +everything else in the Alpine package's default `app.ini` (repository root, +session provider, etc.) is left untouched. + +| Section | Key | Value | +| ----------- | --------------------------------- | ------------------------------------------------ | +| `[server]` | `PROTOCOL` | `http` | +| `[server]` | `HTTP_ADDR` / `HTTP_PORT` | `127.0.0.1` / `3000` (loopback; tailscale serve fronts it) | +| `[server]` | `DOMAIN` / `ROOT_URL` | `GITEA_DOMAIN` / `GITEA_ROOT_URL` — the **public** URL, not the tailnet one | +| `[server]` | `DISABLE_SSH` | `true` (HTTPS-only usage confirmed) | +| `[security]`| `INSTALL_LOCK` | `true`, written before the service's first start | +| `[security]`| `REVERSE_PROXY_LIMIT` | `GITEA_REVERSE_PROXY_LIMIT` (default `2`) — **validate this, see below** | +| `[security]`| `REVERSE_PROXY_TRUSTED_PROXIES` | `GITEA_TRUSTED_PROXIES` — never `*` (CVE-2026-20896) | +| `[service]` | `DISABLE_REGISTRATION`, `REQUIRE_CAPTCHA_FOR_LOGIN`, `ENABLE_CAPTCHA` | `true` | +| `[log]` | `MODE` / `LEVEL` / `ROOT_PATH` | `file` / `info` / `/var/log/gitea` | +| `[log]` | `COLORIZE` | `false` — ANSI codes would break the `` match in the fail2ban filter added by #21 | +| `[metrics]` | `ENABLED`, `TOKEN`, `ENABLED_ISSUE_BY_REPOSITORY`, `ENABLED_ISSUE_BY_LABEL` | `true` / generated once / `true` / `true` | +| `[actions]` | `ENABLED` | `true` | +| `[database]`| `DB_TYPE` / `PATH` | `sqlite3` / `/var/lib/gitea/data/gitea.db` | + +The metrics token is generated once (`openssl rand -hex 32`, or supply +`GITEA_METRICS_TOKEN`) and never touched again once present — a rerun that +regenerated it would silently break the Prometheus scrape config. + +The admin account is created once, non-interactively, after `INSTALL_LOCK` +is set and the service has started (Gitea's CLI needs a migrated DB). A +rerun skips creation if any admin account already exists, so it never resets +an operator-changed password. + +### Usage + +#### Full install (from Proxmox shell) + +```bash +bash -c "$(curl -fsSL https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/main/gitea/install.sh)" +``` + +Re-running the exact same command later refreshes Alpine packages and +reapplies `app.ini`, without touching the SQLite data or regenerating the +metrics token / admin account. + +#### Customisation + +Every parameter is exposed as an environment variable: + +| Variable | Default | Description | +| ------------------------------ | ----------------------------------- | --------------------------------------------------------------------------- | +| `CTID` | auto | Container ID (auto-allocated via `pvesh get /cluster/nextid`) | +| `GITEA_HOSTNAME` | `gitea` | LXC hostname (also used as the tailnet MagicDNS name) | +| `TEMPLATE` | auto-detected | Alpine template; auto-detected from `pveam available` | +| `STORAGE` | `local-lvm` | Proxmox storage for the LXC root disk | +| `TEMPLATE_STORAGE` | `local` | Storage where Alpine templates live | +| `CORES` | `2` | vCPU cores | +| `RAM` | `2048` | RAM in MiB | +| `DISK` | `16` | Root disk size in GB | +| `BRIDGE` | `vmbr0` | Network bridge | +| `LXC_TAG` | `gitea` | Stable tag used to re-discover the container | +| `GITEA_DOMAIN` | `gitea.arnodo.fr` | Public domain | +| `GITEA_ROOT_URL` | `https:///` | Public URL (used for clone URLs, webhooks, redirects) | +| `GITEA_HTTP_ADDR` / `_PORT` | `127.0.0.1` / `3000` | Local listener; loopback by default | +| `GITEA_REVERSE_PROXY_LIMIT` | `2` | Proxy hop count (Traefik + `tailscale serve`) — validate before trusting it | +| `GITEA_TRUSTED_PROXIES` | `127.0.0.0/8,::1/128,100.64.0.0/10` | Never set to `*` | +| `GITEA_METRICS_TOKEN` | generated | Prometheus bearer token; set once, then immutable | +| `GITEA_ADMIN_USER` | `admin` | Admin account username | +| `GITEA_ADMIN_EMAIL` | `admin@` | Admin account email | +| `GITEA_ADMIN_PASSWORD` | generated | Admin account password, shown once at install time if generated | +| `SYSLOG_TARGET` / `SYSLOG_PORT`| `proxy.taila5ad8.ts.net` / `5514` | Where `gitea.log` is forwarded (proxy's generic rsyslog receiver, #20) | +| `TS_AUTHKEY` | _(unset)_ | Pre-auth key; if unset, finish `tailscale up` manually inside the LXC | + +```bash +CTID=130 GITEA_DOMAIN=git.example.com CORES=4 RAM=4096 \ + bash -c "$(curl -fsSL https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/main/gitea/install.sh)" +``` + +#### Update (from inside the LXC) + +```bash +curl -fsSL https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/main/gitea/install.sh | bash +``` + +Auto-detected via the presence of the `gitea` binary. Runs +`refresh_os_packages` (which brings `gitea`/`gitea-openrc` to the latest +available build — no separate `apk upgrade gitea` needed), reapplies +`app.ini`, restarts the service, and refreshes the tailnet/rsyslog setup. +Re-running from the Proxmox host does the same after refreshing the LXC. + +### Architecture + +- **OS**: latest Alpine LXC template (auto-detected), unprivileged, `nesting=1`, `/dev/net/tun` passthrough for Tailscale +- **Package**: `gitea` + `gitea-openrc` from Alpine `community` +- **Service**: OpenRC, as shipped by the package (`supervise-daemon`), runs as user `gitea` +- **Config**: `/etc/gitea/app.ini` — merged via `ini_set`, see table above +- **Data**: `/var/lib/gitea` (SQLite DB, repositories, LFS) +- **Logs**: `/var/log/gitea/gitea.log` (Gitea's structured log, rotated daily/7d) forwarded via rsyslog to the proxy's receiver (#20); `/var/log/gitea/http.log` is the raw process stdout/stderr capture +- **Network**: listener bound to `127.0.0.1:3000`; **Tailscale** runs in the LXC and acts as the reverse proxy (`tailscale serve --https=443`, no port in the resulting URL) + +--- + +## Deployment order — non-negotiable + +This issue's script (#19) only produces a working, privately-reachable +instance. Bringing it onto the public domain safely requires the rest of the +milestone, **in this exact order**: + +``` +#18 → #19 → validation XFF (ci-dessous) → #20 → #21 → #22 +``` + +> Activer le jail fail2ban (#21) **avant** d'avoir validé la chaîne +> `X-Forwarded-For` fait bannir le proxy Traefik lui-même au bout de 5 +> échecs, et `gitea.arnodo.fr` devient inaccessible dans son intégralité. + +Do not deploy #21 until the procedure below has confirmed `gitea.log` +contains the real client IP, not the proxy's tailnet IP. + +## X-Forwarded-For validation procedure + +From an IP known to be external to the tailnet (e.g. mobile hotspot), +trigger a failed login against the public URL. Then, inside the LXC: + +```bash +grep "Failed authentication" /var/log/gitea/gitea.log | tail -1 +``` + +| Result | Interpretation | Action | +| ------------------ | ------------------------------------------ | ---------------------------------------------------- | +| Real public IP | Correct | Proceed to #20 | +| `100.x.x.x` | XFF not unwound far enough | Increase `GITEA_REVERSE_PROXY_LIMIT`, rerun the script | +| `127.0.0.1` | `tailscale serve` masks everything | Verify `127.0.0.0/8` is in `GITEA_TRUSTED_PROXIES` | + +As long as that line does not show the real client IP, #21 stays +undeployed. diff --git a/gitea/install.sh b/gitea/install.sh new file mode 100755 index 0000000..7220791 --- /dev/null +++ b/gitea/install.sh @@ -0,0 +1,662 @@ +#!/bin/bash +# install.sh - Gitea: LXC creation, installation & update +# Usage: +# From Proxmox host : bash -c "$(curl -fsSL https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/main/gitea/install.sh)" +# From inside LXC : bash /root/install.sh (updates packages + app.ini) +# +# Single entrypoint, three automatic modes: +# 1. Proxmox host, no existing container -> create LXC + install Gitea +# 2. Proxmox host, container already present -> update packages + app.ini +# 3. Inside an LXC -> install if missing, otherwise update +# +# Installed via `apk add gitea gitea-openrc` rather than the upstream release +# binary: dl.gitea.com ships glibc/CGO-linked binaries, a bad fit for musl. +# Alpine packages a native musl build in community (verified present on the +# 3.22 template as of writing; check_gitea_channel() re-verifies this on every +# install rather than trusting that to stay true). + +set -euo pipefail + +# --- Config (override via environment) --- +CTID="${CTID:-}" +HOSTNAME_LXC="${GITEA_HOSTNAME:-gitea}" +TEMPLATE="${TEMPLATE:-}" # auto-detected when empty +STORAGE="${STORAGE:-local-lvm}" +TEMPLATE_STORAGE="${TEMPLATE_STORAGE:-local}" +CORES="${CORES:-2}" +RAM="${RAM:-2048}" +DISK="${DISK:-16}" +BRIDGE="${BRIDGE:-vmbr0}" +LXC_TAG="${LXC_TAG:-gitea}" # stable identifier for the container +# SCRIPT_URL is what the host-side flow pipes into the LXC. Override it when +# testing from a non-main branch, e.g. +# SCRIPT_URL="https://gitea.arnodo.fr/.../branch/feat/gitea-lxc/gitea/install.sh" +SCRIPT_URL="${SCRIPT_URL:-https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/main/gitea/install.sh}" +# Optional: pre-authorise the LXC's Tailscale non-interactively. +# Generate at https://login.tailscale.com/admin/settings/keys +TS_AUTHKEY="${TS_AUTHKEY:-}" + +# --- app.ini (managed exclusively via ini_set, see configure_app_ini) --- +GITEA_DOMAIN="${GITEA_DOMAIN:-gitea.arnodo.fr}" # public domain +GITEA_ROOT_URL="${GITEA_ROOT_URL:-https://${GITEA_DOMAIN}/}" # public URL, NOT the tailnet URL +GITEA_HTTP_ADDR="${GITEA_HTTP_ADDR:-127.0.0.1}" # loopback; tailscale serve fronts it +GITEA_HTTP_PORT="${GITEA_HTTP_PORT:-3000}" +# Hop count between the client and this instance. Traefik + `tailscale serve` +# is the assumed chain (2 hops) — validate empirically per gitea/README.md +# before relying on the resulting client IP for the fail2ban jail in #21. +GITEA_REVERSE_PROXY_LIMIT="${GITEA_REVERSE_PROXY_LIMIT:-2}" +# Must never be "*" (CVE-2026-20896). 100.64.0.0/10 is Tailscale's CGNAT range. +GITEA_TRUSTED_PROXIES="${GITEA_TRUSTED_PROXIES:-127.0.0.0/8,::1/128,100.64.0.0/10}" +# Prometheus scrape token. Generated on first install if left unset; once set +# in app.ini it is never regenerated (see setup_metrics_token). +GITEA_METRICS_TOKEN="${GITEA_METRICS_TOKEN:-}" + +# --- Admin account (created once; see create_admin_user) --- +GITEA_ADMIN_USER="${GITEA_ADMIN_USER:-admin}" +GITEA_ADMIN_EMAIL="${GITEA_ADMIN_EMAIL:-admin@${GITEA_DOMAIN}}" +GITEA_ADMIN_PASSWORD="${GITEA_ADMIN_PASSWORD:-}" # generated if unset + +# --- rsyslog forwarding to the proxy's receiver (see proxy #20) --- +SYSLOG_TARGET="${SYSLOG_TARGET:-proxy.taila5ad8.ts.net}" +SYSLOG_PORT="${SYSLOG_PORT:-5514}" + +# --- Fixed paths (Alpine package layout; not meant to be overridden) --- +APP_INI="/etc/gitea/app.ini" +GITEA_WORK_DIR="/var/lib/gitea" +GITEA_LOG_DIR="/var/log/gitea" + +# --- Colors --- +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +# Logs go to stderr so callers can safely use $(fn) without capturing log noise. +log_info() { echo -e "${GREEN}[INFO]${NC} $1" >&2; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $1" >&2; } +log_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; } + +# ============================================================ +# Load shared helpers (lib/common.sh: detect_latest_alpine_template, +# enable_tty1_autologin, find_existing_lxc, refresh_os_packages, ini_set). +# +# Same reasoning as openbao/install.sh and gitea-runner/install.sh: a local +# checkout has the file on disk right next to us, but the documented curl +# one-liner (host or piped into `pct exec` inside the LXC) has no +# BASH_SOURCE path worth trusting, so fall back to fetching lib/common.sh +# over HTTP next to SCRIPT_URL. The LXC already needs outbound network to +# curl this very script and to apk-install gitea, so this adds no new +# failure mode. +# ============================================================ +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-.}")" 2>/dev/null && pwd || true)" +LIB_COMMON_URL="$(dirname "$(dirname "$SCRIPT_URL")")/lib/common.sh" +if [[ -n "$SCRIPT_DIR" && -f "${SCRIPT_DIR}/../lib/common.sh" ]]; then + source "${SCRIPT_DIR}/../lib/common.sh" +else + # shellcheck source=/dev/null + source <(curl -fsSL "$LIB_COMMON_URL") +fi + +# `source <(curl ...)` swallows curl failures: an empty stream still makes +# `source` return 0, so a 404/network error would otherwise only surface +# later as a confusing "command not found" for ini_set et al. Fail loudly +# here instead, with the URL that was tried. +if ! declare -F ini_set >/dev/null; then + log_error "Failed to load lib/common.sh (tried: ${LIB_COMMON_URL})." + exit 1 +fi + +require_root() { + if [[ "$(id -u)" -ne 0 ]]; then + log_error "This script must be run as root (current uid: $(id -u))." + log_error "On Proxmox, launch it from the host shell or via the Web UI shell, both of which run as root." + exit 1 + fi +} + +# ============================================================ +# Refuse to install from edge/community: acceptable for a throwaway test +# box, not for a production instance. Re-checked on every install rather +# than assumed, since the issue this script implements only verified this +# against Alpine 3.22 at write time. +# ============================================================ +check_gitea_channel() { + local repo_line + repo_line=$(apk policy gitea 2>/dev/null | awk '/^[[:space:]]+https?:\/\//{print; exit}') + + if [[ -z "$repo_line" ]]; then + log_error "Package 'gitea' not found in the configured Alpine repositories." + log_error "This Alpine release may not package Gitea — see https://pkgs.alpinelinux.org/packages?name=gitea" + exit 1 + fi + if [[ "$repo_line" == *"/edge/"* ]]; then + log_error "gitea is only available via edge/community on this Alpine release." + log_error "Refusing to install from edge on what should be a production instance." + log_error "Pin an Alpine template where gitea has reached a stable release (TEMPLATE=...)," + log_error "or comment on issue #19 with what you found so the assumption can be revisited." + exit 1 + fi + log_info "gitea package available via:${repo_line}" +} + +# ============================================================ +# Merge app.ini to the keys this script owns via ini_set (#18). Never a +# heredoc overwrite: the file ships with sane Alpine-package defaults for +# everything we don't list here, and a rejoué script must only touch its +# own keys (see lib/common.sh's ini_set contract). +# ============================================================ +configure_app_ini() { + log_info "Configuring ${APP_INI}..." + + ini_set "$APP_INI" server PROTOCOL http + ini_set "$APP_INI" server HTTP_ADDR "$GITEA_HTTP_ADDR" + ini_set "$APP_INI" server HTTP_PORT "$GITEA_HTTP_PORT" + ini_set "$APP_INI" server DOMAIN "$GITEA_DOMAIN" + ini_set "$APP_INI" server ROOT_URL "$GITEA_ROOT_URL" + ini_set "$APP_INI" server DISABLE_SSH true + + # INSTALL_LOCK must land before the service's first start, or the web + # installer is exposed on the public domain and the first visitor becomes + # admin. configure_app_ini() always runs before rc-service gitea start in + # install_inside_lxc — do not reorder that. + ini_set "$APP_INI" security INSTALL_LOCK true + ini_set "$APP_INI" security REVERSE_PROXY_LIMIT "$GITEA_REVERSE_PROXY_LIMIT" + ini_set "$APP_INI" security REVERSE_PROXY_TRUSTED_PROXIES "$GITEA_TRUSTED_PROXIES" + + ini_set "$APP_INI" service DISABLE_REGISTRATION true + ini_set "$APP_INI" service REQUIRE_CAPTCHA_FOR_LOGIN true + ini_set "$APP_INI" service ENABLE_CAPTCHA true + + ini_set "$APP_INI" log MODE file + ini_set "$APP_INI" log LEVEL info + ini_set "$APP_INI" log ROOT_PATH "$GITEA_LOG_DIR" + # ANSI color codes in gitea.log would break the match in the + # fail2ban filter added by #21. + ini_set "$APP_INI" log COLORIZE false + + ini_set "$APP_INI" actions ENABLED true + + ini_set "$APP_INI" database DB_TYPE sqlite3 + ini_set "$APP_INI" database PATH "${GITEA_WORK_DIR}/data/gitea.db" + + setup_metrics_token +} + +# ============================================================ +# Metrics token: generate once, then leave alone. A rejeu that regenerated +# it would silently break the Prometheus scrape config. +# ============================================================ +setup_metrics_token() { + local current_token + current_token=$(awk ' + /^\[metrics\]/ { insec = 1; next } + /^\[/ { insec = 0 } + insec && match($0, /^[ \t]*TOKEN[ \t]*=/) { + sub(/^[^=]*=[ \t]*/, "") + print + exit + } + ' "$APP_INI" 2>/dev/null || true) + + local token="${current_token:-${GITEA_METRICS_TOKEN}}" + if [[ -z "$token" ]]; then + token=$(openssl rand -hex 32) + log_info "Generated new Prometheus metrics token (shown once, save it now):" + log_info " ${token}" + fi + + ini_set "$APP_INI" metrics ENABLED true + ini_set "$APP_INI" metrics TOKEN "$token" + ini_set "$APP_INI" metrics ENABLED_ISSUE_BY_REPOSITORY true + ini_set "$APP_INI" metrics ENABLED_ISSUE_BY_LABEL true +} + +# ============================================================ +# Block until Gitea answers its health endpoint. Both create_admin_user() +# (DB must be migrated) and configure_rsyslog_forwarder() (log file must +# exist) depend on the service actually being up. +# ============================================================ +wait_for_gitea_ready() { + local tries=0 + until curl -fsS "http://${GITEA_HTTP_ADDR}:${GITEA_HTTP_PORT}/api/healthz" >/dev/null 2>&1; do + tries=$((tries + 1)) + if (( tries > 30 )); then + log_error "Gitea did not become healthy within 30s." + log_error "Check: rc-service gitea status && tail -50 ${GITEA_LOG_DIR}/gitea.log" + exit 1 + fi + sleep 1 + done +} + +# ============================================================ +# Idempotent admin creation: the DB only exists once Gitea has started at +# least once (its CLI does not migrate on its own), so this must run after +# wait_for_gitea_ready(). Skips creation if any admin already exists, +# regardless of username, so a rerun never touches an operator-renamed or +# operator-created admin account. +# ============================================================ +create_admin_user() { + local existing + existing=$(su -s /bin/sh gitea -c \ + "gitea admin user list --admin --config '${APP_INI}' --work-path '${GITEA_WORK_DIR}'" \ + 2>/dev/null | tail -n +2 | grep -c . || true) + + if [[ "${existing:-0}" -gt 0 ]]; then + log_info "Admin account already present, skipping creation." + return 0 + fi + + local password="${GITEA_ADMIN_PASSWORD:-$(openssl rand -base64 24)}" + log_info "Creating admin account '${GITEA_ADMIN_USER}'..." + su -s /bin/sh gitea -c \ + "gitea admin user create --admin --username '${GITEA_ADMIN_USER}' --email '${GITEA_ADMIN_EMAIL}' --password '${password}' --must-change-password=true --config '${APP_INI}' --work-path '${GITEA_WORK_DIR}'" + + echo "" + log_info "Admin account created (shown once, save it now):" + log_info " Username: ${GITEA_ADMIN_USER}" + log_info " Password: ${password}" + echo "" +} + +# ============================================================ +# Forward gitea.log to the proxy's generic rsyslog receiver (#20), tagged +# "gitea" so the proxy can route it into its own file (#21) for a +# proxy-side fail2ban jail — a jail running in this LXC would only ever +# see the proxy's own tailnet IP as the source and end up banning the +# proxy. Scoped to $programname == "gitea" so the LXC's own local syslog +# traffic (cron, auth, rsyslog's startup messages) is never forwarded. +# ============================================================ +configure_rsyslog_forwarder() { + log_info "Configuring rsyslog forwarding to ${SYSLOG_TARGET}:${SYSLOG_PORT}..." + apk add --no-cache rsyslog >/dev/null + mkdir -p /etc/rsyslog.d + + cat > /etc/rsyslog.d/50-gitea-forward.conf << EOF +module(load="imfile") + +input(type="imfile" + File="${GITEA_LOG_DIR}/gitea.log" + Tag="gitea" + Severity="info" + Facility="local0") + +if \$programname == "gitea" then { + action(type="omfwd" target="${SYSLOG_TARGET}" port="${SYSLOG_PORT}" protocol="tcp") + stop +} +EOF + + rc-update add rsyslog default >/dev/null 2>&1 || true + rc-service rsyslog status >/dev/null 2>&1 && rc-service rsyslog stop + + # SYSLOG_TARGET is a MagicDNS name; omfwd resolves it once at rsyslog + # startup, so starting before Tailscale has come up and propagated the + # name would leave the forwarder silently broken until the next restart. + # Same bounded-wait pattern as gitea-runner/install.sh's start_pre(). + # configure_tailscale_proxy() must already have run by the time we get + # here — do not reorder that. + local tries=0 + until getent hosts "$SYSLOG_TARGET" >/dev/null 2>&1; do + tries=$((tries + 1)) + if (( tries > 30 )); then + log_warn "Could not resolve ${SYSLOG_TARGET} after 30s — starting rsyslog anyway." + log_warn "Forwarding will stay broken until the name resolves and rsyslog is restarted (rerun this script)." + break + fi + sleep 1 + done + + rc-service rsyslog start +} + +# ============================================================ +# Reusable: bring Tailscale up and publish Gitea on the tailnet. +# Idempotent: re-running is a no-op once Tailscale is logged in and the +# serve mapping is already in place. Mirrors openbao/install.sh's helper +# of the same name. +# ============================================================ +configure_tailscale_proxy() { + if ! command -v tailscale >/dev/null 2>&1; then + log_warn "tailscale CLI not found, skipping reverse-proxy setup." + return 0 + fi + + local backend_state + backend_state=$(tailscale status --json 2>/dev/null | jq -r '.BackendState // "unknown"') + if [[ "$backend_state" != "Running" ]]; then + if [[ -n "$TS_AUTHKEY" ]]; then + log_info "Bringing Tailscale up with provided auth key..." + tailscale up --authkey "$TS_AUTHKEY" --ssh --hostname "$HOSTNAME_LXC" \ + || log_warn "tailscale up failed — run it manually inside the LXC." + else + log_warn "Tailscale not authenticated and TS_AUTHKEY was not supplied." + log_warn "Finish setup inside the LXC with: tailscale up --ssh" + log_warn "Then publish Gitea with: tailscale serve --bg --https=443 http://${GITEA_HTTP_ADDR}:${GITEA_HTTP_PORT}" + return 0 + fi + fi + + if tailscale serve status 2>/dev/null | grep -q "${GITEA_HTTP_ADDR}:${GITEA_HTTP_PORT}"; then + log_info "Tailscale serve already publishes http://${GITEA_HTTP_ADDR}:${GITEA_HTTP_PORT}." + else + log_info "Publishing Gitea on the tailnet via 'tailscale serve' (HTTPS:443)..." + tailscale serve --bg --https=443 "http://${GITEA_HTTP_ADDR}:${GITEA_HTTP_PORT}" \ + || log_warn "tailscale serve failed — enable HTTPS on your tailnet and retry." + fi + + local fqdn + fqdn=$(tailscale status --json 2>/dev/null | jq -r '.Self.DNSName // ""' | sed 's/\.$//') + if [[ -n "$fqdn" ]]; then + log_info "Gitea reachable on the tailnet at: https://${fqdn}" + fi +} + +# ============================================================ +# Proxmox-host helpers +# ============================================================ +allocate_ctid() { + pvesh get /cluster/nextid 2>/dev/null \ + || pvesh get /cluster/resources --type vm --output-format json 2>/dev/null \ + | jq '[.[].vmid] | max + 1' \ + || echo 100 +} + +# Inject the script into the container and execute it in the requested mode. +# Forwards the runtime configuration the inner invocation needs to reproduce +# what the user requested on the host (mirrors openbao/gitea-runner's +# exec_in_lxc). +exec_in_lxc() { + local ctid="$1" + local mode="$2" # --install or --update + + pct exec "$ctid" -- sh -c "apk add --no-cache bash curl jq ca-certificates >/dev/null 2>&1" + curl -fsSL "$SCRIPT_URL" \ + | pct exec "$ctid" -- env \ + SCRIPT_URL="$SCRIPT_URL" \ + GITEA_HOSTNAME="$HOSTNAME_LXC" \ + GITEA_DOMAIN="$GITEA_DOMAIN" \ + GITEA_ROOT_URL="$GITEA_ROOT_URL" \ + GITEA_HTTP_ADDR="$GITEA_HTTP_ADDR" \ + GITEA_HTTP_PORT="$GITEA_HTTP_PORT" \ + GITEA_REVERSE_PROXY_LIMIT="$GITEA_REVERSE_PROXY_LIMIT" \ + GITEA_TRUSTED_PROXIES="$GITEA_TRUSTED_PROXIES" \ + GITEA_METRICS_TOKEN="$GITEA_METRICS_TOKEN" \ + GITEA_ADMIN_USER="$GITEA_ADMIN_USER" \ + GITEA_ADMIN_EMAIL="$GITEA_ADMIN_EMAIL" \ + GITEA_ADMIN_PASSWORD="$GITEA_ADMIN_PASSWORD" \ + SYSLOG_TARGET="$SYSLOG_TARGET" \ + SYSLOG_PORT="$SYSLOG_PORT" \ + TS_AUTHKEY="$TS_AUTHKEY" \ + bash -s -- "$mode" +} + +# ============================================================ +# MODE: Proxmox host — create LXC + install +# ============================================================ +create_lxc() { + log_info "=== Gitea — LXC creation ===" + + if [[ -z "$TEMPLATE" ]]; then + TEMPLATE=$(detect_latest_alpine_template) + else + log_info "Using user-provided template: $TEMPLATE" + fi + ensure_template_present "$TEMPLATE" + + if [[ -z "$CTID" ]]; then + CTID=$(allocate_ctid) + log_info "Auto-selected CTID: $CTID" + fi + + log_info "Creating LXC ${CTID} (${HOSTNAME_LXC})..." + pct create "$CTID" "${TEMPLATE_STORAGE}:vztmpl/${TEMPLATE}" \ + --hostname "$HOSTNAME_LXC" \ + --cores "$CORES" \ + --memory "$RAM" \ + --rootfs "${STORAGE}:${DISK}" \ + --net0 "name=eth0,bridge=${BRIDGE},ip=dhcp" \ + --unprivileged 1 \ + --features "nesting=1" \ + --tags "infra-script,${LXC_TAG}" \ + --onboot 1 \ + --start 0 + + # Tailscale needs /dev/net/tun inside the unprivileged container. + log_info "Adding /dev/net/tun passthrough for Tailscale..." + cat >> "/etc/pve/lxc/${CTID}.conf" </dev/null; do + tries=$((tries + 1)) + if (( tries > 20 )); then + log_error "LXC ${CTID} did not acquire an IP after 20s." + exit 1 + fi + sleep 1 + done + + log_info "Running installer inside LXC ${CTID}..." + exec_in_lxc "$CTID" "--install" + + local ip + ip=$(pct exec "$CTID" -- ip -4 addr show eth0 2>/dev/null | awk '/inet /{print $2}' | cut -d/ -f1 || true) + + echo "" + log_info "=========================================" + log_info "LXC ${CTID} created successfully!" + log_info "=========================================" + echo "" + echo " Hostname : ${HOSTNAME_LXC}" + echo " IP : ${ip:-pending}" + echo "" + echo "IMPORTANT — do not deploy the fail2ban jail (#21) yet. Validate the" + echo "X-Forwarded-For chain first (see gitea/README.md):" + echo " 1. Trigger a failed login from a known external IP." + echo " 2. pct enter ${CTID} && grep 'Failed authentication' ${GITEA_LOG_DIR}/gitea.log | tail -1" + echo " 3. Only once that line shows the real client IP, deploy #20 then #21." + echo "" +} + +# ============================================================ +# MODE: Proxmox host — update existing LXC +# ============================================================ +update_lxc() { + local ctid="$1" + log_info "=== Gitea — updating existing LXC ${ctid} ===" + + if ! pct status "$ctid" | grep -q running; then + log_info "Starting LXC ${ctid}..." + pct start "$ctid" + sleep 3 + fi + + log_info "Updating LXC ${ctid}..." + exec_in_lxc "$ctid" "--update" + + log_info "Update of LXC ${ctid} complete." +} + +# ============================================================ +# MODE: inside LXC — fresh install +# ============================================================ +install_inside_lxc() { + log_info "=== Gitea — installation ===" + + log_info "Updating package index..." + apk update >/dev/null + + check_gitea_channel + + log_info "Installing dependencies..." + apk add --no-cache bash curl jq ca-certificates openssl openrc tailscale >/dev/null + + log_info "Installing gitea + gitea-openrc..." + apk add --no-cache gitea gitea-openrc >/dev/null + + log_info "Enabling tailscaled..." + rc-update add tailscale default >/dev/null 2>&1 || true + rc-service tailscale start >/dev/null 2>&1 || log_warn "tailscaled failed to start (is /dev/net/tun mapped into the LXC?)" + + configure_app_ini + + log_info "Starting gitea service..." + rc-update add gitea default >/dev/null 2>&1 || true + rc-service gitea status >/dev/null 2>&1 && rc-service gitea stop + rc-service gitea start + + wait_for_gitea_ready + create_admin_user + + configure_tailscale_proxy + + # SYSLOG_TARGET is a MagicDNS name — must run after configure_tailscale_proxy + # so the tailnet (and MagicDNS) is actually up by the time rsyslog starts. + configure_rsyslog_forwarder + + log_info "Configuring logrotate for ${GITEA_LOG_DIR}/gitea.log..." + apk add --no-cache logrotate >/dev/null + cat > /etc/logrotate.d/gitea </dev/null || true + + enable_tty1_autologin + + log_info "Configuring MOTD..." + # /etc/profile.d/ runs for every interactive login shell — works for both + # the auto-login tty and Tailscale SSH. Quoted heredoc: every variable is + # resolved at login time, not at install time; the two __GITEA_*__ + # markers below are the only install-time values, substituted after the fact. + cat > /etc/profile.d/00-gitea.sh <<'MOTD' +TS_FQDN=$(tailscale status --json 2>/dev/null | awk -F'"' ' + /"Self"/ { in_self=1 } + in_self && /"DNSName"/ { gsub(/\.$/, "", $4); print $4; exit } +') +[[ -z "$TS_FQDN" ]] && TS_FQDN="$(hostname).ts.net" + +GITEA_VERSION=$(apk info -e -v gitea 2>/dev/null | sed 's/^gitea-//') +[[ -z "$GITEA_VERSION" ]] && GITEA_VERSION="unknown" + +if rc-service gitea status >/dev/null 2>&1; then + SVC_STATE="running" +else + SVC_STATE="stopped" +fi + +echo "" +echo " ____ _ _ " +echo "/ ___(_) |_ ___ __ _ " +echo "| | _| | __/ _ \/ _\` |" +echo "| |_| | | || __/ (_| |" +echo "\____|_|\__\___|\__,_|" +echo "" +echo "Gitea (${GITEA_VERSION})" +echo "─────────────────────────────────────────" +echo "Access:" +echo " • Tailnet : https://${TS_FQDN}" +echo " • Public : __GITEA_ROOT_URL__" +echo " • Service : ${SVC_STATE}" +echo "" +echo "Useful commands:" +echo " rc-service gitea status" +echo " tail -f __GITEA_LOG_DIR__/gitea.log" +echo "─────────────────────────────────────────" +echo "" +MOTD + sed -i "s#__GITEA_ROOT_URL__#${GITEA_ROOT_URL}#; s#__GITEA_LOG_DIR__#${GITEA_LOG_DIR}#" /etc/profile.d/00-gitea.sh + chmod +x /etc/profile.d/00-gitea.sh + + log_info "Cleaning up..." + rm -rf /var/cache/apk/* + + echo "" + log_info "=========================================" + log_info "Gitea installation complete!" + log_info "=========================================" + echo "" + echo " Public URL : ${GITEA_ROOT_URL}" + echo "" + echo "IMPORTANT — do not deploy the fail2ban jail (#21) yet. Validate the" + echo "X-Forwarded-For chain first (see gitea/README.md):" + echo " grep 'Failed authentication' ${GITEA_LOG_DIR}/gitea.log | tail -1" + echo "" +} + +# ============================================================ +# MODE: inside LXC — update only +# ============================================================ +update_inside_lxc() { + log_info "=== Gitea — update ===" + + # refresh_os_packages() runs an unscoped `apk update && apk upgrade`, + # which already brings gitea/gitea-openrc to the latest available build — + # no separate `apk upgrade gitea` call needed. + refresh_os_packages + + configure_app_ini + + rc-service gitea status >/dev/null 2>&1 && rc-service gitea stop + rc-service gitea start + wait_for_gitea_ready + + configure_tailscale_proxy + # SYSLOG_TARGET is a MagicDNS name — must run after configure_tailscale_proxy + # so the tailnet (and MagicDNS) is actually up by the time rsyslog starts. + configure_rsyslog_forwarder + + log_info "Gitea version: $(apk info -e -v gitea 2>/dev/null)" + log_info "Update complete." +} + +# ============================================================ +# Main — dispatch on explicit mode flag or auto-detect context +# ============================================================ +main() { + case "${1:-}" in + --install) + install_inside_lxc + return + ;; + --update) + update_inside_lxc + return + ;; + esac + + if command -v pct >/dev/null 2>&1; then + # Running on a Proxmox host + require_root + + local existing="" + if existing=$(find_existing_lxc); then + log_info "Found existing Gitea LXC (CTID ${existing}, hostname/tag match) — switching to update mode." + update_lxc "$existing" + else + create_lxc + fi + else + # Inside a container (no Proxmox tooling) + require_root + if command -v gitea >/dev/null 2>&1; then + update_inside_lxc + else + install_inside_lxc + fi + fi +} + +main "$@"