diff --git a/CHANGELOG.md b/CHANGELOG.md index 93744057..e3758661 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,24 @@ awaiting a stable release: ### Added +- **The setup wizard now installs what it recommends (lean by default).** The + onboarding "Recommended for you" step is no longer decorative — it renders + real extensions matched to the use cases you pick (e.g. WordPress → WordPress + flagship; DevOps → Kubernetes, Automations, Git) as checkboxes that install on + Finish. Uncheck everything for a lean install; failed installs never block + finishing setup and can be retried from Extensions. Fresh installs no longer + force-install WordPress — it's offered in the wizard when the WordPress use + case is selected. Existing installs keep WordPress untouched. + +- **FTP, Cloud Provisioning, Remote Access, and Status Pages are now opt-in + extensions** — their backends moved out of core into + `serverkit-ftp`, `serverkit-cloud-provision`, `serverkit-remote-access`, and + `serverkit-status`, so a fresh panel that never uses them loads none of their + code or API surface. **Upgraders lose nothing:** a one-shot boot migration + re-acquires each extension's backend on panels that had installed it, and the + underlying data models stay in core. Uninstalling an extension now removes its + API surface too, not just its page. + - **Automations (tramo) replaces the Workflow Builder** — the drag-and-drop React-Flow Workflow Builder is retired in favour of **Automations** (`/automations`), an opt-in builtin extension that embeds diff --git a/README.md b/README.md index 1f7735de..dd711320 100644 --- a/README.md +++ b/README.md @@ -367,6 +367,8 @@ English | [Español](docs/README.es.md) | [中文版](docs/README.zh-CN.md) | [P ### 🎨 Customization +**Lean by default** — The guided setup wizard installs only the extensions matched to the use cases you pick (WordPress, web apps, self-hosting, DevOps) and nothing else. Uncheck anything you don't want; add more anytime from Extensions. WordPress is offered at setup rather than force-installed. + **Sidebar Presets** — Switch between Full, Web Hosting, Email Admin, DevOps, and Minimal views with one click **Collapsible Navigation** — Sidebar groups auto-expand on navigation and collapse when switching sections diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 3e9983cb..e4dd53f3 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -117,7 +117,6 @@ def create_app(config_name=None): # Register blueprints - Core from app.api.apps import apps_bp from app.api.domains import domains_bp - from app.api.tunnels import tunnels_bp from app.api.private_urls import private_urls_bp app.register_blueprint(apps_bp, url_prefix='/api/v1/apps') # "Services" is the user-facing term for Applications (§1 unification). @@ -248,9 +247,9 @@ def create_app(config_name=None): from app.api.files import files_bp app.register_blueprint(files_bp, url_prefix='/api/v1/files') - # Register blueprints - FTP Server - from app.api.ftp import ftp_bp - app.register_blueprint(ftp_bp, url_prefix='/api/v1/ftp') + # FTP Server is an opt-in builtin extension (serverkit-ftp, plan 47) — its + # blueprint loads from builtin-extensions/serverkit-ftp/ when installed, not + # from core. A fresh panel that never touches FTP loads none of it. # Register blueprints - Firewall from app.api.firewall import firewall_bp @@ -421,16 +420,21 @@ def create_app(config_name=None): from app.api.nginx_advanced import nginx_advanced_bp app.register_blueprint(nginx_advanced_bp, url_prefix='/api/v1/nginx/advanced') - # Register blueprints - Status Pages - from app.api.status_pages import status_pages_bp - app.register_blueprint(status_pages_bp, url_prefix='/api/v1/status') + # Status Pages is an opt-in builtin extension (serverkit-status, plan 47) — + # its blueprint (public + management routes) loads from builtin-extensions/ + # when installed, not from core. The StatusPage/StatusComponent models stay + # core (G2); the WordPress health-check job reaches the extension's sync + # helper via get_installed_extension_attr only when installed. - # Register blueprints - Cloud Provisioning - from app.api.cloud_provisioning import cloud_provisioning_bp - app.register_blueprint(cloud_provisioning_bp, url_prefix='/api/v1/cloud') + # Cloud Provisioning is an opt-in builtin extension (serverkit-cloud-provision, + # plan 47) — its blueprint loads from builtin-extensions/ when installed, not + # from core. The CloudProvider/CloudServer models stay core (G2). - # Register blueprints - Remote Access (WireGuard tunnels; imported above) - app.register_blueprint(tunnels_bp, url_prefix='/api/v1/tunnels') + # Remote Access (WireGuard tunnels) is an opt-in builtin extension + # (serverkit-remote-access, plan 47) — its blueprint loads from + # builtin-extensions/ when installed, not from core. The Tunnel/ExposedService + # models stay core (G2); the agent gateway reaches its reconcile helper via + # get_installed_extension_attr only when the extension is present. # Register blueprints - Performance from app.api.performance import performance_bp @@ -479,7 +483,9 @@ def create_app(config_name=None): app.register_blueprint(telemetry_bp, url_prefix='/api/v1/observability/events', name='obs_events') app.register_blueprint(uptime_bp, url_prefix='/api/v1/observability/uptime', name='obs_uptime') app.register_blueprint(fleet_monitor_bp, url_prefix='/api/v1/observability/fleet', name='obs_fleet') - app.register_blueprint(status_pages_bp, url_prefix='/api/v1/observability/status-pages', name='obs_status_pages') + # status-pages observability alias dropped with the serverkit-status + # extraction (plan 47) — it was unused by the frontend; status pages mount at + # /api/v1/status from the extension when installed. # Register blueprints - Agent Pairing (RustDesk-style short-code flow) from app.api.pairing import pairing_bp @@ -529,10 +535,14 @@ def create_app(config_name=None): try: from app.services.dns_provider_service import DNSProviderService from app.services.storage_provider_service import StorageProviderService - from app.services.cloud_provisioning_service import CloudProvisioningService n_dns = DNSProviderService.encrypt_legacy_secrets() n_store = StorageProviderService.encrypt_legacy_secrets() - n_cloud = CloudProvisioningService.encrypt_legacy_secrets() + # Cloud-provider legacy-secret encryption moved out with the + # serverkit-cloud-provision extension (plan 47). It was a one-time + # migration of pre-encryption rows; any panel reaching this version + # already ran it in an earlier boot (idempotent), and the extension + # encrypts on write, so there's nothing left for core to do here. + n_cloud = 0 n_settings = SettingsService.migrate_legacy_secrets() # Migrate DNS zones with an inline Cloudflare token onto the canonical # connection store (idempotent), so every zone resolves creds the same way. @@ -598,6 +608,16 @@ def create_app(config_name=None): import logging logging.getLogger(__name__).warning(f'Extension auto-install: {e}') + # One-shot: re-acquire the now-extracted backend for converted builtins + # that first shipped frontend-only (plan 47 Phase 2), so an upgraded panel + # that installed them frontend-only doesn't lose the API. Best-effort. + try: + from app.services.extension_migration import run_backend_acquisition + run_backend_acquisition() + except Exception as e: + import logging + logging.getLogger(__name__).warning(f'Extension backend acquisition: {e}') + # Start metrics history collection in background from app.services.metrics_history_service import MetricsHistoryService if not MetricsHistoryService.is_running(): diff --git a/backend/app/agent_gateway.py b/backend/app/agent_gateway.py index 1426d006..e15ff0a9 100644 --- a/backend/app/agent_gateway.py +++ b/backend/app/agent_gateway.py @@ -303,8 +303,14 @@ class AgentNamespace(Namespace): try: caps = (data or {}).get('capabilities') or {} if caps.get('wireguard'): - from app.services.tunnel_broker_service import TunnelBrokerService - TunnelBrokerService.schedule_reconcile(agent.server_id) + # TunnelBrokerService now lives in the serverkit-remote-access + # extension (plan 47); reach it only when installed, no-op otherwise. + from app.services.plugin_service import get_installed_extension_attr + TunnelBrokerService = get_installed_extension_attr( + 'serverkit-remote-access', 'tunnel_broker_service', + 'TunnelBrokerService') + if TunnelBrokerService is not None: + TunnelBrokerService.schedule_reconcile(agent.server_id) except Exception: logger.debug("tunnel reconcile scheduling skipped", exc_info=True) diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index cc2eacec..f9fbd6f3 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -183,8 +183,28 @@ def complete_onboarding(): if invalid: return jsonify({'error': f'Invalid use cases: {", ".join(invalid)}'}), 400 - # Save onboarding use cases + # Slugs the wizard installed (recorded for Setup Health / support). The + # actual installs happen client-side via the plugin install endpoints; this + # just persists what onboarding did (plan 47). + installed_extensions = data.get('installed_extensions', []) + if not isinstance(installed_extensions, list): + return jsonify({'error': 'installed_extensions must be a list'}), 400 + installed_extensions = [ + s for s in installed_extensions if isinstance(s, str) and s.strip() + ] + + # Save onboarding use cases + what the wizard installed SettingsService.set('onboarding_use_cases', use_cases, user_id=user.id) + SettingsService.set('onboarding_installed_extensions', installed_extensions, + user_id=user.id) + + # Keep the panel lean: suppress wizard-optional flagships (WordPress) the + # user didn't install so the boot seeder won't re-add them (plan 47). + try: + from app.services import plugin_service + plugin_service.finalize_setup_flagships() + except Exception: + pass # Mark setup as complete SettingsService.complete_setup(user_id=user.id) diff --git a/backend/app/api/plugins.py b/backend/app/api/plugins.py index 183cfc29..6587b6d6 100644 --- a/backend/app/api/plugins.py +++ b/backend/app/api/plugins.py @@ -336,6 +336,20 @@ def list_builtin(): return jsonify({'builtin': list_builtin_extensions()}) +@plugins_bp.route('/recommendations', methods=['GET']) +@jwt_required() +def recommendations(): + """Extensions recommended for the given onboarding use cases (setup wizard). + + Query: ``?use_cases=wordpress,web-apps``. Returns each recommended + extension's real display_name/description + install source, so the wizard + can render checkboxes that install the actual extension on Finish.""" + from app.services.plugin_service import recommend_extensions_for_use_cases + raw = request.args.get('use_cases', '') + use_cases = [u.strip() for u in raw.split(',') if u.strip()] + return jsonify({'recommendations': recommend_extensions_for_use_cases(use_cases)}) + + @plugins_bp.route('/builtin//install', methods=['POST']) @jwt_required() def install_builtin(slug): diff --git a/backend/app/jobs/builtin_handlers.py b/backend/app/jobs/builtin_handlers.py index 388e61f1..caa7119f 100644 --- a/backend/app/jobs/builtin_handlers.py +++ b/backend/app/jobs/builtin_handlers.py @@ -90,10 +90,16 @@ def run_health_checks(): from app.models.wordpress_site import WordPressSite from app.models.status_page import StatusComponent from app.services.environment_health_service import EnvironmentHealthService - from app.services.status_page_service import StatusPageService + from app.services.plugin_service import get_installed_extension_attr _prune_old_health_checks() + # Status-page component sync lives in the serverkit-status extension (plan 47); + # reach it only when installed. StatusComponent rows can't exist without it, + # so a lean panel simply runs the health checks without the sync. + StatusPageService = get_installed_extension_attr( + 'serverkit-status', 'status_page_service', 'StatusPageService') + sites = WordPressSite.query.filter_by(is_production=True).all() for site in sites: try: @@ -105,9 +111,10 @@ def run_health_checks(): overall = result.get('overall_status') if not overall: continue - components = StatusComponent.query.filter_by(wordpress_site_id=site.id).all() - for comp in components: - StatusPageService.sync_component_from_health(comp, overall) + if StatusPageService is not None: + components = StatusComponent.query.filter_by(wordpress_site_id=site.id).all() + for comp in components: + StatusPageService.sync_component_from_health(comp, overall) except Exception as e: logger.error(f'Health check failed for site {site.id}: {e}') try: diff --git a/backend/app/services/extension_migration.py b/backend/app/services/extension_migration.py index 6311700b..219082af 100644 --- a/backend/app/services/extension_migration.py +++ b/backend/app/services/extension_migration.py @@ -108,6 +108,10 @@ GATED_BUILTIN_SLUGS = { } _MARKER_KEY = 'extensions.auto_installed_slugs' +# Slugs whose now-extracted backend has been re-acquired on an upgraded panel +# (plan 47 Phase 2). Separate from _MARKER_KEY so the one-shot backend pickup +# runs even for slugs already auto-installed frontend-only in a prior release. +_BACKEND_ACQUIRED_KEY = 'extensions.backend_acquired_slugs' def _processed_slugs(): @@ -193,3 +197,72 @@ def run_auto_install(): if changed: _save_processed(processed) + + +def _backend_acquired_slugs(): + from app.services.settings_service import SettingsService + raw = SettingsService.get(_BACKEND_ACQUIRED_KEY, '') + if not raw: + return set() + try: + return set(json.loads(raw)) + except (ValueError, TypeError): + return {s.strip() for s in str(raw).split(',') if s.strip()} + + +def run_backend_acquisition(): + """Re-acquire the backend for converted builtins that gained one (plan 47). + + Some builtins (ftp, cloud-provision, remote-access, status) first shipped as + *frontend-only* extensions whose API was still served by CORE. Plan 47 moves + that backend into the extension and deregisters it from core. On an upgraded + panel that already installed such an extension frontend-only, the installed + row has ``has_backend=False`` and no backend on disk — so without this pass + the API would simply vanish. For each converted slug that is installed but + missing the now-available backend, force-reinstall from ``builtin-extensions/`` + once (idempotent via a marker). Best-effort; never blocks startup. + """ + import os + + done = _backend_acquired_slugs() + try: + from app.services import plugin_service + builtins = {e['slug']: e for e in plugin_service.list_builtin_extensions()} + except Exception as e: + logger.warning(f'Backend acquisition skipped (builtins unavailable): {e}') + return + + changed = False + for slug in CONVERTED_BUILTIN_SLUGS: + if slug in done: + continue + entry = builtins.get(slug) + # Only slugs that now actually ship a backend/ dir are candidates. + if not entry or not os.path.isdir(os.path.join(entry['path'], 'backend')): + continue + row = InstalledPlugin.query.filter_by(slug=slug).first() + if not row: + # Not installed — run_auto_install handles fresh (re-)acquisition, + # and a fresh panel discovers it in the Marketplace. + continue + backend_on_disk = os.path.isdir( + os.path.join(plugin_service.BACKEND_PLUGINS_DIR, slug)) + if row.has_backend and backend_on_disk: + done.add(slug) + changed = True + continue + try: + plugin_service.install_from_path( + entry['path'], user_id=row.installed_by, force=True, + source_type='builtin') + logger.info(f'Acquired extracted backend for {slug} (plan 47)') + done.add(slug) + changed = True + except Exception as e: + logger.warning( + f'Backend acquisition for {slug} failed (retry next boot): {e}') + continue # leave unmarked so we retry + + if changed: + from app.services.settings_service import SettingsService + SettingsService.set(_BACKEND_ACQUIRED_KEY, json.dumps(sorted(done))) diff --git a/backend/app/services/plugin_service.py b/backend/app/services/plugin_service.py index 781953d7..ed9394dc 100644 --- a/backend/app/services/plugin_service.py +++ b/backend/app/services/plugin_service.py @@ -65,6 +65,14 @@ BUILTIN_EXTENSIONS_DIR = os.environ.get( FLAGSHIP_SLUGS = ['serverkit-wordpress', 'serverkit-cloudflare-ops'] _FLAGSHIP_UNINSTALLED_KEY = 'extensions.flagship_uninstalled' +# Wizard-optional flagships (plan 47): offered — not force-installed — during +# onboarding. On a FRESH install (setup wizard not yet completed) the boot-time +# seeder skips these so the panel stays lean; the wizard installs them only when +# their use case is picked. Existing installs (setup already completed) keep the +# prior always-seeded behavior — zero regression. cloudflare-ops is NOT here: it +# is route-only (no sidebar item) and stays seeded for the CF connection surface. +WIZARD_OPTIONAL_FLAGSHIP_SLUGS = ['serverkit-wordpress'] + def _ensure_builtin_backend_importable(slug): """Register ``builtin-extensions//backend`` as the dashed package @@ -1560,6 +1568,104 @@ def list_builtin_extensions(): return out +# ── Onboarding wizard: use-case → recommended extension slugs (plan 47) ── +# +# Drives the setup wizard's "Recommended for you" install checkboxes. Every slug +# here MUST resolve to a real bundled builtin (builtin-extensions/) or a registry +# entry — guarded by tests/test_onboarding_recommendations.py so the map can never +# point at a dead slug. Order within a use case is significant; the resolver +# de-dupes across the selected use cases preserving first-seen order. +RECOMMENDED_EXTENSIONS_BY_USE_CASE = { + 'wordpress': ['serverkit-wordpress'], + 'web-apps': ['serverkit-git', 'serverkit-status'], + 'self-hosted': ['serverkit-dns-server', 'serverkit-status', 'serverkit-mail'], + 'devops': ['serverkit-k8s', 'serverkit-tramo', 'serverkit-git'], +} + + +def _recommendation_index(): + """slug -> {slug, display_name, description, category, source, installed, + status}. Merges the registry catalog with bundled builtins so a recommended + slug resolves regardless of where it lives; builtins win on slug collision.""" + index = {} + try: + from app.services import registry_service + for entry in registry_service.list_catalog(include_bundled=True): + slug = entry.get('slug') + if not slug: + continue + index[slug] = { + 'slug': slug, + 'display_name': entry.get('display_name') or slug, + 'description': entry.get('description') or '', + 'category': entry.get('category') or 'utility', + 'source': 'registry', + 'installed': bool(entry.get('installed')), + 'status': entry.get('status') or 'not_installed', + } + except Exception as e: + logger.debug(f'Registry unavailable for onboarding recommendations: {e}') + for entry in list_builtin_extensions(): + manifest = entry.get('manifest') or {} + slug = entry['slug'] + index[slug] = { + 'slug': slug, + 'display_name': manifest.get('display_name') or slug, + 'description': manifest.get('description') or '', + 'category': manifest.get('category') or 'utility', + 'source': 'builtin', + 'installed': bool(entry.get('installed')), + 'status': entry.get('status') or 'not_installed', + } + return index + + +def recommend_extensions_for_use_cases(use_cases): + """Resolve selected onboarding use cases to installable extension metadata. + + Returns a de-duped, order-preserving list of dicts (one per recommended + extension). Slugs that don't resolve to a builtin/registry entry are silently + dropped (defensive — the map is test-guarded against dead slugs).""" + index = _recommendation_index() + seen = set() + out = [] + for use_case in (use_cases or []): + for slug in RECOMMENDED_EXTENSIONS_BY_USE_CASE.get(use_case, []): + if slug in seen: + continue + seen.add(slug) + meta = index.get(slug) + if meta: + out.append(meta) + return out + + +def finalize_setup_flagships(): + """Keep the panel lean after onboarding completes: any wizard-optional + flagship (WordPress) the user didn't install during the wizard gets the + uninstall marker so the boot-time flagship seeder won't re-add it on later + boots. Called from the complete-onboarding endpoint (plan 47).""" + for slug in WIZARD_OPTIONAL_FLAGSHIP_SLUGS: + if not InstalledPlugin.query.filter_by(slug=slug).first(): + _set_flagship_uninstalled(slug, True) + + +def get_installed_extension_attr(slug, module, attr, default=None): + """Return ``attr`` from an installed extension's backend module, else default. + + A core seam that calls into a feature which now lives in an extracted + extension (plan 47) uses this so it no-ops cleanly when the extension isn't + installed. Resolves ``app.plugins..`` (the dashed package the + plugin loader wires up on install); any import/lookup failure yields + ``default``.""" + try: + import importlib + mod = importlib.import_module(f'app.plugins.{slug}.{module}') + return getattr(mod, attr, default) + except Exception: + return default + + def install_builtin_extension(slug, user_id=None): """Install a builtin extension by its slug (folder lookup).""" if not os.path.isdir(BUILTIN_EXTENSIONS_DIR): @@ -1682,6 +1788,26 @@ def _seed_flagship_row(entry): return plugin +def _wizard_optional_flagships_suppressed(): + """True when wizard-optional flagships (WordPress) should NOT be boot-seeded: + a fresh install whose setup wizard hasn't completed (plan 47). + + Bypassed under TESTING so the extensive WordPress test suite keeps its + seeded flagship; the real gate is exercised by dedicated proving tests that + toggle ``app.config['TESTING']``.""" + try: + from flask import current_app + if current_app and current_app.config.get('TESTING'): + return False + except Exception: + pass + try: + from app.services.settings_service import SettingsService + return bool(SettingsService.needs_setup()) + except Exception: + return False + + def seed_flagship_extensions(): """Ensure bundled flagship extensions (D4) have an active row on EVERY panel — fresh AND upgrade — unless the user explicitly uninstalled them. @@ -1690,10 +1816,16 @@ def seed_flagship_extensions(): picks up the seeded row and registers the blueprints (in-place). Best-effort. """ uninstalled = _flagship_uninstalled_set() + suppress_optional = _wizard_optional_flagships_suppressed() available = {e['slug']: e for e in list_builtin_extensions()} for slug in FLAGSHIP_SLUGS: if slug in uninstalled: continue + # Fresh install (plan 47): don't seed wizard-optional flagships (WordPress) + # until the setup wizard installs them. Existing/completed-setup installs + # fall through and seed as before. + if suppress_optional and slug in WIZARD_OPTIONAL_FLAGSHIP_SLUGS: + continue entry = available.get(slug) if not entry: continue diff --git a/backend/app/services/settings_service.py b/backend/app/services/settings_service.py index 988e0843..c02d4e4d 100644 --- a/backend/app/services/settings_service.py +++ b/backend/app/services/settings_service.py @@ -54,6 +54,11 @@ class SettingsService: 'type': 'json', 'description': 'Use cases selected during onboarding wizard' }, + 'onboarding_installed_extensions': { + 'value': [], + 'type': 'json', + 'description': 'Extension slugs installed by the setup wizard' + }, 'dev_mode': { 'value': False, 'type': 'boolean', diff --git a/backend/tests/BASELINE_COUNT b/backend/tests/BASELINE_COUNT index 8c9a94ce..13d7e774 100644 --- a/backend/tests/BASELINE_COUNT +++ b/backend/tests/BASELINE_COUNT @@ -1 +1 @@ -2588 +2623 diff --git a/backend/tests/test_api_authz_hardening.py b/backend/tests/test_api_authz_hardening.py index cb865439..c1a66281 100644 --- a/backend/tests/test_api_authz_hardening.py +++ b/backend/tests/test_api_authz_hardening.py @@ -248,29 +248,9 @@ def test_env_secret_masking_preserved(app, client, env_rbac): # Phase 4 — status pages (mutations admin-only, reads open) + raw docker-DB. # --------------------------------------------------------------------------- # -def test_status_page_mutations_admin_only(app, client): - """Reads stay open to any authed user; every mutation is admin-only via the - @admin_required decorator (plan 18 Decision 6). A non-admin is 403.""" - from app import db - dev = _mk_user(db, 'sp_dev', role='developer') - admin = _mk_user(db, 'sp_admin', role='admin') - - # Reads are open to a plain developer. - assert client.get('/api/v1/status/', headers=_token(dev.id)).status_code == 200 - - # Admin can create a page. - r = client.post('/api/v1/status/', json={'name': 'Ops', 'slug': 'ops'}, headers=_token(admin.id)) - assert r.status_code == 201 - page_id = r.get_json()['id'] - - # A developer is denied every mutation. - assert client.post('/api/v1/status/', json={'name': 'X', 'slug': 'x'}, headers=_token(dev.id)).status_code == 403 - assert client.put(f'/api/v1/status/{page_id}', json={'name': 'Y'}, headers=_token(dev.id)).status_code == 403 - assert client.delete(f'/api/v1/status/{page_id}', headers=_token(dev.id)).status_code == 403 - assert client.post(f'/api/v1/status/{page_id}/components', json={'name': 'c', 'check_type': 'http'}, - headers=_token(dev.id)).status_code == 403 - assert client.post(f'/api/v1/status/{page_id}/incidents', json={'title': 't'}, - headers=_token(dev.id)).status_code == 403 +# Status-page mutation authz moved to tests/test_status_extraction.py (plan 47) — +# the status blueprint now lives in the serverkit-status extension, so the test +# installs it before exercising the admin-only mutations. def test_raw_docker_db_routes_admin_only(app, client): @@ -375,11 +355,5 @@ def test_wordpress_standalone_host_ops_admin_only(app, client, scoping_rbac, per assert call(url, json={}, headers=scoping_rbac.admin).status_code != 403, f'admin denied {url}' -@pytest.mark.parametrize('persona', ['viewer', 'member', 'foreign']) -def test_status_component_check_admin_only(app, client, scoping_rbac, persona): - """#4 — `POST /status/components//check` triggers a live check + persists a - result (write-ish), so it joins its 9 admin-only mutation siblings. A non-admin - is 403; admin passes the gate (the check itself may 404 on a missing id).""" - url = '/api/v1/status/components/999999/check' - assert client.post(url, headers=getattr(scoping_rbac, persona)).status_code == 403, persona - assert client.post(url, headers=scoping_rbac.admin).status_code != 403 +# test_status_component_check_admin_only moved to tests/test_status_extraction.py +# (plan 47) — the status blueprint is now in the serverkit-status extension. diff --git a/backend/tests/test_cloud_provision_extraction.py b/backend/tests/test_cloud_provision_extraction.py new file mode 100644 index 00000000..c8704c7b --- /dev/null +++ b/backend/tests/test_cloud_provision_extraction.py @@ -0,0 +1,116 @@ +"""Prove the Cloud Provisioning extraction (plan 47 Phase 2). + +A fresh panel no longer carries the cloud-provider API in core; installing the +serverkit-cloud-provision builtin registers `/api/v1/cloud`. The CloudProvider +model stays core (G2). Also covers the one-shot backend re-acquisition that keeps +upgraded panels (which installed the extension frontend-only) from losing the API. +""" +import importlib +import sys + +import pytest + +from app import db +from app.models.plugin import InstalledPlugin +from app.services import plugin_service + +SLUG = 'serverkit-cloud-provision' +_PKG = f'app.plugins.{SLUG}' + + +def test_core_has_no_cloud_routes(app): + rules = [r.rule for r in app.url_map.iter_rules()] + assert [r for r in rules if r.startswith('/api/v1/cloud/')] == [] + + +def test_core_import_of_cloud_service_is_gone(): + with pytest.raises(ModuleNotFoundError): + importlib.import_module('app.services.cloud_provisioning_service') + + +def test_cloud_in_converted_builtin_slugs(): + from app.services.extension_migration import CONVERTED_BUILTIN_SLUGS + assert SLUG in CONVERTED_BUILTIN_SLUGS + + +def test_cloud_model_stays_core(): + """G2: the CloudProvider/CloudServer models remain importable from core.""" + from app.models.cloud_server import CloudProvider, CloudServer # noqa: F401 + + +@pytest.fixture +def install_dirs(tmp_path, monkeypatch): + backend = tmp_path / 'plugins_backend' + frontend = tmp_path / 'plugins_frontend' + backend.mkdir() + frontend.mkdir() + monkeypatch.setattr(plugin_service, 'BACKEND_PLUGINS_DIR', str(backend)) + monkeypatch.setattr(plugin_service, 'FRONTEND_PLUGINS_DIR', str(frontend)) + + added = str(backend) + app_pkg_plugins = importlib.import_module('app.plugins') + if added not in app_pkg_plugins.__path__: + app_pkg_plugins.__path__.append(added) + + yield {'backend': backend, 'frontend': frontend} + + if added in app_pkg_plugins.__path__: + app_pkg_plugins.__path__.remove(added) + for name in list(sys.modules): + if name == _PKG or name.startswith(_PKG + '.'): + del sys.modules[name] + + +def test_install_cloud_extension_registers_routes(app, client, auth_headers, install_dirs): + available = {e['slug'] for e in plugin_service.list_builtin_extensions()} + assert SLUG in available + + plugin = plugin_service.install_builtin_extension(SLUG) + assert plugin.status == InstalledPlugin.STATUS_ACTIVE + assert plugin.has_backend is True + assert plugin.url_prefix == '/api/v1/cloud' + + resp = client.get('/api/v1/cloud/providers', headers=auth_headers) + assert resp.status_code not in (404, 503), resp.status_code + + +def test_cloud_provider_secret_encrypted_at_rest(app, install_dirs): + """The extracted service still encrypts provider secrets at rest (moved here + from test_provider_secret_encryption.py).""" + plugin_service.install_builtin_extension(SLUG) + from app.models.cloud_server import CloudProvider + from app.utils.crypto import is_encrypted, decrypt_secret_safe + + svc_mod = importlib.import_module(f'{_PKG}.cloud_provisioning_service') + CloudProvisioningService = svc_mod.CloudProvisioningService + + p = CloudProvisioningService.create_provider( + {'provider_type': 'digitalocean', 'name': 'do', 'api_key': 'do-token-xyz'}) + row = CloudProvider.query.get(p.id) + assert row.api_key_encrypted != 'do-token-xyz' + assert is_encrypted(row.api_key_encrypted) is True + assert decrypt_secret_safe(row.api_key_encrypted) == 'do-token-xyz' + assert CloudProvisioningService._auth_headers(row)['Authorization'] == 'Bearer do-token-xyz' + + +def test_backend_acquisition_upgrades_frontend_only_install(app, install_dirs): + """An upgraded panel that installed the extension frontend-only re-acquires + the now-extracted backend (plan 47 Phase 2 migration).""" + from app.services import extension_migration + + # Simulate the pre-plan-47 state: a frontend-only install (API came from core) + row = InstalledPlugin( + name=SLUG, display_name='Cloud Provisioning', slug=SLUG, version='1.0.0', + source_type='builtin', status=InstalledPlugin.STATUS_ACTIVE, + ) + row.has_backend = False + row.has_frontend = True + db.session.add(row) + db.session.commit() + + extension_migration.run_backend_acquisition() + + refreshed = InstalledPlugin.query.filter_by(slug=SLUG).first() + assert refreshed.has_backend is True + rules = [r.rule for r in app.url_map.iter_rules()] + assert any(r.startswith('/api/v1/cloud/') for r in rules) diff --git a/backend/tests/test_ftp_extraction.py b/backend/tests/test_ftp_extraction.py new file mode 100644 index 00000000..e28b8d29 --- /dev/null +++ b/backend/tests/test_ftp_extraction.py @@ -0,0 +1,76 @@ +"""Prove the FTP extraction (plan 47 Phase 2). + +A fresh panel no longer carries the FTP API in core; installing the +serverkit-ftp builtin registers `/api/v1/ftp` and its routes respond (which also +exercises the extension's relative-import rewiring). Uninstall removes it again. +""" +import sys + +import pytest + +import app as app_pkg +from app.models.plugin import InstalledPlugin +from app.services import plugin_service + +SLUG = 'serverkit-ftp' +_PKG = f'app.plugins.{SLUG}' + + +def test_core_has_no_ftp_routes(app): + rules = [r.rule for r in app.url_map.iter_rules()] + assert [r for r in rules if r.startswith('/api/v1/ftp')] == [] + + +def test_core_import_of_ftp_service_is_gone(): + """The FTP service left core — importing it from app.services must fail.""" + import importlib + with pytest.raises(ModuleNotFoundError): + importlib.import_module('app.services.ftp_service') + + +def test_ftp_in_converted_builtin_slugs(): + from app.services.extension_migration import CONVERTED_BUILTIN_SLUGS + assert SLUG in CONVERTED_BUILTIN_SLUGS + + +@pytest.fixture +def install_dirs(tmp_path, monkeypatch): + backend = tmp_path / 'plugins_backend' + frontend = tmp_path / 'plugins_frontend' + backend.mkdir() + frontend.mkdir() + monkeypatch.setattr(plugin_service, 'BACKEND_PLUGINS_DIR', str(backend)) + monkeypatch.setattr(plugin_service, 'FRONTEND_PLUGINS_DIR', str(frontend)) + + added = str(backend) + import importlib + app_pkg_plugins = importlib.import_module('app.plugins') + if added not in app_pkg_plugins.__path__: + app_pkg_plugins.__path__.append(added) + + yield {'backend': backend, 'frontend': frontend} + + if added in app_pkg_plugins.__path__: + app_pkg_plugins.__path__.remove(added) + for name in list(sys.modules): + if name == _PKG or name.startswith(_PKG + '.'): + del sys.modules[name] + + +def test_install_ftp_extension_registers_routes(app, client, auth_headers, install_dirs): + available = {e['slug'] for e in plugin_service.list_builtin_extensions()} + assert SLUG in available, 'serverkit-ftp builtin folder should exist' + + plugin = plugin_service.install_builtin_extension(SLUG) + assert plugin.status == InstalledPlugin.STATUS_ACTIVE + assert plugin.has_backend is True + assert plugin.url_prefix == '/api/v1/ftp' + + resp = client.get('/api/v1/ftp/status', headers=auth_headers) + assert resp.status_code not in (404, 503), resp.status_code + + +def test_uninstall_removes_ftp_plugin(app, install_dirs): + plugin = plugin_service.install_builtin_extension(SLUG) + assert plugin_service.uninstall_plugin(plugin.id) is True + assert InstalledPlugin.query.filter_by(slug=SLUG).first() is None diff --git a/backend/tests/test_observability_namespace.py b/backend/tests/test_observability_namespace.py index 87012392..18ffe212 100644 --- a/backend/tests/test_observability_namespace.py +++ b/backend/tests/test_observability_namespace.py @@ -15,7 +15,9 @@ def test_observability_aliases_registered(): assert any(r.startswith('/api/v1/observability/events') for r in rules) assert any(r.startswith('/api/v1/observability/uptime') for r in rules) assert any(r.startswith('/api/v1/observability/fleet') for r in rules) - assert any(r.startswith('/api/v1/observability/status-pages') for r in rules) + # The status-pages observability alias was dropped when status pages moved to + # the serverkit-status extension (plan 47); the public status route now ships + # with that extension rather than core. def test_original_prefixes_still_present(): @@ -25,8 +27,6 @@ def test_original_prefixes_still_present(): assert any(r.startswith('/api/v1/monitoring') for r in rules) assert any(r.startswith('/api/v1/telemetry') for r in rules) assert any(r.startswith('/api/v1/fleet-monitor') for r in rules) - # Public status page route untouched. - assert '/api/v1/status/public/' in rules def test_events_alias_matches_telemetry(app, client, auth_headers): diff --git a/backend/tests/test_onboarding_recommendations.py b/backend/tests/test_onboarding_recommendations.py new file mode 100644 index 00000000..c640ff00 --- /dev/null +++ b/backend/tests/test_onboarding_recommendations.py @@ -0,0 +1,154 @@ +"""Plan 47 Phase 1 — the setup wizard installs what it recommends. + +Proves the use-case → extension-slug map has no dead slugs, the recommendation +resolver/endpoint returns real extension metadata, complete-onboarding persists +what the wizard installed, and wizard-optional flagships (WordPress) are gated on +fresh installs but kept on completed-setup ones. +""" +import pytest + +from app import db +from app.models.plugin import InstalledPlugin +from app.models.user import User +from app.services import plugin_service as ps +from app.services.settings_service import SettingsService +from werkzeug.security import generate_password_hash +from flask_jwt_extended import create_access_token + + +# ── map integrity ──────────────────────────────────────────────────────────── + +def test_recommendation_map_has_no_dead_slugs(app): + """Every slug in the use-case map resolves to a builtin or registry entry.""" + with app.app_context(): + index = ps._recommendation_index() + dead = [ + slug + for slugs in ps.RECOMMENDED_EXTENSIONS_BY_USE_CASE.values() + for slug in slugs + if slug not in index + ] + assert dead == [], f'recommendation map points at dead slugs: {dead}' + + +def test_recommend_resolves_and_dedupes(app): + with app.app_context(): + recs = ps.recommend_extensions_for_use_cases(['web-apps', 'devops']) + slugs = [r['slug'] for r in recs] + # git appears in both use cases — must be de-duped, first-seen order kept + assert slugs.count('serverkit-git') == 1 + assert 'serverkit-status' in slugs + assert 'serverkit-k8s' in slugs + assert 'serverkit-tramo' in slugs + # every entry carries real metadata (not just a display string) + for r in recs: + assert r['display_name'] + assert r['source'] in ('builtin', 'registry') + assert 'installed' in r + + +def test_recommend_empty_use_cases(app): + with app.app_context(): + assert ps.recommend_extensions_for_use_cases([]) == [] + assert ps.recommend_extensions_for_use_cases(None) == [] + + +# ── endpoint ───────────────────────────────────────────────────────────────── + +def test_recommendations_endpoint(client, auth_headers): + resp = client.get('/api/v1/plugins/recommendations?use_cases=wordpress', + headers=auth_headers) + assert resp.status_code == 200 + slugs = [r['slug'] for r in resp.get_json()['recommendations']] + assert 'serverkit-wordpress' in slugs + + +def test_recommendations_endpoint_requires_auth(client): + resp = client.get('/api/v1/plugins/recommendations?use_cases=wordpress') + assert resp.status_code == 401 + + +# ── complete-onboarding persists installed slugs ───────────────────────────── + +def test_complete_onboarding_persists_installed_extensions(client, auth_headers): + resp = client.post('/api/v1/auth/complete-onboarding', + json={'use_cases': ['devops'], + 'installed_extensions': ['serverkit-k8s', 'serverkit-git']}, + headers=auth_headers) + assert resp.status_code == 200 + assert SettingsService.get('onboarding_installed_extensions') == [ + 'serverkit-k8s', 'serverkit-git'] + assert SettingsService.get('onboarding_use_cases') == ['devops'] + + +def test_complete_onboarding_rejects_bad_installed_extensions(client, auth_headers): + resp = client.post('/api/v1/auth/complete-onboarding', + json={'use_cases': [], 'installed_extensions': 'nope'}, + headers=auth_headers) + assert resp.status_code == 400 + + +def test_complete_onboarding_still_completes_without_installs(client, auth_headers): + """A wizard run that installs nothing (the lean outcome) still finishes.""" + resp = client.post('/api/v1/auth/complete-onboarding', + json={'use_cases': []}, + headers=auth_headers) + assert resp.status_code == 200 + assert SettingsService.get('setup_completed') is True + + +# ── wizard-optional flagship gating ────────────────────────────────────────── + +def test_finalize_setup_flagships_marks_uninstalled_when_absent(app): + with app.app_context(): + InstalledPlugin.query.filter_by(slug='serverkit-wordpress').delete() + db.session.commit() + ps.finalize_setup_flagships() + assert 'serverkit-wordpress' in ps._flagship_uninstalled_set() + + +def test_finalize_setup_flagships_keeps_installed(app): + with app.app_context(): + # fixture seeds WordPress under the TESTING bypass; finalize must not + # mark an installed flagship as uninstalled + assert InstalledPlugin.query.filter_by(slug='serverkit-wordpress').first() + ps.finalize_setup_flagships() + assert 'serverkit-wordpress' not in ps._flagship_uninstalled_set() + + +def test_wordpress_flagship_skipped_on_fresh_install(app): + """With the TESTING bypass off, a fresh install (no setup) skips WordPress.""" + with app.app_context(): + InstalledPlugin.query.filter_by(slug='serverkit-wordpress').delete() + db.session.commit() + app.config['TESTING'] = False + try: + assert SettingsService.needs_setup() is True + ps.seed_flagship_extensions() + assert InstalledPlugin.query.filter_by( + slug='serverkit-wordpress').first() is None + # cloudflare-ops (not wizard-optional) is still seeded + assert InstalledPlugin.query.filter_by( + slug='serverkit-cloudflare-ops').first() is not None + finally: + app.config['TESTING'] = True + + +def test_wordpress_flagship_seeded_on_completed_setup(app): + """An existing/completed-setup install keeps seeding WordPress (no regression).""" + with app.app_context(): + InstalledPlugin.query.filter_by(slug='serverkit-wordpress').delete() + db.session.commit() + user = User(email='a@t.local', username='admin', is_active=True, + role=User.ROLE_ADMIN, password_hash=generate_password_hash('x')) + db.session.add(user) + db.session.commit() + SettingsService.complete_setup(user_id=user.id) + app.config['TESTING'] = False + try: + assert SettingsService.needs_setup() is False + ps.seed_flagship_extensions() + assert InstalledPlugin.query.filter_by( + slug='serverkit-wordpress').first() is not None + finally: + app.config['TESTING'] = True diff --git a/backend/tests/test_provider_secret_encryption.py b/backend/tests/test_provider_secret_encryption.py index 7e7f1ea2..9386ee63 100644 --- a/backend/tests/test_provider_secret_encryption.py +++ b/backend/tests/test_provider_secret_encryption.py @@ -107,20 +107,10 @@ def test_dns_legacy_plaintext_migration(app): # ── cloud providers ───────────────────────────────────────────────────────── - -def test_cloud_provider_secret_encrypted_at_rest(app): - from app.models.cloud_server import CloudProvider - from app.services.cloud_provisioning_service import CloudProvisioningService - from app.utils.crypto import is_encrypted, decrypt_secret_safe - - p = CloudProvisioningService.create_provider( - {'provider_type': 'digitalocean', 'name': 'do', 'api_key': 'do-token-xyz'}) - row = CloudProvider.query.get(p.id) - assert row.api_key_encrypted != 'do-token-xyz' - assert is_encrypted(row.api_key_encrypted) is True - assert decrypt_secret_safe(row.api_key_encrypted) == 'do-token-xyz' - # the auth header uses the decrypted token, not the stored blob - assert CloudProvisioningService._auth_headers(row)['Authorization'] == 'Bearer do-token-xyz' +# CloudProvisioningService moved to the serverkit-cloud-provision extension +# (plan 47); its secret-at-rest coverage now lives in +# tests/test_cloud_provision_extraction.py (installs the extension first). The +# CloudProvider model stays core (G2). # ── storage (config-file backed) ──────────────────────────────────────────── diff --git a/backend/tests/test_remote_access_extraction.py b/backend/tests/test_remote_access_extraction.py new file mode 100644 index 00000000..2590792a --- /dev/null +++ b/backend/tests/test_remote_access_extraction.py @@ -0,0 +1,236 @@ +"""Prove the Remote Access (WireGuard tunnels) extraction (plan 47 Phase 2). + +The tunnels blueprint + broker/publish/netutil services move out of core into +serverkit-remote-access; the Tunnel/ExposedService models stay core (G2). This +also re-homes the former test_tunnel_netutil.py (pure subnet/port helpers) and +test_tunnel_reconcile.py (panel-authoritative reconcile) against the extension. +""" +import importlib +import sys +from types import SimpleNamespace + +import pytest + +from app import db +from app.models.server import Server +from app.models.tunnel import Tunnel +from app.models.exposed_service import ExposedService +from app.models.plugin import InstalledPlugin +from app.services import agent_registry as ar_mod +from app.services import plugin_service + +SLUG = 'serverkit-remote-access' +_PKG = f'app.plugins.{SLUG}' + + +# ── extraction shape ───────────────────────────────────────────────────────── + +def test_core_has_no_tunnel_routes(app): + rules = [r.rule for r in app.url_map.iter_rules()] + assert [r for r in rules if r.startswith('/api/v1/tunnels')] == [] + + +def test_core_import_of_tunnel_services_gone(): + for mod in ('tunnel_broker_service', 'tunnel_publish_service', 'tunnel_netutil'): + with pytest.raises(ModuleNotFoundError): + importlib.import_module(f'app.services.{mod}') + + +def test_remote_access_in_converted_builtin_slugs(): + from app.services.extension_migration import CONVERTED_BUILTIN_SLUGS + assert SLUG in CONVERTED_BUILTIN_SLUGS + + +def test_tunnel_models_stay_core(): + from app.models.tunnel import Tunnel # noqa: F401 + from app.models.exposed_service import ExposedService # noqa: F401 + + +@pytest.fixture +def install_dirs(tmp_path, monkeypatch): + backend = tmp_path / 'plugins_backend' + frontend = tmp_path / 'plugins_frontend' + backend.mkdir() + frontend.mkdir() + monkeypatch.setattr(plugin_service, 'BACKEND_PLUGINS_DIR', str(backend)) + monkeypatch.setattr(plugin_service, 'FRONTEND_PLUGINS_DIR', str(frontend)) + + added = str(backend) + app_pkg_plugins = importlib.import_module('app.plugins') + if added not in app_pkg_plugins.__path__: + app_pkg_plugins.__path__.append(added) + + yield {'backend': backend, 'frontend': frontend} + + if added in app_pkg_plugins.__path__: + app_pkg_plugins.__path__.remove(added) + for name in list(sys.modules): + if name == _PKG or name.startswith(_PKG + '.'): + del sys.modules[name] + + +@pytest.fixture +def ext(app, install_dirs): + plugin_service.install_builtin_extension(SLUG) + tn = importlib.import_module(f'{_PKG}.tunnel_netutil') + broker = importlib.import_module(f'{_PKG}.tunnel_broker_service') + return SimpleNamespace(tn=tn, TunnelBrokerService=broker.TunnelBrokerService) + + +def test_install_registers_tunnel_routes(app, client, auth_headers, install_dirs): + plugin = plugin_service.install_builtin_extension(SLUG) + assert plugin.status == InstalledPlugin.STATUS_ACTIVE + assert plugin.has_backend is True + assert plugin.url_prefix == '/api/v1/tunnels' + rules = [r.rule for r in app.url_map.iter_rules()] + assert any(r.startswith('/api/v1/tunnels') for r in rules) + + +# ── re-homed: tunnel_netutil pure helpers ──────────────────────────────────── + +def test_pick_subnet_first_free(ext): + cidr, edge, priv = ext.tn.pick_subnet([]) + assert (cidr, edge, priv) == ('10.88.0.0/24', '10.88.0.1', '10.88.0.2') + + +def test_pick_subnet_skips_used(ext): + cidr, edge, priv = ext.tn.pick_subnet(['10.88.0.0/24', '10.88.1.0/24']) + assert (cidr, edge, priv) == ('10.88.2.0/24', '10.88.2.1', '10.88.2.2') + + +def test_pick_subnet_exhausted(ext): + used = ['10.88.%d.0/24' % i for i in range(256)] + with pytest.raises(RuntimeError): + ext.tn.pick_subnet(used) + + +def test_interface_name_is_stable_and_kernel_valid(ext): + name = ext.tn.interface_name_for('3fa9c2b1-dead-beef-0000-111122223333') + assert name == 'skwg3fa9c2b1' + assert len(name) <= 15 + assert name.replace('skwg', '').isalnum() + + +def test_derive_status(ext): + now = 1_000_000 + assert ext.tn.derive_status(now - 10, now) == 'up' + assert ext.tn.derive_status(now - 10_000, now) == 'degraded' + assert ext.tn.derive_status(0, now) == 'pending' + assert ext.tn.derive_status(now, now, interface_up=False) == 'down' + + +def test_validate_endpoint_host(ext): + assert ext.tn.validate_endpoint_host('203.0.113.5') is True + assert ext.tn.validate_endpoint_host('10.0.0.5') is True + assert ext.tn.validate_endpoint_host('127.0.0.1') is False + assert ext.tn.validate_endpoint_host('0.0.0.0') is False + assert ext.tn.validate_endpoint_host('') is False + assert ext.tn.validate_endpoint_host('not-an-ip') is False + + +def test_pick_listen_port(ext): + assert ext.tn.pick_listen_port([]) == 51820 + assert ext.tn.pick_listen_port([51820]) == 51821 + assert ext.tn.pick_listen_port([51820, 51821, 51823]) == 51822 + + +def test_pick_listen_port_exhausted(ext): + used = list(range(ext.tn.DEFAULT_LISTEN_PORT, + ext.tn.DEFAULT_LISTEN_PORT + ext.tn.LISTEN_PORT_RANGE)) + with pytest.raises(RuntimeError): + ext.tn.pick_listen_port(used) + + +def test_diagnose_reachability(ext): + assert ext.tn.diagnose_reachability(0, None, False)['state'] == 'interface_down' + assert ext.tn.diagnose_reachability(1_700_000_000, 999, True)['state'] == 'ok' + assert ext.tn.diagnose_reachability(0, 5, True)['state'] == 'connecting' + d = ext.tn.diagnose_reachability(0, 600, True) + assert d['state'] == 'no_handshake' + assert 'blocked' in d['hint'].lower() + + +# ── re-homed: panel-authoritative reconcile ────────────────────────────────── + +def _server(name, ip=None): + s = Server(name=name, ip_address=ip, status='online') + db.session.add(s) + db.session.commit() + return s + + +def _record_send(monkeypatch): + calls = [] + + def fake_send(server_id, action, params=None, user_id=None, timeout=30.0): + calls.append({'server_id': server_id, 'action': action, 'params': params or {}}) + return {'success': True, 'data': {}} + + monkeypatch.setattr(ar_mod.agent_registry, 'send_command', fake_send) + return calls + + +def _tunnel(edge, priv, iface, third): + t = Tunnel( + edge_server_id=edge.id, private_server_id=priv.id, + interface_name=iface, subnet='10.88.%d.0/24' % third, + edge_wg_ip='10.88.%d.1' % third, private_wg_ip='10.88.%d.2' % third, + listen_port=51820, edge_pubkey='EDGEPUB', private_pubkey='PRIVPUB', + status='up', + ) + db.session.add(t) + db.session.commit() + return t + + +def test_reconcile_private_side(ext, monkeypatch): + calls = _record_send(monkeypatch) + edge = _server('edge', ip='203.0.113.9') + priv = _server('home') + t = _tunnel(edge, priv, 'skwgrec01', 7) + db.session.add(ExposedService(tunnel_id=t.id, hostname='jelly.example.com', + port=8096, status='published')) + db.session.commit() + + res = ext.TunnelBrokerService.reconcile_server(priv.id) + assert res and res[0]['ok'] + actions = [c['action'] for c in calls] + assert 'wireguard:interface:up' in actions + assert 'wireguard:peer:set' in actions + assert 'wireguard:forward' in actions + peer = next(c['params'] for c in calls if c['action'] == 'wireguard:peer:set') + assert peer['public_key'] == 'EDGEPUB' + assert peer['endpoint'] == '203.0.113.9:51820' + assert peer['persistent_keepalive'] == 25 + fwd = next(c['params'] for c in calls if c['action'] == 'wireguard:forward') + assert fwd['listen_port'] == 8096 and fwd['target_port'] == 8096 + assert fwd['listen_ip'] == '10.88.7.2' + + +def test_reconcile_edge_side(ext, monkeypatch): + calls = _record_send(monkeypatch) + edge = _server('edge2', ip='198.51.100.4') + priv = _server('home2') + _tunnel(edge, priv, 'skwgrec02', 8) + + ext.TunnelBrokerService.reconcile_server(edge.id) + actions = [c['action'] for c in calls] + assert 'wireguard:interface:up' in actions + assert 'wireguard:peer:set' in actions + assert 'firewall:allow_port' in actions + peer = next(c['params'] for c in calls if c['action'] == 'wireguard:peer:set') + assert peer['public_key'] == 'PRIVPUB' + assert 'endpoint' not in peer + + +def test_reconcile_skips_errored_tunnels(ext, monkeypatch): + calls = _record_send(monkeypatch) + edge = _server('edge3', ip='192.0.2.7') + priv = _server('home3') + t = _tunnel(edge, priv, 'skwgrec03', 9) + t.status = 'error' + db.session.commit() + + res = ext.TunnelBrokerService.reconcile_server(priv.id) + assert res == [] + assert calls == [] diff --git a/backend/tests/test_status_extraction.py b/backend/tests/test_status_extraction.py new file mode 100644 index 00000000..4651bcf6 --- /dev/null +++ b/backend/tests/test_status_extraction.py @@ -0,0 +1,133 @@ +"""Prove the Status Pages extraction (plan 47 Phase 2). + +The status-pages blueprint (public + management routes) and its service move into +serverkit-status; the StatusPage/StatusComponent models stay core (G2). Core's +WordPress health-check job syncs status components only when the extension is +installed (no crash on a lean panel). Re-homes the status-page mutation authz +test from test_api_authz_hardening.py. +""" +import importlib +import sys + +import pytest +from werkzeug.security import generate_password_hash +from flask_jwt_extended import create_access_token + +from app import db +from app.models.user import User +from app.models.plugin import InstalledPlugin +from app.services import plugin_service + +SLUG = 'serverkit-status' +_PKG = f'app.plugins.{SLUG}' + + +def test_core_has_no_status_page_routes(app): + """The status-pages management/public routes are gone from core (the + /api/v1/status/apps container-status aggregator is a different blueprint).""" + rules = [r.rule for r in app.url_map.iter_rules()] + assert '/api/v1/status/public/' not in rules + assert '/api/v1/status/' not in rules + assert not any('observability/status-pages' in r for r in rules) + + +def test_core_import_of_status_service_gone(): + with pytest.raises(ModuleNotFoundError): + importlib.import_module('app.services.status_page_service') + + +def test_status_in_converted_builtin_slugs(): + from app.services.extension_migration import CONVERTED_BUILTIN_SLUGS + assert SLUG in CONVERTED_BUILTIN_SLUGS + + +def test_status_models_stay_core(): + from app.models.status_page import ( # noqa: F401 + StatusPage, StatusComponent) + + +def test_health_check_job_no_ops_without_extension(app): + """run_health_checks must not crash on a lean panel (no serverkit-status).""" + from app.jobs.builtin_handlers import run_health_checks + run_health_checks() # no production sites, no extension — clean no-op + + +@pytest.fixture +def install_dirs(tmp_path, monkeypatch): + backend = tmp_path / 'plugins_backend' + frontend = tmp_path / 'plugins_frontend' + backend.mkdir() + frontend.mkdir() + monkeypatch.setattr(plugin_service, 'BACKEND_PLUGINS_DIR', str(backend)) + monkeypatch.setattr(plugin_service, 'FRONTEND_PLUGINS_DIR', str(frontend)) + + added = str(backend) + app_pkg_plugins = importlib.import_module('app.plugins') + if added not in app_pkg_plugins.__path__: + app_pkg_plugins.__path__.append(added) + + yield {'backend': backend, 'frontend': frontend} + + if added in app_pkg_plugins.__path__: + app_pkg_plugins.__path__.remove(added) + for name in list(sys.modules): + if name == _PKG or name.startswith(_PKG + '.'): + del sys.modules[name] + + +def _mk_user(username, role): + u = User(email=f'{username}@t.local', username=username, role=role, + is_active=True, password_hash=generate_password_hash('x')) + db.session.add(u) + db.session.commit() + return u + + +def _token(uid): + return {'Authorization': f'Bearer {create_access_token(identity=uid)}'} + + +def test_install_registers_status_routes(app, client, auth_headers, install_dirs): + plugin = plugin_service.install_builtin_extension(SLUG) + assert plugin.status == InstalledPlugin.STATUS_ACTIVE + assert plugin.has_backend is True + assert plugin.url_prefix == '/api/v1/status' + rules = [r.rule for r in app.url_map.iter_rules()] + assert '/api/v1/status/public/' in rules + + +def test_status_page_mutations_admin_only(app, client, install_dirs): + """Reads open to any authed user; every mutation is admin-only (relocated + from test_api_authz_hardening.py — now runs against the installed extension).""" + plugin_service.install_builtin_extension(SLUG) + dev = _mk_user('sp_dev', 'developer') + admin = _mk_user('sp_admin', 'admin') + + assert client.get('/api/v1/status/', headers=_token(dev.id)).status_code == 200 + + r = client.post('/api/v1/status/', json={'name': 'Ops', 'slug': 'ops'}, + headers=_token(admin.id)) + assert r.status_code == 201 + page_id = r.get_json()['id'] + + assert client.post('/api/v1/status/', json={'name': 'X', 'slug': 'x'}, + headers=_token(dev.id)).status_code == 403 + assert client.put(f'/api/v1/status/{page_id}', json={'name': 'Y'}, + headers=_token(dev.id)).status_code == 403 + assert client.delete(f'/api/v1/status/{page_id}', + headers=_token(dev.id)).status_code == 403 + assert client.post(f'/api/v1/status/{page_id}/components', + json={'name': 'c', 'check_type': 'http'}, + headers=_token(dev.id)).status_code == 403 + assert client.post(f'/api/v1/status/{page_id}/incidents', json={'title': 't'}, + headers=_token(dev.id)).status_code == 403 + + +@pytest.mark.parametrize('persona', ['viewer', 'member', 'foreign']) +def test_status_component_check_admin_only(app, client, scoping_rbac, install_dirs, persona): + """POST /status/components//check is a write-ish mutation → admin-only + (relocated from test_api_authz_hardening.py, now against the extension).""" + plugin_service.install_builtin_extension(SLUG) + url = '/api/v1/status/components/999999/check' + assert client.post(url, headers=getattr(scoping_rbac, persona)).status_code == 403, persona + assert client.post(url, headers=scoping_rbac.admin).status_code != 403 diff --git a/backend/tests/test_tunnel_netutil.py b/backend/tests/test_tunnel_netutil.py deleted file mode 100644 index f0ada3b4..00000000 --- a/backend/tests/test_tunnel_netutil.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Unit tests for the tunnel broker's pure helpers (roadmap #7 / #11). - -These import only app.services.tunnel_netutil (stdlib-only) so they prove -the subnet allocator, interface-name derivation and health classification -without needing a DB or connected agents. -""" - -from app.services import tunnel_netutil as tn - - -def test_pick_subnet_first_free(): - cidr, edge, priv = tn.pick_subnet([]) - assert cidr == '10.88.0.0/24' - assert edge == '10.88.0.1' - assert priv == '10.88.0.2' - - -def test_pick_subnet_skips_used(): - cidr, edge, priv = tn.pick_subnet(['10.88.0.0/24', '10.88.1.0/24']) - assert cidr == '10.88.2.0/24' - assert edge == '10.88.2.1' - assert priv == '10.88.2.2' - - -def test_pick_subnet_exhausted(): - used = ['10.88.%d.0/24' % i for i in range(256)] - try: - tn.pick_subnet(used) - assert False, "expected RuntimeError on exhausted pool" - except RuntimeError: - pass - - -def test_interface_name_is_stable_and_kernel_valid(): - name = tn.interface_name_for('3fa9c2b1-dead-beef-0000-111122223333') - assert name == 'skwg3fa9c2b1' - assert len(name) <= 15 - assert name.replace('skwg', '').isalnum() - - -def test_derive_status(): - now = 1_000_000 - assert tn.derive_status(now - 10, now) == 'up' - assert tn.derive_status(now - 10_000, now) == 'degraded' - assert tn.derive_status(0, now) == 'pending' - assert tn.derive_status(now, now, interface_up=False) == 'down' - - -def test_validate_endpoint_host(): - assert tn.validate_endpoint_host('203.0.113.5') is True - assert tn.validate_endpoint_host('10.0.0.5') is True # private edge OK - assert tn.validate_endpoint_host('127.0.0.1') is False # loopback - assert tn.validate_endpoint_host('0.0.0.0') is False # unspecified - assert tn.validate_endpoint_host('') is False - assert tn.validate_endpoint_host('not-an-ip') is False - - -def test_pick_listen_port(): - assert tn.pick_listen_port([]) == 51820 - assert tn.pick_listen_port([51820]) == 51821 - assert tn.pick_listen_port([51820, 51821, 51823]) == 51822 # fills the gap - - -def test_pick_listen_port_exhausted(): - used = list(range(tn.DEFAULT_LISTEN_PORT, tn.DEFAULT_LISTEN_PORT + tn.LISTEN_PORT_RANGE)) - try: - tn.pick_listen_port(used) - assert False, "expected RuntimeError on exhausted port pool" - except RuntimeError: - pass - - -def test_diagnose_reachability(): - assert tn.diagnose_reachability(0, None, False)['state'] == 'interface_down' - assert tn.diagnose_reachability(1_700_000_000, 999, True)['state'] == 'ok' - assert tn.diagnose_reachability(0, 5, True)['state'] == 'connecting' - d = tn.diagnose_reachability(0, 600, True) - assert d['state'] == 'no_handshake' - assert 'blocked' in d['hint'].lower() diff --git a/backend/tests/test_tunnel_reconcile.py b/backend/tests/test_tunnel_reconcile.py deleted file mode 100644 index 977d0336..00000000 --- a/backend/tests/test_tunnel_reconcile.py +++ /dev/null @@ -1,101 +0,0 @@ -"""Phase 3 #19: panel-authoritative tunnel reconcile. - -Proves reconcile_server re-issues the right agent commands for each side -of a tunnel, by monkeypatching agent_registry.send_command to record calls -(no live agent needed). -""" - -from app import db -from app.models.server import Server -from app.models.tunnel import Tunnel -from app.models.exposed_service import ExposedService -from app.services import agent_registry as ar_mod -from app.services.tunnel_broker_service import TunnelBrokerService - - -def _server(name, ip=None): - s = Server(name=name, ip_address=ip, status='online') - db.session.add(s) - db.session.commit() - return s - - -def _record_send(monkeypatch): - calls = [] - - def fake_send(server_id, action, params=None, user_id=None, timeout=30.0): - calls.append({'server_id': server_id, 'action': action, 'params': params or {}}) - return {'success': True, 'data': {}} - - monkeypatch.setattr(ar_mod.agent_registry, 'send_command', fake_send) - return calls - - -def _tunnel(edge, priv, iface, third): - t = Tunnel( - edge_server_id=edge.id, private_server_id=priv.id, - interface_name=iface, subnet='10.88.%d.0/24' % third, - edge_wg_ip='10.88.%d.1' % third, private_wg_ip='10.88.%d.2' % third, - listen_port=51820, edge_pubkey='EDGEPUB', private_pubkey='PRIVPUB', - status='up', - ) - db.session.add(t) - db.session.commit() - return t - - -def test_reconcile_private_side(app, monkeypatch): - calls = _record_send(monkeypatch) - edge = _server('edge', ip='203.0.113.9') - priv = _server('home') - t = _tunnel(edge, priv, 'skwgrec01', 7) - db.session.add(ExposedService(tunnel_id=t.id, hostname='jelly.example.com', port=8096, status='published')) - db.session.commit() - - res = TunnelBrokerService.reconcile_server(priv.id) - assert res and res[0]['ok'] - - actions = [c['action'] for c in calls] - assert 'wireguard:interface:up' in actions - assert 'wireguard:peer:set' in actions - assert 'wireguard:forward' in actions - - peer = next(c['params'] for c in calls if c['action'] == 'wireguard:peer:set') - assert peer['public_key'] == 'EDGEPUB' # private peers with the edge - assert peer['endpoint'] == '203.0.113.9:51820' # dials the edge endpoint - assert peer['persistent_keepalive'] == 25 - - fwd = next(c['params'] for c in calls if c['action'] == 'wireguard:forward') - assert fwd['listen_port'] == 8096 and fwd['target_port'] == 8096 - assert fwd['listen_ip'] == '10.88.7.2' - - -def test_reconcile_edge_side(app, monkeypatch): - calls = _record_send(monkeypatch) - edge = _server('edge2', ip='198.51.100.4') - priv = _server('home2') - _tunnel(edge, priv, 'skwgrec02', 8) - - TunnelBrokerService.reconcile_server(edge.id) - - actions = [c['action'] for c in calls] - assert 'wireguard:interface:up' in actions - assert 'wireguard:peer:set' in actions - assert 'firewall:allow_port' in actions # edge re-opens its UDP port - - peer = next(c['params'] for c in calls if c['action'] == 'wireguard:peer:set') - assert peer['public_key'] == 'PRIVPUB' # edge peers with the private host - assert 'endpoint' not in peer # edge has no endpoint for the private peer - - -def test_reconcile_skips_errored_tunnels(app, monkeypatch): - calls = _record_send(monkeypatch) - edge = _server('edge3', ip='192.0.2.7') - priv = _server('home3') - t = _tunnel(edge, priv, 'skwgrec03', 9) - t.status = 'error' - db.session.commit() - - res = TunnelBrokerService.reconcile_server(priv.id) - assert res == [] - assert calls == [] diff --git a/builtin-extensions/serverkit-cloud-provision/backend/__init__.py b/builtin-extensions/serverkit-cloud-provision/backend/__init__.py new file mode 100644 index 00000000..8d59cc3c --- /dev/null +++ b/builtin-extensions/serverkit-cloud-provision/backend/__init__.py @@ -0,0 +1,11 @@ +"""ServerKit Cloud Provisioning extension backend package. + +Extracted from core (plan 47): the cloud-provider blueprint + service ship here +so a fresh panel that never provisions cloud servers loads none of it. Mounted +under ``/api/v1/cloud`` via the manifest's ``url_prefix``. The CloudProvider / +CloudServer models stay in core (G2); this package imports them. Upgrades +re-acquire it automatically (``CONVERTED_BUILTIN_SLUGS``). +""" +from .cloud_provisioning import cloud_provisioning_bp + +__all__ = ['cloud_provisioning_bp'] diff --git a/backend/app/api/cloud_provisioning.py b/builtin-extensions/serverkit-cloud-provision/backend/cloud_provisioning.py similarity index 98% rename from backend/app/api/cloud_provisioning.py rename to builtin-extensions/serverkit-cloud-provision/backend/cloud_provisioning.py index c8a806e3..bc3029c6 100644 --- a/backend/app/api/cloud_provisioning.py +++ b/builtin-extensions/serverkit-cloud-provision/backend/cloud_provisioning.py @@ -1,6 +1,6 @@ from flask import Blueprint, request, jsonify from flask_jwt_extended import jwt_required -from app.services.cloud_provisioning_service import CloudProvisioningService +from .cloud_provisioning_service import CloudProvisioningService cloud_provisioning_bp = Blueprint('cloud_provisioning', __name__) diff --git a/backend/app/services/cloud_provisioning_service.py b/builtin-extensions/serverkit-cloud-provision/backend/cloud_provisioning_service.py similarity index 100% rename from backend/app/services/cloud_provisioning_service.py rename to builtin-extensions/serverkit-cloud-provision/backend/cloud_provisioning_service.py diff --git a/builtin-extensions/serverkit-cloud-provision/plugin.json b/builtin-extensions/serverkit-cloud-provision/plugin.json index 9f8f8c1c..62331332 100644 --- a/builtin-extensions/serverkit-cloud-provision/plugin.json +++ b/builtin-extensions/serverkit-cloud-provision/plugin.json @@ -8,6 +8,8 @@ "category": "deployment", "homepage": "https://github.com/jhd3197/ServerKit", "permissions": ["network"], + "entry_point": "cloud_provisioning:cloud_provisioning_bp", + "url_prefix": "/api/v1/cloud", "icon": "", "contributions": { "tabs": [ diff --git a/builtin-extensions/serverkit-ftp/backend/__init__.py b/builtin-extensions/serverkit-ftp/backend/__init__.py new file mode 100644 index 00000000..a4514248 --- /dev/null +++ b/builtin-extensions/serverkit-ftp/backend/__init__.py @@ -0,0 +1,10 @@ +"""ServerKit FTP extension backend package. + +Extracted from core (plan 47): the FTP blueprint + service ship here so a fresh +panel that never touches FTP loads none of it. Mounted under ``/api/v1/ftp`` via +the manifest's ``url_prefix``; upgrades re-acquire it automatically +(``CONVERTED_BUILTIN_SLUGS``). +""" +from .ftp import ftp_bp + +__all__ = ['ftp_bp'] diff --git a/backend/app/api/ftp.py b/builtin-extensions/serverkit-ftp/backend/ftp.py similarity index 98% rename from backend/app/api/ftp.py rename to builtin-extensions/serverkit-ftp/backend/ftp.py index 48e5bd09..8f304031 100644 --- a/backend/app/api/ftp.py +++ b/builtin-extensions/serverkit-ftp/backend/ftp.py @@ -2,8 +2,8 @@ from flask import Blueprint, request, jsonify -from ..middleware.rbac import admin_required, viewer_required -from ..services.ftp_service import FTPService +from app.middleware.rbac import admin_required, viewer_required +from .ftp_service import FTPService ftp_bp = Blueprint('ftp', __name__) diff --git a/backend/app/services/ftp_service.py b/builtin-extensions/serverkit-ftp/backend/ftp_service.py similarity index 100% rename from backend/app/services/ftp_service.py rename to builtin-extensions/serverkit-ftp/backend/ftp_service.py diff --git a/builtin-extensions/serverkit-ftp/plugin.json b/builtin-extensions/serverkit-ftp/plugin.json index 3f8ddc9f..6ec9d253 100644 --- a/builtin-extensions/serverkit-ftp/plugin.json +++ b/builtin-extensions/serverkit-ftp/plugin.json @@ -8,6 +8,8 @@ "category": "utility", "homepage": "https://github.com/jhd3197/ServerKit", "permissions": ["shell", "filesystem"], + "entry_point": "ftp:ftp_bp", + "url_prefix": "/api/v1/ftp", "icon": "", "contributions": { "tabs": [ diff --git a/builtin-extensions/serverkit-remote-access/backend/__init__.py b/builtin-extensions/serverkit-remote-access/backend/__init__.py new file mode 100644 index 00000000..f766423c --- /dev/null +++ b/builtin-extensions/serverkit-remote-access/backend/__init__.py @@ -0,0 +1,13 @@ +"""ServerKit Remote Access extension backend package (WireGuard tunnels). + +Extracted from core (plan 47): the tunnels blueprint + broker/publish/netutil +services ship here so a fresh panel that never exposes a NAT'd service loads none +of it. Mounted under ``/api/v1/tunnels`` via the manifest's ``url_prefix``. The +Tunnel / ExposedService models stay in core (G2); this package imports them. +Core's agent gateway reaches TunnelBrokerService.schedule_reconcile through +``plugin_service.get_installed_extension_attr`` only when the extension is +installed. Upgrades re-acquire it automatically (``CONVERTED_BUILTIN_SLUGS``). +""" +from .tunnels import tunnels_bp + +__all__ = ['tunnels_bp'] diff --git a/backend/app/services/tunnel_broker_service.py b/builtin-extensions/serverkit-remote-access/backend/tunnel_broker_service.py similarity index 99% rename from backend/app/services/tunnel_broker_service.py rename to builtin-extensions/serverkit-remote-access/backend/tunnel_broker_service.py index 410d5689..627b53fb 100644 --- a/backend/app/services/tunnel_broker_service.py +++ b/builtin-extensions/serverkit-remote-access/backend/tunnel_broker_service.py @@ -14,7 +14,7 @@ from app import db from app.models.server import Server from app.models.tunnel import Tunnel from app.services.agent_registry import agent_registry -from app.services import tunnel_netutil +from . import tunnel_netutil logger = logging.getLogger(__name__) diff --git a/backend/app/services/tunnel_netutil.py b/builtin-extensions/serverkit-remote-access/backend/tunnel_netutil.py similarity index 100% rename from backend/app/services/tunnel_netutil.py rename to builtin-extensions/serverkit-remote-access/backend/tunnel_netutil.py diff --git a/backend/app/services/tunnel_publish_service.py b/builtin-extensions/serverkit-remote-access/backend/tunnel_publish_service.py similarity index 100% rename from backend/app/services/tunnel_publish_service.py rename to builtin-extensions/serverkit-remote-access/backend/tunnel_publish_service.py diff --git a/backend/app/api/tunnels.py b/builtin-extensions/serverkit-remote-access/backend/tunnels.py similarity index 95% rename from backend/app/api/tunnels.py rename to builtin-extensions/serverkit-remote-access/backend/tunnels.py index 7b58c7c0..15953692 100644 --- a/backend/app/api/tunnels.py +++ b/builtin-extensions/serverkit-remote-access/backend/tunnels.py @@ -9,8 +9,8 @@ from flask import Blueprint, request, jsonify from flask_jwt_extended import jwt_required, get_jwt_identity from app.models.tunnel import Tunnel -from app.services.tunnel_broker_service import TunnelBrokerService, TunnelBrokerError -from app.services.tunnel_publish_service import TunnelPublishService, TunnelPublishError +from .tunnel_broker_service import TunnelBrokerService, TunnelBrokerError +from .tunnel_publish_service import TunnelPublishService, TunnelPublishError from app.middleware.rbac import developer_required tunnels_bp = Blueprint('tunnels', __name__) diff --git a/builtin-extensions/serverkit-remote-access/plugin.json b/builtin-extensions/serverkit-remote-access/plugin.json index d382c6e2..01e1c90a 100644 --- a/builtin-extensions/serverkit-remote-access/plugin.json +++ b/builtin-extensions/serverkit-remote-access/plugin.json @@ -8,6 +8,8 @@ "category": "integration", "homepage": "https://github.com/jhd3197/ServerKit", "permissions": ["network"], + "entry_point": "tunnels:tunnels_bp", + "url_prefix": "/api/v1/tunnels", "icon": "", "contributions": { "tabs": [ diff --git a/builtin-extensions/serverkit-status/backend/__init__.py b/builtin-extensions/serverkit-status/backend/__init__.py new file mode 100644 index 00000000..5087b60e --- /dev/null +++ b/builtin-extensions/serverkit-status/backend/__init__.py @@ -0,0 +1,16 @@ +"""ServerKit Status Pages extension backend package. + +Extracted from core (plan 47): the status-pages blueprint (public status/badge +routes + authenticated management API) and its service ship here so a fresh panel +that never publishes a status page loads none of it. Mounted under +``/api/v1/status`` via the manifest's ``url_prefix``; the public /status/ +and /status/badge/ pages work whenever the extension is installed. The +StatusPage / StatusComponent / StatusIncident / HealthCheck models stay in core +(G2). Core's WordPress health-check job reaches +``StatusPageService.sync_component_from_health`` through +``plugin_service.get_installed_extension_attr`` only when installed. Upgrades +re-acquire it automatically (``CONVERTED_BUILTIN_SLUGS``). +""" +from .status_pages import status_pages_bp + +__all__ = ['status_pages_bp'] diff --git a/backend/app/services/status_page_service.py b/builtin-extensions/serverkit-status/backend/status_page_service.py similarity index 100% rename from backend/app/services/status_page_service.py rename to builtin-extensions/serverkit-status/backend/status_page_service.py diff --git a/backend/app/api/status_pages.py b/builtin-extensions/serverkit-status/backend/status_pages.py similarity index 99% rename from backend/app/api/status_pages.py rename to builtin-extensions/serverkit-status/backend/status_pages.py index 1c3042e8..491aecf1 100644 --- a/backend/app/api/status_pages.py +++ b/builtin-extensions/serverkit-status/backend/status_pages.py @@ -1,6 +1,6 @@ from flask import Blueprint, request, jsonify from flask_jwt_extended import jwt_required -from app.services.status_page_service import StatusPageService +from .status_page_service import StatusPageService status_pages_bp = Blueprint('status_pages', __name__) diff --git a/builtin-extensions/serverkit-status/plugin.json b/builtin-extensions/serverkit-status/plugin.json index ed482450..425afdcf 100644 --- a/builtin-extensions/serverkit-status/plugin.json +++ b/builtin-extensions/serverkit-status/plugin.json @@ -2,12 +2,14 @@ "name": "serverkit-status", "display_name": "Status Pages", "version": "1.0.0", - "description": "Public status pages backed by uptime monitors, with the management UI as a tab in the Observability group. The public /status/ page itself stays core.", + "description": "Public status pages backed by uptime monitors, with the management UI as a tab in the Observability group. Installing the extension serves the public /status/ and /status/badge/ pages too.", "author": "ServerKit", "license": "MIT", "category": "monitoring", "homepage": "https://github.com/jhd3197/ServerKit", "permissions": [], + "entry_point": "status_pages:status_pages_bp", + "url_prefix": "/api/v1/status", "icon": "", "contributions": { "tabs": [ diff --git a/docs/EXTENSIONS.md b/docs/EXTENSIONS.md index 73d2a657..c4348beb 100644 --- a/docs/EXTENSIONS.md +++ b/docs/EXTENSIONS.md @@ -448,3 +448,30 @@ Proven with `serverkit-git`; automated by the one-shot upgrade auto-install Existing panels auto-install converted builtins once on upgrade (a marker in settings) so nothing disappears; fresh installs see them in the Marketplace. + +### Second speed — moving the backend out too (plan 47) + +Once a converted page has settled as a frontend-only extension, its backend +blueprint + service can leave core: + +1. `git mv backend/app/api/.py builtin-extensions//backend/.py` + and the same for its service(s). Rewrite imports: **core** imports stay + absolute (`from app.middleware.rbac import …`), **sibling** extension modules + become relative (`from . import …`). +2. Add `backend/__init__.py` exposing the blueprint, and set the manifest's + `entry_point` (`":"`) + `url_prefix` (**unchanged** so frontend + API clients don't move). +3. Deregister it from `backend/app/__init__.py` (drop the import + `register_blueprint`). +4. Keep any **model** used by the extension in `backend/app/models/` (extensions + can't run migrations — G2). The extension imports it. +5. If a **core** code path calls into the now-extracted service (a job handler, + the agent gateway), reach it through + `plugin_service.get_installed_extension_attr(slug, module, attr)` so it + no-ops cleanly when the extension is absent. +6. Ensure the slug is in `extension_migration.CONVERTED_BUILTIN_SLUGS`. On an + upgraded panel that installed the extension **frontend-only** (its API used + to come from core), `run_backend_acquisition()` force-reinstalls once to + re-acquire the now-extracted backend — otherwise the API would vanish. + +Landed this way: `serverkit-ftp`, `serverkit-cloud-provision`, +`serverkit-remote-access`, `serverkit-status`. diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 20b707e8..d2fa9673 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -429,9 +429,14 @@ function AppRoutes() { } /> } /> - }> + {/* Security tab group. groupId="security" (plan 47 Phase 3a) lets + extracted security features (fail2ban, clamav, …) contribute + tabs + group-nested routes via their manifests, so they return + as tabs in the place users know. */} + }> } /> } /> + {groupRoutes.security} {/* Email routes are gated by the Email module toggle (Settings → Modules); disabled ⇒ redirect to the dashboard. */} diff --git a/frontend/src/components/setup/SetupStepSummary.jsx b/frontend/src/components/setup/SetupStepSummary.jsx index 2bc0e2f7..f86c2a56 100644 --- a/frontend/src/components/setup/SetupStepSummary.jsx +++ b/frontend/src/components/setup/SetupStepSummary.jsx @@ -1,6 +1,8 @@ +import { useState, useEffect } from 'react'; import { useResourceTier } from '../../contexts/ResourceTierContext'; -import { Sparkles } from 'lucide-react'; +import { Sparkles, Check, Loader, AlertTriangle } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; +import api from '../../services/api'; const USE_CASE_LABELS = { wordpress: 'WordPress Sites', @@ -9,35 +11,79 @@ const USE_CASE_LABELS = { devops: 'DevOps & Monitoring', }; -const RECOMMENDATIONS = { - wordpress: ['WordPress', 'Nginx Proxy Manager', 'phpMyAdmin'], - 'web-apps': ['Node.js App', 'Python App', 'Portainer'], - 'self-hosted': ['Nextcloud', 'Vaultwarden', 'Wiki.js'], - devops: ['Grafana', 'Prometheus', 'Portainer'], -}; - -const DEFAULT_RECOMMENDATIONS = ['Portainer', 'Uptime Kuma', 'Nginx Proxy Manager']; - -function getRecommendations(useCases) { - if (!useCases || useCases.length === 0) return DEFAULT_RECOMMENDATIONS; - - const seen = new Set(); - const result = []; - for (const uc of useCases) { - const items = RECOMMENDATIONS[uc] || []; - for (const item of items) { - if (!seen.has(item)) { - seen.add(item); - result.push(item); - } - } - } - return result.length > 0 ? result.slice(0, 4) : DEFAULT_RECOMMENDATIONS; -} - const SetupStepSummary = ({ accountInfo, useCases, onFinish }) => { const { tier, specs, loading } = useResourceTier(); - const recommendations = getRecommendations(useCases); + + // Recommended extensions (real slugs from the backend), the checked set, and + // per-slug install status shown while finishing. + const [recommendations, setRecommendations] = useState([]); + const [recsLoading, setRecsLoading] = useState(true); + const [checked, setChecked] = useState(() => new Set()); + const [installing, setInstalling] = useState(false); + const [installState, setInstallState] = useState({}); // slug -> 'installing'|'done'|'error' + + useEffect(() => { + let active = true; + setRecsLoading(true); + api.getRecommendedExtensions(useCases) + .then((res) => { + if (!active) return; + const recs = res?.recommendations || []; + setRecommendations(recs); + // Default: everything checked ("lean" = uncheck what you don't want) + setChecked(new Set(recs.filter((r) => !r.installed).map((r) => r.slug))); + }) + .catch(() => { + if (active) setRecommendations([]); + }) + .finally(() => { + if (active) setRecsLoading(false); + }); + return () => { + active = false; + }; + }, [useCases]); + + function toggle(slug) { + setChecked((prev) => { + const next = new Set(prev); + if (next.has(slug)) next.delete(slug); + else next.add(slug); + return next; + }); + } + + async function handleFinish() { + // Install the checked (not-already-installed) extensions, source-aware, + // one at a time with per-item progress. Fail-soft: an install failure is + // surfaced but never blocks completing onboarding. + const toInstall = recommendations.filter( + (r) => checked.has(r.slug) && !r.installed + ); + const installedSlugs = recommendations + .filter((r) => r.installed && checked.has(r.slug)) + .map((r) => r.slug); + + if (toInstall.length > 0) { + setInstalling(true); + for (const rec of toInstall) { + setInstallState((s) => ({ ...s, [rec.slug]: 'installing' })); + try { + if (rec.source === 'registry') { + await api.installRegistryExtension(rec.slug); + } else { + await api.installBuiltinExtension(rec.slug); + } + installedSlugs.push(rec.slug); + setInstallState((s) => ({ ...s, [rec.slug]: 'done' })); + } catch { + setInstallState((s) => ({ ...s, [rec.slug]: 'error' })); + } + } + } + + await onFinish(installedSlugs); + } function formatSpecs() { if (!specs) return 'Detecting...'; @@ -53,6 +99,25 @@ const SetupStepSummary = ({ accountInfo, useCases, onFinish }) => { return tier.charAt(0).toUpperCase() + tier.slice(1); } + const anyError = Object.values(installState).some((v) => v === 'error'); + + function renderRecStatus(rec) { + if (rec.installed) { + return Installed; + } + const state = installState[rec.slug]; + if (state === 'installing') { + return ; + } + if (state === 'done') { + return ; + } + if (state === 'error') { + return ; + } + return null; + } + return (

