| 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 |
|
|---|
| 12 | set -euo pipefail
|
|---|
| 13 |
|
|---|
| 14 | KLONKT_DIR="${KLONKT_DIR:-/opt/klonkt}"
|
|---|
| 15 | KLONKT_USER="${KLONKT_USER:-klonkt}"
|
|---|
| 16 | DATA_ROOT="${KLONKT_DATA_ROOT:-/var/lib/klonkt}"
|
|---|
| 17 | NO_CADDY="${KLONKT_NO_CADDY:-}"
|
|---|
| 18 | LANG_DEFAULT="${KLONKT_DEFAULT_LANG:-}"
|
|---|
| 19 |
|
|---|
| 20 | SLUG=""; DOMAIN=""; PORT=""
|
|---|
| 21 | for 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
|
|---|
| 30 | done
|
|---|
| 31 |
|
|---|
| 32 | say() { printf ' %s\n' "$*"; }
|
|---|
| 33 | step() { printf '\n== %s\n' "$*"; }
|
|---|
| 34 | die() { 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.
|
|---|
| 51 | to_ascii() {
|
|---|
| 52 | if LC_ALL=C printf '%s' "$1" | grep -q '[^ -~]'; then
|
|---|
| 53 | python3 -c 'import codecs,sys
|
|---|
| 54 | print(".".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.
|
|---|
| 63 | stripped_ascii() {
|
|---|
| 64 | python3 -c 'import codecs,sys
|
|---|
| 65 | d = sys.argv[1]
|
|---|
| 66 | s = d.replace("", "").replace("️", "")
|
|---|
| 67 | if s == d: raise SystemExit(0)
|
|---|
| 68 | print(".".join(l if l.isascii() else "xn--" + codecs.encode(l, "punycode").decode()
|
|---|
| 69 | for l in s.split(".")))' "$1"
|
|---|
| 70 | }
|
|---|
| 71 |
|
|---|
| 72 | HOST_ASCII="$(to_ascii "$DOMAIN")"
|
|---|
| 73 |
|
|---|
| 74 | DATA_DIR="$DATA_ROOT/$SLUG"
|
|---|
| 75 | ENV_FILE="$DATA_DIR/.env"
|
|---|
| 76 |
|
|---|
| 77 | step "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"
|
|---|
| 82 | id -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 |
|
|---|
| 85 | if [ "$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
|
|---|
| 108 | fi
|
|---|
| 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.
|
|---|
| 117 | if [ -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."
|
|---|
| 124 | fi
|
|---|
| 125 | say "slug $SLUG, domain $DOMAIN, port $PORT"
|
|---|
| 126 |
|
|---|
| 127 | step "Creating $DATA_DIR"
|
|---|
| 128 | mkdir -p "$DATA_DIR"
|
|---|
| 129 |
|
|---|
| 130 | step "Writing .env"
|
|---|
| 131 | SECRET="$(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"
|
|---|
| 144 | say "random SESSION_SECRET, data paths under $DATA_DIR"
|
|---|
| 145 |
|
|---|
| 146 | step "Ownership and permissions"
|
|---|
| 147 | chown -R "$KLONKT_USER:$KLONKT_USER" "$DATA_DIR"
|
|---|
| 148 | chmod 750 "$DATA_DIR"
|
|---|
| 149 | chmod 600 "$ENV_FILE"
|
|---|
| 150 | say "owned by $KLONKT_USER; .env readable only by that user"
|
|---|
| 151 |
|
|---|
| 152 | step "Starting klonkt@$SLUG"
|
|---|
| 153 | systemctl enable --now "klonkt@$SLUG"
|
|---|
| 154 | sleep 3
|
|---|
| 155 | systemctl is-active --quiet "klonkt@$SLUG" || {
|
|---|
| 156 | echo; journalctl -u "klonkt@$SLUG" -n 30 --no-pager || true
|
|---|
| 157 | die "klonkt@$SLUG did not start."
|
|---|
| 158 | }
|
|---|
| 159 | curl -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 |
|
|---|
| 163 | if [ -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
|
|---|
| 187 | fi
|
|---|
| 188 |
|
|---|
| 189 | cat <<EOF
|
|---|
| 190 |
|
|---|
| 191 | Instance 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 |
|
|---|
| 198 | Next: 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
|
|---|
| 204 | EOF
|
|---|