Skip to content

Install profiles, honest capacity advice, and two security fixes - #87

Merged
jhd3197 merged 7 commits into
mainfrom
dev
Aug 2, 2026
Merged

Install profiles, honest capacity advice, and two security fixes#87
jhd3197 merged 7 commits into
mainfrom
dev

Conversation

@jhd3197

@jhd3197 jhd3197 commented Aug 2, 2026

Copy link
Copy Markdown
Owner

The old resource tier looked at a 1-core VPS and told its owner they weren't allowed to create a WordPress site — a strange thing for a self-hosted panel to say to someone about their own hardware. This PR retires that gate and splits it into two questions worth answering separately: what did the installer actually put on this box (the install profile), and how much room is left on it right now (live headroom computed from available memory, not installed memory). install.sh now measures the machine and offers minimal/standard/full — minimal skips Docker entirely, so 512MB boxes and unprivileged LXC guests get a panel that works instead of one that installs cleanly and then OOMs on the first deploy — and the container-dependent pages explain the absence instead of failing at click time. Two security changes ride along: the file manager now refuses panel-internal paths, because on the documented /opt layout a default viewer role holding files.read could walk into the backend .env and leave with JWT_SECRET_KEY (GHSA-rm3m-9mvw-68fh); and a new daily job checks the published advisory feed and tells admins when the running version sits inside a known affected range. The tier label survives for callers that display it, but nothing is gated on it any more — every feature flag is advisory now, since a hard block is wrong the moment the VPS gets resized, and it reads like a paywall on an OSS panel besides.

Contributors

Highlights

  • The installer now asks how much to install, suggests an answer from the detected hardware, and moves on by itself after 15 seconds — a piped curl | bash install takes the suggestion immediately.
  • A Minimal install skips Docker on purpose and stays fully useful: monitoring, domains, certificates, cron and DNS all work, and sudo serverkit update no longer demands a Docker that was never installed.
  • Container, Services and Deployments pages on a Dockerless install now explain what's missing and how to add it, rather than rendering as if they work and erroring on the first click.
  • WordPress site creation is never blocked on server specs again. A constrained server gets an inline warning showing its actual free memory and lets the operator decide.
  • The setup wizard gained a capacity step that reports headroom in plain language ("1.2 GB free — roughly 2 WordPress sites") instead of a tier verdict, plus an optional two-factor enrolment step with downloadable backup codes.
  • Admins get notified when the running panel version falls inside a published security advisory's affected range, and get a one-time follow-up reminder after an update crosses a fix boundary (for example, rotating JWT_SECRET_KEY).
  • The file manager refuses to browse, read, write or download anything inside the panel's own install directory or config directory, for every role including admin.
Technical changes

Security: file manager path protection (GHSA-rm3m-9mvw-68fh)

  • FileService.PROTECTED_ROOTS resolves the panel install dir (two levels up from app/services/) and paths.SERVERKIT_CONFIG_DIR, and is checked inside is_path_allowed before the ALLOWED_ROOTS test — so every operation routed through it (read, write, browse, download, upload) refuses panel-internal paths regardless of role.
  • The match is real_path == root or real_path.startswith(root + os.sep), so a sibling directory sharing a name prefix isn't caught by accident.
  • Root cause: the 1.7.68 RBAC work correctly enforced files.read on read endpoints, but the default viewer role legitimately holds files.read, and on the documented install.sh layout /opt/serverkit sits under the already-allowed root /opt.

Security: advisory feed check

  • New app/services/security_feed_service.py pulls https://serverkit.ai/api/security-feed.json (a cached proxy of the repo's published GitHub Security Advisories) with an 8s timeout.
  • version_in_range() parses GHSA-style ranges (< 1.7.68, >= 1.2.0, < 1.5) with comma-separated clauses ANDed together; an empty or unparseable range matches nothing, so the failure mode is silence rather than a false alarm.
  • Two notification paths: {ghsa}:affected when the running version is inside an advisory range, and {ghsa}:postfix when the previously-seen version was affected, the current one isn't, and the advisory declares a post_fix_action.
  • Dedupe keys, the last-good feed copy and the previously-seen version all live in SystemSettings, so restarts never re-notify and an outbound failure falls back to the cached feed instead of going quiet.
  • Alerts go out via NotificationBusService.send('security.alert', to='admins') with an action path into Settings → System. security_feed.enabled opts out of the outbound call entirely.
  • Registered as builtin.security_feed in _BUILTINS (86400s interval, 600s offset); run_security_feed_check() swallows exceptions so a feed problem can't fail the job runner.

