feat(gitea): script d'installation LXC Alpine + README #26

Closed
Damien wants to merge 2 commits from feat/gitea-lxc into feat/lib-ini-set
3 changed files with 844 additions and 0 deletions
+1
View File
@@ -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` | | [`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` | | [`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-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)"` | | [`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)"` | | [`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)"` |
+181
View File
@@ -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 `<HOST>` 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://<GITEA_DOMAIN>/` | 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@<GITEA_DOMAIN>` | 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.
+662
View File
@@ -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 <HOST> 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" <<EOF
lxc.cgroup2.devices.allow: c 10:200 rwm
lxc.mount.entry: /dev/net dev/net none bind,create=dir
lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file
EOF
log_info "Starting LXC ${CTID}..."
pct start "$CTID"
local tries=0
until pct exec "$CTID" -- sh -c "ip -4 addr show eth0 | grep -q 'inet '" 2>/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 <<EOF
${GITEA_LOG_DIR}/gitea.log {
daily
rotate 7
compress
missingok
notifempty
copytruncate
}
EOF
ln -sf /usr/sbin/logrotate /etc/periodic/daily/logrotate 2>/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 "$@"