-
-
Notifications
You must be signed in to change notification settings - Fork 135
Expand file tree
/
Copy pathinstall.sh
More file actions
2763 lines (2514 loc) · 118 KB
/
Copy pathinstall.sh
File metadata and controls
2763 lines (2514 loc) · 118 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/bin/bash
#
# ServerKit bootstrap installer.
#
# curl -fsSL https://serverkit.ai/install.sh | bash
#
# By default the script clones the repository and builds from source. Two
# environment switches change that:
#
# INSTALL_FROM_RELEASE=1 fetch a pre-built tarball instead of compiling
# BUILD_FROM_SOURCE=1 force a source build even when a release exists
# SERVERKIT_SKIP_SSL=1 run on plain HTTP (no HTTPS / no certbot attempt)
#
# Running your own reverse proxy (Caddy / Traefik / nginx) in front of the panel:
#
# SERVERKIT_EXTERNAL_PROXY=1 your proxy terminates TLS; ours must not
# redirect to HTTPS. Implies SERVERKIT_SKIP_SSL=1
# and configures the trusted-proxy client IP.
# SERVERKIT_PUBLIC_URL=... the public https:// URL browsers use. Required
# with EXTERNAL_PROXY or realtime updates break.
#
# Re-runnable installs:
#
# SERVERKIT_CONFIG=/path/install.conf KEY=VALUE defaults for any of the
# variables above. Explicit environment
# still wins over the file.
#
# The Flask backend runs straight on the host (it needs real system access);
# the React frontend is built to static files and served by the host nginx.
#
set -euo pipefail
# ---------------------------------------------------------------------------
# Declarative install config (SERVERKIT_CONFIG)
# ---------------------------------------------------------------------------
# Sourced before any default is applied, so a file entry behaves exactly like
# passing that variable on the command line. Runs this early — ahead of the
# terminal-styling helpers — so it can only use plain printf/exit.
load_install_config() {
local file="${SERVERKIT_CONFIG:-}"
[ -n "$file" ] || return 0
if [ ! -f "$file" ]; then
printf 'ERROR: SERVERKIT_CONFIG file not found: %s\n' "$file" >&2
exit 1
fi
# The file supplies DEFAULTS. A variable already present in the environment
# was passed explicitly for this run and must win, so apply keys one at a
# time and skip the ones already set — sourcing the file wholesale would
# invert that precedence. Parsing (rather than sourcing) also keeps a stray
# command in the file from executing as root.
local line key value
while IFS= read -r line || [ -n "$line" ]; do
line="${line#"${line%%[![:space:]]*}"}" # strip leading whitespace
case "$line" in ''|'#'*) continue ;; esac
case "$line" in 'export '*) line="${line#export }" ;; esac
case "$line" in *=*) ;; *) continue ;; esac
key="${line%%=*}"
value="${line#*=}"
# Names only; anything else is a typo, not a variable.
case "$key" in ''|*[!A-Za-z0-9_]*) continue ;; esac
value="${value%\"}"; value="${value#\"}"
value="${value%\'}"; value="${value#\'}"
[ -z "${!key+set}" ] || continue
export "$key=$value"
done < "$file"
}
load_install_config
# ---------------------------------------------------------------------------
# Settings and environment contract
# ---------------------------------------------------------------------------
SERVERKIT_DIR="${SERVERKIT_DIR:-/opt/serverkit}"
INSTALL_DIR="$SERVERKIT_DIR"
BASE_NAME="$(basename "$INSTALL_DIR")"
BASE_DIR="$(dirname "$INSTALL_DIR")"
DIR_A="$BASE_DIR/${BASE_NAME}-a"
DIR_B="$BASE_DIR/${BASE_NAME}-b"
VENV_DIR="${SERVERKIT_VENV_DIR:-$INSTALL_DIR/venv}"
LOG_DIR="/var/log/serverkit"
DATA_DIR="/var/lib/serverkit"
BACKUP_DIR="/var/backups/serverkit"
CONFIG_DIR="/etc/serverkit"
PYTHON_MIN="3.11"
# 3.13 included since issue #99: Debian 13 (trixie) ships ONLY python3.13 — no
# 3.11, no 3.12 — so a 3.11-3.12 gate could never be satisfied from its repos
# and every install there fell through to the source build. Ubuntu 26.04 lands
# in the same place. Raising the ceiling needed SQLAlchemy >= 2.0.31 (see
# backend/requirements.txt) and dropping two stacked @classmethod/@staticmethod
# decorators that 3.13 no longer collapses.
# 3.14 included since the 2026-08 distro-compatibility baseline: Arch/Manjaro
# rolling ship ONLY python 3.14 from their repos (no versioned python3.1x
# packages exist to fall back to), so a ceiling of 3.13 made every Arch-family
# install fall through to the source build — the same trap trixie was in.
PYTHON_MAX="3.14"
PYTHON_BIN=""
GITHUB_REPO="${GITHUB_REPO:-jhd3197/ServerKit}"
INSTALL_FROM_RELEASE="${INSTALL_FROM_RELEASE:-0}"
BUILD_FROM_SOURCE="${BUILD_FROM_SOURCE:-0}"
SERVERKIT_VERSION="${SERVERKIT_VERSION:-}"
VERSION="${VERSION:-${SERVERKIT_VERSION:-1.4.11}}"
CHANNEL="${CHANNEL:-Stable}"
# Install profile: what we put on the box, not what the box is allowed to do.
# minimal — panel + nginx + SQLite. No Docker.
# standard — + Docker and the compose plugin (the default).
# full — + recommended extensions and hardening.
# Everything a profile skips stays installable later from the panel. Set
# SERVERKIT_PROFILE to skip the interactive prompt. Thresholds below are
# mirrored by recommend_profile() in backend/app/services/install_profile_service.py
# — change one, change the other.
PROFILE="${SERVERKIT_PROFILE:-}"
RECOMMENDED_PROFILE="standard"
PROFILE_PROMPT_TIMEOUT="${SERVERKIT_PROFILE_TIMEOUT:-15}"
PANEL_DOMAIN="${PANEL_DOMAIN:-}"
PANEL_PORT="${PANEL_PORT:-80}"
SERVERKIT_SKIP_SSL="${SERVERKIT_SKIP_SSL:-0}"
# An external reverse proxy (Caddy, Traefik, nginx elsewhere) terminates TLS in
# front of the panel and speaks plain HTTP upstream. Our own nginx must then NOT
# redirect :80 to HTTPS — the proxy would follow that redirect straight back
# into itself, which is the "too many redirects" the browser reports. So this
# implies SKIP_SSL, and it also turns on the trusted-proxy client-IP path with
# two hops (their proxy appends the client, our nginx appends their proxy).
SERVERKIT_EXTERNAL_PROXY="${SERVERKIT_EXTERNAL_PROXY:-0}"
if [ "$SERVERKIT_EXTERNAL_PROXY" = "1" ]; then
SERVERKIT_SKIP_SSL=1
fi
# The public https:// URL browsers use. Behind a proxy the panel cannot infer
# it, and the Socket.IO handshake rejects the browser's Origin without it.
PANEL_PUBLIC_URL="${SERVERKIT_PUBLIC_URL:-}"
PANEL_PUBLIC_URL="${PANEL_PUBLIC_URL%/}"
SERVERKIT_OFFLINE_TARBALL="${SERVERKIT_OFFLINE_TARBALL:-}"
SERVERKIT_MIRROR_URL="${SERVERKIT_MIRROR_URL:-}"
# Runtime state populated as we go (declared up-front for `set -u`).
OS_FAMILY="unknown"
ARCH=""
DL_ARCH=""
PKG_MGR=""
SAFE_MODE=false
SSL_MODE="insecure"
FIRST_SLOT="$DIR_A"
# Resolve where this script (and thus the bundled scripts/lib helpers) lives so
# shared libraries load whether we run from a clone, a release tree, or piped
# through `curl | bash`. The clone-relative path is tried first; once the source
# is on disk under $INSTALL_DIR that path works too.
SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd || true)"
load_serverkit_lib() {
local name="$1" d
for d in "$SELF_DIR/scripts/lib" "$INSTALL_DIR/scripts/lib"; do
if [ -n "$d" ] && [ -f "$d/$name" ]; then
# shellcheck source=/dev/null
source "$d/$name"
return 0
fi
done
return 1
}
# ---------------------------------------------------------------------------
# Terminal styling
#
# Truecolor is used when the stream is an interactive terminal that has not
# opted out. The ServerKit identity is a violet ramp (V1 brightest .. V5
# deepest); status colors sit alongside it. Everything degrades to plain text
# when colour is unavailable so piped logs stay readable.
# ---------------------------------------------------------------------------
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-dumb}" != "dumb" ]; then
ESC=$'\033'
RST="${ESC}[0m"; BLD="${ESC}[1m"; DIMM="${ESC}[2m"
paint() { printf '%s[38;2;%d;%d;%dm' "$ESC" "$1" "$2" "$3"; }
else
RST=''; BLD=''; DIMM=''
paint() { :; }
fi
V1="$(paint 196 181 253)"; V2="$(paint 167 139 250)"; V3="$(paint 139 92 246)"
V4="$(paint 124 58 237)"; V5="$(paint 109 40 217)"
PAPER="$(paint 237 233 254)"; ASH="$(paint 165 160 190)"; FOG="$(paint 113 108 140)"
HUE_OK="$(paint 52 211 153)"; HUE_WARN="$(paint 250 204 21)"
HUE_ERR="$(paint 248 113 113)"; HUE_LINK="$(paint 103 232 249)"
# Line-level reporting. Distinct verbs, distinct marks, single indent.
good() { printf ' %s✔%s %s\n' "$HUE_OK" "$RST" "$1"; }
warn() { printf ' %s▴%s %s\n' "$HUE_WARN" "$RST" "$1"; }
halt() { printf ' %s✘%s %s\n' "$HUE_ERR" "$RST" "$1" >&2; exit 1; }
step() { printf ' %s❯%s %s\n' "$HUE_LINK" "$RST" "$1"; }
faint() { printf ' %s%s%s\n' "$FOG" "$1" "$RST"; }
# ---------------------------------------------------------------------------
# Phase headers
#
# Each major stage prints a numbered, time-stamped header followed by a violet
# rule. There is no global step total — the index plus elapsed clock is enough
# to read progress, and it never drifts out of sync with the actual path taken
# (source vs. release installs run different stages).
# ---------------------------------------------------------------------------
STARTED_AT=0
PHASE_N=0
clock() {
[ "$STARTED_AT" -gt 0 ] || { printf ''; return; }
local secs=$(( $(date +%s) - STARTED_AT ))
printf '%dm %02ds' "$((secs / 60))" "$((secs % 60))"
}
phase() {
PHASE_N=$((PHASE_N + 1))
local t; t="$(clock)"
printf '\n %s%s%02d%s %s%s%s %s%s%s\n' \
"$BLD" "$V3" "$PHASE_N" "$RST" "$BLD" "$1" "$RST" "$FOG" "$t" "$RST"
printf ' %s%s%s\n\n' "$V4" "──────────────────────────────────────" "$RST"
}
# ---------------------------------------------------------------------------
# Masthead
# ---------------------------------------------------------------------------
masthead() {
printf '\n'
printf ' %s%s▖▌▌%s %s%sServerKit%s %sv%s%s %s•%s %s%s%s\n' \
"$BLD" "$V2" "$RST" "$BLD" "$PAPER" "$RST" "$FOG" "$VERSION" "$RST" \
"$HUE_OK" "$RST" "$ASH" "$CHANNEL" "$RST"
printf ' %s%s▌▖▌%s %sSelf-hosted infrastructure, made simple.%s\n' \
"$BLD" "$V3" "$RST" "$ASH" "$RST"
printf ' %s%s▌▌▖%s %sWeb apps · Databases · Docker · Email · DNS · Security%s\n' \
"$BLD" "$V4" "$RST" "$FOG" "$RST"
printf ' %s%s▘▘▘%s %sPython + React, one command · serverkit.ai%s\n' \
"$BLD" "$V5" "$RST" "$FOG" "$RST"
printf '\n'
}
# ---------------------------------------------------------------------------
# Pre-flight: disk, memory, privileges
# ---------------------------------------------------------------------------
preflight() {
step "Running pre-flight checks..."
# Root first: every check and phase after this point may write to the
# host (choose_pkg_manager drops an apt config file), so a non-root run
# must die on THIS friendly message, not on the first raw
# "Permission denied". (I11)
if [ "$EUID" -ne 0 ]; then
halt "Please run this installer as root (use sudo)."
fi
# Source builds need more headroom than unpacking a release.
local need_kb=5242880
[ "$INSTALL_FROM_RELEASE" = "1" ] && need_kb=2097152
# Probe the filesystem that will actually hold the install — the target
# tree rarely exists yet, so walk up to the nearest existing ancestor —
# and use POSIX -Pk output so a long device name cannot wrap the line
# and skew the awk parse. (I14)
local probe="$INSTALL_DIR" parent
while [ ! -d "$probe" ]; do
parent="$(dirname "$probe")"
[ "$parent" != "$probe" ] || break
probe="$parent"
done
local free_kb
free_kb=$(df -Pk "$probe" 2>/dev/null | awk 'NR==2 {print $4}') || free_kb=""
if [ -n "$free_kb" ] && [ "$free_kb" -lt "$need_kb" ]; then
halt "Need at least $((need_kb / 1024 / 1024))GB free on $probe; less is available."
fi
# `free` is missing from some LXC templates — skip the advisory memory
# check rather than aborting on the bare assignment. (I14)
local free_mem=""
if command -v free &>/dev/null; then
free_mem=$(free -m 2>/dev/null | awk '/^Mem:/ {print $7}') || free_mem=""
else
warn "'free' not found — skipping the memory check."
fi
if [ -n "$free_mem" ] && [ "$free_mem" -lt 256 ]; then
warn "Under 256MB memory free — the install may run slowly."
fi
good "Pre-flight checks passed."
}
# ---------------------------------------------------------------------------
# Identify the OS family and CPU architecture
# ---------------------------------------------------------------------------
# Pure mapping from /etc/os-release ID (+ ID_LIKE fallback) to a ServerKit
# family. Kept as a standalone function so it is unit-testable without a real
# /etc/os-release. Echoes: debian|fedora|rhel|suse|arch|alpine|gentoo|unknown.
os_family_from() {
local id="$1" id_like="$2"
case "$id" in
ubuntu|linuxmint|pop|raspbian|elementary|zorin|debian|devuan)
printf 'debian\n'; return ;;
fedora|nobara)
printf 'fedora\n'; return ;;
rocky|almalinux|rhel|centos|ol|oracle|eurolinux)
printf 'rhel\n'; return ;;
opensuse*|sles|sled|suse|sle-micro)
printf 'suse\n'; return ;;
arch|manjaro|endeavouros|cachyos)
printf 'arch\n'; return ;;
alpine)
printf 'alpine\n'; return ;;
gentoo|funtoo)
printf 'gentoo\n'; return ;;
esac
# Unknown ID — map via ID_LIKE to the closest supported family. Check rhel
# before fedora: RHEL clones advertise ID_LIKE="rhel centos fedora" and want
# the RHEL Docker repo, whereas a pure Fedora spin only lists "fedora".
case " $id_like " in
*debian*|*ubuntu*) printf 'debian\n' ;;
*rhel*|*centos*) printf 'rhel\n' ;;
*fedora*) printf 'fedora\n' ;;
*suse*) printf 'suse\n' ;;
*arch*) printf 'arch\n' ;;
*alpine*) printf 'alpine\n' ;;
*gentoo*) printf 'gentoo\n' ;;
*) printf 'unknown\n' ;;
esac
}
identify_system() {
phase "Detecting System"
local os_release="${SERVERKIT_OS_RELEASE:-/etc/os-release}"
[ -f "$os_release" ] || halt "Cannot detect OS — $os_release is missing."
# os-release defines its own VERSION ("24.04.1 LTS ..."), which would
# clobber the installer's version string; ID/PRETTY_NAME are wanted
# globals (provision_python keys off ID), so source in place and restore
# just our VERSION afterwards. (I22)
local sk_version="$VERSION"
. "$os_release"
VERSION="$sk_version"
OS_FAMILY="$(os_family_from "${ID:-}" "${ID_LIKE:-}")"
case "$OS_FAMILY" in
unknown)
warn "Untested OS '${ID:-unknown}' (ID_LIKE='${ID_LIKE:-}') — continuing anyway." ;;
*)
if [ "${ID:-}" = "$OS_FAMILY" ] || \
{ [ "$OS_FAMILY" = "debian" ] && [ "${ID:-}" = "ubuntu" ]; }; then
good "Detected: ${PRETTY_NAME:-$ID} ($OS_FAMILY family)"
else
warn "Detected: ${PRETTY_NAME:-${ID:-unknown}} — treating as '$OS_FAMILY' family."
fi
;;
esac
ARCH=$(uname -m)
case "$ARCH" in
x86_64) DL_ARCH="amd64"; good "Architecture: x86_64" ;;
aarch64|arm64) DL_ARCH="arm64"; good "Architecture: ARM64" ;;
*) halt "Unsupported architecture: $ARCH" ;;
esac
}
# ---------------------------------------------------------------------------
# Package manager abstraction (apt / dnf / yum / zypper / pacman / apk / emerge)
# ---------------------------------------------------------------------------
# Detection order mirrors scripts/lib/pkg.sh. These run during the early
# dependency phase — before the repo (and thus scripts/lib) is on disk in the
# `curl | bash` path — so the logic is inline here and kept in sync with the lib.
choose_pkg_manager() {
if command -v apt-get &>/dev/null; then
PKG_MGR="apt"
# unattended-upgrades can hold the dpkg lock right after boot; tell
# apt to wait for it rather than failing outright.
mkdir -p /etc/apt/apt.conf.d
cat > /etc/apt/apt.conf.d/99-serverkit-lock-wait.conf <<'APT_EOF'
DPkg::Lock::Timeout "300";
APT_EOF
elif command -v dnf &>/dev/null; then
PKG_MGR="dnf"
elif command -v yum &>/dev/null; then
PKG_MGR="yum"
elif command -v zypper &>/dev/null; then
PKG_MGR="zypper"
elif command -v pacman &>/dev/null; then
PKG_MGR="pacman"
elif command -v apk &>/dev/null; then
PKG_MGR="apk"
elif command -v emerge &>/dev/null; then
PKG_MGR="emerge"
else
halt "No supported package manager found (need apt, dnf, yum, zypper, pacman, apk, or emerge)."
fi
}
refresh_pkg_index() {
# Best-effort by design (mirrors scripts/lib/pkg.sh pkg_refresh): a failed
# index refresh — flaky mirror, momentary lock — must never abort the
# install, so every arm is guarded and the function always returns 0.
# The --refresh flag is dnf-only; classic yum has no such flag, so its arm
# is a plain makecache (same as pkg.sh).
case "$PKG_MGR" in
apt) apt-get update -y >/dev/null 2>&1 || true ;;
dnf) dnf makecache --refresh >/dev/null 2>&1 || true ;;
yum) yum makecache >/dev/null 2>&1 || true ;;
zypper) zypper --non-interactive refresh >/dev/null 2>&1 || true ;;
pacman) pacman -Sy --noconfirm >/dev/null 2>&1 || true ;;
apk) apk update >/dev/null 2>&1 || true ;;
# Portage keeps a full repo snapshot; --sync just refreshes it.
emerge) emerge --sync >/dev/null 2>&1 || true ;;
esac
return 0
}
# Minimal images (Docker base layers, LXC templates, geerlingguy systemd
# containers) can lack curl AND ship with empty package indexes — every later
# phase (release fetch, NodeSource setup, git/venv installs) then fails in
# confusing, hard-to-read ways. Refresh the index once and bootstrap curl up
# front; both are best-effort, and later phases re-probe what they need.
ensure_bootstrap_tools() {
refresh_pkg_index
if ! command -v curl &>/dev/null; then
step "Installing curl (needed for release downloads and repo setup)..."
pkg_add curl ca-certificates
command -v curl &>/dev/null || \
warn "curl could not be installed — release downloads and NodeSource setup will fail; source-build paths remain."
fi
}
# Warn-and-continue package install. Output is captured so a failure can be
# reported with the manager's last lines — and that capture carries an
# `|| rc=$?` guard because a bare `out=$(apt-get ...)` assignment is itself
# an abort point under `set -e`: the old body died on the assignment, before
# it could print anything at all. Always returns 0; callers that *require* a
# package must probe the resulting state (command -v, locate_python, ...)
# rather than this exit code.
pkg_add() {
local out="" rc=0
case "$PKG_MGR" in
# -y is NOT enough on Debian/Ubuntu. It answers apt's own prompts but
# not debconf's: tzdata, keyboard-configuration and friends still open
# an interactive dialog and wait forever. Because this output is
# captured, the installer then hangs in total silence — observed as a
# 60-minute stall on ubuntu:22.04 with the prompt invisible. Worse
# under `curl | bash`, where stdin is the script itself, so debconf can
# read installer source as its answers. The Dpkg options cover the
# other prompt class (modified conffiles), which DEBIAN_FRONTEND alone
# does not suppress. Every other manager here was already guarded.
apt) out=$(DEBIAN_FRONTEND=noninteractive apt-get install -y \
-o Dpkg::Options::=--force-confold \
-o Dpkg::Options::=--force-confdef "$@" 2>&1) || rc=$? ;;
dnf) out=$(dnf install -y "$@" 2>&1) || rc=$? ;;
yum) out=$(yum install -y "$@" 2>&1) || rc=$? ;;
zypper) out=$(zypper --non-interactive install "$@" 2>&1) || rc=$? ;;
pacman) out=$(pacman -S --noconfirm "$@" 2>&1) || rc=$? ;;
apk) out=$(apk add "$@" 2>&1) || rc=$? ;;
# --noreplace: already-installed slots are a success, not an error.
# --ask=n: emerge never prompts by default; pin it anyway (every other
# arm here carries an explicit non-interactive guard).
emerge) out=$(emerge --noreplace --ask=n "$@" 2>&1) || rc=$? ;;
*) warn "No supported package manager — cannot install: $*"; return 0 ;;
esac
if [ "$rc" -ne 0 ]; then
warn "Could not install: $* (exit $rc)"
printf '%s\n' "$out" | tail -5 >&2 || true
fi
return 0
}
# ---------------------------------------------------------------------------
# RHEL-family (Rocky/Alma/RHEL/CentOS 9): upgrade openssh + openssl TOGETHER,
# up front, before any other dnf transaction runs.
#
# Rocky 9 images ship openssh linked against openssl-libs 3.0.x while the
# updates stream carries openssl-libs 3.5.x plus a matching openssh rebuild.
# Any later `dnf install` that pulls dependencies can transitively upgrade
# openssl-libs; if openssh is not upgraded in the SAME transaction, every new
# sshd fork dies with "OpenSSL version mismatch" — remote installs over SSH
# then cut themselves off mid-run. Upgrading both together lets openssh's
# scriptlet restart sshd against the matched libssl (KillMode=process keeps
# the live SSH session alive). Do NOT rewrite this with --exclude=openssl
# shapes; they fail on python3-libs symbol requirements. Best-effort: a box
# without the updates repo (or already current) is a clean no-op.
# ---------------------------------------------------------------------------
upgrade_rhel_crypto_stack() {
[ "$OS_FAMILY" = "rhel" ] || return 0
command -v dnf &>/dev/null || return 0
step "Upgrading openssh/openssl together (avoids sshd 'OpenSSL version mismatch')..."
dnf upgrade -y openssh openssh-server openssh-clients openssl openssl-libs openssl-devel 2>/dev/null || true
return 0
}
# ---------------------------------------------------------------------------
# Memory tuning: low-RAM safe mode and a swap fallback
# ---------------------------------------------------------------------------
gauge_memory() {
# `free` is missing from some LXC templates — degrade to "no safe mode"
# instead of aborting on the bare assignment under pipefail. (I14)
local total=""
if command -v free &>/dev/null; then
total=$(free -m 2>/dev/null | awk '/^Mem:/ {print $2}') || total=""
fi
if [ -z "$total" ]; then
SAFE_MODE=false
warn "Cannot read total memory ('free' missing) — skipping the low-RAM check."
return 0
fi
if [ "$total" -le 700 ]; then
SAFE_MODE=true
warn "Low RAM (${total}MB) — enabling VPS safe mode."
else
SAFE_MODE=false
fi
}
detect_container() {
# Echo the container flavour, or nothing on bare metal / a full VM. This
# matters more than core count: Docker frequently cannot run inside an
# unprivileged LXC or OpenVZ guest, so such a box can look adequate on
# paper and still be unable to host a single app.
if [ -f /.dockerenv ]; then
printf 'docker'
return 0
fi
if [ -d /proc/vz ] && [ ! -d /proc/bc ]; then
printf 'openvz'
return 0
fi
if command -v systemd-detect-virt &>/dev/null; then
local virt
virt=$(systemd-detect-virt --container 2>/dev/null) || virt=""
if [ -n "$virt" ] && [ "$virt" != "none" ]; then
printf '%s' "$virt"
return 0
fi
fi
# /proc/1/environ carries container= for LXC and systemd-nspawn.
if [ -r /proc/1/environ ]; then
if tr '\0' '\n' < /proc/1/environ 2>/dev/null | grep -q '^container=lxc'; then
printf 'lxc'
return 0
fi
fi
return 0
}
recommend_profile() {
# Pick the profile that this hardware can actually carry. Deliberately
# conservative — an operator can always override upward, but silently
# defaulting a 700MB VPS to a Docker install produces a box that installs
# cleanly and then OOMs on the first deploy.
local ram_mb=0 cores=1 disk_gb="" container=""
if command -v free &>/dev/null; then
ram_mb=$(free -m 2>/dev/null | awk '/^Mem:/ {print $2}') || ram_mb=0
fi
[ -n "$ram_mb" ] || ram_mb=0
if command -v nproc &>/dev/null; then
cores=$(nproc 2>/dev/null) || cores=1
fi
[ -n "$cores" ] || cores=1
# Free space on the volume that will hold images and backups.
if command -v df &>/dev/null; then
disk_gb=$(df -BG "$BASE_DIR" 2>/dev/null | awk 'NR==2 {gsub(/G/,"",$4); print $4}') || disk_gb=""
fi
container=$(detect_container)
RECOMMENDED_PROFILE="standard"
if [ "$container" = "lxc" ] || [ "$container" = "openvz" ]; then
RECOMMENDED_PROFILE="minimal"
return 0
fi
# 1.5GB, expressed in MB to stay in integer arithmetic.
if [ "$ram_mb" -gt 0 ] && [ "$ram_mb" -lt 1536 ]; then
RECOMMENDED_PROFILE="minimal"
return 0
fi
if [ -n "$disk_gb" ] && [ "$disk_gb" -lt 5 ]; then
RECOMMENDED_PROFILE="minimal"
return 0
fi
if [ "$ram_mb" -ge 4096 ] && [ "$cores" -ge 4 ]; then
if [ -z "$disk_gb" ] || [ "$disk_gb" -ge 20 ]; then
RECOMMENDED_PROFILE="full"
fi
fi
return 0
}
prompt_for_profile() {
# Honour an explicit choice and never ask again.
case "$PROFILE" in
minimal|standard|full)
good "Install profile: $PROFILE (from SERVERKIT_PROFILE)"
return 0
;;
"")
;;
*)
warn "Unknown SERVERKIT_PROFILE='$PROFILE' — ignoring it."
PROFILE=""
;;
esac
recommend_profile
# curl | bash has no interactive stdin. Take the recommendation rather than
# blocking forever. SERVERKIT_FORCE_PROMPT=1 lets the unit tests drive this
# from a pipe (same hook prompt_for_domain uses).
if [ ! -t 0 ] && [ "${SERVERKIT_FORCE_PROMPT:-0}" != "1" ]; then
PROFILE="$RECOMMENDED_PROFILE"
good "Install profile: $PROFILE (auto-detected)"
return 0
fi
printf '\n'
printf '%sHow much should we install?%s\n' "$BLD" "$RST"
printf ' %s1) minimal%s Panel, nginx and SQLite. No Docker.\n' "$BLD" "$RST"
printf ' Monitoring, domains, certificates, cron and DNS all work.\n'
printf ' %s2) standard%s Adds Docker so this server can host apps.\n' "$BLD" "$RST"
printf ' %s3) full%s Adds recommended extensions and hardening.\n' "$BLD" "$RST"
printf '\n'
printf 'Detected hardware suggests: %s%s%s\n' "$BLD" "$RECOMMENDED_PROFILE" "$RST"
printf 'Anything skipped can be installed later from the panel.\n'
printf '%sTip:%s set SERVERKIT_PROFILE=minimal|standard|full to skip this prompt\n' "$BLD" "$RST"
printf '> [%s] ' "$RECOMMENDED_PROFILE"
local answer=""
# A timeout keeps a half-attended install moving instead of parking on the
# prompt; the recommendation is the fallback either way.
read -r -t "$PROFILE_PROMPT_TIMEOUT" answer || answer=""
answer=$(printf '%s' "$answer" | tr -d ' ' | tr '[:upper:]' '[:lower:]')
case "$answer" in
1|minimal) PROFILE="minimal" ;;
2|standard) PROFILE="standard" ;;
3|full) PROFILE="full" ;;
"") PROFILE="$RECOMMENDED_PROFILE" ;;
*)
warn "Unrecognised answer '$answer' — using $RECOMMENDED_PROFILE."
PROFILE="$RECOMMENDED_PROFILE"
;;
esac
if [ "$PROFILE" != "minimal" ] && [ "$RECOMMENDED_PROFILE" = "minimal" ]; then
warn "Overriding the Minimal recommendation on a constrained host."
warn "Docker may fail to start, or deploys may be OOM-killed."
fi
good "Install profile: $PROFILE"
return 0
}
ensure_swap() {
# No `free` (some LXC templates) → cannot gauge swap; skip quietly. (I14)
if ! command -v free &>/dev/null; then
warn "'free' not found — skipping the swap check."
return 0
fi
# The swapfile path and /proc/swaps are overridable so the unit tests can
# exercise this against fixtures (same pattern as SERVERKIT_NGINX_DIR).
local swapfile="${SERVERKIT_SWAPFILE:-/swapfile}"
local proc_swaps="${SERVERKIT_PROC_SWAPS:-/proc/swaps}"
local swap
swap=$(free -m 2>/dev/null | awk '/^Swap:/ {print $2}') || swap=""
[ -n "$swap" ] || return 0
if [ "$swap" -lt 512 ]; then
step "Adding 1GB of swap..."
# Every arm is guarded: fallocate is unsupported on some filesystems,
# dd dies on a full disk, and swapon fails outright on btrfs/zfs and
# in most containers. The old body claimed "Swap active." no matter
# what and the Vite build then OOM'd — verify via swapon --show /
# /proc/swaps before saying so, and degrade to a warning when no swap
# could actually be brought up. (I19)
if [ ! -f "$swapfile" ]; then
if fallocate -l 1G "$swapfile" 2>/dev/null || \
dd if=/dev/zero of="$swapfile" bs=1M count=1024 status=none 2>/dev/null; then
chmod 600 "$swapfile" 2>/dev/null || true
mkswap "$swapfile" >/dev/null 2>&1 || true
else
# A dd that died on a full disk leaves a partial file behind.
rm -f "$swapfile" 2>/dev/null || true
fi
fi
swapon "$swapfile" 2>/dev/null || true
if swapon --show 2>/dev/null | grep -q "$swapfile" || \
grep -qs "$swapfile" "$proc_swaps"; then
good "Swap active."
else
warn "Could not activate swap (full disk, unsupported filesystem, or container limits)."
warn "Continuing without it — a low-RAM box may need swap for the frontend build."
fi
fi
return 0
}
# ---------------------------------------------------------------------------
# Python 3.11–3.14 — detect a usable interpreter or build one
# ---------------------------------------------------------------------------
ver_in_range() {
# true when $1 is >= PYTHON_MIN and <= PYTHON_MAX
printf '%s\n%s' "$PYTHON_MIN" "$1" | sort -C -V && \
printf '%s\n%s' "$1" "$PYTHON_MAX" | sort -C -V
}
# True when <python> can actually create virtual environments. Debian/Ubuntu
# minimal images split the venv module (ensurepip) into pythonX.Y-venv, so an
# otherwise-valid interpreter still dies much later at `python -m venv` with
# "ensurepip is not available" — probe up front instead. (I8)
# NB: `-m venv --help` alone is NOT enough — Debian's python answers --help
# happily without ensurepip installed, and only fails at creation time. The
# ensurepip import is the real check. (Found via the Test Sandbox full mode
# on the geerlingguy debian12 image.)
py_venv_ok() {
"$1" -m venv --help >/dev/null 2>&1 && \
"$1" -c 'import ensurepip' >/dev/null 2>&1
}
locate_python() {
# Prefer an explicit minor version, newest first, then bare python3. This
# mirrors scripts/update.sh so a box that has python3.11 (Debian 12) but a
# too-new/too-old default python3 is still recognized. A candidate must
# also be venv-capable (py_venv_ok); on Debian-family boxes the missing
# pythonX.Y-venv package is installed on demand before the candidate is
# given up on. Sets PYTHON_BIN and returns 0 on success; returns 1
# (without aborting) when nothing fits.
local c v
for c in python3.14 python3.13 python3.12 python3.11 python3; do
if command -v "$c" &>/dev/null; then
v=$("$c" -c 'import sys;print(".".join(map(str,sys.version_info[:2])))' 2>/dev/null || true)
if [ -n "$v" ] && ver_in_range "$v"; then
if ! py_venv_ok "$c" && [ "$OS_FAMILY" = "debian" ]; then
step "Installing python${v}-venv (the venv module is missing)..."
pkg_add "python${v}-venv"
py_venv_ok "$c" || pkg_add python3-venv
fi
if ! py_venv_ok "$c"; then
warn "$c (Python $v) cannot create virtualenvs (no venv/ensurepip) — skipping it."
continue
fi
PYTHON_BIN="$c"
good "Using $c (Python $v)"
return 0
fi
fi
done
PYTHON_BIN=""
return 1
}
build_python_from_source() {
local ver="3.12.8" src="/tmp/Python-3.12.8"
# Every step is checked and the real error is shown. The old version piped
# each command to `tail -1` and ignored the status, so a failed configure
# looked identical to a successful one and the install died much later with
# "Could not install a supported Python" and no reason (issue #99).
#
# No --enable-optimizations: PGO triples-to-quadruples the build (20+ min on
# a small VPS) to buy runtime speed a control panel never notices. This is a
# last-resort path; finishing matters more than a faster interpreter.
(
set -e
cd /tmp
step "Downloading Python $ver source..."
wget -q "https://www.python.org/ftp/python/$ver/Python-$ver.tgz"
tar xzf "Python-$ver.tgz"
cd "Python-$ver"
step "Configuring Python $ver (this takes a few minutes)..."
./configure --prefix=/usr/local > /tmp/python-build.log 2>&1
step "Compiling Python $ver..."
make -j"$(nproc)" >> /tmp/python-build.log 2>&1
make altinstall >> /tmp/python-build.log 2>&1
)
local rc=$?
rm -rf "$src" "/tmp/Python-$ver.tgz"
if [ "$rc" != "0" ]; then
warn "Building Python $ver failed. Last 20 lines:"
tail -20 /tmp/python-build.log 2>/dev/null | sed 's/^/ /' >&2
warn "Full log: /tmp/python-build.log"
return 1
fi
# A source build silently omits modules whose headers were missing at
# configure time. No ssl means every HTTPS fetch dies; no sqlite3 means the
# database never opens — both far from here and impossible to connect back.
local built="/usr/local/bin/python${ver%.*}"
if [ -x "$built" ]; then
local missing=""
for mod in ssl sqlite3 ctypes; do
"$built" -c "import $mod" >/dev/null 2>&1 || missing="$missing $mod"
done
if [ -n "$missing" ]; then
warn "The Python we built is missing stdlib modules:$missing"
warn "Install the matching -dev packages and re-run the installer."
return 1
fi
fi
return 0
}
provision_python() {
phase "Installing Python"
# Already have a supported interpreter? Nothing to do.
if locate_python; then
return
fi
warn "No supported Python ($PYTHON_MIN–$PYTHON_MAX) found — installing one."
# Prefer the distro's own package over a source compile. Debian 12 ships
# python3.11; Ubuntu 24.04 ships python3.12 (older Ubuntu falls back to the
# deadsnakes PPA); Fedora/RHEL provide 3.12 or 3.11 in their repos. A slow,
# fragile source build is now strictly the last resort. pkg_add is
# warn-and-continue (always returns 0), so each attempt is followed by a
# state probe (command -v) to decide whether the next fallback is needed.
if [ "$OS_FAMILY" = "debian" ]; then
if [ "${ID:-}" = "ubuntu" ]; then
pkg_add python3.12 python3.12-venv python3.12-dev
if ! command -v python3.12 &>/dev/null; then
step "Adding deadsnakes PPA for Python 3.12..."
pkg_add software-properties-common
add-apt-repository -y ppa:deadsnakes/ppa || true
refresh_pkg_index
pkg_add python3.12 python3.12-venv python3.12-dev
fi
else
# Debian (and Debian-like). The default `python3` tracks the
# release — bookworm 3.11, trixie 3.13 — so ask for it FIRST rather
# than naming a version. Trixie carries no python3.11 or python3.12
# at all, so the old "install python3.11, else python3.12" pair
# could only ever fail there (issue #99), and hardcoding 3.13
# instead would just move the same trap to the next release.
# The explicit fallbacks below cover releases whose default is out
# of range. locate_python (not `command -v`) is the probe, because
# a binary existing says nothing about it being in range or
# venv-capable.
refresh_pkg_index
pkg_add python3 python3-venv python3-dev
locate_python >/dev/null 2>&1 || \
pkg_add python3.13 python3.13-venv python3.13-dev
locate_python >/dev/null 2>&1 || \
pkg_add python3.12 python3.12-venv python3.12-dev
locate_python >/dev/null 2>&1 || \
pkg_add python3.11 python3.11-venv python3.11-dev
fi
elif [ "$OS_FAMILY" = "fedora" ] || [ "$OS_FAMILY" = "rhel" ]; then
pkg_add python3.12 python3.12-devel
command -v python3.12 &>/dev/null || pkg_add python3.11 python3.11-devel
elif [ "$OS_FAMILY" = "suse" ]; then
pkg_add python311 python311-devel
command -v python3.11 &>/dev/null || pkg_add python312 python312-devel
elif [ "$OS_FAMILY" = "arch" ]; then
# Rolling release — `python` tracks the newest upstream line the gate
# covers (3.14 as of the 2026-08 baseline). Arch carries no versioned
# python3.1x packages, so there is nothing to fall back to here; if the
# rolling package ever outruns the gate, locate_python below rejects it
# and the source-build path takes over.
pkg_add python
elif [ "$OS_FAMILY" = "alpine" ]; then
pkg_add python3 python3-dev
elif [ "$OS_FAMILY" = "gentoo" ]; then
# Portage carries every supported line as a parallel slot.
pkg_add dev-lang/python:3.13
command -v python3.13 &>/dev/null || pkg_add dev-lang/python:3.12
fi
# Did a distro package give us something usable?
if locate_python; then
good "Python ready: $PYTHON_BIN"
return
fi
# Last resort: compile from source.
warn "Distro packages did not provide a supported Python — building from source."
# A COMPILER, first. This list was only the -dev headers: nothing anywhere
# in the installer ever installed gcc or make, so `./configure` could not
# run on any clean VPS and this whole fallback was decorative (issue #99).
if [ "$OS_FAMILY" = "debian" ]; then
pkg_add build-essential wget zlib1g-dev libbz2-dev libreadline-dev \
libsqlite3-dev libncurses5-dev libncursesw5-dev \
xz-utils tk-dev liblzma-dev libffi-dev libssl-dev
elif [ "$OS_FAMILY" = "suse" ]; then
pkg_add gcc gcc-c++ make wget zlib-devel libbz2-devel readline-devel \
sqlite3-devel ncurses-devel xz-devel tk-devel libffi-devel \
libopenssl-devel
elif [ "$OS_FAMILY" = "gentoo" ]; then
pkg_add sys-devel/gcc sys-devel/make net-misc/wget sys-libs/zlib \
dev-libs/bzip2 sys-libs/readline dev-db/sqlite sys-libs/ncurses \
dev-libs/libffi dev-libs/openssl
else
pkg_add gcc gcc-c++ make wget zlib-devel bzip2-devel readline-devel \
sqlite-devel ncurses-devel xz-devel tk-devel libffi-devel \
openssl-devel
fi
if ! command -v cc >/dev/null 2>&1 && ! command -v gcc >/dev/null 2>&1; then
halt "No C compiler available and no packaged Python $PYTHON_MIN-$PYTHON_MAX on ${ID:-this distro}.
Install one by hand, then re-run: apt-get install build-essential (or: dnf group install \"Development Tools\")"
fi
if ! build_python_from_source; then
halt "Could not build Python from source — see the log above.
Install Python $PYTHON_MIN-$PYTHON_MAX by hand and re-run the installer."
fi
PYTHON_BIN="python3.12"
command -v "$PYTHON_BIN" &>/dev/null || \
halt "Could not install a supported Python — install Python $PYTHON_MIN-$PYTHON_MAX by hand."
good "Python installed ($PYTHON_BIN)."
}
# ---------------------------------------------------------------------------
# Docker engine + compose plugin
# ---------------------------------------------------------------------------
# Add Docker's upstream .repo file, handling both config-manager generations:
# classic dnf4 uses `--add-repo URL`, while dnf5 (Fedora 41+) removed that
# flag in favour of `addrepo --from-repofile=URL`. Warn-and-continue: when
# neither works the docker-ce install below fails too (also non-fatally). (I10)
docker_repo_add() {
local url="$1"
if dnf config-manager --add-repo "$url" >/dev/null 2>&1; then
return 0
fi
if dnf config-manager addrepo --from-repofile="$url" >/dev/null 2>&1; then
return 0
fi
warn "Could not add the Docker repository ($url)."
return 0
}
provision_docker() {
phase "Docker"
# The Minimal profile deliberately ships without Docker. The panel detects
# its absence at runtime and hides app hosting; adding Docker later from
# Settings promotes the install to Standard.
if [ "$PROFILE" = "minimal" ]; then
good "Skipping Docker (minimal profile) — add it later from the panel."
return
fi
if command -v docker &>/dev/null; then
good "Docker already present: $(docker --version | head -1)"
return
fi
step "Installing Docker..."
case "$OS_FAMILY" in
fedora)
pkg_add dnf-plugins-core
docker_repo_add https://download.docker.com/linux/fedora/docker-ce.repo
pkg_add docker-ce docker-ce-cli containerd.io docker-compose-plugin docker-buildx-plugin
;;
rhel)
pkg_add dnf-plugins-core
docker_repo_add https://download.docker.com/linux/rhel/docker-ce.repo
pkg_add docker-ce docker-ce-cli containerd.io docker-compose-plugin docker-buildx-plugin
;;
suse)
pkg_add docker docker-compose
;;
arch)
pkg_add docker docker-compose
;;
alpine)
pkg_add docker docker-cli-compose
;;
*)
# Docker's convenience script (the default Debian/Ubuntu path).
# Stage it to a temp file with retries instead of piping curl
# straight into sh — a connection dropped mid-download would
# otherwise execute half a script. Warn-and-continue on failure,
# falling back to the distro package (docker.io on Debian). (I13)
local dget drc=0
dget="$(mktemp 2>/dev/null)" || dget="/tmp/serverkit-get-docker.sh"
if curl -fsSL --retry 3 https://get.docker.com -o "$dget"; then
sh "$dget" || drc=$?
else
drc=1
fi
rm -f "$dget" 2>/dev/null || true
if [ "$drc" -ne 0 ]; then
warn "The get.docker.com install script failed — trying the distro package instead."
pkg_add docker.io docker-compose-v2
fi
;;
esac
# Enable + start across init systems (systemd on most families, OpenRC on
# Alpine). Guarded so a non-systemd box doesn't abort the install here.
if command -v systemctl &>/dev/null && [ -d /run/systemd/system ]; then
systemctl enable docker 2>/dev/null || true
systemctl start docker 2>/dev/null || true
elif command -v rc-update &>/dev/null; then
rc-update add docker default 2>/dev/null || true
rc-service docker start 2>/dev/null || true
fi