#!/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 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 # --------------------------------------------------------------------------- # 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" PYTHON_MAX="3.12" 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}" PANEL_DOMAIN="${PANEL_DOMAIN:-}" PANEL_PORT="${PANEL_PORT:-80}" SERVERKIT_SKIP_SSL="${SERVERKIT_SKIP_SSL:-0}" 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|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 ;; 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' ;; *) 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) # --------------------------------------------------------------------------- # 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" else halt "No supported package manager found (need apt, dnf, yum, zypper, pacman, or apk)." 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 ;; esac return 0 } # 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 apt) out=$(apt-get install -y "$@" 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=$? ;; *) 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 } 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.12 — 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) py_venv_ok() { "$1" -m venv --help >/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.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() { cd /tmp wget -q https://www.python.org/ftp/python/3.12.8/Python-3.12.8.tgz tar xzf Python-3.12.8.tgz cd Python-3.12.8 ./configure --enable-optimizations --prefix=/usr/local 2>&1 | tail -1 make -j"$(nproc)" 2>&1 | tail -1 make altinstall 2>&1 | tail -1 cd /tmp && rm -rf Python-3.12.8 Python-3.12.8.tgz } provision_python() { phase "Installing Python" # Already have a supported interpreter? Nothing to do. if locate_python; then return fi warn "No supported Python ($PYTHON_MIN–$PYTHON_MAX) found — installing one." # Prefer the distro's own package over a source compile. Debian 12 ships # python3.11; Ubuntu 24.04 ships python3.12 (older Ubuntu falls back to the # deadsnakes PPA); Fedora/RHEL provide 3.12 or 3.11 in their repos. A slow, # fragile source build is now strictly the last resort. pkg_add is # warn-and-continue (always returns 0), so each attempt is followed by a # state probe (command -v) to decide whether the next fallback is needed. if [ "$OS_FAMILY" = "debian" ]; then if [ "${ID:-}" = "ubuntu" ]; then pkg_add python3.12 python3.12-venv python3.12-dev if ! command -v python3.12 &>/dev/null; then step "Adding deadsnakes PPA for Python 3.12..." pkg_add software-properties-common add-apt-repository -y ppa:deadsnakes/ppa || true refresh_pkg_index pkg_add python3.12 python3.12-venv python3.12-dev fi else # Debian (and Debian-like): python3.11 lives in the main repo. refresh_pkg_index pkg_add python3.11 python3.11-venv python3.11-dev command -v python3.11 &>/dev/null || \ pkg_add python3.12 python3.12-venv python3.12-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` is already 3.11+. pkg_add python elif [ "$OS_FAMILY" = "alpine" ]; then pkg_add python3 python3-dev 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." if [ "$OS_FAMILY" = "debian" ]; then pkg_add wget zlib1g-dev libbz2-dev libreadline-dev \ libsqlite3-dev libncurses5-dev libncursesw5-dev \ xz-utils tk-dev liblzma-dev libffi-dev libssl-dev else pkg_add wget zlib-devel bzip2-devel readline-devel \ sqlite-devel ncurses-devel xz-devel tk-devel libffi-devel fi build_python_from_source PYTHON_BIN="python3.12" command -v "$PYTHON_BIN" &>/dev/null || \ halt "Could not install a supported Python — install Python 3.11 or 3.12 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" 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() { 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/'; } node_ready() { command -v node &>/dev/null && command -v npm &>/dev/null || return 1 local m; m="$(node_major)" [ -n "$m" ] && [ "$m" -ge 18 ] 2>/dev/null } 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 older than 18 — falling back to NodeSource 20." if [ "$OS_FAMILY" = "debian" ]; then curl -fsSL https://deb.nodesource.com/setup_20.x | bash - >/dev/null 2>&1 || true else curl -fsSL https://rpm.nodesource.com/setup_20.x | bash - >/dev/null 2>&1 || true fi pkg_add nodejs || true fi node_ready || \ halt "Node.js 18+ (with npm) is required but could not be installed. Install Node 20 LTS and re-run." good "Node.js $(node --version) installed." } # --------------------------------------------------------------------------- # 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" else warn "Could not download checksums.txt — skipping verification" 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..." 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. if [ "$INSTALL_FROM_RELEASE" = "1" ] && [ -f "$FIRST_SLOT/venv/bin/activate" ] && [ -x "$FIRST_SLOT/venv/bin/python" ]; then 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 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 gevent gevent-websocket --quiet good "Python environment ready." } # --------------------------------------------------------------------------- # Application configuration and generated secrets # --------------------------------------------------------------------------- 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 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 cat > "$INSTALL_DIR/.env" < /etc/nginx/serverkit-conf.d/canonical-domain.conf <&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." } # --------------------------------------------------------------------------- # 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 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 } # --------------------------------------------------------------------------- # 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" 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|@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 # --------------------------------------------------------------------------- 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 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 printf '\n %sFirst step%s create an admin user\n\n' "$BLD" "$RST" 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 } # --------------------------------------------------------------------------- # 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 gauge_memory ensure_swap prompt_for_domain 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 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 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 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. [ "${BASH_SOURCE[0]}" = "${0}" ] || return 0 main "$@"