source: Klonkt/scripts/install.sh@ 3e1aab1

main
Last change on this file since 3e1aab1 was 5462bab, checked in by Robin <roboburr@…>, 6 weeks ago

Fix 500 op de Uiterlijk-pagina na een halve update (boiert.eu)

Bart kreeg een 500 op /admin/sites/<slug>/edit. Oorzaak was een gemengde
staat: zijn klonkt-update was gegenereerd voor de oude single-service-layout
en herstart nog klonkt.service, de unit die de migratie juist heeft
uitgezet. De code op schijf werd dus wel bijgewerkt, maar het draaiende
proces nooit herstart. De oude route rendert dan het nieuwe template, dat
een variabele verwacht die de oude route niet meegeeft: ReferenceError, en
Express cachet het gecompileerde template tot de volgende herstart.

Drie lagen gefixt:

  1. Het aliasveld degradeert bij een ontbrekende variabele naar leeg in plaats van de hele pagina mee te nemen (typeof-guard, zelfde patroon als auth-register). Een deploy-moment mag nooit een 500 opleveren.
  2. De nieuw-site-form gaf dezelfde ReferenceError ook met volledig nieuwe code: de /new-route rendert hetzelfde template maar gaf apAliases niet mee. Lokaal gereproduceerd en bevestigd gefixt (beide pagina's 200).
  3. De wortel: een gedeeld script dat /usr/local/bin/klonkt-update herschrijft voor de actuele layout. De migratie draait het voortaan zelf, install.sh genereert de updater er ook mee (kan nooit meer uiteenlopen), en al gemigreerde servers repareren het met een los commando.

Changed files:
src/routes/admin-sites.js

  • /new geeft apAliases mee aan het template

src/views/pages/admin-site-edit.ejs

  • typeof-guard op apAliases met uitleg waarom

scripts/klonkt-migrate-data.sh

  • herschrijft de updater na de unit-omschakeling; waarschuwt als het script in een oudere checkout ontbreekt

scripts/install.sh

  • inline updater-generatie vervangen door het gedeelde script

deploy/MULTI-INSTANCE.md

  • reparatie-instructie voor servers die voor deze fix zijn gemigreerd

New file:
scripts/klonkt-refresh-updater.sh

  • idempotent; detecteert de branch uit de checkout; herstart alle klonkt@<slug>-instances, of klonkt.service als er geen zijn

-robo
Co-Authored-By: Claude Fable 5 <noreply@…>

  • Property mode set to 100644
File size: 15.2 KB
Line 
1#!/usr/bin/env bash
2#
3# Klonkt — installer for a Debian/Ubuntu VPS.
4# Installs Node 20, Caddy (automatic HTTPS) and Klonkt as a systemd service.
5#
6# Safe on a server that ALREADY runs things: it won't upgrade your system Node,
7# auto-picks a free port, and skips Caddy if a webserver/reverse-proxy is already
8# listening on port 80/443 (you then get instructions to put Klonkt behind your
9# own proxy).
10#
11# Usage (as root), non-interactive:
12# curl -fsSL https://raw.githubusercontent.com/roboburr/klonkt/main/scripts/install.sh \
13# | sudo bash -s -- --domain klonkt.example.com
14# Or interactively from a downloaded file:
15# sudo bash install.sh
16#
17# Re-running on the same server = update (git pull + restart).
18# Fully isolated alternative: Docker (see docker-compose.yml in the repo).
19#
20set -euo pipefail
21
22# ── Settings (override via env var or flag) ────────────────────────────────
23KLONKT_REPO="${KLONKT_REPO:-https://github.com/roboburr/klonkt.git}"
24# `stable` = the release channel: it only moves forward to a version that has been verified,
25# so a self-host auto-update (klonkt-update) never pulls work-in-progress. Use `--branch main`
26# for the bleeding-edge dev branch instead.
27KLONKT_BRANCH_SET="${KLONKT_BRANCH:+1}" # channel chosen via env? (empty = no, "1" = yes)
28KLONKT_BRANCH="${KLONKT_BRANCH:-stable}"
29KLONKT_DIR="${KLONKT_DIR:-/opt/klonkt}"
30# Where instance data lives, one directory per slug. The code in KLONKT_DIR is
31# shared; everything an instance writes stays under here.
32KLONKT_DATA_ROOT="${KLONKT_DATA_ROOT:-/var/lib/klonkt}"
33# Short name for this instance: its directory under the data root and its
34# systemd unit (klonkt@<slug>). Derived from the domain when left empty.
35KLONKT_SLUG="${KLONKT_SLUG:-}"
36KLONKT_USER="${KLONKT_USER:-klonkt}"
37KLONKT_PORT="${KLONKT_PORT:-3000}"
38KLONKT_DOMAIN="${KLONKT_DOMAIN:-}"
39KLONKT_LANG="${KLONKT_DEFAULT_LANG:-}"
40NODE_MAJOR="${NODE_MAJOR:-20}"
41NO_CADDY="${KLONKT_NO_CADDY:-}" # set to 1 to NEVER install Caddy (own proxy)
42NODE_FORCE="${NODE_FORCE:-}" # set to 1 to (re)install system Node anyway
43PORT_EXPLICIT=0
44BRANCH_EXPLICIT="${KLONKT_BRANCH_SET:-0}" # 1 = operator chose the channel (env or --branch)
45
46while [ $# -gt 0 ]; do
47 case "$1" in
48 --domain) KLONKT_DOMAIN="$2"; shift 2;;
49 --repo) KLONKT_REPO="$2"; shift 2;;
50 --branch) KLONKT_BRANCH="$2"; BRANCH_EXPLICIT=1; shift 2;;
51 --dir) KLONKT_DIR="$2"; shift 2;;
52 --port) KLONKT_PORT="$2"; PORT_EXPLICIT=1; shift 2;;
53 --lang) KLONKT_LANG="$2"; shift 2;;
54 --no-caddy) NO_CADDY=1; shift;;
55 --force-node) NODE_FORCE=1; shift;;
56 -h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0;;
57 *) echo "Unknown option: $1" >&2; exit 1;;
58 esac
59done
60
61log() { printf '\n\033[1;33m▸ %s\033[0m\n' "$*"; }
62ok() { printf '\033[1;32m ✓ %s\033[0m\n' "$*"; }
63warn() { printf '\033[1;33m ! %s\033[0m\n' "$*"; }
64die() { printf '\033[1;31m✗ %s\033[0m\n' "$*" >&2; exit 1; }
65as_klonkt() { runuser -u "$KLONKT_USER" -- env HOME="$KLONKT_DIR" "$@"; }
66port_busy() { ss -ltnH 2>/dev/null | awk '{print $4}' | grep -qE "[:.]${1}$"; }
67
68[ "$(id -u)" = 0 ] || die "Run this as root (sudo bash install.sh)."
69command -v apt-get >/dev/null || die "Debian/Ubuntu only (apt). On other systems use the Docker route."
70
71if [ -z "$KLONKT_DOMAIN" ]; then
72 read -rp "Domain for Klonkt (e.g. klonkt.example.com): " KLONKT_DOMAIN </dev/tty || true
73fi
74[ -n "$KLONKT_DOMAIN" ] || die "No domain given (--domain or KLONKT_DOMAIN)."
75case "$KLONKT_REPO" in
76 *OWNER/*) die "Set the real repo URL first: --repo https://github.com/<you>/klonkt.git (or KLONKT_REPO=...).";;
77esac
78
79export DEBIAN_FRONTEND=noninteractive
80
81# ── Preflight: see what's already running, adapt instead of clobbering ──────
82log "Preflight (what's already running?)…"
83apt-get update -y >/dev/null
84apt-get install -y iproute2 >/dev/null 2>&1 || true
85
86# Port: busy? With --port → error. Otherwise auto-pick a free one.
87if port_busy "$KLONKT_PORT"; then
88 if [ "$PORT_EXPLICIT" = 1 ]; then
89 die "Port ${KLONKT_PORT} is already in use. Pick a free port with --port."
90 fi
91 picked=""
92 for p in $(seq "$KLONKT_PORT" $((KLONKT_PORT+30))); do
93 port_busy "$p" || { picked="$p"; break; }
94 done
95 [ -n "$picked" ] || die "No free port found near ${KLONKT_PORT}. Provide one with --port."
96 warn "port ${KLONKT_PORT} busy → Klonkt uses ${picked}"
97 KLONKT_PORT="$picked"
98else
99 ok "port ${KLONKT_PORT} free"
100fi
101
102# Webserver on 80/443 that isn't Caddy? → skip Caddy, own-proxy mode.
103FOREIGN_PROXY=0
104if [ -z "$NO_CADDY" ] && command -v ss >/dev/null 2>&1; then
105 if ss -ltnpH 2>/dev/null | grep -E '[:.](80|443) ' | grep -viq 'caddy'; then
106 NO_CADDY=1; FOREIGN_PROXY=1
107 warn "something is already listening on port 80/443 (not Caddy) → NOT installing Caddy; you'll get proxy instructions"
108 fi
109fi
110
111# ── Node: respect an existing version, don't silently upgrade ──────────────
112log "Node ${NODE_MAJOR}.x…"
113if command -v node >/dev/null 2>&1 && [ -z "$NODE_FORCE" ]; then
114 CUR="$(node -v | sed 's/v//;s/\..*//')"
115 if [ "$CUR" -lt "$NODE_MAJOR" ]; then
116 die "Node $(node -v) is already installed on this server; Klonkt needs ≥${NODE_MAJOR}.
117 I will NOT auto-upgrade your system Node — that could break other apps.
118 Options: (a) use the Docker route (own Node, touches nothing), or
119 (b) upgrade Node yourself, or (c) force with NODE_FORCE=1 (at your own risk)."
120 fi
121 ok "using existing node $(node -v)"
122else
123 curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | bash -
124 apt-get install -y nodejs
125 ok "node $(node -v) installed"
126fi
127
128log "Other packages…"
129apt-get install -y curl ca-certificates git gnupg openssl build-essential python3
130apt-get install -y webp >/dev/null 2>&1 || true # cwebp = image→WebP (optional)
131ok "base packages"
132
133if [ -z "$NO_CADDY" ]; then
134 log "Caddy (reverse proxy + auto-HTTPS)…"
135 if ! command -v caddy >/dev/null 2>&1; then
136 curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
137 curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' > /etc/apt/sources.list.d/caddy-stable.list
138 apt-get update -y
139 apt-get install -y caddy
140 fi
141 ok "caddy present"
142fi
143
144log "Service user '${KLONKT_USER}'…"
145id -u "$KLONKT_USER" >/dev/null 2>&1 || useradd --system --home-dir "$KLONKT_DIR" --shell /usr/sbin/nologin "$KLONKT_USER"
146ok "user"
147
148log "Fetching Klonkt source…"
149if [ -d "$KLONKT_DIR/.git" ]; then
150 git -C "$KLONKT_DIR" remote set-url origin "$KLONKT_REPO"
151 # Re-run on an EXISTING install: keep the channel this install already tracks — never
152 # silently switch it to the stable default. Only an explicit --branch / KLONKT_BRANCH
153 # overrides; a fresh install (else-branch) uses the stable default.
154 if [ "$BRANCH_EXPLICIT" != "1" ]; then
155 _cur=$(git -C "$KLONKT_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || true)
156 [ -n "$_cur" ] && [ "$_cur" != "HEAD" ] && KLONKT_BRANCH="$_cur"
157 fi
158 log "Channel: $KLONKT_BRANCH"
159 git -C "$KLONKT_DIR" fetch --depth 1 origin "$KLONKT_BRANCH"
160 # Check out FETCH_HEAD AS the target branch — not `reset --hard origin/$KLONKT_BRANCH`
161 # (a single-branch/shallow clone, or one that started on a different branch like main,
162 # has no origin/<branch> ref → "ambiguous argument 'origin/stable'"), and not a plain
163 # `reset --hard FETCH_HEAD` (that would leave the OLD local branch, e.g. main, pointing at
164 # a stable commit → `git status` reports it as diverged from origin/main). `checkout -f -B`
165 # makes the local branch BE $KLONKT_BRANCH at the fetched tip: robust, forced, no divergence.
166 git -C "$KLONKT_DIR" checkout -qf -B "$KLONKT_BRANCH" FETCH_HEAD
167else
168 [ -e "$KLONKT_DIR" ] && [ -n "$(ls -A "$KLONKT_DIR" 2>/dev/null)" ] && die "$KLONKT_DIR already exists and is not a git checkout. Pick --dir, or clean it up."
169 mkdir -p "$KLONKT_DIR"
170 git clone --depth 1 --branch "$KLONKT_BRANCH" "$KLONKT_REPO" "$KLONKT_DIR"
171fi
172chown -R "$KLONKT_USER:$KLONKT_USER" "$KLONKT_DIR"
173ok "code in $KLONKT_DIR"
174
175# --- where this instance keeps its data -------------------------------------
176# New installs put data in /var/lib/klonkt/<slug> so the checkout stays free of
177# user data and can be shared by more instances later. An install that already
178# has its .env inside the checkout is left exactly as it is: re-running the
179# installer must never move a live database. Convert those deliberately with
180# scripts/klonkt-migrate-data.sh.
181if [ -z "$KLONKT_SLUG" ]; then
182 KLONKT_SLUG="$(printf '%s' "${KLONKT_DOMAIN:-default}" | sed 's/^www\.//' | cut -d. -f1 \
183 | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9._-')"
184 [ -n "$KLONKT_SLUG" ] || KLONKT_SLUG=default
185fi
186# A database in the checkout but no .env is too ambiguous to guess at: refuse,
187# rather than start a fresh empty instance beside data nobody is reading.
188if [ ! -f "$KLONKT_DIR/.env" ] && [ -f "$KLONKT_DIR/storage/database.sqlite" ]; then
189 die "found $KLONKT_DIR/storage/database.sqlite but no .env next to it.
190 Put the .env back and re-run, or move the old storage/ aside first."
191fi
192if [ -f "$KLONKT_DIR/.env" ]; then
193 LAYOUT=legacy
194 ENV="$KLONKT_DIR/.env"
195 DATA_DIR="$KLONKT_DIR/storage"
196 SERVICE="klonkt"
197 mkdir -p "$DATA_DIR/media" "$DATA_DIR/audio"
198 chown -R "$KLONKT_USER:$KLONKT_USER" "$DATA_DIR"
199 ok "existing layout kept (data inside $KLONKT_DIR; split it with scripts/klonkt-migrate-data.sh)"
200else
201 LAYOUT=split
202 DATA_DIR="$KLONKT_DATA_ROOT/$KLONKT_SLUG"
203 ENV="$DATA_DIR/.env"
204 SERVICE="klonkt@${KLONKT_SLUG}"
205 mkdir -p "$DATA_DIR/media" "$DATA_DIR/audio"
206 chown -R "$KLONKT_USER:$KLONKT_USER" "$DATA_DIR"
207 chmod 750 "$DATA_DIR"
208 ok "data in $DATA_DIR (instance '$KLONKT_SLUG')"
209fi
210
211log "Installing dependencies (npm ci)…"
212as_klonkt bash -c "cd '$KLONKT_DIR' && npm ci --omit=dev"
213ok "node_modules"
214
215log ".env…"
216if [ ! -f "$ENV" ]; then
217 SECRET="$(openssl rand -hex 32)"
218 {
219 echo "NODE_ENV=production"
220 echo "PORT=${KLONKT_PORT}"
221 # Bind to loopback only: Caddy (this host) reaches it; the internet cannot
222 # hit the app directly on its port, bypassing HTTPS.
223 echo "HOST=127.0.0.1"
224 echo "SESSION_SECRET=${SECRET}"
225 # Absolute, so the app does not depend on its working directory and the
226 # data can sit outside the checkout. Media subdirectories (avatars,
227 # post-images, ...) follow MEDIA_PATH by themselves.
228 echo "DATABASE_PATH=${DATA_DIR}/database.sqlite"
229 echo "MEDIA_PATH=${DATA_DIR}/media"
230 echo "AUDIO_PATH=${DATA_DIR}/audio"
231 echo "PUBLIC_BASE_URL=https://${KLONKT_DOMAIN}"
232 [ -n "$KLONKT_LANG" ] && echo "KLONKT_DEFAULT_LANG=${KLONKT_LANG}"
233 } > "$ENV"
234 chown "$KLONKT_USER:$KLONKT_USER" "$ENV"; chmod 600 "$ENV"
235 ok "new .env (random SESSION_SECRET, app bound to 127.0.0.1)"
236else
237 # sync the port in an existing .env with the chosen port
238 if grep -q '^PORT=' "$ENV"; then sed -i "s/^PORT=.*/PORT=${KLONKT_PORT}/" "$ENV"; fi
239 # harden older installs: bind to loopback if not already configured
240 grep -q '^HOST=' "$ENV" || echo "HOST=127.0.0.1" >> "$ENV"
241 ok "kept existing .env (port synced, bound to 127.0.0.1)"
242fi
243
244log "systemd service…"
245NODE_BIN="$(command -v node)"
246if [ "$LAYOUT" = split ]; then
247 # One template, one service per instance. Adding a site later is a data
248 # directory plus an .env, with no second copy of the code.
249 sed -e "s#^User=klonkt\$#User=${KLONKT_USER}#" \
250 -e "s#^Group=klonkt\$#Group=${KLONKT_USER}#" \
251 -e "s#^WorkingDirectory=/opt/klonkt\$#WorkingDirectory=${KLONKT_DIR}#" \
252 -e "s#^EnvironmentFile=/var/lib/klonkt/%i/.env\$#EnvironmentFile=${KLONKT_DATA_ROOT}/%i/.env#" \
253 -e "s#^ReadWritePaths=/var/lib/klonkt/%i\$#ReadWritePaths=${KLONKT_DATA_ROOT}/%i#" \
254 -e "s#^ExecStart=/usr/bin/node src/server.js\$#ExecStart=${NODE_BIN} src/server.js#" \
255 "$KLONKT_DIR/deploy/klonkt@.service" > /etc/systemd/system/klonkt@.service
256 chmod 0644 /etc/systemd/system/klonkt@.service
257 systemctl daemon-reload
258 systemctl enable --now "klonkt@${KLONKT_SLUG}"
259 ok "klonkt@${KLONKT_SLUG} running on 127.0.0.1:${KLONKT_PORT}"
260else
261 cat > /etc/systemd/system/klonkt.service <<EOF
262[Unit]
263Description=Klonkt
264After=network-online.target
265Wants=network-online.target
266
267[Service]
268Type=simple
269User=${KLONKT_USER}
270WorkingDirectory=${KLONKT_DIR}
271ExecStart=${NODE_BIN} src/server.js
272Environment=NODE_ENV=production
273Restart=always
274RestartSec=3
275NoNewPrivileges=true
276ProtectSystem=full
277PrivateTmp=true
278
279[Install]
280WantedBy=multi-user.target
281EOF
282 systemctl daemon-reload
283 systemctl enable --now klonkt
284 ok "klonkt.service running on 127.0.0.1:${KLONKT_PORT}"
285fi
286
287if [ -z "$NO_CADDY" ]; then
288 log "Caddy config for ${KLONKT_DOMAIN}…"
289 CADDY=/etc/caddy/Caddyfile
290 SITE_BLOCK="${KLONKT_DOMAIN} {
291 reverse_proxy 127.0.0.1:${KLONKT_PORT}
292 encode gzip zstd
293}"
294 touch "$CADDY"
295 if grep -q '/usr/share/caddy' "$CADDY"; then
296 cp "$CADDY" "${CADDY}.bak.$(date +%s)"
297 printf '%s\n' "$SITE_BLOCK" > "$CADDY"
298 elif ! grep -q "^${KLONKT_DOMAIN} {" "$CADDY"; then
299 printf '\n%s\n' "$SITE_BLOCK" >> "$CADDY"
300 fi
301 caddy validate --config "$CADDY" --adapter caddyfile >/dev/null 2>&1 || die "Caddy config invalid — check $CADDY"
302 systemctl reload caddy 2>/dev/null || systemctl restart caddy
303 ok "caddy serving ${KLONKT_DOMAIN}"
304fi
305
306log "Update command 'klonkt-update'…"
307# Generated by the shared script so an install and a later layout migration
308# can never drift apart on what the updater restarts.
309KLONKT_DIR="$KLONKT_DIR" KLONKT_USER="$KLONKT_USER" \
310KLONKT_DATA_ROOT="$KLONKT_DATA_ROOT" KLONKT_BRANCH="$KLONKT_BRANCH" \
311 bash "$KLONKT_DIR/scripts/klonkt-refresh-updater.sh"
312ok "klonkt-update"
313
314echo
315echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
316echo " Klonkt is running! 🎉"
317echo
318if [ -n "$NO_CADDY" ]; then
319 echo " Klonkt listens on: http://127.0.0.1:${KLONKT_PORT}"
320 if [ "$FOREIGN_PROXY" = 1 ]; then
321 echo " A webserver is already running on 80/443 — put Klonkt behind it."
322 fi
323 echo " Example nginx:"
324 echo " location / { proxy_pass http://127.0.0.1:${KLONKT_PORT}; proxy_set_header Host \$host;"
325 echo " proxy_set_header X-Forwarded-Proto \$scheme; }"
326 echo " Example Caddy:"
327 echo " ${KLONKT_DOMAIN} { reverse_proxy 127.0.0.1:${KLONKT_PORT} }"
328else
329 echo " • Open your site: https://${KLONKT_DOMAIN}"
330fi
331echo " • First run: go to /auth/register and create your admin account."
332echo
333echo " Manage: systemctl status ${SERVICE} · journalctl -u ${SERVICE} -f · klonkt-update"
334echo " Lost password: cd ${KLONKT_DIR} && runuser -u ${KLONKT_USER} -- env HOME=${KLONKT_DIR} npm run reset-admin"
335if [ "$LAYOUT" = split ]; then
336 echo
337 echo " Code: ${KLONKT_DIR} shared, nothing of yours lives here"
338 echo " Data: ${DATA_DIR} database, uploads and .env — back up this one"
339 echo " Another site on this server, sharing the same code:"
340 echo " sudo bash ${KLONKT_DIR}/scripts/klonkt-add-instance.sh <slug> <domain>"
341fi
342echo
343echo " DNS: make sure A + AAAA of ${KLONKT_DOMAIN} point to this server."
344echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
Note: See TracBrowser for help on using the repository browser.