#!/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 pipefailPROG="$(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 <<EOFUsage: $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, andforces 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 certEOF 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=1while [ $# -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 ;; esacdone[ -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 1fi# ---------------------------------------------------------------------------# 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)"fimkdir -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"' EXITcat > "$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>PLISTif [ -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 || trueif 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 1fi# ---------------------------------------------------------------------------# 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=0STORED_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=1fiif [ "$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" >&2fi# ---------------------------------------------------------------------------# 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)"fiECC_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" >&2chmod 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' ' ')"filog ""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."