clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

main
forge / server / container / start.sh
start.sh 8.4K executable
1#!/bin/sh2# Container entrypoint. /srv/git is a persistent volume mounted over the git3# user's home, so state is initialized here on first boot and refreshed from4# the environment on every boot:5# FORGE_DOMAIN name for pages and git@ (e.g. src.rest)6# FORGE_PAGES_DOMAIN separate origin for sites (optional)7# FORGE_OWNER, FORGE_OWNER_KEY first user and their public key8# FORGE_TAILCAT_ALLOW comma-separated tailcat client keys (nodekey:...)9# allowed to reach sshd and Caddy through tailcat10# TYPESAFE_API_KEY for hooks and builds (written to .forge/secrets)11# FORGE_BACKUP_*, FORGE_MIRROR_* backups, identity.db's litestream replica12# (FORGE_LITESTREAM_PATH, default litestream/identity)13# and the pages mirror (written to .forge/backup.env;14# README.md, "Backups and moving hosts")15# FORGE_LAKE_* the lake's data on R2 instead of the volume (also16# .forge/backup.env; README.md, "The lake and insights")17set -eu18root=/srv/git19forge=/opt/forge/bin/forge20domain=${FORGE_DOMAIN:-localhost}21pages=${FORGE_PAGES_DOMAIN:-}22as_git() { runuser -u git -- env FORGE_ROOT=$root "$@"; }2324chown git:git $root25chmod 750 $root2627# Host keys live on the volume so clients see the same key across deploys.28mkdir -p $root/.hostkeys29chmod 700 $root/.hostkeys30for t in ed25519 ecdsa rsa; do31 [ -f $root/.hostkeys/ssh_host_${t}_key ] || ssh-keygen -q -t $t -N '' -f $root/.hostkeys/ssh_host_${t}_key32done3334# Locks on the volume outlive a container killed mid-job, and nothing runs35# yet, so clear them.36rm -rf $root/.forge/build.lock $root/.forge/backup.lock $root/.forge/mirror.lock3738# A box without identity.db (a new one) takes it from litestream's replica,39# before forge server init makes an empty one: replicating that would bury40# the replica's. If the replica can't be read, nothing replicates this boot.41replicate=042if [ -n "${FORGE_BACKUP_REPOSITORY:-}" ] && command -v litestream >/dev/null 2>&1; then43 mkdir -p $root/.forge44 chown git:git $root/.forge45 if as_git $forge identity restore --if-missing; then replicate=146 else echo "start: identity.db could not be restored from litestream, so it isn't replicated until the next boot" >&2; fi47fi4849as_git $forge server init >/dev/null 2>&15051sites=/sites/{owner}/{repo}/{site}/52previews=/previews/{owner}/{repo}/{site}/{branch}/53if [ -n "$pages" ]; then54 sites=https://$pages$sites55 previews=https://$pages$previews56fi57as_git sh -c "cat >$root/.forge/config" <<EOF58FORGE_NAME=${FORGE_NAME:-forge}59FORGE_BASE_URL=https://$domain60FORGE_SSH=git@$domain61FORGE_SITES_URL=$sites62FORGE_PREVIEWS_URL=$previews63FORGE_PAGES_DOMAIN=$pages64FORGE_OWNER=${FORGE_OWNER:-}65EOF6667# Hooks run under sshd and don't inherit this environment. Secrets go in a68# file only git can read, not in config, which the pages server also loads.69as_git sh -c "umask 077; cat >$root/.forge/secrets" <<EOF70TYPESAFE_API_KEY=${TYPESAFE_API_KEY:-}71EOF7273# Backup and mirror settings for forge backup and forge mirror. A file of74# their own, for git and root only, that backups leave out. Values are75# quoted, since the restic password can be anything.76q() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; }77as_git sh -c "umask 077; cat >$root/.forge/backup.env" <<EOF78FORGE_BACKUP_REPOSITORY=$(q "${FORGE_BACKUP_REPOSITORY:-}")79FORGE_BACKUP_PASSWORD=$(q "${FORGE_BACKUP_PASSWORD:-}")80FORGE_BACKUP_KEY_ID=$(q "${FORGE_BACKUP_KEY_ID:-}")81FORGE_BACKUP_SECRET=$(q "${FORGE_BACKUP_SECRET:-}")82FORGE_BACKUP_REGION=$(q "${FORGE_BACKUP_REGION:-}")83FORGE_BACKUP_KEEP_DAILY=$(q "${FORGE_BACKUP_KEEP_DAILY:-}")84FORGE_BACKUP_KEEP_WEEKLY=$(q "${FORGE_BACKUP_KEEP_WEEKLY:-}")85FORGE_LITESTREAM_PATH=$(q "${FORGE_LITESTREAM_PATH:-}")86FORGE_MIRROR_S3=$(q "${FORGE_MIRROR_S3:-}")87FORGE_MIRROR_KEY_ID=$(q "${FORGE_MIRROR_KEY_ID:-}")88FORGE_MIRROR_SECRET=$(q "${FORGE_MIRROR_SECRET:-}")89FORGE_MIRROR_PROVIDER=$(q "${FORGE_MIRROR_PROVIDER:-}")90FORGE_MIRROR_REGION=$(q "${FORGE_MIRROR_REGION:-}")91FORGE_MIRROR_DELAY=$(q "${FORGE_MIRROR_DELAY:-}")92FORGE_MIRROR_MIN_INTERVAL=$(q "${FORGE_MIRROR_MIN_INTERVAL:-}")93FORGE_LAKE_DATA=$(q "${FORGE_LAKE_DATA:-}")94FORGE_LAKE_KEY_ID=$(q "${FORGE_LAKE_KEY_ID:-}")95FORGE_LAKE_SECRET=$(q "${FORGE_LAKE_SECRET:-}")96FORGE_LAKE_ENDPOINT=$(q "${FORGE_LAKE_ENDPOINT:-}")97FORGE_LAKE_REGION=$(q "${FORGE_LAKE_REGION:-}")98EOF99100if [ -n "${FORGE_OWNER:-}" ] && [ -n "${FORGE_OWNER_KEY:-}" ] && ! grep -q "^$FORGE_OWNER " $root/.forge/keys; then101 printf '%s\n' "$FORGE_OWNER_KEY" | as_git $forge user add "$FORGE_OWNER" -102fi103as_git $forge keys sync104105# TLS is terminated in front of us; Caddy serves plain HTTP on 8080. The106# config is written anew on every boot, so Caddy keeps no copy of it.107{108 # Requests come from the edge through tailcat, which connects from109 # loopback; Fly's proxy in front of the edge puts the client's address110 # in Fly-Client-IP, which {client_ip} is then (X-Forge-Client-IP,111 # lib/limits.ts). Nothing else reaches Caddy here.112 printf '{\n\tauto_https off\n\thttp_port 8080\n\tadmin off\n\tpersist_config off\n\tservers {\n\t\ttrusted_proxies static 127.0.0.1/8 ::1/128\n\t\tclient_ip_headers Fly-Client-IP\n\t}\n}\n\n'113 as_git $forge server caddyfile :8080 "$pages" "${pages:+http://$pages}"114} >/etc/caddy/Caddyfile115116mkdir -p /run/sshd117/usr/sbin/sshd118119# git http-backend (smart HTTP) runs as git; Caddy reaches the socket via120# the git group.121mkdir -p /run/fcgiwrap122chown git:git /run/fcgiwrap123chmod 750 /run/fcgiwrap124rm -f /run/fcgiwrap/sock125runuser -u git -- sh -c 'umask 007; exec fcgiwrap -c 4 -s unix:/run/fcgiwrap/sock' &126until [ -S /run/fcgiwrap/sock ]; do sleep 0.1; done127ln -sf /run/fcgiwrap/sock /run/fcgiwrap.socket128129# Publish sshd and Caddy to the edge over tailcat (WireGuard, no inbound130# ports). The server key is created once and kept on the volume, so the131# address is stable; only the listed client keys can connect.132if [ -n "${FORGE_TAILCAT_ALLOW:-}" ]; then133 tc=$root/.tailcat134 mkdir -p $tc135 chmod 700 $tc136 [ -f $tc/server.private.json ] || tailcat genkey --key=$tc/server.private.json --fixed-region >/dev/null137 TAILCAT_ADDR_FILE=$tc/address tailcat --key=$tc/server.private.json serve --allow="$FORGE_TAILCAT_ALLOW" 2222,8080 &138fi139140# Live page updates (server-sent events), behind Caddy at /events/.141touch $root/.forge/events.jsonl142chown git:git $root/.forge/events.jsonl143runuser -u git -- env FORGE_STATE=$root/.forge node /opt/forge/lib/events-server.ts >>$root/.forge/events.log 2>&1 &144145# identity.db, copied to the backup bucket as it changes (forge identity).146if [ "$replicate" = 1 ]; then147 as_git sh -c "exec $forge identity replicate" >>$root/.forge/litestream.log 2>&1 &148fi149150# Private repos' pages for their readers, and the 404 any other missing page151# gets: Caddy hands it every path www has no file for. Runs as git, which152# owns the private pages and identity.db.153runuser -u git -- env FORGE_ROOT=$root sh -c "set -a; . $root/.forge/config; exec node /opt/forge/lib/private-server.ts" >>$root/.forge/private.log 2>&1 &154155# Repo sites, each on its own hostname under the pages domain, behind Caddy.156# Runs as caddy: it only reads deploys, which are untrusted content.157if [ -n "$pages" ]; then158 runuser -u caddy -- env FORGE_ROOT=$root sh -c "set -a; . $root/.forge/config; exec node /opt/forge/lib/pages-server.ts" &159fi160161# Refresh pages in the background (templates may have changed with the image).162as_git sh -c "FORGE_BUILD_REASON=boot $forge build >>$root/.forge/build.log 2>&1" &163164# Daily backups at FORGE_BACKUP_AT (HH:MM, UTC), checked every minute: one165# runs once that time has passed and today's hasn't succeeded, so a box that166# was off catches up after it boots, and a failed one is retried hourly. A167# loop, not cron: nothing more to install or supervise. Runs as root, like168# forge-admin backup, and reports to the container log.169if [ -n "${FORGE_BACKUP_REPOSITORY:-}" ]; then170 at=$(printf '%s' "${FORGE_BACKUP_AT:-03:30}" | tr -d :)171 case $at in [0-2][0-9][0-5][0-9]) ;; *) echo "start: FORGE_BACKUP_AT wants HH:MM; using 03:30" >&2; at=0330 ;; esac172 (173 set +e174 retry=0175 while sleep 60; do176 [ "$(date -u +%H%M)" -ge "$at" ] || continue177 [ "$(sed -n 's/^ok_day=//p' $root/.forge/backup.status 2>/dev/null)" != "$(date -u +%Y-%m-%d)" ] || continue178 [ "$(date +%s)" -ge "$retry" ] || continue179 FORGE_ROOT=$root $forge backup >/dev/null 2>&1 || retry=$(($(date +%s) + 3600))180 FORGE_ROOT=$root $forge backups --status | sed 's/^/backup: /'181 done182 ) &183fi184185exec runuser -u caddy -- caddy run --config /etc/caddy/Caddyfile --adapter caddyfile