fix(simplex): repair the caddy-init YAML fold that broke every deploy

The caddy-init service's `command:` used a YAML FOLDED scalar (`>`). Base
indent is set by `sh -c '` at 6; the closing `}` and the heredoc's `EOF`
were BOTH at 6, so YAML folded them into `} EOF`. The heredoc opened with
`<<EOF` therefore never saw a terminator line.

Confirmed by parsing the generated compose file: the command contains
`} EOF` and no bare EOF line. Feeding the resulting script to sh:

  here-document at line 3 delimited by end-of-file (wanted `EOF')
  syntax error: unexpected end of file          (rc=2)

So caddy-init exited 2 before its `>` redirect ever ran -- no Caddyfile was
ever written, on any deploy. The `if [ ! -f /etc/caddy/Caddyfile ]` guard
never executed at all. caddy gates on
`caddy-init: condition: service_completed_successfully`, and smp-server and
xftp-server gate on caddy being healthy, so nothing in the stack started.
`docker compose up -d` returned non-zero and `set -euo pipefail` aborted the
script before the final report. The same failure hit every entry point:
automations.sh, cloud-init, the OpenRC unit's start() (so every boot), and
restore.sh -- which restores the same broken compose file backup.sh saved.

Generate the Caddyfile from the deploy shell instead and drop caddy-init
entirely, which removes the whole class of problem. Unlike the old
first-run-only guard this also applies a changed DOMAIN, ACME_EMAIL or
KEY_TYPE on a re-run rather than freezing them at the first deploy.

The heredoc here is deliberately unquoted so ${DOMAIN}/${ACME_EMAIL}/
${KEY_TYPE} expand; Caddy's {uri} has no `$` and survives. `cmp -s` stays
inside an `if` condition -- as `cmp -s A B && CADDY_CHANGED=1` it would trip
set -e whenever the files matched. Caddy is restarted only when the file
actually changed AND compose did not already replace the container, since a
bind-mounted file's contents are not part of the compose config hash and
restarting seconds after a first start would interrupt initial ACME issuance.

The Caddyfile is now real on-disk state rather than something a container
regenerates, so it is added to backup.sh's targets and restore.sh's file
list; without that, a restore would start caddy against an empty
./caddy_conf and the servers would never pass their health gate.

Verified: first deploy writes it and reports no change; an identical re-run
reports no change; a corrected DOMAIN rewrites it and flags the restart;
{uri} survives and no .new file is left behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 14:21:07 -05:00
co-authored by Claude Opus 5
parent a785a2bd9e
commit 0f2273545a
3 changed files with 58 additions and 42 deletions
+1
View File
@@ -98,6 +98,7 @@ TARGETS=(
# Current environment and docker-compose setup
"$SIMPLEX_DIR/.env"
"$SIMPLEX_DIR/docker-compose.yml"
"$SIMPLEX_DIR/caddy_conf/Caddyfile"
"$SIMPLEX_DIR/print-addresses.sh"
# Tor configs
+56 -42
View File
@@ -221,7 +221,7 @@ done
# 5. Lay out /opt/simplex
# ----------------------------------------------------------------------------
log "Writing compose stack to ${INSTALL_DIR}..."
mkdir -p "$INSTALL_DIR"/tor_conf
mkdir -p "$INSTALL_DIR"/tor_conf "$INSTALL_DIR"/caddy_conf
cd "$INSTALL_DIR"
cat > .env <<EOF
@@ -235,53 +235,56 @@ CERT_PATH=${CERT_PATH}
EOF
chmod 600 .env
# Caddyfile. This used to be produced by a caddy-init container whose `command:`
# was a YAML FOLDED scalar (`>`): the closing `}` and the heredoc's `EOF` were
# both at the scalar's base indent, so YAML folded them into `} EOF`, leaving the
# heredoc unterminated and the script a parse error. caddy-init exited 2 on every
# run, `service_completed_successfully` never fired, and nothing downstream of
# caddy ever started. Generating the file here removes the whole class of problem
# -- and, unlike the old `if [ ! -f ]` guard, applies a changed DOMAIN/ACME_EMAIL/
# KEY_TYPE on a re-run instead of freezing them at the first deploy.
#
# The heredoc is deliberately UNQUOTED so ${DOMAIN}/${ACME_EMAIL}/${KEY_TYPE}
# expand here. Caddy's own {uri} has no `$` so it survives; any future Caddy
# {$ENV} placeholder must be escaped as \${...}.
CADDY_CHANGED=0
cat > caddy_conf/Caddyfile.new <<EOF
{
email ${ACME_EMAIL}
}
http://smp.${DOMAIN} {
redir https://smp.${DOMAIN}{uri} permanent
}
smp.${DOMAIN}:8443 {
tls { key_type ${KEY_TYPE} }
reverse_proxy smp-server:8000
}
http://xftp.${DOMAIN} {
redir https://xftp.${DOMAIN}{uri} permanent
}
xftp.${DOMAIN}:8443 {
tls { key_type ${KEY_TYPE} }
reverse_proxy xftp-server:8000
}
EOF
# `cmp -s` exits 1 when the files differ, so it stays inside an `if` condition --
# never `cmp -s A B && CADDY_CHANGED=1`, which trips set -e when they match.
if [[ -f caddy_conf/Caddyfile ]] && ! cmp -s caddy_conf/Caddyfile caddy_conf/Caddyfile.new; then
CADDY_CHANGED=1
fi
mv -f caddy_conf/Caddyfile.new caddy_conf/Caddyfile
chmod 0644 caddy_conf/Caddyfile
cat > docker-compose.yml <<'YAML'
name: simplex
services:
caddy-init:
image: alpine:latest
command: >
sh -c '
if [ ! -f /etc/caddy/Caddyfile ]; then
cat > /etc/caddy/Caddyfile <<EOF
{
email ${ACME_EMAIL}
}
http://smp.${DOMAIN} {
redir https://smp.${DOMAIN}{uri} permanent
}
smp.${DOMAIN}:8443 {
tls { key_type ${KEY_TYPE} }
reverse_proxy smp-server:8000
}
http://xftp.${DOMAIN} {
redir https://xftp.${DOMAIN}{uri} permanent
}
xftp.${DOMAIN}:8443 {
tls { key_type ${KEY_TYPE} }
reverse_proxy xftp-server:8000
}
EOF
fi
'
environment:
DOMAIN: ${DOMAIN:?}
ACME_EMAIL: ${ACME_EMAIL:?}
KEY_TYPE: ${KEY_TYPE:-rsa4096}
volumes:
- ./caddy_conf:/etc/caddy
restart: "no"
caddy:
image: caddy:2-alpine
depends_on:
caddy-init:
condition: service_completed_successfully
cap_add:
- NET_ADMIN
ports:
@@ -494,7 +497,18 @@ rc-update add simplex default
log "Pulling images and starting the stack..."
cd "$INSTALL_DIR"
docker compose pull
# A bind-mounted file's CONTENTS are not part of the compose config hash, so a
# changed Caddyfile does not by itself cause a recreate and `up -d` would leave
# it unloaded. Only restart when it actually changed AND compose did not already
# replace the container -- restarting Caddy seconds after a first start would
# interrupt the initial ACME issuance for nothing.
_caddy_before="$(docker compose ps -q caddy 2>/dev/null || true)"
docker compose up -d
_caddy_after="$(docker compose ps -q caddy 2>/dev/null || true)"
if (( CADDY_CHANGED )) && [[ -n "$_caddy_before" && "$_caddy_before" == "$_caddy_after" ]]; then
log "Caddyfile changed; restarting Caddy to load it..."
docker compose restart caddy || warn "Could not restart Caddy; run: cd $INSTALL_DIR && docker compose restart caddy"
fi
log "Waiting up to 90s for Tor to publish hidden services..."
for _ in $(seq 1 90); do
+1
View File
@@ -238,6 +238,7 @@ SIMPLEX_FILES=(
"docker-compose.yml"
"print-addresses.sh"
"tor_conf/"
"caddy_conf/"
)
for item in "${SIMPLEX_FILES[@]}"; do