source: Klonkt/scripts/install.sh@ f882048

main
Last change on this file since f882048 was f882048, checked in by roboburr <roboburr@…>, 2 months ago

fix(install): a re-run keeps the install's existing channel (no silent switch to stable)

A re-run without --branch fell back to the stable default, which would silently switch an
existing main install onto stable. Now a re-run detects the branch the install already tracks
and stays on it; only an explicit --branch / KLONKT_BRANCH overrides, and a fresh install still
defaults to stable. The chosen channel is thus a persistent property of the install, not a
per-command default.

  • scripts/install.sh — BRANCH_EXPLICIT flag (set by env KLONKT_BRANCH or --branch); on an existing .git checkout keep the current branch unless explicitly overridden; log the channel

Co-Authored-By: Claude <noreply@…>

  • Property mode set to 100644
File size: 12.3 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}"
30KLONKT_USER="${KLONKT_USER:-klonkt}"
31KLONKT_PORT="${KLONKT_PORT:-3000}"
32KLONKT_DOMAIN="${KLONKT_DOMAIN:-}"
33KLONKT_LANG="${KLONKT_DEFAULT_LANG:-}"
34NODE_MAJOR="${NODE_MAJOR:-20}"
35NO_CADDY="${KLONKT_NO_CADDY:-}" # set to 1 to NEVER install Caddy (own proxy)
36NODE_FORCE="${NODE_FORCE:-}" # set to 1 to (re)install system Node anyway
37PORT_EXPLICIT=0
38BRANCH_EXPLICIT="${KLONKT_BRANCH_SET:-0}" # 1 = operator chose the channel (env or --branch)
39
40while [ $# -gt 0 ]; do
41 case "$1" in
42 --domain) KLONKT_DOMAIN="$2"; shift 2;;
43 --repo) KLONKT_REPO="$2"; shift 2;;
44 --branch) KLONKT_BRANCH="$2"; BRANCH_EXPLICIT=1; shift 2;;
45 --dir) KLONKT_DIR="$2"; shift 2;;
46 --port) KLONKT_PORT="$2"; PORT_EXPLICIT=1; shift 2;;
47 --lang) KLONKT_LANG="$2"; shift 2;;
48 --no-caddy) NO_CADDY=1; shift;;
49 --force-node) NODE_FORCE=1; shift;;
50 -h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0;;
51 *) echo "Unknown option: $1" >&2; exit 1;;
52 esac
53done
54
55log() { printf '\n\033[1;33m▸ %s\033[0m\n' "$*"; }
56ok() { printf '\033[1;32m ✓ %s\033[0m\n' "$*"; }
57warn() { printf '\033[1;33m ! %s\033[0m\n' "$*"; }
58die() { printf '\033[1;31m✗ %s\033[0m\n' "$*" >&2; exit 1; }
59as_klonkt() { runuser -u "$KLONKT_USER" -- env HOME="$KLONKT_DIR" "$@"; }
60port_busy() { ss -ltnH 2>/dev/null | awk '{print $4}' | grep -qE "[:.]${1}$"; }
61
62[ "$(id -u)" = 0 ] || die "Run this as root (sudo bash install.sh)."
63command -v apt-get >/dev/null || die "Debian/Ubuntu only (apt). On other systems use the Docker route."
64
65if [ -z "$KLONKT_DOMAIN" ]; then
66 read -rp "Domain for Klonkt (e.g. klonkt.example.com): " KLONKT_DOMAIN </dev/tty || true
67fi
68[ -n "$KLONKT_DOMAIN" ] || die "No domain given (--domain or KLONKT_DOMAIN)."
69case "$KLONKT_REPO" in
70 *OWNER/*) die "Set the real repo URL first: --repo https://github.com/<you>/klonkt.git (or KLONKT_REPO=...).";;
71esac
72
73export DEBIAN_FRONTEND=noninteractive
74
75# ── Preflight: see what's already running, adapt instead of clobbering ──────
76log "Preflight (what's already running?)…"
77apt-get update -y >/dev/null
78apt-get install -y iproute2 >/dev/null 2>&1 || true
79
80# Port: busy? With --port → error. Otherwise auto-pick a free one.
81if port_busy "$KLONKT_PORT"; then
82 if [ "$PORT_EXPLICIT" = 1 ]; then
83 die "Port ${KLONKT_PORT} is already in use. Pick a free port with --port."
84 fi
85 picked=""
86 for p in $(seq "$KLONKT_PORT" $((KLONKT_PORT+30))); do
87 port_busy "$p" || { picked="$p"; break; }
88 done
89 [ -n "$picked" ] || die "No free port found near ${KLONKT_PORT}. Provide one with --port."
90 warn "port ${KLONKT_PORT} busy → Klonkt uses ${picked}"
91 KLONKT_PORT="$picked"
92else
93 ok "port ${KLONKT_PORT} free"
94fi
95
96# Webserver on 80/443 that isn't Caddy? → skip Caddy, own-proxy mode.
97FOREIGN_PROXY=0
98if [ -z "$NO_CADDY" ] && command -v ss >/dev/null 2>&1; then
99 if ss -ltnpH 2>/dev/null | grep -E '[:.](80|443) ' | grep -viq 'caddy'; then
100 NO_CADDY=1; FOREIGN_PROXY=1
101 warn "something is already listening on port 80/443 (not Caddy) → NOT installing Caddy; you'll get proxy instructions"
102 fi
103fi
104
105# ── Node: respect an existing version, don't silently upgrade ──────────────
106log "Node ${NODE_MAJOR}.x…"
107if command -v node >/dev/null 2>&1 && [ -z "$NODE_FORCE" ]; then
108 CUR="$(node -v | sed 's/v//;s/\..*//')"
109 if [ "$CUR" -lt "$NODE_MAJOR" ]; then
110 die "Node $(node -v) is already installed on this server; Klonkt needs ≥${NODE_MAJOR}.
111 I will NOT auto-upgrade your system Node — that could break other apps.
112 Options: (a) use the Docker route (own Node, touches nothing), or
113 (b) upgrade Node yourself, or (c) force with NODE_FORCE=1 (at your own risk)."
114 fi
115 ok "using existing node $(node -v)"
116else
117 curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | bash -
118 apt-get install -y nodejs
119 ok "node $(node -v) installed"
120fi
121
122log "Other packages…"
123apt-get install -y curl ca-certificates git gnupg openssl build-essential python3
124apt-get install -y webp >/dev/null 2>&1 || true # cwebp = image→WebP (optional)
125ok "base packages"
126
127if [ -z "$NO_CADDY" ]; then
128 log "Caddy (reverse proxy + auto-HTTPS)…"
129 if ! command -v caddy >/dev/null 2>&1; then
130 curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
131 curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' > /etc/apt/sources.list.d/caddy-stable.list
132 apt-get update -y
133 apt-get install -y caddy
134 fi
135 ok "caddy present"
136fi
137
138log "Service user '${KLONKT_USER}'…"
139id -u "$KLONKT_USER" >/dev/null 2>&1 || useradd --system --home-dir "$KLONKT_DIR" --shell /usr/sbin/nologin "$KLONKT_USER"
140ok "user"
141
142log "Fetching Klonkt source…"
143if [ -d "$KLONKT_DIR/.git" ]; then
144 git -C "$KLONKT_DIR" remote set-url origin "$KLONKT_REPO"
145 # Re-run on an EXISTING install: keep the channel this install already tracks — never
146 # silently switch it to the stable default. Only an explicit --branch / KLONKT_BRANCH
147 # overrides; a fresh install (else-branch) uses the stable default.
148 if [ "$BRANCH_EXPLICIT" != "1" ]; then
149 _cur=$(git -C "$KLONKT_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || true)
150 [ -n "$_cur" ] && [ "$_cur" != "HEAD" ] && KLONKT_BRANCH="$_cur"
151 fi
152 log "Channel: $KLONKT_BRANCH"
153 git -C "$KLONKT_DIR" fetch --depth 1 origin "$KLONKT_BRANCH"
154 # Check out FETCH_HEAD AS the target branch — not `reset --hard origin/$KLONKT_BRANCH`
155 # (a single-branch/shallow clone, or one that started on a different branch like main,
156 # has no origin/<branch> ref → "ambiguous argument 'origin/stable'"), and not a plain
157 # `reset --hard FETCH_HEAD` (that would leave the OLD local branch, e.g. main, pointing at
158 # a stable commit → `git status` reports it as diverged from origin/main). `checkout -f -B`
159 # makes the local branch BE $KLONKT_BRANCH at the fetched tip: robust, forced, no divergence.
160 git -C "$KLONKT_DIR" checkout -qf -B "$KLONKT_BRANCH" FETCH_HEAD
161else
162 [ -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."
163 mkdir -p "$KLONKT_DIR"
164 git clone --depth 1 --branch "$KLONKT_BRANCH" "$KLONKT_REPO" "$KLONKT_DIR"
165fi
166mkdir -p "$KLONKT_DIR/storage/media" "$KLONKT_DIR/storage/audio"
167chown -R "$KLONKT_USER:$KLONKT_USER" "$KLONKT_DIR"
168ok "code in $KLONKT_DIR"
169
170log "Installing dependencies (npm ci)…"
171as_klonkt bash -c "cd '$KLONKT_DIR' && npm ci --omit=dev"
172ok "node_modules"
173
174log ".env…"
175ENV="$KLONKT_DIR/.env"
176if [ ! -f "$ENV" ]; then
177 SECRET="$(openssl rand -hex 32)"
178 {
179 echo "NODE_ENV=production"
180 echo "PORT=${KLONKT_PORT}"
181 # Bind to loopback only: Caddy (this host) reaches it; the internet cannot
182 # hit the app directly on its port, bypassing HTTPS.
183 echo "HOST=127.0.0.1"
184 echo "SESSION_SECRET=${SECRET}"
185 echo "DATABASE_PATH=./storage/database.sqlite"
186 echo "MEDIA_PATH=./storage/media"
187 echo "AUDIO_PATH=./storage/audio"
188 echo "PUBLIC_BASE_URL=https://${KLONKT_DOMAIN}"
189 [ -n "$KLONKT_LANG" ] && echo "KLONKT_DEFAULT_LANG=${KLONKT_LANG}"
190 } > "$ENV"
191 chown "$KLONKT_USER:$KLONKT_USER" "$ENV"; chmod 600 "$ENV"
192 ok "new .env (random SESSION_SECRET, app bound to 127.0.0.1)"
193else
194 # sync the port in an existing .env with the chosen port
195 if grep -q '^PORT=' "$ENV"; then sed -i "s/^PORT=.*/PORT=${KLONKT_PORT}/" "$ENV"; fi
196 # harden older installs: bind to loopback if not already configured
197 grep -q '^HOST=' "$ENV" || echo "HOST=127.0.0.1" >> "$ENV"
198 ok "kept existing .env (port synced, bound to 127.0.0.1)"
199fi
200
201log "systemd service…"
202NODE_BIN="$(command -v node)"
203cat > /etc/systemd/system/klonkt.service <<EOF
204[Unit]
205Description=Klonkt
206After=network-online.target
207Wants=network-online.target
208
209[Service]
210Type=simple
211User=${KLONKT_USER}
212WorkingDirectory=${KLONKT_DIR}
213ExecStart=${NODE_BIN} src/server.js
214Environment=NODE_ENV=production
215Restart=always
216RestartSec=3
217NoNewPrivileges=true
218ProtectSystem=full
219PrivateTmp=true
220
221[Install]
222WantedBy=multi-user.target
223EOF
224systemctl daemon-reload
225systemctl enable --now klonkt
226ok "klonkt.service running on 127.0.0.1:${KLONKT_PORT}"
227
228if [ -z "$NO_CADDY" ]; then
229 log "Caddy config for ${KLONKT_DOMAIN}…"
230 CADDY=/etc/caddy/Caddyfile
231 SITE_BLOCK="${KLONKT_DOMAIN} {
232 reverse_proxy 127.0.0.1:${KLONKT_PORT}
233 encode gzip zstd
234}"
235 touch "$CADDY"
236 if grep -q '/usr/share/caddy' "$CADDY"; then
237 cp "$CADDY" "${CADDY}.bak.$(date +%s)"
238 printf '%s\n' "$SITE_BLOCK" > "$CADDY"
239 elif ! grep -q "^${KLONKT_DOMAIN} {" "$CADDY"; then
240 printf '\n%s\n' "$SITE_BLOCK" >> "$CADDY"
241 fi
242 caddy validate --config "$CADDY" --adapter caddyfile >/dev/null 2>&1 || die "Caddy config invalid — check $CADDY"
243 systemctl reload caddy 2>/dev/null || systemctl restart caddy
244 ok "caddy serving ${KLONKT_DOMAIN}"
245fi
246
247log "Update command 'klonkt-update'…"
248cat > /usr/local/bin/klonkt-update <<EOF
249#!/usr/bin/env bash
250set -euo pipefail
251D="${KLONKT_DIR}"
252B=\$(runuser -u ${KLONKT_USER} -- git -C "\$D" rev-parse HEAD 2>/dev/null || true)
253runuser -u ${KLONKT_USER} -- git -C "\$D" fetch --depth 1 origin ${KLONKT_BRANCH}
254runuser -u ${KLONKT_USER} -- git -C "\$D" checkout -qf -B ${KLONKT_BRANCH} FETCH_HEAD
255A=\$(runuser -u ${KLONKT_USER} -- git -C "\$D" rev-parse HEAD)
256if [ "\$B" = "\$A" ]; then
257 echo "Klonkt is already up to date (\$A) — nothing to do."
258 exit 0
259fi
260if ! runuser -u ${KLONKT_USER} -- git -C "\$D" diff --quiet "\$B" "\$A" -- package-lock.json 2>/dev/null; then
261 runuser -u ${KLONKT_USER} -- env HOME="\$D" bash -c "cd '\$D' && npm ci --omit=dev"
262fi
263systemctl restart klonkt
264echo "Klonkt updated (\$A) + restarted."
265EOF
266chmod +x /usr/local/bin/klonkt-update
267ok "klonkt-update"
268
269echo
270echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
271echo " Klonkt is running! 🎉"
272echo
273if [ -n "$NO_CADDY" ]; then
274 echo " Klonkt listens on: http://127.0.0.1:${KLONKT_PORT}"
275 if [ "$FOREIGN_PROXY" = 1 ]; then
276 echo " A webserver is already running on 80/443 — put Klonkt behind it."
277 fi
278 echo " Example nginx:"
279 echo " location / { proxy_pass http://127.0.0.1:${KLONKT_PORT}; proxy_set_header Host \$host;"
280 echo " proxy_set_header X-Forwarded-Proto \$scheme; }"
281 echo " Example Caddy:"
282 echo " ${KLONKT_DOMAIN} { reverse_proxy 127.0.0.1:${KLONKT_PORT} }"
283else
284 echo " • Open your site: https://${KLONKT_DOMAIN}"
285fi
286echo " • First run: go to /auth/register and create your admin account."
287echo
288echo " Manage: systemctl status klonkt · journalctl -u klonkt -f · klonkt-update"
289echo " Lost password: cd ${KLONKT_DIR} && runuser -u ${KLONKT_USER} -- env HOME=${KLONKT_DIR} npm run reset-admin"
290echo
291echo " DNS: make sure A + AAAA of ${KLONKT_DOMAIN} point to this server."
292echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
Note: See TracBrowser for help on using the repository browser.