Install profiles

  • New app/services/install_profile_service.py: recommend_profile() (LXC/OpenVZ → minimal, <1.5GB RAM → minimal, <5GB free disk → minimal, ≥4GB/≥4 cores/≥20GB → full, else standard), get_profile() (settings override → SERVERKIT_PROFILE env → standard, unrecognised values logged and ignored rather than raised), set_profile(), and get_profile_info() which reports drift between intent and reality in both directions.
  • get_capabilities() probes live and caches for 60s: _docker_usable() runs docker info --format '{{.ServerVersion}}' instead of trusting shutil.which, because inside LXC the client is frequently present while the daemon has never started.
  • install.sh gains detect_container() (/.dockerenv, /proc/vz without /proc/bc, systemd-detect-virt --container, container= in /proc/1/environ) and recommend_profile() mirroring the Python thresholds in integer shell arithmetic — the comment on both sides says change one, change the other.
  • prompt_for_profile() runs before any provisioning, honours an explicit SERVERKIT_PROFILE, auto-accepts the recommendation when stdin isn't a tty (with SERVERKIT_FORCE_PROMPT=1 as the test hook, matching prompt_for_domain), and warns when an operator overrides a Minimal recommendation upward.
  • provision_docker() and ensure_compose_plugin() early-return on minimal; SERVERKIT_PROFILE is written to .env on fresh installs and rewritten via sed on re-runs, alongside SERVERKIT_SSL_MODE.
  • New provision_hardening() is what makes full more than standard: installs and enables fail2ban (without which the panel's jail management manages nothing) and certbot. Every step is warn-and-continue, and it runs before configure_nginx so the HTTPS attempt finds certbot already there.
  • scripts/update.sh: install_profile() reads SERVERKIT_PROFILE from the active slot's .env and reports standard for anything unrecognised, which is exactly what every pre-profile install is. update_needs_docker() drops docker and docker compose from the preflight requirement list on minimal installs — unless it's a Docker deployment, or Docker is present anyway. have_docker() is split out as its own function so tests can redefine it; restricting PATH to hide Docker would also hide the coreutils install_profile() parses with.

Capacity reporting

  • ResourceTierService reworked around get_headroom(), computed from psutil's available memory minus OS_RESERVE_MB (256MB for page cache, sshd, nginx, journald), returning a fits map against WORKLOAD_FOOTPRINTS_MB plus warnings for LXC/OpenVZ hosts, low disk, and low-RAM boxes with no swap.
  • Headroom is never served from the 1-hour specs cache — it's the number that moves as apps get deployed, so it's recomputed on every call while specs and the tier label stay cached.
  • _describe_headroom() renders the figure as the largest workload that fits, so small boxes degrade to "enough for static sites only" instead of claiming "0 sites".
  • _get_system_specs() now also returns total_memory_gb, swap_mb, disk_free_gb (measured on /var/lib/serverkit/var/lib/docker/) and container. The wizard had been reading total_memory_gb and rendering a blank RAM figure for as long as only ram_gb existed.
  • _get_features_for_tier() is now entirely permissive: wordpress_create is always True and wordpress_create_advised carries the recommendation. can_create_wordpress() returns True for compatibility; new is_wordpress_advised() exposes the advice.
  • New admin-only GET /api/v1/system/capacity (tier info + profile info + recommended_profile) and PUT /api/v1/system/capacity/profile. /system/resource-tier is left in place for callers that only want the label.
  • DoctorService._expected_services() drops the Docker probe only when it's both unexpected (minimal profile) and absent, so an operator who added Docker later — or a standard install that lost it — still gets probed for real drift; skipped services get an explanatory ok row, and any exception during profile resolution falls back to probing.
  • fleet_doctor_service.DOCTOR_UNITS is deliberately left profile-unaware, with a comment explaining why: an install profile describes the panel host, and telling a Dockerless agent apart from a stopped-Docker agent needs the agent to report "unit not installed", which is a change in the separate serverkit-agent repo.

Frontend

  • ResourceGate.jsx deleted. ResourceAdvisory.jsx replaces it as an inline warning driven by the backend's headroom.fits map, rendering nothing when the workload fits and never blocking anything.
  • New RequiresDocker.jsx wraps Docker, Services and Deployments. It fails open — when capacity hasn't loaded or the viewer isn't an admin, canHostApps is true and children render normally, so a capability probe can never hide a working page.
  • SetupStepTierSetupStepCapacity: seeded from the installed profile rather than the recommendation, renders profile cards from the backend's own PROFILE_DESCRIPTIONS so installer and panel can't disagree, and only writes on an actual change (a failed write is swallowed rather than stranding someone mid-wizard).
  • New SetupStepSecurity with offer → enrol → backup-codes stages, plus an already stage keyed off get2FAStatus() so stepping back and forward doesn't re-offer an enrolment that already landed server-side. Wizard is 4 steps → 5; SetupStepSummary now reports profile, headroom summary and 2FA state instead of a tier name.
  • ResourceTierContext reads /system/capacity and exposes headroom, profile, profiles, capabilities, recommendedProfile, profileDrift, canHostApps and isWordPressAdvised, replacing canCreateWordPress.
  • SCSS: new components/_requires-docker.scss imported in main.scss; the .resource-gate* blocks in pages/_wordpress.scss are replaced by the much smaller .resource-advisory; pages/_setup-wizard.scss gains capacity and security step styles.

Docs

  • docs/INSTALLATION.md documents the profile table, SERVERKIT_PROFILE / SERVERKIT_PROFILE_TIMEOUT, the "a profile is a starting point, not a licence tier" framing, and what a minimal install changes about updates, Docker-dependent pages and the health doctor.

jhd3197 and others added 4 commits July 31, 2026 14:56
- 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)
…-rm3m-9mvw-68fh)

