#!/usr/bin/env bash # # deploy.sh -- deploy Ergo (IRC server) behind Caddy (Let's Encrypt) on Alpine, # Debian or Alma. Single-node; runs as root. # # What this does: # 1. Installs docker + compose (and openssl/jq/curl) if missing; creates the # `ergo` system user the container runs as. # 2. Lays down the stack in $STACK_DIR: docker-compose.yml, caddy/etc/Caddyfile, # update.sh, ergoctl (+ /usr/local/bin/ergoctl wrapper), ergolib.sh. # 3. Seeds .env on first run, pinning ERGO_TAG to the newest release. # 4. Generates ircd/ircd.yaml ONCE from the pulled image's own default config # (server name, network name, listeners, websocket origin, message-history # backend, admin oper with a random password saved to secrets/admin.pass) # and validates it with a throwaway `ergo run --smoke` before anything # starts. Never rewritten. # 5. Opens 80/443/6697 (+6667 with PLAINTEXT=1) on the host firewall. Both # containers use host networking, so this firewall really applies. # 6. Pulls images, validates the Caddyfile, brings the stack up, waits for # health, copies Caddy's Let's Encrypt cert into ircd/ and rehashes Ergo. # 7. Schedules update.sh: certsync every 15 min, update check daily. # # Idempotent: re-run to apply compose/Caddyfile changes or pull images. It never # touches ircd/ircd.yaml, secrets/ or caddy/etc/conf.d/; in .env it only refreshes # ERGO_UID/GID, re-pins a floating ERGO_TAG, and writes back settings you passed # explicitly on this run (everything else is adopted FROM .env, not overwritten). # # Self-contained: docker-compose.yml, docker-compose.postgres.yml, Caddyfile, # conf.d-readme.caddy, ergo.motd, .env.example, ergolib.sh, update.sh and ergoctl # are embedded as a base64 tar.gz at the bottom of this file. Rebuild with # build.sh after editing them. # # Usage: # bash deploy.sh # interactive prompts # ERGO_DOMAIN=irc.example.com ACME_EMAIL=me@x.com NETWORK_NAME=MyNet \ # SKIP_PROMPTS=1 bash deploy.sh # non-interactive # HISTORY=postgres bash deploy.sh # FIRST deploy only: persistent history in # # PostgreSQL (default sqlite; off = RAM only) # HISTORY_EXPIRE=90d bash deploy.sh # how long messages are kept (default 30d) # PLAINTEXT=1 bash deploy.sh # FIRST deploy only: also serve plaintext # # :6667 publicly (+STS). Fixed thereafter. # ERGO_TAG=v2.19.1 bash deploy.sh # pin a release on the FIRST deploy; after # # that use: ergoctl update update # UPDATE_POLICY=security bash deploy.sh # also latest | off # ERGO_AUTOUPDATE=0 bash deploy.sh # install update.sh but don't schedule the # # daily update (certsync is still scheduled) # CERT_WAIT=0 bash deploy.sh # don't wait for the first LE cert # SKIP_DOCKER_INSTALL=1 bash deploy.sh set -euo pipefail : "${STACK_DIR:=/srv/ergo}" : "${SKIP_DOCKER_INSTALL:=0}" : "${FORCE:=0}" : "${SKIP_PROMPTS:=0}" [[ "$SKIP_PROMPTS" == "1" ]] && FORCE=1 : "${ERGO_DOMAIN:=}" : "${ACME_EMAIL:=}" : "${NETWORK_NAME:=}" : "${ERGO_IMAGE:=ghcr.io/ergochat/ergo}" : "${ERGO_TAG:=}" # blank = pin the newest release # Remember which settings the caller actually passed, BEFORE the defaults below # hide that. On a re-run the rest come from the existing .env instead, so a # plain `bash deploy.sh` never silently reverts a deployed stack's settings. # (`|| true` on the trailing `&&`s below: a false test makes the whole list # return 1, which aborts the script under `set -e` if it ever ends a block.) EXPLICIT="" for _k in ERGO_TAG PLAINTEXT HISTORY UPDATE_POLICY FORCE_UPDATE UPDATE_GRACE CADDY_AUTOUPDATE CADDY_TAG; do [[ -n "${!_k:-}" ]] && EXPLICIT="${EXPLICIT} ${_k}" || true done : "${PLAINTEXT:=0}" : "${HISTORY:=sqlite}" # sqlite | postgres | off (see the README) # How long messages are kept. Upstream's 1w applies to PERSISTENT storage too -- # it deletes, it is not just a query cutoff -- so persistence with the shipped # default buys only a week. Retention is a policy (and privacy) decision: raise # or lower it here, or later with 'ergoctl edit'. : "${HISTORY_EXPIRE:=30d}" : "${POSTGRES_TAG:=17-alpine}" # major version is PINNED; update.sh never moves it : "${POSTGRES_USER:=ergo}" : "${POSTGRES_DB:=ergo_history}" : "${POSTGRES_PORT:=5432}" : "${UPDATE_POLICY:=latest}" : "${FORCE_UPDATE:=0}" : "${UPDATE_GRACE:=60}" : "${CADDY_AUTOUPDATE:=0}" : "${CADDY_TAG:=2-alpine}" : "${ERGO_AUTOUPDATE:=1}" # 0 = install update.sh but don't schedule the daily update : "${CERT_WAIT:=180}" # seconds to wait for Caddy's first Let's Encrypt cert log() { printf '\033[1;32m[+]\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m[!]\033[0m %s\n' "$*" >&2; } die() { printf '\033[1;31m[x]\033[0m %s\n' "$*" >&2; exit 1; } [[ $EUID -eq 0 ]] || die "Run as root." # --------------------------------------------------------------------------- # Extract the embedded archive; ergolib.sh (shared helpers) comes from it. # --------------------------------------------------------------------------- SCRIPT_DIR=$(mktemp -d -t ergo-deploy.XXXXXX) trap 'rm -rf "$SCRIPT_DIR"' EXIT extract_archive() { grep -a -A 9999999 '^__ARCHIVE_BELOW__$' "$0" \ | tail -n +2 \ | base64 -d \ | tar -xz -C "$SCRIPT_DIR" } if grep -q -a '^__ARCHIVE_BELOW__$' "$0"; then log "Extracting embedded deployment files..." extract_archive else die "No embedded archive found. Run build.sh to embed deployment files." fi EMBEDDED=(docker-compose.yml docker-compose.postgres.yml Caddyfile conf.d-readme.caddy ergo.motd .env.example ergolib.sh update.sh ergoctl) for f in "${EMBEDDED[@]}"; do [[ -f "$SCRIPT_DIR/$f" ]] || die "Embedded archive missing $f" done # shellcheck source=ergolib.sh . "$SCRIPT_DIR/ergolib.sh" ergo_set_paths # --------------------------------------------------------------------------- # Host packages + Docker (deploy-only helpers; ergolib has the rest) # --------------------------------------------------------------------------- pkg_install() { # best-effort install across the three families case "$(osfam)" in alpine) apk add -q "$@" || true ;; debian) DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$@" || true ;; rhel) dnf install -y -q "$@" || true ;; esac } install_docker() { if command -v docker >/dev/null 2>&1; then log "Docker already installed: $(docker --version)" else log "Installing Docker (OS: $(osfam))..." case "$(osfam)" in alpine) apk add -q docker docker-cli-compose openrc ;; debian|rhel) command -v curl >/dev/null 2>&1 || pkg_install curl curl -fsSL https://get.docker.com | sh ;; *) die "Unsupported OS for auto Docker install. Set SKIP_DOCKER_INSTALL=1 and install Docker yourself." ;; esac fi if command -v rc-update >/dev/null 2>&1; then rc-update add docker default >/dev/null 2>&1 || true rc-service docker status >/dev/null 2>&1 || rc-service docker start elif command -v systemctl >/dev/null 2>&1; then systemctl enable --now docker >/dev/null 2>&1 || systemctl start docker || true fi # dockerd may return before its socket listens (esp. OpenRC); don't race it. local i for i in $(seq 1 30); do docker info >/dev/null 2>&1 && return sleep 1 done warn "Docker daemon not ready after 30s; continuing (compose may fail -- check 'docker info')." } # The container runs as this host user (no login, no home). It owns ./ircd so # the DB, config and the copied TLS key are never readable by other local users. ensure_ergo_user() { if ! getent passwd ergo >/dev/null 2>&1; then log "Creating system user 'ergo' (owns $IRCD_DIR; the container runs as it)..." case "$(osfam)" in alpine) getent group ergo >/dev/null 2>&1 || addgroup -S ergo adduser -S -D -H -G ergo -s /sbin/nologin -g "Ergo IRC server" ergo ;; *) useradd -r -M -s /usr/sbin/nologin -d /nonexistent -c "Ergo IRC server" ergo 2>/dev/null \ || useradd -r -M -s /sbin/nologin -c "Ergo IRC server" ergo ;; esac fi ERGO_UID="$(id -u ergo)"; ERGO_GID="$(id -g ergo)" } open_ports() { # Host networking: these ports are bound on the host itself, so the INPUT # firewall genuinely governs them (unlike Docker-published ports). local ports=(80/tcp 443/tcp 6697/tcp) [[ "$PLAINTEXT" == "1" ]] && ports+=(6667/tcp) # ufw and firewalld are additive: unlike the repo's declarative ports.d # engine they never drop a port that left the list, so turning PLAINTEXT # back off would leave 6667 open. Revoke it explicitly. if [[ "$PLAINTEXT" != "1" ]]; then if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q '^Status: active'; then ufw status 2>/dev/null | grep -q '^6667/tcp' && { log "Closing 6667/tcp in ufw (PLAINTEXT=0)..."; ufw delete allow 6667/tcp >/dev/null 2>&1 || true; } elif command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then if firewall-cmd --query-port=6667/tcp --permanent >/dev/null 2>&1; then log "Closing 6667/tcp in firewalld (PLAINTEXT=0)..." firewall-cmd -q --remove-port=6667/tcp --permanent || true; firewall-cmd -q --reload || true fi fi fi if [[ -d /etc/firewall/ports.d && -x /usr/local/sbin/firewall-apply ]]; then log "Registering ${ports[*]} with the host firewall..." printf '%s\n' "${ports[@]}" > /etc/firewall/ports.d/ergo.rule /usr/local/sbin/firewall-apply elif command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q '^Status: active'; then log "ufw active -- allowing ${ports[*]}..." local p; for p in "${ports[@]}"; do ufw allow "$p" >/dev/null; done elif command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then log "firewalld active -- allowing ${ports[*]}..." local p; for p in "${ports[@]}"; do firewall-cmd -q --add-port="$p" --permanent; done firewall-cmd -q --reload else warn "No managed host firewall found; make sure ${ports[*]} are reachable (and nothing else is)." fi } # Anything already listening on our host ports (only checked while our own # containers are not running -- on re-runs they hold the ports themselves). check_ports_free() { local listing p busy="" listing="$(netstat -ltn 2>/dev/null || ss -ltn 2>/dev/null || true)" [[ -n "$listing" ]] || return 0 for p in 80 443 6697 8097 6667; do if printf '%s\n' "$listing" | grep -qE "[:.]${p}[[:space:]]"; then busy="${busy} ${p}"; fi done [[ -z "$busy" ]] || die "Ports already in use on this host:${busy}. Both containers use host networking, so this stack needs 80/443 (Caddy), 6697/6667/8097 (Ergo) to itself. Another Caddy stack on this box? Move it or this." } # --------------------------------------------------------------------------- # Prompt for required vars, then normalise + validate them # --------------------------------------------------------------------------- prompt() { # [default] local varname="$1" message="$2" def="${3:-}" local -n ref="$varname" if [[ -z "${ref:-}" ]]; then if [[ "$SKIP_PROMPTS" == "1" ]]; then [[ -n "$def" ]] && { ref="$def"; return 0; } die "$varname required (set it in the environment; running with SKIP_PROMPTS=1)." fi if [[ -n "$def" ]]; then read -r -p "$message [$def]: " ref; ref="${ref:-$def}" else read -r -p "$message: " ref; fi [[ -n "$ref" ]] || die "$varname required." fi } prompt ERGO_DOMAIN "Public hostname of the IRC server (e.g. irc.example.com)" ERGO_DOMAIN="$(printf '%s' "$ERGO_DOMAIN" | tr 'A-Z' 'a-z' | sed -E 's#^https?://##; s#[/.]+$##')" [[ "$ERGO_DOMAIN" =~ ^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$ ]] \ || die "ERGO_DOMAIN '$ERGO_DOMAIN' is not a valid hostname." # On a re-run these must default to what is DEPLOYED, not to a fresh guess: # otherwise pressing Enter through the prompts silently renames the network to the # hostname in the summary while .env keeps the real one. Test for emptiness -- # env_get succeeds even when the key is absent. _ae_default="$(env_get ACME_EMAIL)" _nn_default="$(env_get NETWORK_NAME)" [[ -n "$_nn_default" ]] || _nn_default="$ERGO_DOMAIN" prompt ACME_EMAIL "Let's Encrypt email" "$_ae_default" prompt NETWORK_NAME "IRC network name (no spaces)" "$_nn_default" [[ "$NETWORK_NAME" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$ ]] \ || die "NETWORK_NAME '$NETWORK_NAME' must be 1-64 chars of letters, digits, . _ - (no spaces: it is sent as an IRC parameter)." [[ "$PLAINTEXT" == "0" || "$PLAINTEXT" == "1" ]] || die "PLAINTEXT must be 0 or 1." case "$UPDATE_POLICY" in latest|security|off) ;; *) die "UPDATE_POLICY must be latest, security or off." ;; esac case "$HISTORY" in sqlite|postgres|off) ;; *) die "HISTORY must be sqlite, postgres or off." ;; esac # Re-run guard: server.name is immutable once Ergo has run with it, and the # cert/websocket origin/Caddy site all derive from ERGO_DOMAIN. if [[ -f "$IRCD_DIR/ircd.yaml" ]]; then existing="$(yaml_server_name "$IRCD_DIR/ircd.yaml" || true)" if [[ -n "$existing" && "$existing" != "$ERGO_DOMAIN" ]]; then die "This stack is already deployed as '$existing' (ircd/ircd.yaml server.name) but ERGO_DOMAIN=$ERGO_DOMAIN. Ergo cannot rename a running server; re-run with ERGO_DOMAIN=$existing, or stop the stack, move $STACK_DIR away and deploy fresh." fi fi # --------------------------------------------------------------------------- # Re-run: adopt the deployed settings from .env # # Everything below (the firewall rule, the scheduled jobs, the summary) must # describe the stack as deployed, not this shell's defaults. A setting the # caller passed explicitly wins and is written back; PLAINTEXT is the exception, # because the listener it selects lives in ircd.yaml, which is never rewritten. # --------------------------------------------------------------------------- if [[ -f "$ENV_FILE" ]]; then for _k in PLAINTEXT HISTORY UPDATE_POLICY FORCE_UPDATE UPDATE_GRACE CADDY_AUTOUPDATE CADDY_TAG POSTGRES_TAG POSTGRES_USER POSTGRES_DB POSTGRES_PORT; do _v="$(env_get "$_k")" [[ -n "$_v" ]] || continue if [[ " $EXPLICIT " == *" $_k "* ]]; then [[ "${!_k}" == "$_v" ]] && continue if [[ "$_k" == "HISTORY" ]]; then die "This stack was deployed with HISTORY=${_v} and you passed HISTORY=${!_k}. The backend is recorded in ircd/ircd.yaml, which deploy.sh never rewrites, so re-running cannot switch it (and switching does not migrate existing messages). To change it: 'ergoctl history ${!_k}'." fi if [[ "$_k" == "PLAINTEXT" ]]; then die "This stack was deployed with PLAINTEXT=${_v} and you passed PLAINTEXT=${!_k}. The listener is fixed in ircd/ircd.yaml, which deploy.sh never rewrites, so re-running cannot change it. To switch: edit the plaintext listener with 'ergoctl edit' ($( [[ "$_v" == 1 ]] && echo '":6667": -> "127.0.0.1:6667":' || echo '"127.0.0.1:6667": -> ":6667":' ) and server.sts.enabled), set PLAINTEXT=${!_k} in $ENV_FILE, run 'ergoctl restart', then re-run this script to fix the firewall." fi log "${_k}: ${_v} -> ${!_k} (updating $ENV_FILE)." else printf -v "$_k" '%s' "$_v" fi done [[ "$PLAINTEXT" == "0" || "$PLAINTEXT" == "1" ]] || die "PLAINTEXT in $ENV_FILE must be 0 or 1 (found '$PLAINTEXT')." case "$UPDATE_POLICY" in latest|security|off) ;; *) die "UPDATE_POLICY in $ENV_FILE must be latest, security or off (found '$UPDATE_POLICY')." ;; esac fi # --------------------------------------------------------------------------- # Host prerequisites # --------------------------------------------------------------------------- [[ "$SKIP_DOCKER_INSTALL" == "1" ]] || install_docker for tool in openssl jq curl; do command -v "$tool" >/dev/null 2>&1 || { log "Installing $tool..."; pkg_install "$tool"; } command -v "$tool" >/dev/null 2>&1 || die "$tool is required (certsync/updater); install it and re-run." done command -v docker >/dev/null 2>&1 || die "docker not found." docker compose version >/dev/null 2>&1 || die "docker compose (v2 plugin) not found." ensure_ergo_user # Other host-network containers share Ergo's trust boundary (its loopback). docker info >/dev/null 2>&1 || die "The Docker daemon is not running or not reachable (check 'docker info'). Start it and re-run." others="$(docker ps -q 2>/dev/null | xargs -r docker inspect -f '{{.Name}} {{.HostConfig.NetworkMode}}' 2>/dev/null \ | awk '$2=="host" && $1!="/ergo" && $1!="/ergo-caddy" {print $1}' | tr '\n' ' ' || true)" [[ -z "$others" ]] || warn "Other containers use host networking: ${others}-- they can reach Ergo's loopback listeners (see README: Security model)." if [[ -z "$(dc ps -q 2>/dev/null || true)" ]]; then check_ports_free; fi open_ports # --------------------------------------------------------------------------- # Stack directory + files # --------------------------------------------------------------------------- log "Setting up $STACK_DIR..." install -d -m 0700 "$STACK_DIR" "$SECRETS_DIR" "$BACKUP_DIR" "$TEMPLATES_DIR" "$STATE_DIR" install -d -m 0700 "$CADDY_DIR" "$CADDY_DIR/data" "$CADDY_DIR/config" install -d -m 0755 "$CADDY_DIR/etc" "$CADDY_DIR/etc/conf.d" install -m 0640 "$SCRIPT_DIR/docker-compose.yml" "$STACK_DIR/docker-compose.yml" install -m 0640 "$SCRIPT_DIR/docker-compose.postgres.yml" "$STACK_DIR/docker-compose.postgres.yml" # Caddy has no admin API here and bind-mounted file contents are not part of the # compose config hash, so `up -d` alone would leave a changed Caddyfile unloaded. # Only an ACTUAL change counts: on a first deploy the destination does not exist # yet, and restarting Caddy seconds after it started would interrupt the very # first ACME issuance for nothing. CADDY_CHANGED=0 if [[ -f "$CADDY_DIR/etc/Caddyfile" ]] && ! cmp -s "$SCRIPT_DIR/Caddyfile" "$CADDY_DIR/etc/Caddyfile"; then CADDY_CHANGED=1 fi install -m 0644 "$SCRIPT_DIR/Caddyfile" "$CADDY_DIR/etc/Caddyfile" [[ -e "$CADDY_DIR/etc/conf.d/00-readme.caddy" ]] || install -m 0644 "$SCRIPT_DIR/conf.d-readme.caddy" "$CADDY_DIR/etc/conf.d/00-readme.caddy" install -m 0644 "$SCRIPT_DIR/ergolib.sh" "$STACK_DIR/ergolib.sh" install -m 0750 "$SCRIPT_DIR/update.sh" "$STACK_DIR/update.sh" install -m 0750 "$SCRIPT_DIR/ergoctl" "$STACK_DIR/ergoctl" install -d -m 0755 /usr/local/bin cat > /usr/local/bin/ergoctl < "$SECRETS_DIR/postgres.pass" ) fi chmod 0600 "$SECRETS_DIR/postgres.pass" PG_PASS="$(cat "$SECRETS_DIR/postgres.pass")" else env_set COMPOSE_FILE "docker-compose.yml" fi missing=() for var in ERGO_DOMAIN ACME_EMAIL NETWORK_NAME ERGO_TAG ERGO_UID ERGO_GID; do grep -E "^${var}=.+$" "$ENV_FILE" >/dev/null || missing+=("$var") done (( ${#missing[@]} == 0 )) || die "Missing values in $ENV_FILE: ${missing[*]}" # --------------------------------------------------------------------------- # Pull images, validate the Caddyfile (static config -- no admin API needed) # --------------------------------------------------------------------------- cd "$STACK_DIR" # Pull per service. ERGO_TAG is an immutable vX.Y.Z, so pulling it is a no-op # after the first time. caddy's tag FLOATS, and a bare `docker compose pull` # would adopt a newer Caddy on every re-run and let `up -d` recreate it -- # silently doing what CADDY_AUTOUPDATE=0 promises not to, with none of # update.sh's health check or rollback. Only pull it when it is missing, or when # the operator opted in. log "Pulling $IMAGE..." docker compose pull ergo _caddy_tag="$(env_get CADDY_TAG)"; _caddy_tag="${_caddy_tag:-2-alpine}" if [[ "$CADDY_AUTOUPDATE" == "1" ]] || ! docker image inspect "caddy:${_caddy_tag}" >/dev/null 2>&1; then log "Pulling caddy:${_caddy_tag}..." docker compose pull caddy else log "Keeping the local caddy:${_caddy_tag} (CADDY_AUTOUPDATE=0; update it with 'ergoctl update caddy')." fi if [[ "$HISTORY" == "postgres" ]]; then docker compose pull postgres; fi log "Validating the Caddyfile..." # A one-off `compose run` container gets a generated name (compose drops the # service's container_name for these), so this is safe while ergo-caddy runs. docker compose run --rm --no-deps -T caddy \ caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile >/dev/null \ || die "Caddyfile validation failed. Check $CADDY_DIR/etc/Caddyfile and conf.d/*.caddy." # --------------------------------------------------------------------------- # First run: generate ircd.yaml from the image's own default config # --------------------------------------------------------------------------- render_ircd_yaml() { #