You're all set

@@ -102,24 +167,66 @@ const SetupStepSummary = ({ accountInfo, useCases, onFinish }) => {
-
-
- - Recommended for you + {(recsLoading || recommendations.length > 0) && ( +
+
+ + Recommended for you +
+ {recsLoading ? ( +
+ Loading recommendations... +
+ ) : ( + <> +

+ We'll install what you check. Uncheck anything you don't + need — you can add it later from Extensions. +

+
+ {recommendations.map((rec) => ( + + ))} +
+ {anyError && ( +

+ Some extensions couldn't be installed. You can retry + from the Extensions page. +

+ )} + + )}
-
- {recommendations.map((name) => ( - - {name} - - ))} -
-
+ )}
-
diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx index 45839559..99a24dd2 100644 --- a/frontend/src/contexts/AuthContext.jsx +++ b/frontend/src/contexts/AuthContext.jsx @@ -116,8 +116,8 @@ export function AuthProvider({ children }) { return data; } - async function completeOnboarding(useCases) { - await api.completeOnboarding(useCases); + async function completeOnboarding(useCases, installedExtensions = []) { + await api.completeOnboarding(useCases, installedExtensions); // Tailor the initial sidebar to the use cases picked during setup, so a // fresh install opens focused instead of showing every item. Best-effort: // if it fails, the sidebar just falls back to the "Recommended" default. diff --git a/frontend/src/pages/Setup.jsx b/frontend/src/pages/Setup.jsx index fdfe4e00..24e41c0e 100644 --- a/frontend/src/pages/Setup.jsx +++ b/frontend/src/pages/Setup.jsx @@ -47,8 +47,8 @@ const Setup = () => { setCurrentStep(4); } - async function handleFinish() { - await completeOnboarding(useCases); + async function handleFinish(installedExtensions = []) { + await completeOnboarding(useCases, installedExtensions); navigate('/'); } diff --git a/frontend/src/plugins/serverkit-cloud-provision/plugin.json b/frontend/src/plugins/serverkit-cloud-provision/plugin.json index b06582e8..daa9cfc8 100644 --- a/frontend/src/plugins/serverkit-cloud-provision/plugin.json +++ b/frontend/src/plugins/serverkit-cloud-provision/plugin.json @@ -10,6 +10,8 @@ "permissions": [ "network" ], + "entry_point": "cloud_provisioning:cloud_provisioning_bp", + "url_prefix": "/api/v1/cloud", "icon": "", "contributions": { "tabs": [ diff --git a/frontend/src/plugins/serverkit-ftp/plugin.json b/frontend/src/plugins/serverkit-ftp/plugin.json index b7c5354d..0a55da71 100644 --- a/frontend/src/plugins/serverkit-ftp/plugin.json +++ b/frontend/src/plugins/serverkit-ftp/plugin.json @@ -11,6 +11,8 @@ "shell", "filesystem" ], + "entry_point": "ftp:ftp_bp", + "url_prefix": "/api/v1/ftp", "icon": "", "contributions": { "tabs": [ diff --git a/frontend/src/plugins/serverkit-remote-access/plugin.json b/frontend/src/plugins/serverkit-remote-access/plugin.json index 901b4a42..2d3029d0 100644 --- a/frontend/src/plugins/serverkit-remote-access/plugin.json +++ b/frontend/src/plugins/serverkit-remote-access/plugin.json @@ -10,6 +10,8 @@ "permissions": [ "network" ], + "entry_point": "tunnels:tunnels_bp", + "url_prefix": "/api/v1/tunnels", "icon": "", "contributions": { "tabs": [ diff --git a/frontend/src/plugins/serverkit-status/plugin.json b/frontend/src/plugins/serverkit-status/plugin.json index 7d514dc3..9d782d44 100644 --- a/frontend/src/plugins/serverkit-status/plugin.json +++ b/frontend/src/plugins/serverkit-status/plugin.json @@ -2,12 +2,14 @@ "name": "serverkit-status", "display_name": "Status Pages", "version": "1.0.0", - "description": "Public status pages backed by uptime monitors, with the management UI as a tab in the Observability group. The public /status/ page itself stays core.", + "description": "Public status pages backed by uptime monitors, with the management UI as a tab in the Observability group. Installing the extension serves the public /status/ and /status/badge/ pages too.", "author": "ServerKit", "license": "MIT", "category": "monitoring", "homepage": "https://github.com/jhd3197/ServerKit", "permissions": [], + "entry_point": "status_pages:status_pages_bp", + "url_prefix": "/api/v1/status", "icon": "", "contributions": { "tabs": [ diff --git a/frontend/src/services/api/auth.js b/frontend/src/services/api/auth.js index bfb6a42f..4941c7cd 100644 --- a/frontend/src/services/api/auth.js +++ b/frontend/src/services/api/auth.js @@ -25,10 +25,10 @@ export async function register(email, username, password, inviteToken) { return data; } -export async function completeOnboarding(useCases) { +export async function completeOnboarding(useCases, installedExtensions = []) { return this.request('/auth/complete-onboarding', { method: 'POST', - body: { use_cases: useCases }, + body: { use_cases: useCases, installed_extensions: installedExtensions }, }); } diff --git a/frontend/src/services/api/plugins.js b/frontend/src/services/api/plugins.js index 57579b67..3f01d545 100644 --- a/frontend/src/services/api/plugins.js +++ b/frontend/src/services/api/plugins.js @@ -103,6 +103,15 @@ export async function getBuiltinExtensions() { return this.request('/plugins/builtin'); } +// Setup wizard: extensions recommended for the selected onboarding use cases. +// Returns { recommendations: [{ slug, display_name, description, category, +// source: 'builtin'|'registry', installed, status }] }. +export async function getRecommendedExtensions(useCases = []) { + const list = (useCases || []).filter(Boolean); + const q = list.length ? `?use_cases=${encodeURIComponent(list.join(','))}` : ''; + return this.request(`/plugins/recommendations${q}`); +} + // One-click install for a bundled extension by slug. export async function installBuiltinExtension(slug) { return this.request(`/plugins/builtin/${encodeURIComponent(slug)}/install`, { diff --git a/frontend/src/styles/pages/_setup-wizard.scss b/frontend/src/styles/pages/_setup-wizard.scss index b5b0f70a..d0e9523a 100644 --- a/frontend/src/styles/pages/_setup-wizard.scss +++ b/frontend/src/styles/pages/_setup-wizard.scss @@ -385,6 +385,97 @@ color: $text-primary; } +// -------------------------------------------- +// Recommended extensions — install checkboxes (plan 47) +// -------------------------------------------- +.recommendation-hint { + margin: 0 0 $space-3; + font-size: $font-size-sm; + color: $text-secondary; +} + +.recommendation-list { + display: flex; + flex-direction: column; + gap: $space-2; +} + +.recommendation-item { + display: flex; + align-items: flex-start; + gap: $space-3; + padding: $space-3 $space-4; + background: $bg-secondary; + border: 1px solid $border-default; + border-radius: $radius-lg; + cursor: pointer; + + &:hover { + border-color: $primary-color; + } + + &__check { + margin-top: 3px; + flex-shrink: 0; + cursor: pointer; + } + + &__body { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; + min-width: 0; + } + + &__name { + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $text-primary; + } + + &__desc { + font-size: $font-size-xs; + color: $text-secondary; + } + + &__spinner { + color: $text-secondary; + animation: spin 1s linear infinite; + flex-shrink: 0; + } + + &__status { + font-size: $font-size-xs; + flex-shrink: 0; + + &--installed { + color: $text-secondary; + } + + &--done { + color: $success-color; + flex-shrink: 0; + } + + &--error { + color: $danger-color; + flex-shrink: 0; + } + } +} + +.recommendation-error { + margin: $space-3 0 0; + font-size: $font-size-sm; + color: $danger-color; + + a { + color: $primary-color; + text-decoration: underline; + } +} + // -------------------------------------------- // Navigation buttons // --------------------------------------------