raw
sh
#!/usr/bin/env bash
#
# Serve HTTPS and keep serving it, unattended.
#
# Issues a publicly-trusted certificate over ACME DNS-01 and — the part that
# gets skipped — installs the thing that renews it, then proves that renewal
# path works by running it once. A certificate without a scheduler looks
# finished and silently expires 90 days later; this script refuses to leave a
# host in that state.
#
# Order matters and is the whole design:
#
#   1. ensure the ACME client exists
#   2. install the renewal scheduler          <- before there is anything to renew
#   3. issue the certificate
#   4. install it, with a reload command      <- servers read PEMs only at startup
#   5. force one renewal of this certificate, scoped to it
#
# Step 5 makes renewal observed instead of asserted: the first run exercises the
# same renew -> install -> reload path that runs in 60 days, so "it renews" is a
# thing you watched rather than a thing you hoped. It is scoped to this domain
# rather than run as `--cron --force`, which would force-renew every
# certificate in a shared ACME home — burning duplicate-certificate quota and
# firing unrelated services' reload hooks.
#
# Usage:
#   https.sh --domain DOMAIN --cert PATH --key PATH --reload-cmd CMD
#            [--dns DNS_API] [--ca CA] [--keylength LEN] [--no-force-renewal]
#
# Required:
#   --domain       Certificate subject, e.g. '*.example.com' or 'app.example.com'
#   --cert         Where to install the fullchain PEM (the server reads this)
#   --key          Where to install the private key PEM
#   --reload-cmd   Command that makes the running server pick up a new cert.
#                  Without this, renewal accomplishes nothing.
#
# Optional:
#   --dns          acme.sh DNS API plugin (default: dns_cf, Cloudflare).
#                  Other providers work in principle; only dns_cf is tested.
#   --ca           ACME CA (default: letsencrypt). Set explicitly because
#                  acme.sh defaults to ZeroSSL, which is rarely what you meant.
#   --keylength    Key type (default: ec-256).
#   --no-force-renewal
#                  Skip step 5 even on a first run. The forced renewal spends
#                  one of Let's Encrypt's 5 duplicate certificates per week;
#                  skipping it means the renewal path is never proven.
#
# The forced renewal happens only when this run actually issued the certificate.
# A re-run against an existing certificate skips it, so re-running is quota-free
# and does not re-trigger reload hooks.
#
# DNS-01 is the default because it is the only challenge that works for a name
# that does not resolve to a publicly reachable host. HTTP-01 requires the CA to
# fetch a token from the host; for a tailnet/VPN/LAN name there is no route, so
# it cannot work at all. DNS-01 proves control by writing a TXT record instead.
#
# The DNS provider credential is read from the environment on first run and
# saved by acme.sh into its own home (mode 600). It is never echoed, never
# placed on a command line, and never copied elsewhere. For Cloudflare:
#
#   export CF_Token=...        # scoped to DNS:Edit on the one zone
#   export CF_Account_ID=...
#
# Collect these with collect-secrets.sh rather than pasting them into a shell.
#
# Idempotent: safe to re-run. An existing client, scheduler, or certificate is
# left alone rather than reinstalled.
#
# Platform: macOS/launchd. The renewal scheduler is installed as a LaunchAgent.
# On other platforms the script prints what to do and exits rather than guessing
# at a scheduler it cannot verify.

set -euo pipefail

PROG="$(basename "$0")"

ACME_HOME="${ACME_HOME:-$HOME/.acme.sh}"
LAUNCH_LABEL="com.dev-skills.acme-renew"
LAUNCH_PLIST="$HOME/Library/LaunchAgents/${LAUNCH_LABEL}.plist"
RENEW_HOUR="${RENEW_HOUR:-3}"

log() { echo "$@" >&2; }
step() { echo "==> $*" >&2; }

usage() {
  cat >&2 <<EOF
Usage:
  $PROG --domain DOMAIN --cert PATH --key PATH --reload-cmd CMD
        [--dns DNS_API] [--ca CA] [--keylength LEN] [--no-force-renewal]

Issues a certificate over ACME DNS-01, installs the renewal scheduler, and
forces one renewal so the renewal path is proven rather than assumed.

Required:
  --domain       e.g. '*.example.com'
  --cert         where to install the fullchain PEM
  --key          where to install the private key PEM
  --reload-cmd   command that makes the server pick up a new cert
EOF
  exit 2
}

