Files
ServerKit/install.sh
T
Juan DenisandClaude Opus 4.8 a09bf0fbbe feat(security): gate panel HSTS on installer SSL mode (keep HTTPS optional)
The Flask middleware emitted HSTS with includeSubDomains+preload whenever not in
debug mode -- even on HTTP-only or Cloudflare-Flexible installs, where preload is
a hard-to-reverse commitment. HTTPS is intentionally optional, so HSTS is now
gated on the operator's recorded SSL choice.

- config._resolve_ssl_mode(): SERVERKIT_SSL_MODE env -> /etc/serverkit/ssl-mode
  file -> safe 'insecure' default. Exposed as SSL_MODE / HSTS_ENABLED.
- security middleware sends HSTS only when HSTS_ENABLED and not debug. Behind a
  proxy Flask can't distinguish real TLS from a Flexible edge via
  X-Forwarded-Proto, so we trust the installer's choice, not the request scheme.
- install.sh records SERVERKIT_SSL_MODE in .env; existing installs are covered by
  the /etc/serverkit/ssl-mode file. The nginx edge still emits HSTS in its own
  secure server block, so secure installs are unaffected.

Proving tests in tests/test_security_headers.py. Full backend suite: 342 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:52:40 -04:00

981 lines
35 KiB
Bash
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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
# ---------------------------------------------------------------------------
INSTALL_DIR="/opt/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}"
# 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"
# ---------------------------------------------------------------------------
# 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..."
# Source builds need more headroom than unpacking a release.
local need_kb=5242880
[ "$INSTALL_FROM_RELEASE" = "1" ] && need_kb=2097152
local free_kb
free_kb=$(df /opt 2>/dev/null | awk 'NR==2 {print $4}')
if [ -n "$free_kb" ] && [ "$free_kb" -lt "$need_kb" ]; then
halt "Need at least $((need_kb / 1024 / 1024))GB free on /opt; less is available."
fi
local free_mem
free_mem=$(free -m | awk '/^Mem:/ {print $7}')
if [ -n "$free_mem" ] && [ "$free_mem" -lt 256 ]; then
warn "Under 256MB memory free — the install may run slowly."
fi
if [ "$EUID" -ne 0 ]; then
halt "Please run this installer as root (use sudo)."
fi
good "Pre-flight checks passed."
}
# ---------------------------------------------------------------------------
# Identify the OS family and CPU architecture
# ---------------------------------------------------------------------------
identify_system() {
phase "Detecting System"
[ -f /etc/os-release ] || halt "Cannot detect OS — /etc/os-release is missing."
. /etc/os-release
case "${ID:-}" in
ubuntu|debian)
OS_FAMILY="debian"; good "Detected: $PRETTY_NAME" ;;
fedora)
OS_FAMILY="fedora"; good "Detected: $PRETTY_NAME" ;;
rocky|almalinux|rhel|centos)
OS_FAMILY="rhel"; good "Detected: $PRETTY_NAME (RHEL family)" ;;
*)
warn "Untested OS '${ID:-unknown}' — continuing anyway." ;;
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)
# ---------------------------------------------------------------------------
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"
else
halt "No supported package manager found (need apt, dnf, or yum)."
fi
}
refresh_pkg_index() {
case "$PKG_MGR" in
apt) apt-get update -y >/dev/null 2>&1 ;;
dnf) dnf makecache --refresh >/dev/null 2>&1 ;;
yum) yum makecache --refresh >/dev/null 2>&1 ;;
esac
}
pkg_add() {
local out rc
case "$PKG_MGR" in
apt) out=$(apt-get install -y "$@" 2>&1) ;;
dnf) out=$(dnf install -y "$@" 2>&1) ;;
yum) out=$(yum install -y "$@" 2>&1) ;;
esac
rc=$?
if [ $rc -ne 0 ]; then
warn "Could not install: $* (exit $rc)"
printf '%s\n' "$out" | tail -5 >&2
return $rc
fi
}
# ---------------------------------------------------------------------------
# Memory tuning: low-RAM safe mode and a swap fallback
# ---------------------------------------------------------------------------
gauge_memory() {
local total
total=$(free -m | awk '/^Mem:/ {print $2}')
if [ "$total" -le 700 ]; then
SAFE_MODE=true
warn "Low RAM (${total}MB) — enabling VPS safe mode."
else
SAFE_MODE=false
fi
}
ensure_swap() {
local swap
swap=$(free -m | awk '/^Swap:/ {print $2}')
if [ "$swap" -lt 512 ]; then
step "Adding 1GB of swap..."
if [ ! -f /swapfile ]; then
fallocate -l 1G /swapfile 2>/dev/null || \
dd if=/dev/zero of=/swapfile bs=1M count=1024 status=none
chmod 600 /swapfile
mkswap /swapfile >/dev/null
fi
swapon /swapfile 2>/dev/null || true
good "Swap active."
fi
}
# ---------------------------------------------------------------------------
# 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
}
locate_python() {
if command -v python3 &>/dev/null; then
local v
v=$(python3 -c 'import sys;print(".".join(map(str,sys.version_info[:2])))')
if ver_in_range "$v"; then
PYTHON_BIN="python3"
good "Using system Python $v"
return
fi
fi
warn "System Python is outside the supported $PYTHON_MIN–$PYTHON_MAX range."
}
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"
locate_python
[ -n "$PYTHON_BIN" ] && return
step "Installing Python 3.12..."
if [ "$OS_FAMILY" = "debian" ]; then
if [ "$ID" = "ubuntu" ]; then
pkg_add software-properties-common
add-apt-repository -y ppa:deadsnakes/ppa
refresh_pkg_index
pkg_add python3.12 python3.12-venv python3.12-dev
else
step "Compiling Python 3.12 from source (a few minutes)..."
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
build_python_from_source
fi
PYTHON_BIN="python3.12"
elif [ "$OS_FAMILY" = "fedora" ] || [ "$OS_FAMILY" = "rhel" ]; then
if ! pkg_add python3.12 python3.12-devel; then
step "Compiling Python 3.12 from source (a few minutes)..."
pkg_add wget zlib-devel bzip2-devel readline-devel \
sqlite-devel ncurses-devel xz-devel tk-devel libffi-devel
build_python_from_source
fi
PYTHON_BIN="python3.12"
fi
command -v "$PYTHON_BIN" &>/dev/null || \
halt "Could not install Python 3.12 — install Python 3.11 or 3.12 by hand."
good "Python 3.12 installed."
}
# ---------------------------------------------------------------------------
# Docker engine + compose plugin
# ---------------------------------------------------------------------------
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
dnf config-manager --add-repo 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
dnf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repo
pkg_add docker-ce docker-ce-cli containerd.io docker-compose-plugin docker-buildx-plugin
;;
*)
curl -fsSL https://get.docker.com | sh
;;
esac
systemctl enable docker
systemctl start docker
good "Docker installed."
}
ensure_compose_plugin() {
if docker compose version &>/dev/null; then
good "Docker Compose plugin present."
return
fi
step "Installing Docker Compose plugin..."
pkg_add docker-compose-plugin
good "Docker Compose plugin installed."
}
# ---------------------------------------------------------------------------
# Node.js 20 (only needed for source builds — releases ship a built frontend)
# ---------------------------------------------------------------------------
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 command -v node &>/dev/null; then
good "Node.js already present: $(node --version)"
return
fi
step "Installing Node.js 20 LTS..."
if [ "$OS_FAMILY" = "debian" ]; then
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - >/dev/null 2>&1
else
curl -fsSL https://rpm.nodesource.com/setup_20.x | bash - >/dev/null 2>&1
fi
pkg_add nodejs
good "Node.js $(node --version) installed."
}
# ---------------------------------------------------------------------------
# Release tarball path
# ---------------------------------------------------------------------------
resolve_release_tag() {
if [ -n "$SERVERKIT_VERSION" ]; then
printf '%s' "$SERVERKIT_VERSION"
return
fi
curl -sf "https://api.github.com/repos/${GITHUB_REPO}/releases/latest" \
| grep '"tag_name"' | head -1 | cut -d'"' -f4
}
fetch_release() {
phase "Downloading Pre-built Release"
local tag; tag=$(resolve_release_tag)
if [ -z "$tag" ]; then
warn "Latest release tag unknown — falling back to a source build."
INSTALL_FROM_RELEASE=0
return 1
fi
good "Latest release: $tag"
local base="https://github.com/${GITHUB_REPO}/releases/download/${tag}"
local 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 "Unpacking release..."
rm -rf "$INSTALL_DIR"
mkdir -p "$(dirname "$INSTALL_DIR")"
tar xzf "$tarball" -C "$(dirname "$INSTALL_DIR")"
rm -f "$tarball"
good "Release unpacked into $INSTALL_DIR"
}
# ---------------------------------------------------------------------------
# Source path: fetch / refresh the repository
# ---------------------------------------------------------------------------
sync_source() {
phase "Source Code"
if [ -d "$INSTALL_DIR/.git" ]; then
step "Refreshing the existing checkout..."
cd "$INSTALL_DIR"
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..."
rm -rf "$INSTALL_DIR"
git clone --depth 1 "https://github.com/${GITHUB_REPO}.git" "$INSTALL_DIR"
cd "$INSTALL_DIR"
fi
good "Repository ready."
}
# ---------------------------------------------------------------------------
# On-disk layout
# ---------------------------------------------------------------------------
make_directories() {
phase "Creating Directories"
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"
step "Creating the virtual environment..."
$PYTHON_BIN -m venv "$VENV_DIR"
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."
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=$(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)
# ---------------------------------------------------------------------------
build_frontend() {
phase "Frontend Build"
if [ "$INSTALL_FROM_RELEASE" = "1" ]; then
good "Using the pre-built frontend from the release."
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."
}
# ---------------------------------------------------------------------------
# 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
systemctl 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/
# 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.
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
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() {
if [ "$SSL_MODE" = "secure" ]; then
sed -i "s|/etc/letsencrypt/live/YOUR_DOMAIN/|/etc/letsencrypt/live/$PANEL_DOMAIN/|g" \
/etc/nginx/sites-available/serverkit.conf
ln -sf /etc/nginx/sites-available/serverkit.conf /etc/nginx/sites-enabled/serverkit.conf
else
ln -sf /etc/nginx/sites-available/serverkit-insecure.conf /etc/nginx/sites-enabled/serverkit.conf
fi
mkdir -p /etc/serverkit
printf '%s\n' "$SSL_MODE" > /etc/serverkit/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.
[ -n "$PANEL_DOMAIN" ] && printf '%s\n' "$PANEL_DOMAIN" > /etc/serverkit/panel-domain
}
# ---------------------------------------------------------------------------
# Server-wide TLS floor. Rewrites the ssl_protocols / ssl_ciphers lines in the
# main nginx.conf (or injects them) so every HTTPS listener is forced onto
# TLS 1.2/1.3 with AEAD-only ciphers. Editing nginx.conf in place -- rather than
# adding a conf.d snippet -- avoids nginx's "duplicate ssl_protocols" error on
# distros (Debian/Ubuntu) that already set it in http{}. Idempotent.
# ---------------------------------------------------------------------------
harden_global_tls() {
local conf=/etc/nginx/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'
if grep -qE '^[[:space:]]*ssl_protocols[[:space:]]' "$conf"; 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 grep -qE '^[[:space:]]*ssl_ciphers[[:space:]]' "$conf"; 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
# ---------------------------------------------------------------------------
install_service() {
phase "Systemd Service"
cp "$INSTALL_DIR/serverkit-backend.service" /etc/systemd/system/serverkit.service
chmod 644 /etc/systemd/system/serverkit.service
chmod +x "$INSTALL_DIR/serverkit"
ln -sf "$INSTALL_DIR/serverkit" /usr/local/bin/serverkit
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 "Pruning stale Docker networks..."
docker network prune -f 2>/dev/null || true
docker container prune -f 2>/dev/null || true
step "Building the frontend container..."
cd "$INSTALL_DIR"
docker compose build 2>&1 | tail -5
step "Starting the backend..."
systemctl start serverkit
step "Starting the frontend container..."
docker compose up -d 2>&1 | tail -5
step "Starting nginx..."
systemctl 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
if [ -d "$INSTALL_DIR.backup" ]; then
rm -rf "$INSTALL_DIR"
mv "$INSTALL_DIR.backup" "$INSTALL_DIR"
warn "Restored the previous installation from 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() {
if [ -d "$INSTALL_DIR" ] && [ ! -d "$INSTALL_DIR.backup" ]; then
step "Backing up the existing installation..."
cp -a "$INSTALL_DIR" "$INSTALL_DIR.backup"
good "Backup saved to $INSTALL_DIR.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() {
local v
v=$(cat "$INSTALL_DIR/VERSION" 2>/dev/null | tr -d '\n\r ')
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() {
[ -z "$PANEL_DOMAIN" ] && [ -t 0 ] || return 0
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
PANEL_DOMAIN=$(printf '%s' "$PANEL_DOMAIN" | tr -d ' ')
[ -n "$PANEL_DOMAIN" ] && PANEL_PORT="80"
}
# ---------------------------------------------------------------------------
# Orchestration
# ---------------------------------------------------------------------------
main() {
STARTED_AT=$(date +%s)
masthead
# Default to a release install unless a source tree is already present or
# the caller forced a source build / pinned a version.
if [ "$BUILD_FROM_SOURCE" != "1" ] && [ ! -d "$INSTALL_DIR/backend/src" ] && [ -z "${SERVERKIT_VERSION:-}" ]; then
INSTALL_FROM_RELEASE=1
fi
identify_system
choose_pkg_manager
preflight
gauge_memory
ensure_swap
prompt_for_domain
snapshot_existing
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
# The CLI needs a venv even on release installs.
if [ ! -d "$VENV_DIR" ]; then
provision_python
build_virtualenv
fi
fi
sync_templates
configure_nginx
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
}
main "$@"