23 Commits
Author SHA1 Message Date
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
Juan Denis 5ffc7257a3 fix(installer): require build tools only for local venv builds 2026-09-20 23:02:09 -04:00
Juan Denis b63427e2a2 fix(installer): provision native Python build dependencies (#148) 2026-09-20 22:29:53 -04:00
Juan Denis ab27c7b058 fix(distro): patch all 6 failing distro-compatibility probes
- Harness: bootstrap find/awk in distro-compatibility.py (AL2023,
  Tumbleweed, SLES BCI minimal images lack them; the source-level
  suites legitimately use both)
- Python gate 3.11-3.14: Arch/Manjaro rolling ship only python 3.14
  with no versioned fallback packages (same trap as Debian 13/#99);
  align scripts/update.sh, which had drifted to a 3.11-3.12 gate
- Gentoo: add emerge as a supported installer package-manager path
  (install.sh, scripts/lib/pkg.sh, distro.py family, deps manifest);
  provision via slotted dev-lang/python:3.13 -> 3.12
- Tests: move out-of-range stubs above the new ceiling, shadow all
  locate_python candidate names (real python3.14/3.13 in Manjaro/
  Gentoo images were winning over fixtures), cover the 7th manager
- Catalog: Gentoo is no longer a documented expected failure

Verified: test_lib 32/32, test_install 124/124, test_update 76/76,
pytest test_serverkit_installer 46/46, and all 6 previously failing
images now pass both quick and provision container probes (12/12).
2026-08-28 23:31:36 -04:00
Juan DenisandClaude Opus 5 7656e63b4c fix(test): T44 assumed openssl and python3 exist in the CI containers
write_config mints its secrets with `openssl rand -hex 32` and a python3
one-liner. The cross-distro containers install ONLY bash — ubuntu:24.04 and
debian:12 ship neither binary — so under `set -e` the failed command
substitution aborted write_config before it wrote a line, and three assertions
read an empty .env:

    ✘ stock install produced: []
    ✘ an exposed backend port still trusted X-Forwarded-For: []
    ✘ external proxy did not set 2 hops: []

The three T44 cases that passed are exactly the ones taking write_config's
existing-.env early return, which never reaches key generation — that split is
what identified the cause.

My bug, and the avoidable kind: T44 was the one part of this suite that leaned
on real binaries, in a file whose whole premise is that it stubs every external
tool. It passed locally only because a dev box happens to carry openssl and
python on PATH.

Stub both inside the three affected subshells. Shell functions take precedence
over PATH lookup, including through `"${PYTHON_BIN:-python3}"`, so no PATH
juggling is needed. The stub values are literals rather than built with
seq/tr/head — those are precisely the tools a stripped base image turns out not
to have, which is the bug being fixed.

Verified in the exact failing image: ubuntu:24.04 goes from 121 passed / 3
failed to 124 passed / 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 14:46:55 -04:00
Juan DenisandClaude Opus 5 8b417d2ae5 fix(install): apt could stop and wait for an answer nobody can give
`apt-get install -y` answers apt's own prompts but NOT debconf's. tzdata,
keyboard-configuration and friends still open an interactive dialog and block
forever. pkg_add captures apt's output, so the installer hangs in complete
silence — no prompt, no progress, nothing to diagnose.

Observed, not theorised: ubuntu:22.04 sat for 60 minutes on 39 bytes of output,
stuck in tzdata.postinst -> debconf frontend. Real Debian/Ubuntu servers are
exposed to the same stall; a container is just where it showed up. Under
`curl | bash` it is worse still, because stdin is the installer script itself,
so debconf can consume installer source as its answers.

DEBIAN_FRONTEND was set NOWHERE in install.sh, scripts/lib/pkg.sh or update.sh.
Every other manager was already guarded (dnf/yum -y, zypper --non-interactive,
pacman --noconfirm) — apt was the only unguarded one, and pkg.sh's own
docstring already claimed the whole function was non-interactive.

In pkg.sh it goes in ARGV as `env DEBIAN_FRONTEND=noninteractive …`, not an
export: _pkg_sudo runs plain `sudo "$@"` and sudo's env_reset would drop an
exported variable before apt saw it. --force-confold/--force-confdef cover the
other prompt class (modified conffiles), which DEBIAN_FRONTEND does not
suppress.

Tests: pkg_install must be non-interactive for every manager, and pkg_add's apt
branch is guarded statically. Three existing assertions pinned the exact apt
argv (`install -y <pkg>`) and now match the package instead — a flag added to
pkg_add should not fail tests whose subject is which package got installed.

Verified across the whole distro matrix: ubuntu:22.04 went from a 60-minute
hang to 7 passed in minutes; debian:13/12, ubuntu:24.04, fedora:40,
rockylinux:9 and opensuse/leap:15.5 all 7 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 12:21:37 -04:00
Juan DenisandClaude Opus 5 4e80d0655f test(install): exercise Python provisioning against real distro repos
Seven distro images were green throughout issue #99 because every one of them
runs the unit suites against STUBS. That is the right trade for logic, but it
left the one thing that talks to real package repos completely untested — so a
new Debian release dropping the Python we ask for could only ever be found by a
user.

scripts/test/test_provision_python.sh runs identify_system, choose_pkg_manager
and provision_python for real, then proves the result is usable: in range, can
create a virtualenv, has working pip, and imports ssl/sqlite3/ctypes. It needs
no systemd, nginx or ports, so it fits the plain containers the matrix already
pulls. Reaching the source build counts as a FAILURE, not a pass: a distro that
gets there is one we have not really added support for.

Wired up as a new `python-provisioning` job over the same images, and debian:13
added to both matrices. Adding trixie to the existing matrix alone would have
been decorative — those legs would have gone green on a distro that cannot
install.

Proven in both directions before being relied on: against the pre-fix tree it
reproduced #99 exactly on debian:13 (the reporter's apt errors, the fall
through to a source build, and the abort message), while debian:12 passed 7/7.

Also fixed the suite pinning the bug. T2 asserted `ver_in_range 3.13` must be
FALSE and T1's stub used 3.13 as its "too new" case, so the gate could not be
widened without the tests failing. Both moved to 3.14, plus a direct guard on
PYTHON_MAX — otherwise a regression to 3.12 could be "fixed" by editing the
assertion to match. 122 passed, 0 failed.

full-stack-test.ps1 documents why debian13 is NOT in the VM harness: no trixie
box publishes a Hyper-V provider (generic/debian13 does not exist,
debian/trixie64 is libvirt-only, bento/debian-13 has no hyperv) and Multipass
is Ubuntu-only. Named there with what remains uncovered on trixie — the full
systemd + nginx + API end-to-end — rather than left as a silent gap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 11:55:47 -04:00
Juan DenisandClaude Opus 5 0bb7e4ab54 fix(install): derive the real client IP on a stock host install
install.sh wrote no TRUST_PROXY_HEADERS line at all, so the code default (False)
applied while every host install serves the panel through its own nginx. Per-IP
rate limits, the login brute-force throttle and every audit-log entry therefore
keyed on 127.0.0.1, which quietly weakened all three -- one abusive client shared
a bucket with everyone else. .env.example claimed true, so the two disagreed and
it looked configured.

Trusting X-Forwarded-For is only safe when nothing can reach gunicorn without
traversing a proxy we control, so the switch follows that condition rather than
assuming it. On a host install it holds: render_service_unit binds gunicorn to
127.0.0.1 and bootstrap_firewall opens SSH + 80/443 + PANEL_PORT and never the
backend port. backend_bind_host() is now the single source of truth for both the
systemd unit and the trust decision so the two cannot drift, and
backend_is_loopback_only() gates the setting.

- stock install: TRUST_PROXY_HEADERS=true, TRUSTED_PROXY_HOPS=1
- SERVERKIT_EXTERNAL_PROXY=1: true with 2 hops, unchanged
- SERVERKIT_BIND_HOST not loopback: false plus a warning. The port is directly
  reachable there, so the header is attacker-chosen, and a forged IP is worse
  than a wrong one because it defeats the throttles that consume it.

Re-runs backfill it into installs that predate this, via _env_default_key, which
fills a gap but never overrides an uncommented value -- flipping someone's
deliberate false back on would be the worst kind of surprise.

.env.example moves to false on purpose: that file is the Docker/manual path and
docker-compose.yml publishes 5000 on all interfaces, so true was the unsafe
default there. Host installs never read it. SECURITY.md claimed "the shipped
nginx deploy sets it", which was untrue until now.

T44 covers all six behaviours, each mutation-checked by line number: disabling
the branch, inverting it to trust an exposed port, and disabling the backfill
each fail exactly one assertion and no others.

Shell suite 121 passed. Backend suite 3542 passed, 48 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 23:52:55 -04:00
Juan DenisandClaude Opus 5 4223e2ed6a feat(install): SERVERKIT_EXTERNAL_PROXY and SERVERKIT_CONFIG
Make "someone else terminates TLS in front of the panel" a supported install
shape instead of something you reverse-engineer from this script (issue #96).

SERVERKIT_EXTERNAL_PROXY=1 implies SERVERKIT_SKIP_SSL=1, so the plain-HTTP nginx
site is selected and there is no 301 for the external proxy to follow back into
itself; it also writes TRUST_PROXY_HEADERS=true and TRUSTED_PROXY_HOPS=2 (their
proxy appends the real client, our nginx appends their proxy) and asks for
SERVERKIT_PUBLIC_URL, deriving it from PANEL_DOMAIN when it can and warning
rather than blocking when there is no tty. Without that URL the Socket.IO
handshake is rejected on origin and the UI loads but never live-updates.

It applies on re-runs too, via a new _env_set_key helper. "Convert my install to
sit behind Caddy" is a re-run and lands in the existing-.env branch, which
previously only refreshed the ssl mode and profile -- so the documented
conversion path would have silently done nothing. A plain re-run never removes
proxy settings someone configured by hand.

SERVERKIT_CONFIG names a KEY=VALUE file of defaults for the same variables. It
is PARSED, not sourced: sourcing would let file assignments overwrite the
environment, inverting the documented precedence, and would execute anything
else in the file as root. Applying keys one at a time and skipping names already
set gives the stated semantics safely.

scripts/test/test_install.sh T43 covers all of it, including that a command
placed in the config file does not run, that the converted .env flips
SERVERKIT_SSL_MODE to insecure (config resolves the env var before the file, so
writing /etc/serverkit/ssl-mode alone would leave HSTS on), and that a default
install is untouched. T12 gained guards for the compose and Dockerfile
regressions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 23:06:54 -04:00
Juan DenisandClaude Opus 5 27dec7ddb8 fix(install): the firewall bootstrap never ran on a stock Ubuntu box
Found by the full-stack VM harness, which 3524 unit tests and a 7-image
container matrix had all missed. On a real Ubuntu 24.04 install the log read
"Opening HTTP/HTTPS (80, 443) via nftables" and not one line of
bootstrap_firewall appeared: it returned at its "a firewall is already active"
guard, so the box got its two ports opened and no default-deny policy at all.
The headline feature of that work was a no-op on exactly the population it was
written for, and it looked green the whole way.

The guard asked `[ "$(firewall_detect)" != "none" ]`. firewall_detect answers a
different question — "which backend do I open a port THROUGH" — and returns
`nftables` whenever the nft binary exists and can list a ruleset, including an
empty one. That is true of every modern Linux, so the guard reported every box
as already protected. Verified directly against a stock-image fixture: same
inputs, `firewall_detect` -> nftables, inbound-default-deny -> unprotected.

firewall_inbound_default_deny() asks what the bootstrap actually cares about:
does this host already deny inbound by default? Presence of rules is
deliberately NOT the test -- Docker installs plenty of nft and iptables chains
and protects nothing. A default-deny policy on the input hook is: firewalld
running, ufw active *with* "deny (incoming)", an nft base chain hooked to input
with policy drop or reject, or an iptables INPUT policy that is not ACCEPT.
Unknown answers 1 (unprotected), because the caller's response is to offer
protection and every caller re-checks before acting.

firewall_detect is left alone -- configure_firewall legitimately wants a
backend name for a binary that exists -- and both functions now carry a comment
saying which question they answer, since that is what was conflated.

Six cases pin the behaviour, five of them shapes the old guard got wrong: an
empty nft ruleset must run the bootstrap, Docker's chains must not read as
protection, an active-but-allow-inbound ufw must be fixed rather than
respected, a real nft policy drop must be left alone, and the two predicates
must disagree on a stock image -- that last one asserted at the predicate level
so the distinction survives a refactor of either function. make_stub_stdout is
new because an exit-code stub cannot express "the tool ran fine and reported
nothing", which is the whole stock-image case.

Green on all seven CI images (265/265/265/275/275/275/263, 0 failed).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 19:56:53 -04:00
Juan DenisandClaude Opus 5 3c30d2f537 feat(install): put a default-deny firewall and fail2ban on a stock install
Plan 73 item 8. The installer opened 80/443 on whatever firewall it found and
shrugged -- "assuming ports 80/443 are already open" -- when there wasn't one,
which is the state of a stock Ubuntu/Debian VPS. That box then ran ServerKit
with every port a deploy maps onto the host (8001-8999, plus database
containers on their stock ports) facing the internet. fail2ban only shipped
with the "full" capacity profile, so the typical small VPS got neither.

The dangerous half is enabling default-deny without allowing the port sshd
actually answers on, over the SSH session running the installer. detect_ssh_ports
therefore never guesses: it asks `sshd -T` (the effective config, which resolves
the Include that Ubuntu 22.04+ ships), then sshd_config plus its drop-ins with
comments stripped, then a live `ss`. Multiple Port lines all come back. 22 is
used only when a config exists and names no port -- sshd's compiled-in default
-- and if nothing answers at all the function fails and no firewall is
installed. An exposed box is recoverable; a locked-out one usually is not.

That interlock had a real bug in its first draft, caught before it shipped and
now pinned by a named regression test: most boxes have no sshd_config.d/, the
unmatched glob makes cat exit non-zero, and under `set -o pipefail` that wiped
a correctly parsed answer and fell through to the 22 default -- returning 22 on
a box whose sshd is on 2222, which is precisely the lockout this function
exists to prevent.

Ordering is the other safety property and is asserted: every allow rule (all
detected SSH ports, 80, 443, and PANEL_PORT when non-standard) lands before
`default deny incoming`, and the policy before `--force enable`. The bootstrap
refuses to act inside a container, when a firewall is already active (it is the
operator's -- opening ports on it is fine, rewriting its default policy is not),
under SERVERKIT_SKIP_FIREWALL=1, and when a previous run bootstrapped but the
firewall is off now, since the operator turned it off deliberately. Non-Debian
installs are byte-identical to before: RHEL-family boots with firewalld already
active, and a firewalld default-deny bootstrap for boxes where it is inactive
is marked as an explicit follow-up rather than guessed at.

fail2ban moves to the standard profile with an sshd jail that watches the same
ports detect_ssh_ports found -- a jail pinned to :22 on a box listening on :2222
bans nobody -- and declares `backend = systemd` when there is no auth.log to
tail, without which the jail refuses to start and fail2ban looks installed while
protecting nothing. certbot stays full-only.

setup-health gains firewall (critical) and fail2ban (recommended) items. The
split is deliberate: this file grades critical by silent breakage, and an
unfirewalled box qualifies -- the operator set it in motion by deploying,
nothing in the UI says the port is public, and the failure mode is compromise
rather than an error. A missing fail2ban thins defence in depth but breaks
nothing silently, since the panel already throttles logins per IP. Both are
gated on actually owning the host -- Linux, /etc/serverkit present, not
containerised -- so a dev checkout or CI runner drops them entirely instead of
reporting a fake ok that would inflate the score.

SetupHealthWidget was orphaned; so, it turns out, was its stylesheet, which
main.scss never imported. Both are wired up, as a plain admin-only card above
the board rather than a placeable widget: an unfirewalled box has to be visible
without the operator first opting into showing it. It collapses to one line
when everything is clean.

Implementing this inverts a shipped test that asserted provision_hardening was
a no-op on the standard profile; it is rewritten to the new contract rather
than deleted, with a minimal-profile case added alongside.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 16:33:47 -04:00
Juan DenisandClaude Opus 5 8e55f2355e fix(install): unbreak curl | bash — guard BASH_SOURCE under set -u
Piped execution leaves BASH_SOURCE unset, so the sourceability guard's bare
${BASH_SOURCE[0]} was an unbound variable under `set -u` and aborted the
install before main() ever ran:

    bash: line 2107: BASH_SOURCE[0]: unbound variable

This broke the one-liner README/DEPLOYMENT document, on every distro — not
just the Debian 13 it was reported on (reproduced on debian:13 bash 5.2.37,
ubuntu:22.04 bash 5.1.16, almalinux:9 bash 5.1.8). CI never saw it because
every job runs `bash install.sh` from a checkout, where BASH_SOURCE IS set.

Use the same non-empty-first guard scripts/install.sh already carries, and
add the proving test the panel installer was missing (T41) — the agent
installer has had its twin since the same fix landed there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 22:42:52 -04:00
Juan Denis 937cbb2b81 feat(setup): capacity-aware install profiles and resource advisory
- install_profile_service: record what install.sh put on the box
  (minimal/standard/full), recommended from detected hardware and
  overridable; thresholds mirrored by recommend_profile in install.sh
- setup wizard: replace the hard ResourceGate/SetupStepTier with
  SetupStepCapacity + SetupStepSecurity
- ResourceAdvisory: inline capacity warning at the point of action
  (WordPress/Docker/Services/Deployments), backed by headroom.fits
- RequiresDocker component for docker-dependent surfaces
- install.sh/update.sh: profile detection + tests
  (test_install.sh, test_update.sh, test_server_capacity.py)
2026-07-31 14:56:37 -04:00
Juan DenisandClaude Opus 4.8 df1b85055d test(install): update node_ready test to the vite 8 Node floor
The installer's node_ready() now gates on Node ^20.19.0 || >=22.12.0 (was >=18),
so the T3 assertion "node_ready true for node v18" was stale and failed CI.
Rewrite T3 to prove the real floor: accepts 20.19+/22.12+ (with npm), rejects
v16/v18/20.17/20.18/21.x/22.11. (Dropped an npm-absent sub-case that can't be
isolated in-harness — command -v npm still finds the runner's real npm.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 20:40:18 -04:00
Juan DenisandClaude Fable 5 7345807b12 test(scripts): chain multi-statement if-subshell assertions (no vacuous passes)
bash suppresses set -e inside any tested-context command list — an
if-condition subshell, or the left side of ||, even with set -Eeuo
pipefail re-armed inside — so multi-statement test bodies only gated
on their LAST statement. Today's cmp-less EL9 CI run proved it: a
byte-identity test passed while its cmp assertions exited 127.

28 blocks across test_update/test_install/test_lib/test_agent_install
converted to explicit && chains so every assertion gates the result.
No test weakened; no vacuous pass was hiding a real script bug (each
newly-gated statement was verified to genuinely succeed).

Verified: Git Bash 171 passed / 0 failed; rockylinux:9 all five
suites 177p/0f; opensuse/leap:15.5 148p/0f (pre-existing python3/tar
skips).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:53:14 -04:00
Juan DenisandClaude Fable 5 b44305d4f7 test(install): survive minimal CI images without cmp/tar; chain if-subshell assertions
EL9/Fedora containers ship without diffutils and openSUSE Leap without
tar, which failed 4 of the 7 cross-distro unit jobs:

- files_identical() in stubs.sh: cmp when available, sha256sum
  (coreutils, everywhere) otherwise
- the two tarball-fixture tests skip cleanly when tar is absent
  (harness's existing skip convention; proven on the other distros)
- the t16/t16r assertion lists are now && chains: bash suppresses
  set -e inside an if-condition subshell, so sequential statements
  only gated on the LAST one — the cmp-less EL9 run proved it by
  passing t16r vacuously while cmp exited 127

Verified in the actual images: rockylinux:9 65p/0f/0s,
opensuse/leap:15.5 61p/0f/4s (was 63p/2f and 61p/2f).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:38:37 -04:00
Juan DenisandClaude Fable 5 502ed19ae0 test(scripts): fresh-box observation loop, failing-stub library, exit-status assertions
The July 2 outage function had a unit test — with a populated fixture
only. This encodes the missing net:

- fresh-minimal-box fixture loop: every observation/discovery/snapshot/
  report function in update.sh (17) and install.sh (16) must survive a
  zero-app box under set -Eeuo pipefail; policy comment requires new
  functions to be appended
- scripts/test/stubs.sh: shared failing-stub factories (systemctl exit
  5, docker fail/empty, curl exit 22, GitHub rate-limit JSON)
- assertion sweep: 43 bare capture-then-compare sites now include exit
  status in the assertion
- best-effort contract test: the run block's real APP_BASELINE ->
  report chain exits 0 on a fresh box AND on a genuine regression
- fix(update): the loop immediately caught report_failure's trailing
  '[ -n $UPDATE_LOG ] && printf' returning 1 when UPDATE_LOG is empty
  (same species as the outage) — converted to if, added to the loop

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 18:30:26 -04:00
Juan DenisandClaude Fable 5 8a2d2ffd83 fix(install): guard host probes, docker bootstrap, swap, release fallback (I11,I13-I19,I22-I24)
- EUID check runs before any host write; friendly non-root halt (I11)
- get.docker.com staged to temp file with retry + pkg fallback (I13)
- df -Pk on the real install target; free guarded for LXC (I14)
- release tag via releases/latest redirect (no API quota) + loud
  source-fallback warning (I15)
- tar/cp failures in fetch_release restore live state and fall back
  to source cleanly (I16)
- svc_enable/svc_start init-system abstraction at service call sites (I17)
- stale slot-A collision guard in ensure_install_layout (I18)
- swap verified via /proc/swaps before claiming active; dd/mkswap
  failures degrade to warn (I19)
- VERSION clobber, telemetry pipefail, stale SSL_MODE rewrite,
  apply_frontend_root end-of-loop && (I22-I24, bonus)
- 22 new proving assertions (61 passed local, 64 on Linux)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 17:33:58 -04:00
Juan DenisandClaude Fable 5 f2e7715479 fix(install): close data-loss re-run + no-domain abort, harden pkg/venv/docker paths (I1-I10,I12,I20,I21)
- no-domain curl-pipe install no longer aborts at the nginx phase (I1);
  blank Enter at the domain prompt no longer aborts interactive installs (I2)
- re-running install.sh over a live install preserves .env and
  backend/instance/ (stash/restore, fixed backend/app detection) (I4, I20)
- symlinked default layout no longer deletes its own venv copy source (I3)
- pkg_add/refresh_pkg_index genuinely warn-and-continue under set -e;
  yum arm drops dnf-only --refresh (I6, I12)
- restore Rocky/RHEL-9 paired openssh+openssl upfront upgrade (I5)
- git installed on demand for source sync (I7); locate_python validates
  venv capability + installs pythonX.Y-venv on Debian-family (I8)
- compose plugin fallback chain docker-compose-plugin -> -v2 -> warn (I9)
- dnf5 config-manager addrepo fallback for Fedora 41+ (I10)
- firewall-phase state writes are best-effort (I21)
- 25 new proving assertions in test_install.sh (40 passed local, 42 on Linux)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 17:13:04 -04:00
Juan DenisandClaude Opus 4.8 31330a09be fix(deploy): make docker-compose.yml frontend-only (backend runs on host)
ServerKit's backend manages the host it runs on -- it creates app dirs under
/var/serverkit/apps, drives the host Docker daemon (one container per app),
and reloads nginx. The default compose containerized the backend, so it ran
as a non-root user into a root-owned /var/serverkit/apps with no host
docker.sock: WordPress/app creation failed with
`Permission denied: /var/serverkit/apps/...`. It also contradicted install.sh,
which runs the backend on the host via systemd and expects compose to bring
up only the frontend.

- docker-compose.yml: frontend-only; the frontend reaches the host backend via
  extra_hosts ["backend:host-gateway"].
- backend/Dockerfile: chown /var/serverkit to appuser (latent EACCES on the
  apps dir for dev/demo container use) + header noting it cannot manage host.
- frontend/nginx.conf: document the backend:host-gateway upstream.
- docs/DEPLOYMENT.md: add a Deployment model note; fix build/start + data paths.
- scripts/test/test_install.sh: T12 guards against re-adding a backend service.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:20:32 -04:00
Juan DenisandClaude Opus 4.8 f744106d64 test(scripts): Phase 4 — vm update/uninstall tests + 7-distro CI matrix
Hardening plan §7. Catches distro-specific regressions before release.

- scripts/test/vm-update.sh: install → forced local update (full blue/green
  deploy/migrate/switch/health) → re-run to confirm the "Already up to date"
  version-gate. Same VM harness contract as vm-install.sh (log + status file).
- scripts/test/vm-uninstall.sh: install + seed data → default uninstall asserts
  data preserved (DB snapshot in backups, /var/lib survives) → reinstall →
  --purge asserts data removed. End-to-end proof of the unified uninstaller.
- scripts-ci.yml: now runs all three serverless suites (update+install+lib),
  lints the new lib/ + test/ files (error severity) and keeps scripts/lib/*
  warning-clean, and expands the cross-distro unit matrix to Ubuntu 22.04/24.04,
  Debian 12, Rocky 9, AlmaLinux 9, Fedora 40, and openSUSE Leap 15.5.
- test_install.sh: +choose_pkg_manager graceful-failure case (7.4); a no-op
  docker stub so the uninstall-matrix cases run where docker isn't installed.
- test_lib.sh: neutralize the ambient container=oci env var (RPM/SUSE images)
  and force an empty PATH for the "no package manager" case — both bugs the new
  matrix surfaced (the tests, not the code).
- README: fixed the stale Fedora box, documented the new VM scripts + suites.

Validated by running the exact CI container logic locally across all seven
images: test_update 13/13, test_install 15-16/0, test_lib 13/13 everywhere.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 01:00:45 -04:00
Juan DenisandClaude Opus 4.8 ff35375b50 feat(install): Phase 3 — multi-distro abstraction, init/env detection, templated unit
Hardening plan §6. Reduces the distro-specific if/else surface and handles
edge environments:

- Dependency manifest (scripts/deps/manifest.yaml): declarative per-family
  package/python/node/docker/init spec. Consumed directly by the Phase 5
  Python bootstrap; install.sh mirrors it inline (it can't parse YAML before
  Python exists in the curl|bash path).

- New sourceable libs (built in parallel, each shellcheck-clean + unit-tested):
  - scripts/lib/pkg.sh  — pkg_detect/refresh/install/is_installed across
    apt/dnf/yum/zypper/pacman/apk (PKG_MGR_OVERRIDE + PKG_DRY_RUN for tests).
  - scripts/lib/init.sh — init_detect + start/stop/enable/disable/reload/
    is_active across systemd/openrc/runit/sysvinit/none (INIT_OVERRIDE/DRY_RUN).
  - scripts/lib/env.sh  — is_container/is_wsl/has_systemd (override-friendly).

- ID_LIKE handling (G5/6.5): install.sh's os_family_from() (now a pure,
  testable function reading ${SERVERKIT_OS_RELEASE}) maps unknown IDs via
  ID_LIKE to debian/rhel/fedora/suse/arch/alpine — rhel before fedora so RHEL
  clones pick the RHEL Docker repo.

- zypper + pacman + apk (G6/6.6): choose_pkg_manager/refresh_pkg_index/pkg_add,
  provision_docker and provision_python gained suse/arch/alpine branches;
  Docker enable/start is now init-aware (systemd OR OpenRC) so it no longer
  aborts on Alpine.

- Templated systemd unit (6.7): serverkit-backend.service → templates/
  serverkit-backend.service.in, rendered (render_service_unit) at install and
  in refresh_config so a custom SERVERKIT_DIR/venv/log path is honored.
  install_service warns-and-skips enable when has_systemd is false (G5).

- nginx TLS floor as a reversible conf.d snippet (6.8): install.sh + update.sh
  prefer /etc/nginx/conf.d/serverkit-tls.conf when nginx.conf doesn't already
  declare ssl_protocols (RHEL/Fedora/SUSE), falling back to the in-place edit
  on Debian/Ubuntu to avoid a 'duplicate ssl_protocols' error. uninstall
  removes only that snippet.

Tests: new scripts/test/test_lib.sh (13) + 4 new test_install.sh cases
(os_family_from, render_service_unit, harden_global_tls A/B). 13+15+10 pass;
shellcheck error+warning clean across all new/changed shell.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:46:38 -04:00
Juan DenisandClaude Opus 4.8 754bfb4a67 feat(install): Phase 1 hardening — firewall automation, py3.11/3.12, unified uninstall
Critical install/update/uninstall hotfixes from the hardening plan
(docs/plans/INSTALLER_UPDATER_UNINSTALLER_HARDENING_PLAN.md §4):

- Firewall automation (G1): new sourceable scripts/lib/firewall.sh detects
  firewalld/ufw/nftables/iptables and opens 80/443; install.sh runs
  configure_firewall, update.sh re-ensures it idempotently. Exactly what we
  open is recorded in /etc/serverkit/install-state.json (scripts/lib/state.sh)
  so uninstall undoes only our rules. Fixes the #1 "installed but unreachable"
  failure on fresh RHEL-family boxes.

- Accept Python 3.11 OR 3.12 (G2): locate_python now iterates
  python3.12/3.11/3 and provision_python installs the distro package
  (python3.11 on Debian 12, 3.12 on Ubuntu/Fedora, 3.11/3.12 on RHEL) before
  ever falling back to a source compile.

- Use $PYTHON_BIN for secret generation instead of a bare python3.

- Distro-first Node 20 (replacing unconditional NodeSource): install the
  distro nodejs+npm, fall back to NodeSource only when it's missing or < 18,
  and validate node>=18 before continuing.

- Unify uninstall (G4): new scripts/lib/uninstall.sh is the single canonical
  teardown routine shared by uninstall.sh and `serverkit uninstall` (they had
  drifted — one ran `down -v`, one didn't). Default preserves user data
  (volumes kept, DB snapshotted to backups); --purge/--delete-volumes removes
  volumes+DB+data dirs; --keep-data preserves /etc + /var/lib + backups;
  --yes/--dry-run added; honors SERVERKIT_DIR. Removes the apt lock-wait
  drop-in on uninstall.

Tests: new scripts/test/test_install.sh (11 cases, source-able install.sh)
covers python/node detection, firewall dry-run + state, and the uninstall
data-handling matrix. Existing test_update.sh still green. shellcheck clean
(error + warning) on all new/changed shell.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:24:39 -04:00