mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-30 16:47:01 +09:00
Introduces a full-stack Test Sandbox feature to run distro matrix tests in Docker from the UI: new backend API/service, sandbox_runs model + migration, run/log endpoints, and a new frontend page with mode selection, run history, live polling, cancel, and per-distro logs. Also adds ARM64 scripts CI coverage (ubuntu-24.04-arm) and strengthens install/test automation for minimal images and overlay rebuilds: early curl/bootstrap index refresh, stricter venv validation, release-venv glibc fallback, Node reprovisioning on source fallback, rsync --delete overlay behavior, npm bootstrap in VM install, Windows tar pinning in PowerShell harness, plus updated test docs and distro coverage notes.
1865 lines
78 KiB
Bash
1865 lines
78 KiB
Bash
#!/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
|
||
}
|
||
|
||
# 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
|
||
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 <python> can actually create virtual environments. Debian/Ubuntu
|
||
# minimal images split the venv module (ensurepip) into pythonX.Y-venv, so an
|
||
# otherwise-valid interpreter still dies much later at `python -m venv` with
|
||
# "ensurepip is not available" — probe up front instead. (I8)
|
||
# NB: `-m venv --help` alone is NOT enough — Debian's python answers --help
|
||
# happily without ensurepip installed, and only fails at creation time. The
|
||
# ensurepip import is the real check. (Found via the Test Sandbox full mode
|
||
# on the geerlingguy debian12 image.)
|
||
py_venv_ok() {
|
||
"$1" -m venv --help >/dev/null 2>&1 && \
|
||
"$1" -c 'import ensurepip' >/dev/null 2>&1
|
||
}
|
||
|
||
locate_python() {
|
||
# Prefer an explicit minor version, newest first, then bare python3. This
|
||
# mirrors scripts/update.sh so a box that has python3.11 (Debian 12) but a
|
||
# too-new/too-old default python3 is still recognized. A candidate must
|
||
# also be venv-capable (py_venv_ok); on Debian-family boxes the missing
|
||
# pythonX.Y-venv package is installed on demand before the candidate is
|
||
# given up on. Sets PYTHON_BIN and returns 0 on success; returns 1
|
||
# (without aborting) when nothing fits.
|
||
local c v
|
||
for c in python3.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/'; }
|
||
# 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"
|
||
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..."
|
||
# 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
|
||
|
||
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" <<EOF
|
||
# ServerKit Configuration
|
||
# Generated on $(date)
|
||
|
||
# Security Keys (auto-generated, keep secret!)
|
||
SECRET_KEY=$secret_key
|
||
JWT_SECRET_KEY=$jwt_secret
|
||
SERVERKIT_ENCRYPTION_KEY=$encryption_key
|
||
|
||
# Database (SQLite by default)
|
||
DATABASE_URL=sqlite:///$INSTALL_DIR/backend/instance/serverkit.db
|
||
|
||
# CORS Origins (comma-separated, add your domain)
|
||
CORS_ORIGINS=$cors_origins
|
||
|
||
# Public URL for agents and install commands (optional)
|
||
${public_url:+# SERVERKIT_PUBLIC_URL=$public_url}
|
||
|
||
# SSL mode (secure|insecure) — gates the panel's HSTS header so HTTPS stays
|
||
# optional. Mirrors /etc/serverkit/ssl-mode; set to 'secure' only when this
|
||
# server terminates real end-to-end HTTPS.
|
||
SERVERKIT_SSL_MODE=$SSL_MODE
|
||
|
||
# Ports
|
||
PORT=80
|
||
SSL_PORT=443
|
||
|
||
# Environment
|
||
FLASK_ENV=production
|
||
EOF
|
||
chmod 600 "$INSTALL_DIR/.env"
|
||
good "Configuration generated."
|
||
|
||
# When a domain is configured, bounce bare-IP visitors to it.
|
||
if [ -n "$PANEL_DOMAIN" ]; then
|
||
mkdir -p /etc/nginx/serverkit-conf.d
|
||
cat > /etc/nginx/serverkit-conf.d/canonical-domain.conf <<EOF
|
||
# Auto-generated by ServerKit install.sh
|
||
# Redirect direct IP access to the canonical panel domain.
|
||
server {
|
||
listen 80 default_server;
|
||
server_name _;
|
||
if (\$host ~* ^[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+\$) {
|
||
return 301 ${url_scheme}://$PANEL_DOMAIN\$request_uri;
|
||
}
|
||
}
|
||
EOF
|
||
good "Canonical-domain redirect configured for $PANEL_DOMAIN"
|
||
fi
|
||
}
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Frontend build (skipped on release installs)
|
||
# ---------------------------------------------------------------------------
|
||
# Give each install a unique favicon tile color so a favicon-hash comparison
|
||
# across ServerKit installs differs — a light-touch, Cloudflare-lava-lamp-style
|
||
# deterrent, NOT a security control.
|
||
#
|
||
# "Car-paint" model: pick a curated base HUE (tasteful families, no muddy
|
||
# yellows), then jitter the hue a few degrees and randomize the shade
|
||
# (saturation + lightness, kept rich and mid-dark — never light/washed-out,
|
||
# since a white glyph sits on top). So two installs that land on the same
|
||
# family still get visibly different paint, and the overall space is large
|
||
# enough that an identical color across installs is rare. The favicon is a
|
||
# plain-text SVG, so this is a pure string replace (fill → an hsl() value): no
|
||
# image tooling, and if it can't run the shipped default color just stays.
|
||
randomize_favicon() {
|
||
local fav="$INSTALL_DIR/frontend/dist/favicon.svg"
|
||
[ -f "$fav" ] || return 0
|
||
# Assign the color ONCE and persist it in $CONFIG_DIR (survives updates), so
|
||
# `serverkit update` re-applies the SAME color instead of repainting the box
|
||
# on every update — a car keeps its paint. Existing installs get a color on
|
||
# their first update. See scripts/update.sh for the matching re-apply.
|
||
local color_file="$CONFIG_DIR/favicon-color"
|
||
local hsl=""
|
||
[ -s "$color_file" ] && hsl="$(cat "$color_file" 2>/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|(<rect width=\"32\" height=\"32\" rx=\"7\" fill=\")[^\"]+(\")|\1${hsl}\2|" "$fav" 2>/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
|
||
}
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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" <<EOF
|
||
# Auto-generated by ServerKit — server-wide TLS floor. Safe to remove.
|
||
ssl_protocols TLSv1.2 TLSv1.3;
|
||
ssl_ciphers ${ciphers};
|
||
EOF
|
||
return 0
|
||
fi
|
||
|
||
# Fall back to editing nginx.conf in place (a snippet would duplicate the
|
||
# existing directive). Remove any stale snippet we may have dropped before.
|
||
rm -f "$nginx_dir/conf.d/serverkit-tls.conf" 2>/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 <out>, 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"
|
||
# Bind the API to loopback by default — host nginx fronts it on :80/:443, so
|
||
# the raw gunicorn port must not be world-reachable. Operators who front it
|
||
# differently can override with SERVERKIT_BIND_HOST=0.0.0.0.
|
||
local bind_host="${SERVERKIT_BIND_HOST:-127.0.0.1}"
|
||
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
|
||
# ---------------------------------------------------------------------------
|
||
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
|
||
ensure_bootstrap_tools
|
||
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
|
||
|
||
# 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
|
||
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 "$@"
|