#!/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) # # The first account becomes the administrator, so a fresh panel asks for a # one-time setup code (printed at the end; `serverkit setup-code` repeats it). # Unattended installs can create the admin instead and skip the code: # # SERVERKIT_ADMIN_EMAIL=... with SERVERKIT_ADMIN_PASSWORD (8+ chars); # SERVERKIT_ADMIN_USERNAME=... optional, defaults to the email's local part # # 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 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 } python_build_ready() { command -v make >/dev/null 2>&1 || return 1 # Compile against THIS interpreter's headers, including pyconfig.h. Merely # finding a Python executable or a different version's -dev package is not # enough for pip's native dependencies (issue #148). "$PYTHON_BIN" - <<'PY' >/dev/null 2>&1 import pathlib import shlex import subprocess import sysconfig import tempfile with tempfile.TemporaryDirectory(prefix='serverkit-python-build-') as work: source = pathlib.Path(work) / 'probe.c' source.write_text('#include \nint main(void) { return 0; }\n') includes = {sysconfig.get_path('include'), sysconfig.get_path('platinclude')} command = shlex.split(sysconfig.get_config_var('CC') or 'cc') command += ['-I' + path for path in includes if path] command += ['-c', str(source), '-o', str(pathlib.Path(work) / 'probe.o')] subprocess.run(command, check=True) PY } ensure_python_build_deps() { python_build_ready && return 0 local v v=$("$PYTHON_BIN" -c 'import sys;print(".".join(map(str,sys.version_info[:2])))') step "Installing build tools and headers for Python $v..." refresh_pkg_index case "$OS_FAMILY" in debian) pkg_add build-essential "python${v}-dev" ;; fedora|rhel) pkg_add gcc make "python${v}-devel" ;; suse) pkg_add gcc make "python${v//./}-devel" ;; arch) pkg_add base-devel python ;; alpine) pkg_add build-base python3-dev ;; gentoo) pkg_add sys-devel/gcc sys-devel/make "dev-lang/python:${v}" ;; esac # pkg_add deliberately warns and continues. Probe again so failure is # reported here, rather than much later as an opaque pip build failure. if ! python_build_ready; then halt "Native Python dependencies cannot be built with $PYTHON_BIN (Python $v). Install a C compiler, make, and the matching Python development headers, then re-run. On Debian/Ubuntu: apt-get install build-essential python${v}-dev" return 1 fi } provision_python() { phase "Installing Python" # Native build prerequisites are checked by build_virtualenv only when a # local build is needed; a working release venv does not need a compiler. 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 refresh_pkg_index pkg_add python3 python3-venv python3-dev if locate_python; then return fi 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 # Don't claim success the old unconditional way — probe. Docker stays # best-effort here (managed app deploys need it, the panel itself runs # without it), so a miss is a loud warning, not a halt. (I13) if command -v docker &>/dev/null; then good "Docker installed." else warn "Docker could not be installed — managed app deployments need it." warn "Install it manually (https://docs.docker.com/engine/install/) when you need containers." fi } ensure_compose_plugin() { # No Docker on the minimal profile, so nothing to plug into. if [ "$PROFILE" = "minimal" ]; then return fi if docker compose version &>/dev/null; then good "Docker Compose plugin present." return fi step "Installing Docker Compose plugin..." # Fallback chain (I9): when Docker came from the distro repo (docker.io), # Docker's own repo was never configured, so 'docker-compose-plugin' does # not exist there — Ubuntu ships the same plugin as 'docker-compose-v2'. # Probe after each attempt; if neither lands, warn and continue (the box # may have compose v1, or the operator can add it later). pkg_add docker-compose-plugin if ! docker compose version &>/dev/null; then pkg_add docker-compose-v2 fi if docker compose version &>/dev/null; then good "Docker Compose plugin installed." else warn "Could not install the Docker Compose plugin — 'docker compose' is unavailable." warn "Managed app deployments need it; install docker-compose-plugin or docker-compose-v2 manually." fi } # --------------------------------------------------------------------------- # Node.js 20 (only needed for source builds — releases ship a built frontend) # --------------------------------------------------------------------------- # Node 18+ is the floor for the Vite frontend build. Distro nodejs on every # currently-supported target (Ubuntu 24.04, Debian 12, Fedora 40+) already meets # it, so we install the distro package first and only pipe NodeSource into bash # when the distro can't deliver a new-enough Node + npm. node_major() { node --version 2>/dev/null | sed -E 's/^v([0-9]+).*/\1/'; } # Vite 8 / rolldown (the frontend bundler) require Node ^20.19.0 || >=22.12.0. # On anything older, npm silently skips rolldown's native binary and # `npm run build` dies with a cryptic MODULE_NOT_FOUND — so gate on the real floor. node_ready() { command -v node &>/dev/null && command -v npm &>/dev/null || return 1 local v M m v="$(node --version 2>/dev/null | sed -E 's/^v//')" M="${v%%.*}"; m="${v#*.}"; m="${m%%.*}" case "$M" in ''|*[!0-9]*) return 1;; esac case "$m" in ''|*[!0-9]*) return 1;; esac if [ "$M" -eq 20 ] && [ "$m" -ge 19 ]; then return 0; fi if [ "$M" -eq 22 ] && [ "$m" -ge 12 ]; then return 0; fi if [ "$M" -ge 23 ]; then return 0; fi return 1 } provision_node() { phase "Node.js" if [ "$INSTALL_FROM_RELEASE" = "1" ]; then good "Skipping Node.js — the release ships a pre-built frontend." return fi if node_ready; then good "Node.js already present: $(node --version)" return fi step "Installing Node.js (distro repo first)..." # Debian/Ubuntu split npm into its own package; Fedora bundles it. pkg_add nodejs npm 2>/dev/null || pkg_add nodejs 2>/dev/null || true command -v npm &>/dev/null || pkg_add npm 2>/dev/null || true if ! node_ready; then warn "Distro Node.js is missing or too old to build the frontend (need 20.19+ or 22.12+) — falling back to NodeSource 22 LTS." if [ "$OS_FAMILY" = "debian" ]; then curl -fsSL https://deb.nodesource.com/setup_22.x | bash - >/dev/null 2>&1 || true else curl -fsSL https://rpm.nodesource.com/setup_22.x | bash - >/dev/null 2>&1 || true fi pkg_add nodejs || true fi node_ready || \ halt "Node.js 20.19+ or 22.12+ (with npm) is required to build the frontend but could not be installed. Install Node 22 LTS and re-run." good "Node.js $(node --version) ready." } # --------------------------------------------------------------------------- # Live-state carry: keep .env + backend/instance/ across a slot rewrite # --------------------------------------------------------------------------- # fetch_release and sync_source both rebuild $FIRST_SLOT with an rm -rf. On a # re-run over an existing install that tree holds irreplaceable state: the # generated .env (SECRET_KEY / JWT_SECRET_KEY / SERVERKIT_ENCRYPTION_KEY — # losing that key orphans every encrypted secret) and backend/instance/ (the # SQLite database). Copy them aside before the rewrite and restore them after, # mirroring scripts/update.sh's deploy_source carry-forward. Both helpers are # best-effort and always return 0 — preservation must never abort an install. stash_live_state() { # Echoes a stash directory holding the live state, or nothing when there # is nothing to preserve (fresh install) or no stash could be created. local src="$1" stash if [ ! -f "$src/.env" ] && [ ! -d "$src/backend/instance" ]; then return 0 fi stash="$(mktemp -d 2>/dev/null || true)" [ -n "$stash" ] || return 0 cp -a "$src/.env" "$stash/.env" 2>/dev/null || true cp -a "$src/backend/instance" "$stash/instance" 2>/dev/null || true printf '%s' "$stash" return 0 } restore_live_state() { local stash="$1" dest="$2" [ -n "$stash" ] || return 0 if [ -f "$stash/.env" ]; then cp -a "$stash/.env" "$dest/.env" 2>/dev/null || true fi if [ -d "$stash/instance" ]; then mkdir -p "$dest/backend" 2>/dev/null || true rm -rf "$dest/backend/instance" 2>/dev/null || true cp -a "$stash/instance" "$dest/backend/instance" 2>/dev/null || true fi rm -rf "$stash" 2>/dev/null || true good "Preserved the existing .env and database across the rewrite." return 0 } # --------------------------------------------------------------------------- # Release tarball path # --------------------------------------------------------------------------- resolve_release_tag() { if [ -n "$SERVERKIT_VERSION" ]; then printf '%s' "$SERVERKIT_VERSION" return fi # Primary: the releases/latest redirect. Its Location header carries the # tag and — unlike api.github.com, which allows only 60 requests/hour per # IP and is routinely exhausted behind shared cloud NAT — it has no API # quota. The API lookup stays as the fallback. Both arms are guarded so # an unresolvable tag comes back as EMPTY output (the caller warns loudly) # instead of a set -e abort. (I15) local tag="" tag=$(curl -sfLI -o /dev/null -w '%{url_effective}' \ "https://github.com/${GITHUB_REPO}/releases/latest" 2>/dev/null \ | sed -n 's|.*/releases/tag/||p') || tag="" if [ -n "$tag" ]; then printf '%s' "$tag" return 0 fi curl -sf "https://api.github.com/repos/${GITHUB_REPO}/releases/latest" 2>/dev/null \ | grep '"tag_name"' | head -1 | cut -d'"' -f4 || true } fetch_release() { phase "Downloading Pre-built Release" local tag tarball tmp_dir unpacked if [ -n "$SERVERKIT_OFFLINE_TARBALL" ]; then [ -f "$SERVERKIT_OFFLINE_TARBALL" ] || halt "Offline tarball not found: $SERVERKIT_OFFLINE_TARBALL" tag="offline" tarball="$SERVERKIT_OFFLINE_TARBALL" good "Using offline tarball: $tarball" else tag=$(resolve_release_tag) || tag="" if [ -z "$tag" ]; then # Loud on purpose: this used to be a silent downgrade that turned # a 2-minute release install into a full compile. (I15) warn "Could not resolve the latest release tag (GitHub unreachable or API rate-limited)." warn "FALLING BACK TO A FULL SOURCE BUILD — much slower, and it compiles the frontend here." warn "To install a release instead, pin one: SERVERKIT_VERSION=vX.Y.Z" INSTALL_FROM_RELEASE=0 return 1 fi good "Latest release: $tag" local base if [ -n "$SERVERKIT_MIRROR_URL" ]; then base="$SERVERKIT_MIRROR_URL" else base="https://github.com/${GITHUB_REPO}/releases/download/${tag}" fi tarball="/tmp/serverkit-${tag}-linux-${DL_ARCH}.tar.gz" step "Fetching release tarball (${DL_ARCH})..." if ! curl -sfL "${base}/serverkit-${tag}-linux-${DL_ARCH}.tar.gz" -o "$tarball"; then warn "No release tarball for this platform — falling back to source." INSTALL_FROM_RELEASE=0 return 1 fi step "Verifying checksum..." if curl -sfL "${base}/checksums.txt" -o "/tmp/serverkit-checksums-${tag}.txt"; then if ! (cd /tmp && sha256sum -c <(grep "serverkit-${tag}-linux-${DL_ARCH}.tar.gz" "serverkit-checksums-${tag}.txt") >/dev/null 2>&1); then halt "Checksum verification failed for release tarball." fi good "Checksum verified" elif [ "${SERVERKIT_ALLOW_UNVERIFIED:-0}" = "1" ]; then warn "No checksums.txt — installing UNVERIFIED (SERVERKIT_ALLOW_UNVERIFIED=1)" else # Every release publishes checksums.txt; without it the tarball # cannot be trusted, so build from source instead. warn "Could not download checksums.txt — not installing an unverified release; falling back to source." rm -f "$tarball" INSTALL_FROM_RELEASE=0 return 1 fi fi step "Unpacking release..." tmp_dir="$(mktemp -d)" # A failed unpack (corrupt download, disk full) must take the clean # source fallback, not "succeed" into a half-written tree. (I16) if ! tar xzf "$tarball" -C "$tmp_dir"; then warn "Could not unpack the release tarball (corrupt download or disk full) — falling back to source." rm -rf "$tmp_dir" INSTALL_FROM_RELEASE=0 return 1 fi unpacked="$tmp_dir/serverkit" [ ! -d "$unpacked" ] && unpacked="$tmp_dir/opt/serverkit" if [ ! -d "$unpacked" ]; then unpacked="$(find "$tmp_dir" -maxdepth 2 -type d -name serverkit | head -n1)" fi [ -d "$unpacked" ] || halt "Release tarball layout is unrecognized (expected serverkit/ or opt/serverkit/)." ensure_install_layout # A re-run over a live install must never destroy its secrets or database # (the old bare rm -rf below did exactly that). Stash them, rewrite, # restore. (I4) local live_dir stash live_dir="$(readlink -f "$INSTALL_DIR" 2>/dev/null || echo "$FIRST_SLOT")" [ -d "$live_dir" ] || live_dir="$FIRST_SLOT" stash="$(stash_live_state "$live_dir")" rm -rf "$FIRST_SLOT" # Disk full mid-copy: a half-written slot must not masquerade as a # success. Put the live state back and hand control to the caller's # source fallback. (I16) if ! cp -a "$unpacked" "$FIRST_SLOT"; then warn "Could not copy the release into $FIRST_SLOT (disk full?) — falling back to source." rm -rf "$tmp_dir" mkdir -p "$FIRST_SLOT" 2>/dev/null || true restore_live_state "$stash" "$FIRST_SLOT" INSTALL_FROM_RELEASE=0 return 1 fi rm -rf "$tmp_dir" restore_live_state "$stash" "$FIRST_SLOT" [ -L "$INSTALL_DIR" ] || ln -s "$FIRST_SLOT" "$INSTALL_DIR" chmod +x "$INSTALL_DIR/serverkit" chmod +x "$INSTALL_DIR/scripts/"*.sh 2>/dev/null || true good "Release unpacked into $FIRST_SLOT" } # --------------------------------------------------------------------------- # Source path: fetch / refresh the repository # --------------------------------------------------------------------------- sync_source() { phase "Source Code" # Source installs clone with git, but minimal images don't guarantee it # and the preflight never checked — install it on demand, and halt only # if that fails too (a source install cannot proceed without git). (I7) if ! command -v git &>/dev/null; then step "Installing git..." # Cleaned-down images (Docker base layers, geerlingguy systemd # containers) ship with EMPTY apt indexes — without a refresh, # `apt-get install git` finds no candidate. refresh_pkg_index is # best-effort and a no-op-costly-but-harmless on populated images. refresh_pkg_index pkg_add git command -v git &>/dev/null || \ halt "git is required for a source install but could not be installed." fi ensure_install_layout if [ -d "$FIRST_SLOT/.git" ]; then step "Refreshing the existing checkout..." cd "$FIRST_SLOT" if ! git pull --ff-only; then warn "Fast-forward failed (local edits?) — hard-resetting to origin/main." git fetch origin git reset --hard origin/main fi else step "Cloning ServerKit..." # Same live-state carry as fetch_release: the non-git tree replaced # here may still be a live install (e.g. one installed from a release # tarball) holding the generated .env and the database. (I4) local stash stash="$(stash_live_state "$FIRST_SLOT")" rm -rf "$FIRST_SLOT" git clone --depth 1 "https://github.com/${GITHUB_REPO}.git" "$FIRST_SLOT" restore_live_state "$stash" "$FIRST_SLOT" cd "$FIRST_SLOT" fi [ -L "$INSTALL_DIR" ] || ln -sfn "$FIRST_SLOT" "$INSTALL_DIR" good "Repository ready." } # --------------------------------------------------------------------------- # On-disk layout (blue/green slots + symlink) # --------------------------------------------------------------------------- ensure_install_layout() { # Migrate legacy real-directory installs into the blue/green layout. if [ -d "$INSTALL_DIR" ] && [ ! -L "$INSTALL_DIR" ]; then step "Migrating to blue/green install layout..." # A stale slot left by an aborted earlier run would make `mv` NEST # the live tree inside it ($FIRST_SLOT/serverkit). The live install # is $INSTALL_DIR (a real dir here, so nothing links to the slot) — # clear the stale slot before moving. (I18) if [ -e "$FIRST_SLOT" ]; then warn "Removing a stale $FIRST_SLOT left by an earlier run." rm -rf "$FIRST_SLOT" fi mv "$INSTALL_DIR" "$FIRST_SLOT" ln -s "$FIRST_SLOT" "$INSTALL_DIR" good "Active install is now $INSTALL_DIR → $FIRST_SLOT" fi mkdir -p "$DIR_A" "$DIR_B" [ -L "$INSTALL_DIR" ] || ln -s "$FIRST_SLOT" "$INSTALL_DIR" } make_directories() { phase "Creating Directories" ensure_install_layout local d for d in "$LOG_DIR" "$DATA_DIR" "$BACKUP_DIR" "$CONFIG_DIR" "$INSTALL_DIR/backend/instance"; do mkdir -p -m 0750 "$d" done for d in "$INSTALL_DIR/nginx/ssl" /etc/serverkit/templates /var/serverkit/apps \ /var/www/acme /etc/nginx/serverkit-locations; do mkdir -p -m 0755 "$d" done good "Directories created." } # --------------------------------------------------------------------------- # Python virtual environment + dependencies # --------------------------------------------------------------------------- build_virtualenv() { phase "Python Environment" # If the release shipped a pre-built venv at the expected path, use it — # but only if its interpreter actually runs on this host. The release # tarball is built on Ubuntu 24.04, so its venv python hard-requires # GLIBC_2.38 and dies on Ubuntu 22.04 / Debian 11 / older. In that case # discard it and fall through to a locally-built venv. if [ "$INSTALL_FROM_RELEASE" = "1" ] && [ -f "$FIRST_SLOT/venv/bin/activate" ] && [ -x "$FIRST_SLOT/venv/bin/python" ]; then if ! "$FIRST_SLOT/venv/bin/python" -c '' >/dev/null 2>&1; then warn "Release venv's python does not run on this host (too-new glibc build) — building a fresh venv instead." rm -rf "$FIRST_SLOT/venv" else step "Using pre-built virtual environment from release..." # In the default layout $INSTALL_DIR is a symlink to $FIRST_SLOT, so # $VENV_DIR *is* $FIRST_SLOT/venv — the rm -rf below would delete the # very venv it is about to "copy", and the cp would then fail. When # source and destination resolve to the same directory the venv is # already in place; verify and keep it. (I3) local src_venv dst_venv src_venv="$(readlink -f "$FIRST_SLOT/venv" 2>/dev/null || echo "$FIRST_SLOT/venv")" dst_venv="$(readlink -f "$VENV_DIR" 2>/dev/null || echo "$VENV_DIR")" if [ "$src_venv" = "$dst_venv" ]; then good "Virtual environment already in place from the release." return fi rm -rf "$VENV_DIR" cp -a "$FIRST_SLOT/venv" "$VENV_DIR" good "Virtual environment installed from release." return fi fi # Existing interpreters may have venv support without the headers needed # by pip. Check after the release fast path, including its local fallback. ensure_python_build_deps || return $? step "Creating the virtual environment..." $PYTHON_BIN -m venv "$VENV_DIR" if [ ! -f "$VENV_DIR/bin/activate" ]; then halt "Virtual environment creation failed: $VENV_DIR/bin/activate not found." fi source "$VENV_DIR/bin/activate" step "Installing Python dependencies..." pip install --upgrade pip --quiet if [ "$SAFE_MODE" = true ]; then pip install --no-cache-dir -r "$INSTALL_DIR/backend/requirements.txt" --quiet else pip install -r "$INSTALL_DIR/backend/requirements.txt" --quiet fi pip install gunicorn --quiet good "Python environment ready." } # --------------------------------------------------------------------------- # Application configuration and generated secrets # --------------------------------------------------------------------------- # Where gunicorn listens. Loopback by default — host nginx fronts the panel on # :80/:443, so the raw gunicorn port must not be world-reachable. Operators who # front it differently override with SERVERKIT_BIND_HOST=0.0.0.0. # # One definition, two consumers: it renders the systemd unit AND decides whether # X-Forwarded-For is trustworthy. Those answers must never disagree. backend_bind_host() { printf '%s' "${SERVERKIT_BIND_HOST:-127.0.0.1}" } # True when the only route to Flask is through our own nginx. # # This is the whole security condition behind TRUST_PROXY_HEADERS. ProxyFix # takes X-Forwarded-For at face value, which is safe exactly when a proxy we # control is guaranteed to have written it — i.e. when nothing can open a socket # to gunicorn directly. Loopback binding is what guarantees that; the installer # firewall also never opens the backend port (SSH + 80/443 + PANEL_PORT only). backend_is_loopback_only() { case "$(backend_bind_host)" in 127.0.0.1|localhost|::1|'[::1]') return 0 ;; *) return 1 ;; esac } # Set KEY=value in an env file, replacing any existing definition (commented or # not). Rewrites the file instead of `sed -i s|...|`: values here are URLs, and # an unescaped '/', '&' or '|' in a sed replacement is syntax, not data. # Fill a gap without ever overriding a choice. An uncommented definition is the # operator's — someone may have turned trust off deliberately, and a re-run # silently turning it back on would be the worst kind of surprise. _env_default_key() { local file="$1" key="$2" value="$3" grep -qE "^[[:space:]]*${key}=" "$file" && return 0 _env_set_key "$file" "$key" "$value" } _env_set_key() { local file="$1" key="$2" value="$3" tmp # Beside the target, not in /tmp: this file carries SECRET_KEY and the # encryption key, and $INSTALL_DIR is root-owned while /tmp is world-listable. tmp="$(mktemp "${file}.XXXXXX")" grep -v -E "^[[:space:]]*#?[[:space:]]*${key}=" "$file" > "$tmp" || true printf '%s=%s\n' "$key" "$value" >> "$tmp" # cat rather than mv so the original mode (0600) and owner survive. cat "$tmp" > "$file" rm -f "$tmp" } write_config() { phase "Configuration" if [ -f "$INSTALL_DIR/.env" ]; then warn ".env already exists — leaving the current configuration in place." # ...except the SSL mode, which reflects THIS run's outcome (a re-run # may have gained or lost HTTPS). Refresh just that key so the # panel's HSTS gate matches /etc/serverkit/ssl-mode instead of a # stale value from the previous install. (I24) if grep -q '^SERVERKIT_SSL_MODE=' "$INSTALL_DIR/.env"; then sed -i "s|^SERVERKIT_SSL_MODE=.*|SERVERKIT_SSL_MODE=$SSL_MODE|" "$INSTALL_DIR/.env" else printf 'SERVERKIT_SSL_MODE=%s\n' "$SSL_MODE" >> "$INSTALL_DIR/.env" fi # Same reasoning as the SSL mode above: a re-run may have chosen a # different profile, and the panel reads this to decide whether to # offer app hosting. if grep -q '^SERVERKIT_PROFILE=' "$INSTALL_DIR/.env"; then sed -i "s|^SERVERKIT_PROFILE=.*|SERVERKIT_PROFILE=$PROFILE|" "$INSTALL_DIR/.env" else printf 'SERVERKIT_PROFILE=%s\n' "$PROFILE" >> "$INSTALL_DIR/.env" fi # Converting an existing install to sit behind an external proxy is a # re-run with SERVERKIT_EXTERNAL_PROXY=1, and it lands here — the .env # already exists. Applying the switch only on a first install would make # the documented conversion path silently do nothing. Only ever ADD the # trust settings; never remove them on an ordinary re-run, which would # quietly break client IPs for someone who set them by hand. if [ "$SERVERKIT_EXTERNAL_PROXY" = "1" ]; then _env_set_key "$INSTALL_DIR/.env" TRUST_PROXY_HEADERS true _env_set_key "$INSTALL_DIR/.env" TRUSTED_PROXY_HOPS 2 if [ -n "$PANEL_PUBLIC_URL" ]; then _env_set_key "$INSTALL_DIR/.env" SERVERKIT_PUBLIC_URL "$PANEL_PUBLIC_URL" fi good "External-proxy settings applied to the existing .env." elif backend_is_loopback_only; then # Installs made before the trusted-proxy default existed have no # TRUST_PROXY_HEADERS line at all, so they key rate limits, login # lockout and audit logs on nginx's own address. Backfill it on a # re-run — but only where the line is absent, never over an explicit # setting. if ! grep -qE '^[[:space:]]*TRUST_PROXY_HEADERS=' "$INSTALL_DIR/.env"; then _env_default_key "$INSTALL_DIR/.env" TRUST_PROXY_HEADERS true _env_default_key "$INSTALL_DIR/.env" TRUSTED_PROXY_HOPS 1 good "Enabled trusted-proxy client IPs (behind this install's own nginx)." fi fi return fi step "Generating configuration..." local secret_key jwt_secret encryption_key secret_key=$(openssl rand -hex 32) jwt_secret=$(openssl rand -hex 32) encryption_key=$("${PYTHON_BIN:-python3}" -c 'import base64, os; print(base64.urlsafe_b64encode(os.urandom(32)).decode())') # Widen CORS / advertise a public URL when a panel domain is known. local public_url="" cors_origins="http://localhost,https://localhost" local url_scheme="http" if [ -n "$PANEL_DOMAIN" ]; then if [ "$SSL_MODE" = "secure" ]; then url_scheme="https" cors_origins="http://localhost,https://localhost,https://$PANEL_DOMAIN" else url_scheme="http" cors_origins="http://localhost,https://localhost,http://$PANEL_DOMAIN" fi public_url="${url_scheme}://$PANEL_DOMAIN" fi # Behind someone else's proxy the browser's origin is the proxy's https URL, # which no local signal reveals — SSL_MODE is 'insecure' here precisely # because *we* do not terminate TLS. So the operator-supplied value wins, # and it must be live (not commented out) or every websocket handshake 400s. local proxy_block="" if [ -n "$PANEL_PUBLIC_URL" ]; then public_url="$PANEL_PUBLIC_URL" case ",$cors_origins," in *",$public_url,"*) ;; *) cors_origins="$cors_origins,$public_url" ;; esac fi # Derived-from-the-domain URLs stay commented (they are a guess, and a wrong # SERVERKIT_PUBLIC_URL breaks websockets rather than merely not helping). # An operator-supplied one is written live. local public_url_line="" if [ -n "$PANEL_PUBLIC_URL" ]; then public_url_line="SERVERKIT_PUBLIC_URL=$public_url" elif [ -n "$public_url" ]; then public_url_line="# SERVERKIT_PUBLIC_URL=$public_url" fi if [ "$SERVERKIT_EXTERNAL_PROXY" = "1" ]; then # Two hops: their proxy appends the real client, our nginx appends their # proxy. ProxyFix counts from the right, so 1 would log their proxy's IP # for every request and 3 would let a client forge its own. proxy_block=$(cat <<'PROXYEOF' # Trusted reverse proxy — set because this install sits behind an external # TLS-terminating proxy (SERVERKIT_EXTERNAL_PROXY=1). Derives the real client # IP from X-Forwarded-For for rate limits, login lockout and audit logs. # TRUSTED_PROXY_HOPS = proxies in front of Flask: external proxy + our nginx. TRUST_PROXY_HEADERS=true TRUSTED_PROXY_HOPS=2 PROXYEOF ) elif backend_is_loopback_only; then proxy_block=$(cat <<'PROXYEOF' # Trusted reverse proxy — every host install serves the panel through its own # nginx, and gunicorn binds loopback (see the systemd unit), so X-Forwarded-For # is written by that nginx and nothing can reach Flask directly to forge it. # Without this the panel keys per-IP rate limits, the login brute-force throttle # and every audit-log entry on nginx's own address instead of the real client. # One hop: our nginx is the only proxy in front. Raise it if you add another # (e.g. Cloudflare on top ⇒ 2). TRUST_PROXY_HEADERS=true TRUSTED_PROXY_HOPS=1 PROXYEOF ) else # The raw gunicorn port is exposed, so a client can connect without # passing through our nginx and X-Forwarded-For becomes attacker-chosen. # Trusting it here would let anyone forge the IP that rate limits, login # lockout and audit logs key on — strictly worse than logging one wrong # address. Left off, and said out loud rather than silently. proxy_block=$(cat <<'PROXYEOF' # Trusted reverse proxy — deliberately OFF. SERVERKIT_BIND_HOST exposes the # backend port directly, so X-Forwarded-For is client-controlled and cannot be # trusted. Client IPs will show as the proxy/socket peer. If you do front this # with a proxy AND block direct access to the backend port, set: # TRUST_PROXY_HEADERS=true # TRUSTED_PROXY_HOPS= TRUST_PROXY_HEADERS=false PROXYEOF ) warn "SERVERKIT_BIND_HOST=${SERVERKIT_BIND_HOST:-} exposes the backend port directly." warn "Leaving TRUST_PROXY_HEADERS off: X-Forwarded-For would be client-forgeable." fi cat > "$INSTALL_DIR/.env" < /etc/nginx/serverkit-conf.d/canonical-domain.conf </dev/null)" if [ -z "$hsl" ]; then local hues=(210 225 235 250 265 285 320 345 12 25 160 175 190) local base="${hues[$RANDOM % ${#hues[@]}]}" local jitter=$(( RANDOM % 21 - 10 )) # -10..+10 degrees local h=$(( (base + jitter + 360) % 360 )) local s=$(( 48 + RANDOM % 28 )) # 48-75% saturation local l=$(( 32 + RANDOM % 15 )) # 32-46% lightness (dark enough for the glyph) hsl="hsl(${h}, ${s}%, ${l}%)" mkdir -p "$CONFIG_DIR" 2>/dev/null || true printf '%s\n' "$hsl" > "$color_file" 2>/dev/null || true good "Favicon tint assigned: ${hsl}" fi sed -i -E "s|(/dev/null || true # Serve the same tinted mark at /favicon.ico so a blind favicon-hash fetch # there varies per install too (SVG bytes under an .ico name — browsers use # the linked SVG; this only affects what a scanner hashes at that URL). cp "$fav" "$INSTALL_DIR/frontend/dist/favicon.ico" 2>/dev/null || true } build_frontend() { phase "Frontend Build" if [ "$INSTALL_FROM_RELEASE" = "1" ]; then good "Using the pre-built frontend from the release." randomize_favicon return fi step "Installing npm packages..." cd "$INSTALL_DIR/frontend" npm ci --prefer-offline 2>&1 | tail -3 step "Compiling the frontend bundle..." NODE_OPTIONS="--max-old-space-size=1024" npm run build 2>&1 | tail -5 good "Frontend built." randomize_favicon } # --------------------------------------------------------------------------- # SSH port discovery — the safety interlock in front of default-deny. # # Turning on a default-deny firewall without allowing the port sshd actually # answers on locks the operator out of their own server, over the very SSH # session running this installer. So: never guess. Ask, in descending order of # authority, and treat "no answer" as a hard stop rather than "probably 22". # # 1. `sshd -T` — the EFFECTIVE config. It resolves Include directives, so it # sees a port declared in /etc/ssh/sshd_config.d/*.conf that a naive grep # of sshd_config alone would miss (Ubuntu 22.04+ ships exactly that shape). # 2. sshd_config + its drop-ins, parsed here. Covers boxes where `sshd -T` # refuses to run (missing host keys in a fresh image, non-root, ...). # 3. Whatever sshd is listening on right now, per `ss`. Catches a running # daemon whose config on disk has drifted from what it was started with. # # A config file that names no Port at all means sshd's compiled-in default, so # that case — and ONLY that case — resolves to 22. If not one of the three # sources produced anything, the function returns non-zero and the caller must # not enable a firewall: an exposed box is recoverable, a locked-out one # usually is not. # # Echoes one port per line. SSHD_CONFIG_ROOT overrides /etc/ssh for tests. # --------------------------------------------------------------------------- detect_ssh_ports() { local etc="${SSHD_CONFIG_ROOT:-/etc/ssh}" local main="$etc/sshd_config" local raw="" ports="" p have_source=0 if command -v sshd >/dev/null 2>&1; then raw="$(sshd -T 2>/dev/null | awk 'tolower($1) == "port" { print $2 }')" || raw="" [ -n "$raw" ] && have_source=1 fi if [ -z "$raw" ] && [ -f "$main" ]; then have_source=1 # Strip comments first so a commented-out "#Port 22" never counts. # # The `|| true` around cat is load-bearing, not defensive noise: most # boxes have no sshd_config.d/*.conf, the unmatched glob makes cat exit # non-zero, and under the installer's `set -o pipefail` that failure # would wipe a perfectly good answer — falling through to the "22" # default below on a box whose sshd is on 2222, and locking the # operator out. Exactly the failure this function exists to prevent. raw="$( { cat "$main" "$etc"/sshd_config.d/*.conf 2>/dev/null || true; } \ | sed -e 's/#.*//' \ | awk 'tolower($1) == "port" { print $2 }' )" || raw="" fi if [ -z "$raw" ] && command -v ss >/dev/null 2>&1; then raw="$(ss -H -tlnp 2>/dev/null | awk '/"sshd"/ { n = split($4, a, ":"); print a[n] }')" || raw="" [ -n "$raw" ] && have_source=1 fi # A config that exists but names no Port means the built-in default. if [ -z "$raw" ] && [ "$have_source" = "1" ]; then raw="22" fi # Keep only plausible port numbers, de-duplicated, order preserved. for p in $raw; do case "$p" in ''|*[!0-9]*) continue ;; esac [ "$p" -ge 1 ] && [ "$p" -le 65535 ] || continue case " $ports " in *" $p "*) continue ;; esac ports="$ports $p" done ports="${ports# }" [ -n "$ports" ] || return 1 for p in $ports; do printf '%s\n' "$p"; done } # Run a firewall command, or print it under FW_DRY_RUN. Same contract as # scripts/lib/firewall.sh's _fw_run, kept local so the bootstrap below never # depends on a private of that lib. fw_bootstrap_run() { if [ "${FW_DRY_RUN:-0}" = "1" ]; then printf ' [firewall] would run: %s\n' "$*" return 0 fi # Best-effort, like the lib's _fw_run callers: a rule that fails to apply # must never abort a `set -e` install. bootstrap_firewall's closing # `ufw status` check is what actually decides whether this worked. "$@" >/dev/null 2>&1 || true } # --------------------------------------------------------------------------- # Firewall bootstrap: put a default-deny firewall on a box that has none. # # The installer used to open 80/443 on whatever firewall it found and shrug # ("assuming ports 80/443 are already open") when there wasn't one — which is # the state of a stock Ubuntu/Debian VPS. That box then runs ServerKit with # every port a deploy maps onto the host (8001-8999, plus database containers) # facing the internet. # # Refuses to act, in this order, when: # * SERVERKIT_SKIP_FIREWALL=1 — operator said no; never argue. # * OS_FAMILY is not debian — UFW is Debian/Ubuntu. RHEL-family boxes # already boot with firewalld ACTIVE, so # configure_firewall's open step covers # them. A firewalld default-deny bootstrap # for the boxes where it is *inactive* is # a deliberate follow-up: zones/services # are different enough that copying this # logic across would be wrong. # * a firewall is already active — it is the operator's. Opening ports on # it is fine (next phase); rewriting its # default policy is not. # * inside a container — the host owns netfilter; ufw either # fails outright (no NET_ADMIN) or, worse, # half-applies. # * we bootstrapped on a previous run and it is off now — the operator turned # it off on purpose. A re-run must not # undo that. # * the SSH port is unknown — see detect_ssh_ports. Hard stop. # --------------------------------------------------------------------------- FIREWALL_BOOTSTRAP_KEY="firewall_bootstrapped" bootstrap_firewall() { if [ "${SERVERKIT_SKIP_FIREWALL:-0}" = "1" ]; then good "Skipping firewall setup (SERVERKIT_SKIP_FIREWALL=1)." return 0 fi if [ "${OS_FAMILY:-}" != "debian" ]; then return 0 fi # "Is this box already protected", NOT "does a firewall binary exist". # # This used to ask firewall_detect() != none, which was wrong in the way # that matters: firewall_detect returns `nftables` when the nft binary is # merely present and can list an empty ruleset, so on a stock Ubuntu cloud # image — precisely the box this function exists for — the bootstrap # returned here silently and never installed anything. Caught on a real # 24.04 VM: the install logged "Opening HTTP/HTTPS (80, 443) via nftables" # while every line below went unprinted. if firewall_inbound_default_deny; then good "Inbound traffic is already filtered by default — leaving the firewall alone." return 0 fi local ctr ctr="$(detect_container)" if [ -n "$ctr" ]; then warn "Running inside $ctr — leaving the firewall to the host." return 0 fi if command -v state_get >/dev/null 2>&1 \ && [ "$(state_get "$FIREWALL_BOOTSTRAP_KEY" 2>/dev/null || true)" = "1" ]; then good "A previous install set up the firewall and it is off now — leaving it alone." return 0 fi # ---- the interlock. Nothing below runs without a known SSH port. ------- local ssh_ports if ! ssh_ports="$(detect_ssh_ports)"; then warn "Could not determine which port sshd listens on — NOT enabling a firewall." warn "Refusing to risk locking you out. Declare 'Port ' in /etc/ssh/sshd_config and re-run," warn "or set up UFW yourself: ufw allow /tcp && ufw allow 80,443/tcp && ufw enable" return 0 fi if ! command -v ufw >/dev/null 2>&1; then step "Installing UFW..." pkg_add ufw fi if ! command -v ufw >/dev/null 2>&1; then warn "UFW is unavailable — this box is left without a firewall." return 0 fi # Order IS the safety property: every allow rule lands BEFORE the # default-deny policy, and the policy before the firewall is switched on. # `ufw allow` is idempotent ("Skipping adding existing rule"), so a re-run # never duplicates anything. local port for port in $ssh_ports; do step "Allowing SSH on ${port}/tcp..." fw_bootstrap_run ufw allow "${port}/tcp" done fw_bootstrap_run ufw allow 80/tcp fw_bootstrap_run ufw allow 443/tcp # A panel published on a non-standard port needs that port too, or the # operator loses the UI the instant default-deny lands. case "${PANEL_PORT:-80}" in 80|443) : ;; *) fw_bootstrap_run ufw allow "${PANEL_PORT}/tcp" ;; esac fw_bootstrap_run ufw default deny incoming fw_bootstrap_run ufw default allow outgoing fw_bootstrap_run ufw --force enable if [ "${FW_DRY_RUN:-0}" = "1" ]; then good "Firewall bootstrap (dry run) complete." return 0 fi if ufw status 2>/dev/null | grep -q 'Status: active'; then good "UFW enabled: default-deny inbound; SSH ($(printf '%s' "$ssh_ports" | tr '\n' ' ')), 80 and 443 allowed." if command -v state_set >/dev/null 2>&1; then state_set "$FIREWALL_BOOTSTRAP_KEY" 1 || true fi else warn "UFW did not come up active — this box is still unfirewalled." fi return 0 } # --------------------------------------------------------------------------- # Firewall: open 80/443 so the panel is reachable from the outside, and record # exactly what we opened in /etc/serverkit/install-state.json so uninstall can # undo only those rules. Fresh RHEL-family boxes run firewalld by default, which # is the #1 reason an otherwise-good install looks "broken" (port 80 blocked). # --------------------------------------------------------------------------- configure_firewall() { phase "Firewall" if ! load_serverkit_lib firewall.sh; then warn "Firewall helper not found — open ports 80/443 manually if needed." return 0 fi load_serverkit_lib state.sh || true # Stock Debian/Ubuntu ships no firewall at all; put one there before the # "no active firewall detected" branch below just shrugs at it. bootstrap_firewall local backend backend="$(firewall_detect)" if [ "$backend" = "none" ]; then warn "No active firewall detected — assuming ports 80/443 are already open." return 0 fi step "Opening HTTP/HTTPS (80, 443) via $backend..." firewall_open "$backend" 80/tcp 443/tcp if [ "${FW_DRY_RUN:-0}" != "1" ] && command -v state_set >/dev/null 2>&1; then # Best-effort, exactly like update.sh's ensure_firewall: failing to # RECORD the opened ports must not abort an install whose firewall # work already succeeded. (I21) state_set firewall_backend "$backend" || true state_append firewall_ports 80/tcp || true state_append firewall_ports 443/tcp || true fi good "Firewall configured ($backend): 80/tcp and 443/tcp open." } # --------------------------------------------------------------------------- # Init-system probe + tiny service verbs (mirrors scripts/lib/init.sh) # --------------------------------------------------------------------------- # install.sh runs pre-clone in the `curl | bash` path, so it cannot source # scripts/lib/init.sh — the detection is inline here and kept in sync with the # lib: systemd when /run/systemd/system exists, then OpenRC/SysV fallbacks. # INIT_OVERRIDE (same contract as init.sh) forces the answer for unit tests. # Best-effort: on a box none of these can drive (containers, WSL) the verbs # warn and return 0 — a missing init system must never abort the install. (I17) svc_has_systemd() { if [ -n "${INIT_OVERRIDE:-}" ]; then [ "$INIT_OVERRIDE" = "systemd" ] return fi [ -d /run/systemd/system ] && command -v systemctl &>/dev/null } svc_enable() { local svc="$1" if svc_has_systemd; then systemctl enable "$svc" 2>/dev/null || warn "Could not enable $svc at boot." elif command -v rc-update &>/dev/null; then rc-update add "$svc" default 2>/dev/null || warn "Could not enable $svc at boot." elif command -v chkconfig &>/dev/null; then chkconfig "$svc" on 2>/dev/null || warn "Could not enable $svc at boot." elif command -v update-rc.d &>/dev/null; then update-rc.d "$svc" enable 2>/dev/null || warn "Could not enable $svc at boot." else warn "No init system found to enable $svc — enable it at boot manually." fi return 0 } svc_start() { local svc="$1" if svc_has_systemd; then systemctl start "$svc" 2>/dev/null || warn "Could not start $svc." elif command -v rc-service &>/dev/null; then rc-service "$svc" start 2>/dev/null || warn "Could not start $svc." elif command -v service &>/dev/null; then service "$svc" start 2>/dev/null || warn "Could not start $svc." else warn "No init system found to start $svc — start it manually." fi return 0 } # Write the SSH brute-force jail as a jail.d drop-in. # # sshd is the one service every box exposes and it is under a password spray # within minutes of getting a public IP, so this is the jail that matters on a # fresh VPS. Two things make it correct rather than decorative: # # * it watches the SAME port(s) detect_ssh_ports found for the firewall — a # jail pinned to :22 on a box whose sshd moved to :2222 bans nobody; # * it declares `backend = systemd` when there is no syslog file to tail # (Ubuntu 24.04+ and RHEL are journald-only). Without that the jail refuses # to start with "Have not found any log file for sshd jail" and fail2ban # looks installed while protecting nothing. # # The filename deliberately does NOT start with "serverkit-": that prefix is # Fail2banJailService's ownership namespace for per-site jails, and this file is # the installer's. It sorts after Debian's defaults-debian.conf, so jail.d # ordering makes ours the winner. Regenerated on every run (idempotent). write_sshd_jail() { local jail_dir ports backend_line="" jail_dir="${FAIL2BAN_JAIL_DIR:-/etc/fail2ban/jail.d}" if ! ports="$(detect_ssh_ports)"; then warn "Could not determine the SSH port — leaving fail2ban's sshd jail at its defaults." return 0 fi ports="$(printf '%s' "$ports" | tr '\n' ',' | sed 's/,$//')" if [ ! -f /var/log/auth.log ] && [ ! -f /var/log/secure ] && svc_has_systemd; then backend_line="backend = systemd" fi mkdir -p "$jail_dir" 2>/dev/null || true if ! cat > "$jail_dir/sshd-serverkit.conf" </dev/null; then good "fail2ban already present." else step "Installing fail2ban..." pkg_add fail2ban fi if command -v fail2ban-server &>/dev/null; then write_sshd_jail svc_enable fail2ban svc_start fail2ban good "fail2ban enabled — manage jails from Security in the panel." else warn "fail2ban unavailable; the panel's jail management will stay inert." fi # Everything below is what makes "full" more than "standard". [ "$PROFILE" = "full" ] || return 0 # Certbot only matters when this install is actually attempting HTTPS. if [ "$SERVERKIT_SKIP_SSL" = "1" ]; then good "Skipping certbot (SERVERKIT_SKIP_SSL=1)." elif command -v certbot &>/dev/null; then good "certbot already present." else step "Installing certbot..." case "$OS_FAMILY" in debian|ubuntu) pkg_add certbot python3-certbot-nginx ;; fedora|rhel) pkg_add certbot python3-certbot-nginx ;; suse) pkg_add certbot python3-certbot-nginx ;; arch) pkg_add certbot certbot-nginx ;; alpine) pkg_add certbot certbot-nginx ;; *) pkg_add certbot ;; esac if command -v certbot &>/dev/null; then good "certbot installed." else warn "certbot unavailable — HTTPS stays best-effort on this host." fi fi return 0 } # --------------------------------------------------------------------------- # nginx site + reverse-proxy wiring # --------------------------------------------------------------------------- configure_nginx() { phase "Nginx" if command -v nginx &>/dev/null; then good "Nginx already installed." else step "Installing Nginx..." pkg_add nginx svc_enable nginx good "Nginx installed." fi systemctl stop nginx 2>/dev/null || true rm -f /etc/nginx/sites-enabled/default mkdir -p /etc/nginx/sites-available /etc/nginx/sites-enabled /etc/nginx/serverkit-conf.d /var/www/certbot # Fedora/RHEL nginx.conf doesn't include sites-enabled by default. if ! grep -q "sites-enabled" /etc/nginx/nginx.conf; then sed -i '/http {/a \ include /etc/nginx/sites-enabled/*;' /etc/nginx/nginx.conf fi # Server-wide TLS floor: harden ssl_protocols/ssl_ciphers in the main # nginx.conf so even the default server and vhosts ServerKit did not # generate cannot negotiate TLS 1.0/1.1 or weak ciphers. harden_global_tls # Drop any foreign catch-all vhost that would shadow the panel. for conf in /etc/nginx/sites-enabled/*; do [ -f "$conf" ] || continue local name; name=$(basename "$conf") case "$name" in serverkit-*|serverkit.conf) continue ;; esac if grep -Eq 'server_name[[:space:]]+_;' "$conf" 2>/dev/null; then warn "Removing conflicting catch-all vhost: $name" rm -f "/etc/nginx/sites-enabled/$name" "/etc/nginx/sites-available/$name" fi done cp "$INSTALL_DIR/nginx/sites-available/serverkit.conf" /etc/nginx/sites-available/ cp "$INSTALL_DIR/nginx/sites-available/serverkit-insecure.conf" /etc/nginx/sites-available/ cp "$INSTALL_DIR/nginx/sites-available/example.conf.template" /etc/nginx/sites-available/ # The panel frontend is served as static files by host nginx (no container). # The shipped config roots at the default /opt/serverkit; point it at the real # install dir when SERVERKIT_DIR was customised. apply_frontend_root /etc/nginx/sites-available/serverkit.conf \ /etc/nginx/sites-available/serverkit-insecure.conf # Decide whether we can use HTTPS. SSL is best-effort: if certbot fails or # no domain is given, we fall back to plain HTTP rather than forcing the # user to fix DNS/certs before they can use ServerKit. choose_ssl_mode install_nginx_config_for_mode # SELinux: let nginx make upstream connections to the app containers AND read # the SPA bundle it now serves from $INSTALL_DIR (default /opt, which is not # httpd_sys_content_t by default — without this nginx 403s every panel asset). if { [ "$OS_FAMILY" = "fedora" ] || [ "$OS_FAMILY" = "rhel" ]; } && command -v setsebool &>/dev/null; then setsebool -P httpd_can_network_connect 1 2>/dev/null || true fi selinux_label_frontend_dist good "Nginx configured ($SSL_MODE)." } # --------------------------------------------------------------------------- # Choose secure or insecure mode. Never block the install because of SSL. # --------------------------------------------------------------------------- choose_ssl_mode() { if [ "$SERVERKIT_SKIP_SSL" = "1" ]; then SSL_MODE="insecure" warn "SERVERKIT_SKIP_SSL=1 — using plain HTTP." return fi if [ -z "$PANEL_DOMAIN" ]; then SSL_MODE="insecure" warn "No panel domain provided — using plain HTTP (access by IP)." warn "Set PANEL_DOMAIN and re-run the installer to try HTTPS automatically." return fi if ! command -v certbot &>/dev/null; then SSL_MODE="insecure" warn "Certbot not installed — cannot request SSL automatically." warn "Continuing with plain HTTP. Install certbot and run: certbot --nginx -d $PANEL_DOMAIN" return fi # Temporarily use the insecure config so certbot's webroot challenge can # answer on port 80. ln -sf /etc/nginx/sites-available/serverkit-insecure.conf /etc/nginx/sites-enabled/serverkit.conf step "Attempting Let's Encrypt certificate for $PANEL_DOMAIN..." if systemctl start nginx 2>/dev/null && \ certbot certonly --webroot -w /var/www/certbot -d "$PANEL_DOMAIN" \ --non-interactive --agree-tos --register-unsafely-without-email 2>/dev/null; then SSL_MODE="secure" good "Let's Encrypt certificate issued for $PANEL_DOMAIN." else SSL_MODE="insecure" warn "Could not obtain SSL certificate for $PANEL_DOMAIN." warn "Common causes: DNS not pointing to this server, port 80 not reachable, or rate limits." warn "Continuing with plain HTTP. Retry later with: certbot --nginx -d $PANEL_DOMAIN" fi systemctl stop nginx 2>/dev/null || true } # --------------------------------------------------------------------------- # Install the nginx config matching the chosen SSL mode. # --------------------------------------------------------------------------- install_nginx_config_for_mode() { # Dirs are overridable so the unit tests can exercise this against # fixtures (same pattern as harden_global_tls / update.sh's NGINX_DIR). local nginx_dir="${SERVERKIT_NGINX_DIR:-/etc/nginx}" local config_dir="${SERVERKIT_CONFIG_DIR:-/etc/serverkit}" if [ "$SSL_MODE" = "secure" ]; then sed -i "s|/etc/letsencrypt/live/YOUR_DOMAIN/|/etc/letsencrypt/live/$PANEL_DOMAIN/|g" \ "$nginx_dir/sites-available/serverkit.conf" ln -sf "$nginx_dir/sites-available/serverkit.conf" "$nginx_dir/sites-enabled/serverkit.conf" else ln -sf "$nginx_dir/sites-available/serverkit-insecure.conf" "$nginx_dir/sites-enabled/serverkit.conf" fi mkdir -p "$config_dir" printf '%s\n' "$SSL_MODE" > "$config_dir/ssl-mode" # Persist the domain so update.sh can re-apply the cert path on upgrades # without depending on the (commented-out) .env public URL. An empty # domain (the default no-domain install) is simply not persisted — and as # the LAST statement of the function this must be an `if`, not a # `[ -n ] && ...` list: the list form returned 1 on an empty domain and # set -e killed every no-domain install right here. (I1) if [ -n "$PANEL_DOMAIN" ]; then printf '%s\n' "$PANEL_DOMAIN" > "$config_dir/panel-domain" fi } # Point the static-frontend `root` at the real install dir. The shipped nginx # config defaults to /opt/serverkit; only a customised SERVERKIT_DIR needs the # rewrite. update.sh's refresh_config carries the same logic across upgrades. apply_frontend_root() { [ "$INSTALL_DIR" = "/opt/serverkit" ] && return 0 local f for f in "$@"; do # `if`, not a `[ -f ] && ...` list: with the list form a missing conf # as the LAST file made the loop (and thus the function) return 1, # and set -e killed the custom-SERVERKIT_DIR install right here — # the same species as I1/I2. if [ -f "$f" ]; then sed -i "s|/opt/serverkit/frontend/dist|$INSTALL_DIR/frontend/dist|g" "$f" fi done return 0 } # Label the SPA bundle so SELinux-enforcing hosts (Fedora/RHEL) let nginx read # it. /opt is not httpd_sys_content_t by default, so without this every panel # asset 403s. Label the *real* slot path (blue/green resolves through a symlink). # Best-effort: a permissive/disabled box, or one without the tools, just no-ops. selinux_label_frontend_dist() { command -v selinuxenabled &>/dev/null && selinuxenabled 2>/dev/null || return 0 local real dist real="$(readlink -f "$INSTALL_DIR" 2>/dev/null || echo "$INSTALL_DIR")" dist="${real}/frontend/dist" [ -d "$dist" ] || return 0 if command -v semanage &>/dev/null && command -v restorecon &>/dev/null; then semanage fcontext -a -t httpd_sys_content_t "${dist}(/.*)?" 2>/dev/null \ || semanage fcontext -m -t httpd_sys_content_t "${dist}(/.*)?" 2>/dev/null || true restorecon -R "$dist" 2>/dev/null || true elif command -v chcon &>/dev/null; then chcon -R -t httpd_sys_content_t "$dist" 2>/dev/null || true fi } # --------------------------------------------------------------------------- # Server-wide TLS floor — force every HTTPS listener onto TLS 1.2/1.3 with # AEAD-only ciphers, so even the default server and non-ServerKit vhosts can't # negotiate weak TLS. # # Preferred: drop a self-contained /etc/nginx/conf.d/serverkit-tls.conf snippet # — ServerKit-owned, trivially reversible on uninstall, and never touches the # distro's nginx.conf. BUT a snippet is `include`d into the same http{} context, # so if nginx.conf ALREADY declares ssl_protocols (Debian/Ubuntu do) a second # declaration is a "duplicate ssl_protocols" error. In that case we fall back to # rewriting the existing directives in place. Idempotent either way. # --------------------------------------------------------------------------- harden_global_tls() { local nginx_dir="${SERVERKIT_NGINX_DIR:-/etc/nginx}" local conf="$nginx_dir/nginx.conf" [ -f "$conf" ] || return 0 local ciphers='ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384' # Does the main config already declare these in http{}? (Debian/Ubuntu yes, # RHEL/Fedora/SUSE typically no.) local has_proto=0 has_ciphers=0 grep -qE '^[[:space:]]*ssl_protocols[[:space:]]' "$conf" && has_proto=1 grep -qE '^[[:space:]]*ssl_ciphers[[:space:]]' "$conf" && has_ciphers=1 # The conf.d snippet is only safe when neither directive already exists AND # nginx.conf includes conf.d/*.conf inside http{} (the default on every # supported family). if [ "$has_proto" = "0" ] && [ "$has_ciphers" = "0" ] && \ grep -qE 'include[[:space:]]+/etc/nginx/conf\.d/\*\.conf' "$conf"; then mkdir -p "$nginx_dir/conf.d" cat > "$nginx_dir/conf.d/serverkit-tls.conf" </dev/null || true if [ "$has_proto" = "1" ]; then sed -i -E 's|^([[:space:]]*)ssl_protocols[[:space:]].*|\1ssl_protocols TLSv1.2 TLSv1.3;|' "$conf" else sed -i '/http {/a \ ssl_protocols TLSv1.2 TLSv1.3;' "$conf" fi if [ "$has_ciphers" = "1" ]; then sed -i -E "s|^([[:space:]]*)ssl_ciphers[[:space:]].*|\1ssl_ciphers ${ciphers};|" "$conf" else sed -i "/http {/a \\ ssl_ciphers ${ciphers};" "$conf" fi } # --------------------------------------------------------------------------- # systemd unit + CLI symlink # --------------------------------------------------------------------------- # Render the systemd unit from the template to , substituting the resolved # install/venv/log paths so a custom SERVERKIT_DIR is honored (the old hardcoded # unit baked in /opt/serverkit). The unit references the /opt/serverkit symlink, # so blue/green switches need no re-render. Returns 1 if no template/unit exists. render_service_unit() { local out="$1" local template="$INSTALL_DIR/templates/serverkit-backend.service.in" local bind_host bind_host="$(backend_bind_host)" if [ -f "$template" ]; then sed -e "s|@SERVERKIT_DIR@|$INSTALL_DIR|g" \ -e "s|@SERVERKIT_VENV_DIR@|$VENV_DIR|g" \ -e "s|@PORT@|5000|g" \ -e "s|@BIND_HOST@|$bind_host|g" \ -e "s|@USER@|root|g" \ -e "s|@LOG_DIR@|$LOG_DIR|g" \ "$template" > "$out" return 0 elif [ -f "$INSTALL_DIR/serverkit-backend.service" ]; then cp "$INSTALL_DIR/serverkit-backend.service" "$out" return 0 fi return 1 } install_service() { phase "Systemd Service" render_service_unit /etc/systemd/system/serverkit.service || \ halt "No systemd unit template found at $INSTALL_DIR/templates/serverkit-backend.service.in" chmod 644 /etc/systemd/system/serverkit.service chmod +x "$INSTALL_DIR/serverkit" ln -sf "$INSTALL_DIR/serverkit" /usr/local/bin/serverkit # Bash tab-completion (best-effort — not every distro ships bash-completion). if [ -d /etc/bash_completion.d ]; then bash "$INSTALL_DIR/serverkit" completion > /etc/bash_completion.d/serverkit 2>/dev/null \ || rm -f /etc/bash_completion.d/serverkit fi # Without systemd (LXC/WSL/containers) we can't enable the unit — say so # clearly instead of failing cryptically (Goal G5). The probe is inline # (svc_has_systemd) so it holds even when scripts/lib never made it to # disk, where the old env.sh-based check silently skipped itself. (I17) if ! svc_has_systemd; then warn "systemd not detected — installed the unit but cannot enable/start it here." warn "Start the backend with a supervisor, or run it in the foreground; see docs/ARCHITECTURE.md." return 0 fi systemctl daemon-reload systemctl enable serverkit good "Systemd service installed." } # --------------------------------------------------------------------------- # App templates # --------------------------------------------------------------------------- sync_templates() { phase "App Templates" if [ -d "$INSTALL_DIR/backend/templates" ]; then cp -r "$INSTALL_DIR/backend/templates/"*.yaml /etc/serverkit/templates/ 2>/dev/null || true cp -r "$INSTALL_DIR/backend/templates/"*.yml /etc/serverkit/templates/ 2>/dev/null || true good "Installed $(ls /etc/serverkit/templates/*.yaml 2>/dev/null | wc -l) app templates." else warn "No app templates found." fi } # --------------------------------------------------------------------------- # Bring services up # --------------------------------------------------------------------------- launch_services() { phase "Starting Services" step "Starting the backend..." svc_start serverkit # The frontend is static files served by host nginx (built into # frontend/dist by build_frontend) — no container to build or start. step "Starting nginx..." svc_start nginx good "Services started." } # --------------------------------------------------------------------------- # Health probe + automatic rollback # --------------------------------------------------------------------------- revert_install() { warn "Health check failed — rolling back..." systemctl stop serverkit 2>/dev/null || true systemctl stop nginx 2>/dev/null || true local active active="$(readlink -f "$INSTALL_DIR" 2>/dev/null || echo "$INSTALL_DIR")" if [ -d "$active.backup" ]; then rm -rf "$active" cp -a "$active.backup" "$active" warn "Restored the previous installation from $active.backup" fi systemctl daemon-reload systemctl start serverkit 2>/dev/null || true systemctl start nginx 2>/dev/null || true halt "Install rolled back. Inspect logs: journalctl -u serverkit -n 50" } await_health() { phase "Health Check" step "Waiting for the backend to answer..." local waited=0 while [ "$waited" -lt 30 ]; do if curl -sf --max-time 5 http://127.0.0.1:5000/api/v1/system/health >/dev/null 2>&1; then good "Backend healthy." return 0 fi sleep 2 waited=$((waited + 2)) done warn "Backend did not respond on port 5000." return 1 } # --------------------------------------------------------------------------- # Safety copy of an existing install before we touch it # --------------------------------------------------------------------------- snapshot_existing() { local active active="$(readlink -f "$INSTALL_DIR" 2>/dev/null || echo "$INSTALL_DIR")" [ -d "$active" ] || return 0 if [ ! -d "$active.backup" ]; then step "Backing up the existing installation..." cp -a "$active" "$active.backup" good "Backup saved to $active.backup" return 0 fi # A .backup left by an earlier run is what revert_install restores — a # stale one would silently roll the box back to a months-old tree. # Refresh it: build the new copy first and swap only on success, so a # failed copy never costs us the backup we already have. (I20) step "Refreshing the existing installation backup..." rm -rf "$active.backup.new" 2>/dev/null || true if cp -a "$active" "$active.backup.new" 2>/dev/null; then rm -rf "$active.backup" mv "$active.backup.new" "$active.backup" good "Backup refreshed at $active.backup" else rm -rf "$active.backup.new" 2>/dev/null || true warn "Could not refresh $active.backup — keeping the previous backup." fi } # --------------------------------------------------------------------------- # Closing summary # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # First administrator # --------------------------------------------------------------------------- # Until an account exists, whoever registers first owns the panel, so the # panel requires a one-time setup code for that registration. Show it to the # person running the installer, or skip it entirely when they named the admin. claim_first_admin() { SETUP_CODE="" ADMIN_CREATED="" local backend="$INSTALL_DIR/backend" python="$VENV_DIR/bin/python" [ -x "$python" ] || return 0 if [ -n "${SERVERKIT_ADMIN_EMAIL:-}" ] && [ -n "${SERVERKIT_ADMIN_PASSWORD:-}" ]; then local username="${SERVERKIT_ADMIN_USERNAME:-${SERVERKIT_ADMIN_EMAIL%%@*}}" if (cd "$backend" && "$python" cli.py create-admin --email "$SERVERKIT_ADMIN_EMAIL" \ --username "$username" --password "$SERVERKIT_ADMIN_PASSWORD") >/dev/null 2>&1; then ADMIN_CREATED="$SERVERKIT_ADMIN_EMAIL" else warn "Could not create the admin from SERVERKIT_ADMIN_EMAIL (it may already exist)." fi fi # Last line only: creating the app can log first. Anything that is not a # code means the panel already has an account (an upgrade, or the above). SETUP_CODE=$(cd "$backend" && "$python" cli.py setup-code 2>/dev/null | tail -n 1 \ | grep -E '^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$' || true) } print_outro() { local ip ip=$(curl -sf --max-time 5 https://api.ipify.org 2>/dev/null || \ hostname -I 2>/dev/null | awk '{print $1}' || echo "YOUR_SERVER_IP") printf '\n' printf ' %s%s✔ ServerKit installed successfully%s %s%s%s\n' \ "$BLD" "$HUE_OK" "$RST" "$FOG" "$(clock)" "$RST" printf ' %s%s%s\n\n' "$V4" "──────────────────────────────────────" "$RST" if [ -n "$PANEL_DOMAIN" ]; then if [ "$SSL_MODE" = "secure" ]; then printf ' %sPanel URL%s https://%s\n' "$BLD" "$RST" "$PANEL_DOMAIN" else printf ' %sPanel URL%s http://%s\n' "$BLD" "$RST" "$PANEL_DOMAIN" fi else printf ' %sPanel URL%s http://%s\n' "$BLD" "$RST" "$ip" fi printf ' %sProfile%s %s\n' "$BLD" "$RST" "$PROFILE" if [ "$PROFILE" = "minimal" ]; then printf ' Docker was not installed, so app hosting is off.\n' printf ' Add it any time from Settings — nothing is locked.\n' fi if [ "$SSL_MODE" != "secure" ]; then printf '\n %sWARNING%s Running without HTTPS. Passwords and tokens will be\n' "$BLD" "$RST" printf ' transmitted unencrypted. Set PANEL_DOMAIN and run\n' printf ' certbot, or set SERVERKIT_SKIP_SSL=1 to suppress this warning.\n' fi if [ -n "${ADMIN_CREATED:-}" ]; then printf '\n %sAdmin%s %s (from SERVERKIT_ADMIN_EMAIL) - sign in\n\n' "$BLD" "$RST" "$ADMIN_CREATED" elif [ -n "${SETUP_CODE:-}" ]; then printf '\n %sSetup code%s %s%s%s\n' "$BLD" "$RST" "$BLD" "$SETUP_CODE" "$RST" printf ' The setup page asks for it before creating the admin.\n' printf ' Show it again: serverkit setup-code\n\n' else printf '\n' fi printf ' %sCLI%s serverkit status\n' "$BLD" "$RST" printf ' serverkit create-admin\n' printf ' serverkit --help\n\n' printf ' %sService%s systemctl status serverkit\n' "$BLD" "$RST" printf ' journalctl -u serverkit -f\n' printf ' systemctl restart serverkit\n\n' printf ' %sPaths%s install : %s\n' "$BLD" "$RST" "$INSTALL_DIR" printf ' config : %s/.env\n' "$INSTALL_DIR" printf ' backups : %s\n\n' "$BACKUP_DIR" printf ' %sUpdate%s sudo serverkit update\n\n' "$HUE_WARN" "$RST" } # --------------------------------------------------------------------------- # Anonymous install ping (best-effort) # --------------------------------------------------------------------------- ping_telemetry() { # Guarded: a missing VERSION file fails the pipeline under pipefail, and # this runs AFTER a successful install — it must never abort it. (I23) local v v=$(cat "$INSTALL_DIR/VERSION" 2>/dev/null | tr -d '\n\r ') || v="" curl -s "https://serverkit.ai/track/install?v=${v}" >/dev/null 2>&1 || true } # --------------------------------------------------------------------------- # Ask for a panel domain when running interactively # --------------------------------------------------------------------------- prompt_for_domain() { # Nothing to ask when a domain is already set, or when there is no # interactive stdin to ask on (curl | bash). SERVERKIT_FORCE_PROMPT=1 # lets the unit tests drive the prompt from a pipe. [ -z "$PANEL_DOMAIN" ] || return 0 if [ ! -t 0 ] && [ "${SERVERKIT_FORCE_PROMPT:-0}" != "1" ]; then return 0 fi printf '\n' printf '%sEnter your panel domain (e.g. panel.example.com)%s\n' "$BLD" "$RST" printf 'Leave blank to access via IP (no SSL attempt)\n' printf '%sTip:%s set PANEL_DOMAIN=... in the environment to skip this prompt\n' "$BLD" "$RST" printf ' set SERVERKIT_SKIP_SSL=1 to disable HTTPS entirely\n' printf '> ' read -r PANEL_DOMAIN || true PANEL_DOMAIN=$(printf '%s' "$PANEL_DOMAIN" | tr -d ' ') # A blank answer (plain Enter) is the documented default — and as the # LAST statement of the function this must be an `if`, not a # `[ -n ] && ...` list: the list form returned 1 on a blank answer and # set -e killed every interactive no-domain install right here. (I2) if [ -n "$PANEL_DOMAIN" ]; then PANEL_PORT="80" fi } prompt_for_public_url() { # Only meaningful when someone else terminates TLS in front of us. [ "$SERVERKIT_EXTERNAL_PROXY" = "1" ] || return 0 [ -z "$PANEL_PUBLIC_URL" ] || return 0 # Derive rather than ask when the domain already answers the question. if [ -n "$PANEL_DOMAIN" ]; then PANEL_PUBLIC_URL="https://$PANEL_DOMAIN" good "Public URL: $PANEL_PUBLIC_URL (from PANEL_DOMAIN)" return 0 fi if [ ! -t 0 ] && [ "${SERVERKIT_FORCE_PROMPT:-0}" != "1" ]; then warn "SERVERKIT_EXTERNAL_PROXY=1 without SERVERKIT_PUBLIC_URL." warn "Realtime updates will be rejected until you set it in $INSTALL_DIR/.env." return 0 fi printf '\n' printf '%sPublic URL of the panel, as your reverse proxy publishes it%s\n' "$BLD" "$RST" printf 'Example: https://serverkit.example.com\n' printf 'Required: the browser sends this as its websocket Origin, and the\n' printf 'panel rejects the handshake when it does not match.\n' printf '%sTip:%s set SERVERKIT_PUBLIC_URL=... to skip this prompt\n' "$BLD" "$RST" printf '> ' read -r PANEL_PUBLIC_URL || true PANEL_PUBLIC_URL=$(printf '%s' "$PANEL_PUBLIC_URL" | tr -d ' ') PANEL_PUBLIC_URL="${PANEL_PUBLIC_URL%/}" if [ -z "$PANEL_PUBLIC_URL" ]; then warn "No public URL given — realtime updates will be rejected until" warn "SERVERKIT_PUBLIC_URL is set in $INSTALL_DIR/.env." fi } # --------------------------------------------------------------------------- # Orchestration # --------------------------------------------------------------------------- # Decide whether a fresh install should default to a release tarball. An # EXISTING install must never flip to release mode by default: fetch_release # rewrites the whole slot. The old check tested $INSTALL_DIR/backend/src — a # path that never existed (the tree is backend/app) — so every re-run over a # live install picked release mode and rm -rf'd it, .env and database # included. Probe the real tree and the generated .env instead. (I4) should_default_to_release() { [ "$BUILD_FROM_SOURCE" != "1" ] || return 1 [ -z "${SERVERKIT_VERSION:-}" ] || return 1 [ ! -d "$INSTALL_DIR/backend/app" ] || return 1 [ ! -f "$INSTALL_DIR/.env" ] || return 1 return 0 } main() { STARTED_AT=$(date +%s) masthead # Default to a release install unless an install is already present or # the caller forced a source build / pinned a version. if should_default_to_release; then INSTALL_FROM_RELEASE=1 fi identify_system # preflight's root check must run before choose_pkg_manager, which # writes the apt lock-wait drop-in to /etc. (I11) preflight choose_pkg_manager ensure_bootstrap_tools gauge_memory ensure_swap # Ask what to install before anything is provisioned — the answer decides # whether provision_docker runs at all. prompt_for_profile prompt_for_domain prompt_for_public_url snapshot_existing # RHEL 9 family: keep sshd alive across the dnf work below (see the # function's comment for the full openssl/openssh mismatch story). upgrade_rhel_crypto_stack provision_docker ensure_compose_plugin provision_node if [ "$INSTALL_FROM_RELEASE" = "1" ]; then fetch_release || warn "Falling back to a source build." fi # When the release fetch failed, provision_node above already skipped # Node.js ("the release ships a pre-built frontend") — but the source # build below compiles the frontend and needs npm. provision_node is # idempotent (node_ready early-return), so just run it again. (Found via # Test Sandbox full mode on a GitHub-API-rate-limited host.) if [ "$INSTALL_FROM_RELEASE" != "1" ]; then provision_node fi if [ "$INSTALL_FROM_RELEASE" != "1" ]; then provision_python sync_source make_directories build_virtualenv build_frontend else make_directories # Release tarballs now ship a pre-built, relocatable venv. If one is # present, build_virtualenv will use it; otherwise it falls back to a # local build. provision_python build_virtualenv fi sync_templates # Before configure_nginx: on the full profile this is what puts certbot on # the box, and configure_nginx's HTTPS attempt needs it already there. provision_hardening configure_nginx configure_firewall write_config install_service # Tolerate failures through start + health so rollback can run. set +e launch_services await_health || revert_install set -e ping_telemetry claim_first_admin print_outro } # Sourcing this file (e.g. from scripts/test/test_install.sh) defines every # function above for unit testing without running an install. Only a direct # execution falls through to the run below. # # Piped execution (`curl -fsSL … | bash`, the documented one-liner) leaves # BASH_SOURCE unset entirely, so a bare ${BASH_SOURCE[0]} is an unbound # variable under `set -u` and kills the install before main() — test the # array is non-empty FIRST. Same guard as scripts/install.sh. if [ -n "${BASH_SOURCE[0]:-}" ] && [ "${BASH_SOURCE[0]}" != "$0" ]; then return 0 fi main "$@"