diff --git a/VERSION b/VERSION index 049ac335..170f86bf 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.7.76 +1.7.78 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/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/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/file_service.py b/backend/app/services/file_service.py index f9e8c05c..7a3977d2 100644 --- a/backend/app/services/file_service.py +++ b/backend/app/services/file_service.py @@ -33,6 +33,25 @@ 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). + # __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), + ] + # File extensions that can be edited in browser EDITABLE_EXTENSIONS = { '.txt', '.md', '.json', '.xml', '.yml', '.yaml', '.ini', '.conf', '.cfg', @@ -49,10 +68,14 @@ 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) - return any(real_path.startswith(root) for root in cls.ALLOWED_ROOTS) + if any(real_path == root or real_path.startswith(root + os.sep) + for root in cls.PROTECTED_ROOTS): + return False + 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/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/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_files_rbac.py b/backend/tests/test_files_rbac.py index 8be34026..e5c7eb93 100644 --- a/backend/tests/test_files_rbac.py +++ b/backend/tests/test_files_rbac.py @@ -140,6 +140,83 @@ 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) + + +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'), +]) +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 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' 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