Files
Juan Denis e6e94dd23a Refuse release tarballs without a checksum
Both the installer and the updater treated a missing checksums.txt as
"skip verification" and installed the tarball anyway. Every release
publishes one, so its absence means a broken mirror or a tampered
download. The installer now falls back to a source build and the
updater refuses; SERVERKIT_ALLOW_UNVERIFIED=1 overrides for mirrors
that do not host checksums.
2026-10-10 18:20:27 -04:00

2398 lines
111 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.
#!/usr/bin/env bash
#
# Unit tests for install.sh + scripts/lib/{firewall,state,uninstall}.sh —
# runs in seconds, no server, no install.
#
# install.sh is source-able: when sourced it defines every function and then
# returns *before* main() (the BASH_SOURCE guard). That lets us exercise the
# Python/Node detection, the firewall abstraction, the install-state tracker,
# and the canonical uninstall routine against throwaway fixtures and PATH stubs
# instead of a real distro and a real /etc.
#
# Each unit-under-test runs in a subshell that re-enables `set -Eeuo pipefail`,
# so an unguarded command silently aborting under set -e is caught here as a
# failed assertion rather than on someone's server.
#
# Run: bash scripts/test/test_install.sh
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
INSTALL_SH="$REPO_DIR/install.sh"
LIB_DIR="$REPO_DIR/scripts/lib"
# Shared stub factories + the fresh-box fixture builder.
# shellcheck source=stubs.sh
source "$SCRIPT_DIR/stubs.sh"
PASS=0
FAIL=0
SKIP=0
ok() { PASS=$((PASS + 1)); printf ' \033[32m✔\033[0m %s\n' "$1"; }
bad() { FAIL=$((FAIL + 1)); printf ' \033[31m✘\033[0m %s\n' "$1"; }
skip() { SKIP=$((SKIP + 1)); printf ' \033[33m∼\033[0m %s (skipped)\n' "$1"; }
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
# --------------------------------------------------------------------------
# PATH stubs.
# STUB_BIN — node/npm fakes, added on the global PATH only during T3.
# PY_STUB — fake python interpreters, applied ONLY inside T1's subshell so
# the real python3 stays available for the state.sh test (T6).
# Each python stub echoes a fixed "major.minor" when called with `-c`.
# --------------------------------------------------------------------------
STUB_BIN="$WORK/bin"
PY_STUB="$WORK/pybin"
mkdir -p "$STUB_BIN" "$PY_STUB"
# A no-op docker stub so the uninstall routine's `command -v docker` succeeds and
# its compose-down branch runs deterministically — the CI containers don't have
# docker installed, and we're only asserting on dry-run output anyway.
make_stub_ok "$STUB_BIN" docker
mkpy() { # mkpy <name> <major.minor>
{
printf '#!/usr/bin/env bash\n'
printf 'if [ "${1:-}" = "-c" ]; then printf "%s"; fi\n' "$2"
} > "$PY_STUB/$1"
chmod +x "$PY_STUB/$1"
}
# The newer minor-version stubs report an out-of-range 3.15 (rejected);
# python3.11 is in range; bare python3 is an old 3.10 (rejected). locate_python
# must therefore pick python3.11 — proving it no longer blindly trusts
# `python3`.
#
# The out-of-range stub used to report 3.13, which stopped being out of range
# when the gate moved to cover Debian 13 (issue #99), then 3.14, which stopped
# being out of range when the gate moved to cover Arch rolling (2026-08
# baseline). Pick a version ABOVE PYTHON_MAX, and move it whenever the ceiling
# moves — the point of the stub is "too new", not any particular number. Every
# candidate name locate_python probes is shadowed so a real python3.1x on the
# host can never win over the fixtures.
mkpy python3.14 3.15
mkpy python3.13 3.15
mkpy python3.12 3.15
mkpy python3.11 3.11
mkpy python3 3.10
export PATH="$STUB_BIN:$PATH"
# --------------------------------------------------------------------------
# Source install.sh (functions only). Point the install dir at the sandbox.
# --------------------------------------------------------------------------
export SERVERKIT_DIR="$WORK/opt/serverkit"
# shellcheck disable=SC1090
source "$INSTALL_SH"
set +e +u # hand control back to the harness; tests re-arm set -e per subshell
printf '\ninstall.sh + lib unit tests\n\n'
# --------------------------------------------------------------------------
# T1 — locate_python prefers a supported minor version over a too-old python3.
# --------------------------------------------------------------------------
# && chain: set -e is suppressed inside an if-condition substitution (see T16).
if ! res="$( set -Eeuo pipefail; PATH="$PY_STUB:$PATH"
locate_python >/dev/null 2>&1 && printf '%s' "$PYTHON_BIN" )"; then
bad "locate_python aborted under set -Eeuo pipefail"
elif [ "$res" = "python3.11" ]; then
ok "locate_python picks python3.11 when python3 is 3.10 and python3.12 is out of range"
else
bad "locate_python chose [$res], expected python3.11"
fi
# --------------------------------------------------------------------------
# T2 — ver_in_range gate (3.11/3.12/3.13/3.14 accepted, 3.10/3.15 rejected).
#
# 3.13 MUST be accepted: Debian 13 ships only python3.13, and this assertion
# previously required it to be rejected — the suite was actively pinning the
# bug that made trixie uninstallable (issue #99). 3.14 MUST be accepted: Arch/
# Manjaro rolling ship only python 3.14 and carry no versioned fallback
# packages at all (2026-08 compatibility baseline).
# --------------------------------------------------------------------------
if ( set -Eeuo pipefail; ver_in_range 3.11 ) && ( set -Eeuo pipefail; ver_in_range 3.12 ) && \
( set -Eeuo pipefail; ver_in_range 3.13 ) && ( set -Eeuo pipefail; ver_in_range 3.14 ) && \
! ( set -Eeuo pipefail; ver_in_range 3.10 ) && ! ( set -Eeuo pipefail; ver_in_range 3.15 ); then
ok "ver_in_range accepts 3.11/3.12/3.13/3.14 and rejects 3.10/3.15"
else
bad "ver_in_range gate is wrong"
fi
# Debian 13 carries ONLY python3.13, so the ceiling has to include it or the
# installer can never satisfy itself from trixie's repos. Guard the constant
# directly — the gate above would still pass if PYTHON_MAX regressed to 3.12
# and someone "fixed" the assertion to match.
if [ "$PYTHON_MAX" = "3.13" ] || printf '%s\n%s' "3.13" "$PYTHON_MAX" | sort -C -V; then
ok "PYTHON_MAX ($PYTHON_MAX) covers Debian 13's python3.13"
else
bad "PYTHON_MAX is $PYTHON_MAX — Debian 13 ships only python3.13 and would fall to a source build"
fi
# Same guard for the rolling ceiling: Arch/Manjaro carry ONLY the newest python
# (3.14 at the 2026-08 baseline) and no versioned fallback packages, so a
# ceiling below it can never be satisfied from their repos.
if [ "$PYTHON_MAX" = "3.14" ] || printf '%s\n%s' "3.14" "$PYTHON_MAX" | sort -C -V; then
ok "PYTHON_MAX ($PYTHON_MAX) covers Arch rolling's python 3.14"
else
bad "PYTHON_MAX is $PYTHON_MAX — Arch/Manjaro ship only python 3.14 and would fall to a source build"
fi
# --------------------------------------------------------------------------
# T3 — node_ready gates on vite 8's Node floor (^20.19.0 || >=22.12.0) AND npm
# present. The frontend bundler (rolldown) can't build on older Node — npm
# silently drops its native binary — so a source install must fail fast rather
# than mid-build; 21.x and 22.11 are also rejected (outside the range).
# --------------------------------------------------------------------------
printf '#!/usr/bin/env bash\nexit 0\n' > "$STUB_BIN/npm"; chmod +x "$STUB_BIN/npm"
_mk_node_stub() { printf '#!/usr/bin/env bash\necho v%s\n' "$1" > "$STUB_BIN/node"; chmod +x "$STUB_BIN/node"; }
node_gate_ok=1
for v in 20.19.0 20.19.5 22.12.0 22.13.1 24.2.0; do
_mk_node_stub "$v"
( set -Eeuo pipefail; node_ready ) || node_gate_ok=0
done
for v in 16.20.0 18.19.0 20.17.0 20.18.9 21.7.0 22.11.0; do
_mk_node_stub "$v"
( set -Eeuo pipefail; node_ready ) && node_gate_ok=0
done
if [ "$node_gate_ok" = 1 ]; then
ok "node_ready accepts 20.19+/22.12+ (with npm) and rejects older (incl. v18, 21.x, 22.11)"
else
bad "node_ready mis-gated a Node version against the vite 8 floor"
fi
rm -f "$STUB_BIN/node" "$STUB_BIN/npm"
# --------------------------------------------------------------------------
# T4 — configure_firewall dry-run prints the exact firewall commands and
# records nothing (FW_DRY_RUN suppresses state writes).
# --------------------------------------------------------------------------
state_file="$WORK/state-t4.json"
if ! out="$(
set -Eeuo pipefail
export SERVERKIT_STATE_FILE="$state_file"
FW_DRY_RUN=1 FIREWALL_BACKEND=firewalld configure_firewall 2>&1
)"; then
bad "configure_firewall dry-run returned non-zero: [$out]"
elif printf '%s' "$out" | grep -q 'firewall-cmd --permanent --add-port=80/tcp' && \
printf '%s' "$out" | grep -q 'firewall-cmd --permanent --add-port=443/tcp'; then
ok "configure_firewall dry-run prints the expected firewalld commands"
else
bad "configure_firewall dry-run did not print the expected commands"
fi
if [ ! -f "$state_file" ]; then
ok "configure_firewall dry-run writes no install-state.json"
else
bad "configure_firewall dry-run unexpectedly wrote state"
fi
# --------------------------------------------------------------------------
# T5 — firewall_detect honors the FIREWALL_BACKEND override.
# --------------------------------------------------------------------------
if ! res="$( set -Eeuo pipefail; source "$LIB_DIR/firewall.sh" \
&& FIREWALL_BACKEND=ufw firewall_detect )"; then
bad "firewall_detect aborted under set -Eeuo pipefail"
elif [ "$res" = "ufw" ]; then
ok "firewall_detect honors FIREWALL_BACKEND override"
else
bad "firewall_detect ignored the override (got [$res])"
fi
# --------------------------------------------------------------------------
# T6 — state.sh roundtrip: set/get scalar, append dedup, list.
# --------------------------------------------------------------------------
if command -v python3 >/dev/null 2>&1; then
t6_rc=0
res="$(
# && chain: this capture sits on the left of `||`, where bash
# suppresses set -e even inside the substitution (see T16).
# tr -d '\r' normalizes the CRLF that Python's text-mode stdout emits on
# Windows; on Linux there is nothing to strip.
set -Eeuo pipefail
export SERVERKIT_STATE_FILE="$WORK/state-t6.json"
source "$LIB_DIR/state.sh" \
&& state_set firewall_backend firewalld \
&& state_append firewall_ports 80/tcp \
&& state_append firewall_ports 443/tcp \
&& state_append firewall_ports 80/tcp `# duplicate — must be ignored` \
&& printf '%s|%s' "$(state_get firewall_backend | tr -d '\r')" \
"$(state_list firewall_ports | tr -d '\r' | tr '\n' ',')"
)" || t6_rc=$?
if [ "$t6_rc" -eq 0 ] && [ "$res" = "firewalld|80/tcp,443/tcp," ]; then
ok "state.sh set/get/append(dedup)/list roundtrip"
else
bad "state.sh roundtrip wrong: rc=$t6_rc got [$res]"
fi
else
skip "state.sh roundtrip — python3 unavailable here"
fi
# --------------------------------------------------------------------------
# T7 — the canonical uninstall routine: default preserves data, --purge
# removes it, --keep-data preserves the config dir. All in dry-run.
# --------------------------------------------------------------------------
inst="$WORK/opt/serverkit"
mkdir -p "$inst/scripts/lib"
cp "$LIB_DIR/firewall.sh" "$LIB_DIR/state.sh" "$inst/scripts/lib/"
touch "$inst/docker-compose.yml"
uninstall_out() { # uninstall_out <extra-env>
(
# && chain: called from if-conditions, where set -e is suppressed
# all the way down (see T16).
set -Eeuo pipefail
source "$LIB_DIR/uninstall.sh" \
&& export SERVERKIT_DIR="$inst" SERVERKIT_UNINSTALL_DRY_RUN=1 \
&& eval "$1" \
&& serverkit_uninstall_core 2>&1
)
}
if ! def_out="$(uninstall_out '')"; then
bad "uninstall default (dry-run) returned non-zero"
elif printf '%s' "$def_out" | grep -q 'down --remove-orphans' && \
! printf '%s' "$def_out" | grep -q 'down -v' && \
! printf '%s' "$def_out" | grep -q 'rm -rf /var/lib/serverkit'; then
ok "uninstall default: compose down WITHOUT -v and keeps /var/lib/serverkit"
else
bad "uninstall default behaved wrong (deleted data or used -v)"
fi
if ! purge_out="$(uninstall_out 'export SERVERKIT_PURGE=1')"; then
bad "uninstall --purge (dry-run) returned non-zero"
elif printf '%s' "$purge_out" | grep -q 'down -v' && \
printf '%s' "$purge_out" | grep -q 'rm -rf /var/lib/serverkit'; then
ok "uninstall --purge: compose down -v and removes data dirs"
else
bad "uninstall --purge did not remove volumes/data"
fi
if ! keep_out="$(uninstall_out 'export SERVERKIT_KEEP_DATA=1')"; then
bad "uninstall --keep-data (dry-run) returned non-zero"
elif printf '%s' "$keep_out" | grep -q 'Preserving data' && \
! printf '%s' "$keep_out" | grep -q 'rm -rf /etc/serverkit'; then
ok "uninstall --keep-data: preserves /etc/serverkit"
else
bad "uninstall --keep-data removed the config dir"
fi
# --------------------------------------------------------------------------
# T8 — os_family_from: ID mapping + ID_LIKE fallback (incl. rhel-before-fedora).
# --------------------------------------------------------------------------
fam_ok=1
check_fam() { # check_fam <id> <id_like> <expected>
local got; got="$(os_family_from "$1" "$2")"
[ "$got" = "$3" ] || { bad "os_family_from('$1','$2') -> [$got], expected $3"; fam_ok=0; }
}
check_fam ubuntu "" debian
check_fam debian "" debian
check_fam rocky "" rhel
check_fam fedora "" fedora
check_fam opensuse-leap "" suse
check_fam arch "" arch
check_fam alpine "" alpine
check_fam gentoo "" gentoo
check_fam funtoo "" gentoo
check_fam mydistro "debian" debian # unknown ID, debian-like
check_fam clone "rhel centos fedora" rhel # RHEL clone: rhel wins over fedora
check_fam spin "fedora" fedora # pure fedora spin
check_fam suselike "suse opensuse" suse
check_fam wat "" unknown
[ "$fam_ok" = "1" ] && ok "os_family_from maps known IDs and falls back to ID_LIKE (rhel before fedora)"
# --------------------------------------------------------------------------
# T9 — render_service_unit honors a custom SERVERKIT_DIR (no @PLACEHOLDERS@).
# --------------------------------------------------------------------------
inst2="$WORK/custom/srv"
mkdir -p "$inst2/templates"
cp "$REPO_DIR/templates/serverkit-backend.service.in" "$inst2/templates/"
unit_out="$WORK/rendered.service"
t9_rc=0
(
set -Eeuo pipefail
INSTALL_DIR="$inst2"; VENV_DIR="$inst2/venv"; LOG_DIR="/var/log/serverkit"
render_service_unit "$unit_out"
) || t9_rc=$?
if [ "$t9_rc" -eq 0 ] && grep -q "WorkingDirectory=$inst2/backend" "$unit_out" && \
grep -q "$inst2/venv/bin/gunicorn" "$unit_out" && \
! grep -q '@SERVERKIT_DIR@\|@SERVERKIT_VENV_DIR@\|@PORT@' "$unit_out"; then
ok "render_service_unit substitutes a custom SERVERKIT_DIR and leaves no placeholders"
else
bad "render_service_unit left placeholders or used the wrong paths"
fi
# --------------------------------------------------------------------------
# T10 — harden_global_tls: conf.d snippet when safe, in-place edit otherwise.
# --------------------------------------------------------------------------
# (A) RHEL-style: no ssl_protocols, has conf.d include → reversible snippet.
ndA="$WORK/nginxA"; mkdir -p "$ndA/conf.d"
printf 'http {\n include /etc/nginx/conf.d/*.conf;\n}\n' > "$ndA/nginx.conf"
tlsA_rc=0
( set -Eeuo pipefail; SERVERKIT_NGINX_DIR="$ndA" harden_global_tls ) || tlsA_rc=$?
if [ "$tlsA_rc" -eq 0 ] && [ -f "$ndA/conf.d/serverkit-tls.conf" ] && \
grep -q 'TLSv1.2 TLSv1.3' "$ndA/conf.d/serverkit-tls.conf" && \
! grep -q 'ssl_protocols' "$ndA/nginx.conf"; then
ok "harden_global_tls drops a reversible conf.d snippet when nginx.conf has none"
else
bad "harden_global_tls should have written a conf.d snippet (RHEL-style)"
fi
# (B) Debian-style: ssl_protocols already present → edit in place, NO snippet
# (a second declaration would be a 'duplicate ssl_protocols' error).
ndB="$WORK/nginxB"; mkdir -p "$ndB/conf.d"
printf 'http {\n ssl_protocols TLSv1.1 TLSv1.2;\n include /etc/nginx/conf.d/*.conf;\n}\n' > "$ndB/nginx.conf"
tlsB_rc=0
( set -Eeuo pipefail; SERVERKIT_NGINX_DIR="$ndB" harden_global_tls ) || tlsB_rc=$?
if [ "$tlsB_rc" -eq 0 ] && [ ! -f "$ndB/conf.d/serverkit-tls.conf" ] && \
grep -q 'ssl_protocols TLSv1.2 TLSv1.3;' "$ndB/nginx.conf"; then
ok "harden_global_tls edits nginx.conf in place (no duplicate) when ssl_protocols exists"
else
bad "harden_global_tls should have edited in place (Debian-style)"
fi
# --------------------------------------------------------------------------
# T11 — choose_pkg_manager halts cleanly when no package manager is present
# (empty PATH hides apt/dnf/yum/zypper/pacman/apk). No /etc writes happen
# because the apt branch is never reached.
# --------------------------------------------------------------------------
if ( set -Eeuo pipefail; PATH=""; choose_pkg_manager ) >/dev/null 2>&1; then
bad "choose_pkg_manager should fail when no package manager exists"
else
ok "choose_pkg_manager halts cleanly when no package manager is found"
fi
# --------------------------------------------------------------------------
# T12 — docker-compose.yml shape.
#
# Two separate contracts live in this one file, and both have already been
# broken once:
#
# 1. No `backend:` service. On a HOST install the backend manages the host
# (creates /var/serverkit/apps/*, drives host Docker, reloads nginx),
# which a container cannot do — a backend container fails app creation
# with `Permission denied: /var/serverkit/apps/...` and has no host
# docker.sock. This broke WordPress/app creation once already.
# 2. Plain `docker compose up -d` must start something. Every service was
# once behind the `legacy-frontend` profile, so the default command
# started NOTHING while the README still told people to run it — issue
# #96, where the reporter ran the profile by hand and got an nginx
# container serving an empty dist directory (403).
# --------------------------------------------------------------------------
compose="$REPO_DIR/docker-compose.yml"
# Service keys inside the `services:` block that carry no `profiles:` — i.e.
# exactly what a plain `docker compose up -d` would start. Literal spaces
# rather than {2}/{4} intervals so this works on any awk.
compose_default_services="$(awk '
/^services:/ { in_services = 1; next }
/^[a-zA-Z]/ { in_services = 0 }
!in_services { next }
/^ [a-zA-Z0-9_-]+:[ ]*$/ { svc = $1; sub(/:$/, "", svc); order[++n] = svc; cur = svc; next }
/^ profiles:/ { if (cur != "") profiled[cur] = 1 }
END { for (i = 1; i <= n; i++) if (!profiled[order[i]]) print order[i] }
' "$compose" | sort | tr '\n' ' ')"
if grep -Eq '^[[:space:]]{2}backend:' "$compose"; then
bad "docker-compose.yml defines a 'backend' service — the backend must run on the host, not in a container"
elif ! grep -Eq '^[[:space:]]{2}frontend:' "$compose"; then
bad "docker-compose.yml is missing the legacy 'frontend' service"
elif ! grep -q 'backend:host-gateway' "$compose"; then
bad "docker-compose.yml frontend is missing the 'backend:host-gateway' alias to reach the host backend"
elif ! awk '/^ frontend:/{f=1} f&&/^ profiles:/{print;exit}' "$compose" | grep -q 'legacy-frontend'; then
bad "the legacy frontend service is no longer behind the 'legacy-frontend' profile"
else
ok "docker-compose.yml has no backend service and keeps the legacy frontend profiled"
fi
# `docker compose up -d` must resolve to at least one service. Anything without
# a `profiles:` key starts by default.
if [ -z "${compose_default_services// /}" ]; then
bad "every docker-compose.yml service is behind a profile — 'docker compose up -d' would start NOTHING (issue #96)"
elif ! grep -Eq '^[[:space:]]{2}serverkit:' "$compose"; then
bad "docker-compose.yml no longer defines the all-in-one 'serverkit' service"
else
ok "'docker compose up -d' starts the all-in-one panel [$compose_default_services]"
fi
# The all-in-one image writes its SQLite database to /app/instance. The
# Dockerfile must create that directory and chown it to the non-root runtime
# user, or the container dies on boot with "unable to open database file" —
# and a named volume mounted there inherits root ownership (issue #96).
dockerfile="$REPO_DIR/Dockerfile"
if ! grep -q '/app/instance' "$dockerfile"; then
bad "Dockerfile never creates /app/instance — the container cannot open its database"
elif ! grep -Eq 'mkdir -p .*/app/instance' "$dockerfile"; then
bad "Dockerfile mentions /app/instance but does not mkdir it"
else
ok "Dockerfile creates /app/instance for the database volume"
fi
# --------------------------------------------------------------------------
# T13 — I1: the default no-domain install must survive install_nginx_config_
# for_mode. `[ -n "$PANEL_DOMAIN" ] && printf ...` as the function's LAST
# statement returned 1 on an empty domain and set -e killed every curl-pipe
# install at the nginx phase (shipped broken since v1.6.25).
# --------------------------------------------------------------------------
t="$WORK/t13"; mkdir -p "$t/nginx/sites-available" "$t/nginx/sites-enabled" "$t/cfg"
printf 'server { listen 80; }\n' > "$t/nginx/sites-available/serverkit-insecure.conf"
printf 'ssl_certificate /etc/letsencrypt/live/YOUR_DOMAIN/fullchain.pem;\n' \
> "$t/nginx/sites-available/serverkit.conf"
if ( set -Eeuo pipefail
SERVERKIT_NGINX_DIR="$t/nginx" SERVERKIT_CONFIG_DIR="$t/cfg" \
PANEL_DOMAIN="" SSL_MODE=insecure install_nginx_config_for_mode ) >/dev/null 2>&1; then
if [ "$(cat "$t/cfg/ssl-mode")" = "insecure" ] && [ ! -f "$t/cfg/panel-domain" ]; then
ok "install_nginx_config_for_mode survives an empty PANEL_DOMAIN (the v1.6.25 no-domain abort)"
else
bad "install_nginx_config_for_mode wrote the wrong mode/domain state for a no-domain install"
fi
else
bad "install_nginx_config_for_mode DIED with an empty PANEL_DOMAIN under set -e (I1 regression)"
fi
if ( set -Eeuo pipefail
SERVERKIT_NGINX_DIR="$t/nginx" SERVERKIT_CONFIG_DIR="$t/cfg" \
PANEL_DOMAIN="panel.example.com" SSL_MODE=secure install_nginx_config_for_mode ) >/dev/null 2>&1 \
&& grep -q 'live/panel.example.com/' "$t/nginx/sites-available/serverkit.conf" \
&& [ "$(cat "$t/cfg/panel-domain")" = "panel.example.com" ]; then
ok "install_nginx_config_for_mode (secure) applies the cert path and persists the domain"
else
bad "install_nginx_config_for_mode (secure) did not apply the cert path / persist the domain"
fi
# --------------------------------------------------------------------------
# T14 — I2: plain Enter at the interactive domain prompt must not abort.
# Same end-of-function `[ -n ] && ...` shape as I1. SERVERKIT_FORCE_PROMPT=1
# lets the test drive the prompt from a pipe instead of a tty.
# --------------------------------------------------------------------------
if printf '\n' | ( set -Eeuo pipefail
PANEL_DOMAIN="" SERVERKIT_FORCE_PROMPT=1 prompt_for_domain ) >/dev/null 2>&1; then
ok "prompt_for_domain survives a blank answer (plain Enter, the no-domain default)"
else
bad "prompt_for_domain DIED on a blank answer under set -e (I2 regression)"
fi
if ! res="$(printf 'panel.example.com\n' | (
set -Eeuo pipefail
PANEL_DOMAIN=""; PANEL_PORT=""; SERVERKIT_FORCE_PROMPT=1
prompt_for_domain >/dev/null 2>&1 \
&& printf '%s|%s' "$PANEL_DOMAIN" "$PANEL_PORT"
))"; then
bad "prompt_for_domain aborted on a typed domain under set -Eeuo pipefail"
elif [ "$res" = "panel.example.com|80" ]; then
ok "prompt_for_domain accepts a typed domain and sets the port"
else
bad "prompt_for_domain returned [$res], expected panel.example.com|80"
fi
# --------------------------------------------------------------------------
# T15 — I4 (detection half): should_default_to_release must see an EXISTING
# install. The old check probed backend/src (never exists — the tree is
# backend/app), so every re-run picked release mode and fetch_release
# rm -rf'd the live install, .env + SQLite DB included.
# --------------------------------------------------------------------------
t="$WORK/t15"
mkdir -p "$t/fresh" "$t/existing/backend/app" "$t/envonly"
printf 'SECRET_KEY=x\n' > "$t/envonly/.env"
det_ok=1
( set -Eeuo pipefail; INSTALL_DIR="$t/fresh"; BUILD_FROM_SOURCE=0; SERVERKIT_VERSION=""
should_default_to_release ) || { bad "fresh box should default to a release install"; det_ok=0; }
( set -Eeuo pipefail; INSTALL_DIR="$t/existing"; BUILD_FROM_SOURCE=0; SERVERKIT_VERSION=""
should_default_to_release ) && { bad "existing backend/app tree must NOT default to release (data loss)"; det_ok=0; }
( set -Eeuo pipefail; INSTALL_DIR="$t/envonly"; BUILD_FROM_SOURCE=0; SERVERKIT_VERSION=""
should_default_to_release ) && { bad "existing .env must NOT default to release (data loss)"; det_ok=0; }
( set -Eeuo pipefail; INSTALL_DIR="$t/fresh"; BUILD_FROM_SOURCE=1; SERVERKIT_VERSION=""
should_default_to_release ) && { bad "BUILD_FROM_SOURCE=1 must force the source path"; det_ok=0; }
[ "$det_ok" = "1" ] && ok "should_default_to_release detects an existing install (backend/app or .env)"
# --------------------------------------------------------------------------
# T16 — I4 (preservation half): live state survives a slot rewrite
# byte-identical. First the helpers in isolation, then the real fetch_release
# offline-tarball path over a symlinked blue/green layout (Linux CI; skipped
# where symlinks are unsupported, same convention as test_update.sh T4).
# --------------------------------------------------------------------------
t="$WORK/t16"; mkdir -p "$t/slot/backend/instance"
printf 'SECRET_KEY=sentinel-abc123\nSERVERKIT_ENCRYPTION_KEY=k\n' > "$t/slot/.env"
printf 'SQLITE-SENTINEL-BYTES\n' > "$t/slot/backend/instance/serverkit.db"
cp "$t/slot/.env" "$t/env.orig"
cp "$t/slot/backend/instance/serverkit.db" "$t/db.orig"
if (
# One && chain, not sequential statements: bash suppresses set -e inside
# an if-condition subshell, so only an explicit chain makes every
# assertion gate the result (a cmp-less EL9 CI image proved this —
# the sibling t16r test passed vacuously while cmp exited 127).
set -Eeuo pipefail
stash="$(stash_live_state "$t/slot")" \
&& [ -n "$stash" ] \
&& rm -rf "$t/slot" \
&& mkdir -p "$t/slot/backend" `# fresh tree: no .env, no instance/` \
&& restore_live_state "$stash" "$t/slot" \
&& files_identical "$t/slot/.env" "$t/env.orig" \
&& files_identical "$t/slot/backend/instance/serverkit.db" "$t/db.orig"
) >/dev/null 2>&1; then
ok "stash/restore_live_state carries .env + instance DB byte-identical across a rewrite"
else
bad "stash/restore_live_state lost or altered the live state"
fi
t="$WORK/t16r"; slotA="$t/sk-a"; slotB="$t/sk-b"; inst="$t/sk"
mkdir -p "$slotA/backend/instance"
printf 'SECRET_KEY=keep-me\n' > "$slotA/.env"
printf 'DB-SENTINEL\n' > "$slotA/backend/instance/serverkit.db"
cp "$slotA/.env" "$t/env.orig"; cp "$slotA/backend/instance/serverkit.db" "$t/db.orig"
ln -sfn "$slotA" "$inst" 2>/dev/null || true
if [ ! -L "$inst" ]; then
skip "fetch_release live-state carry — symlinks unsupported here (runs on Linux CI)"
elif ! command -v tar >/dev/null 2>&1; then
# openSUSE Leap minimal images ship without tar; neither the fixture nor
# fetch_release itself can run there. Proven on the other 6 distros.
skip "fetch_release live-state carry — tar unavailable here"
else
stage="$t/stage"; mkdir -p "$stage/serverkit/backend/app" "$stage/serverkit/scripts"
printf '#!/bin/sh\n' > "$stage/serverkit/serverkit"
printf '9.9.9\n' > "$stage/serverkit/VERSION"
tar czf "$t/rel.tar.gz" -C "$stage" serverkit
if (
set -Eeuo pipefail
INSTALL_DIR="$inst"; DIR_A="$slotA"; DIR_B="$slotB"; FIRST_SLOT="$slotA"
SERVERKIT_OFFLINE_TARBALL="$t/rel.tar.gz"
fetch_release \
&& files_identical "$slotA/.env" "$t/env.orig" \
&& files_identical "$slotA/backend/instance/serverkit.db" "$t/db.orig" \
&& [ -d "$slotA/backend/app" ]
) >/dev/null 2>&1; then
ok "fetch_release re-run preserves .env + database byte-identical (the data-loss bug)"
else
bad "fetch_release re-run destroyed or altered .env / the database (I4 regression)"
fi
fi
# --------------------------------------------------------------------------
# T17 — I20: snapshot_existing must refresh a stale .backup instead of
# silently keeping a months-old tree (which revert_install would then
# restore over a much newer install).
# --------------------------------------------------------------------------
t="$WORK/t17"; mkdir -p "$t/inst"
printf 'v1\n' > "$t/inst/marker"
snap_rc=0
( set -Eeuo pipefail; INSTALL_DIR="$t/inst"; snapshot_existing ) >/dev/null 2>&1 || snap_rc=$?
if [ "$snap_rc" -eq 0 ] && [ -f "$t/inst.backup/marker" ] && grep -q v1 "$t/inst.backup/marker"; then
ok "snapshot_existing creates the first backup"
else
bad "snapshot_existing rc=$snap_rc or did not create a backup"
fi
printf 'v2\n' > "$t/inst/marker"
snap_rc=0
( set -Eeuo pipefail; INSTALL_DIR="$t/inst"; snapshot_existing ) >/dev/null 2>&1 || snap_rc=$?
if [ "$snap_rc" -eq 0 ] && grep -q v2 "$t/inst.backup/marker" 2>/dev/null && [ ! -d "$t/inst.backup.new" ]; then
ok "snapshot_existing refreshes a stale .backup on re-run (no months-old rollback source)"
else
bad "snapshot_existing rc=$snap_rc, kept the stale backup (I20 regression) or left .backup.new behind"
fi
# --------------------------------------------------------------------------
# T18 — I3: the release-venv install must not delete its own copy source. In
# the default layout $VENV_DIR resolves to $FIRST_SLOT/venv, so the old
# rm -rf + cp deleted the venv then failed to copy it. Same-path → keep in
# place; distinct path → still copies.
# --------------------------------------------------------------------------
t="$WORK/t18"; mkdir -p "$t/slot/venv/bin" "$t/elsewhere"
: > "$t/slot/venv/bin/activate"
printf '#!/bin/sh\n' > "$t/slot/venv/bin/python"; chmod +x "$t/slot/venv/bin/python"
if (
set -Eeuo pipefail
INSTALL_FROM_RELEASE=1; FIRST_SLOT="$t/slot"; VENV_DIR="$t/slot/venv"
locate_python() { PYTHON_BIN=python3.14; return 0; }
ensure_python_build_deps() { exit 1; }
provision_python
build_virtualenv \
&& [ -f "$t/slot/venv/bin/activate" ]
) >/dev/null 2>&1; then
ok "build_virtualenv keeps the venv when source and destination are the same directory"
else
bad "build_virtualenv deleted its own venv on the same-path layout (I3 regression)"
fi
if (
set -Eeuo pipefail
INSTALL_FROM_RELEASE=1; FIRST_SLOT="$t/slot"; VENV_DIR="$t/elsewhere/venv"
build_virtualenv \
&& [ -f "$t/elsewhere/venv/bin/activate" ] \
&& [ -f "$t/slot/venv/bin/activate" ]
) >/dev/null 2>&1; then
ok "build_virtualenv still copies the release venv to a distinct VENV_DIR"
else
bad "build_virtualenv failed to copy the release venv to a custom VENV_DIR"
fi
ln -sfn "$t/slot" "$t/link" 2>/dev/null || true
if [ ! -L "$t/link" ]; then
skip "build_virtualenv symlinked default layout — symlinks unsupported here (runs on Linux CI)"
else
if (
set -Eeuo pipefail
INSTALL_FROM_RELEASE=1; FIRST_SLOT="$t/slot"; VENV_DIR="$t/link/venv"
build_virtualenv \
&& [ -x "$t/slot/venv/bin/python" ]
) >/dev/null 2>&1; then
ok "build_virtualenv survives the default symlinked layout (/opt/serverkit → slot)"
else
bad "build_virtualenv broke on the symlinked layout (I3 regression)"
fi
fi
# --------------------------------------------------------------------------
# T19 — I6/I12: pkg_add promises warn-and-continue, so a failing package
# manager must produce a warning and exit 0 — the old body died on the
# out=$(...) assignment under set -e, printing NOTHING. refresh_pkg_index is
# best-effort too, and its yum arm must not use the dnf-only --refresh flag.
# --------------------------------------------------------------------------
t="$WORK/t19"; mkdir -p "$t/bin"
printf '#!/usr/bin/env bash\necho "E: Unable to locate package"\nexit 100\n' > "$t/bin/apt-get"
chmod +x "$t/bin/apt-get"
out="$( set -Eeuo pipefail; PATH="$t/bin:$PATH"; PKG_MGR=apt pkg_add nosuchpkg 2>&1 )"
rc=$?
if [ "$rc" -eq 0 ] && printf '%s' "$out" | grep -q 'Could not install: nosuchpkg'; then
ok "pkg_add warns and returns 0 when the package manager fails (errexit-immune body)"
else
bad "pkg_add rc=$rc (expected 0) or printed no warning: [$out]"
fi
if ( set -Eeuo pipefail; PATH="$t/bin:$PATH"; PKG_MGR=apt refresh_pkg_index ) >/dev/null 2>&1; then
ok "refresh_pkg_index is best-effort (a failed index refresh never aborts)"
else
bad "refresh_pkg_index propagated a failure under set -e (I6 regression)"
fi
if ! grep -qE '^[^#]*yum makecache --refresh' "$INSTALL_SH"; then
ok "refresh_pkg_index yum arm avoids the dnf-only --refresh flag (I12)"
else
bad "the yum arm uses 'makecache --refresh' again — that flag is dnf-only (I12 regression)"
fi
# --------------------------------------------------------------------------
# T20 — I5: the RHEL-9 upfront openssh+openssl paired upgrade (drops sshd
# 'OpenSSL version mismatch' on Rocky 9) must run the exact paired
# transaction, survive a failing dnf, and no-op on other families.
# --------------------------------------------------------------------------
t="$WORK/t20"; mkdir -p "$t/bin"
DNF_LOG="$t/dnf.log"; : > "$DNF_LOG"
cat > "$t/bin/dnf" <<EOF
#!/usr/bin/env bash
printf '%s\n' "\$*" >> "$DNF_LOG"
exit 1
EOF
chmod +x "$t/bin/dnf"
if ( set -Eeuo pipefail; PATH="$t/bin:$PATH"; OS_FAMILY=rhel upgrade_rhel_crypto_stack ) >/dev/null 2>&1 \
&& grep -q 'upgrade -y openssh openssh-server openssh-clients openssl openssl-libs openssl-devel' "$DNF_LOG"; then
ok "upgrade_rhel_crypto_stack upgrades openssh+openssl in ONE transaction and survives a failing dnf"
else
bad "upgrade_rhel_crypto_stack missing/wrong dnf transaction or aborted (I5 regression)"
fi
: > "$DNF_LOG"
rhel_rc=0
( set -Eeuo pipefail; PATH="$t/bin:$PATH"; OS_FAMILY=debian upgrade_rhel_crypto_stack ) >/dev/null 2>&1 || rhel_rc=$?
if [ "$rhel_rc" -eq 0 ] && [ ! -s "$DNF_LOG" ]; then
ok "upgrade_rhel_crypto_stack is a no-op outside the RHEL family"
else
bad "upgrade_rhel_crypto_stack rc=$rhel_rc or ran dnf on a non-RHEL family"
fi
# --------------------------------------------------------------------------
# T21 — I7: sync_source needs git but nothing installed it. Without git and
# with the on-demand install failing, sync_source must halt LOUDLY (clear
# message) before ever attempting the clone. Curated PATH: a failing apt-get
# plus a real tail; no git anywhere.
# --------------------------------------------------------------------------
t="$WORK/t21"; mkdir -p "$t/bin"
printf '#!/usr/bin/env bash\nexit 1\n' > "$t/bin/apt-get"; chmod +x "$t/bin/apt-get"
printf '#!/usr/bin/env bash\nexec %s "$@"\n' "$(command -v tail)" > "$t/bin/tail"; chmod +x "$t/bin/tail"
out="$( set -Eeuo pipefail; PATH="$t/bin"; PKG_MGR=apt sync_source 2>&1 )"
rc=$?
if [ "$rc" -ne 0 ] && printf '%s' "$out" | grep -q 'git is required'; then
ok "sync_source installs git on demand and halts clearly when that fails (never a cryptic clone error)"
else
bad "sync_source rc=$rc without the 'git is required' halt: [$out]"
fi
# --------------------------------------------------------------------------
# T22 — I8: locate_python must reject an interpreter whose venv module is
# broken (Debian/Ubuntu minimal without pythonX.Y-venv → 'ensurepip is not
# available' much later), and on Debian-family it must first try to install
# the matching pythonX.Y-venv package.
# --------------------------------------------------------------------------
t="$WORK/t22"; PYX="$t/py"; mkdir -p "$PYX" "$t/bin"
mkvless() { # mkvless <name> <major.minor> — version OK, `-m venv` broken
{
printf '#!/usr/bin/env bash\n'
printf 'if [ "${1:-}" = "-c" ]; then printf "%s"; exit 0; fi\n' "$2"
printf 'if [ "${1:-}" = "-m" ]; then exit 1; fi\n'
printf 'exit 0\n'
} > "$PYX/$1"
chmod +x "$PYX/$1"
}
# Every candidate name locate_python probes must be shadowed here — a real
# python3.14 (Manjaro) or python3.13 (Gentoo stage3) earlier in PATH would
# otherwise win over the fixtures and turn this into a host-dependent test.
mkvless python3.14 3.15 # out of range — rejected before the venv probe
mkvless python3.13 3.15 # out of range — rejected before the venv probe
mkvless python3.12 3.12 # in range but venv-less — must be rejected
mkvless python3.11 3.11 # in range but venv-less — must be rejected
mkvless python3 3.10 # too old
if ( set -Eeuo pipefail; PATH="$PYX:$PATH"; locate_python ) >/dev/null 2>&1; then
bad "locate_python accepted a venv-less interpreter (I8 regression)"
else
ok "locate_python rejects an interpreter whose '-m venv' is broken"
fi
APT_LOG="$t/apt.log"; : > "$APT_LOG"
cat > "$t/bin/apt-get" <<EOF
#!/usr/bin/env bash
printf '%s\n' "\$*" >> "$APT_LOG"
exit 1
EOF
chmod +x "$t/bin/apt-get"
( set -Eeuo pipefail; PATH="$t/bin:$PYX:$PATH"; OS_FAMILY=debian PKG_MGR=apt locate_python ) >/dev/null 2>&1
# Match the PACKAGE, not the exact argv: pkg_add also passes
# -o Dpkg::Options::=… now, and an assertion pinned to `install -y <pkg>`
# breaks on any future flag without the behaviour under test changing.
if grep -qE 'install .*python3\.11-venv' "$APT_LOG"; then
ok "locate_python (Debian family) tries to install the matching pythonX.Y-venv package"
else
bad "locate_python never attempted the python3.11-venv fallback; apt saw: $(tr '\n' ';' < "$APT_LOG")"
fi
# --------------------------------------------------------------------------
# T23 — I9: distro-repo Docker (docker.io) has no Docker-repo
# 'docker-compose-plugin'; ensure_compose_plugin must fall back to Ubuntu's
# 'docker-compose-v2' and, when neither lands, warn and continue instead of
# aborting the install.
# --------------------------------------------------------------------------
t="$WORK/t23"; mkdir -p "$t/bin"
APT_LOG="$t/apt.log"; : > "$APT_LOG"
printf '#!/usr/bin/env bash\nexit 1\n' > "$t/bin/docker"; chmod +x "$t/bin/docker"
cat > "$t/bin/apt-get" <<EOF
#!/usr/bin/env bash
printf '%s\n' "\$*" >> "$APT_LOG"
exit 100
EOF
chmod +x "$t/bin/apt-get"
out="$( set -Eeuo pipefail; PATH="$t/bin:$PATH"; PKG_MGR=apt ensure_compose_plugin 2>&1 )"
rc=$?
if [ "$rc" -eq 0 ] && grep -qE 'install .*docker-compose-plugin' "$APT_LOG" \
&& grep -qE 'install .*docker-compose-v2' "$APT_LOG" \
&& printf '%s' "$out" | grep -q 'docker compose'; then
ok "ensure_compose_plugin tries plugin → docker-compose-v2 → warn-and-continue (never aborts)"
else
bad "ensure_compose_plugin rc=$rc; apt saw: $(tr '\n' ';' < "$APT_LOG")"
fi
# --------------------------------------------------------------------------
# T24 — I10: dnf5 (Fedora 41+) removed `config-manager --add-repo URL`;
# docker_repo_add must fall back to the dnf5 `addrepo --from-repofile=` form,
# and stay warn-and-continue when both forms fail.
# --------------------------------------------------------------------------
t="$WORK/t24"; mkdir -p "$t/bin"
DNF_LOG="$t/dnf.log"; : > "$DNF_LOG"
cat > "$t/bin/dnf" <<EOF
#!/usr/bin/env bash
printf '%s\n' "\$*" >> "$DNF_LOG"
case "\$*" in
"config-manager --add-repo "*) exit 1 ;; # dnf5: flag removed
"config-manager addrepo "*) exit 0 ;;
esac
exit 0
EOF
chmod +x "$t/bin/dnf"
if ( set -Eeuo pipefail; PATH="$t/bin:$PATH"
docker_repo_add https://example.invalid/docker-ce.repo ) >/dev/null 2>&1 \
&& grep -q 'config-manager addrepo --from-repofile=https://example.invalid/docker-ce.repo' "$DNF_LOG"; then
ok "docker_repo_add falls back to the dnf5 addrepo syntax when --add-repo fails"
else
bad "docker_repo_add never tried the dnf5 syntax; dnf saw: $(tr '\n' ';' < "$DNF_LOG")"
fi
printf '#!/usr/bin/env bash\nexit 1\n' > "$t/bin/dnf"; chmod +x "$t/bin/dnf"
if ( set -Eeuo pipefail; PATH="$t/bin:$PATH"
docker_repo_add https://example.invalid/docker-ce.repo ) >/dev/null 2>&1; then
ok "docker_repo_add warns and continues when both config-manager forms fail"
else
bad "docker_repo_add aborted when both config-manager forms failed (must warn-and-continue)"
fi
# --------------------------------------------------------------------------
# T25 — I21: a failing state_set/state_append (e.g. unwritable
# install-state.json) must not abort the firewall phase whose actual firewall
# work already succeeded. The state file is pointed under a regular FILE so
# state.sh's python genuinely fails; ufw is stubbed so firewall_open runs.
# --------------------------------------------------------------------------
if command -v python3 >/dev/null 2>&1; then
t="$WORK/t25"; mkdir -p "$t/bin"
printf '#!/usr/bin/env bash\nexit 0\n' > "$t/bin/ufw"; chmod +x "$t/bin/ufw"
: > "$t/blocker" # regular file — state.json can't live under it
out="$(
set -Eeuo pipefail
export PATH="$t/bin:$PATH"
export SERVERKIT_STATE_FILE="$t/blocker/state.json"
FIREWALL_BACKEND=ufw configure_firewall 2>&1
)"
rc=$?
if [ "$rc" -eq 0 ] && printf '%s' "$out" | grep -q 'Firewall configured'; then
ok "configure_firewall survives failing state_set/state_append (records are best-effort)"
else
bad "configure_firewall rc=$rc when state writes fail (I21 regression): [$out]"
fi
else
skip "configure_firewall state-failure guard — python3 unavailable here"
fi
# --------------------------------------------------------------------------
# T26 — regression guards baked into the source (same spirit as
# test_update.sh T26): the audited failure shapes must never creep back in.
# --------------------------------------------------------------------------
guards_ok=1
if grep -qE '^[^#]*backend/src' "$INSTALL_SH"; then
bad "install.sh probes backend/src again — that path never exists (I4 detection regression)"
guards_ok=0
fi
if ! grep -qE '^[[:space:]]*upgrade_rhel_crypto_stack$' "$INSTALL_SH"; then
bad "main() no longer calls upgrade_rhel_crypto_stack (I5 regression)"
guards_ok=0
fi
[ "$guards_ok" = "1" ] && ok "source guards: no backend/src probe; main runs the RHEL crypto upgrade"
# --------------------------------------------------------------------------
# T27 — I11: nothing may write to the host before the root check. main() must
# run preflight BEFORE choose_pkg_manager (which drops the apt lock-wait file
# into /etc), and inside preflight the root check must come first — so a
# non-root run halts on the friendly message, not a raw "Permission denied".
# Behavioral half is dual: as non-root the friendly halt fires even with NO
# df/free on PATH (the check precedes every probe); as root (distro-container
# CI) preflight passes on stubbed df/free and probes the actual install
# target with `df -Pk` (I14).
# --------------------------------------------------------------------------
main_body="$(awk '/^main\(\) \{/,/^\}/' "$INSTALL_SH")"
pf_at="$(printf '%s\n' "$main_body" | grep -n '^[[:space:]]*preflight$' | cut -d: -f1 | head -1)"
pkg_at="$(printf '%s\n' "$main_body" | grep -n '^[[:space:]]*choose_pkg_manager$' | cut -d: -f1 | head -1)"
if [ -n "$pf_at" ] && [ -n "$pkg_at" ] && [ "$pf_at" -lt "$pkg_at" ]; then
ok "main() runs preflight (root check) before choose_pkg_manager's host write"
else
bad "main() order wrong: preflight at [$pf_at], choose_pkg_manager at [$pkg_at] (I11 regression)"
fi
t="$WORK/t27"; mkdir -p "$t/bin" "$t/deep"
if [ "$(id -u)" -ne 0 ]; then
out="$( set -Eeuo pipefail; PATH="$t/bin"; preflight 2>&1 )"
rc=$?
if [ "$rc" -ne 0 ] && printf '%s' "$out" | grep -q 'as root'; then
ok "preflight halts a non-root run on the friendly message before any probe or write"
else
bad "preflight (non-root) rc=$rc without the friendly root halt (I11 regression): [$out]"
fi
else
DF_LOG="$t/df.log"; : > "$DF_LOG"
cat > "$t/bin/df" <<EOF
#!/usr/bin/env bash
printf '%s\n' "\$*" >> "$DF_LOG"
printf 'Filesystem 1024-blocks Used Available Capacity Mounted on\n'
printf '/dev/sda1 99999999 1 99999999 1%% /\n'
EOF
chmod +x "$t/bin/df"
printf '#!/usr/bin/env bash\nprintf "Mem: 4096 1024 3072 0 0 3072\\n"\n' > "$t/bin/free"
chmod +x "$t/bin/free"
out="$( set -Eeuo pipefail; PATH="$t/bin:$PATH"; INSTALL_DIR="$t/deep/nested/target" preflight 2>&1 )"
rc=$?
if [ "$rc" -eq 0 ] && printf '%s' "$out" | grep -q 'Pre-flight checks passed' && \
grep -q -- "-Pk $t/deep" "$DF_LOG"; then
ok "preflight (root) probes the install target's filesystem with df -Pk (I14)"
else
bad "preflight (root) rc=$rc; df saw: $(tr '\n' ';' < "$DF_LOG") (I14 regression): [$out]"
fi
fi
# --------------------------------------------------------------------------
# T28 — I14: `free` is absent from some LXC templates; gauge_memory must
# degrade to no-safe-mode with a warning, not abort on the bare assignment
# under pipefail.
# --------------------------------------------------------------------------
t="$WORK/t28"; mkdir -p "$t/nofree"
if ! res="$( set -Eeuo pipefail; PATH="$t/nofree"; SAFE_MODE=true
gauge_memory >/dev/null 2>&1 && printf '%s' "$SAFE_MODE" )"; then
bad "gauge_memory aborted without 'free' under set -Eeuo pipefail (I14 regression)"
elif [ "$res" = "false" ]; then
ok "gauge_memory degrades gracefully when 'free' is absent (LXC templates)"
else
bad "gauge_memory aborted or left SAFE_MODE=[$res] without 'free' (I14 regression)"
fi
# --------------------------------------------------------------------------
# T29 — I19: ensure_swap must verify swap actually came up before claiming
# "Swap active." (on btrfs/containers swapon silently fails and the Vite
# build later OOMs), and a full disk (fallocate AND dd fail) must degrade to
# a warning, never an abort.
# --------------------------------------------------------------------------
t="$WORK/t29"; mkdir -p "$t/bin"
printf '#!/usr/bin/env bash\nprintf "Mem: 2048 512 1536 0 0 1536\\nSwap: 0 0 0\\n"\n' > "$t/bin/free"
printf '#!/usr/bin/env bash\nexit 1\n' > "$t/bin/fallocate"
printf '#!/usr/bin/env bash\nexit 1\n' > "$t/bin/dd"
printf '#!/usr/bin/env bash\nexit 1\n' > "$t/bin/mkswap"
printf '#!/usr/bin/env bash\nexit 1\n' > "$t/bin/swapon"
chmod +x "$t/bin/"*
out="$( set -Eeuo pipefail; PATH="$t/bin:$PATH"
SERVERKIT_SWAPFILE="$t/swapfile" SERVERKIT_PROC_SWAPS="$t/proc_swaps" ensure_swap 2>&1 )"
rc=$?
if [ "$rc" -eq 0 ] && printf '%s' "$out" | grep -q 'Could not activate swap' && \
! printf '%s' "$out" | grep -q 'Swap active'; then
ok "ensure_swap warns and continues when no swap can be brought up (full disk / btrfs)"
else
bad "ensure_swap rc=$rc or claimed swap it never activated (I19 regression): [$out]"
fi
PROCS="$t/proc_swaps2"; : > "$PROCS"
cat > "$t/bin/fallocate" <<'FALLOC_EOF'
#!/usr/bin/env bash
touch "${@: -1}"
FALLOC_EOF
cat > "$t/bin/swapon" <<EOF
#!/usr/bin/env bash
if [ "\${1:-}" = "--show" ]; then cat "$PROCS" 2>/dev/null; exit 0; fi
printf '%s\n' "\$1" >> "$PROCS"
EOF
printf '#!/usr/bin/env bash\nexit 0\n' > "$t/bin/mkswap"
chmod +x "$t/bin/"*
if ! out="$( set -Eeuo pipefail; PATH="$t/bin:$PATH"
SERVERKIT_SWAPFILE="$t/swapfile2" SERVERKIT_PROC_SWAPS="$PROCS" ensure_swap 2>&1 )"; then
bad "ensure_swap returned non-zero on the success path"
elif printf '%s' "$out" | grep -q 'Swap active' && grep -q "$t/swapfile2" "$PROCS"; then
ok "ensure_swap claims success only after swapon verifiably activated the file"
else
bad "ensure_swap did not verify/activate swap on the success path (I19): [$out]"
fi
# --------------------------------------------------------------------------
# T30 — I13: the get.docker.com convenience script (the DEFAULT Debian/Ubuntu
# path) must be staged to a file with curl --retry and run from disk — never
# piped straight into sh — and a failed download must warn and fall back to
# the distro package instead of aborting. Curated PATH: exec-wrappers for the
# real tools plus curl/apt-get stubs, and crucially NO docker (the global
# stub would short-circuit provision_docker).
# --------------------------------------------------------------------------
t="$WORK/t30"; mkdir -p "$t/bin"
# Absolute-path shebangs: this test runs with PATH="$t/bin" ONLY (so that
# `command -v docker` genuinely fails), which means `/usr/bin/env bash` could
# not resolve bash for the stubs themselves.
REAL_BASH="${BASH:-$(command -v bash)}"
for tool in sh mktemp rm tail; do
printf '#!%s\nexec %s "$@"\n' "$REAL_BASH" "$(command -v "$tool")" > "$t/bin/$tool"
chmod +x "$t/bin/$tool"
done
CURL_LOG="$t/curl.log"; : > "$CURL_LOG"
cat > "$t/bin/curl" <<EOF
#!$REAL_BASH
printf '%s\n' "\$*" >> "$CURL_LOG"
out=""
while [ \$# -gt 0 ]; do
if [ "\$1" = "-o" ]; then out="\$2"; shift; fi
shift
done
if [ -n "\$out" ]; then printf 'echo ran > "%s"\n' "$t/get-docker-ran" > "\$out"; fi
exit 0
EOF
chmod +x "$t/bin/curl"
if ( set -Eeuo pipefail; PATH="$t/bin"
OS_FAMILY=debian PKG_MGR=apt provision_docker ) >/dev/null 2>&1 \
&& [ -f "$t/get-docker-ran" ] && grep -q -- '--retry 3' "$CURL_LOG"; then
ok "provision_docker stages get.docker.com to a file with retries and executes it (no curl|sh)"
else
bad "provision_docker did not stage/run the docker script (I13); curl saw: $(tr '\n' ';' < "$CURL_LOG")"
fi
APT_LOG="$t/apt.log"; : > "$APT_LOG"
printf '#!%s\nexit 22\n' "$REAL_BASH" > "$t/bin/curl"; chmod +x "$t/bin/curl"
cat > "$t/bin/apt-get" <<EOF
#!$REAL_BASH
printf '%s\n' "\$*" >> "$APT_LOG"
exit 100
EOF
chmod +x "$t/bin/apt-get"
out="$( set -Eeuo pipefail; PATH="$t/bin"
OS_FAMILY=debian PKG_MGR=apt provision_docker 2>&1 )"
rc=$?
if [ "$rc" -eq 0 ] && printf '%s' "$out" | grep -q 'get.docker.com' && \
grep -qE 'install .*docker\.io' "$APT_LOG"; then
ok "provision_docker warns and falls back to the distro package when the download fails"
else
bad "provision_docker rc=$rc on a failed download (I13); apt saw: $(tr '\n' ';' < "$APT_LOG"): [$out]"
fi
# --------------------------------------------------------------------------
# T31 — I15: the release tag must come from the releases/latest redirect
# (no API quota) with api.github.com only as a fallback — the 60-req/hr
# unauthenticated API limit is routinely exhausted behind shared cloud NAT —
# and an unresolvable tag must produce a LOUD source-build warning, never the
# old silent downgrade.
# --------------------------------------------------------------------------
t="$WORK/t31"; mkdir -p "$t/bin"
CURL_LOG="$t/curl.log"; : > "$CURL_LOG"
cat > "$t/bin/curl" <<EOF
#!/usr/bin/env bash
printf '%s\n' "\$*" >> "$CURL_LOG"
case "\$*" in
*github.com/*/releases/latest*) printf 'https://github.com/x/y/releases/tag/v9.9.9'; exit 0 ;;
esac
exit 22
EOF
chmod +x "$t/bin/curl"
if ! res="$( set -Eeuo pipefail; PATH="$t/bin:$PATH"; SERVERKIT_VERSION="" resolve_release_tag )"; then
bad "resolve_release_tag (redirect path) returned non-zero"
elif [ "$res" = "v9.9.9" ] && ! grep -q 'api.github.com' "$CURL_LOG"; then
ok "resolve_release_tag reads the tag from the releases/latest redirect (no API quota burned)"
else
bad "resolve_release_tag primary path returned [$res]; curl saw: $(tr '\n' ';' < "$CURL_LOG")"
fi
: > "$CURL_LOG"
cat > "$t/bin/curl" <<EOF
#!/usr/bin/env bash
printf '%s\n' "\$*" >> "$CURL_LOG"
case "\$*" in
*api.github.com*) printf '{"tag_name": "v8.8.8",\n'; exit 0 ;;
esac
exit 22
EOF
chmod +x "$t/bin/curl"
if ! res="$( set -Eeuo pipefail; PATH="$t/bin:$PATH"; SERVERKIT_VERSION="" resolve_release_tag )"; then
bad "resolve_release_tag (API fallback) returned non-zero"
elif [ "$res" = "v8.8.8" ]; then
ok "resolve_release_tag falls back to the GitHub API when the redirect is unreachable"
else
bad "resolve_release_tag API fallback returned [$res] (I15)"
fi
printf '#!/usr/bin/env bash\nexit 22\n' > "$t/bin/curl"; chmod +x "$t/bin/curl"
res="$( set -Eeuo pipefail; PATH="$t/bin:$PATH"
SERVERKIT_OFFLINE_TARBALL=""; SERVERKIT_VERSION=""; INSTALL_FROM_RELEASE=1
rc=0; fetch_release >"$t/fetch.out" 2>&1 || rc=$?
printf '%s|%s' "$rc" "$INSTALL_FROM_RELEASE" )"
if [ "$res" = "1|0" ] && grep -q 'SOURCE BUILD' "$t/fetch.out"; then
ok "fetch_release warns LOUDLY and flips to source when no tag resolves (no silent downgrade)"
else
bad "fetch_release tag-fallback wrong: state [$res]: $(tr '\n' ';' < "$t/fetch.out" 2>/dev/null)"
fi
# T31b — a missing checksums.txt used to be "skipping verification" and the
# unverified tarball was installed. It must fall back to source instead,
# removing the download, unless SERVERKIT_ALLOW_UNVERIFIED=1.
cat > "$t/bin/curl" <<EOF
#!/usr/bin/env bash
out=""; url=""
while [ \$# -gt 0 ]; do
case "\$1" in -o) out="\$2"; shift 2 ;; -*) shift ;; *) url="\$1"; shift ;; esac
done
case "\$url" in
*checksums.txt) exit 22 ;;
*.tar.gz) printf 'bytes' > "\$out" ;;
*) exit 22 ;;
esac
EOF
chmod +x "$t/bin/curl"
res="$( set -Eeuo pipefail; PATH="$t/bin:$PATH"
SERVERKIT_OFFLINE_TARBALL=""; SERVERKIT_VERSION="v9.9.9"; INSTALL_FROM_RELEASE=1
SERVERKIT_MIRROR_URL="http://mirror.invalid"; DL_ARCH=amd64
resolve_release_tag() { echo v9.9.9; }
rc=0; fetch_release >"$t/fetch.out" 2>&1 || rc=$?
left=no; [ -e /tmp/serverkit-v9.9.9-linux-amd64.tar.gz ] && left=yes
printf '%s|%s|%s' "$rc" "$INSTALL_FROM_RELEASE" "$left" )"
if [ "$res" = "1|0|no" ] && grep -q 'not installing an unverified release' "$t/fetch.out"; then
ok "fetch_release falls back to source when checksums.txt is missing (no unverified install)"
else
bad "fetch_release with no checksums.txt: state [$res]: $(tr '\n' ';' < "$t/fetch.out" 2>/dev/null)"
fi
rm -f /tmp/serverkit-v9.9.9-linux-amd64.tar.gz
# --------------------------------------------------------------------------
# T32 — I16: failures inside fetch_release's unpack/copy (corrupt download,
# disk full) must take the clean fallback-to-source path — return nonzero so
# main()'s existing fallback fires — never a half-copied tree that "succeeds"
# and fails confusingly later. The cp half also proves the live .env is put
# back before handing over (symlink layout: skipped on Git Bash, runs on CI).
# --------------------------------------------------------------------------
t="$WORK/t32"; mkdir -p "$t"
printf 'this is not a tarball\n' > "$t/garbage.tar.gz"
res="$( set -Eeuo pipefail
INSTALL_DIR="$t/opt/sk"; DIR_A="$t/opt/sk-a"; DIR_B="$t/opt/sk-b"; FIRST_SLOT="$t/opt/sk-a"
SERVERKIT_OFFLINE_TARBALL="$t/garbage.tar.gz"; INSTALL_FROM_RELEASE=1
rc=0; fetch_release >"$t/fetch.out" 2>&1 || rc=$?
printf '%s|%s' "$rc" "$INSTALL_FROM_RELEASE" )"
if [ "$res" = "1|0" ] && grep -q 'falling back to source' "$t/fetch.out"; then
ok "fetch_release: a failed tar unpack takes the clean source fallback, not a fake success"
else
bad "fetch_release tar-failure path wrong (I16 regression): [$res]: $(tr '\n' ';' < "$t/fetch.out" 2>/dev/null)"
fi
t="$WORK/t32c"; slotA="$t/sk-a"; slotB="$t/sk-b"; inst="$t/sk"
mkdir -p "$slotA/backend/instance" "$t/bin"
printf 'SECRET_KEY=survive-cp\n' > "$slotA/.env"
cp "$slotA/.env" "$t/env.orig"
ln -sfn "$slotA" "$inst" 2>/dev/null || true
if [ ! -L "$inst" ]; then
skip "fetch_release cp-failure fallback — symlinks unsupported here (runs on Linux CI)"
elif ! command -v tar >/dev/null 2>&1; then
skip "fetch_release cp-failure fallback — tar unavailable here"
else
stage="$t/stage"; mkdir -p "$stage/serverkit/backend/app"
printf '#!/bin/sh\n' > "$stage/serverkit/serverkit"
tar czf "$t/rel.tar.gz" -C "$stage" serverkit
cat > "$t/bin/cp" <<EOF
#!/usr/bin/env bash
last=""
for a in "\$@"; do last="\$a"; done
if [ "\$last" = "$slotA" ]; then exit 1; fi
exec $(command -v cp) "\$@"
EOF
chmod +x "$t/bin/cp"
res="$( set -Eeuo pipefail; PATH="$t/bin:$PATH"
INSTALL_DIR="$inst"; DIR_A="$slotA"; DIR_B="$slotB"; FIRST_SLOT="$slotA"
SERVERKIT_OFFLINE_TARBALL="$t/rel.tar.gz"; INSTALL_FROM_RELEASE=1
rc=0; fetch_release >/dev/null 2>&1 || rc=$?
printf '%s|%s' "$rc" "$INSTALL_FROM_RELEASE" )"
if [ "$res" = "1|0" ] && files_identical "$slotA/.env" "$t/env.orig"; then
ok "fetch_release: a failed slot copy restores the live .env and falls back to source"
else
bad "fetch_release cp-failure lost live state or faked success (I16 regression): [$res]"
fi
fi
# --------------------------------------------------------------------------
# T33 — I17: the inline service verbs (mirroring scripts/lib/init.sh, which
# install.sh cannot source pre-clone) must drive systemctl on systemd boxes,
# fall back to OpenRC/SysV elsewhere, and warn-and-continue when nothing can
# be driven. INIT_OVERRIDE pins detection (same contract as init.sh).
# --------------------------------------------------------------------------
t="$WORK/t33"; mkdir -p "$t/sysd" "$t/rc" "$t/none"
SVC_LOG="$t/svc.log"; : > "$SVC_LOG"
cat > "$t/sysd/systemctl" <<EOF
#!/usr/bin/env bash
printf 'systemctl %s\n' "\$*" >> "$SVC_LOG"
EOF
chmod +x "$t/sysd/systemctl"
svc_rc=0
( set -Eeuo pipefail; PATH="$t/sysd:$PATH"
INIT_OVERRIDE=systemd svc_enable nginx \
&& INIT_OVERRIDE=systemd svc_start nginx ) >/dev/null 2>&1 || svc_rc=$?
if [ "$svc_rc" -eq 0 ] && grep -q 'systemctl enable nginx' "$SVC_LOG" && grep -q 'systemctl start nginx' "$SVC_LOG"; then
ok "svc_enable/svc_start drive systemctl on systemd boxes"
else
bad "svc verbs rc=$svc_rc or missed systemctl (I17): $(tr '\n' ';' < "$SVC_LOG")"
fi
: > "$SVC_LOG"
cat > "$t/rc/rc-update" <<EOF
#!/usr/bin/env bash
printf 'rc-update %s\n' "\$*" >> "$SVC_LOG"
EOF
cat > "$t/rc/rc-service" <<EOF
#!/usr/bin/env bash
printf 'rc-service %s\n' "\$*" >> "$SVC_LOG"
EOF
chmod +x "$t/rc/"*
# Full PATH kept (the stubs' env-bash shebang needs it); the override makes
# svc_has_systemd false, so the real systemctl is never consulted.
( set -Eeuo pipefail; PATH="$t/rc:$PATH"
INIT_OVERRIDE=openrc svc_enable docker; INIT_OVERRIDE=openrc svc_start docker ) >/dev/null 2>&1
rc=$?
if [ "$rc" -eq 0 ] && grep -q 'rc-update add docker default' "$SVC_LOG" && \
grep -q 'rc-service docker start' "$SVC_LOG"; then
ok "svc verbs fall back to OpenRC without systemd (no bare-systemctl abort)"
else
bad "svc OpenRC fallback rc=$rc (I17): $(tr '\n' ';' < "$SVC_LOG")"
fi
out="$( set -Eeuo pipefail; PATH="$t/none"
INIT_OVERRIDE=none svc_start nginx 2>&1; INIT_OVERRIDE=none svc_enable nginx 2>&1 )"
rc=$?
if [ "$rc" -eq 0 ] && printf '%s' "$out" | grep -q 'No init system'; then
ok "svc verbs warn-and-continue when no init system can be driven (WSL/containers)"
else
bad "svc verbs rc=$rc with no init system — must warn and return 0 (I17): [$out]"
fi
# --------------------------------------------------------------------------
# T34 — I18: migrating a legacy real-directory install while a stale slot
# from an aborted earlier run exists must not `mv` the live tree INSIDE it
# (/opt/serverkit-a/serverkit); the stale slot is cleared first.
# --------------------------------------------------------------------------
t="$WORK/t34"; mkdir -p "$t/opt/serverkit" "$t/opt/serverkit-a/stale"
printf 'live\n' > "$t/opt/serverkit/marker"
# Deliberately status-unchecked: ensure_install_layout's trailing `ln -s`
# legitimately fails on Git Bash (MSYS "symlinks" are copies, so the -L probe
# is false and the re-link hits an existing dir); the file assertions below
# fully constrain the behaviour under test. On Linux CI the subshell exits 0.
( set -Eeuo pipefail
INSTALL_DIR="$t/opt/serverkit"; DIR_A="$t/opt/serverkit-a"; DIR_B="$t/opt/serverkit-b"
FIRST_SLOT="$t/opt/serverkit-a"
ensure_install_layout ) >/dev/null 2>&1
if [ -f "$t/opt/serverkit-a/marker" ] && [ ! -e "$t/opt/serverkit-a/serverkit" ]; then
ok "ensure_install_layout clears a stale slot instead of nesting the live tree inside it"
else
bad "ensure_install_layout nested/lost the live tree on a stale-slot collision (I18 regression)"
fi
# --------------------------------------------------------------------------
# T35 — I22: /etc/os-release defines its own VERSION ("24.04.1 LTS ..."),
# which used to clobber the installer's version string when sourced.
# --------------------------------------------------------------------------
t="$WORK/t35"; mkdir -p "$t"
cat > "$t/os-release" <<'OSR_EOF'
ID=ubuntu
ID_LIKE=debian
PRETTY_NAME="Ubuntu 24.04 LTS"
VERSION="24.04.1 LTS (Noble Numbat)"
OSR_EOF
if ! res="$( set -Eeuo pipefail
VERSION="sk-sentinel"
SERVERKIT_OS_RELEASE="$t/os-release" identify_system >/dev/null 2>&1 \
&& printf '%s|%s' "$VERSION" "$OS_FAMILY" )"; then
bad "identify_system aborted under set -Eeuo pipefail"
elif [ "$res" = "sk-sentinel|debian" ]; then
ok "identify_system keeps the installer's \$VERSION across the os-release source"
else
bad "identify_system clobbered VERSION/OS_FAMILY (I22 regression): [$res]"
fi
# --------------------------------------------------------------------------
# T36 — I23: ping_telemetry runs AFTER a successful install; a missing
# VERSION file (pipefail on cat|tr) must never abort it. Empty PATH also
# proves the curl is best-effort.
# --------------------------------------------------------------------------
t="$WORK/t36"; mkdir -p "$t/empty" "$t/noinstall"
if ( set -Eeuo pipefail; PATH="$t/empty"; INSTALL_DIR="$t/noinstall" ping_telemetry ) >/dev/null 2>&1; then
ok "ping_telemetry survives a missing VERSION file after a successful install"
else
bad "ping_telemetry DIED on a missing VERSION under pipefail (I23 regression)"
fi
# --------------------------------------------------------------------------
# T37 — I24: write_config's early return must still refresh the SSL-mode key
# in a pre-existing .env — a re-run that gained/lost HTTPS otherwise leaves
# the panel's HSTS gate reading a stale value — without touching the
# irreplaceable generated secrets.
# --------------------------------------------------------------------------
t="$WORK/t37"; mkdir -p "$t/inst"
printf 'SECRET_KEY=sentinel-keep\nSERVERKIT_SSL_MODE=insecure\nPORT=80\n' > "$t/inst/.env"
wc_rc=0
( set -Eeuo pipefail; INSTALL_DIR="$t/inst"; SSL_MODE=secure; write_config ) >/dev/null 2>&1 || wc_rc=$?
if [ "$wc_rc" -eq 0 ] && grep -q '^SERVERKIT_SSL_MODE=secure$' "$t/inst/.env" && \
grep -q '^SECRET_KEY=sentinel-keep$' "$t/inst/.env" && \
[ "$(grep -c '^SERVERKIT_SSL_MODE=' "$t/inst/.env")" = "1" ]; then
ok "write_config early-return refreshes SERVERKIT_SSL_MODE in an existing .env (secrets intact)"
else
bad "write_config rc=$wc_rc or left a stale/duplicated SSL mode in the existing .env (I24 regression)"
fi
printf 'SECRET_KEY=nokey-line\n' > "$t/inst/.env"
wc_rc=0
( set -Eeuo pipefail; INSTALL_DIR="$t/inst"; SSL_MODE=insecure; write_config ) >/dev/null 2>&1 || wc_rc=$?
if [ "$wc_rc" -eq 0 ] && grep -q '^SERVERKIT_SSL_MODE=insecure$' "$t/inst/.env" && grep -q '^SECRET_KEY=nokey-line$' "$t/inst/.env"; then
ok "write_config appends SERVERKIT_SSL_MODE when a pre-existing .env lacks it"
else
bad "write_config rc=$wc_rc or did not add the missing SSL-mode key to a pre-existing .env (I24)"
fi
# --------------------------------------------------------------------------
# T38 — apply_frontend_root: same end-of-function `[ -f ] && ...` species as
# I1/I2, reachable with a custom SERVERKIT_DIR when the last conf is missing.
# --------------------------------------------------------------------------
t="$WORK/t38"; mkdir -p "$t"
printf 'root /opt/serverkit/frontend/dist;\n' > "$t/site.conf"
if ( set -Eeuo pipefail; INSTALL_DIR="$t/custom"
apply_frontend_root "$t/site.conf" "$t/does-not-exist.conf" ) >/dev/null 2>&1 \
&& grep -q "root $t/custom/frontend/dist;" "$t/site.conf"; then
ok "apply_frontend_root rewrites the root and survives a missing conf as the last arg"
else
bad "apply_frontend_root aborted on a missing conf under set -e (the I1/I2 species)"
fi
# --------------------------------------------------------------------------
# T39 — static guards for this slice's one-liner shapes (same spirit as T26).
# --------------------------------------------------------------------------
guards2_ok=1
if grep -qE '^[^#]*get\.docker\.com[^|#]*\|[[:space:]]*sh' "$INSTALL_SH"; then
bad "install.sh pipes get.docker.com straight into sh again (I13 regression)"
guards2_ok=0
fi
if grep -qE '^[^#]*df /opt' "$INSTALL_SH"; then
bad "preflight hardcodes 'df /opt' again — must probe \$INSTALL_DIR with df -Pk (I14 regression)"
guards2_ok=0
fi
if ! grep -q 'svc_enable nginx' "$INSTALL_SH" || ! grep -q 'svc_start serverkit' "$INSTALL_SH"; then
bad "nginx/serverkit are enabled/started with bare systemctl again (I17 regression)"
guards2_ok=0
fi
[ "$guards2_ok" = "1" ] && ok "source guards: no curl|sh docker pipe, no 'df /opt', svc verbs at the call sites"
# --------------------------------------------------------------------------
# T40 — the fresh-minimal-box loop (twin of test_update.sh's; see the
# 2026-07-02 outage note there). EVERY observation/discovery/snapshot/report/
# probe function in install.sh must survive the emptiest valid world a fresh
# box presents — nothing installed yet, zero apps, zero containers, no
# optional confs, dead network, empty state — under set -Eeuo pipefail.
#
# POLICY: every new observation/discovery/snapshot/report function added to
# install.sh MUST be appended here (add args/disposition to the case table
# when needed). Dispositions:
# must0 — output/result is captured by assignment somewhere (X="$(fn)"), so
# a non-zero exit IS the outage: the function must exit 0.
# pred — used only in conditional position (if fn; ...) or legitimately
# environment-dependent (root checks, host tool probes): it must
# merely return normally (no set -u crash, no signal death).
# --------------------------------------------------------------------------
FRESH="$WORK/freshbox"
make_fresh_box_fixture "$FRESH"
# --------------------------------------------------------------------------
# T31 — install profiles: recommend_profile must read the hardware, and the
# minimal profile must genuinely skip Docker. A profile is what we install,
# not a licence tier, so the only thing under test here is "did the right
# provisioning get skipped".
# --------------------------------------------------------------------------
t="$WORK/t31"; mkdir -p "$t/bin"
mkfree() { # mkfree <total_mb>
printf '#!/usr/bin/env bash\nprintf " total\\nMem: %s 0 0\\nSwap: 0 0 0\\n"\n' "$1" \
> "$t/bin/free"
chmod +x "$t/bin/free"
}
mknproc() { printf '#!/usr/bin/env bash\nprintf "%s"\n' "$1" > "$t/bin/nproc"; chmod +x "$t/bin/nproc"; }
mkdf() { # mkdf <avail_gb>
printf '#!/usr/bin/env bash\nprintf "FS 1G 1G %sG 1%%%% /\\nFS 1G 1G %sG 1%%%% /\\n"\n' "$1" "$1" \
> "$t/bin/df"
chmod +x "$t/bin/df"
}
# 700MB box → minimal, no matter how many cores it claims.
mkfree 700; mknproc 8; mkdf 100
res="$( set -Eeuo pipefail; PATH="$t/bin:$PATH"; BASE_DIR="$t"
RECOMMENDED_PROFILE=""; recommend_profile >/dev/null 2>&1
printf '%s' "$RECOMMENDED_PROFILE" )"
if [ "$res" = "minimal" ]; then
ok "recommend_profile: 700MB box → minimal"
else
bad "recommend_profile: 700MB box gave [$res], expected minimal"
fi
# 2GB / 2 cores → standard.
mkfree 2048; mknproc 2; mkdf 50
res="$( set -Eeuo pipefail; PATH="$t/bin:$PATH"; BASE_DIR="$t"
RECOMMENDED_PROFILE=""; recommend_profile >/dev/null 2>&1
printf '%s' "$RECOMMENDED_PROFILE" )"
if [ "$res" = "standard" ]; then
ok "recommend_profile: 2GB/2-core box → standard"
else
bad "recommend_profile: 2GB/2-core box gave [$res], expected standard"
fi
# 8GB / 4 cores / 50GB free → full.
mkfree 8192; mknproc 4; mkdf 50
res="$( set -Eeuo pipefail; PATH="$t/bin:$PATH"; BASE_DIR="$t"
RECOMMENDED_PROFILE=""; recommend_profile >/dev/null 2>&1
printf '%s' "$RECOMMENDED_PROFILE" )"
if [ "$res" = "full" ]; then
ok "recommend_profile: 8GB/4-core box → full"
else
bad "recommend_profile: 8GB/4-core box gave [$res], expected full"
fi
# A roomy box with a nearly-full disk still drops to minimal — images and
# backups need somewhere to land before RAM is the binding constraint.
mkfree 8192; mknproc 8; mkdf 2
res="$( set -Eeuo pipefail; PATH="$t/bin:$PATH"; BASE_DIR="$t"
RECOMMENDED_PROFILE=""; recommend_profile >/dev/null 2>&1
printf '%s' "$RECOMMENDED_PROFILE" )"
if [ "$res" = "minimal" ]; then
ok "recommend_profile: 8GB box with 2GB disk free → minimal"
else
bad "recommend_profile: low-disk box gave [$res], expected minimal"
fi
# No `free` at all (LXC templates again) must not abort under pipefail.
mkdir -p "$t/empty"
if res="$( set -Eeuo pipefail; PATH="$t/empty"; BASE_DIR="$t"
RECOMMENDED_PROFILE=""; recommend_profile >/dev/null 2>&1
printf '%s' "$RECOMMENDED_PROFILE" )"; then
ok "recommend_profile survives a box with no free/nproc/df (got [$res])"
else
bad "recommend_profile aborted with no free/nproc/df under set -Eeuo pipefail"
fi
# The whole point of minimal: provision_docker must not touch the box.
out="$( set -Eeuo pipefail; PROFILE=minimal
provision_docker 2>&1 )"
if printf '%s' "$out" | grep -q 'Skipping Docker'; then
ok "provision_docker is a no-op on the minimal profile"
else
bad "provision_docker ran on the minimal profile: [$(printf '%s' "$out" | tail -c 120)]"
fi
out="$( set -Eeuo pipefail; PROFILE=minimal
ensure_compose_plugin 2>&1 )"
if [ -z "$out" ]; then
ok "ensure_compose_plugin is a no-op on the minimal profile"
else
bad "ensure_compose_plugin ran on the minimal profile: [$out]"
fi
# "full" has to earn its name — it was byte-identical to "standard" until
# provision_hardening existed, so the card promised things nothing installed.
pkgt="$WORK/t31pkg"; mkdir -p "$pkgt"
out="$( set -Eeuo pipefail; PROFILE=minimal; OS_FAMILY=debian; PKG_MGR=apt
SERVERKIT_SKIP_SSL=0
pkg_add() { printf 'PKGADD:%s\n' "$*"; }
provision_hardening 2>&1 )"
if [ -z "$out" ]; then
ok "provision_hardening is a no-op on the minimal profile"
else
bad "provision_hardening ran on minimal: [$out]"
fi
# fail2ban is standard-and-up (plan 73 item 8): the small VPS is exactly the box
# that gets SSH-brute-forced, so leaving it to "full" meant the typical install
# got no jail at all. certbot stays a "full" extra — HTTPS is optional.
out="$( set -Eeuo pipefail; PROFILE=standard; OS_FAMILY=debian; PKG_MGR=apt
SERVERKIT_SKIP_SSL=0
pkg_add() { printf 'PKGADD:%s\n' "$*"; }
svc_enable() { :; }; svc_start() { :; }
provision_hardening 2>&1 )"
if printf '%s' "$out" | grep -q 'PKGADD:fail2ban' && \
! printf '%s' "$out" | grep -q 'PKGADD:certbot'; then
ok "provision_hardening installs fail2ban (not certbot) on the standard profile"
else
bad "standard profile hardening is wrong: [$(printf '%s' "$out" | tr '\n' ' ')]"
fi
out="$( set -Eeuo pipefail; PROFILE=full; OS_FAMILY=debian; PKG_MGR=apt
SERVERKIT_SKIP_SSL=0
pkg_add() { printf 'PKGADD:%s\n' "$*"; }
svc_enable() { :; }; svc_start() { :; }
provision_hardening 2>&1 )"
if printf '%s' "$out" | grep -q 'PKGADD:fail2ban' && \
printf '%s' "$out" | grep -q 'PKGADD:certbot'; then
ok "provision_hardening installs fail2ban + certbot on the full profile"
else
bad "full profile did not provision the hardening stack: [$(printf '%s' "$out" | tr '\n' ' ')]"
fi
# HTTPS off means certbot is pointless; fail2ban still applies.
out="$( set -Eeuo pipefail; PROFILE=full; OS_FAMILY=debian; PKG_MGR=apt
SERVERKIT_SKIP_SSL=1
pkg_add() { printf 'PKGADD:%s\n' "$*"; }
svc_enable() { :; }; svc_start() { :; }
provision_hardening 2>&1 )"
if printf '%s' "$out" | grep -q 'PKGADD:fail2ban' && \
! printf '%s' "$out" | grep -q 'PKGADD:certbot'; then
ok "provision_hardening skips certbot when SERVERKIT_SKIP_SSL=1"
else
bad "SERVERKIT_SKIP_SSL=1 did not suppress certbot: [$(printf '%s' "$out" | tr '\n' ' ')]"
fi
# A failing package must warn, never abort the install (pkg_add's contract).
if out="$( set -Eeuo pipefail; PROFILE=full; OS_FAMILY=debian; PKG_MGR=apt
SERVERKIT_SKIP_SSL=0
pkg_add() { return 0; }
svc_enable() { :; }; svc_start() { :; }
provision_hardening 2>&1 )"; then
ok "provision_hardening survives packages that never appear"
else
bad "provision_hardening aborted when a hardening package failed to install"
fi
# provision_hardening must run before configure_nginx — certbot has to be on
# the box before the HTTPS attempt, or full silently degrades to plain HTTP.
# Match call lines only — a nearby comment mentioning configure_nginx would
# otherwise satisfy the ordering grep.
if awk '/^main\(\)/,/^}/' "$INSTALL_SH" \
| grep -E '^[[:space:]]*(provision_hardening|configure_nginx)[[:space:]]*$' \
| head -2 | tr '\n' ' ' | grep -q 'provision_hardening.*configure_nginx'; then
ok "main() runs provision_hardening before configure_nginx"
else
bad "provision_hardening runs after configure_nginx — certbot would arrive too late"
fi
# An explicit SERVERKIT_PROFILE must be honoured verbatim and never prompt.
res="$( set -Eeuo pipefail; PROFILE="full"
prompt_for_profile >/dev/null 2>&1; printf '%s' "$PROFILE" )"
if [ "$res" = "full" ]; then
ok "prompt_for_profile honours an explicit SERVERKIT_PROFILE"
else
bad "prompt_for_profile clobbered an explicit profile: got [$res]"
fi
# A garbage value falls back to detection rather than installing "banana".
mkfree 2048; mknproc 2; mkdf 50
res="$( set -Eeuo pipefail; PATH="$t/bin:$PATH"; BASE_DIR="$t"; PROFILE="banana"
prompt_for_profile >/dev/null 2>&1 </dev/null; printf '%s' "$PROFILE" )"
if [ "$res" = "standard" ]; then
ok "prompt_for_profile rejects an unknown SERVERKIT_PROFILE and re-detects"
else
bad "prompt_for_profile accepted a bogus profile: got [$res]"
fi
# Non-interactive (curl | bash) must take the recommendation, not block.
mkfree 700; mknproc 1; mkdf 20
res="$( set -Eeuo pipefail; PATH="$t/bin:$PATH"; BASE_DIR="$t"; PROFILE=""
prompt_for_profile >/dev/null 2>&1 </dev/null; printf '%s' "$PROFILE" )"
if [ "$res" = "minimal" ]; then
ok "prompt_for_profile auto-selects the recommendation with no tty"
else
bad "prompt_for_profile gave [$res] non-interactively, expected minimal"
fi
INSTALL_OBSERVERS=(
os_family_from ver_in_range py_venv_ok node_major node_ready
locate_python gauge_memory identify_system preflight resolve_release_tag
should_default_to_release svc_has_systemd snapshot_existing
choose_ssl_mode await_health ping_telemetry
detect_container recommend_profile
)
loop_fail=0
for fn in "${INSTALL_OBSERVERS[@]}"; do
args=(); mode=must0
case "$fn" in
os_family_from) args=(ubuntu "") ;;
ver_in_range) args=(3.11) ;;
py_venv_ok) args=(python3); mode=pred ;;
node_major|node_ready|locate_python|preflight|svc_has_systemd|await_health) mode=pred ;;
esac
out="$(
{
set -Eeuo pipefail
export PATH="$FRESH/bin:$PATH"
INSTALL_DIR="$FRESH/opt/serverkit"; SERVERKIT_DIR="$INSTALL_DIR"
DIR_A="$FRESH/opt/serverkit-a"; DIR_B="$FRESH/opt/serverkit-b"
FIRST_SLOT="$DIR_A"
OS_FAMILY=unknown; PKG_MGR=""; PANEL_DOMAIN=""; SSL_MODE=insecure
SERVERKIT_SKIP_SSL=0; BUILD_FROM_SOURCE=0; SERVERKIT_VERSION=""
INSTALL_FROM_RELEASE=0; SERVERKIT_OFFLINE_TARBALL=""; SERVERKIT_MIRROR_URL=""
SERVERKIT_OS_RELEASE="$FRESH/etc/os-release"
SERVERKIT_NGINX_DIR="$FRESH/etc/nginx"
SERVERKIT_CONFIG_DIR="$FRESH/etc/serverkit"
"$fn" ${args[@]+"${args[@]}"}
} </dev/null 2>&1
)"
rc=$?
if [ "$mode" = "must0" ] && [ "$rc" -ne 0 ]; then
bad "fresh-box loop: $fn exited $rc on a fresh box: [$(printf '%s' "$out" | tail -c 160)]"
loop_fail=1
elif [ "$mode" = "pred" ] && { [ "$rc" -gt 128 ] || printf '%s' "$out" | grep -q 'unbound variable'; }; then
bad "fresh-box loop: predicate $fn crashed (rc=$rc) on a fresh box: [$(printf '%s' "$out" | tail -c 160)]"
loop_fail=1
fi
done
if [ "$loop_fail" = "0" ]; then
ok "fresh-box loop: all ${#INSTALL_OBSERVERS[@]} observation/discovery functions survive a fresh box"
fi
# --------------------------------------------------------------------------
# T41 — the source guard must not break piped execution (`curl … | bash`, the
# one-liner README/DEPLOYMENT document). Piped, bash leaves BASH_SOURCE unset,
# so a bare ${BASH_SOURCE[0]} is an unbound variable under `set -u` and aborts
# before main() ever runs — which is exactly what shipped and broke every
# curl-piped install ("line NNNN: BASH_SOURCE[0]: unbound variable").
#
# CI only ever runs `bash install.sh` from a checkout, where BASH_SOURCE IS
# set, so the matrix cannot see this. Running the real installer piped would
# install a server, so exercise the shipped tail — the guard plus the `main
# "$@"` dispatch, extracted verbatim — against a harmless main() stub.
# --------------------------------------------------------------------------
guard_ln="$(grep -n 'BASH_SOURCE' "$INSTALL_SH" | grep -vE ':[[:space:]]*#' | tail -1 | cut -d: -f1)"
guard_tail="$WORK/t41_tail.sh"
{
printf 'set -euo pipefail\n'
printf 'main() { printf "REACHED_MAIN"; }\n'
sed -n "${guard_ln},\$p" "$INSTALL_SH"
} > "$guard_tail"
out="$(bash < "$guard_tail" 2>&1)"; rc=$?
if [ "$rc" -eq 0 ] && [ "$out" = "REACHED_MAIN" ]; then
ok "piped execution (stdin, as curl|bash) still reaches main()"
else
bad "piped execution broken by the source guard: rc=$rc out=[$out]"
fi
# The other half of the contract: a real `source` must still return early, or
# this whole suite would run an install instead of unit-testing one.
out="$( set -euo pipefail; source "$guard_tail"; printf 'EARLY_RETURN' )"
if [ "$out" = "EARLY_RETURN" ]; then
ok "sourcing still returns before main()"
else
bad "the source guard no longer short-circuits a source: out=[$out]"
fi
# --------------------------------------------------------------------------
# T42 — plan 73 item 8: a stock Debian/Ubuntu box gets a real firewall, and the
# SSH port is discovered BEFORE default-deny is switched on.
#
# This is the single most dangerous thing the installer does: enabling
# default-deny without allowing the port sshd actually answers on locks the
# operator out over the very session running the install. Every case below is a
# lockout-safety assertion.
#
# `sshd` and `ss` are stubbed to fail throughout so detection is forced down to
# the config-file parser and the fixtures decide the answer — otherwise the
# result would depend on whether the CI runner happens to run sshd.
# --------------------------------------------------------------------------
t42="$WORK/t42"; mkdir -p "$t42/bin" "$t42/ssh/sshd_config.d" "$t42/state"
make_stub_exit "$t42/bin" 1 sshd ss
# -- detection: a drop-in Port wins over a commented-out one in the main file.
printf '#Port 22\nPermitRootLogin no\n' > "$t42/ssh/sshd_config"
printf 'Port 2222\n' > "$t42/ssh/sshd_config.d/99-port.conf"
res="$( set -Eeuo pipefail; PATH="$t42/bin:$PATH"
SSHD_CONFIG_ROOT="$t42/ssh" detect_ssh_ports | tr '\n' ' ' )"
if [ "$res" = "2222 " ]; then
ok "detect_ssh_ports reads sshd_config.d drop-ins and ignores commented Ports"
else
bad "detect_ssh_ports got [$res], expected the drop-in's 2222"
fi
# -- detection: several Port lines all come back, de-duplicated.
printf 'Port 22\nPort 2222\nPort 22\n' > "$t42/ssh/sshd_config"
: > "$t42/ssh/sshd_config.d/99-port.conf"
res="$( set -Eeuo pipefail; PATH="$t42/bin:$PATH"
SSHD_CONFIG_ROOT="$t42/ssh" detect_ssh_ports | tr '\n' ' ' )"
if [ "$res" = "22 2222 " ]; then
ok "detect_ssh_ports returns every declared port, de-duplicated"
else
bad "detect_ssh_ports got [$res], expected [22 2222 ]"
fi
# -- detection: NO sshd_config.d directory at all — the common shape, and the
# one that bit hardest. The unmatched glob makes `cat` exit non-zero; under
# the installer's `set -o pipefail` that wiped the parsed answer and the
# function fell through to its "22" default. On a box whose sshd is on 2222
# that means UFW allows the wrong port and locks the operator out. Pinning
# it here because the failure only ever shows up on a real server.
t42b="$WORK/t42b"; mkdir -p "$t42b/ssh"
printf 'Port 2222\n' > "$t42b/ssh/sshd_config"
res="$( set -Eeuo pipefail; PATH="$t42/bin:$PATH"
SSHD_CONFIG_ROOT="$t42b/ssh" detect_ssh_ports | tr '\n' ' ' )"
if [ "$res" = "2222 " ]; then
ok "detect_ssh_ports reads sshd_config with no drop-in dir (pipefail regression)"
else
bad "detect_ssh_ports got [$res] with no sshd_config.d — expected 2222, LOCKOUT RISK"
fi
# -- detection: a config that names no Port means sshd's built-in default.
printf 'PermitRootLogin no\n' > "$t42/ssh/sshd_config"
res="$( set -Eeuo pipefail; PATH="$t42/bin:$PATH"
SSHD_CONFIG_ROOT="$t42/ssh" detect_ssh_ports | tr '\n' ' ' )"
if [ "$res" = "22 " ]; then
ok "detect_ssh_ports falls back to 22 when the config names no Port"
else
bad "detect_ssh_ports got [$res] for a Port-less config, expected 22"
fi
# -- detection: nothing to go on at all → NON-ZERO. This is the fail-safe the
# bootstrap keys off; if it ever starts guessing, the guard is gone.
if ( set -Eeuo pipefail; PATH="$t42/bin:$PATH"
SSHD_CONFIG_ROOT="$t42/nonexistent" detect_ssh_ports >/dev/null 2>&1 ); then
bad "detect_ssh_ports invented an SSH port with no config, no sshd and no ss"
else
ok "detect_ssh_ports fails loudly when no source can name a port"
fi
# -- bootstrap: the happy path. Allow rules for SSH/80/443 must all be issued
# BEFORE `ufw default deny incoming`, and default-deny before `ufw enable`.
printf 'Port 2222\n' > "$t42/ssh/sshd_config"
out="$( set -Eeuo pipefail; PATH="$t42/bin:$PATH"
OS_FAMILY=debian; PANEL_PORT=80; FW_DRY_RUN=1; FIREWALL_BACKEND=none
SSHD_CONFIG_ROOT="$t42/ssh"
detect_container() { printf ''; }
pkg_add() { :; }
source "$LIB_DIR/firewall.sh"
command -v ufw >/dev/null 2>&1 || ufw() { :; }
bootstrap_firewall 2>&1 )"
allow_ssh_ln="$(printf '%s\n' "$out" | grep -n 'ufw allow 2222/tcp' | head -1 | cut -d: -f1)"
allow_80_ln="$( printf '%s\n' "$out" | grep -n 'ufw allow 80/tcp' | head -1 | cut -d: -f1)"
allow_443_ln="$(printf '%s\n' "$out" | grep -n 'ufw allow 443/tcp' | head -1 | cut -d: -f1)"
deny_ln="$( printf '%s\n' "$out" | grep -n 'ufw default deny incoming' | head -1 | cut -d: -f1)"
enable_ln="$( printf '%s\n' "$out" | grep -n 'ufw --force enable' | head -1 | cut -d: -f1)"
if [ -n "$allow_ssh_ln" ] && [ -n "$allow_80_ln" ] && [ -n "$allow_443_ln" ] \
&& [ -n "$deny_ln" ] && [ -n "$enable_ln" ] \
&& [ "$allow_ssh_ln" -lt "$deny_ln" ] && [ "$allow_80_ln" -lt "$deny_ln" ] \
&& [ "$allow_443_ln" -lt "$deny_ln" ] && [ "$deny_ln" -lt "$enable_ln" ]; then
ok "bootstrap_firewall allows SSH/80/443 before default-deny, and denies before enabling"
else
bad "bootstrap_firewall rule ordering is unsafe: [$(printf '%s' "$out" | tr '\n' ' ')]"
fi
# -- bootstrap: the interlock. No discoverable SSH port ⇒ NOTHING is enabled.
out="$( set -Eeuo pipefail; PATH="$t42/bin:$PATH"
OS_FAMILY=debian; FW_DRY_RUN=1; FIREWALL_BACKEND=none
SSHD_CONFIG_ROOT="$t42/nonexistent"
detect_container() { printf ''; }
pkg_add() { :; }
source "$LIB_DIR/firewall.sh"
command -v ufw >/dev/null 2>&1 || ufw() { :; }
bootstrap_firewall 2>&1 )"
if ! printf '%s' "$out" | grep -q 'ufw --force enable' \
&& ! printf '%s' "$out" | grep -q 'default deny' \
&& printf '%s' "$out" | grep -qi 'NOT enabling'; then
ok "bootstrap_firewall refuses to enable a firewall when the SSH port is unknown"
else
bad "bootstrap_firewall enabled default-deny without knowing the SSH port: [$(printf '%s' "$out" | tr '\n' ' ')]"
fi
# -- bootstrap: a non-standard PANEL_PORT is allowed too, or default-deny takes
# the panel UI down with it.
printf 'Port 22\n' > "$t42/ssh/sshd_config"
out="$( set -Eeuo pipefail; PATH="$t42/bin:$PATH"
OS_FAMILY=debian; PANEL_PORT=8443; FW_DRY_RUN=1; FIREWALL_BACKEND=none
SSHD_CONFIG_ROOT="$t42/ssh"
detect_container() { printf ''; }
pkg_add() { :; }
source "$LIB_DIR/firewall.sh"
command -v ufw >/dev/null 2>&1 || ufw() { :; }
bootstrap_firewall 2>&1 )"
if printf '%s' "$out" | grep -q 'ufw allow 8443/tcp'; then
ok "bootstrap_firewall opens a non-standard PANEL_PORT before default-deny"
else
bad "bootstrap_firewall would lock the panel out on PANEL_PORT=8443: [$(printf '%s' "$out" | tr '\n' ' ')]"
fi
# --------------------------------------------------------------------------
# "Is this box already protected?" — the question the bootstrap's skip-guard
# has to ask. It originally asked firewall_detect() != none, which answers a
# DIFFERENT question ("which backend do I open a port through") and returns
# `nftables` for a bare nft binary with an empty ruleset. Every case below was
# written after a real Ubuntu 24.04 VM install logged "via nftables" while the
# bootstrap printed nothing at all.
# --------------------------------------------------------------------------
fw_probe_dir() { # fw_probe_dir <name> -> fresh bin dir on stdout
local d="$t42/fw-$1"; rm -rf "$d"; mkdir -p "$d"; printf '%s' "$d"
}
run_bootstrap() { # run_bootstrap <bindir>
( set -Eeuo pipefail; PATH="$1:$PATH"
OS_FAMILY=debian; FW_DRY_RUN=1
SSHD_CONFIG_ROOT="$t42/ssh"
detect_container() { printf ''; }
pkg_add() { :; }
source "$LIB_DIR/firewall.sh"
bootstrap_firewall 2>&1 )
}
# -- The two predicates answer DIFFERENT questions, and conflating them is what
# broke this. Pinned at the predicate level so the distinction survives any
# future refactor of either one.
d="$(fw_probe_dir predicates)"
make_stub_stdout "$d" nft 0 </dev/null
make_stub_stdout "$d" ufw 0 <<'EOF'
Status: inactive
EOF
res="$( set -Eeuo pipefail; PATH="$d:$PATH"
source "$LIB_DIR/firewall.sh"
printf '%s/' "$(firewall_detect)"
firewall_inbound_default_deny && printf 'protected' || printf 'unprotected' )"
if [ "$res" = "nftables/unprotected" ]; then
ok "firewall_detect names a backend where inbound_default_deny sees no protection"
else
bad "expected [nftables/unprotected], got [$res]"
fi
# -- THE REGRESSION: stock cloud image — nft binary present, ruleset EMPTY, ufw
# installed but inactive. Nothing is filtering, so the bootstrap MUST run.
d="$(fw_probe_dir empty)"
make_stub_stdout "$d" nft 0 </dev/null
make_stub_stdout "$d" ufw 0 <<'EOF'
Status: inactive
EOF
out="$(run_bootstrap "$d")"
if printf '%s' "$out" | grep -q 'ufw allow'; then
ok "bootstrap_firewall runs on a box whose nft ruleset is empty (stock-image regression)"
else
bad "bootstrap_firewall skipped an UNPROTECTED box: [$(printf '%s' "$out" | tr '\n' ' ')]"
fi
# -- Docker's chains are not a host firewall. A box running Docker has plenty of
# nft rules and still accepts every inbound port.
d="$(fw_probe_dir docker)"
make_stub_stdout "$d" nft 0 <<'EOF'
table ip nat {
chain DOCKER {
iifname "docker0" counter packets 0 bytes 0 return
}
}
table ip filter {
chain FORWARD {
type filter hook forward priority filter; policy drop;
}
}
EOF
make_stub_stdout "$d" ufw 0 <<'EOF'
Status: inactive
EOF
out="$(run_bootstrap "$d")"
if printf '%s' "$out" | grep -q 'ufw allow'; then
ok "bootstrap_firewall is not fooled by Docker's nftables chains"
else
bad "Docker's rules read as a protected box: [$(printf '%s' "$out" | tr '\n' ' ')]"
fi
# -- A real default-deny nft policy on the INPUT hook IS protection: hands off.
d="$(fw_probe_dir nftdeny)"
make_stub_stdout "$d" nft 0 <<'EOF'
table inet filter {
chain input {
type filter hook input priority filter; policy drop;
tcp dport 22 accept
}
}
EOF
out="$(run_bootstrap "$d")"
if printf '%s' "$out" | grep -qi 'already filtered'; then
ok "bootstrap_firewall leaves a real nftables default-deny alone"
else
bad "bootstrap_firewall did not respect an nft input policy drop: [$(printf '%s' "$out" | tr '\n' ' ')]"
fi
# -- An ALREADY-ACTIVE ufw is the operator's. Open ports on it
# (configure_firewall's job), never rewrite its default policy.
d="$(fw_probe_dir ufwactive)"
make_stub_stdout "$d" ufw 0 <<'EOF'
Status: active
Default: deny (incoming), allow (outgoing), disabled (routed)
EOF
out="$(run_bootstrap "$d")"
if printf '%s' "$out" | grep -qi 'already filtered' \
&& ! printf '%s' "$out" | grep -q 'ufw default deny'; then
ok "bootstrap_firewall leaves an already-active firewall's policy alone"
else
bad "bootstrap_firewall touched an existing firewall: [$(printf '%s' "$out" | tr '\n' ' ')]"
fi
# -- ufw installed and active but ALLOWING inbound is not protection.
d="$(fw_probe_dir ufwallow)"
make_stub_stdout "$d" ufw 0 <<'EOF'
Status: active
Default: allow (incoming), allow (outgoing), disabled (routed)
EOF
out="$(run_bootstrap "$d")"
if printf '%s' "$out" | grep -q 'ufw default deny'; then
ok "bootstrap_firewall fixes an active ufw that still allows inbound"
else
bad "an allow-inbound ufw read as protected: [$(printf '%s' "$out" | tr '\n' ' ')]"
fi
# -- bootstrap: non-Debian is a no-op (UFW is Debian/Ubuntu; RHEL boots with
# firewalld active and is handled by configure_firewall's open step).
out="$( set -Eeuo pipefail; PATH="$t42/bin:$PATH"
OS_FAMILY=rhel; FW_DRY_RUN=1; FIREWALL_BACKEND=none
SSHD_CONFIG_ROOT="$t42/ssh"
detect_container() { printf ''; }
pkg_add() { :; }
source "$LIB_DIR/firewall.sh"
bootstrap_firewall 2>&1 )"
if [ -z "$out" ]; then
ok "bootstrap_firewall is a no-op outside the Debian family"
else
bad "bootstrap_firewall tried to install UFW on a non-Debian box: [$(printf '%s' "$out" | tr '\n' ' ')]"
fi
# -- bootstrap: SERVERKIT_SKIP_FIREWALL=1 and containers are both hands-off.
out="$( set -Eeuo pipefail; PATH="$t42/bin:$PATH"
OS_FAMILY=debian; FW_DRY_RUN=1; FIREWALL_BACKEND=none
SERVERKIT_SKIP_FIREWALL=1; SSHD_CONFIG_ROOT="$t42/ssh"
detect_container() { printf ''; }
pkg_add() { :; }
source "$LIB_DIR/firewall.sh"
bootstrap_firewall 2>&1 )"
out2="$( set -Eeuo pipefail; PATH="$t42/bin:$PATH"
OS_FAMILY=debian; FW_DRY_RUN=1; FIREWALL_BACKEND=none
SSHD_CONFIG_ROOT="$t42/ssh"
detect_container() { printf 'lxc'; }
pkg_add() { :; }
source "$LIB_DIR/firewall.sh"
bootstrap_firewall 2>&1 )"
if ! printf '%s' "$out" | grep -q 'ufw' && ! printf '%s' "$out2" | grep -q 'ufw allow'; then
ok "bootstrap_firewall honours SERVERKIT_SKIP_FIREWALL and skips containers"
else
bad "bootstrap_firewall ignored an opt-out: [$out] [$out2]"
fi
# -- the re-run guard: we bootstrapped once, the operator switched the firewall
# off, a later install must NOT switch it back on.
out="$( set -Eeuo pipefail; PATH="$t42/bin:$PATH"
OS_FAMILY=debian; FW_DRY_RUN=1; FIREWALL_BACKEND=none
SSHD_CONFIG_ROOT="$t42/ssh"
detect_container() { printf ''; }
pkg_add() { :; }
source "$LIB_DIR/firewall.sh"
state_get() { [ "$1" = "firewall_bootstrapped" ] && printf '1' || printf ''; }
bootstrap_firewall 2>&1 )"
if ! printf '%s' "$out" | grep -q 'ufw --force enable'; then
ok "bootstrap_firewall does not re-enable a firewall the operator turned off"
else
bad "bootstrap_firewall re-enabled a deliberately disabled firewall: [$(printf '%s' "$out" | tr '\n' ' ')]"
fi
# -- the sshd jail watches the port sshd actually uses, not a hardcoded 22.
printf 'Port 2222\n' > "$t42/ssh/sshd_config"
jail_dir="$t42/jail.d"
out="$( set -Eeuo pipefail; PATH="$t42/bin:$PATH"
SSHD_CONFIG_ROOT="$t42/ssh"; FAIL2BAN_JAIL_DIR="$jail_dir"
write_sshd_jail 2>&1 )"
if [ -f "$jail_dir/sshd-serverkit.conf" ] \
&& grep -q '^\[sshd\]' "$jail_dir/sshd-serverkit.conf" \
&& grep -q '^enabled = true' "$jail_dir/sshd-serverkit.conf" \
&& grep -q '^port = 2222' "$jail_dir/sshd-serverkit.conf"; then
ok "write_sshd_jail enables the sshd jail on the detected SSH port"
else
bad "write_sshd_jail wrote the wrong jail: [$(cat "$jail_dir/sshd-serverkit.conf" 2>/dev/null | tr '\n' ' ')] [$out]"
fi
# Re-running must not append or duplicate — the file is regenerated whole.
( set -Eeuo pipefail; PATH="$t42/bin:$PATH"
SSHD_CONFIG_ROOT="$t42/ssh"; FAIL2BAN_JAIL_DIR="$jail_dir"
write_sshd_jail >/dev/null 2>&1 )
if [ "$(grep -c '^\[sshd\]' "$jail_dir/sshd-serverkit.conf")" = "1" ]; then
ok "write_sshd_jail is idempotent across re-runs"
else
bad "write_sshd_jail duplicated the jail stanza on a re-run"
fi
# -- static guard: the interlock must stay wired. bootstrap_firewall has to call
# detect_ssh_ports, and the `ufw --force enable` line must live after it.
if awk '/^bootstrap_firewall\(\)/,/^}/' "$INSTALL_SH" | grep -q 'detect_ssh_ports'; then
ok "bootstrap_firewall still consults detect_ssh_ports before enabling anything"
else
bad "bootstrap_firewall no longer detects the SSH port — LOCKOUT RISK"
fi
# --------------------------------------------------------------------------
# T43 — plan 71: running behind an operator's own reverse proxy.
#
# Issue #96. Two switches, and the failure modes are silent ones: a panel that
# still 301s to HTTPS traps the proxy in a redirect loop, and a missing
# SERVERKIT_PUBLIC_URL leaves the UI loading but never live-updating because
# every websocket handshake is rejected on origin.
# --------------------------------------------------------------------------
printf '\nT43 — external reverse proxy (SERVERKIT_EXTERNAL_PROXY / SERVERKIT_CONFIG)\n'
t43="$WORK/t43"
mkdir -p "$t43"
# -- EXTERNAL_PROXY implies SKIP_SSL. Sourcing install.sh re-runs the settings
# block, so the implication is observable without running an install.
out="$( set -Eeuo pipefail
SERVERKIT_EXTERNAL_PROXY=1
unset SERVERKIT_SKIP_SSL || true
source "$INSTALL_SH" >/dev/null 2>&1
printf '%s' "$SERVERKIT_SKIP_SSL" )"
if [ "$out" = "1" ]; then
ok "SERVERKIT_EXTERNAL_PROXY=1 implies SERVERKIT_SKIP_SSL=1 (no HTTPS redirect to loop on)"
else
bad "SERVERKIT_EXTERNAL_PROXY=1 left SERVERKIT_SKIP_SSL=[$out] — the panel would still 301 to HTTPS"
fi
# -- and it must NOT flip SKIP_SSL for an ordinary install.
out="$( set -Eeuo pipefail
unset SERVERKIT_EXTERNAL_PROXY SERVERKIT_SKIP_SSL || true
source "$INSTALL_SH" >/dev/null 2>&1
printf '%s' "$SERVERKIT_SKIP_SSL" )"
if [ "$out" = "0" ]; then
ok "the default install is untouched (SERVERKIT_SKIP_SSL=0)"
else
bad "an ordinary install now skips SSL (SERVERKIT_SKIP_SSL=[$out])"
fi
# -- a trailing slash on the public URL must be stripped: a browser's Origin
# header never carries one, so storing it raw would never match.
out="$( set -Eeuo pipefail
SERVERKIT_EXTERNAL_PROXY=1
SERVERKIT_PUBLIC_URL="https://panel.example.com/"
source "$INSTALL_SH" >/dev/null 2>&1
printf '%s' "$PANEL_PUBLIC_URL" )"
if [ "$out" = "https://panel.example.com" ]; then
ok "a trailing slash is stripped from SERVERKIT_PUBLIC_URL"
else
bad "SERVERKIT_PUBLIC_URL normalised to [$out]"
fi
# -- SERVERKIT_CONFIG supplies defaults...
cat > "$t43/install.conf" <<'CONF'
# ServerKit install config
SERVERKIT_EXTERNAL_PROXY=1
SERVERKIT_PUBLIC_URL="https://from-file.example.com"
SERVERKIT_PROFILE=minimal
CONF
out="$( set -Eeuo pipefail
unset SERVERKIT_EXTERNAL_PROXY SERVERKIT_PUBLIC_URL SERVERKIT_PROFILE || true
SERVERKIT_CONFIG="$t43/install.conf"
source "$INSTALL_SH" >/dev/null 2>&1
printf '%s|%s|%s' "$SERVERKIT_EXTERNAL_PROXY" "$PANEL_PUBLIC_URL" "$PROFILE" )"
if [ "$out" = "1|https://from-file.example.com|minimal" ]; then
ok "SERVERKIT_CONFIG supplies defaults (quotes stripped, comments ignored)"
else
bad "SERVERKIT_CONFIG produced [$out]"
fi
# -- ...but explicit environment always wins over the file.
out="$( set -Eeuo pipefail
unset SERVERKIT_EXTERNAL_PROXY || true
SERVERKIT_CONFIG="$t43/install.conf"
SERVERKIT_PUBLIC_URL="https://from-env.example.com"
SERVERKIT_PROFILE=full
source "$INSTALL_SH" >/dev/null 2>&1
printf '%s|%s' "$PANEL_PUBLIC_URL" "$PROFILE" )"
if [ "$out" = "https://from-env.example.com|full" ]; then
ok "explicit environment overrides SERVERKIT_CONFIG"
else
bad "SERVERKIT_CONFIG overrode the explicit environment: [$out]"
fi
# -- a missing config file is a hard error, not a silently-default install.
if ( set -Eeuo pipefail
SERVERKIT_CONFIG="$t43/nope.conf"
source "$INSTALL_SH" >/dev/null 2>&1 ) ; then
bad "a missing SERVERKIT_CONFIG file was ignored"
else
ok "a missing SERVERKIT_CONFIG file aborts instead of installing the wrong shape"
fi
# -- the file must be parsed, not executed: a command in it must not run.
printf 'touch %s/pwned\nSERVERKIT_PROFILE=minimal\n' "$t43" > "$t43/evil.conf"
( set -Eeuo pipefail
unset SERVERKIT_PROFILE || true
SERVERKIT_CONFIG="$t43/evil.conf"
source "$INSTALL_SH" >/dev/null 2>&1 ) || true
if [ -e "$t43/pwned" ]; then
bad "SERVERKIT_CONFIG executed a command from the file — it must be parsed, not sourced"
else
ok "SERVERKIT_CONFIG is parsed, not sourced (a stray command does not run as root)"
fi
# -- write_config on an EXISTING .env must apply the proxy settings, because
# "convert my install to sit behind Caddy" is a re-run and lands there.
env_dir="$t43/opt"
mkdir -p "$env_dir"
cat > "$env_dir/.env" <<'ENVEOF'
SECRET_KEY=keep-me
SERVERKIT_SSL_MODE=secure
# TRUST_PROXY_HEADERS=false
ENVEOF
( set -Eeuo pipefail
source "$INSTALL_SH" >/dev/null 2>&1
INSTALL_DIR="$env_dir"; SSL_MODE=insecure; PROFILE=standard
SERVERKIT_EXTERNAL_PROXY=1; PANEL_PUBLIC_URL="https://panel.example.com"
write_config >/dev/null 2>&1 )
if grep -q '^TRUST_PROXY_HEADERS=true' "$env_dir/.env" \
&& grep -q '^TRUSTED_PROXY_HOPS=2' "$env_dir/.env" \
&& grep -q '^SERVERKIT_PUBLIC_URL=https://panel.example.com$' "$env_dir/.env" \
&& grep -q '^SECRET_KEY=keep-me' "$env_dir/.env" \
&& [ "$(grep -c 'TRUST_PROXY_HEADERS' "$env_dir/.env")" = "1" ]; then
ok "converting an existing install rewrites the proxy keys and keeps the secrets"
else
bad "existing-.env conversion produced: [$(tr '\n' ' ' < "$env_dir/.env")]"
fi
# -- and SERVERKIT_SSL_MODE must flip to insecure in the .env, not only in
# /etc/serverkit/ssl-mode. _resolve_ssl_mode() reads the ENV VAR FIRST and
# only falls back to the file, and run.py load_dotenv()s this file — so a
# stale SERVERKIT_SSL_MODE=secure here keeps HSTS on and tells browsers to
# force HTTPS on a port that now only speaks plain HTTP.
if grep -q '^SERVERKIT_SSL_MODE=insecure' "$env_dir/.env" \
&& ! grep -q '^SERVERKIT_SSL_MODE=secure' "$env_dir/.env"; then
ok "the converted .env records ssl-mode=insecure so HSTS stops being sent"
else
bad "SERVERKIT_SSL_MODE was not flipped to insecure: [$(grep SERVERKIT_SSL_MODE "$env_dir/.env" | tr '\n' ' ')]"
fi
# -- an ordinary re-run must not strip proxy settings someone configured by hand.
( set -Eeuo pipefail
source "$INSTALL_SH" >/dev/null 2>&1
INSTALL_DIR="$env_dir"; SSL_MODE=insecure; PROFILE=standard
SERVERKIT_EXTERNAL_PROXY=0
write_config >/dev/null 2>&1 )
if grep -q '^TRUST_PROXY_HEADERS=true' "$env_dir/.env"; then
ok "a plain re-run leaves existing proxy settings alone"
else
bad "a plain re-run removed TRUST_PROXY_HEADERS from an existing .env"
fi
# -- only ONE default server may exist on :80.
#
# Both site configs declare `listen 80 default_server` and then include
# /etc/nginx/serverkit-conf.d/*.conf. install.sh writes the canonical-domain
# redirect into that directory whenever PANEL_DOMAIN is set, and it used to
# declare `listen 80 default_server` as well — which nginx rejects outright
# ("a duplicate default server for 0.0.0.0:80"), in BOTH ssl modes. Since
# launch_services starts nginx after write_config, every first install with a
# domain produced an nginx that would not start. Verified against real nginx.
# Directives only — the block's own comments explain this trap by naming it.
canon_block="$(awk '/canonical-domain.conf <<EOF/,/^EOF$/' "$INSTALL_SH" \
| grep -v '^[[:space:]]*#')"
if [ -z "$canon_block" ]; then
bad "could not find the canonical-domain.conf heredoc in install.sh"
elif printf '%s' "$canon_block" | grep -q 'default_server'; then
bad "the canonical-domain redirect declares default_server — nginx will refuse to start on any PANEL_DOMAIN install"
elif ! printf '%s' "$canon_block" | grep -q 'server_name ~'; then
bad "the canonical-domain redirect no longer matches a bare IP by regex server_name — the redirect will never fire"
else
ok "the canonical-domain redirect matches a bare IP without a second default_server"
fi
# -- and the shipped sites must remain the ONLY default server on :80.
for site in serverkit.conf serverkit-insecure.conf; do
n="$(grep -c 'listen 80 default_server' "$REPO_DIR/nginx/sites-available/$site" || true)"
if [ "$n" = "1" ]; then
ok "$site declares exactly one default server on :80"
else
bad "$site declares $n default servers on :80 (expected 1)"
fi
done
# --------------------------------------------------------------------------
# T44 — every install must derive the REAL client IP.
#
# install.sh wrote no TRUST_PROXY_HEADERS line at all, so the config default
# (False) applied while the panel always sits behind its own nginx: per-IP rate
# limits, the login brute-force throttle and every audit-log entry keyed on
# 127.0.0.1. .env.example claimed `true`, so the two disagreed.
#
# Trusting X-Forwarded-For is only safe because nothing can reach gunicorn
# without passing through that nginx — render_service_unit binds loopback and
# the firewall never opens the backend port. Expose the port and the header
# becomes attacker-chosen, so the switch must follow the bind host.
# --------------------------------------------------------------------------
printf '\nT44 — trusted client IP on a stock install\n'
t44="$WORK/t44"; mkdir -p "$t44"
# write_config mints secrets with `openssl rand -hex 32` and a python3 one-liner.
# The distro containers this suite runs in ship NEITHER — ubuntu:24.04 and
# debian:12 have no openssl and no python3 — and under `set -e` the failed
# command substitution aborted write_config before it wrote a single line, so
# three assertions read an empty file and failed in CI while passing on a dev
# box that happens to have both. Stub them: this test is about which proxy keys
# land in .env, not about how random the secrets are. (Same philosophy as the
# rest of the suite, which stubs every external tool.)
#
# Shell functions win over PATH lookup, including for `"${PYTHON_BIN:-python3}"`,
# so no PATH juggling is needed. Defined by calling this inside each subshell.
# Literal values, no seq/head/tr: those are exactly the sort of tool a stripped
# base image turns out not to have, which is the bug being fixed here.
_t44_stub_keygen() {
openssl() { printf '%s\n' "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; }
python3() { printf '%s\n' "ZmFrZS1mZXJuZXQta2V5LWZvci11bml0LXRlc3RzLTAwMDA9"; }
}
# -- the stock install: loopback-bound gunicorn behind our own nginx.
out="$( set -Eeuo pipefail
unset SERVERKIT_BIND_HOST SERVERKIT_EXTERNAL_PROXY || true
source "$INSTALL_SH" >/dev/null 2>&1
_t44_stub_keygen
rm -rf "$t44/a"; mkdir -p "$t44/a"
INSTALL_DIR="$t44/a"; SSL_MODE=insecure; PROFILE=standard
write_config >/dev/null 2>&1
cat "$t44/a/.env" )"
if printf '%s' "$out" | grep -q '^TRUST_PROXY_HEADERS=true' \
&& printf '%s' "$out" | grep -q '^TRUSTED_PROXY_HOPS=1'; then
ok "a stock install trusts its own nginx and derives the real client IP (1 hop)"
else
bad "stock install produced: [$(printf '%s' "$out" | grep -i proxy | tr '\n' ' ')]"
fi
# -- exposing the raw backend port must turn it OFF: X-Forwarded-For is then
# client-controlled, and a forged IP defeats the very throttles it feeds.
out="$( set -Eeuo pipefail
unset SERVERKIT_EXTERNAL_PROXY || true
SERVERKIT_BIND_HOST=0.0.0.0
source "$INSTALL_SH" >/dev/null 2>&1
_t44_stub_keygen
rm -rf "$t44/b"; mkdir -p "$t44/b"
INSTALL_DIR="$t44/b"; SSL_MODE=insecure; PROFILE=standard
write_config >/dev/null 2>&1
cat "$t44/b/.env" )"
if printf '%s' "$out" | grep -q '^TRUST_PROXY_HEADERS=false' \
&& ! printf '%s' "$out" | grep -q '^TRUST_PROXY_HEADERS=true'; then
ok "SERVERKIT_BIND_HOST=0.0.0.0 leaves trust OFF (the header would be forgeable)"
else
bad "an exposed backend port still trusted X-Forwarded-For: [$(printf '%s' "$out" | grep -i proxy | tr '\n' ' ')]"
fi
# -- the external-proxy switch still wins, with its own hop count.
out="$( set -Eeuo pipefail
SERVERKIT_EXTERNAL_PROXY=1
source "$INSTALL_SH" >/dev/null 2>&1
_t44_stub_keygen
rm -rf "$t44/c"; mkdir -p "$t44/c"
INSTALL_DIR="$t44/c"; SSL_MODE=insecure; PROFILE=standard
write_config >/dev/null 2>&1
cat "$t44/c/.env" )"
if printf '%s' "$out" | grep -q '^TRUSTED_PROXY_HOPS=2'; then
ok "SERVERKIT_EXTERNAL_PROXY still overrides the default with 2 hops"
else
bad "external proxy did not set 2 hops: [$(printf '%s' "$out" | grep -i proxy | tr '\n' ' ')]"
fi
# -- backfill on a re-run: pre-existing installs are the ones actually suffering.
mkdir -p "$t44/old"
printf 'SECRET_KEY=keep-me\nSERVERKIT_SSL_MODE=insecure\n' > "$t44/old/.env"
( set -Eeuo pipefail
unset SERVERKIT_BIND_HOST SERVERKIT_EXTERNAL_PROXY || true
source "$INSTALL_SH" >/dev/null 2>&1
INSTALL_DIR="$t44/old"; SSL_MODE=insecure; PROFILE=standard
write_config >/dev/null 2>&1 )
if grep -q '^TRUST_PROXY_HEADERS=true' "$t44/old/.env" \
&& grep -q '^TRUSTED_PROXY_HOPS=1' "$t44/old/.env" \
&& grep -q '^SECRET_KEY=keep-me' "$t44/old/.env"; then
ok "a re-run backfills the trust setting into an install that predates it"
else
bad "re-run backfill produced: [$(tr '\n' ' ' < "$t44/old/.env")]"
fi
# -- but an explicit operator choice is never overridden. Someone who turned
# trust off did it for a reason, and a re-run flipping it back on silently
# would be the worst possible surprise.
mkdir -p "$t44/explicit"
printf 'SECRET_KEY=keep-me\nTRUST_PROXY_HEADERS=false\n' > "$t44/explicit/.env"
( set -Eeuo pipefail
unset SERVERKIT_BIND_HOST SERVERKIT_EXTERNAL_PROXY || true
source "$INSTALL_SH" >/dev/null 2>&1
INSTALL_DIR="$t44/explicit"; SSL_MODE=insecure; PROFILE=standard
write_config >/dev/null 2>&1 )
if grep -q '^TRUST_PROXY_HEADERS=false' "$t44/explicit/.env" \
&& ! grep -q '^TRUST_PROXY_HEADERS=true' "$t44/explicit/.env"; then
ok "a re-run never overrides an explicit TRUST_PROXY_HEADERS=false"
else
bad "a re-run overrode the operator's explicit setting: [$(tr '\n' ' ' < "$t44/explicit/.env")]"
fi
# -- the two consumers of the bind host must agree. The systemd unit and the
# trust decision reading different values is exactly how this class of bug
# comes back.
unit="$t44/unit.service"
( set -Eeuo pipefail
unset SERVERKIT_BIND_HOST || true
source "$INSTALL_SH" >/dev/null 2>&1
INSTALL_DIR="$REPO_DIR"; VENV_DIR="$REPO_DIR/venv"; LOG_DIR="$t44"
render_service_unit "$unit" >/dev/null 2>&1 ) || true
if grep -q -- '-b 127.0.0.1:5000' "$unit" 2>/dev/null; then
ok "render_service_unit and the trust decision share one bind host (loopback)"
else
bad "the rendered unit does not bind loopback: [$(grep ExecStart "$unit" 2>/dev/null)]"
fi
# -- apt must never be able to open an interactive prompt.
#
# `apt-get install -y` answers apt's questions but NOT debconf's: tzdata and
# friends still open a dialog and block. pkg_add captures output, so the
# installer then hangs in complete silence — a real 60-minute stall on
# ubuntu:22.04, found only once provisioning was exercised for real. Under
# `curl | bash` it is worse: stdin is the script, so debconf can consume
# installer source as its answers.
apt_line="$(awk '/^pkg_add\(\)/,/^}/' "$INSTALL_SH" | grep -A3 'apt)')"
if printf '%s' "$apt_line" | grep -q 'DEBIAN_FRONTEND=noninteractive'; then
ok "pkg_add runs apt with DEBIAN_FRONTEND=noninteractive"
else
bad "pkg_add's apt branch can block on a debconf prompt: [$apt_line]"
fi
# Issue #148: reusing an interpreter must still provision matching headers.
if ( set -Eeuo pipefail
OS_FAMILY=debian; ID=ubuntu
locate_python() { PYTHON_BIN=python3.14; return 0; }
python3.14() { printf '3.14\n'; }
ready=0
python_build_ready() { [ "$ready" = 1 ]; }
refresh_pkg_index() { :; }
pkg_add() {
[ "$*" = 'build-essential python3.14-dev' ] || exit 1
ready=1
}
provision_python >/dev/null
ensure_python_build_deps >/dev/null
[ "$ready" = 1 ]
); then
ok "existing Python 3.14 gets matching headers and build tools"
else
bad "existing Python skips native build prerequisites"
fi
if ( set -Eeuo pipefail
OS_FAMILY=debian; PYTHON_BIN=python3.14
python3.14() { printf '3.14\n'; }
python_build_ready() { return 1; }
refresh_pkg_index() { :; }
pkg_add() { return 0; } # warn-and-continue package failure
halt() { return 1; }
ensure_python_build_deps >/dev/null 2>&1
); then
bad "missing headers were accepted after package installation failed"
else
ok "missing build prerequisites fail before pip"
fi
if ( set -Eeuo pipefail
OS_FAMILY=debian; ID=ubuntu; installed=0
locate_python() { [ "$installed" = 1 ] && { PYTHON_BIN=python3.14; return 0; }; return 1; }
refresh_pkg_index() { :; }
pkg_add() {
[ "$*" = 'python3 python3-venv python3-dev' ] || exit 1
installed=1
}
ensure_python_build_deps() { exit 1; }
add-apt-repository() { exit 1; }
provision_python >/dev/null
[ "$installed" = 1 ]
); then
ok "Ubuntu uses its supported default Python without a PPA"
else
bad "Ubuntu did not use its default Python first"
fi
if ( set -Eeuo pipefail
python_build_ready() { return 0; }
pkg_add() { exit 1; }
refresh_pkg_index() { exit 1; }
ensure_python_build_deps
); then
ok "working native build environment needs no package changes"
else
bad "working native build environment tried to install packages"
fi
# A source install, absent release venv, or incompatible release interpreter
# must all check native prerequisites before creating a venv or invoking pip.
for build_case in source missing-release broken-release; do
if ( set -Eeuo pipefail
build_dir="$WORK/native-$build_case"
mkdir -p "$build_dir/slot"
FIRST_SLOT="$build_dir/slot"; VENV_DIR="$FIRST_SLOT/venv"
INSTALL_FROM_RELEASE=1
[ "$build_case" != source ] || INSTALL_FROM_RELEASE=0
if [ "$build_case" = broken-release ]; then
mkdir -p "$VENV_DIR/bin"
: > "$VENV_DIR/bin/activate"
printf '#!/bin/sh\nexit 1\n' > "$VENV_DIR/bin/python"
chmod +x "$VENV_DIR/bin/python"
fi
ready=0; pip_called=0
ensure_python_build_deps() { ready=1; }
PYTHON_BIN=test_build_python
test_build_python() {
[ "$ready" = 1 ] || exit 1
mkdir -p "$VENV_DIR/bin"
: > "$VENV_DIR/bin/activate"
}
pip() { [ "$ready" = 1 ] || exit 1; pip_called=1; }
build_virtualenv >/dev/null 2>&1
[ "$ready" = 1 ] && [ "$pip_called" = 1 ]
); then
ok "$build_case checks build prerequisites before pip"
else
bad "$build_case skipped build prerequisites"
fi
done
if ( set -Eeuo pipefail
INSTALL_FROM_RELEASE=0
ensure_python_build_deps() { return 1; }
PYTHON_BIN=never_build_python
never_build_python() { exit 99; }
rc=0
build_virtualenv >/dev/null 2>&1 || rc=$?
[ "$rc" = 1 ]
); then
ok "failed prerequisites stop the local build before venv creation"
else
bad "local build ignored prerequisite failure"
fi
# -- static guard: the documented insecure site must keep shipping, since both
# the docs and SERVERKIT_EXTERNAL_PROXY point operators straight at it.
if [ -f "$REPO_DIR/nginx/sites-available/serverkit-insecure.conf" ] \
&& ! grep -qE 'return[[:space:]]+30[12]' "$REPO_DIR/nginx/sites-available/serverkit-insecure.conf"; then
ok "serverkit-insecure.conf ships and contains no redirect for a proxy to loop on"
else
bad "serverkit-insecure.conf is missing or now redirects — issue #96 would recur"
fi
# --------------------------------------------------------------------------
printf '\n%d passed, %d failed, %d skipped\n\n' "$PASS" "$FAIL" "$SKIP"
[ "$FAIL" -eq 0 ]