The 1.7.68 RBAC fix enforced files.read on read endpoints, but the default
viewer role legitimately has files.read=true, and on the documented
install.sh layout the panel install dir (/opt/serverkit) sits under the
allowed root /opt. A viewer could read the backend .env, steal
JWT_SECRET_KEY / SERVERKIT_ENCRYPTION_KEY, and forge admin sessions.

FileService now carries PROTECTED_ROOTS (the panel install dir and
SERVERKIT_CONFIG_DIR), checked inside is_path_allowed before the
ALLOWED_ROOTS test, so every file-manager operation (read, write, browse,
download, upload, ...) refuses panel-internal paths for every role.

Regression tests: protected paths rejected by is_path_allowed; viewer
read/download of the backend .env returns 403; allowed roots keep working.

Co-authored-by: CaptBoykin <CaptBoykin@users.noreply.github.com>
New builtin job security-feed (daily) pulls the normalized advisory feed
from serverkit.ai/api/security-feed.json (a cached proxy of the repo's
published GitHub Security Advisories) and:

- notifies admins (security.alert, critical) when the running panel
  version falls inside an advisory's affected range, once per advisory;
- fires a one-time post-fix reminder after an upgrade crosses a fix
  boundary when the advisory declares a "## Post-upgrade actions" section
  (e.g. rotate JWT_SECRET_KEY after GHSA-rm3m-9mvw-68fh).

Dedupe keys, the last-good feed copy, and the previously-seen version live
in SystemSettings, so restarts never re-notify and outbound failures serve
the cached feed. Opt out via the security_feed.enabled setting. Privacy
page on serverkit.ai updated to disclose the daily call.
Copilot AI review requested due to automatic review settings August 2, 2026 22:44

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Not ready to approve

The file-manager security fix still has a path-calculation + allowed-root prefix issue that can leave panel-internal paths reachable.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.

Pull request overview

This PR replaces the old “resource tier” hard-gating with install profiles + live capacity headroom, and ships two security-oriented improvements: file-manager path protection for panel-internal secrets and a daily GHSA advisory-feed check that notifies admins when the running version is affected.

Changes:

  • Add installer/profile plumbing (minimal/standard/full) and make updates/doctor behavior profile-aware (notably allowing updates on minimal without Docker).
  • Rework capacity reporting around live “headroom” (available memory) and convert frontend UX from blocking gates to inline advisories / Docker requirement explainers.
  • Add security feed checking as a builtin job and expand file-manager restrictions to block panel-internal paths.
