From d34cdac5428de37b1c083e2679a0ea93745c810d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20N=C3=A4geli?= Date: Wed, 14 Oct 2026 14:48:29 +0200 Subject: [PATCH] Add a script that adds www. to the custom domains' certificates reissue-www.sh goes through the certificates the server reads. It reissues each one whose names lack their www. with both names, through the web root, and installs the files. A www. that does not resolve to where its domain does is left out and named, the wildcard is left alone, and the server is not restarted - the next deploy picks them up. Co-Authored-By: Claude Opus 5.5 --- README.md | 13 ++++ reissue-www.sh | 190 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 203 insertions(+) create mode 100755 reissue-www.sh diff --git a/README.md b/README.md index 7d1c2b1..c5508f2 100644 --- a/README.md +++ b/README.md @@ -1667,6 +1667,19 @@ Leave it out where its DNS does not point here, or certbot fails for both names. The challenge for `www.` is sent on to the domain like any other request once the domain is set, which the certificate authority follows. +`reissue-www.sh` adds `www.` to the certificates issued without it. It looks +through the certificates folder and reissues each certificate whose names lack +their `www.` with both names. It finds the certbot lineage by the names, and +leaves out a `www.` that does not resolve to where its domain does, saying so. +Then it installs the files. It does not restart the server, so the new +certificates are served from the next restart on: + +```bash +sudo ./reissue-www.sh --list # what would be reissued, nothing changed +sudo ./reissue-www.sh --dry-run # asks the staging CA, installs nothing +sudo ./reissue-www.sh # reissues and installs +``` + Mind the order: once the domain is set, the lambda's subdomain sends its visitors to the domain at once. The challenge is answered for any host as soon as its DNS points here, whether the domain is set or not, so the diff --git a/reissue-www.sh b/reissue-www.sh new file mode 100755 index 0000000..d379f3b --- /dev/null +++ b/reissue-www.sh @@ -0,0 +1,190 @@ +#!/usr/bin/env bash +# +# Adds www. to the certificates of the lambdas' own domains, so a visitor who +# types www.example.com trusts the server long enough to be sent on to +# example.com. +# +# The server sends www. in front of a domain on to the domain, but a browser +# only follows that once the handshake succeeded - and a certificate issued +# for example.com alone does not carry www.example.com, so its visitors see a +# warning instead. This goes through the certificates in the folder the +# server reads, reissues every one that lacks the www. of one of its names +# with both and installs the files where the container reads them. +# +# It does not restart the server: the io_uring engine learns the names it has +# certificates for when it opens its port, so the new ones are served from the +# next restart on - the deploy that brings the redirect, say. +# +# A www. whose DNS does not point where its domain does is left out and named: +# certbot would fail for every name of the certificate. The wildcard of the +# hosting domain and the site's own certificate are left alone. +# +# sudo ./reissue-www.sh reissue and install what lacks www. +# sudo ./reissue-www.sh --list say what would be reissued, change nothing +# sudo ./reissue-www.sh --dry-run ask the staging CA, install nothing +# +set -euo pipefail + +CERTS=${CERTS:-/opt/genhttp-lambda/certs} +ACME=${ACME:-/opt/genhttp-lambda/acme} +LIVE=${LIVE:-/etc/letsencrypt/live} + +# the group the server runs as, which reads the private keys +GROUP=1001 + +list_only=no; dry_run=no +for arg in "$@"; do + case "$arg" in + --list) list_only=yes ;; + --dry-run) dry_run=yes ;; + -h|--help) sed -n '2,25p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "unknown option: $arg (try --help)" >&2; exit 2 ;; + esac +done + +if [ "$(id -u)" -ne 0 ]; then + echo "This needs root, for certbot and the certificates." >&2 + echo "Run: sudo $0 $*" >&2 + exit 1 +fi + +say() { printf '\n\033[1m%s\033[0m\n' "$*"; } +ok() { printf ' \033[32mok\033[0m %s\n' "$*"; } +skip() { printf ' \033[33mskip\033[0m %s\n' "$*"; } +bad() { printf ' \033[31mFAIL\033[0m %s\n' "$*"; } + +# the names a certificate carries, one per line, sorted +names_of() { + { openssl x509 -in "$1" -noout -ext subjectAltName 2>/dev/null || true; } \ + | tr ',' '\n' | sed -n 's/^ *DNS://p' | sort -u +} + +# the addresses a name resolves to, one per line, sorted - nothing for a name +# that does not resolve +addresses_of() { { getent ahosts "$1" 2>/dev/null || true; } | awk '{print $1}' | sort -u; } + +# the certbot lineage whose certificate carries exactly these names - the +# folder name below the certificates is a convention, the names are not +lineage_of() { + local wanted=$1 folder=$2 live + for live in "$LIVE"/*/; do + [ -f "$live/fullchain.pem" ] || continue + if [ "$(names_of "$live/fullchain.pem")" = "$wanted" ]; then + basename "$live" + return + fi + done + if [ -d "$LIVE/$folder" ]; then + echo "$folder" + fi +} + +# what is to be done, one line each: folder, lineage, names separated by spaces +plan=() + +say "Looking through $CERTS" + +for dir in "$CERTS"/*/; do + folder=$(basename "$dir") + cert="$dir/fullchain.pem" + + [ -f "$cert" ] || continue + + names=$(names_of "$cert") + + if grep -q '^\*\.' <<<"$names"; then + skip "$folder: a wildcard, which is the hosting domain's" + continue + fi + + missing=(); skipped=no + + for name in $names; do + case "$name" in www.*) continue ;; esac + + grep -qx "www.$name" <<<"$names" && continue + + www_addresses=$(addresses_of "www.$name") + own_addresses=$(addresses_of "$name") + + if [ -z "$www_addresses" ]; then + skip "$folder: www.$name does not resolve"; skipped=yes + continue + fi + + # every address of the www. has to be one of the domain's, which points + # here since its certificate is being served + if [ -n "$(comm -23 <(echo "$www_addresses") <(echo "$own_addresses"))" ]; then + skip "$folder: www.$name resolves to $(echo "$www_addresses" | paste -sd ' '), elsewhere than $name"; skipped=yes + continue + fi + + missing+=("www.$name") + done + + if [ ${#missing[@]} -eq 0 ]; then + [ "$skipped" = yes ] || ok "$folder: nothing to add" + continue + fi + + wanted=$(printf '%s\n' $names "${missing[@]}" | sort -u) + + lineage=$(lineage_of "$names" "$folder") + + if [ -z "$lineage" ]; then + bad "$folder: no certificate in $LIVE carries $(echo "$names" | paste -sd ' ') - issue it by hand" + continue + fi + + printf ' \033[1madd\033[0m %s: %s (certbot lineage %s)\n' "$folder" "${missing[*]}" "$lineage" + plan+=("$folder $lineage $(echo "$wanted" | paste -sd ' ')") +done + +if [ ${#plan[@]} -eq 0 ]; then + say "Nothing to reissue." + exit 0 +fi + +[ "$list_only" = yes ] && exit 0 + +installed=0; failed=0 + +for entry in "${plan[@]}"; do + read -r folder lineage wanted <<<"$entry" + + domains=() + for name in $wanted; do domains+=(-d "$name"); done + + say "Reissuing $folder for $wanted" + + # the challenges are answered by the server from the web root, for any + # host - a www. it sends on is followed to its domain by the CA + args=(certonly --non-interactive --cert-name "$lineage" --expand --webroot -w "$ACME" "${domains[@]}") + [ "$dry_run" = yes ] && args+=(--dry-run) + + if ! certbot "${args[@]}"; then + bad "$folder: certbot failed, the certificate is unchanged" + failed=$((failed + 1)) + continue + fi + + if [ "$dry_run" = yes ]; then + ok "$folder: the staging CA would issue it" + continue + fi + + install -m 0644 -o root -g "$GROUP" "$LIVE/$lineage/fullchain.pem" "$CERTS/$folder/" + install -m 0640 -o root -g "$GROUP" "$LIVE/$lineage/privkey.pem" "$CERTS/$folder/" + + ok "$folder: installed" + installed=$((installed + 1)) +done + +[ "$installed" -gt 0 ] && say "Installed $installed certificate(s), served from the next restart of the server on." + +if [ "$failed" -gt 0 ]; then + say "$failed certificate(s) could not be reissued - see above." + exit 1 +fi + +say "Done."