require_command() {
  if ! command -v "$1" >/dev/null 2>&1; then
    log "error: $1 not found in PATH"
    log "$2"
    exit 1
  fi
}

DOMAIN=""
CERT_PATH=""
KEY_PATH=""
RELOAD_CMD=""
DNS_API="dns_cf"
CA="letsencrypt"
KEYLENGTH="ec-256"
FORCE_RENEWAL=1

while [ $# -gt 0 ]; do
  case "$1" in
    --domain) DOMAIN="${2:-}"; shift 2 ;;
    --cert) CERT_PATH="${2:-}"; shift 2 ;;
    --key) KEY_PATH="${2:-}"; shift 2 ;;
    --reload-cmd) RELOAD_CMD="${2:-}"; shift 2 ;;
    --dns) DNS_API="${2:-}"; shift 2 ;;
    --ca) CA="${2:-}"; shift 2 ;;
    --keylength) KEYLENGTH="${2:-}"; shift 2 ;;
    --no-force-renewal) FORCE_RENEWAL=0; shift ;;
    -h|--help) usage ;;
    *) log "error: unknown argument: $1"; usage ;;
  esac
done

[ -n "$DOMAIN" ] || { log "error: --domain is required"; usage; }
[ -n "$CERT_PATH" ] || { log "error: --cert is required"; usage; }
[ -n "$KEY_PATH" ] || { log "error: --key is required"; usage; }
# A reload command is not optional: without it a renewed certificate sits on
# disk while the running server keeps serving the old one from memory.
[ -n "$RELOAD_CMD" ] || { log "error: --reload-cmd is required"; usage; }

if [ "$(uname -s)" != "Darwin" ]; then
  log "error: $PROG installs a launchd scheduler and supports macOS only."
  log ""
  log "On this platform, do the same five steps by hand:"
  log "  1. install acme.sh"
  log "  2. install a daily scheduler running: acme.sh --cron --home $ACME_HOME"
  log "  3. acme.sh --issue --dns $DNS_API -d '$DOMAIN' --server $CA --keylength $KEYLENGTH"
  log "  4. acme.sh --install-cert -d '$DOMAIN' --fullchain-file ... --key-file ... --reloadcmd ..."
  log "  5. acme.sh --cron --force --home $ACME_HOME   # prove the renewal path"
  exit 1
fi

# ---------------------------------------------------------------------------
# 1. Ensure the ACME client.
# ---------------------------------------------------------------------------
step "Checking for acme.sh"
if command -v acme.sh >/dev/null 2>&1; then
  ACME="$(command -v acme.sh)"
  log "    found: $ACME"
elif [ -x "$ACME_HOME/acme.sh" ]; then
  ACME="$ACME_HOME/acme.sh"
  log "    found: $ACME"
else
  require_command brew "Install acme.sh first: brew install acme.sh"
  log "    not found; installing via Homebrew"
  brew install acme.sh >&2
  ACME="$(command -v acme.sh)"
fi
mkdir -p "$ACME_HOME"

# ---------------------------------------------------------------------------
# 2. Install the renewal scheduler — BEFORE issuing anything.
#
# This is deliberately first. Doing it last is how a host ends up with a valid
# certificate and no trigger: the issuance succeeds, everything looks correct,
# and the scheduling step gets skipped because nothing appears to be missing.
#
# One scheduler per machine, not per app: `acme.sh --cron` renews every
# certificate in the store, so a second app installing its own would race this
# one over the same directory.
#
# Note a package-manager install of acme.sh does NOT create a scheduler the way
# upstream's `acme.sh --install` does — a Homebrew install gives you the binary
# and nothing else. Never assume one exists.
# ---------------------------------------------------------------------------
step "Installing the renewal scheduler ($LAUNCH_LABEL)"
mkdir -p "$HOME/Library/LaunchAgents" "$HOME/Library/Logs/acme"

