Changeset bc9ef3b in Klonkt for scripts/klonkt-add-instance.sh


Ignore:
Timestamp:
08/20/2026 09:50:04 AM (3 weeks ago)
Author:
Bart <bart@…>
Branches:
main
Children:
96a99bf
Parents:
520c993
git-author:
Bart <bart@…> (08/20/2026 09:48:11 AM)
git-committer:
Bart <bart@…> (08/20/2026 09:50:04 AM)
Message:

add-instance: schrijf naar conf.d, en weiger ongeldige IDN-namen

Twee dingen, waarvan het eerste een bug is die vanmiddag ontstond toen de
Caddyfile opgesplitst werd.

De controle "bestaat dit domein al?" greppte in /etc/caddy/Caddyfile, waar sinds
de splitsing geen enkel site-blok meer staat. Die kon dus nooit meer iets
vinden: opnieuw draaien voor een bestaand domein plakte een DUPLICAAT achteraan,
Caddy weigert dubbele site-adressen, validate faalde, en het script stierf met
het kapotte blok nog in het bestand. Er werd een .bak gemaakt en nooit
teruggezet.

Nu één bestand per site in conf.d, de controle kijkt of dat bestand er is, en
bij een mislukte validatie wordt het blok weer weggehaald in plaats van te
blijven staan. De draaiende Caddy merkt er niets van; pas de volgende reload zou
over een kapotte config struikelen, en reloaden is precies wat de volgende
persoon doet. De terugval op systemctl restart caddy is weg: een mislukte
reload meld je, je escaleert hem niet naar het neerhalen van alle sites.

Daarnaast gaat een internationaal domein als ASCII het blok, de bestandsnaam en
PUBLIC_BASE_URL in. Per label punycode en bewust géén IDNA-bibliotheek: Pythons
ingebouwde idna-codec STRIPT de zero-width joiner en levert een andere naam op
(eentje zonder certificaat), en de strikte IDNA2008-tools weigeren emoji
helemaal. Coderen wat de beheerder werkelijk typte is het enige dat overeenkomt
met het DNS-record dat hij maakte.

En een naam met een joiner of variation selector wordt geweigerd, niet met een
waarschuwing doorgelaten: twee browsers accepteren zo'n naam niet, dus het zou
een site opleveren die onder de bedoelde naam voor niemand bereikbaar is. De
fout noemt beide vormen — wat je typte en wat clients in plaats daarvan vragen —
en er wordt niets aangemaakt. Wie het tóch wil geeft de punycode-vorm als
domein op; die is ASCII en loopt gewoon door.

Co-Authored-By: Claude Opus 5 <claude@…>

File:
1 edited

Legend:

