source: Klonkt/scripts/klonkt-add-instance.sh@ 96a99bf

main
Last change on this file since 96a99bf was bc9ef3b, checked in by Bart <bart@…>, 3 weeks ago

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@…>

  • Property mode set to 100755
File size: 7.8 KB
Line 
1#!/usr/bin/env bash
2#
3# Add a Klonkt instance. An instance is a data directory and an .env file; the
4# code in /opt/klonkt is shared with every other instance and is not copied.
5#
6# sudo bash scripts/klonkt-add-instance.sh <slug> <domain> [port]
7# sudo bash scripts/klonkt-add-instance.sh blog blog.example.com --no-caddy
8#
9# Leave the port out and a free one is chosen. Run klonkt-update once and every
10# instance on the machine moves to the new code together.
11
12set -euo pipefail
13
14KLONKT_DIR="${KLONKT_DIR:-/opt/klonkt}"
15KLONKT_USER="${KLONKT_USER:-klonkt}"
16DATA_ROOT="${KLONKT_DATA_ROOT:-/var/lib/klonkt}"
17NO_CADDY="${KLONKT_NO_CADDY:-}"
18LANG_DEFAULT="${KLONKT_DEFAULT_LANG:-}"
19
20SLUG=""; DOMAIN=""; PORT=""
21for arg in "$@"; do
22 case "$arg" in
23 --no-caddy) NO_CADDY=1 ;;
24 -*) echo "unknown option: $arg" >&2; exit 2 ;;
25 *) if [ -z "$SLUG" ]; then SLUG="$arg"
26 elif [ -z "$DOMAIN" ]; then DOMAIN="$arg"
27 elif [ -z "$PORT" ]; then PORT="$arg"
28 fi ;;
29 esac
30done
31
32say() { printf ' %s\n' "$*"; }
33step() { printf '\n== %s\n' "$*"; }
34die() { printf '\nERROR: %s\n' "$*" >&2; exit 1; }
35
36[ "$(id -u)" = 0 ] || die "run this as root (sudo)."
37[ -n "$SLUG" ] && [ -n "$DOMAIN" ] || die "usage: $0 <slug> <domain> [port] [--no-caddy]"
38[[ "$SLUG" =~ ^[a-z0-9][a-z0-9._-]*$ ]] || die "slug must be lowercase letters, digits, dot, dash or underscore."
39
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
74DATA_DIR="$DATA_ROOT/$SLUG"
75ENV_FILE="$DATA_DIR/.env"
76
77step "Preflight"
78[ -d "$KLONKT_DIR" ] || die "no shared code at $KLONKT_DIR. Install Klonkt first."
79[ -f "$KLONKT_DIR/src/config/paths.js" ] || die \
80 "this build is too old to share one checkout between instances.
81 Update first: klonkt-update"
82id -u "$KLONKT_USER" >/dev/null 2>&1 || die "user $KLONKT_USER does not exist"
83[ -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
109[ -f /etc/systemd/system/klonkt@.service ] || {
110 [ -f "$KLONKT_DIR/deploy/klonkt@.service" ] || die "missing $KLONKT_DIR/deploy/klonkt@.service"
111 install -m 0644 "$KLONKT_DIR/deploy/klonkt@.service" /etc/systemd/system/klonkt@.service
112 systemctl daemon-reload
113 say "installed the systemd template (first instance on this machine)"
114}
115
116# Pick a port nobody is listening on and no other instance has claimed.
117if [ -z "$PORT" ]; then
118 for p in $(seq 3000 3099); do
119 grep -rqs "^PORT=${p}$" "$DATA_ROOT"/*/.env && continue
120 ss -ltnH "sport = :$p" 2>/dev/null | grep -q . && continue
121 PORT="$p"; break
122 done
123 [ -n "$PORT" ] || die "no free port found in 3000-3099; pass one explicitly."
124fi
125say "slug $SLUG, domain $DOMAIN, port $PORT"
126
127step "Creating $DATA_DIR"
128mkdir -p "$DATA_DIR"
129
130step "Writing .env"
131SECRET="$(openssl rand -hex 32)"
132{
133 echo "NODE_ENV=production"
134 echo "PORT=${PORT}"
135 # Loopback only: the reverse proxy reaches it, the internet cannot bypass HTTPS.
136 echo "HOST=127.0.0.1"
137 echo "SESSION_SECRET=${SECRET}"
138 echo "PUBLIC_BASE_URL=https://${HOST_ASCII}"
139 echo "DATABASE_PATH=${DATA_DIR}/database.sqlite"
140 echo "MEDIA_PATH=${DATA_DIR}/media"
141 echo "AUDIO_PATH=${DATA_DIR}/audio"
142 [ -n "$LANG_DEFAULT" ] && echo "KLONKT_DEFAULT_LANG=${LANG_DEFAULT}"
143} > "$ENV_FILE"
144say "random SESSION_SECRET, data paths under $DATA_DIR"
145
146step "Ownership and permissions"
147chown -R "$KLONKT_USER:$KLONKT_USER" "$DATA_DIR"
148chmod 750 "$DATA_DIR"
149chmod 600 "$ENV_FILE"
150say "owned by $KLONKT_USER; .env readable only by that user"
151
152step "Starting klonkt@$SLUG"
153systemctl enable --now "klonkt@$SLUG"
154sleep 3
155systemctl is-active --quiet "klonkt@$SLUG" || {
156 echo; journalctl -u "klonkt@$SLUG" -n 30 --no-pager || true
157 die "klonkt@$SLUG did not start."
158}
159curl -fsS --max-time 8 -o /dev/null "http://127.0.0.1:${PORT}/" \
160 && say "responding on 127.0.0.1:${PORT}" \
161 || say "WARNING: no answer yet on 127.0.0.1:${PORT}; check journalctl -u klonkt@$SLUG -f"
162
163if [ -z "$NO_CADDY" ] && command -v caddy >/dev/null 2>&1; then
164 step "Caddy"
165 CADDY=/etc/caddy/Caddyfile
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"
176 else
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.
181 caddy validate --config "$CADDY" --adapter caddyfile >/dev/null 2>&1 \
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}"
186 fi
187fi
188
189cat <<EOF
190
191Instance ready.
192
193 data $DATA_DIR
194 unit klonkt@$SLUG
195 port 127.0.0.1:$PORT
196 code $KLONKT_DIR (shared with every other instance)
197
198Next: open https://${DOMAIN}/auth/register and create the admin account.
199
200 status systemctl status klonkt@$SLUG
201 logs journalctl -u klonkt@$SLUG -f
202 update klonkt-update # updates the code once, restarts all instances
203 backup $DATA_DIR # this directory is the whole instance
204EOF
Note: See TracBrowser for help on using the repository browser.