# Render the plist we *want*, then compare. Testing only for the file's
# existence would let a re-run with a different --home, acme.sh path, or
# RENEW_HOUR leave launchd pointed at the old configuration while this script
# reports the new one as loaded — the certificate would then never renew.
PLIST_WANTED="$(mktemp)"
trap 'rm -f "$PLIST_WANTED"' EXIT
cat > "$PLIST_WANTED" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key><string>${LAUNCH_LABEL}</string>
  <key>ProgramArguments</key>
  <array>
    <string>${ACME}</string>
    <string>--cron</string>
    <string>--home</string>
    <string>${ACME_HOME}</string>
  </array>
  <key>StartCalendarInterval</key>
  <dict><key>Hour</key><integer>${RENEW_HOUR}</integer><key>Minute</key><integer>0</integer></dict>
  <key>StandardOutPath</key><string>${HOME}/Library/Logs/acme/renew.log</string>
  <key>StandardErrorPath</key><string>${HOME}/Library/Logs/acme/renew.err.log</string>
</dict>
</plist>
PLIST

if [ -f "$LAUNCH_PLIST" ] && cmp -s "$PLIST_WANTED" "$LAUNCH_PLIST"; then
  log "    already correct: $LAUNCH_PLIST"
else
  if [ -f "$LAUNCH_PLIST" ]; then
    log "    configuration changed; replacing $LAUNCH_PLIST"
    launchctl bootout "gui/$(id -u)/${LAUNCH_LABEL}" 2>/dev/null || true
  else
    log "    wrote $LAUNCH_PLIST"
  fi
  cp "$PLIST_WANTED" "$LAUNCH_PLIST"
fi

# Load it as the user, in the GUI session — the reload command usually targets a
# per-user service, which a system-session job could not talk to.
launchctl bootstrap "gui/$(id -u)" "$LAUNCH_PLIST" 2>/dev/null || true
if launchctl list 2>/dev/null | grep -q "$LAUNCH_LABEL"; then
  log "    scheduler loaded (daily at ${RENEW_HOUR}:00)"
else
  log "error: scheduler did not load; renewal would never fire"
  exit 1
fi

# ---------------------------------------------------------------------------
# 3. Issue the certificate.
#
# The CA is set explicitly: acme.sh defaults to ZeroSSL, so omitting --server
# silently gets a different CA than intended.
# ---------------------------------------------------------------------------
step "Issuing certificate for $DOMAIN (CA: $CA, DNS: $DNS_API)"

# Compare the Main_Domain column literally and exactly. A substring/regex search
# would match a merely-similar name — `example.com` against a stored
# `www.example.com` — and skip an issuance that never happened, so the install
# below would then fail on a certificate that does not exist. Domain names also
# contain `.` and `*`, which are regex metacharacters.
ALREADY_ISSUED=0
STORED_KEYLENGTH=""
STORED_KEYLENGTH="$(
  "$ACME" --list --home "$ACME_HOME" 2>/dev/null \
    | awk -v d="$DOMAIN" 'NR > 1 && $1 == d { print $2; exit }'
)"
if [ -n "$STORED_KEYLENGTH" ]; then
  ALREADY_ISSUED=1
fi

if [ "$ALREADY_ISSUED" -eq 1 ]; then
  log "    already issued; leaving it alone"
else
  "$ACME" --issue \
    --home "$ACME_HOME" \
    --dns "$DNS_API" \
    -d "$DOMAIN" \
    --server "$CA" \
    --keylength "$KEYLENGTH" >&2
fi

# ---------------------------------------------------------------------------
# 4. Install it, with the reload command.
#
# --reloadcmd is what turns "a new file exists" into "the server is serving it".
# Servers read cert and key at startup and hold them in memory, so without this
# renewal changes nothing until the next unrelated restart.
# ---------------------------------------------------------------------------
step "Installing cert to $CERT_PATH (reload: $RELOAD_CMD)"
mkdir -p "$(dirname "$CERT_PATH")" "$(dirname "$KEY_PATH")"

