feat(gitea): add LXC install script + README

New gitea/install.sh, modeled on openbao/install.sh's single-entrypoint
three-mode pattern. Installs via `apk add gitea gitea-openrc` rather
than a downloaded release binary: dl.gitea.com ships glibc/CGO-linked
binaries, a bad fit for musl. check_gitea_channel() re-verifies on
every install that the package is available via community (not edge)
rather than trusting that to stay true.

app.ini is owned exclusively by ini_set (#18) — never a heredoc
overwrite — so a rejoué script adds newly-required keys without
clobbering operator changes elsewhere in the file. INSTALL_LOCK is
set before the service's first start so the web installer is never
exposed. The Prometheus metrics token and admin account are each
created once and left alone on reruns. gitea.log is forwarded to the
proxy's rsyslog receiver (#20) tagged "gitea", scoped so the LXC's own
local syslog traffic is never forwarded — a jail running in this LXC
would only ever see the proxy's tailnet IP and end up banning the
proxy itself.

Root README's script table gets a line for the new script.

Refs #19
This commit is contained in:
Damien
2026-07-31 19:19:33 +02:00
parent 905166d1cc
commit f71abf9b23
3 changed files with 822 additions and 0 deletions
+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.