Caddy could not obtain a certificate on first boot: every request to acme-v02.api.letsencrypt.org timed out. Containers could reach 1.1.1.1:443 by address but resolved no names at all. Container DNS goes to aardvark-dns on the bridge gateway (10.89.10.1:53). That traffic terminates on the host, so it takes the input hook, not forward. Netavark accepts it in its own table, but gitea_filter's input chain has policy drop, and a packet must be accepted by every base chain on the hook. Our drop won. gitea_filter now accepts tcp/udp 53 from the podman subnet to its gateway. Both come from instance metadata, so the ruleset is rendered with envsubst, as the fail2ban jail already is. It is not interface-based because netavark's bridge name (podman1) is not pinned. setup_nftables also validates the rendered file before installing it. Previously it installed first and validated second, so a ruleset that failed to parse stayed in /etc/sysconfig and would fail nftables.service on the next boot, leaving the host with no gitea_filter table. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
644 lines
27 KiB
Bash
Executable File
644 lines
27 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# Gitea VM bootstrap. Set as the GCE `startup-script` metadata value by Pulumi,
|
|
# and re-run by gitea-config-sync.service with --sync-only after a config push.
|
|
#
|
|
# MUST be idempotent: GCE runs the startup script on every boot.
|
|
#
|
|
# (no args) full run -- packages, disk, SELinux, firewall, units, config
|
|
# --sync-only re-pull vm/ from GCS, re-render templates, restart what changed
|
|
#
|
|
set -euo pipefail
|
|
|
|
readonly STATE_DIR=/opt/gitea-config
|
|
readonly RENDER_DIR=/etc/containers/systemd
|
|
readonly LOG_TAG=gitea-bootstrap
|
|
|
|
log() { echo "[${LOG_TAG}] $*" >&2; }
|
|
die() { echo "[${LOG_TAG}] FATAL: $*" >&2; exit 1; }
|
|
warn() { echo "[${LOG_TAG}] WARN: $*" >&2; }
|
|
|
|
MODE=full
|
|
[[ "${1:-}" == "--sync-only" ]] && MODE=sync
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Instance metadata (populated by Pulumi)
|
|
# ---------------------------------------------------------------------------
|
|
meta() {
|
|
curl -fsS -H 'Metadata-Flavor: Google' \
|
|
"http://169.254.169.254/computeMetadata/v1/instance/attributes/$1" 2>/dev/null || true
|
|
}
|
|
|
|
CONFIG_BUCKET=$(meta config-bucket)
|
|
BACKUP_BUCKET=$(meta backup-bucket)
|
|
GCP_PROJECT=$(meta gcp-project)
|
|
AR_HOST=$(meta ar-host)
|
|
IMAGE_GITEA=$(meta image-gitea)
|
|
IMAGE_CADDY=$(meta image-caddy)
|
|
DOMAIN=$(meta domain)
|
|
APP_NAME=$(meta app-name)
|
|
PODMAN_SUBNET=$(meta podman-subnet)
|
|
PODMAN_GATEWAY=$(meta podman-gateway)
|
|
REQUIRE_SIGNIN_VIEW=$(meta require-signin-view)
|
|
WAF_MODE=$(meta waf-mode)
|
|
DATA_DISK_DEVICE=$(meta data-disk-device)
|
|
|
|
[[ -n "${CONFIG_BUCKET}" ]] || die "config-bucket metadata is missing; nothing to sync from"
|
|
[[ -n "${DOMAIN}" ]] || die "domain metadata is missing"
|
|
: "${APP_NAME:=Gitea}"
|
|
: "${REQUIRE_SIGNIN_VIEW:=false}"
|
|
# DetectionOnly is the safe default: run it, read what it flags, add
|
|
# exclusions, then switch to On. See docs/waf.md.
|
|
: "${WAF_MODE:=DetectionOnly}"
|
|
case "${WAF_MODE}" in
|
|
On|DetectionOnly|Off) ;;
|
|
*) die "waf-mode must be On, DetectionOnly, or Off (got: ${WAF_MODE})" ;;
|
|
esac
|
|
: "${DATA_DISK_DEVICE:=/dev/disk/by-id/google-gitea-data}"
|
|
|
|
export GCP_PROJECT AR_HOST IMAGE_GITEA IMAGE_CADDY DOMAIN APP_NAME
|
|
export PODMAN_SUBNET PODMAN_GATEWAY REQUIRE_SIGNIN_VIEW WAF_MODE
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Packages
|
|
# ---------------------------------------------------------------------------
|
|
install_packages() {
|
|
log "installing packages"
|
|
dnf -y install \
|
|
podman container-selinux \
|
|
nftables \
|
|
jq gettext \
|
|
policycoreutils-python-utils \
|
|
xfsprogs
|
|
|
|
# fail2ban lives in EPEL on RHEL-family distros. The exact package set has
|
|
# shifted between EPEL releases, so probe rather than assume -- this is the
|
|
# one dependency most likely to be named differently on EPEL 10.
|
|
if ! rpm -q epel-release >/dev/null 2>&1; then
|
|
dnf -y install epel-release || warn "epel-release unavailable; fail2ban will be skipped"
|
|
fi
|
|
if dnf -y install fail2ban fail2ban-server 2>/dev/null; then
|
|
# The systemd journal backend needs the Python bindings; without them
|
|
# fail2ban silently falls back and matches nothing.
|
|
dnf -y install python3-systemd || warn "python3-systemd missing; the systemd backend may not work"
|
|
else
|
|
warn "fail2ban not installable from configured repos -- skipping fail2ban setup"
|
|
fi
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Data disk
|
|
# ---------------------------------------------------------------------------
|
|
setup_data_disk() {
|
|
log "configuring data disk ${DATA_DISK_DEVICE}"
|
|
[[ -e "${DATA_DISK_DEVICE}" ]] || die "data disk ${DATA_DISK_DEVICE} not present"
|
|
|
|
if ! blkid "${DATA_DISK_DEVICE}" >/dev/null 2>&1; then
|
|
log "disk is unformatted -- creating XFS filesystem"
|
|
mkfs.xfs -q "${DATA_DISK_DEVICE}"
|
|
fi
|
|
|
|
local uuid
|
|
uuid=$(blkid -s UUID -o value "${DATA_DISK_DEVICE}")
|
|
[[ -n "${uuid}" ]] || die "could not read UUID from ${DATA_DISK_DEVICE}"
|
|
|
|
mkdir -p /var/lib/gitea
|
|
|
|
# By UUID, never by device path: GCE can reorder /dev/sdX across reboots.
|
|
if ! grep -q "UUID=${uuid}" /etc/fstab; then
|
|
log "adding fstab entry for ${uuid}"
|
|
printf 'UUID=%s /var/lib/gitea xfs defaults,nofail,x-systemd.device-timeout=30 0 2\n' \
|
|
"${uuid}" >> /etc/fstab
|
|
fi
|
|
|
|
systemctl daemon-reload
|
|
mountpoint -q /var/lib/gitea || mount /var/lib/gitea
|
|
mountpoint -q /var/lib/gitea || die "/var/lib/gitea failed to mount"
|
|
|
|
# Set the SELinux label persistently ONCE, rather than putting :Z on the
|
|
# quadlet's volume line. :Z would force a recursive relabel of the entire
|
|
# repository tree on every container start.
|
|
if ! semanage fcontext -l 2>/dev/null | grep -q '^/var/lib/gitea(/\.\*)?'; then
|
|
log "setting persistent SELinux fcontext on /var/lib/gitea"
|
|
semanage fcontext -a -t container_file_t '/var/lib/gitea(/.*)?' || \
|
|
warn "semanage fcontext failed; check SELinux state"
|
|
fi
|
|
restorecon -RF /var/lib/gitea || warn "restorecon failed"
|
|
|
|
# The mount point itself must belong to the container's uid, not just the
|
|
# subdirectories: Gitea creates GITEA_CUSTOM (/var/lib/gitea/custom) at
|
|
# startup, and a root-owned 0755 mount point makes that mkdir fail with a
|
|
# bare "permission denied" that reads like an SELinux problem.
|
|
chown 1000:1000 /var/lib/gitea
|
|
chmod 0750 /var/lib/gitea
|
|
install -d -o 1000 -g 1000 -m 0750 \
|
|
/var/lib/gitea/data /var/lib/gitea/log /var/lib/gitea/custom
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Swap
|
|
# ---------------------------------------------------------------------------
|
|
# GCE instances ship with no swap. On e2-small (2 GB) that is a real risk: a
|
|
# steady-state Gitea + Caddy/Coraza pair measures ~155 MB, but git subprocesses
|
|
# spawned during a push (git-receive-pack, index-pack, gc) push the total past
|
|
# 400 MB on a modest repo and scale with repo size. Without swap, the OOM killer
|
|
# picks a victim mid-push.
|
|
#
|
|
# This is ballast, not working memory -- hence the low swappiness. If the box is
|
|
# swapping steadily, the answer is a bigger machine type, not more swap.
|
|
setup_swap() {
|
|
local swapfile=/swapfile size_mb=2048
|
|
|
|
if swapon --show=NAME --noheadings 2>/dev/null | grep -qx "${swapfile}"; then
|
|
log "swap already active"
|
|
else
|
|
if [[ ! -f "${swapfile}" ]]; then
|
|
log "creating ${size_mb}MB swap file"
|
|
# dd, not fallocate: a fallocated file can carry unwritten extents
|
|
# that mkswap accepts and the kernel then refuses to swap to.
|
|
dd if=/dev/zero of="${swapfile}" bs=1M count="${size_mb}" status=none
|
|
chmod 0600 "${swapfile}"
|
|
mkswap "${swapfile}" >/dev/null
|
|
fi
|
|
swapon "${swapfile}" || warn "swapon failed"
|
|
fi
|
|
|
|
grep -q "^${swapfile} " /etc/fstab \
|
|
|| printf '%s none swap sw 0 0\n' "${swapfile}" >> /etc/fstab
|
|
|
|
echo 'vm.swappiness = 10' > /etc/sysctl.d/90-gitea-swappiness.conf
|
|
sysctl -q -p /etc/sysctl.d/90-gitea-swappiness.conf || warn "could not apply swappiness"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Podman / netavark
|
|
# ---------------------------------------------------------------------------
|
|
configure_podman() {
|
|
log "configuring podman firewall driver"
|
|
mkdir -p /etc/containers/containers.conf.d
|
|
# Netavark keeps its rules in a dedicated `netavark` nftables table, which is
|
|
# what makes coexistence with our own table workable. Changing this with
|
|
# containers running leaves conflicting rules behind -- it is set here,
|
|
# before anything starts, and a reboot is the documented fix if it is ever
|
|
# changed on a live host.
|
|
cat > /etc/containers/containers.conf.d/10-gitea.conf <<'EOF'
|
|
[network]
|
|
firewall_driver = "nftables"
|
|
EOF
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Host firewall
|
|
# ---------------------------------------------------------------------------
|
|
# Runs on every invocation so a pushed vm/nftables/gitea.nft change applies
|
|
# without waiting for a reboot.
|
|
setup_nftables() {
|
|
log "installing nftables ruleset"
|
|
# firewalld and a hand-managed ruleset will fight. Pick one.
|
|
systemctl disable --now firewalld >/dev/null 2>&1 || true
|
|
systemctl mask firewalld >/dev/null 2>&1 || true
|
|
|
|
[[ -n "${PODMAN_SUBNET}" && -n "${PODMAN_GATEWAY}" ]] \
|
|
|| die "podman-subnet/podman-gateway metadata missing; cannot render the ruleset"
|
|
|
|
# Validate BEFORE installing. A ruleset that fails to parse must never land
|
|
# in /etc/sysconfig: nftables.service would fail to load it on the next boot
|
|
# and the host would come up with no gitea_filter table at all.
|
|
local tmp
|
|
tmp=$(mktemp)
|
|
envsubst '${PODMAN_SUBNET} ${PODMAN_GATEWAY}' < "${STATE_DIR}/nftables/gitea.nft" > "${tmp}"
|
|
if ! nft -c -f "${tmp}"; then
|
|
rm -f "${tmp}"
|
|
die "nftables ruleset failed validation"
|
|
fi
|
|
install -m 0600 "${tmp}" /etc/sysconfig/nftables.conf
|
|
rm -f "${tmp}"
|
|
systemctl enable --now nftables
|
|
systemctl reload nftables
|
|
|
|
# If this fails, the ruleset flushed something it should not have.
|
|
nft list table inet gitea_filter >/dev/null || die "gitea_filter table missing after reload"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Artifact Registry credentials for root podman
|
|
# ---------------------------------------------------------------------------
|
|
install_ar_auth() {
|
|
log "installing Artifact Registry auth refresher"
|
|
cat > /usr/local/bin/gitea-ar-auth <<'EOF'
|
|
#!/usr/bin/env bash
|
|
# Writes a docker-format auth file for Artifact Registry using the VM service
|
|
# account's metadata token.
|
|
#
|
|
# podman-auto-update.service runs as root with no interactive gcloud session, so
|
|
# it needs a credential sitting on disk. This is the #1 reason auto-update
|
|
# quietly stops working on GCE.
|
|
set -euo pipefail
|
|
|
|
AR_HOST=$(curl -fsS -H 'Metadata-Flavor: Google' \
|
|
http://169.254.169.254/computeMetadata/v1/instance/attributes/ar-host)
|
|
TOKEN=$(curl -fsS -H 'Metadata-Flavor: Google' \
|
|
http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token \
|
|
| jq -r .access_token)
|
|
|
|
[[ -n "${TOKEN}" && "${TOKEN}" != "null" ]] || { echo "no access token from metadata server" >&2; exit 1; }
|
|
|
|
AUTH=$(printf 'oauth2accesstoken:%s' "${TOKEN}" | base64 -w0)
|
|
umask 077
|
|
tmp=$(mktemp /etc/containers/.ar-auth.XXXXXX)
|
|
jq -n --arg host "${AR_HOST}" --arg auth "${AUTH}" \
|
|
'{auths: {($host): {auth: $auth}}}' > "${tmp}"
|
|
chmod 0600 "${tmp}"
|
|
mv "${tmp}" /etc/containers/ar-auth.json
|
|
EOF
|
|
chmod 0755 /usr/local/bin/gitea-ar-auth
|
|
/usr/local/bin/gitea-ar-auth || warn "initial AR auth refresh failed"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helper scripts
|
|
# ---------------------------------------------------------------------------
|
|
install_helpers() {
|
|
log "installing helper scripts"
|
|
|
|
cat > /usr/local/bin/gitea-backup <<EOF
|
|
#!/usr/bin/env bash
|
|
# Streams a portable \`gitea dump\` straight to Cloud Storage.
|
|
#
|
|
# Complements the PD snapshot policy rather than replacing it: a dump restores
|
|
# onto any host, a snapshot only restores this disk.
|
|
set -euo pipefail
|
|
BUCKET="${BACKUP_BUCKET}"
|
|
[[ -n "\${BUCKET}" ]] || { echo "no backup bucket configured" >&2; exit 0; }
|
|
stamp=\$(date -u +%Y%m%dT%H%M%SZ)
|
|
podman exec -u 1000 gitea gitea dump -c /etc/gitea/app.ini -t /tmp -f - \\
|
|
| gcloud storage cp - "gs://\${BUCKET}/dumps/gitea-\${stamp}.zip"
|
|
echo "backup complete: gs://\${BUCKET}/dumps/gitea-\${stamp}.zip"
|
|
EOF
|
|
chmod 0755 /usr/local/bin/gitea-backup
|
|
|
|
cat > /usr/local/bin/gitea-reboot-if-needed <<'EOF'
|
|
#!/usr/bin/env bash
|
|
# Reboots only when the package layer says a reboot is genuinely required.
|
|
#
|
|
# `needs-restarting -r` exits 0 for "no reboot needed" and 1 for "reboot
|
|
# needed". Anything else -- most likely 127 because the dnf5 plugin is packaged
|
|
# differently on this release -- means we do not KNOW, and "do not know" must
|
|
# never mean "reboot the Gitea host every Sunday".
|
|
set -uo pipefail
|
|
|
|
dnf needs-restarting -r >/dev/null 2>&1
|
|
rc=$?
|
|
|
|
case "${rc}" in
|
|
0) echo "no reboot required" ; exit 0 ;;
|
|
1) echo "reboot required by pending updates -- rebooting" ; systemctl reboot ;;
|
|
*) echo "needs-restarting returned ${rc} (plugin missing?) -- NOT rebooting" >&2
|
|
echo "install the dnf needs-restarting plugin, or this check is inert" >&2
|
|
exit 0 ;;
|
|
esac
|
|
EOF
|
|
chmod 0755 /usr/local/bin/gitea-reboot-if-needed
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config sync + render
|
|
# ---------------------------------------------------------------------------
|
|
sync_config() {
|
|
log "syncing configuration from gs://${CONFIG_BUCKET}/vm/"
|
|
mkdir -p "${STATE_DIR}"
|
|
gcloud storage rsync --recursive --delete-unmatched-destination-objects \
|
|
"gs://${CONFIG_BUCKET}/vm" "${STATE_DIR}" \
|
|
|| die "config sync failed"
|
|
}
|
|
|
|
# Reads a Gitea secret from Secret Manager. If it has no version yet, generates
|
|
# one -- but ONLY if a Gitea image is available locally to generate it with.
|
|
#
|
|
# INTERNAL_TOKEN must be a valid Gitea-issued JWT, so this cannot be a random
|
|
# string from Pulumi. Normally scripts/bootstrap.sh has already populated these
|
|
# before the first `pulumi up`; this is the safety net.
|
|
fetch_or_create_secret() {
|
|
local name="$1" value=""
|
|
if value=$(gcloud secrets versions access latest --secret="${name}" --project="${GCP_PROJECT}" 2>/dev/null); then
|
|
printf '%s' "${value}"
|
|
return 0
|
|
fi
|
|
|
|
if ! podman image exists "${IMAGE_GITEA}" 2>/dev/null; then
|
|
return 1
|
|
fi
|
|
|
|
local key
|
|
case "${name}" in
|
|
*secret-key) key=SECRET_KEY ;;
|
|
*internal-token) key=INTERNAL_TOKEN ;;
|
|
*oauth2-jwt-secret) key=JWT_SECRET ;;
|
|
*lfs-jwt-secret) key=LFS_JWT_SECRET ;;
|
|
*) return 1 ;;
|
|
esac
|
|
|
|
log "generating missing secret ${name}"
|
|
value=$(podman run --rm "${IMAGE_GITEA}" generate secret "${key}") || return 1
|
|
printf '%s' "${value}" \
|
|
| gcloud secrets versions add "${name}" --project="${GCP_PROJECT}" --data-file=- >/dev/null || return 1
|
|
printf '%s' "${value}"
|
|
}
|
|
|
|
# Renders src -> dst only if the content actually differs, and reports whether
|
|
# it changed. Keeps config-sync from restarting healthy services for no reason.
|
|
render() {
|
|
local src="$1" dst="$2" owner="$3" mode="$4" vars="$5"
|
|
local tmp
|
|
tmp=$(mktemp)
|
|
envsubst "${vars}" < "${src}" > "${tmp}"
|
|
if [[ -f "${dst}" ]] && cmp -s "${tmp}" "${dst}"; then
|
|
rm -f "${tmp}"
|
|
return 1
|
|
fi
|
|
install -o "${owner%:*}" -g "${owner#*:}" -m "${mode}" "${tmp}" "${dst}"
|
|
rm -f "${tmp}"
|
|
log "rendered ${dst}"
|
|
return 0
|
|
}
|
|
|
|
# Decides whether Caddy can live on the podman bridge or needs the host network.
|
|
#
|
|
# The googleclouddns ACME plugin authenticates via Application Default
|
|
# Credentials, which on GCE means reaching the metadata server at
|
|
# 169.254.169.254. If a container on our bridge cannot reach it, DNS-01 issuance
|
|
# fails at certificate time -- long after this script has reported success -- so
|
|
# the check happens here, up front, and the answer is cached.
|
|
probe_caddy_network() {
|
|
local cache=/etc/gitea/caddy-network
|
|
if [[ -f "${cache}" ]]; then
|
|
cat "${cache}"
|
|
return 0
|
|
fi
|
|
if ! podman image exists "${IMAGE_GITEA}" 2>/dev/null; then
|
|
# Cannot probe yet (first boot, before the first image build). Assume the
|
|
# bridge and re-probe on the next config-sync.
|
|
echo bridge
|
|
return 0
|
|
fi
|
|
|
|
# The network normally does not exist yet: on a full boot this runs before
|
|
# the quadlet units start. Creating it here with the same arguments quadlet
|
|
# uses keeps the probe honest -- otherwise `podman run --network gitea`
|
|
# fails and the probe wrongly concludes the metadata server is unreachable.
|
|
# stdout is discarded because this function's stdout IS the return value.
|
|
podman network create --ignore \
|
|
--subnet "${PODMAN_SUBNET}" --gateway "${PODMAN_GATEWAY}" gitea >/dev/null 2>&1 || true
|
|
|
|
local mode=host
|
|
if podman run --rm --network gitea --entrypoint curl "${IMAGE_GITEA}" \
|
|
-fsS -m 10 -H 'Metadata-Flavor: Google' \
|
|
http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token \
|
|
>/dev/null 2>&1; then
|
|
mode=bridge
|
|
else
|
|
warn "metadata server unreachable from the podman bridge -- Caddy will use the host network"
|
|
fi
|
|
mkdir -p /etc/gitea
|
|
echo "${mode}" > "${cache}"
|
|
echo "${mode}"
|
|
}
|
|
|
|
render_all() {
|
|
log "rendering configuration"
|
|
mkdir -p /etc/gitea /etc/caddy "${RENDER_DIR}"
|
|
|
|
local secret_key internal_token oauth2_jwt lfs_jwt
|
|
if ! secret_key=$(fetch_or_create_secret gitea-secret-key) \
|
|
|| ! internal_token=$(fetch_or_create_secret gitea-internal-token) \
|
|
|| ! oauth2_jwt=$(fetch_or_create_secret gitea-oauth2-jwt-secret) \
|
|
|| ! lfs_jwt=$(fetch_or_create_secret gitea-lfs-jwt-secret); then
|
|
# Deliberately NOT a failure: on first boot the image does not exist yet
|
|
# and the secrets may not be populated. Writing an app.ini with empty
|
|
# SECRET_KEY/INTERNAL_TOKEN would be far worse than doing nothing --
|
|
# Gitea would come up with broken sessions and tokens.
|
|
warn "Gitea secrets unavailable -- skipping config render (will retry on the next sync)"
|
|
return 0
|
|
fi
|
|
|
|
local caddy_mode caddy_network caddy_publish caddy_sysctl gitea_upstream
|
|
caddy_mode=$(probe_caddy_network)
|
|
if [[ "${caddy_mode}" == "host" ]]; then
|
|
caddy_network="host"
|
|
caddy_publish="# Network=host: ports are bound directly, publishing would be invalid."
|
|
caddy_sysctl="# Network=host: podman rejects net.* sysctls; set on the host instead."
|
|
gitea_upstream="127.0.0.1:3000"
|
|
# Container-local sysctls are unavailable on the host network, so allow
|
|
# unprivileged binds to 80/443 host-wide. Narrower than granting the
|
|
# container CAP_NET_BIND_SERVICE.
|
|
echo 'net.ipv4.ip_unprivileged_port_start = 80' > /etc/sysctl.d/90-gitea-caddy.conf
|
|
sysctl -q -p /etc/sysctl.d/90-gitea-caddy.conf || warn "could not apply unprivileged port sysctl"
|
|
else
|
|
caddy_network="gitea.network"
|
|
caddy_publish=$'PublishPort=80:80\nPublishPort=443:443\nPublishPort=443:443/udp'
|
|
caddy_sysctl="Sysctl=net.ipv4.ip_unprivileged_port_start=0"
|
|
gitea_upstream="gitea:3000"
|
|
rm -f /etc/sysctl.d/90-gitea-caddy.conf
|
|
fi
|
|
|
|
# The ACME contact address lives in Secret Manager rather than instance
|
|
# metadata, to keep it out of the public repository. Without it Caddy still
|
|
# issues certificates, just under an account with no contact address -- far
|
|
# better than an empty `email` directive, which fails to parse and leaves
|
|
# nothing serving TLS.
|
|
local acme_email caddy_email
|
|
if acme_email=$(gcloud secrets versions access latest --secret=gitea-acme-email \
|
|
--project="${GCP_PROJECT}" 2>/dev/null) && [[ -n "${acme_email}" ]]; then
|
|
caddy_email="email ${acme_email}"
|
|
else
|
|
warn "secret gitea-acme-email unreadable -- Caddy will register without a contact address"
|
|
caddy_email="# no ACME contact address: secret gitea-acme-email was unreadable at render time"
|
|
fi
|
|
|
|
# Trust both the bridge CIDR and loopback so this value stays correct in
|
|
# either Caddy networking mode. Rootful podman SNATs host-loopback traffic
|
|
# to the bridge gateway, so the CIDR covers the host-network case too.
|
|
local trusted_proxies="${PODMAN_SUBNET},127.0.0.1/32"
|
|
|
|
local changed=0
|
|
|
|
export GITEA_SECRET_KEY="${secret_key}" \
|
|
GITEA_INTERNAL_TOKEN="${internal_token}" \
|
|
GITEA_OAUTH2_JWT_SECRET="${oauth2_jwt}" \
|
|
GITEA_LFS_JWT_SECRET="${lfs_jwt}" \
|
|
TRUSTED_PROXIES="${trusted_proxies}" \
|
|
GITEA_UPSTREAM="${gitea_upstream}" \
|
|
CADDY_NETWORK="${caddy_network}" \
|
|
CADDY_PUBLISH_PORTS="${caddy_publish}" \
|
|
CADDY_SYSCTL="${caddy_sysctl}" \
|
|
CADDY_EMAIL="${caddy_email}"
|
|
|
|
# app.ini is 0400 owned by uid 1000: it holds SECRET_KEY and INTERNAL_TOKEN,
|
|
# and the container runs as that uid and must be able to read it.
|
|
render "${STATE_DIR}/config/app.ini.tmpl" /etc/gitea/app.ini 1000:1000 0400 \
|
|
'${APP_NAME} ${DOMAIN} ${GITEA_SECRET_KEY} ${GITEA_INTERNAL_TOKEN} ${GITEA_OAUTH2_JWT_SECRET} ${GITEA_LFS_JWT_SECRET} ${TRUSTED_PROXIES} ${REQUIRE_SIGNIN_VIEW}' \
|
|
&& changed=1
|
|
|
|
render "${STATE_DIR}/config/Caddyfile.tmpl" /etc/caddy/Caddyfile root:root 0644 \
|
|
'${DOMAIN} ${CADDY_EMAIL} ${GITEA_UPSTREAM} ${WAF_MODE}' \
|
|
&& changed=1
|
|
|
|
local unit
|
|
for unit in gitea.network gitea.container caddy.container caddy-data.volume caddy-config.volume; do
|
|
render "${STATE_DIR}/quadlets/${unit}" "${RENDER_DIR}/${unit}" root:root 0644 \
|
|
'${IMAGE_GITEA} ${IMAGE_CADDY} ${PODMAN_SUBNET} ${PODMAN_GATEWAY} ${GCP_PROJECT} ${CADDY_NETWORK} ${CADDY_PUBLISH_PORTS} ${CADDY_SYSCTL}' \
|
|
&& changed=1
|
|
done
|
|
|
|
for unit in "${STATE_DIR}"/systemd/*; do
|
|
[[ -f "${unit}" ]] || continue
|
|
local base; base=$(basename "${unit}")
|
|
if ! cmp -s "${unit}" "/etc/systemd/system/${base}"; then
|
|
install -m 0644 "${unit}" "/etc/systemd/system/${base}"
|
|
log "installed unit ${base}"
|
|
changed=1
|
|
fi
|
|
done
|
|
|
|
unset GITEA_SECRET_KEY GITEA_INTERNAL_TOKEN GITEA_OAUTH2_JWT_SECRET GITEA_LFS_JWT_SECRET
|
|
|
|
systemctl daemon-reload
|
|
if (( changed )); then
|
|
log "configuration changed -- restarting services"
|
|
systemctl restart gitea.service || warn "gitea did not restart cleanly"
|
|
systemctl restart caddy.service || warn "caddy did not restart cleanly"
|
|
else
|
|
log "configuration unchanged"
|
|
fi
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# fail2ban
|
|
# ---------------------------------------------------------------------------
|
|
# Runs on every invocation, not just full boots: the jail's enabled flag is
|
|
# derived from WAF_MODE, and that has to be re-applied whenever the mode changes.
|
|
setup_fail2ban() {
|
|
command -v fail2ban-server >/dev/null 2>&1 || { warn "fail2ban not installed; skipping"; return 0; }
|
|
log "configuring fail2ban"
|
|
install -m 0644 "${STATE_DIR}/fail2ban/action.d/nft-prerouting.conf" /etc/fail2ban/action.d/
|
|
install -m 0644 "${STATE_DIR}/fail2ban/filter.d/gitea.conf" /etc/fail2ban/filter.d/
|
|
install -m 0644 "${STATE_DIR}/fail2ban/filter.d/caddy-coraza.conf" /etc/fail2ban/filter.d/
|
|
|
|
# One config value drives both halves: the WAF only blocks in On, and only
|
|
# then is it safe to escalate a WAF verdict into an nftables ban. In
|
|
# DetectionOnly the jail is inert so tuning cannot lock anyone out.
|
|
local coraza_enabled=false
|
|
[[ "${WAF_MODE}" == "On" ]] && coraza_enabled=true
|
|
export CORAZA_JAIL_ENABLED="${coraza_enabled}"
|
|
log "coraza fail2ban jail enabled=${coraza_enabled} (waf-mode=${WAF_MODE})"
|
|
|
|
envsubst '${PODMAN_SUBNET} ${CORAZA_JAIL_ENABLED}' \
|
|
< "${STATE_DIR}/fail2ban/jail.d/gitea.local" > /etc/fail2ban/jail.d/gitea.local
|
|
chmod 0644 /etc/fail2ban/jail.d/gitea.local
|
|
systemctl enable --now fail2ban
|
|
systemctl reload fail2ban || systemctl restart fail2ban
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Automatic updates
|
|
# ---------------------------------------------------------------------------
|
|
setup_auto_updates() {
|
|
log "configuring automatic updates"
|
|
install -m 0644 "${STATE_DIR}/dnf/automatic.conf" /etc/dnf/automatic.conf
|
|
|
|
# AlmaLinux 10 ships dnf5, where the unit is dnf5-automatic.timer -- but the
|
|
# package providing it has moved around between releases, so resolve it
|
|
# instead of hardcoding a name that may not exist.
|
|
local timer=""
|
|
for candidate in dnf5-automatic.timer dnf-automatic.timer; do
|
|
if systemctl list-unit-files "${candidate}" >/dev/null 2>&1 \
|
|
&& systemctl cat "${candidate}" >/dev/null 2>&1; then
|
|
timer="${candidate}"; break
|
|
fi
|
|
done
|
|
if [[ -z "${timer}" ]]; then
|
|
log "no dnf automatic timer present -- installing provider"
|
|
dnf -y install "$(dnf -q provides '*/dnf5-automatic.timer' 2>/dev/null | awk 'NR==1{print $1}')" \
|
|
|| dnf -y install dnf-automatic \
|
|
|| warn "could not install a dnf-automatic provider"
|
|
for candidate in dnf5-automatic.timer dnf-automatic.timer; do
|
|
systemctl cat "${candidate}" >/dev/null 2>&1 && { timer="${candidate}"; break; }
|
|
done
|
|
fi
|
|
[[ -n "${timer}" ]] && systemctl enable --now "${timer}" || warn "no dnf automatic timer enabled"
|
|
|
|
systemctl enable --now podman-auto-update.timer
|
|
}
|
|
|
|
enable_units() {
|
|
log "enabling units"
|
|
systemctl daemon-reload
|
|
systemctl enable --now gitea-ar-auth.timer
|
|
systemctl enable --now gitea-backup.timer
|
|
systemctl enable --now gitea-reboot-window.timer
|
|
# Quadlet-generated units are not "enabled" in the usual sense -- the
|
|
# [Install] section is honoured by the generator at daemon-reload time.
|
|
systemctl start gitea.service || warn "gitea not started yet (expected before the first image build)"
|
|
systemctl start caddy.service || warn "caddy not started yet (expected before the first image build)"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main
|
|
# ---------------------------------------------------------------------------
|
|
main() {
|
|
log "starting (mode=${MODE})"
|
|
|
|
if [[ "${MODE}" == "full" ]]; then
|
|
install_packages
|
|
setup_data_disk
|
|
setup_swap
|
|
configure_podman
|
|
install_helpers
|
|
fi
|
|
|
|
sync_config
|
|
|
|
# Make the synced copy the canonical one, so gitea-config-sync.service always
|
|
# runs the version that matches the config in the bucket.
|
|
#
|
|
# Under --sync-only this file IS the script bash is currently reading. GNU
|
|
# install truncates in place and bash reads scripts incrementally, so a
|
|
# naive copy can rewrite the interpreter's input mid-execution -- exactly in
|
|
# the case this mechanism exists for (a vm/bootstrap.sh change). Skip when
|
|
# identical, and otherwise replace via atomic rename onto a fresh inode so
|
|
# the running process keeps reading the old one.
|
|
if ! cmp -s "${STATE_DIR}/bootstrap.sh" /usr/local/sbin/gitea-bootstrap; then
|
|
install -m 0755 "${STATE_DIR}/bootstrap.sh" /usr/local/sbin/.gitea-bootstrap.new
|
|
mv -f /usr/local/sbin/.gitea-bootstrap.new /usr/local/sbin/gitea-bootstrap
|
|
log "updated /usr/local/sbin/gitea-bootstrap"
|
|
fi
|
|
|
|
if [[ "${MODE}" == "full" ]]; then
|
|
install_ar_auth
|
|
fi
|
|
|
|
# setup_nftables and setup_fail2ban run in BOTH modes, deliberately.
|
|
#
|
|
# They apply configuration that lives in the vm/ tree, so gating them on a
|
|
# full boot would mean a pushed change never takes effect until the next
|
|
# reboot. The fail2ban case is the dangerous one: flipping gitea:wafMode to
|
|
# On re-renders the Caddyfile through render_all and Caddy starts issuing
|
|
# 403s, but the jail that acts on them would stay enabled=false -- a WAF
|
|
# that blocks and a ban that never happens, with nothing in the logs to say
|
|
# so. Both functions are idempotent and self-validating (`nft -c` before
|
|
# load, `command -v fail2ban-server` before touching fail2ban).
|
|
setup_nftables
|
|
setup_fail2ban
|
|
|
|
render_all
|
|
|
|
if [[ "${MODE}" == "full" ]]; then
|
|
setup_auto_updates
|
|
enable_units
|
|
fi
|
|
|
|
log "done"
|
|
}
|
|
|
|
main "$@"
|