# acme.sh keeps ECC and RSA certificates in separate stores and selects between
# them with --ecc. Passing it unconditionally while --keylength is caller-
# configurable would point the install at the ECC store after issuing an RSA
# certificate — installing nothing, or silently installing a stale ECC one.
#
# Select the store from the certificate that actually exists, not from the
# argument: on a re-run whose --keylength differs from what was issued earlier,
# the argument describes a certificate that was never created, so trusting it
# would install stale material or fail the reload. The stored key type wins
# whenever there is a stored certificate.
EFFECTIVE_KEYLENGTH="${STORED_KEYLENGTH:-$KEYLENGTH}"
if [ -n "$STORED_KEYLENGTH" ] && [ "$STORED_KEYLENGTH" != "$KEYLENGTH" ]; then
  log "    note: existing certificate is $STORED_KEYLENGTH, not the requested" \
      "$KEYLENGTH; installing the existing one"
  log "    (to change key type, revoke/remove the certificate and re-run)"
fi

ECC_FLAG=()
case "$EFFECTIVE_KEYLENGTH" in
  ec-*) ECC_FLAG=(--ecc) ;;
esac

"$ACME" --install-cert \
  --home "$ACME_HOME" \
  -d "$DOMAIN" \
  ${ECC_FLAG[@]+"${ECC_FLAG[@]}"} \
  --fullchain-file "$CERT_PATH" \
  --key-file "$KEY_PATH" \
  --reloadcmd "$RELOAD_CMD" >&2
chmod 600 "$KEY_PATH"

# ---------------------------------------------------------------------------
# 5. Force one renewal of this certificate.
#
# The point is to run, right now, the renew -> install -> reload path that the
# scheduler will run in 60 days, so the loop is observed rather than assumed.
#
# Scoped to this domain on purpose. `--cron --force` would be the more literal
# rehearsal of what the scheduler invokes, but `--cron` iterates the whole ACME
# home: on a host holding a second certificate it force-renews all of them,
# burning duplicate-certificate quota and firing other services' reload hooks.
# Since `--cron` is only "for each certificate that is due, renew it", renewing
# this one on purpose exercises everything that matters.
#
# Only runs when this invocation actually issued the certificate, so re-running
# the script is quota-free and does not re-trigger the reload command.
# ---------------------------------------------------------------------------
if [ "$FORCE_RENEWAL" -eq 1 ] && [ "$ALREADY_ISSUED" -eq 0 ]; then
  step "Forcing one renewal to prove the path works"
  "$ACME" --renew -d "$DOMAIN" --force \
    --home "$ACME_HOME" \
    ${ECC_FLAG[@]+"${ECC_FLAG[@]}"} >&2
  log "    renewed, reinstalled, and ran the reload command"
elif [ "$FORCE_RENEWAL" -eq 0 ]; then
  log "==> Skipping forced renewal (--no-force-renewal)"
else
  log "==> Certificate already existed; not forcing a renewal"
  log "    (re-runs stay quota-free; the path was proven on first run)"
fi

# ---------------------------------------------------------------------------
# Report. The peer check is printed, never asserted: a host cannot verify its
# own reachability when a port redirect sits in front of the service, because
# locally-originated traffic takes a different path than a remote client's.
# ---------------------------------------------------------------------------
step "Done"
log ""
log "  cert:      $CERT_PATH"
log "  key:       $KEY_PATH"
log "  scheduler: $LAUNCH_LABEL (daily at ${RENEW_HOUR}:00)"
log "  log:       $HOME/Library/Logs/acme/renew.log"
if [ -x "$(command -v openssl || true)" ]; then
  log ""
  log "  $(openssl x509 -in "$CERT_PATH" -noout -subject -enddate 2>/dev/null | tr '\n' ' ')"
fi
log ""
log "NOT YET VERIFIED: whether peers can actually reach this."
log "A check from this host proves nothing when a port redirect (pf, iptables,"
log "NAT) is in front — the local path differs from a remote client's and"
log "usually works even when the remote one is broken."
log ""
log "Run this from a DIFFERENT machine:"
log ""
log "  echo | openssl s_client -connect <this-host-ip>:443 -servername <hostname> \\"
log "    2>/dev/null | openssl x509 -noout -subject -dates"
log ""
log "Expect the subject to name your hostname. Then load it in a browser on a"
log "third device: no warning is the signal — modern browsers show no padlock."