Unmodified
Added
Removed
  • scripts/klonkt-add-instance.sh

    r520c993 rbc9ef3b  
    3838[[ "$SLUG" =~ ^[a-z0-9][a-z0-9._-]*$ ]] || die "slug must be lowercase letters, digits, dot, dash or underscore."
    3939
     40# An internationalised domain is written into the Caddy block, the filename and
     41# .env as ASCII. Not because Caddy needs it — it copes — but because an emoji
     42# name can carry a zero-width joiner and variation selectors, and those are
     43# INVISIBLE. An editor, a paste or a well-meant tidy-up drops one and the vhost
     44# stops matching with nothing on screen to explain why.
     45#
     46# Per-label punycode, and deliberately not an IDNA library. Python's built-in
     47# `idna` codec STRIPS the joiner and yields a different name (one that has no
     48# certificate); the strict IDNA2008 tools reject emoji outright. Encoding what
     49# the operator actually typed is the only thing that matches the DNS record
     50# they actually made.
     51to_ascii() {
     52  if LC_ALL=C printf '%s' "$1" | grep -q '[^ -~]'; then
     53    python3 -c 'import codecs,sys
     54print(".".join(l if l.isascii() else "xn--" + codecs.encode(l, "punycode").decode()
     55               for l in sys.argv[1].split(".")))' "$1"
     56  else
     57    printf '%s\n' "$1"
     58  fi
     59}
     60
     61# The same name with joiner and variation selectors removed: what a client that
     62# normalises them away will ask for instead. Empty when the name has none.
     63stripped_ascii() {
     64  python3 -c 'import codecs,sys
     65d = sys.argv[1]
     66s = d.replace("‍", "").replace("️", "")
     67if s == d: raise SystemExit(0)
     68print(".".join(l if l.isascii() else "xn--" + codecs.encode(l, "punycode").decode()
     69               for l in s.split(".")))' "$1"
     70}
     71
     72HOST_ASCII="$(to_ascii "$DOMAIN")"
     73
    4074DATA_DIR="$DATA_ROOT/$SLUG"
    4175ENV_FILE="$DATA_DIR/.env"
     
    4882id -u "$KLONKT_USER" >/dev/null 2>&1 || die "user $KLONKT_USER does not exist"
    4983[ -e "$DATA_DIR" ] && die "$DATA_DIR already exists. Pick another slug."
     84
     85if [ "$HOST_ASCII" != "$DOMAIN" ]; then
     86  say "IDN: ${DOMAIN} -> ${HOST_ASCII}"
     87  ALIAS_ASCII="$(stripped_ascii "$DOMAIN" || true)"
     88  if [ -n "$ALIAS_ASCII" ]; then
     89    die "refusing ${DOMAIN}
     90
     91  This name contains a zero-width joiner or a variation selector, which makes
     92  it invalid under IDNA2008. Browsers reject it — two were tested — so nobody
     93  could reach the site by the name you just typed. Worse, clients that quietly
     94  strip those characters ask for a DIFFERENT name than the one you registered:
     95
     96      you typed : ${HOST_ASCII}
     97      they ask  : ${ALIAS_ASCII}
     98
     99  Nothing has been created. Use an emoji that is a single codepoint, or an
     100  ordinary name.
     101
     102  If you want this anyway, pass the punycode form as the domain, and give the
     103  stripped name its own DNS record and a redirect block so one identity keeps
     104  one canonical address:
     105
     106      $0 $SLUG ${HOST_ASCII}"
     107  fi
     108fi
    50109[ -f /etc/systemd/system/klonkt@.service ] || {
    51110  [ -f "$KLONKT_DIR/deploy/klonkt@.service" ] || die "missing $KLONKT_DIR/deploy/klonkt@.service"
     
    77136  echo "HOST=127.0.0.1"
    78137  echo "SESSION_SECRET=${SECRET}"
    79   echo "PUBLIC_BASE_URL=https://${DOMAIN}"
     138  echo "PUBLIC_BASE_URL=https://${HOST_ASCII}"
    80139  echo "DATABASE_PATH=${DATA_DIR}/database.sqlite"
    81140  echo "MEDIA_PATH=${DATA_DIR}/media"
     
    105164  step "Caddy"
    106165  CADDY=/etc/caddy/Caddyfile
    107   if grep -q "^${DOMAIN} {" "$CADDY" 2>/dev/null; then
    108     say "a block for ${DOMAIN} already exists, left untouched"
     166  CONFD=/etc/caddy/conf.d
     167  # One file per site. Older machines keep every block in the single Caddyfile,
     168  # so add the import if it is missing: this then works on both without moving
     169  # anything that is already there.
     170  mkdir -p "$CONFD"
     171  grep -q '^[[:space:]]*import[[:space:]]\+conf\.d/' "$CADDY" 2>/dev/null \
     172    || printf '\nimport conf.d/*.caddyfile\n' >> "$CADDY"
     173  BLOCK="$CONFD/${HOST_ASCII}.caddyfile"
     174  if [ -e "$BLOCK" ]; then
     175    say "a block for ${HOST_ASCII} already exists, left untouched"
    109176  else
    110     cp "$CADDY" "${CADDY}.bak.$(date +%s)" 2>/dev/null || true
    111     printf '\n%s {\n    reverse_proxy 127.0.0.1:%s\n    encode gzip zstd\n}\n' "$DOMAIN" "$PORT" >> "$CADDY"
     177    printf '%s {\n    reverse_proxy 127.0.0.1:%s\n    encode gzip zstd\n}\n' "$HOST_ASCII" "$PORT" > "$BLOCK"
     178    # Take the block away again rather than leave a config that will not load.
     179    # The running Caddy is unaffected until someone reloads, and reloading is
     180    # precisely what the next person to touch this machine will do.
    112181    caddy validate --config "$CADDY" --adapter caddyfile >/dev/null 2>&1 \
    113       || die "Caddy config invalid after adding ${DOMAIN} — check $CADDY (a .bak was made)"
    114     systemctl reload caddy 2>/dev/null || systemctl restart caddy
    115     say "serving ${DOMAIN}"
     182      || { rm -f "$BLOCK"; die "Caddy config invalid for ${HOST_ASCII} — the block was removed again, nothing changed"; }
     183    systemctl reload caddy \
     184      || die "caddy reload failed. NOT restarting: that would drop every site on this machine. See: journalctl -u caddy -n 30"
     185    say "serving ${HOST_ASCII}"
    116186  fi
    117187fi
Note: See TracChangeset for help on using the changeset viewer.