File summaries
File Description
VERSION Bumps panel version to 1.7.77.
scripts/update.sh Makes Docker a conditional preflight dependency based on install profile.
scripts/test/test_update.sh Adds unit coverage for profile-driven Docker preflight logic.
scripts/test/test_install.sh Adds unit coverage for installer profile recommendation + skip/provision behavior.
install.sh Adds profile prompting/recommendation, Docker skipping for minimal, and hardening provisioning for full.
frontend/src/styles/pages/_wordpress.scss Replaces blocked “resource gate” styling with inline “resource advisory” styling.
frontend/src/styles/pages/_setup-wizard.scss Adds styles for new capacity + security (2FA) wizard steps.
frontend/src/styles/main.scss Imports the new requires-docker component stylesheet.
frontend/src/styles/components/_requires-docker.scss Adds styling for Docker-missing explainer view.
frontend/src/pages/WordPress.jsx Removes hard gate and adds inline capacity advisory at point of action.
frontend/src/pages/Setup.jsx Expands wizard to 5 steps (adds Capacity + Security steps).
frontend/src/pages/Services.jsx Wraps page with RequiresDocker to explain Dockerless installs.
frontend/src/pages/Docker.jsx Wraps page with RequiresDocker to explain Dockerless installs.
frontend/src/pages/Deployments.jsx Wraps page with RequiresDocker to explain Dockerless installs.
frontend/src/contexts/ResourceTierContext.jsx Switches to admin-only /system/capacity and exposes headroom/profile/capabilities fields.
frontend/src/components/setup/SetupStepTier.jsx Removes the old tier-based wizard step.
frontend/src/components/setup/SetupStepSummary.jsx Updates summary to report profile + headroom + 2FA state.
frontend/src/components/setup/SetupStepSecurity.jsx Adds optional 2FA enrollment flow with backup codes during setup.
frontend/src/components/setup/SetupStepCapacity.jsx Adds capacity/profile confirmation step driven by backend profile descriptions + headroom.
frontend/src/components/ResourceGate.jsx Deletes the old blocking resource gate component.
frontend/src/components/ResourceAdvisory.jsx Adds a non-blocking inline capacity warning component.
frontend/src/components/RequiresDocker.jsx Adds a Docker-missing explainer wrapper for container-dependent pages.
docs/INSTALLATION.md Documents install profiles, env knobs, and minimal-install behavior changes.
backend/tests/test_server_capacity.py Adds backend test coverage for headroom, advisory flags, profiles, endpoints, and doctor behavior.
backend/tests/test_security_feed.py Adds backend test coverage for advisory feed range matching + notifications + dedupe.
backend/tests/test_jobs.py Updates builtin schedule expectations to include the security feed job.
backend/tests/test_files_rbac.py Adds regression tests ensuring panel-internal paths are blocked via file manager endpoints.
backend/app/services/security_feed_service.py Implements GHSA advisory feed fetch/caching + affected/post-fix notification logic.
backend/app/services/resource_tier_service.py Reworks tier service to include live headroom + advisory-only feature flags.
backend/app/services/install_profile_service.py Adds install profile resolution, recommendation, capabilities probe, and drift reporting.
backend/app/services/fleet_doctor_service.py Documents why fleet doctor remains profile-unaware.
backend/app/services/file_service.py Adds PROTECTED_ROOTS exclusion to prevent panel-internal file access.
backend/app/services/doctor_service.py Skips Docker checks only when minimal profile + Docker absent; otherwise probes as drift.
backend/app/jobs/builtin_handlers.py Registers and runs the daily security feed check as a builtin job.
backend/app/api/system.py Adds admin-only /system/capacity and /system/capacity/profile endpoints.
Review details

Suppressed comments (1)

backend/app/services/file_service.py:72

  • The allowed-root check uses real_path.startswith(root), which is vulnerable to prefix collisions (e.g. /optmalicious/... will match the allowed root /opt). Since this method gates file manager access, it should use the same real_path == root or real_path.startswith(root + os.sep) pattern used for PROTECTED_ROOTS.
            real_path = os.path.realpath(path)
            if any(real_path == root or real_path.startswith(root + os.sep)
                   for root in cls.PROTECTED_ROOTS):
                return False
            return any(real_path.startswith(root) for root in cls.ALLOWED_ROOTS)
  • Files reviewed: 35/35 changed files
  • Comments generated: 1
  • Review effort level: Lite

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

Comment thread backend/app/services/file_service.py Outdated
Comment on lines +43 to +45
_BACKEND_DIR = os.path.realpath(os.path.join(os.path.dirname(__file__), '..'))
_INSTALL_DIR = os.path.realpath(os.path.join(_BACKEND_DIR, '..'))
PROTECTED_ROOTS = [
jhd3197 and others added 3 commits August 2, 2026 18:56
Copilot review on the GHSA-rm3m-9mvw-68fh patch caught two issues:

- _BACKEND_DIR/_INSTALL_DIR were one level too shallow (backend/app and
  backend/ instead of backend/ and the install root), which left
  <install>/.env and frontend/dist reachable on the deployed layout —
  the exact PoC path from the advisory. _BACKEND_DIR stays in
  PROTECTED_ROOTS so flat layouts (code at /app) are covered too.
- The ALLOWED_ROOTS check used a bare startswith, so e.g. /optfoo
  matched the /opt root. Both checks now require an exact match or a
  path-boundary prefix.

New tests pin the level math (the existing cases computed the expected
layout independently and passed on Windows for the wrong reason) and
simulate the deployed layout with the install dir inside an allowed root.
@jhd3197
jhd3197 merged commit 0877d38 into main Aug 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants