From 937cbb2b8125b8e6178eab1f8cd45f1b43ae984d Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Fri, 31 Jul 2026 14:56:37 -0400 Subject: [PATCH 1/6] 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) --- backend/app/api/system.py | 51 ++ backend/app/services/doctor_service.py | 43 +- backend/app/services/fleet_doctor_service.py | 8 + .../app/services/install_profile_service.py | 251 ++++++++++ backend/app/services/resource_tier_service.py | 311 ++++++++++--- backend/tests/test_server_capacity.py | 435 ++++++++++++++++++ docs/INSTALLATION.md | 33 ++ frontend/src/components/RequiresDocker.jsx | 61 +++ frontend/src/components/ResourceAdvisory.jsx | 61 +++ frontend/src/components/ResourceGate.jsx | 124 ----- .../components/setup/SetupStepCapacity.jsx | 216 +++++++++ .../components/setup/SetupStepSecurity.jsx | 305 ++++++++++++ .../src/components/setup/SetupStepSummary.jsx | 29 +- .../src/components/setup/SetupStepTier.jsx | 127 ----- frontend/src/contexts/ResourceTierContext.jsx | 27 +- frontend/src/pages/Deployments.jsx | 3 + frontend/src/pages/Docker.jsx | 3 + frontend/src/pages/Services.jsx | 3 + frontend/src/pages/Setup.jsx | 23 +- frontend/src/pages/WordPress.jsx | 24 +- .../styles/components/_requires-docker.scss | 76 +++ frontend/src/styles/main.scss | 1 + frontend/src/styles/pages/_setup-wizard.scss | 194 +++++++- frontend/src/styles/pages/_wordpress.scss | 250 +--------- install.sh | 245 ++++++++++ scripts/test/test_install.sh | 182 ++++++++ scripts/test/test_update.sh | 91 ++++ scripts/update.sh | 51 +- 28 files changed, 2642 insertions(+), 586 deletions(-) create mode 100644 backend/app/services/install_profile_service.py create mode 100644 backend/tests/test_server_capacity.py create mode 100644 frontend/src/components/RequiresDocker.jsx create mode 100644 frontend/src/components/ResourceAdvisory.jsx delete mode 100644 frontend/src/components/ResourceGate.jsx create mode 100644 frontend/src/components/setup/SetupStepCapacity.jsx create mode 100644 frontend/src/components/setup/SetupStepSecurity.jsx delete mode 100644 frontend/src/components/setup/SetupStepTier.jsx create mode 100644 frontend/src/styles/components/_requires-docker.scss diff --git a/backend/app/api/system.py b/backend/app/api/system.py index 2e5968f5..16f8dd2e 100644 --- a/backend/app/api/system.py +++ b/backend/app/api/system.py @@ -7,6 +7,7 @@ from app.models import User from app.services.system_service import SystemService from app.services.resource_tier_service import ResourceTierService +from app.services import install_profile_service from app.services.site_domain_service import SiteDomainService from app.utils.domain import is_valid_canonical_domain from app.utils.version import get_panel_version, get_install_dir @@ -283,6 +284,56 @@ def get_resource_tier(): return jsonify(tier_info), 200 +@system_bp.route('/capacity', methods=['GET']) +@jwt_required() +def get_capacity(): + """ + Server capacity: live headroom, install profile, and probed capabilities. + + This is the endpoint the setup wizard and the dashboard should read. + /resource-tier remains for callers that only want the tier label; this one + additionally answers "what did we install" and "can this box host apps". + """ + current_user_id = get_jwt_identity() + user = User.query.get(current_user_id) + + if not user or user.role != 'admin': + return jsonify({'error': 'Admin access required'}), 403 + + force_refresh = request.args.get('refresh', 'false').lower() == 'true' + tier_info = ResourceTierService.get_tier_info(force_refresh=force_refresh) + profile_info = install_profile_service.get_profile_info(force_refresh=force_refresh) + + return jsonify({ + **tier_info, + **profile_info, + 'recommended_profile': install_profile_service.recommend_profile( + tier_info['specs'] + ), + }), 200 + + +@system_bp.route('/capacity/profile', methods=['PUT']) +@jwt_required() +def set_capacity_profile(): + """Record an operator's profile change made after install.""" + current_user_id = get_jwt_identity() + user = User.query.get(current_user_id) + + if not user or user.role != 'admin': + return jsonify({'error': 'Admin access required'}), 403 + + data = request.get_json() or {} + profile = data.get('profile') + + try: + install_profile_service.set_profile(profile, user_id=user.id) + except ValueError as e: + return jsonify({'error': str(e)}), 400 + + return jsonify(install_profile_service.get_profile_info()), 200 + + @system_bp.route('/time', methods=['GET']) @jwt_required() def get_server_time(): diff --git a/backend/app/services/doctor_service.py b/backend/app/services/doctor_service.py index 96dbb0c4..59b5e437 100644 --- a/backend/app/services/doctor_service.py +++ b/backend/app/services/doctor_service.py @@ -127,16 +127,57 @@ def _drift_checks(cls): r.get('detail') or 'Check could not run.')) return checks + @classmethod + def _expected_services(cls): + """ + Core services this install is actually supposed to be running. + + The minimal install profile ships without Docker on purpose, so probing + for it there would report a permanent, unfixable failure on a perfectly + healthy box. Docker is only dropped when it is genuinely both + unexpected *and* absent — an operator who installed it later, or a + standard/full install that has lost it (real drift), still gets probed. + """ + from app.services import install_profile_service as ips + + services = [] + for name in CORE_SERVICES: + if name != 'docker': + services.append(name) + continue + try: + unexpected = ips.get_profile() == ips.PROFILE_MINIMAL + absent = not ips.get_capabilities().get('docker') + except Exception: # noqa: BLE001 + # Never let profile resolution suppress a real health check. + services.append(name) + continue + if not (unexpected and absent): + services.append(name) + return services + + @classmethod + def _skipped_service_checks(cls, probed): + """An 'ok' row explaining each core service deliberately not probed.""" + return [ + _check(f'service.{name}', f'{name} service', 'ok', + 'Not installed — this install uses the Minimal profile. ' + 'Add it from Settings to enable app hosting.') + for name in CORE_SERVICES if name not in probed + ] + @classmethod def _service_checks(cls): checks = [] + probed = cls._expected_services() if not sys.platform.startswith('linux'): for name in CORE_SERVICES: checks.append(_check(f'service.{name}', f'{name} service', 'warn', 'unsupported on this host')) return checks + checks.extend(cls._skipped_service_checks(probed)) from app.utils.system import ServiceControl - for name in CORE_SERVICES: + for name in probed: try: active = ServiceControl.is_active(name) except Exception as e: # noqa: BLE001 diff --git a/backend/app/services/fleet_doctor_service.py b/backend/app/services/fleet_doctor_service.py index 6fdf36fd..cda68f59 100644 --- a/backend/app/services/fleet_doctor_service.py +++ b/backend/app/services/fleet_doctor_service.py @@ -43,6 +43,14 @@ # Core systemd units the fleet doctor probes on every agent (mirrors the # panel-host doctor's CORE_SERVICES). +# +# Deliberately NOT profile-aware, unlike DoctorService._expected_services(): +# an install profile describes the *panel* host, and these units live on remote +# agent boxes that were never provisioned by this panel's installer. Telling a +# Dockerless agent apart from a stopped-Docker agent needs the agent to report +# "unit not installed" rather than just active=false, which is a change in the +# serverkit-agent repo — panel↔agent protocol changes are not atomic with this +# one. Until then a Dockerless fleet member reports a Docker failure. DOCTOR_UNITS = ('nginx', 'docker') # Capability an agent advertises when it implements the batched doctor:probe diff --git a/backend/app/services/install_profile_service.py b/backend/app/services/install_profile_service.py new file mode 100644 index 00000000..a9fc1742 --- /dev/null +++ b/backend/app/services/install_profile_service.py @@ -0,0 +1,251 @@ +""" +Install Profile Service + +An install profile records *what install.sh put on the box* — not what the box +is allowed to do. It is a starting point, chosen once from detected hardware +and always overridable by the operator, and everything a profile skips stays +installable later from Settings. Nothing here is a licence check. + + minimal Panel + nginx + SQLite. No Docker, no Node toolchain. + For 512MB-1GB boxes, LXC/OpenVZ containers where Docker will not + run anyway, and hosts where Docker is managed elsewhere. + Monitoring, domains, certificates, cron and DNS all still work. + + standard + Docker and the compose plugin. Can host applications. + + full + the recommended extension set for the chosen use cases and the + source-build toolchain. + +The recommendation thresholds below are mirrored by ``recommend_profile`` in +install.sh. They have to agree: the installer picks a profile before the panel +exists, and the wizard then shows the operator what was picked. Change one, +change the other. +""" + +import logging +import os +import shutil +import subprocess +import time + +logger = logging.getLogger(__name__) + +# The Docker probe shells out and can block for its full timeout on a host with +# a wedged daemon — exactly the host most likely to be asking. Cache the answer +# briefly so a page load never pays that twice, but keep the window short +# enough that installing Docker shows up without a restart. +_CAPABILITY_TTL_SECONDS = 60 +_capability_cache = {'data': None, 'timestamp': 0} + +PROFILE_MINIMAL = 'minimal' +PROFILE_STANDARD = 'standard' +PROFILE_FULL = 'full' + +VALID_PROFILES = (PROFILE_MINIMAL, PROFILE_STANDARD, PROFILE_FULL) + +DEFAULT_PROFILE = PROFILE_STANDARD + +# Settings key holding an operator override applied after install (e.g. they +# installed minimal, then added Docker from Settings later). +PROFILE_SETTING_KEY = 'install.profile' + +# Recommendation thresholds — keep in sync with install.sh recommend_profile(). +MINIMAL_MAX_RAM_GB = 1.5 +MINIMAL_MIN_DISK_GB = 5 +FULL_MIN_RAM_GB = 4 +FULL_MIN_CORES = 4 +FULL_MIN_DISK_GB = 20 + +PROFILE_DESCRIPTIONS = { + PROFILE_MINIMAL: { + 'label': 'Minimal', + 'summary': 'Panel only — monitoring, domains, certificates, cron and DNS.', + 'installs': ['ServerKit panel', 'nginx', 'SQLite'], + 'skips': ['Docker', 'Node.js build toolchain'], + 'suited_for': '512MB-1GB RAM, or containers where Docker cannot run.', + }, + PROFILE_STANDARD: { + 'label': 'Standard', + 'summary': 'Everything in Minimal, plus Docker so the server can host apps.', + 'installs': ['ServerKit panel', 'nginx', 'SQLite', 'Docker', 'compose plugin'], + 'skips': ['Use-case extensions (pick them in the next step)'], + 'suited_for': '2GB RAM and up.', + }, + PROFILE_FULL: { + 'label': 'Full', + 'summary': 'Everything in Standard, plus the daemons the panel\'s security ' + 'and certificate features depend on.', + 'installs': [ + 'ServerKit panel', 'nginx', 'SQLite', 'Docker', 'compose plugin', + 'fail2ban (powers jail management)', 'certbot (automatic HTTPS)', + ], + 'skips': [], + 'suited_for': '4GB RAM, 4 cores and 20GB disk or better.', + }, +} + + +def recommend_profile(specs): + """ + Recommend a profile from detected hardware. + + Mirrors install.sh's recommend_profile(). Deliberately conservative: a box + that is recommended Minimal can still be pushed to Standard by the + operator, but silently defaulting a 700MB VPS to a Docker install produces + an OOM during the first deploy. + + Args: + specs: dict from ResourceTierService._get_system_specs() + + Returns: + str: one of VALID_PROFILES + """ + ram_gb = specs.get('ram_gb') or 0 + cores = specs.get('cpu_cores') or 1 + disk_free_gb = specs.get('disk_free_gb') + container = specs.get('container') + + # Unprivileged LXC/OpenVZ frequently cannot run Docker at all. Recommending + # Standard there produces an install that looks fine and then fails on the + # first deploy. + if container in ('lxc', 'openvz'): + return PROFILE_MINIMAL + + if ram_gb < MINIMAL_MAX_RAM_GB: + return PROFILE_MINIMAL + + if disk_free_gb is not None and disk_free_gb < MINIMAL_MIN_DISK_GB: + return PROFILE_MINIMAL + + if ( + ram_gb >= FULL_MIN_RAM_GB + and cores >= FULL_MIN_CORES + and (disk_free_gb is None or disk_free_gb >= FULL_MIN_DISK_GB) + ): + return PROFILE_FULL + + return PROFILE_STANDARD + + +def get_profile(): + """ + The profile this install is running under. + + Resolution order: operator override in settings, then the value install.sh + wrote to the environment, then the default. An unrecognised value falls + back to the default rather than raising — a hand-edited .env should not + take the panel down. + """ + try: + from app.services.settings_service import SettingsService + override = SettingsService.get(PROFILE_SETTING_KEY) + if override in VALID_PROFILES: + return override + except Exception as e: + # Called before the DB is ready during early boot. + logger.debug(f'Settings unavailable for install profile: {e}') + + env_profile = (os.environ.get('SERVERKIT_PROFILE') or '').strip().lower() + if env_profile in VALID_PROFILES: + return env_profile + + if env_profile: + logger.warning( + f'Unrecognised SERVERKIT_PROFILE={env_profile!r}; ' + f'falling back to {DEFAULT_PROFILE}.' + ) + return DEFAULT_PROFILE + + +def set_profile(profile, user_id=None): + """Record an operator's profile change (e.g. after adding Docker later).""" + if profile not in VALID_PROFILES: + raise ValueError(f'Unknown profile: {profile}') + + from app.services.settings_service import SettingsService + SettingsService.set(PROFILE_SETTING_KEY, profile, user_id=user_id) + return profile + + +def _binary_present(name): + return shutil.which(name) is not None + + +def _docker_usable(): + """ + Whether Docker is present *and* the daemon answers. + + An installed binary proves nothing — inside LXC the client is often present + while the daemon has never started, which is exactly the case the Minimal + profile exists for. + """ + if not _binary_present('docker'): + return False + try: + result = subprocess.run( + ['docker', 'info', '--format', '{{.ServerVersion}}'], + capture_output=True, + timeout=5, + ) + return result.returncode == 0 + except Exception as e: + logger.debug(f'Docker probe failed: {e}') + return False + + +def get_capabilities(force_refresh=False): + """ + What this install can actually do right now, probed live. + + The profile says what was *intended*; this says what is *true*. They drift + — an operator can apt-install Docker on a Minimal box, and a Standard box + can have a broken daemon. Cached for _CAPABILITY_TTL_SECONDS because the + Docker probe can block. + """ + now = time.time() + if ( + not force_refresh + and _capability_cache['data'] is not None + and (now - _capability_cache['timestamp']) < _CAPABILITY_TTL_SECONDS + ): + return dict(_capability_cache['data']) + + docker = _docker_usable() + capabilities = { + 'docker': docker, + 'node': _binary_present('node'), + 'nginx': _binary_present('nginx'), + 'git': _binary_present('git'), + # The panel is only useful for hosting apps if containers work. + 'can_host_apps': docker, + } + + _capability_cache['data'] = capabilities + _capability_cache['timestamp'] = now + return dict(capabilities) + + +def get_profile_info(force_refresh=False): + """Profile, its description, live capabilities, and any drift between them.""" + profile = get_profile() + capabilities = get_capabilities(force_refresh=force_refresh) + + drift = [] + if profile in (PROFILE_STANDARD, PROFILE_FULL) and not capabilities['docker']: + drift.append( + 'This install is on the ' + f'{PROFILE_DESCRIPTIONS[profile]["label"]} profile but Docker is not ' + 'responding, so apps cannot be deployed.' + ) + if profile == PROFILE_MINIMAL and capabilities['docker']: + drift.append( + 'Docker is available even though this install is on the Minimal ' + 'profile — app hosting will work.' + ) + + return { + 'profile': profile, + 'profiles': PROFILE_DESCRIPTIONS, + 'capabilities': capabilities, + 'drift': drift, + } diff --git a/backend/app/services/resource_tier_service.py b/backend/app/services/resource_tier_service.py index 43ed226d..e5acc453 100644 --- a/backend/app/services/resource_tier_service.py +++ b/backend/app/services/resource_tier_service.py @@ -1,50 +1,94 @@ """ -Resource Tier Service +Resource Tier / Server Capacity Service -Determines server resource tier based on CPU and RAM, and controls -feature availability accordingly. +Answers two questions that the panel has to keep separate: -Tiers: -- lite: 1 CPU core OR <2GB RAM - Limited features (no WordPress creation) -- standard: 2-3 cores, 2-4GB RAM - Most features enabled -- performance: 4+ cores, >4GB RAM - All features enabled +1. "What did we install?" — the install profile (minimal/standard/full). Decided + once by install.sh, recorded in config, and readable from + ``install_profile_service``. It is a starting point, never a permanent SKU. + +2. "What can this box still hold?" — headroom. This is live: it changes every + time the operator deploys something, and it is what the UI should actually + surface. + +The original three-bucket tier (lite/standard/performance) conflated the two. +It classified a server once from total CPU/RAM and then permanently disabled a +button, which is both wrong the moment the VPS is resized and reads like a +paywall on an OSS panel. ``tier`` is still derived and returned for the callers +that display it, but every feature flag is now advisory — the panel reports +headroom and lets the operator decide. """ +import logging +import os +import shutil import time + import psutil -# Module-level cache with 1-hour TTL +logger = logging.getLogger(__name__) + +# Module-level cache. Specs and the tier label are stable enough to cache for an +# hour; headroom is recomputed on every call because it is the number that moves. _tier_cache = { 'data': None, 'timestamp': 0, 'ttl': 3600 # 1 hour } +# Memory the panel refuses to hand out to workloads. ServerKit's own process +# floor is ~250MB (gunicorn + SQLAlchemy + the Python interpreter's ~76MB +# baseline); psutil's `available` already excludes that once the panel is up, so +# only the OS margin is subtracted from headroom. PANEL_FOOTPRINT_MB is kept for +# install-time and reporting callers that need the number explicitly. +PANEL_FOOTPRINT_MB = 256 +# Kernel page cache, sshd, nginx, journald. Handing an app the last free +# megabyte is how a box becomes unreachable, so this is never offered. +OS_RESERVE_MB = 256 + +# Rough steady-state RSS per workload kind, used to translate a headroom number +# into something an operator can act on ("about one WordPress site"). These are +# deliberately conservative — over-promising capacity is worse than +# under-promising it. +WORKLOAD_FOOTPRINTS_MB = { + 'static': 32, + 'node': 192, + 'python': 192, + 'database': 384, + 'wordpress': 512, +} + +# Disk below this on the data volume means image pulls and backups will fail +# before RAM ever becomes the binding constraint. +LOW_DISK_THRESHOLD_GB = 5 + class ResourceTierService: - """Service for determining server resource tier and feature availability.""" + """Server capacity reporting: specs, live headroom, and an advisory tier.""" TIER_LITE = 'lite' TIER_STANDARD = 'standard' TIER_PERFORMANCE = 'performance' - # Minimum requirements for WordPress + # Comfortable-minimum guidance for WordPress. Advisory only — nothing in + # the backend refuses a create based on these. MIN_CORES_FOR_WORDPRESS = 2 MIN_RAM_GB_FOR_WORDPRESS = 2 @classmethod def get_tier_info(cls, force_refresh=False): """ - Get resource tier information including specs, tier, and feature permissions. + Get capacity information: specs, live headroom, tier label, advisories. Args: - force_refresh: If True, bypass cache and recalculate + force_refresh: If True, bypass the specs cache and re-read hardware Returns: dict: { 'tier': 'lite'|'standard'|'performance', - 'specs': {'cpu_cores': int, 'ram_gb': float, 'ram_bytes': int}, - 'features': {'wordpress_create': bool, ...}, + 'specs': {...}, + 'headroom': {...}, + 'features': {...}, # advisory flags, all currently permissive 'cached': bool } """ @@ -57,127 +101,260 @@ def get_tier_info(cls, force_refresh=False): ) if cache_valid and not force_refresh: - return {**_tier_cache['data'], 'cached': True} + cached = _tier_cache['data'] + # Headroom is never served from cache — it is the live number. + return { + **cached, + 'headroom': cls.get_headroom(cached['specs']), + 'cached': True, + } - # Get system specs specs = cls._get_system_specs() - - # Calculate tier tier = cls._calculate_tier(specs) - - # Get features for this tier features = cls._get_features_for_tier(tier, specs) - result = { + _tier_cache['data'] = { 'tier': tier, 'specs': specs, 'features': features, - 'cached': False } + _tier_cache['timestamp'] = current_time - # Update cache - _tier_cache['data'] = { + return { 'tier': tier, 'specs': specs, - 'features': features + 'features': features, + 'headroom': cls.get_headroom(specs), + 'cached': False, } - _tier_cache['timestamp'] = current_time - - return result @classmethod def _get_system_specs(cls): """ - Get system CPU and RAM specifications using psutil. + Get CPU, RAM, disk, swap and virtualisation facts about this host. - Returns: - dict: {'cpu_cores': int, 'ram_gb': float, 'ram_bytes': int} + Both ``ram_gb`` and ``total_memory_gb`` are returned: the original key + plus the name the setup wizard was already reading. The wizard rendered + a blank RAM figure for as long as only ``ram_gb`` existed. """ cpu_cores = psutil.cpu_count(logical=False) or psutil.cpu_count(logical=True) or 1 memory = psutil.virtual_memory() ram_bytes = memory.total ram_gb = round(ram_bytes / (1024 ** 3), 2) + swap_mb = 0 + try: + swap_mb = int(psutil.swap_memory().total / (1024 ** 2)) + except Exception as e: # pragma: no cover - platform dependent + logger.debug(f'Could not read swap: {e}') + + disk_free_gb = None + try: + disk_free_gb = round(shutil.disk_usage(cls._data_path()).free / (1024 ** 3), 2) + except Exception as e: # pragma: no cover - platform dependent + logger.debug(f'Could not read disk usage: {e}') + return { 'cpu_cores': cpu_cores, 'ram_gb': ram_gb, - 'ram_bytes': ram_bytes + 'total_memory_gb': ram_gb, + 'ram_bytes': ram_bytes, + 'swap_mb': swap_mb, + 'disk_free_gb': disk_free_gb, + 'container': cls._detect_container(), + } + + @staticmethod + def _data_path(): + """Volume to measure free space on — where images and backups land.""" + for path in ('/var/lib/serverkit', '/var/lib/docker', '/'): + if os.path.isdir(path): + return path + return os.getcwd() + + @staticmethod + def _detect_container(): + """ + Identify container virtualisation, or None on bare metal / a full VM. + + This matters more than core count on small hosts: Docker frequently + cannot run inside an unprivileged LXC or OpenVZ container at all, so a + box can look adequate on paper and still be unable to host anything. + """ + try: + if os.path.exists('/.dockerenv'): + return 'docker' + if os.path.isdir('/proc/vz') and not os.path.isdir('/proc/bc'): + return 'openvz' + # systemd-nspawn and LXC both advertise themselves here. + with open('/proc/1/environ', 'rb') as fh: + environ = fh.read().decode('utf-8', 'replace') + for entry in environ.split('\0'): + if entry.startswith('container='): + return entry.split('=', 1)[1] or 'container' + except Exception: + # /proc is absent on Windows dev boxes and restricted in some + # sandboxes — "unknown" is the honest answer, not an error. + return None + return None + + @classmethod + def get_headroom(cls, specs=None): + """ + How much room is actually left for workloads, right now. + + Unlike the tier label this is computed from *available* memory rather + than installed memory, so it reflects what the operator has already + deployed. Returns the raw numbers plus a plain-language summary and a + per-workload fit map the UI can render without doing arithmetic. + + Returns: + dict: { + 'ram_available_mb': int, + 'ram_for_apps_mb': int, + 'disk_free_gb': float|None, + 'swap_mb': int, + 'fits': {workload: bool}, + 'summary': str, + 'warnings': [str], + } + """ + if specs is None: + specs = cls._get_system_specs() + + try: + available_mb = int(psutil.virtual_memory().available / (1024 ** 2)) + except Exception as e: # pragma: no cover - platform dependent + logger.debug(f'Could not read available memory: {e}') + available_mb = 0 + + ram_for_apps_mb = max(0, available_mb - OS_RESERVE_MB) + + fits = { + name: ram_for_apps_mb >= need + for name, need in WORKLOAD_FOOTPRINTS_MB.items() + } + + warnings = [] + if specs.get('container') in ('lxc', 'openvz'): + warnings.append( + f"This host is an {specs['container'].upper()} container. Docker often " + 'cannot run in one — if container features fail, that is why.' + ) + disk_free_gb = specs.get('disk_free_gb') + if disk_free_gb is not None and disk_free_gb < LOW_DISK_THRESHOLD_GB: + warnings.append( + f'Only {disk_free_gb} GB of disk free. Image pulls and backups ' + 'need room before RAM becomes the limit.' + ) + if specs.get('ram_gb', 0) < 2 and not specs.get('swap_mb'): + warnings.append( + 'No swap on a low-RAM box. Builds are likely to be OOM-killed.' + ) + + return { + 'ram_available_mb': available_mb, + 'ram_for_apps_mb': ram_for_apps_mb, + 'panel_footprint_mb': PANEL_FOOTPRINT_MB, + 'os_reserve_mb': OS_RESERVE_MB, + 'disk_free_gb': disk_free_gb, + 'swap_mb': specs.get('swap_mb', 0), + 'fits': fits, + 'summary': cls._describe_headroom(ram_for_apps_mb), + 'warnings': warnings, } + @classmethod + def _describe_headroom(cls, ram_for_apps_mb): + """ + Turn a megabyte count into something an operator can act on. + + "1.2 GB free — about one WordPress site" beats "you are Lite tier", + which says nothing about what they can actually do next. + """ + if ram_for_apps_mb < WORKLOAD_FOOTPRINTS_MB['static']: + return 'No room for new workloads right now.' + + gb = round(ram_for_apps_mb / 1024, 1) + amount = f'{gb} GB' if ram_for_apps_mb >= 1024 else f'{ram_for_apps_mb} MB' + + # Describe capacity with the largest workload that fits, so the phrasing + # degrades gracefully on small boxes instead of claiming "0 sites". + if ram_for_apps_mb >= WORKLOAD_FOOTPRINTS_MB['wordpress']: + count = ram_for_apps_mb // WORKLOAD_FOOTPRINTS_MB['wordpress'] + unit = 'WordPress site' if count == 1 else 'WordPress sites' + return f'{amount} free — roughly {count} {unit}.' + if ram_for_apps_mb >= WORKLOAD_FOOTPRINTS_MB['node']: + count = ram_for_apps_mb // WORKLOAD_FOOTPRINTS_MB['node'] + unit = 'small app' if count == 1 else 'small apps' + return f'{amount} free — roughly {count} {unit}, but not WordPress.' + return f'{amount} free — enough for static sites only.' + @classmethod def _calculate_tier(cls, specs): """ - Determine tier based on system specs. + Derive the advisory tier label from installed specs. + + Retained for display and for callers that still read ``tier``. Nothing + is gated on it any more — see ``_get_features_for_tier``. - Tier determination: - Lite: 1 core OR <2GB RAM - Performance: 4+ cores AND >4GB RAM - - Standard: Everything else (2-3 cores, 2-4GB RAM) - - Args: - specs: dict from _get_system_specs() - - Returns: - str: 'lite', 'standard', or 'performance' + - Standard: everything else """ cpu_cores = specs['cpu_cores'] ram_gb = specs['ram_gb'] - # Lite tier: single core or very low RAM if cpu_cores < 2 or ram_gb < 2: return cls.TIER_LITE - # Performance tier: high resources if cpu_cores >= 4 and ram_gb > 4: return cls.TIER_PERFORMANCE - # Standard tier: moderate resources return cls.TIER_STANDARD @classmethod def _get_features_for_tier(cls, tier, specs): """ - Get feature permissions based on tier and specs. - - Args: - tier: The calculated tier string - specs: System specs dict + Advisory capability flags. - Returns: - dict: Feature permission flags + Every flag is permissive. ``*_advised`` companions carry the + recommendation so the UI can warn at the point of action instead of + hiding the action, which is the difference between protecting an + operator and overruling them. """ - # WordPress creation requires minimum resources - can_create_wordpress = ( + wordpress_advised = ( specs['cpu_cores'] >= cls.MIN_CORES_FOR_WORDPRESS and specs['ram_gb'] >= cls.MIN_RAM_GB_FOR_WORDPRESS ) return { - 'wordpress_create': can_create_wordpress, - 'wordpress_manage': True, # Always allow managing existing sites - 'docker': True, # Docker available on all tiers - 'databases': True, # Database management available on all tiers + 'wordpress_create': True, + 'wordpress_create_advised': wordpress_advised, + 'wordpress_manage': True, + 'docker': True, + 'databases': True, } @classmethod def can_create_wordpress(cls): """ - Quick check if WordPress site creation is allowed. + Whether WordPress creation is permitted. Always True. - Returns: - bool: True if WordPress creation is permitted + Kept so existing callers keep working; use + ``is_wordpress_advised()`` for the resource recommendation. """ + return True + + @classmethod + def is_wordpress_advised(cls): + """Whether this server comfortably meets the WordPress recommendation.""" tier_info = cls.get_tier_info() - return tier_info['features']['wordpress_create'] + return tier_info['features']['wordpress_create_advised'] @classmethod def get_minimum_requirements(cls): - """ - Get the minimum requirements for WordPress creation. - - Returns: - dict: {'cpu_cores': int, 'ram_gb': int} - """ + """Recommended minimum specs for WordPress.""" return { 'cpu_cores': cls.MIN_CORES_FOR_WORDPRESS, 'ram_gb': cls.MIN_RAM_GB_FOR_WORDPRESS diff --git a/backend/tests/test_server_capacity.py b/backend/tests/test_server_capacity.py new file mode 100644 index 00000000..33e64317 --- /dev/null +++ b/backend/tests/test_server_capacity.py @@ -0,0 +1,435 @@ +"""Server capacity: live headroom, install profiles, and the de-gated features. + +Covers the shift away from the three-bucket tier gate. The old service +classified a box once from total CPU/RAM and then permanently disabled a +button; these tests pin the replacement — headroom computed from *available* +memory, an install profile that says what was provisioned, and feature flags +that advise instead of block. +""" +import os +from unittest.mock import patch + +import pytest + +from app.services import install_profile_service as ips +from app.services.resource_tier_service import ( + OS_RESERVE_MB, + WORKLOAD_FOOTPRINTS_MB, + ResourceTierService, +) + + +@pytest.fixture(autouse=True) +def _clear_caches(): + """Specs cache for an hour and capabilities for a minute; tests must not + inherit each other's readings.""" + from app.services import resource_tier_service as rts + + def reset(): + rts._tier_cache['data'] = None + rts._tier_cache['timestamp'] = 0 + ips._capability_cache['data'] = None + ips._capability_cache['timestamp'] = 0 + + reset() + yield + reset() + + +def _specs(ram_gb=4, cores=4, disk_free_gb=50, swap_mb=1024, container=None): + return { + 'cpu_cores': cores, + 'ram_gb': ram_gb, + 'total_memory_gb': ram_gb, + 'ram_bytes': int(ram_gb * 1024 ** 3), + 'swap_mb': swap_mb, + 'disk_free_gb': disk_free_gb, + 'container': container, + } + + +# ── specs shape ────────────────────────────────────────────────────────────── + +def test_specs_expose_both_ram_key_names(): + """The wizard reads total_memory_gb; the original service only set ram_gb, + so RAM rendered blank on every setup screen. Both must be present.""" + specs = ResourceTierService._get_system_specs() + assert specs['ram_gb'] == specs['total_memory_gb'] + assert specs['total_memory_gb'] > 0 + + +def test_specs_include_disk_swap_and_container_keys(): + specs = ResourceTierService._get_system_specs() + for key in ('disk_free_gb', 'swap_mb', 'container', 'cpu_cores'): + assert key in specs + + +# ── headroom ───────────────────────────────────────────────────────────────── + +def test_headroom_subtracts_the_os_reserve_from_available(): + """Headroom is available memory minus the OS margin — never total memory. + Using total is what made the old tier wrong the moment anything deployed.""" + available_mb = 4096 + with patch('psutil.virtual_memory') as vm: + vm.return_value.available = available_mb * 1024 ** 2 + headroom = ResourceTierService.get_headroom(_specs()) + + assert headroom['ram_available_mb'] == available_mb + assert headroom['ram_for_apps_mb'] == available_mb - OS_RESERVE_MB + + +def test_headroom_never_goes_negative_on_an_exhausted_box(): + with patch('psutil.virtual_memory') as vm: + vm.return_value.available = 8 * 1024 ** 2 # 8MB left + headroom = ResourceTierService.get_headroom(_specs()) + + assert headroom['ram_for_apps_mb'] == 0 + assert headroom['fits']['wordpress'] is False + assert headroom['fits']['static'] is False + + +def test_headroom_fit_map_tracks_the_workload_footprints(): + # Exactly enough for WordPress once the OS reserve is taken off the top. + available_mb = WORKLOAD_FOOTPRINTS_MB['wordpress'] + OS_RESERVE_MB + with patch('psutil.virtual_memory') as vm: + vm.return_value.available = available_mb * 1024 ** 2 + headroom = ResourceTierService.get_headroom(_specs()) + + assert headroom['fits']['wordpress'] is True + assert headroom['fits']['node'] is True + + # One megabyte short and WordPress stops fitting, but a Node app still does. + with patch('psutil.virtual_memory') as vm: + vm.return_value.available = (available_mb - 1) * 1024 ** 2 + headroom = ResourceTierService.get_headroom(_specs()) + + assert headroom['fits']['wordpress'] is False + assert headroom['fits']['node'] is True + + +@pytest.mark.parametrize('for_apps_mb,expected_fragment', [ + (0, 'No room'), + (64, 'static sites only'), + (400, 'but not WordPress'), + (600, 'WordPress site'), + (1600, 'WordPress sites'), +]) +def test_headroom_summary_is_actionable_prose(for_apps_mb, expected_fragment): + """"1.2 GB free — roughly 2 WordPress sites" beats "you are Lite tier".""" + assert expected_fragment in ResourceTierService._describe_headroom(for_apps_mb) + + +def test_headroom_warns_about_containers_low_disk_and_missing_swap(): + with patch('psutil.virtual_memory') as vm: + vm.return_value.available = 4096 * 1024 ** 2 + headroom = ResourceTierService.get_headroom( + _specs(ram_gb=1, disk_free_gb=2, swap_mb=0, container='lxc') + ) + + joined = ' '.join(headroom['warnings']).lower() + assert 'lxc' in joined + assert 'disk free' in joined + assert 'swap' in joined + + +def test_headroom_is_never_served_from_cache(): + """Specs may be cached for an hour; the live number must not be.""" + # get_tier_info() also reads .total for the specs, so the mock has to carry + # a real number there or _calculate_tier compares against a MagicMock. + with patch('psutil.virtual_memory') as vm: + vm.return_value.total = 8 * 1024 ** 3 + vm.return_value.available = 4096 * 1024 ** 2 + first = ResourceTierService.get_tier_info() + assert first['cached'] is False + + with patch('psutil.virtual_memory') as vm: + vm.return_value.total = 8 * 1024 ** 3 + vm.return_value.available = 512 * 1024 ** 2 + second = ResourceTierService.get_tier_info() + + assert second['cached'] is True # specs came from cache... + # ...but headroom was recomputed against the new reading. + assert second['headroom']['ram_available_mb'] == 512 + assert second['headroom']['ram_for_apps_mb'] != first['headroom']['ram_for_apps_mb'] + + +# ── de-gating ──────────────────────────────────────────────────────────────── + +def test_wordpress_creation_is_never_blocked_on_resources(): + """A hard gate reads like a paywall on an OSS panel and is wrong the moment + the VPS is resized. The flag stays permissive; the advice moves to + wordpress_create_advised.""" + tiny = _specs(ram_gb=0.5, cores=1) + features = ResourceTierService._get_features_for_tier( + ResourceTierService.TIER_LITE, tiny + ) + + assert features['wordpress_create'] is True + assert features['wordpress_create_advised'] is False + assert ResourceTierService.can_create_wordpress() is True + + +def test_wordpress_is_advised_on_an_adequate_box(): + features = ResourceTierService._get_features_for_tier( + ResourceTierService.TIER_STANDARD, _specs(ram_gb=4, cores=2) + ) + assert features['wordpress_create_advised'] is True + + +@pytest.mark.parametrize('ram_gb,cores,expected', [ + (0.5, 1, ResourceTierService.TIER_LITE), + (1, 4, ResourceTierService.TIER_LITE), + (4, 2, ResourceTierService.TIER_STANDARD), + (8, 4, ResourceTierService.TIER_PERFORMANCE), +]) +def test_tier_label_still_derives_for_display(ram_gb, cores, expected): + """Tier survives as a display label — nothing gates on it any more.""" + assert ResourceTierService._calculate_tier(_specs(ram_gb, cores)) == expected + + +# ── install profiles ───────────────────────────────────────────────────────── + +@pytest.mark.parametrize('specs,expected', [ + # Mirrors T31 in scripts/test/test_install.sh — the installer and the panel + # must not disagree about what a given box should get. + (_specs(ram_gb=0.7, cores=8, disk_free_gb=100), ips.PROFILE_MINIMAL), + (_specs(ram_gb=2, cores=2, disk_free_gb=50), ips.PROFILE_STANDARD), + (_specs(ram_gb=8, cores=4, disk_free_gb=50), ips.PROFILE_FULL), + (_specs(ram_gb=8, cores=8, disk_free_gb=2), ips.PROFILE_MINIMAL), + (_specs(ram_gb=8, cores=8, container='lxc'), ips.PROFILE_MINIMAL), + (_specs(ram_gb=8, cores=8, container='openvz'), ips.PROFILE_MINIMAL), +]) +def test_recommend_profile_matches_the_installer_thresholds(specs, expected): + assert ips.recommend_profile(specs) == expected + + +def test_recommend_profile_tolerates_missing_facts(): + """A host where disk/container could not be read must still get an answer.""" + assert ips.recommend_profile({}) in ips.VALID_PROFILES + assert ips.recommend_profile( + {'ram_gb': 8, 'cpu_cores': 4, 'disk_free_gb': None} + ) == ips.PROFILE_FULL + + +def test_get_profile_reads_the_installer_env(app): + with app.app_context(): + with patch.dict(os.environ, {'SERVERKIT_PROFILE': 'minimal'}): + assert ips.get_profile() == ips.PROFILE_MINIMAL + + +def test_get_profile_falls_back_on_a_hand_edited_env(app): + """A typo in .env must not take the panel down.""" + with app.app_context(): + with patch.dict(os.environ, {'SERVERKIT_PROFILE': 'banana'}): + assert ips.get_profile() == ips.DEFAULT_PROFILE + + +def test_set_profile_overrides_the_env(app): + with app.app_context(): + with patch.dict(os.environ, {'SERVERKIT_PROFILE': 'minimal'}): + ips.set_profile(ips.PROFILE_STANDARD) + assert ips.get_profile() == ips.PROFILE_STANDARD + + +def test_set_profile_rejects_unknown_values(app): + with app.app_context(): + with pytest.raises(ValueError): + ips.set_profile('enterprise-plus') + + +def test_capabilities_report_docker_unusable_when_the_daemon_is_dead(app): + """An installed binary proves nothing — inside LXC the client is often + present while the daemon has never started.""" + with app.app_context(): + with patch.object(ips, '_binary_present', return_value=True), \ + patch('subprocess.run') as run: + run.return_value.returncode = 1 + caps = ips.get_capabilities() + + assert caps['docker'] is False + assert caps['can_host_apps'] is False + + +def test_docker_probe_is_cached_between_calls(app): + """`docker info` can block for its full timeout on exactly the wedged host + most likely to be asking, so a page load must not pay for it twice.""" + with app.app_context(): + with patch.object(ips, '_binary_present', return_value=True), \ + patch('subprocess.run') as run: + run.return_value.returncode = 0 + ips.get_capabilities() + ips.get_capabilities() + assert run.call_count == 1 + + ips.get_capabilities(force_refresh=True) + assert run.call_count == 2 + + +def test_profile_info_flags_drift_between_intent_and_reality(app): + with app.app_context(): + with patch.dict(os.environ, {'SERVERKIT_PROFILE': 'standard'}), \ + patch.object(ips, 'get_capabilities', return_value={ + 'docker': False, 'node': True, 'nginx': True, + 'git': True, 'can_host_apps': False, + }): + info = ips.get_profile_info() + + assert info['profile'] == ips.PROFILE_STANDARD + assert any('Docker is not responding' in d for d in info['drift']) + + +def test_every_profile_is_described_for_the_wizard(): + """The wizard renders card bodies straight from the backend, so a profile + without a description would render an empty card.""" + for profile in ips.VALID_PROFILES: + described = ips.PROFILE_DESCRIPTIONS[profile] + assert described['label'] + assert described['summary'] + assert described['installs'] + assert 'skips' in described + assert described['suited_for'] + + +# ── doctor must not fail a healthy Dockerless box ──────────────────────────── + +def test_doctor_skips_docker_on_a_minimal_install(app): + """A minimal install has no Docker by design; probing for it reported a + permanent, unrepairable 'Not running.' failure on a healthy box.""" + from app.services.doctor_service import DoctorService + + with app.app_context(): + with patch.dict(os.environ, {'SERVERKIT_PROFILE': 'minimal'}), \ + patch.object(ips, 'get_capabilities', return_value={ + 'docker': False, 'node': True, 'nginx': True, + 'git': True, 'can_host_apps': False, + }): + probed = DoctorService._expected_services() + skipped = DoctorService._skipped_service_checks(probed) + + assert 'docker' not in probed + assert 'nginx' in probed + assert [c['status'] for c in skipped] == ['ok'] + assert 'Minimal profile' in skipped[0]['detail'] + + +def test_doctor_still_probes_docker_when_it_was_installed_later(app): + """Minimal profile but Docker present = the operator added it; it is in use + and a stopped daemon is a real failure.""" + from app.services.doctor_service import DoctorService + + with app.app_context(): + with patch.dict(os.environ, {'SERVERKIT_PROFILE': 'minimal'}), \ + patch.object(ips, 'get_capabilities', return_value={ + 'docker': True, 'node': True, 'nginx': True, + 'git': True, 'can_host_apps': True, + }): + probed = DoctorService._expected_services() + + assert 'docker' in probed + + +def test_doctor_still_fails_a_standard_install_missing_docker(app): + """Standard promised Docker, so its absence is drift and must surface.""" + from app.services.doctor_service import DoctorService + + with app.app_context(): + with patch.dict(os.environ, {'SERVERKIT_PROFILE': 'standard'}), \ + patch.object(ips, 'get_capabilities', return_value={ + 'docker': False, 'node': True, 'nginx': True, + 'git': True, 'can_host_apps': False, + }): + probed = DoctorService._expected_services() + + assert 'docker' in probed + + +def test_doctor_probes_everything_when_profile_resolution_breaks(app): + """Profile lookup must never be able to suppress a real health check.""" + from app.services.doctor_service import DoctorService + + with app.app_context(): + with patch.object(ips, 'get_profile', side_effect=RuntimeError('boom')): + probed = DoctorService._expected_services() + + assert 'docker' in probed + assert 'nginx' in probed + + +def test_docker_stays_repairable_even_when_not_probed(app): + """_restart_service gates on the static CORE_SERVICES allowlist, so an + operator who installs Docker later can still have the doctor restart it.""" + from app.services.doctor_service import CORE_SERVICES + + assert 'docker' in CORE_SERVICES + + +# ── endpoints ──────────────────────────────────────────────────────────────── + +def test_capacity_endpoint_returns_headroom_and_profile(client, auth_headers): + resp = client.get('/api/v1/system/capacity', headers=auth_headers) + assert resp.status_code == 200 + + body = resp.get_json() + assert body['profile'] in ips.VALID_PROFILES + assert body['recommended_profile'] in ips.VALID_PROFILES + assert 'summary' in body['headroom'] + assert 'fits' in body['headroom'] + assert body['specs']['total_memory_gb'] > 0 + assert body['capabilities']['can_host_apps'] in (True, False) + # De-gated: the API must never report creation as forbidden. + assert body['features']['wordpress_create'] is True + + +def test_capacity_endpoint_requires_admin(client, app): + from app import db + from app.models import User + from flask_jwt_extended import create_access_token + from werkzeug.security import generate_password_hash + + with app.app_context(): + user = User( + email='dev@test.local', + username='devuser', + password_hash=generate_password_hash('x'), + role=User.ROLE_DEVELOPER, + is_active=True, + ) + db.session.add(user) + db.session.commit() + token = create_access_token(identity=user.id) + + resp = client.get( + '/api/v1/system/capacity', + headers={'Authorization': f'Bearer {token}'}, + ) + assert resp.status_code == 403 + + +def test_capacity_endpoint_rejects_anonymous(client): + assert client.get('/api/v1/system/capacity').status_code == 401 + + +def test_profile_can_be_changed_through_the_api(client, auth_headers): + resp = client.put( + '/api/v1/system/capacity/profile', + json={'profile': 'minimal'}, + headers=auth_headers, + ) + assert resp.status_code == 200 + assert resp.get_json()['profile'] == ips.PROFILE_MINIMAL + + resp = client.put( + '/api/v1/system/capacity/profile', + json={'profile': 'enterprise-plus'}, + headers=auth_headers, + ) + assert resp.status_code == 400 + + +def test_resource_tier_endpoint_still_works_for_existing_callers(client, auth_headers): + resp = client.get('/api/v1/system/resource-tier', headers=auth_headers) + assert resp.status_code == 200 + + body = resp.get_json() + assert body['tier'] in ('lite', 'standard', 'performance') + assert 'headroom' in body diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index c919a939..dc5fb501 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -22,11 +22,38 @@ services automatically. It uses an atomic blue/green layout: `/opt/serverkit` is a symlink to either `/opt/serverkit-a` or `/opt/serverkit-b`, so failed updates can roll back instantly. +### Install profiles + +The installer measures the machine and asks how much to install. Answer within +15 seconds or accept the suggestion; a piped (`curl | bash`) install takes the +suggestion immediately. + +| Profile | Provisions | Suited for | +|---------|-----------|------------| +| `minimal` | Panel, nginx, SQLite. **No Docker.** | 512MB–1GB boxes, LXC/OpenVZ guests where Docker cannot run, or hosts where Docker is managed elsewhere. Monitoring, domains, certificates, cron and DNS all work. | +| `standard` | Adds Docker and the compose plugin | 2GB RAM and up. This is the default. | +| `full` | Adds `fail2ban` (powers the panel's jail management) and `certbot` (automatic HTTPS) | 4GB RAM, 4 cores, 20GB disk and up. | + +**A profile is a starting point, not a licence tier.** Nothing is permanently +locked: anything a profile skips can be installed later from the panel, and the +panel probes what is actually available at runtime rather than trusting the +recorded profile. + +On a `minimal` install: + +- Container-dependent pages (Containers, Services, Deployments) explain that + Docker is missing and how to add it, instead of failing on first use. +- `sudo serverkit update` does **not** require Docker. Install Docker later and + updates start validating it again automatically. +- The health doctor does not report the absent Docker service as a failure. + ### Install options | Variable | Purpose | |----------|---------| | `PANEL_DOMAIN=panel.example.com` | Set the panel domain and attempt Let's Encrypt | +| `SERVERKIT_PROFILE=minimal\|standard\|full` | Pick the install profile and skip the prompt | +| `SERVERKIT_PROFILE_TIMEOUT=15` | Seconds to wait at the profile prompt before taking the suggestion | | `SERVERKIT_SKIP_SSL=1` | Skip HTTPS/certbot entirely | | `INSTALL_FROM_RELEASE=1` | Install from the latest GitHub release tarball instead of cloning source | | `SERVERKIT_VERSION=v1.7.0` | Pin a specific release version | @@ -39,6 +66,12 @@ Example with a domain: curl -fsSL https://serverkit.ai/install.sh | sudo PANEL_DOMAIN=panel.example.com bash ``` +Example on a small VPS, skipping Docker: + +```bash +curl -fsSL https://serverkit.ai/install.sh | sudo SERVERKIT_PROFILE=minimal bash +``` + Example offline install: ```bash diff --git a/frontend/src/components/RequiresDocker.jsx b/frontend/src/components/RequiresDocker.jsx new file mode 100644 index 00000000..c0db13d1 --- /dev/null +++ b/frontend/src/components/RequiresDocker.jsx @@ -0,0 +1,61 @@ +import { Link } from 'react-router-dom'; +import { Boxes, Terminal } from 'lucide-react'; +import { useResourceTier } from '../contexts/ResourceTierContext'; + +/** + * Explains a Dockerless install instead of letting the page fail at click time. + * + * The Minimal install profile ships without Docker on purpose. Everything that + * hosts a workload — containers, services, deployments — needs it, and without + * this the pages render as if they work and then error on the first action. + * + * Fails open: when capacity has not loaded, or the viewer is not an admin (the + * context only fetches for admins), `canHostApps` is true and children render + * as normal. A capability probe must never be able to hide a working page. + */ +const RequiresDocker = ({ children, what = 'This page' }) => { + const { canHostApps, loading, profile, profiles } = useResourceTier(); + + if (loading || canHostApps) return children; + + const profileLabel = profiles?.[profile]?.label || 'Minimal'; + + return ( +
+
+ +
+ +

Docker isn't available

+ +

+ {what} needs Docker to run containers, and this server was + installed with the {profileLabel} profile, which + leaves it out. Nothing is locked — adding Docker turns this page + on. +

+ +
+
+ + Install it on the server, then restart ServerKit: +
+ + curl -fsSL https://get.docker.com | sh + + + sudo systemctl restart serverkit + +
+ +

+ ServerKit re-checks for Docker automatically. Monitoring, domains, + certificates, cron and DNS keep working without it — see{' '} + Settings → System for this + install's profile. +

+
+ ); +}; + +export default RequiresDocker; diff --git a/frontend/src/components/ResourceAdvisory.jsx b/frontend/src/components/ResourceAdvisory.jsx new file mode 100644 index 00000000..1ce468a9 --- /dev/null +++ b/frontend/src/components/ResourceAdvisory.jsx @@ -0,0 +1,61 @@ +import { AlertTriangle } from 'lucide-react'; +import { useResourceTier } from '../contexts/ResourceTierContext'; + +// Approximate steady-state RSS per workload, mirroring WORKLOAD_FOOTPRINTS_MB +// in backend/app/services/resource_tier_service.py. Only used for the copy — +// the fit decision itself comes from the backend's headroom.fits map. +const WORKLOAD_LABELS = { + wordpress: { name: 'A WordPress site', needsMb: 512 }, + database: { name: 'A database', needsMb: 384 }, + node: { name: 'A Node app', needsMb: 192 }, + python: { name: 'A Python app', needsMb: 192 }, +}; + +/** + * Inline capacity warning shown at the point of action. + * + * This deliberately does not block anything. The panel's job is to tell the + * operator what their server can carry, not to overrule them on their own + * hardware — a hard gate reads like a paywall and is wrong the moment the VPS + * is resized. Renders nothing when the workload comfortably fits. + */ +const ResourceAdvisory = ({ workload = 'wordpress' }) => { + const { headroom, loading } = useResourceTier(); + + if (loading || !headroom) return null; + + const fits = headroom.fits?.[workload]; + if (fits !== false) return null; + + const meta = WORKLOAD_LABELS[workload] || { + name: 'This workload', + needsMb: null, + }; + const free = headroom.ram_for_apps_mb; + + return ( +
+ +
+
+ Tight on memory — {headroom.summary} +
+

+ {meta.name} typically needs about {meta.needsMb} MB and this + server has {free} MB free for workloads. You can still create + one; expect it to be slow or to get OOM-killed under load. + Adding RAM or swap fixes it. +

+ {headroom.warnings?.length > 0 && ( +
    + {headroom.warnings.map((warning) => ( +
  • {warning}
  • + ))} +
+ )} +
+
+ ); +}; + +export default ResourceAdvisory; diff --git a/frontend/src/components/ResourceGate.jsx b/frontend/src/components/ResourceGate.jsx deleted file mode 100644 index c158160a..00000000 --- a/frontend/src/components/ResourceGate.jsx +++ /dev/null @@ -1,124 +0,0 @@ -import { AlertTriangle, Cpu, HardDrive, Server, ArrowUpCircle } from 'lucide-react'; -import { useResourceTier } from '../contexts/ResourceTierContext'; - -const ResourceGate = ({ children, feature = 'wordpress_create' }) => { - const { - tier, - specs, - features, - loading, - isLiteTier, - canCreateWordPress - } = useResourceTier(); - - // While loading, show nothing or a skeleton - if (loading) { - return children; - } - - // Check if the specific feature is blocked - const isBlocked = feature === 'wordpress_create' && !canCreateWordPress; - - if (!isBlocked) { - return children; - } - - // Minimum requirements - const minCores = 2; - const minRamGb = 2; - - return ( -
-
-
- -
- -

Server Resources Insufficient

- -

- WordPress site creation requires more server resources than currently available. - Your server is classified as a Lite tier server. -

- -
-
-
- - Your Server -
-
-
- - CPU Cores - - {specs?.cpu_cores || '?'} - -
-
- - RAM - - {specs?.ram_gb || '?'} GB - -
-
-
- -
- -
- -
-
- - Minimum Required -
-
-
- - CPU Cores - {minCores}+ -
-
- - RAM - {minRamGb}+ GB -
-
-
-
- -
-

Recommendation

-

- Upgrade your server to at least {minCores} CPU cores and{' '} - {minRamGb}GB RAM to enable WordPress site creation. - A Standard or Performance tier server - is recommended for optimal WordPress performance. -

-
- -
-
-
Lite
-
1 core or <2GB RAM
-
WordPress: Blocked
-
-
-
Standard
-
2-3 cores, 2-4GB RAM
-
WordPress: Allowed
-
-
-
Performance
-
4+ cores, >4GB RAM
-
WordPress: Allowed
-
-
-
-
- ); -}; - -export default ResourceGate; diff --git a/frontend/src/components/setup/SetupStepCapacity.jsx b/frontend/src/components/setup/SetupStepCapacity.jsx new file mode 100644 index 00000000..14758730 --- /dev/null +++ b/frontend/src/components/setup/SetupStepCapacity.jsx @@ -0,0 +1,216 @@ +import { useState } from 'react'; +import { useResourceTier } from '../../contexts/ResourceTierContext'; +import { Check, X, AlertTriangle, Loader, Cpu, MemoryStick, HardDrive } from 'lucide-react'; +import api from '../../services/api'; + +// Display order. The card bodies themselves come from the backend so the +// installer and the panel can never disagree about what a profile contains. +const PROFILE_ORDER = ['minimal', 'standard', 'full']; + +const SetupStepCapacity = ({ useCases, onComplete }) => { + const { + specs, + headroom, + profile, + profiles, + capabilities, + recommendedProfile, + profileDrift, + loading, + } = useResourceTier(); + + // Seeded from what was actually installed, not from the recommendation — + // this step confirms reality first and offers a change second. + const [selected, setSelected] = useState(null); + const [saving, setSaving] = useState(false); + + const activeProfile = selected || profile || recommendedProfile; + + if (loading) { + return ( +
+
+ +
+
+ ); + } + + async function handleContinue() { + // Only write when the operator actually changed something. + if (selected && selected !== profile) { + setSaving(true); + try { + await api.request('/system/capacity/profile', { + method: 'PUT', + body: { profile: selected }, + }); + } catch { + // A failed preference write must not strand someone in the + // wizard — the profile is advisory and editable in Settings. + } finally { + setSaving(false); + } + } + onComplete(); + } + + function renderSpecs() { + if (!specs) return null; + const items = [ + { + icon: Cpu, + label: `${specs.cpu_cores} core${specs.cpu_cores > 1 ? 's' : ''}`, + }, + { + icon: MemoryStick, + label: `${specs.total_memory_gb} GB RAM`, + }, + ]; + if (specs.disk_free_gb != null) { + items.push({ icon: HardDrive, label: `${specs.disk_free_gb} GB disk free` }); + } + return ( +
+ {items.map(({ icon: Icon, label }) => ( + + + {label} + + ))} + {specs.container && ( + + {specs.container} container + + )} +
+ ); + } + + // Selecting a profile richer than what is installed is allowed, but the + // panel cannot apt-install Docker from inside the wizard — so say plainly + // what is still needed instead of pretending the choice took effect. + const needsDocker = + (activeProfile === 'standard' || activeProfile === 'full') && + capabilities?.docker === false; + + const warnings = headroom?.warnings || []; + + return ( +
+

What this server can hold

+

+ We measured your hardware. Nothing here is a locked plan — anything + skipped can be installed later from Settings. +

+ +
+
+ {headroom?.summary || 'Measuring available capacity...'} +
+ {renderSpecs()} +
+ + {warnings.map((warning) => ( +
+ +
{warning}
+
+ ))} + + {profileDrift?.map((note) => ( +
+ +
{note}
+
+ ))} + +
+ {PROFILE_ORDER.filter((id) => profiles?.[id]).map((id) => { + const info = profiles[id]; + const isActive = activeProfile === id; + const isInstalled = profile === id; + const isRecommended = recommendedProfile === id; + + return ( + + ); + })} +
+ + {needsDocker && ( +
+ +
+ Docker is not installed or not responding, so app hosting stays + off until it is. We'll remember this choice — install Docker + and restart ServerKit, or re-run the installer with{' '} + SERVERKIT_PROFILE={activeProfile}. +
+
+ )} + + {useCases?.includes('wordpress') && headroom?.fits?.wordpress === false && ( +
+ +
+ You picked WordPress, but there is only{' '} + {headroom.ram_for_apps_mb} MB free and a site needs about + 512 MB. You can still create one — expect it to be slow, or + add RAM or swap first. +
+
+ )} + +
+ +
+
+ ); +}; + +export default SetupStepCapacity; diff --git a/frontend/src/components/setup/SetupStepSecurity.jsx b/frontend/src/components/setup/SetupStepSecurity.jsx new file mode 100644 index 00000000..569f189f --- /dev/null +++ b/frontend/src/components/setup/SetupStepSecurity.jsx @@ -0,0 +1,305 @@ +import { useState, useEffect } from 'react'; +import { ShieldCheck, Copy, Download, AlertTriangle, Check, Loader } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import api from '../../services/api'; + +// Enrolment is a three-beat flow. Offer is the default so someone who just +// wants a dashboard is one click from moving on — 2FA is offered here because +// this is the only moment we know the operator is at a keyboard with their +// phone, not because it is mandatory. +const STAGE_OFFER = 'offer'; +const STAGE_ENROLL = 'enroll'; +const STAGE_CODES = 'codes'; +const STAGE_ALREADY = 'already'; + +const CODE_LENGTH = 6; + +const SetupStepSecurity = ({ onComplete }) => { + const [stage, setStage] = useState(STAGE_OFFER); + const [setupData, setSetupData] = useState(null); + const [code, setCode] = useState(''); + const [backupCodes, setBackupCodes] = useState([]); + const [savedCodes, setSavedCodes] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + + // Stepping back to Capacity and forward again re-mounts this step, but the + // enrolment already landed server-side — re-offering it would just 400. + useEffect(() => { + let active = true; + api.get2FAStatus() + .then((status) => { + if (active && status?.enabled) setStage(STAGE_ALREADY); + }) + .catch(() => { + // Status is a convenience; the offer path still works without it. + }); + return () => { + active = false; + }; + }, []); + + async function handleEnable() { + setBusy(true); + setError(''); + try { + const data = await api.initiate2FASetup(); + setSetupData(data); + setStage(STAGE_ENROLL); + } catch (err) { + setError(err.message || 'Could not start two-factor setup.'); + } finally { + setBusy(false); + } + } + + async function handleConfirm() { + if (code.length !== CODE_LENGTH) { + setError(`Enter the ${CODE_LENGTH}-digit code from your app.`); + return; + } + setBusy(true); + setError(''); + try { + const result = await api.confirm2FASetup(code); + setBackupCodes(result.backup_codes || []); + setStage(STAGE_CODES); + } catch (err) { + setError(err.message || 'That code did not match. Try the next one.'); + } finally { + setBusy(false); + } + } + + function copyCodes() { + navigator.clipboard?.writeText(backupCodes.join('\n')); + setSavedCodes(true); + } + + function downloadCodes() { + const body = [ + 'ServerKit backup codes', + '', + 'Each code works once, in place of your authenticator app.', + 'Store them somewhere you can reach without this server.', + '', + ...backupCodes, + ].join('\n'); + + const url = URL.createObjectURL(new Blob([body], { type: 'text/plain' })); + const link = document.createElement('a'); + link.href = url; + link.download = 'serverkit-backup-codes.txt'; + link.click(); + URL.revokeObjectURL(url); + setSavedCodes(true); + } + + if (stage === STAGE_ALREADY) { + return ( +
+

Two-factor is on

+

+ This account already has two-factor authentication enabled. You + can regenerate backup codes or turn it off from Settings. +

+ +
+
+ +
+
+
+ Two-factor authentication active +
+

+ You'll be asked for a code from your authenticator + app the next time you sign in. +

+
+
+ +
+ +
+
+ ); + } + + if (stage === STAGE_OFFER) { + return ( +
+

Protect this account

+

+ This panel can restart services, open firewall ports and read your + databases. Two-factor authentication means a stolen password alone + is not enough to do any of that. +

+ +
+
+ +
+
+
+ Two-factor authentication +
+

+ Takes about thirty seconds with any authenticator app — + 1Password, Aegis, Google Authenticator. You'll get + backup codes in case you lose the device. +

+
+
+ + {error && ( +
+ +
{error}
+
+ )} + +
+ + +
+
+ ); + } + + if (stage === STAGE_ENROLL) { + return ( +
+

Scan this code

+

+ Open your authenticator app, scan the QR code, then enter the + six-digit code it shows. +

+ +
+ {setupData?.qr_code ? ( + Two-factor QR code + ) : ( +
+ +
+ )} + +
+
+ Can't scan? Enter this key instead: +
+ {setupData?.secret} +
+
+ +
+ + setCode(e.target.value.replace(/\D/g, '').slice(0, CODE_LENGTH)) + } + onKeyDown={(e) => { + if (e.key === 'Enter') handleConfirm(); + }} + placeholder="000000" + inputMode="numeric" + autoComplete="one-time-code" + className="security-enroll__input" + aria-label="Six-digit verification code" + /> +
+ + {error && ( +
+ +
{error}
+
+ )} + +
+ + +
+
+ ); + } + + // STAGE_CODES — shown exactly once. The backend will not reissue these. + return ( +
+

Save your backup codes

+

+ These are shown once and never again. Each works a single time if you + lose your authenticator — keep them somewhere that does not depend on + this server being reachable. +

+ +
+ {backupCodes.map((backupCode) => ( + + {backupCode} + + ))} +
+ +
+ + + {savedCodes && ( + + + Saved + + )} +
+ +
+ +
+
+ ); +}; + +export default SetupStepSecurity; diff --git a/frontend/src/components/setup/SetupStepSummary.jsx b/frontend/src/components/setup/SetupStepSummary.jsx index 586dfa89..41809ad8 100644 --- a/frontend/src/components/setup/SetupStepSummary.jsx +++ b/frontend/src/components/setup/SetupStepSummary.jsx @@ -17,8 +17,8 @@ const USE_CASE_LABELS = { devops: 'DevOps & Monitoring', }; -const SetupStepSummary = ({ accountInfo, useCases, onFinish }) => { - const { tier, specs, loading } = useResourceTier(); +const SetupStepSummary = ({ accountInfo, useCases, twoFactorEnabled, onFinish }) => { + const { specs, headroom, profile, profiles, loading } = useResourceTier(); // Sidebar profile. Pre-selected from the use cases already picked, so the // default path is zero extra clicks — "Change" reveals the full set for @@ -107,10 +107,9 @@ const SetupStepSummary = ({ accountInfo, useCases, onFinish }) => { return parts.join(', '); } - function tierLabel() { + function profileLabel() { if (loading) return 'Detecting...'; - if (!tier) return 'Unknown'; - return tier.charAt(0).toUpperCase() + tier.slice(1); + return profiles?.[profile]?.label || 'Standard'; } const anyError = Object.values(installState).some((v) => v === 'error'); @@ -172,13 +171,29 @@ const SetupStepSummary = ({ accountInfo, useCases, onFinish }) => {
Server
- Tier - {tierLabel()} + Profile + {profileLabel()}
Specs {formatSpecs()}
+ {headroom?.summary && ( +
+ Capacity + {headroom.summary} +
+ )} +
+ +
+
Security
+
+ Two-factor + + {twoFactorEnabled ? 'Enabled' : 'Off — you can turn it on in Settings'} + +
diff --git a/frontend/src/components/setup/SetupStepTier.jsx b/frontend/src/components/setup/SetupStepTier.jsx deleted file mode 100644 index 5f4d4fb2..00000000 --- a/frontend/src/components/setup/SetupStepTier.jsx +++ /dev/null @@ -1,127 +0,0 @@ -import { useResourceTier } from '../../contexts/ResourceTierContext'; -import { Check, X, AlertTriangle, Loader } from 'lucide-react'; - -const TIERS = [ - { - id: 'lite', - name: 'Lite', - specs: '1 core or <2 GB RAM', - features: [ - { label: 'Docker containers', available: true }, - { label: 'Database management', available: true }, - { label: 'App deployment', available: true }, - { label: 'Manage WordPress', available: true }, - { label: 'Create WordPress sites', available: false }, - ], - }, - { - id: 'standard', - name: 'Standard', - specs: '2-3 cores, 2-4 GB RAM', - features: [ - { label: 'Docker containers', available: true }, - { label: 'Database management', available: true }, - { label: 'App deployment', available: true }, - { label: 'Manage WordPress', available: true }, - { label: 'Create WordPress sites', available: true }, - ], - }, - { - id: 'performance', - name: 'Performance', - specs: '4+ cores, >4 GB RAM', - features: [ - { label: 'Docker containers', available: true }, - { label: 'Database management', available: true }, - { label: 'App deployment', available: true }, - { label: 'Manage WordPress', available: true }, - { label: 'Create WordPress sites', available: true }, - ], - }, -]; - -const SetupStepTier = ({ useCases, onComplete }) => { - const { tier, specs, loading } = useResourceTier(); - const showWarning = useCases.includes('wordpress') && tier === 'lite'; - - if (loading) { - return ( -
-
- -
-
- ); - } - - function formatSpecs() { - if (!specs) return null; - const parts = []; - if (specs.cpu_cores) parts.push(`${specs.cpu_cores} core${specs.cpu_cores > 1 ? 's' : ''}`); - if (specs.total_memory_gb) parts.push(`${specs.total_memory_gb} GB RAM`); - return parts.join(', '); - } - - return ( -
-

Server Resources

-

- We detected your server's hardware. Here's what each tier unlocks. -

- - {showWarning && ( -
- -
- Your server is in the Lite tier. WordPress site - creation requires at least the Standard tier (2+ cores, 2+ GB RAM). - You can still manage existing WordPress sites. -
-
- )} - -
- {TIERS.map((t) => { - const isDetected = tier === t.id; - return ( -
-
- {t.name} - {isDetected && ( - Your Server - )} -
-
- {isDetected && specs ? formatSpecs() : t.specs} -
-
- {t.features.map((f) => ( -
- - {f.available ? : } - - {f.label} -
- ))} -
-
- ); - })} -
- -
- -
-
- ); -}; - -export default SetupStepTier; diff --git a/frontend/src/contexts/ResourceTierContext.jsx b/frontend/src/contexts/ResourceTierContext.jsx index 4eb51776..266a5954 100644 --- a/frontend/src/contexts/ResourceTierContext.jsx +++ b/frontend/src/contexts/ResourceTierContext.jsx @@ -22,14 +22,16 @@ export function ResourceTierProvider({ children }) { try { setLoading(true); setError(null); + // /system/capacity is a superset of /system/resource-tier: same + // specs and tier, plus live headroom and the install profile. const endpoint = forceRefresh - ? '/system/resource-tier?refresh=true' - : '/system/resource-tier'; + ? '/system/capacity?refresh=true' + : '/system/capacity'; const data = await api.request(endpoint); setTierInfo(data); } catch (err) { - console.error('Failed to fetch resource tier:', err); - setError(err.message || 'Failed to fetch resource tier'); + console.error('Failed to fetch server capacity:', err); + setError(err.message || 'Failed to fetch server capacity'); } finally { setLoading(false); } @@ -47,7 +49,22 @@ export function ResourceTierProvider({ children }) { loading, error, refresh, - canCreateWordPress: tierInfo?.features?.wordpress_create ?? true, + + // Live capacity — the number that actually moves as apps get deployed. + headroom: tierInfo?.headroom || null, + + // What this install provisioned, and what it can do right now. + profile: tierInfo?.profile || null, + profiles: tierInfo?.profiles || {}, + capabilities: tierInfo?.capabilities || {}, + recommendedProfile: tierInfo?.recommended_profile || null, + profileDrift: tierInfo?.drift || [], + canHostApps: tierInfo?.capabilities?.can_host_apps ?? true, + + // Creation is never blocked on resources — this reports whether the + // server comfortably meets the recommendation so the UI can warn at + // the point of action instead of hiding the action. + isWordPressAdvised: tierInfo?.features?.wordpress_create_advised ?? true, isLiteTier: tierInfo?.tier === 'lite', isStandardTier: tierInfo?.tier === 'standard', isPerformanceTier: tierInfo?.tier === 'performance', diff --git a/frontend/src/pages/Deployments.jsx b/frontend/src/pages/Deployments.jsx index dc9cd1e8..3ea7f5e9 100644 --- a/frontend/src/pages/Deployments.jsx +++ b/frontend/src/pages/Deployments.jsx @@ -16,6 +16,7 @@ import Skeleton from '../components/Skeleton'; import { Button } from '@/components/ui/button'; import { SegControl, SearchField } from '@/components/ds'; import { useTopbarActions } from '@/hooks/useTopbarActions'; +import RequiresDocker from '../components/RequiresDocker'; import { KIND_CHIP, stepTicks, @@ -170,6 +171,7 @@ const Deployments = () => { ); return ( +
{
+
); }; diff --git a/frontend/src/pages/Docker.jsx b/frontend/src/pages/Docker.jsx index da5b259a..2e0f2a39 100644 --- a/frontend/src/pages/Docker.jsx +++ b/frontend/src/pages/Docker.jsx @@ -20,6 +20,7 @@ import ImagesTab, { PullImageButton } from '../components/docker/ImagesTab'; import NetworksTab, { CreateNetworkButton } from '../components/docker/NetworksTab'; import VolumesTab, { CreateVolumeButton } from '../components/docker/VolumesTab'; import PruneButton from '../components/docker/PruneButton'; +import RequiresDocker from '../components/RequiresDocker'; const Docker = () => { const [activeTab, setActiveTab] = useTabParam('/docker', VALID_TABS); @@ -228,6 +229,7 @@ const Docker = () => { }; return ( +
@@ -349,6 +351,7 @@ const Docker = () => {
+
); }; diff --git a/frontend/src/pages/Services.jsx b/frontend/src/pages/Services.jsx index df9f53f6..c0b8c0d6 100644 --- a/frontend/src/pages/Services.jsx +++ b/frontend/src/pages/Services.jsx @@ -27,6 +27,7 @@ import { DialogDescription, DialogFooter, } from '@/components/ui/dialog'; +import RequiresDocker from '../components/RequiresDocker'; const STATUS_PILL = { running: 'green', stopped: 'gray', deploying: 'amber', building: 'amber', failed: 'red' }; @@ -312,6 +313,7 @@ const Services = () => { ]; return ( + { }} /> + ); }; diff --git a/frontend/src/pages/Setup.jsx b/frontend/src/pages/Setup.jsx index 0699450f..de0de67a 100644 --- a/frontend/src/pages/Setup.jsx +++ b/frontend/src/pages/Setup.jsx @@ -5,16 +5,18 @@ import { Check, ArrowLeft } from 'lucide-react'; import ServerKitLogo from '../components/ServerKitLogo'; import SetupStepAccount from '../components/setup/SetupStepAccount'; import SetupStepIntent from '../components/setup/SetupStepIntent'; -import SetupStepTier from '../components/setup/SetupStepTier'; +import SetupStepCapacity from '../components/setup/SetupStepCapacity'; +import SetupStepSecurity from '../components/setup/SetupStepSecurity'; import SetupStepSummary from '../components/setup/SetupStepSummary'; import { Button } from '@/components/ui/button'; -const TOTAL_STEPS = 4; +const TOTAL_STEPS = 5; const STEP_TITLES = [ 'Account', 'Use Cases', - 'Resources', + 'Capacity', + 'Security', 'Summary', ]; @@ -25,6 +27,7 @@ const Setup = () => { const [currentStep, setCurrentStep] = useState(1); const [accountInfo, setAccountInfo] = useState(null); const [useCases, setUseCases] = useState([]); + const [twoFactorEnabled, setTwoFactorEnabled] = useState(false); // If user is already authenticated (e.g. page refresh mid-wizard), skip to step 2 useEffect(() => { @@ -43,10 +46,15 @@ const Setup = () => { setCurrentStep(3); } - function handleTierComplete() { + function handleCapacityComplete() { setCurrentStep(4); } + function handleSecurityComplete(enabled) { + setTwoFactorEnabled(Boolean(enabled)); + setCurrentStep(5); + } + async function handleFinish(installedExtensions = [], sidebarPreset = null) { await completeOnboarding(useCases, installedExtensions, sidebarPreset); navigate('/'); @@ -95,16 +103,19 @@ const Setup = () => { ); case 3: return ( - ); case 4: + return ; + case 5: return ( ); diff --git a/frontend/src/pages/WordPress.jsx b/frontend/src/pages/WordPress.jsx index 95ea19a5..7c4adc5c 100644 --- a/frontend/src/pages/WordPress.jsx +++ b/frontend/src/pages/WordPress.jsx @@ -2,8 +2,7 @@ import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import wordpressApi from '../services/wordpress'; import { useToast } from '../contexts/ToastContext'; -import { useResourceTier } from '../contexts/ResourceTierContext'; -import ResourceGate from '../components/ResourceGate'; +import ResourceAdvisory from '../components/ResourceAdvisory'; import Spinner from '../components/Spinner'; import ResourceListPage from '../components/layouts/ResourceListPage'; import { Globe, ChevronRight } from 'lucide-react'; @@ -53,7 +52,6 @@ function WordPress() { const navigate = useNavigate(); const toast = useToast(); - const { isLiteTier } = useResourceTier(); useEffect(() => { loadSites(); @@ -232,17 +230,6 @@ function WordPress() { ); } - // Lite tier with no sites -> resource gate - if (sites.length === 0 && isLiteTier) { - return ( -
- -
- -
- ); - } - const allTags = Array.from(new Set(sites.flatMap(s => s.tags || []))).sort(); const runningCount = sites.filter(s => s.status === 'running').length; const q = siteSearch.trim().toLowerCase(); @@ -348,7 +335,11 @@ function WordPress() { keyField="id" onRowClick={(site) => navigate(`/wordpress/${site.id}`)} rowClassName={(site) => (selectedIds.has(site.id) ? 'is-selected' : '')} - header={(createdCreds || createdSite) && ( + header={<> + {/* Advisory, not a gate: a constrained server says so here and + the operator still decides. */} + + {(createdCreds || createdSite) && (
{createdSite && (createdSite.domain ? ( @@ -374,7 +365,8 @@ function WordPress() {
- )} + )} + } filters={[ { value: 'all', label: 'All', count: sites.length }, { value: 'running', label: 'Running', count: runningCount }, diff --git a/frontend/src/styles/components/_requires-docker.scss b/frontend/src/styles/components/_requires-docker.scss new file mode 100644 index 00000000..75e6bde2 --- /dev/null +++ b/frontend/src/styles/components/_requires-docker.scss @@ -0,0 +1,76 @@ +// ============================================ +// REQUIRES DOCKER — capability explainer +// ============================================ +// Shown in place of container-dependent pages on a Minimal install. This is an +// explanation with a route forward, not a blocked-state gate: the operator is +// told what is missing and exactly how to add it. + +.requires-docker { + max-width: 560px; + margin: 0 auto; + padding: $space-8 $space-5; + text-align: center; + + &__icon { + color: $text-tertiary; + margin-bottom: $space-4; + } + + &__title { + font-size: $font-size-xl; + font-weight: $font-weight-bold; + color: $text-primary; + margin-bottom: $space-3; + } + + &__text { + font-size: $font-size-md; + color: $text-secondary; + line-height: $line-height-relaxed; + margin-bottom: $space-6; + } + + &__how { + text-align: left; + padding: $space-4; + margin-bottom: $space-5; + background: $bg-elevated; + border: 1px solid $border-default; + border-radius: $radius-lg; + } + + &__how-label { + display: flex; + align-items: center; + gap: $space-2; + font-size: $font-size-sm; + color: $text-secondary; + margin-bottom: $space-3; + } + + &__cmd { + display: block; + font-family: $font-mono; + font-size: $font-size-sm; + color: $text-primary; + padding: $space-2 $space-3; + background: $bg-tertiary; + border-radius: $radius-md; + overflow-x: auto; + white-space: nowrap; + + & + & { + margin-top: $space-2; + } + } + + &__footnote { + font-size: $font-size-sm; + color: $text-tertiary; + line-height: $line-height-normal; + + a { + color: $accent-primary; + } + } +} diff --git a/frontend/src/styles/main.scss b/frontend/src/styles/main.scss index 8eb36868..9147bb11 100644 --- a/frontend/src/styles/main.scss +++ b/frontend/src/styles/main.scss @@ -75,6 +75,7 @@ @import 'components/_dashboard-widgets'; // configurable dashboard grid (skw-* namespace) @import 'components/_widget-editor'; // wide preview+settings widget drawer (skwe-* namespace) @import 'components/_system-notices'; +@import 'components/_requires-docker'; @import 'components/container-status'; @import 'components/api-key-scopes'; @import 'components/onboarding-wizard'; diff --git a/frontend/src/styles/pages/_setup-wizard.scss b/frontend/src/styles/pages/_setup-wizard.scss index 14a66ba2..2d6fd859 100644 --- a/frontend/src/styles/pages/_setup-wizard.scss +++ b/frontend/src/styles/pages/_setup-wizard.scss @@ -283,6 +283,63 @@ } } +// Profile cards are buttons in the capacity step, so undo the UA button +// styling that would otherwise centre the text and shrink the type. +.tier-card--selectable { + width: 100%; + text-align: left; + font: inherit; + color: inherit; + cursor: pointer; + + &:hover { + border-color: $accent-primary; + } +} + +.tier-card-badge--muted { + background: $bg-tertiary; + color: $text-secondary; +} + +// -------------------------------------------- +// Capacity step (Step 3) +// -------------------------------------------- +.capacity-headline { + padding: $space-5; + margin-bottom: $space-5; + background: $bg-elevated; + border: 1px solid $border-default; + border-radius: $radius-xl; + + &__summary { + font-size: $font-size-lg; + font-weight: $font-weight-semibold; + color: $text-primary; + line-height: $line-height-tight; + } +} + +.capacity-specs { + display: flex; + flex-wrap: wrap; + gap: $space-4; + margin-top: $space-3; +} + +.capacity-spec { + display: inline-flex; + align-items: center; + gap: $space-2; + font-size: $font-size-sm; + color: $text-secondary; + + &--muted { + color: $text-tertiary; + text-transform: capitalize; + } +} + .tier-warning { display: flex; align-items: flex-start; @@ -293,6 +350,14 @@ border-radius: $radius-lg; margin-bottom: $space-6; + code { + font-family: $font-mono; + font-size: $font-size-xs; + padding: 1px 5px; + background: $bg-tertiary; + border-radius: $radius-sm; + } + .tier-warning-icon { flex-shrink: 0; color: $warning; @@ -306,7 +371,134 @@ } // -------------------------------------------- -// Summary panel (Step 4) +// Security step (Step 4) — optional 2FA enrolment +// -------------------------------------------- +.security-offer { + display: flex; + align-items: flex-start; + gap: $space-4; + padding: $space-5; + margin-bottom: $space-6; + background: $bg-elevated; + border: 1px solid $border-default; + border-radius: $radius-xl; + + &__icon { + flex-shrink: 0; + color: $accent-primary; + } + + &__title { + font-weight: $font-weight-semibold; + margin-bottom: $space-2; + } + + &__desc { + font-size: $font-size-sm; + color: $text-secondary; + line-height: $line-height-normal; + margin: 0; + } +} + +.security-enroll { + display: flex; + align-items: center; + gap: $space-5; + padding: $space-5; + margin-bottom: $space-5; + background: $bg-elevated; + border: 1px solid $border-default; + border-radius: $radius-xl; + + @media (max-width: $breakpoint-md) { + flex-direction: column; + text-align: center; + } + + &__qr { + flex-shrink: 0; + width: 160px; + height: 160px; + border-radius: $radius-lg; + // QR codes render as dark-on-transparent; a white plate keeps them + // scannable in dark mode. + background: #fff; + padding: $space-2; + + &--empty { + @include flex-center(); + background: $bg-tertiary; + } + } + + &__manual-label { + font-size: $font-size-sm; + color: $text-secondary; + margin-bottom: $space-2; + } + + &__secret { + display: inline-block; + font-family: $font-mono; + font-size: $font-size-sm; + letter-spacing: 1px; + word-break: break-all; + padding: $space-2 $space-3; + background: $bg-tertiary; + border-radius: $radius-md; + } + + &__verify { + margin-bottom: $space-5; + } + + &__input { + font-family: $font-mono; + font-size: $font-size-xl; + letter-spacing: 8px; + text-align: center; + max-width: 220px; + } +} + +.security-codes { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: $space-2; + padding: $space-4; + margin-bottom: $space-4; + background: $bg-elevated; + border: 1px solid $border-default; + border-radius: $radius-xl; + + &__item { + font-family: $font-mono; + font-size: $font-size-sm; + text-align: center; + padding: $space-2; + background: $bg-tertiary; + border-radius: $radius-md; + } + + &__actions { + display: flex; + align-items: center; + gap: $space-3; + margin-bottom: $space-6; + } + + &__saved { + display: inline-flex; + align-items: center; + gap: $space-2; + font-size: $font-size-sm; + color: $success; + } +} + +// -------------------------------------------- +// Summary panel (Step 5) // -------------------------------------------- .summary-panel { background: $bg-elevated; diff --git a/frontend/src/styles/pages/_wordpress.scss b/frontend/src/styles/pages/_wordpress.scss index 09a0137e..4fc40dfd 100644 --- a/frontend/src/styles/pages/_wordpress.scss +++ b/frontend/src/styles/pages/_wordpress.scss @@ -2953,254 +2953,46 @@ } // ============================================ -// RESOURCE GATE - BLOCKED STATE UI +// RESOURCE ADVISORY - INLINE CAPACITY WARNING // ============================================ +// Replaces the old blocked-state gate. Warns at the point of action and lets +// the operator proceed; nothing here hides a control. -.resource-gate { +.resource-advisory { display: flex; - align-items: center; - justify-content: center; - min-height: 60vh; - padding: $space-6; -} - -.resource-gate-container { - max-width: 700px; - text-align: center; -} - -// ---- Monthly client reports (#33) ---- -.wp-report-month-list { - flex-wrap: wrap; - margin-top: $space-3; -} - -.wp-report-actions { - margin-left: auto; - display: inline-flex; - gap: $space-2; -} - -// Print isolation: when the report's "Print" button toggles -// `body.wp-report-printing`, hide all app chrome and lay out only the report. -// Uses the classic visibility trick so it doesn't depend on enumerating every -// layout class, and only applies inside @media print so the screen is untouched. -@media print { - body.wp-report-printing { - * { - visibility: hidden !important; - } - - .wp-report-printable, - .wp-report-printable * { - visibility: visible !important; - } - - .wp-report-printable { - position: absolute; - inset: 0 auto auto 0; - width: 100%; - padding: 0; - margin: 0; - } - - .wp-report-no-print { - display: none !important; - } - } -} - -.resource-gate-icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 80px; - height: 80px; - background: fade($warning-raw, 12%); - border-radius: 50%; - color: $color-warning; - margin-bottom: $space-5; -} - -.resource-gate-title { - font-size: $font-size-xl; - font-weight: $font-weight-bold; - color: $text-primary; - margin: 0 0 $space-3; -} - -.resource-gate-description { - font-size: $font-size-base; - color: $text-secondary; - margin: 0 0 $space-6; - line-height: 1.6; - - strong { - color: $color-warning; - font-weight: $font-weight-semibold; - } -} - -.resource-gate-specs { - display: flex; - align-items: center; - justify-content: center; - gap: $space-4; - margin-bottom: $space-6; - - @media (max-width: 600px) { - flex-direction: column; - gap: $space-3; - } -} - -.resource-gate-spec-arrow { - color: $text-tertiary; - - @media (max-width: 600px) { - transform: rotate(90deg); - } -} - -.resource-gate-spec-card { - background: $bg-card; - border: 1px solid $border-subtle; - border-radius: $radius-lg; + align-items: flex-start; + gap: $space-3; padding: $space-4; - min-width: 200px; - - &.current { - border-color: fade($danger-raw, 40%); - background: fade($danger-raw, 4%); - } - - &.required { - border-color: fade($success-raw, 40%); - background: fade($success-raw, 4%); - } -} - -.resource-gate-spec-header { - display: flex; - align-items: center; - justify-content: center; - gap: $space-2; - font-size: $font-size-sm; - font-weight: $font-weight-semibold; - color: $text-primary; - margin-bottom: $space-3; - padding-bottom: $space-2; - border-bottom: 1px solid $border-subtle; -} - -.resource-gate-spec-items { - display: flex; - flex-direction: column; - gap: $space-2; -} - -.resource-gate-spec-item { - display: flex; - align-items: center; - gap: $space-2; - font-size: $font-size-sm; + margin-bottom: $space-4; + background: fade($warning-raw, 8%); + border: 1px solid $color-warning; + border-radius: $radius-md; - svg { - color: $text-tertiary; + &__icon { flex-shrink: 0; + margin-top: 2px; + color: $color-warning; } - .spec-label { - color: $text-secondary; - flex: 1; - } - - .spec-value { - font-weight: $font-weight-semibold; - color: $color-success; - font-family: $font-family-mono; - - &.insufficient { - color: $color-danger; - } - } -} - -.resource-gate-recommendation { - background: $bg-tertiary; - border: 1px solid $border-subtle; - border-radius: $radius-lg; - padding: $space-4; - margin-bottom: $space-6; - text-align: left; - - h4 { + &__title { font-size: $font-size-sm; - font-weight: $font-weight-semibold; + font-weight: $font-weight-bold; color: $text-primary; - margin: 0 0 $space-2; + margin-bottom: $space-1; } - p { + &__text { font-size: $font-size-sm; color: $text-secondary; + line-height: $line-height-normal; margin: 0; - line-height: 1.5; - - strong { - color: $text-primary; - } } -} - -.resource-gate-tiers { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: $space-3; - @media (max-width: 600px) { - grid-template-columns: 1fr; - } -} - -.resource-gate-tier { - background: $bg-card; - border: 1px solid $border-subtle; - border-radius: $radius-md; - padding: $space-3; - text-align: center; - - &.current { - border-color: $color-warning; - background: fade($warning-raw, 6%); - } - - .tier-name { - font-size: $font-size-sm; - font-weight: $font-weight-bold; - color: $text-primary; - margin-bottom: $space-1; - } - - .tier-specs { + &__list { + margin: $space-2 0 0; + padding-left: $space-4; font-size: $font-size-xs; color: $text-tertiary; - margin-bottom: $space-2; - } - - .tier-wp { - font-size: $font-size-xs; - font-weight: $font-weight-medium; - color: $color-danger; - padding: 2px 8px; - background: fade($danger-raw, 10%); - border-radius: $radius-sm; - display: inline-block; - - &.allowed { - color: $color-success; - background: fade($success-raw, 10%); - } } } diff --git a/install.sh b/install.sh index de4dabc6..694b930f 100644 --- a/install.sh +++ b/install.sh @@ -42,6 +42,18 @@ SERVERKIT_VERSION="${SERVERKIT_VERSION:-}" VERSION="${VERSION:-${SERVERKIT_VERSION:-1.4.11}}" CHANNEL="${CHANNEL:-Stable}" +# Install profile: what we put on the box, not what the box is allowed to do. +# minimal — panel + nginx + SQLite. No Docker. +# standard — + Docker and the compose plugin (the default). +# full — + recommended extensions and hardening. +# Everything a profile skips stays installable later from the panel. Set +# SERVERKIT_PROFILE to skip the interactive prompt. Thresholds below are +# mirrored by recommend_profile() in backend/app/services/install_profile_service.py +# — change one, change the other. +PROFILE="${SERVERKIT_PROFILE:-}" +RECOMMENDED_PROFILE="standard" +PROFILE_PROMPT_TIMEOUT="${SERVERKIT_PROFILE_TIMEOUT:-15}" + PANEL_DOMAIN="${PANEL_DOMAIN:-}" PANEL_PORT="${PANEL_PORT:-80}" SERVERKIT_SKIP_SSL="${SERVERKIT_SKIP_SSL:-0}" @@ -399,6 +411,148 @@ gauge_memory() { fi } +detect_container() { + # Echo the container flavour, or nothing on bare metal / a full VM. This + # matters more than core count: Docker frequently cannot run inside an + # unprivileged LXC or OpenVZ guest, so such a box can look adequate on + # paper and still be unable to host a single app. + if [ -f /.dockerenv ]; then + printf 'docker' + return 0 + fi + if [ -d /proc/vz ] && [ ! -d /proc/bc ]; then + printf 'openvz' + return 0 + fi + if command -v systemd-detect-virt &>/dev/null; then + local virt + virt=$(systemd-detect-virt --container 2>/dev/null) || virt="" + if [ -n "$virt" ] && [ "$virt" != "none" ]; then + printf '%s' "$virt" + return 0 + fi + fi + # /proc/1/environ carries container= for LXC and systemd-nspawn. + if [ -r /proc/1/environ ]; then + if tr '\0' '\n' < /proc/1/environ 2>/dev/null | grep -q '^container=lxc'; then + printf 'lxc' + return 0 + fi + fi + return 0 +} + +recommend_profile() { + # Pick the profile that this hardware can actually carry. Deliberately + # conservative — an operator can always override upward, but silently + # defaulting a 700MB VPS to a Docker install produces a box that installs + # cleanly and then OOMs on the first deploy. + local ram_mb=0 cores=1 disk_gb="" container="" + + if command -v free &>/dev/null; then + ram_mb=$(free -m 2>/dev/null | awk '/^Mem:/ {print $2}') || ram_mb=0 + fi + [ -n "$ram_mb" ] || ram_mb=0 + + if command -v nproc &>/dev/null; then + cores=$(nproc 2>/dev/null) || cores=1 + fi + [ -n "$cores" ] || cores=1 + + # Free space on the volume that will hold images and backups. + if command -v df &>/dev/null; then + disk_gb=$(df -BG "$BASE_DIR" 2>/dev/null | awk 'NR==2 {gsub(/G/,"",$4); print $4}') || disk_gb="" + fi + + container=$(detect_container) + + RECOMMENDED_PROFILE="standard" + + if [ "$container" = "lxc" ] || [ "$container" = "openvz" ]; then + RECOMMENDED_PROFILE="minimal" + return 0 + fi + # 1.5GB, expressed in MB to stay in integer arithmetic. + if [ "$ram_mb" -gt 0 ] && [ "$ram_mb" -lt 1536 ]; then + RECOMMENDED_PROFILE="minimal" + return 0 + fi + if [ -n "$disk_gb" ] && [ "$disk_gb" -lt 5 ]; then + RECOMMENDED_PROFILE="minimal" + return 0 + fi + if [ "$ram_mb" -ge 4096 ] && [ "$cores" -ge 4 ]; then + if [ -z "$disk_gb" ] || [ "$disk_gb" -ge 20 ]; then + RECOMMENDED_PROFILE="full" + fi + fi + return 0 +} + +prompt_for_profile() { + # Honour an explicit choice and never ask again. + case "$PROFILE" in + minimal|standard|full) + good "Install profile: $PROFILE (from SERVERKIT_PROFILE)" + return 0 + ;; + "") + ;; + *) + warn "Unknown SERVERKIT_PROFILE='$PROFILE' — ignoring it." + PROFILE="" + ;; + esac + + recommend_profile + + # curl | bash has no interactive stdin. Take the recommendation rather than + # blocking forever. SERVERKIT_FORCE_PROMPT=1 lets the unit tests drive this + # from a pipe (same hook prompt_for_domain uses). + if [ ! -t 0 ] && [ "${SERVERKIT_FORCE_PROMPT:-0}" != "1" ]; then + PROFILE="$RECOMMENDED_PROFILE" + good "Install profile: $PROFILE (auto-detected)" + return 0 + fi + + printf '\n' + printf '%sHow much should we install?%s\n' "$BLD" "$RST" + printf ' %s1) minimal%s Panel, nginx and SQLite. No Docker.\n' "$BLD" "$RST" + printf ' Monitoring, domains, certificates, cron and DNS all work.\n' + printf ' %s2) standard%s Adds Docker so this server can host apps.\n' "$BLD" "$RST" + printf ' %s3) full%s Adds recommended extensions and hardening.\n' "$BLD" "$RST" + printf '\n' + printf 'Detected hardware suggests: %s%s%s\n' "$BLD" "$RECOMMENDED_PROFILE" "$RST" + printf 'Anything skipped can be installed later from the panel.\n' + printf '%sTip:%s set SERVERKIT_PROFILE=minimal|standard|full to skip this prompt\n' "$BLD" "$RST" + printf '> [%s] ' "$RECOMMENDED_PROFILE" + + local answer="" + # A timeout keeps a half-attended install moving instead of parking on the + # prompt; the recommendation is the fallback either way. + read -r -t "$PROFILE_PROMPT_TIMEOUT" answer || answer="" + answer=$(printf '%s' "$answer" | tr -d ' ' | tr '[:upper:]' '[:lower:]') + + case "$answer" in + 1|minimal) PROFILE="minimal" ;; + 2|standard) PROFILE="standard" ;; + 3|full) PROFILE="full" ;; + "") PROFILE="$RECOMMENDED_PROFILE" ;; + *) + warn "Unrecognised answer '$answer' — using $RECOMMENDED_PROFILE." + PROFILE="$RECOMMENDED_PROFILE" + ;; + esac + + if [ "$PROFILE" != "minimal" ] && [ "$RECOMMENDED_PROFILE" = "minimal" ]; then + warn "Overriding the Minimal recommendation on a constrained host." + warn "Docker may fail to start, or deploys may be OOM-killed." + fi + + good "Install profile: $PROFILE" + return 0 +} + ensure_swap() { # No `free` (some LXC templates) → cannot gauge swap; skip quietly. (I14) if ! command -v free &>/dev/null; then @@ -599,6 +753,14 @@ docker_repo_add() { provision_docker() { phase "Docker" + # The Minimal profile deliberately ships without Docker. The panel detects + # its absence at runtime and hides app hosting; adding Docker later from + # Settings promotes the install to Standard. + if [ "$PROFILE" = "minimal" ]; then + good "Skipping Docker (minimal profile) — add it later from the panel." + return + fi + if command -v docker &>/dev/null; then good "Docker already present: $(docker --version | head -1)" return @@ -667,6 +829,10 @@ provision_docker() { } ensure_compose_plugin() { + # No Docker on the minimal profile, so nothing to plug into. + if [ "$PROFILE" = "minimal" ]; then + return + fi if docker compose version &>/dev/null; then good "Docker Compose plugin present." return @@ -1072,6 +1238,14 @@ write_config() { else printf 'SERVERKIT_SSL_MODE=%s\n' "$SSL_MODE" >> "$INSTALL_DIR/.env" fi + # Same reasoning as the SSL mode above: a re-run may have chosen a + # different profile, and the panel reads this to decide whether to + # offer app hosting. + if grep -q '^SERVERKIT_PROFILE=' "$INSTALL_DIR/.env"; then + sed -i "s|^SERVERKIT_PROFILE=.*|SERVERKIT_PROFILE=$PROFILE|" "$INSTALL_DIR/.env" + else + printf 'SERVERKIT_PROFILE=%s\n' "$PROFILE" >> "$INSTALL_DIR/.env" + fi return fi @@ -1118,6 +1292,11 @@ ${public_url:+# SERVERKIT_PUBLIC_URL=$public_url} # server terminates real end-to-end HTTPS. SERVERKIT_SSL_MODE=$SSL_MODE +# Install profile (minimal|standard|full) — what this install provisioned, not +# a licence tier. The panel reads it to decide whether to offer app hosting; +# anything skipped can still be installed later from Settings. +SERVERKIT_PROFILE=$PROFILE + # Ports PORT=80 SSL_PORT=443 @@ -1291,6 +1470,60 @@ svc_start() { return 0 } +provision_hardening() { + # What makes "full" more than "standard". + # + # The panel ships two features that quietly do nothing unless a daemon it + # never installs is present: fail2ban_jail_service manages jails but no + # profile installed fail2ban, and configure_nginx only warns "Install + # certbot and run..." when certbot is missing. Standard leaves both to the + # operator; full is the profile that says "put the whole thing on". + # + # Every step is warn-and-continue (pkg_add's contract) — a hardening extra + # that fails to install must never abort an otherwise good install. + [ "$PROFILE" = "full" ] || return 0 + + phase "Hardening" + + if command -v fail2ban-server &>/dev/null; then + good "fail2ban already present." + else + step "Installing fail2ban..." + pkg_add fail2ban + fi + if command -v fail2ban-server &>/dev/null; then + svc_enable fail2ban + svc_start fail2ban + good "fail2ban enabled — manage jails from Security in the panel." + else + warn "fail2ban unavailable; the panel's jail management will stay inert." + fi + + # Certbot only matters when this install is actually attempting HTTPS. + if [ "$SERVERKIT_SKIP_SSL" = "1" ]; then + good "Skipping certbot (SERVERKIT_SKIP_SSL=1)." + elif command -v certbot &>/dev/null; then + good "certbot already present." + else + step "Installing certbot..." + case "$OS_FAMILY" in + debian|ubuntu) pkg_add certbot python3-certbot-nginx ;; + fedora|rhel) pkg_add certbot python3-certbot-nginx ;; + suse) pkg_add certbot python3-certbot-nginx ;; + arch) pkg_add certbot certbot-nginx ;; + alpine) pkg_add certbot certbot-nginx ;; + *) pkg_add certbot ;; + esac + if command -v certbot &>/dev/null; then + good "certbot installed." + else + warn "certbot unavailable — HTTPS stays best-effort on this host." + fi + fi + + return 0 +} + # --------------------------------------------------------------------------- # nginx site + reverse-proxy wiring # --------------------------------------------------------------------------- @@ -1704,6 +1937,12 @@ print_outro() { printf ' %sPanel URL%s http://%s\n' "$BLD" "$RST" "$ip" fi + printf ' %sProfile%s %s\n' "$BLD" "$RST" "$PROFILE" + if [ "$PROFILE" = "minimal" ]; then + printf ' Docker was not installed, so app hosting is off.\n' + printf ' Add it any time from Settings — nothing is locked.\n' + fi + if [ "$SSL_MODE" != "secure" ]; then printf '\n %sWARNING%s Running without HTTPS. Passwords and tokens will be\n' "$BLD" "$RST" printf ' transmitted unencrypted. Set PANEL_DOMAIN and run\n' @@ -1801,6 +2040,9 @@ main() { ensure_bootstrap_tools gauge_memory ensure_swap + # Ask what to install before anything is provisioned — the answer decides + # whether provision_docker runs at all. + prompt_for_profile prompt_for_domain snapshot_existing @@ -1841,6 +2083,9 @@ main() { fi sync_templates + # Before configure_nginx: on the full profile this is what puts certbot on + # the box, and configure_nginx's HTTPS attempt needs it already there. + provision_hardening configure_nginx configure_firewall write_config diff --git a/scripts/test/test_install.sh b/scripts/test/test_install.sh index 93581a31..68c49b04 100644 --- a/scripts/test/test_install.sh +++ b/scripts/test/test_install.sh @@ -1200,11 +1200,193 @@ fi 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 + 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 + 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=standard; 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 standard profile" +else + bad "provision_hardening ran on standard: [$out]" +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 2>&1 "$pt/slot/.env"; } + +read_profile() { # read_profile — echo install_profile() under a fixture slot + ( set -Eeuo pipefail + INSTALL_DIR="$pt/slot"; DIR_A="$pt/slot"; DIR_B="$pt/other" + install_profile ) +} + +mkenv 'SERVERKIT_PROFILE=minimal' +if [ "$(read_profile)" = "minimal" ]; then + ok "install_profile reads SERVERKIT_PROFILE from the active slot's .env" +else + bad "install_profile misread a minimal .env: got [$(read_profile)]" +fi + +mkenv 'SERVERKIT_PROFILE=banana' +if [ "$(read_profile)" = "standard" ]; then + ok "install_profile falls back to standard on a bogus value" +else + bad "install_profile accepted a bogus profile: got [$(read_profile)]" +fi + +# Every install predating profiles has no such key and is effectively standard. +mkenv 'PORT=80' +if [ "$(read_profile)" = "standard" ]; then + ok "install_profile treats a pre-profile .env as standard" +else + bad "install_profile mishandled a pre-profile .env: got [$(read_profile)]" +fi + +# No .env at all (fresh base dir) must not abort under pipefail. +if res="$( set -Eeuo pipefail; INSTALL_DIR="$pt/missing"; DIR_A="$pt/missing" + DIR_B="$pt/other"; install_profile )"; then + ok "install_profile survives a missing .env (got [$res])" +else + bad "install_profile aborted with no .env under set -Eeuo pipefail" +fi + +# The actual regression: minimal + no docker binary → Docker is not required. +# have_docker() is the seam; hiding docker by trimming PATH would also hide the +# coreutils install_profile parses with, and the assertion would then pass for +# the wrong reason. +mkenv 'SERVERKIT_PROFILE=minimal' +if ( set -Eeuo pipefail + INSTALL_DIR="$pt/slot"; DIR_A="$pt/slot"; DIR_B="$pt/other" + have_docker() { return 1; } + update_needs_docker ); then + bad "update_needs_docker still demands Docker on a minimal Dockerless box (update would halt)" +else + ok "update_needs_docker: minimal + no docker → Docker not required" +fi + +# ...but if the operator later installed Docker, keep validating it. +if ( set -Eeuo pipefail + INSTALL_DIR="$pt/slot"; DIR_A="$pt/slot"; DIR_B="$pt/other" + have_docker() { return 0; } + update_needs_docker ); then + ok "update_needs_docker: minimal + docker present → still validated" +else + bad "update_needs_docker skipped a Docker that is actually installed" +fi + +# A standard install without Docker is real drift and must still halt. +mkenv 'SERVERKIT_PROFILE=standard' +if ( set -Eeuo pipefail + INSTALL_DIR="$pt/slot"; DIR_A="$pt/slot"; DIR_B="$pt/other" + have_docker() { return 1; } + update_needs_docker ); then + ok "update_needs_docker: standard profile still requires Docker" +else + bad "update_needs_docker let a standard install skip Docker" +fi + +# An all-Docker deployment needs Docker no matter what the profile says. +mkenv 'SERVERKIT_PROFILE=minimal' +if ( set -Eeuo pipefail + INSTALL_DIR="$pt/slot"; DIR_A="$pt/slot"; DIR_B="$pt/other" + have_docker() { return 1; } + is_docker_deployment() { return 0; } + update_needs_docker ); then + ok "update_needs_docker: all-Docker deployment overrides the minimal profile" +else + bad "update_needs_docker skipped Docker for an all-Docker deployment" +fi + # -------------------------------------------------------------------------- printf '\n%d passed, %d failed, %d skipped\n\n' "$PASS" "$FAIL" "$SKIP" [ "$FAIL" -eq 0 ] diff --git a/scripts/update.sh b/scripts/update.sh index a0a19bdb..d69a5a13 100644 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -510,6 +510,42 @@ next_real_dir() { fi } +# Read the install profile recorded by install.sh in the active slot's .env. +# Anything unrecognised — including every install predating profiles — reports +# "standard", which is exactly what those installs are. +install_profile() { + local active env_file profile="" + active="$(active_real_dir)" + if [ -n "$active" ]; then + env_file="$active/.env" + if [ -f "$env_file" ]; then + profile="$(grep -E '^SERVERKIT_PROFILE=' "$env_file" 2>/dev/null \ + | head -1 | cut -d= -f2- | tr -d ' \r')" + fi + fi + case "$profile" in + minimal|standard|full) printf '%s' "$profile" ;; + *) printf 'standard' ;; + esac +} + +# Whether this update has to insist on Docker. The minimal profile ships +# without it on purpose, so demanding it here would make every minimal box +# installable but un-updatable. Two escape hatches keep that honest: an +# all-Docker deployment obviously needs it, and if Docker is present at all we +# still validate compose so a half-provisioned box is caught early. +# Split out so the unit tests can redefine it. Restricting PATH to hide docker +# would also hide the coreutils install_profile parses with, and the test would +# then pass for the wrong reason. +have_docker() { command -v docker &>/dev/null; } + +update_needs_docker() { + [ "$(install_profile)" = "minimal" ] || return 0 + is_docker_deployment && return 0 + have_docker && return 0 + return 1 +} + # --------------------------------------------------------------------------- # Pre-flight checks # --------------------------------------------------------------------------- @@ -541,18 +577,27 @@ preflight_check() { # must be checked up front — discovering it missing mid-update would abort # after the new slot is already half-built. Release tarballs ship a # prebuilt dist and stay npm-free. - local cmd missing=() required=(git curl tar rsync systemctl nginx docker python3) + local cmd missing=() required=(git curl tar rsync systemctl nginx python3) [ "$USE_RELEASE" = "1" ] || required+=(npm) + # Docker is conditional — see update_needs_docker(). + if update_needs_docker; then + required+=(docker) + fi for cmd in "${required[@]}"; do command -v "$cmd" &>/dev/null || missing+=("$cmd") done - if ! docker compose version &>/dev/null && ! docker-compose --version &>/dev/null; then - missing+=("docker compose") + if update_needs_docker; then + if ! docker compose version &>/dev/null && ! docker-compose --version &>/dev/null; then + missing+=("docker compose") + fi fi if [ ${#missing[@]} -gt 0 ]; then halt "Missing required tools: ${missing[*]}" fi good "Required tools available" + if ! update_needs_docker; then + good "Docker not required (minimal profile)" + fi # Disk space (need 2 GiB free on the install filesystem) local avail_kb avail_gb From 5bc3f26d7729005a1d4e1db570014dce68b0f0a2 Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Sun, 2 Aug 2026 17:21:02 -0400 Subject: [PATCH 2/6] fix(security): block panel-internal paths from the file manager (GHSA-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 --- backend/app/services/file_service.py | 19 ++++++++++- backend/tests/test_files_rbac.py | 50 ++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/backend/app/services/file_service.py b/backend/app/services/file_service.py index f9e8c05c..34c8195f 100644 --- a/backend/app/services/file_service.py +++ b/backend/app/services/file_service.py @@ -33,6 +33,20 @@ class FileService: # Allowed root directories for browsing (security) ALLOWED_ROOTS = ['/home', '/var/www', '/opt', '/srv', '/var/log', paths.SERVERKIT_DIR] + # Panel-internal directories that must NEVER be reachable through the file + # manager, for any role. The backend .env (JWT_SECRET_KEY, encryption key, + # DB creds), the SQLite instance dir and the generated deploy config all + # live here; on the documented install.sh layout the install dir sits under + # the allowed root /opt, so without this exclusion any user with files.read + # (including the default viewer role) could read the panel's own secrets + # and forge admin sessions (GHSA-rm3m-9mvw-68fh). + _BACKEND_DIR = os.path.realpath(os.path.join(os.path.dirname(__file__), '..')) + _INSTALL_DIR = os.path.realpath(os.path.join(_BACKEND_DIR, '..')) + PROTECTED_ROOTS = [ + _INSTALL_DIR, + os.path.realpath(paths.SERVERKIT_CONFIG_DIR), + ] + # File extensions that can be edited in browser EDITABLE_EXTENSIONS = { '.txt', '.md', '.json', '.xml', '.yml', '.yaml', '.ini', '.conf', '.cfg', @@ -49,9 +63,12 @@ class FileService: @classmethod def is_path_allowed(cls, path: str) -> bool: - """Check if path is within allowed directories.""" + """Check if path is within allowed directories and not panel-internal.""" try: 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) except (ValueError, OSError): return False diff --git a/backend/tests/test_files_rbac.py b/backend/tests/test_files_rbac.py index 8be34026..12d58ed5 100644 --- a/backend/tests/test_files_rbac.py +++ b/backend/tests/test_files_rbac.py @@ -140,6 +140,56 @@ def test_revoked_files_read_blocks_read_endpoints(client, no_read_headers, url): assert 'files' in resp.get_json()['error'] +# --------------------------------------------------------------------------- # +# GHSA-rm3m-9mvw-68fh: the panel's own files must never be reachable through +# the file manager, for ANY role. The default viewer role legitimately has +# files.read=True, and the documented install layout (/opt/serverkit) sits +# under the allowed root /opt — so without the PROTECTED_ROOTS exclusion a +# viewer could read the backend .env, steal JWT_SECRET_KEY, and forge admin +# sessions. +# --------------------------------------------------------------------------- # + +import os as _os + +import app as _app_pkg +from app import paths as _paths +from app.services.file_service import FileService as _FileService + +_BACKEND_DIR = _os.path.realpath(_os.path.join(_os.path.dirname(_app_pkg.__file__), '..')) +_INSTALL_DIR = _os.path.realpath(_os.path.join(_BACKEND_DIR, '..')) + +PROTECTED_CASES = [ + _os.path.join(_INSTALL_DIR, '.env'), + _os.path.join(_BACKEND_DIR, '.env'), + _os.path.join(_BACKEND_DIR, 'run.py'), + _os.path.join(_BACKEND_DIR, 'instance', 'serverkit.db'), + _os.path.join(_INSTALL_DIR, 'frontend', 'dist', 'index.html'), + _os.path.join(_paths.SERVERKIT_CONFIG_DIR, 'deployments.json'), +] + + +@pytest.mark.parametrize('path', PROTECTED_CASES) +def test_panel_files_are_never_allowed(path): + assert not _FileService.is_path_allowed(path) + + +@pytest.mark.parametrize('url', [ + '/api/v1/files/read?path=' + _os.path.join(_INSTALL_DIR, '.env'), + '/api/v1/files/download?path=' + _os.path.join(_BACKEND_DIR, '.env'), +]) +def test_viewer_cannot_read_panel_files(client, role_headers, url): + """A viewer (files.read=True) still gets 403 on panel-internal paths.""" + resp = client.get(url, headers=role_headers[User.ROLE_VIEWER]) + assert resp.status_code == 403 + + +def test_normal_paths_still_allowed(tmp_path, monkeypatch): + """Paths under the allowed roots keep working (native path, any OS).""" + monkeypatch.setattr(_FileService, 'ALLOWED_ROOTS', [str(tmp_path)]) + assert _FileService.is_path_allowed(str(tmp_path / 'apps' / 'mysite' / 'index.php')) + assert _FileService.is_path_allowed(str(tmp_path / '.env')) # app envs unaffected + + # --------------------------------------------------------------------------- # # log_service._read_syslog: the service filter must never go through a shell # (subprocess.list2cmdline is cmd.exe quoting and does not stop $(...)/backtick From 0b0bfcce70adb18e00e168ba1e23246cfe0a2660 Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Sun, 2 Aug 2026 18:35:25 -0400 Subject: [PATCH 3/6] feat(security): daily GHSA feed check with admin notifications 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. --- backend/app/jobs/builtin_handlers.py | 17 ++ backend/app/services/security_feed_service.py | 159 ++++++++++++++++++ backend/tests/test_jobs.py | 10 +- backend/tests/test_security_feed.py | 143 ++++++++++++++++ 4 files changed, 325 insertions(+), 4 deletions(-) create mode 100644 backend/app/services/security_feed_service.py create mode 100644 backend/tests/test_security_feed.py diff --git a/backend/app/jobs/builtin_handlers.py b/backend/app/jobs/builtin_handlers.py index 5ea9ebaf..9587f07f 100644 --- a/backend/app/jobs/builtin_handlers.py +++ b/backend/app/jobs/builtin_handlers.py @@ -286,6 +286,22 @@ def run_job_retention(): return None +def run_security_feed_check(): + """Daily security-advisory feed check. + + Pulls the normalized GHSA feed from serverkit.ai and notifies admins when + the running panel version falls inside a published advisory's affected + range, plus one-time post-fix reminders (e.g. key rotation) after an + upgrade crosses a fix boundary. Fails silent on outbound errors.""" + from app.services.security_feed_service import check_security_feed + + try: + return check_security_feed() + except Exception as e: + logger.debug(f'Security feed check skipped: {e}') + return None + + def run_extension_update_check(): """Daily registry check for installed-extension updates (#50). @@ -350,6 +366,7 @@ def run_extension_update_check(): ('builtin.registrar_expiry', run_registrar_expiry, 'registrar-expiry', 86400, 300), ('builtin.backup_scheduler', run_backup_scheduler, 'backup-scheduler', 30, 30), ('builtin.extension_updates', run_extension_update_check, 'extension-updates', 86400, 600), + ('builtin.security_feed', run_security_feed_check, 'security-feed', 86400, 600), ('builtin.job_retention', run_job_retention, 'job-retention', 21600, 1500), ] diff --git a/backend/app/services/security_feed_service.py b/backend/app/services/security_feed_service.py new file mode 100644 index 00000000..a6849406 --- /dev/null +++ b/backend/app/services/security_feed_service.py @@ -0,0 +1,159 @@ +"""Security-advisory feed check. + +Once a day (builtin job `security-feed`) the panel pulls the normalized GHSA +feed from https://serverkit.ai/api/security-feed.json (a cached proxy of the +repo's published GitHub Security Advisories) and: + +1. notifies admins when the RUNNING version falls inside an advisory's + affected range ("you are vulnerable — update"), once per advisory; +2. after an upgrade crosses a fix boundary, fires a one-time post-fix + reminder when the advisory declares follow-up actions (e.g. rotating + JWT_SECRET_KEY after GHSA-rm3m-9mvw-68fh). + +Dedupe + last-good feed + the previously-seen version live in SystemSettings, +so restarts never re-notify. Outbound failures are silent: a panel with no +internet access simply never notifies. Set the `security_feed.enabled` +setting to false to opt out of the outbound call entirely. +""" +import json +import logging +import urllib.request + +from app import db +from app.models.system_settings import SystemSettings +from app.utils.version import compare_versions, get_panel_version + +logger = logging.getLogger(__name__) + +FEED_URL = 'https://serverkit.ai/api/security-feed.json' +FEED_TIMEOUT = 8 # seconds + +ENABLED_KEY = 'security_feed.enabled' +NOTIFIED_KEY = 'security_feed.notified' # json list of dedupe keys +LAST_VERSION_KEY = 'security_feed.last_version' +LAST_GOOD_KEY = 'security_feed.last_good' # json {"advisories": [...]} + +_OPERATORS = { + '<': lambda c: c < 0, + '<=': lambda c: c <= 0, + '>': lambda c: c > 0, + '>=': lambda c: c >= 0, + '=': lambda c: c == 0, + '==': lambda c: c == 0, +} + + +def version_in_range(version, range_str): + """Evaluate a GHSA-style version range (e.g. '< 1.7.68', '>= 1.2.0, < 1.5') + against `version`. Comma-separated clauses AND together. An empty or + unparseable range matches nothing (fail closed — never cry wolf).""" + if not version or not range_str: + return False + for clause in range_str.split(','): + clause = clause.strip() + if not clause: + continue + op, _, bound = clause.partition(' ') + pred = _OPERATORS.get(op.strip()) + bound = bound.strip() + if pred is None or not bound: + return False + if not pred(compare_versions(version, bound)): + return False + return True + + +def is_enabled(): + """Opt-out switch for the outbound feed call. Default on.""" + return SystemSettings.get(ENABLED_KEY, True) not in (False, 'false', '0') + + +def fetch_feed(): + """GET the advisory feed; fall back to the last-good copy on any failure. + + Returns a list of advisory dicts (possibly empty). Never raises.""" + try: + req = urllib.request.Request( + FEED_URL, headers={'User-Agent': 'ServerKit-SecurityFeed/1.0'}) + with urllib.request.urlopen(req, timeout=FEED_TIMEOUT) as resp: + data = json.loads(resp.read().decode('utf-8')) + advisories = data.get('advisories') or [] + SystemSettings.set(LAST_GOOD_KEY, {'advisories': advisories}, 'json') + db.session.commit() + return advisories + except Exception as e: + logger.debug(f'Security feed fetch failed (using last-good): {e}') + cached = SystemSettings.get(LAST_GOOD_KEY) or {} + return cached.get('advisories') or [] + + +def _notify(alert_type, message): + from app.notifications.service import NotificationBusService + NotificationBusService.send( + 'security.alert', + to='admins', + data={'alert_type': alert_type, 'message': message}, + action_path='/settings/system', + action_label='Review update', + ) + + +def check_security_feed(current_version=None): + """Daily job entry point. Returns a small result dict for the job log.""" + if not is_enabled(): + return {'skipped': 'disabled'} + + current = current_version or get_panel_version() + advisories = fetch_feed() + + notified = set(SystemSettings.get(NOTIFIED_KEY, []) or []) + alerts = 0 + + # 1) Running version inside an affected range -> "update now" alert. + for adv in advisories: + ghsa = adv.get('ghsa') + affected = adv.get('affected') + if not ghsa or not affected: + continue + if f'{ghsa}:affected' in notified: + continue + if not version_in_range(current, affected): + continue + fixed = adv.get('fixed_in') or 'a newer release' + _notify( + f'Known vulnerability {ghsa}', + f'{adv.get("summary", "Security advisory")} — this panel runs ' + f'{current}, which is in the affected range ({affected}). ' + f'Fixed in {fixed}. Update with: sudo serverkit update. ' + f'Details: {adv.get("url", "")}', + ) + notified.add(f'{ghsa}:affected') + alerts += 1 + + # 2) Upgrade crossing: previously-seen version was affected, current is + # not -> one-time post-fix reminder (key rotation, etc.). + previous = SystemSettings.get(LAST_VERSION_KEY) + if previous and previous != current: + for adv in advisories: + ghsa = adv.get('ghsa') + affected = adv.get('affected') + post_fix = adv.get('post_fix_action') + if not ghsa or not affected or not post_fix: + continue + if f'{ghsa}:postfix' in notified: + continue + if version_in_range(previous, affected) and not version_in_range(current, affected): + _notify( + f'Post-update action required ({ghsa})', + f'This panel previously ran {previous}, which was affected ' + f'by {ghsa} ({adv.get("summary", "security advisory")}). ' + f'The update is installed — complete these follow-up steps: ' + f'{post_fix}', + ) + notified.add(f'{ghsa}:postfix') + alerts += 1 + SystemSettings.set(LAST_VERSION_KEY, current) + SystemSettings.set(NOTIFIED_KEY, sorted(notified), 'json') + db.session.commit() + + return {'advisories': len(advisories), 'alerts': alerts} diff --git a/backend/tests/test_jobs.py b/backend/tests/test_jobs.py index fc39acd3..269260c6 100644 --- a/backend/tests/test_jobs.py +++ b/backend/tests/test_jobs.py @@ -175,18 +175,20 @@ def test_register_and_seed(self, app): assert 'builtin.health_check' in kinds assert 'builtin.backup_scheduler' in kinds assert 'builtin.extension_updates' in kinds + assert 'builtin.security_feed' in kinds assert 'builtin.job_retention' in kinds assert 'builtin.monitor_check' in kinds - assert len([k for k in kinds if k.startswith('builtin.')]) == 12 + assert len([k for k in kinds if k.startswith('builtin.')]) == 13 builtin_handlers.seed_builtin_schedules() - # 12 builtin.* schedules (incl. job-retention and the monitor sweep) + + # 13 builtin.* schedules (incl. job-retention, the monitor sweep and the + # security-feed check) + # login-link/SSO reapers + drift/FIM/bandwidth sweeps + the host doctor # sweep (plan 26) + the setup-health nag (plan 22). - assert ScheduledJob.query.count() == 19 + assert ScheduledJob.query.count() == 20 # Seeding twice doesn't duplicate. builtin_handlers.seed_builtin_schedules() - assert ScheduledJob.query.count() == 19 + assert ScheduledJob.query.count() == 20 class TestApi: diff --git a/backend/tests/test_security_feed.py b/backend/tests/test_security_feed.py new file mode 100644 index 00000000..4a618e30 --- /dev/null +++ b/backend/tests/test_security_feed.py @@ -0,0 +1,143 @@ +"""Tests for the security-advisory feed check (security_feed_service).""" +import pytest + +from app import db as _db +from app.models.system_settings import SystemSettings +from app.services import security_feed_service as sfs + +FEED = [ + { + 'ghsa': 'GHSA-test-0001', + 'cve': None, + 'severity': 'high', + 'summary': 'Test advisory one', + 'affected': '< 1.7.68', + 'fixed_in': '1.7.68', + 'url': 'https://example.test/GHSA-test-0001', + 'published_at': '2026-08-01T00:00:00Z', + 'post_fix_action': 'Rotate JWT_SECRET_KEY.', + }, + { + 'ghsa': 'GHSA-test-0002', + 'cve': None, + 'severity': 'medium', + 'summary': 'Test advisory two', + 'affected': '>= 1.5.0, < 1.6.0', + 'fixed_in': '1.6.0', + 'url': 'https://example.test/GHSA-test-0002', + 'published_at': '2026-08-01T00:00:00Z', + 'post_fix_action': None, + }, +] + + +@pytest.fixture +def feed_env(app, monkeypatch): + """Feed stub + notification recorder, inside an app context.""" + sent = [] + monkeypatch.setattr(sfs, 'fetch_feed', lambda: FEED) + from app.notifications.service import NotificationBusService + monkeypatch.setattr( + NotificationBusService, 'send', + classmethod(lambda cls, event, to, data=None, **kw: sent.append( + {'event': event, 'to': to, 'data': data, **kw}))) + with app.app_context(): + yield sent + + +# --------------------------------------------------------------------------- # +# version range evaluation +# --------------------------------------------------------------------------- # + +@pytest.mark.parametrize('version,range_str,expected', [ + ('1.7.59', '< 1.7.68', True), + ('1.7.68', '< 1.7.68', False), + ('1.7.67', '< 1.7.68', True), + ('1.5.4', '>= 1.5.0, < 1.6.0', True), + ('1.6.0', '>= 1.5.0, < 1.6.0', False), + ('1.4.9', '>= 1.5.0, < 1.6.0', False), + ('1.7.59', '', False), + ('1.7.59', None, False), + ('1.7.59', 'banana', False), +]) +def test_version_in_range(version, range_str, expected): + assert sfs.version_in_range(version, range_str) is expected + + +# --------------------------------------------------------------------------- # +# affected-version alerting +# --------------------------------------------------------------------------- # + +def test_alerts_when_current_version_affected(feed_env): + result = sfs.check_security_feed(current_version='1.7.59') + assert result['alerts'] == 1 + assert len(feed_env) == 1 + note = feed_env[0] + assert note['event'] == 'security.alert' + assert note['to'] == 'admins' + assert 'GHSA-test-0001' in note['data']['alert_type'] + assert '1.7.59' in note['data']['message'] + + +def test_no_alert_when_version_not_affected(feed_env): + result = sfs.check_security_feed(current_version='1.7.77') + assert result['alerts'] == 0 + assert feed_env == [] + + +def test_alert_fires_once_per_advisory(feed_env): + sfs.check_security_feed(current_version='1.7.59') + result = sfs.check_security_feed(current_version='1.7.59') + assert result['alerts'] == 0 + assert len(feed_env) == 1 + + +def test_multi_clause_range_alerts(feed_env): + result = sfs.check_security_feed(current_version='1.5.4') + assert result['alerts'] == 2 # both advisories match 1.5.4 + + +def test_disabled_skips_everything(app, feed_env): + SystemSettings.set(sfs.ENABLED_KEY, False, 'boolean') + _db.session.commit() + result = sfs.check_security_feed(current_version='1.7.59') + assert result == {'skipped': 'disabled'} + assert feed_env == [] + + +# --------------------------------------------------------------------------- # +# post-fix reminders after an upgrade crosses the fix boundary +# --------------------------------------------------------------------------- # + +def test_post_fix_reminder_after_upgrade_crossing(feed_env): + # Panel was last seen on an affected version... + sfs.check_security_feed(current_version='1.7.59') + assert len(feed_env) == 1 + # ...and comes up fixed: the advisory carries a post_fix_action. + result = sfs.check_security_feed(current_version='1.7.77') + assert result['alerts'] == 1 + note = feed_env[-1] + assert 'Post-update action' in note['data']['alert_type'] + assert 'Rotate JWT_SECRET_KEY' in note['data']['message'] + + +def test_no_post_fix_reminder_without_action(feed_env): + # 1.5.4 -> 1.6.0 crosses GHSA-test-0002, which declares no post-fix action. + sfs.check_security_feed(current_version='1.5.4') + sent_before = len(feed_env) + sfs.check_security_feed(current_version='1.6.0') + assert len(feed_env) == sent_before + + +def test_post_fix_reminder_fires_once(feed_env): + sfs.check_security_feed(current_version='1.7.59') + sfs.check_security_feed(current_version='1.7.77') + result = sfs.check_security_feed(current_version='1.7.77') + assert result['alerts'] == 0 + + +def test_first_seen_version_records_without_reminder(feed_env): + """A fresh panel (no last_version) never gets post-fix reminders.""" + result = sfs.check_security_feed(current_version='1.7.77') + assert result['alerts'] == 0 + assert SystemSettings.get(sfs.LAST_VERSION_KEY) == '1.7.77' From b8a031e73326c6a06092c1716345e9a2eff488da Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 2 Aug 2026 22:36:24 +0000 Subject: [PATCH 4/6] chore: bump version to 1.7.77 [skip ci] --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 049ac335..b181d356 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.7.76 +1.7.77 From 048a560f8cb2898f2ac4d6c5db917d67be903f56 Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Sun, 2 Aug 2026 18:56:33 -0400 Subject: [PATCH 5/6] fix(security): correct PROTECTED_ROOTS path math; tighten root matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 /.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. --- backend/app/services/file_service.py | 10 ++++++++-- backend/tests/test_files_rbac.py | 27 +++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/backend/app/services/file_service.py b/backend/app/services/file_service.py index 34c8195f..7a3977d2 100644 --- a/backend/app/services/file_service.py +++ b/backend/app/services/file_service.py @@ -40,10 +40,15 @@ class FileService: # the allowed root /opt, so without this exclusion any user with files.read # (including the default viewer role) could read the panel's own secrets # and forge admin sessions (GHSA-rm3m-9mvw-68fh). - _BACKEND_DIR = os.path.realpath(os.path.join(os.path.dirname(__file__), '..')) + # __file__ = /backend/app/services/file_service.py — two levels up + # is the backend dir, three is the install root. Getting this wrong by one + # level silently un-protects /.env and frontend/dist. + _BACKEND_DIR = os.path.realpath( + os.path.join(os.path.dirname(__file__), '..', '..')) _INSTALL_DIR = os.path.realpath(os.path.join(_BACKEND_DIR, '..')) PROTECTED_ROOTS = [ _INSTALL_DIR, + _BACKEND_DIR, # covers flat layouts (e.g. code at /app, install=/ inert) os.path.realpath(paths.SERVERKIT_CONFIG_DIR), ] @@ -69,7 +74,8 @@ def is_path_allowed(cls, path: str) -> bool: 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) + return any(real_path == root or real_path.startswith(root + os.sep) + for root in cls.ALLOWED_ROOTS) except (ValueError, OSError): return False diff --git a/backend/tests/test_files_rbac.py b/backend/tests/test_files_rbac.py index 12d58ed5..e5c7eb93 100644 --- a/backend/tests/test_files_rbac.py +++ b/backend/tests/test_files_rbac.py @@ -173,6 +173,33 @@ def test_panel_files_are_never_allowed(path): assert not _FileService.is_path_allowed(path) +def test_protected_roots_resolve_to_the_real_install_layout(): + """The service's own path math must land on /backend and + — one level off silently un-protects /.env and + frontend/dist (caught in review; the other tests compute the expected + layout independently of the service, so they can't see the service + getting it wrong).""" + services_dir = _os.path.dirname(_os.path.realpath( + _os.path.join(_os.path.dirname(_app_pkg.__file__), 'services', 'file_service.py'))) + backend_dir = _os.path.realpath(_os.path.join(services_dir, '..', '..')) + install_dir = _os.path.realpath(_os.path.join(backend_dir, '..')) + assert _FileService._BACKEND_DIR == backend_dir + assert _FileService._INSTALL_DIR == install_dir + + +def test_install_root_blocked_even_when_under_an_allowed_root(monkeypatch): + """Deployed layout simulation: the install dir sits INSIDE an allowed + root (/opt/serverkit under /opt). Protected must win over allowed — + this is the exact /read .env path from the advisory.""" + install_dir = _FileService._INSTALL_DIR + monkeypatch.setattr(_FileService, 'ALLOWED_ROOTS', [install_dir]) + assert not _FileService.is_path_allowed(_os.path.join(install_dir, '.env')) + assert not _FileService.is_path_allowed( + _os.path.join(install_dir, 'frontend', 'dist', 'index.html')) + assert not _FileService.is_path_allowed( + _os.path.join(install_dir, 'backend', 'run.py')) + + @pytest.mark.parametrize('url', [ '/api/v1/files/read?path=' + _os.path.join(_INSTALL_DIR, '.env'), '/api/v1/files/download?path=' + _os.path.join(_BACKEND_DIR, '.env'), From ee18e2e884b73aa5e95efd43d8191a56ac158c56 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 2 Aug 2026 22:58:13 +0000 Subject: [PATCH 6/6] chore: bump version to 1.7.78 [skip ci] --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index b181d356..170f86bf 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.7.77 +1.7.78