Files
ServerKit/backend/app/services/plugin_service.py
Juan Denis 0aea7e3787 fix(plugins): hot-load extension blueprints on a live app (#144)
Flask refuses register_blueprint once the app has handled its first
request, so on any running panel — where login and dashboard requests
long precede the first extension install — the hot-load in
_register_plugin_blueprint always raised, the installer only logged a
'will load on restart' warning, and the extension's routes stayed
missing. Its pages rendered (frontend is static) while every API call
failed: GETs were swallowed by the tabs' console.error handlers, and the
first POST fell through to the static-file route, surfacing as the 405
reported for the Malware Scanner custom-path scan.

_register_blueprint_live waives Flask's setup guard for the duration of
the registration call. That is safe in this deployment shape: the panel
runs a single gunicorn worker with threads, so the process installing
the plugin is the process serving its routes, and werkzeug rebuilds its
URL matcher lazily on the next bind while in-flight requests keep the
matcher they already bound.
2026-09-18 01:28:59 -04:00

2444 lines
101 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Plugin Service - Download, install, and manage ServerKit plugins from URLs.
Plugins are zip files containing a plugin.json manifest, optional backend/
and frontend/ directories. They get extracted into the ServerKit plugins
directories and auto-registered at startup.
"""
import io
import json
import logging
import os
import re
import shutil
import subprocess
from app.utils.system import run_checked
import sys
import tempfile
import zipfile
import requests
from app import db
from app.utils.env import env_bool
from app.models.plugin import InstalledPlugin
logger = logging.getLogger(__name__)
# Resolve paths relative to the backend directory.
# __file__ = backend/app/services/plugin_service.py
# _APP_DIR = backend/app/
# _BACKEND_ROOT = backend/
# _PROJECT_ROOT = ServerKit/
#
# Both targets are env-var overridable. The defaults work for native dev
# (backend run from a checkout). They break in Docker because the backend
# image only contains /app — the host's frontend directory isn't mounted —
# so dockerized panels MUST set SERVERKIT_FRONTEND_PLUGINS_DIR (and bind
# mount the corresponding host folder) when installing plugins that ship
# a frontend.
_APP_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_BACKEND_ROOT = os.path.dirname(_APP_DIR)
_PROJECT_ROOT = os.path.dirname(_BACKEND_ROOT)
BACKEND_PLUGINS_DIR = os.environ.get(
'SERVERKIT_BACKEND_PLUGINS_DIR',
os.path.join(_APP_DIR, 'plugins'),
)
FRONTEND_PLUGINS_DIR = os.environ.get(
'SERVERKIT_FRONTEND_PLUGINS_DIR',
os.path.join(_PROJECT_ROOT, 'frontend', 'src', 'plugins'),
)
# Folder of bundled-with-the-repo extensions that ship as plugins
# but are not installed by default. The discovery endpoint enumerates
# this folder so the Marketplace can show one-click installs without
# requiring users to know absolute paths.
BUILTIN_EXTENSIONS_DIR = os.environ.get(
'SERVERKIT_BUILTIN_EXTENSIONS_DIR',
os.path.join(_PROJECT_ROOT, 'builtin-extensions'),
)
# Flagship extensions (decision D4): bundled, installed-by-default on EVERY panel
# (fresh and upgrade), and uninstallable. Unlike the niche Tier-1 conversions
# (handled one-shot in extension_migration), a flagship is seeded in-place — no
# file copy — its backend loads straight from builtin-extensions/ and its frontend
# is pre-bundled (D5). A user uninstall records a marker so it stays gone.
#
# serverkit-wordpress GRADUATED from this list in plan 52 Phase 5: it left the
# tree into its own repo and now distributes through the registry (the
# k8s/tramo model — bundled index entry + GitHub release zip), offered during
# onboarding via RECOMMENDED_EXTENSIONS_BY_USE_CASE. Its uninstall-marker
# semantics are preserved (the settings key machinery below is untouched), so
# a user who uninstalled WP before the extraction is never re-seeded.
FLAGSHIP_SLUGS = [] # cloudflare-ops left the tree (plan 52 Ph2 cutover); machinery kept for future flagships
_FLAGSHIP_UNINSTALLED_KEY = 'extensions.flagship_uninstalled'
# Wizard-optional flagships (plan 47): offered — not force-installed — during
# onboarding. Currently EMPTY: WordPress (the only member) left the tree in
# plan 52 Phase 5 and is now a registry extension the wizard offers like any
# other recommendation. The machinery stays — a future in-tree flagship that
# should be wizard-optional goes here.
WIZARD_OPTIONAL_FLAGSHIP_SLUGS = []
def _ensure_builtin_backend_importable(slug):
"""Register ``builtin-extensions/<slug>/backend`` as the dashed package
``app.plugins.<slug>`` in ``sys.modules`` (in-place, no copy), so its
blueprints and services import — and relative imports inside resolve —
without the plugin being copy-installed under ``app/plugins/``.
This is what makes a dashed, default-installed flagship loadable uniformly in
production, dev checkouts, and the test suite. Idempotent; returns ``True`` if
the package is (now) importable.
"""
import importlib
import importlib.util
pkg = f'app.plugins.{slug}'
if pkg in sys.modules:
return True
try:
importlib.import_module(pkg) # already copy-installed under app/plugins/
return True
except ImportError:
pass
backend_dir = os.path.join(BUILTIN_EXTENSIONS_DIR, slug, 'backend')
init_py = os.path.join(backend_dir, '__init__.py')
if not os.path.isfile(init_py):
return False
try:
importlib.import_module('app.plugins') # ensure the parent package exists
spec = importlib.util.spec_from_file_location(
pkg, init_py, submodule_search_locations=[backend_dir]
)
module = importlib.util.module_from_spec(spec)
sys.modules[pkg] = module
spec.loader.exec_module(module)
return True
except Exception as e:
logger.warning(f'In-place load of {pkg} failed: {e}')
sys.modules.pop(pkg, None)
return False
def _ensure_backend_dir():
"""Create the backend plugin dir; raise a useful error if we can't."""
try:
os.makedirs(BACKEND_PLUGINS_DIR, exist_ok=True)
except OSError as e:
raise ValueError(
f"Cannot create backend plugin directory at {BACKEND_PLUGINS_DIR}: {e}. "
f"Set SERVERKIT_BACKEND_PLUGINS_DIR to a writable path."
)
init_path = os.path.join(BACKEND_PLUGINS_DIR, '__init__.py')
if not os.path.exists(init_path):
with open(init_path, 'w') as f:
f.write('')
def _ensure_frontend_dir():
"""Create the frontend plugin dir on demand. Called only when a plugin
actually ships frontend content — plugins without frontends never need
this directory and shouldn't fail just because it doesn't exist (a
common case in Docker)."""
try:
os.makedirs(FRONTEND_PLUGINS_DIR, exist_ok=True)
except OSError as e:
raise ValueError(
f"This plugin ships a frontend, but the panel can't write to "
f"the frontend plugin directory ({FRONTEND_PLUGINS_DIR}): {e}.\n\n"
f"If the panel runs in Docker, this is expected — the container "
f"sees only /app, not the host's frontend folder. Two fixes:\n"
f" • Bind-mount the host's frontend/src/plugins into the "
f"container and set SERVERKIT_FRONTEND_PLUGINS_DIR to that path.\n"
f" • Or run the backend natively for plugin development.\n\n"
f"You can also install the backend half only by stripping the "
f"frontend/ folder from the plugin zip, but the UI half won't load."
)
def _ensure_dirs():
"""Backwards-compatible eager init for backend dir only. Frontend dir is
lazy now — see _ensure_frontend_dir."""
_ensure_backend_dir()
def _github_token():
"""Optional GitHub token (SERVERKIT_GITHUB_TOKEN). Lifts the 60/hr anonymous
rate limit and enables private-repo installs. Never logged or echoed back."""
return (os.environ.get('SERVERKIT_GITHUB_TOKEN') or '').strip()
def _is_github_host(url):
"""True for github.com / api.github.com / codeload.github.com URLs — the only
hosts the token is ever attached to (never leak it to third-party mirrors,
and never over plain http — audit L6)."""
return bool(re.match(r'^https://(?:[a-z0-9-]+\.)?github\.com/', url or '', re.I))
def _github_api_headers(accept='application/vnd.github+json'):
headers = {'Accept': accept, 'User-Agent': 'ServerKit-Plugin-Installer/1.0'}
token = _github_token()
if token:
headers['Authorization'] = f'Bearer {token}'
return headers
class GitHubResolveError(ValueError):
"""Actionable failure resolving a GitHub URL (rate limit, 404, private)."""
def _normalize_source_url(url):
"""Accept `owner/repo` and `owner/repo@tag` shorthand, normalizing to a
github.com URL. Full URLs and direct .zip links pass through unchanged."""
if not url:
return url
u = url.strip()
if u.startswith('http://') or u.startswith('https://') or u.endswith('.zip'):
return u
m = re.match(r'^([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+?)(?:@(.+))?$', u)
if not m:
return u
owner, repo, tag = m.groups()
if tag:
return f'https://github.com/{owner}/{repo}/releases/tag/{tag}'
return f'https://github.com/{owner}/{repo}'
def _resolve_github_url(url, strict=False):
"""Convert a GitHub repo URL to the latest release zip download URL.
Handles:
- https://github.com/user/repo -> latest release zip
- https://github.com/user/repo/releases/tag/v1.0.0 -> that release zip
- Direct zip URLs pass through unchanged
When ``strict`` is False (installs) any API hiccup silently falls back to the
default-branch/tag zipball. When ``strict`` is True (preview) failures raise
:class:`GitHubResolveError` with an actionable message, and a
``.used_zipball_fallback`` flag records whether no release asset was found.
"""
if url.endswith('.zip'):
return url
# Match github.com/owner/repo patterns
gh_match = re.match(
r'https?://github\.com/([^/]+)/([^/]+?)(?:\.git)?(?:/releases/tag/([^/]+))?/?$',
url,
)
if not gh_match:
return url
owner, repo, tag = gh_match.groups()
if tag:
# Specific release - get its assets
api_url = f'https://api.github.com/repos/{owner}/{repo}/releases/tags/{tag}'
else:
# Latest release
api_url = f'https://api.github.com/repos/{owner}/{repo}/releases/latest'
try:
resp = requests.get(api_url, timeout=15, headers=_github_api_headers())
if strict:
_raise_for_github_status(resp, owner, repo)
resp.raise_for_status()
release = resp.json()
# Look for a .zip asset (prefer plugin zip over source)
for asset in release.get('assets', []):
if asset['name'].endswith('.zip'):
return asset['browser_download_url']
# No .zip asset — fall back to source zipball (default branch of the tag).
zipball = release.get('zipball_url') or (
f'https://api.github.com/repos/{owner}/{repo}/zipball/{tag}' if tag
else f'https://api.github.com/repos/{owner}/{repo}/zipball')
_mark_zipball_fallback(zipball)
return zipball
except GitHubResolveError:
raise
except Exception as e:
logger.warning(f'Could not resolve GitHub release URL: {e}')
# Fallback: try the zipball endpoint directly (no release published).
zipball = (f'https://api.github.com/repos/{owner}/{repo}/zipball/{tag}' if tag
else f'https://api.github.com/repos/{owner}/{repo}/zipball')
_mark_zipball_fallback(zipball)
return zipball
# Records the most recent resolution's zipball fallback so the preview flow can
# warn "no release found (installing the default-branch zipball)". Only read
# immediately after a _resolve_github_url call.
_LAST_RESOLUTION = {'zipball_fallback': False}
def _mark_zipball_fallback(_url):
_LAST_RESOLUTION['zipball_fallback'] = True
def _raise_for_github_status(resp, owner, repo):
"""Turn a GitHub API error response into an actionable GitHubResolveError."""
if resp.status_code == 200:
_LAST_RESOLUTION['zipball_fallback'] = False
return
if resp.status_code == 403 and resp.headers.get('X-RateLimit-Remaining') == '0':
reset = resp.headers.get('X-RateLimit-Reset')
mins = ''
try:
import time as _t
if reset:
mins = f' (try again in ~{max(1, round((int(reset) - _t.time()) / 60))} min)'
except Exception:
pass
raise GitHubResolveError(
f'GitHub API rate limit reached{mins}. Set SERVERKIT_GITHUB_TOKEN '
f'to raise the limit and enable private-repo installs.')
if resp.status_code == 404:
raise GitHubResolveError(
f'Repository or release not found for {owner}/{repo}. Check the URL, '
f'or set SERVERKIT_GITHUB_TOKEN if the repository is private.')
raise GitHubResolveError(
f'GitHub API returned HTTP {resp.status_code} for {owner}/{repo}.')
def _download_zip(url):
"""Download a zip file from URL and return bytes."""
resolved = _resolve_github_url(url)
return _download_resolved(resolved)
# ── Download safety (audit L4): scheme/host checks, redirect re-validation,
# and byte caps, shared by the zip and detached-signature fetches.
_MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024 # extension zip cap
_MAX_SIGNATURE_BYTES = 64 * 1024 # detached .minisig cap
_MAX_EXTRACT_BYTES = 250 * 1024 * 1024 # decompression cap (audit L5)
_MAX_REDIRECTS = 5
def _ip_is_public(ip):
import ipaddress
addr = ipaddress.ip_address(ip)
return not (addr.is_private or addr.is_loopback or addr.is_link_local
or addr.is_multicast or addr.is_reserved or addr.is_unspecified)
def _assert_url_safe(url):
"""Reject anything that isn't an https URL to a public host (SSRF guard).
Rules: https everywhere; http only for loopback dev hosts
(http://localhost:8000/ext.zip while developing); loopback targets are
always allowed (a same-host registry is legitimate), but RFC1918 /
link-local / reserved targets are refused by default — a panel must never
follow an attacker-supplied URL into the internal network or the cloud
metadata service. Operators with a private-zone registry can opt out with
SERVERKIT_ALLOW_PRIVATE_DOWNLOADS=1.
"""
import ipaddress
import socket
from urllib.parse import urlparse
parsed = urlparse(url or '')
scheme = (parsed.scheme or '').lower()
if scheme not in ('http', 'https'):
raise ValueError(
f"Unsupported URL scheme {scheme!r} — extension downloads must be https://")
host = parsed.hostname
if not host:
raise ValueError('Download URL has no host')
try:
ips = {info[4][0] for info in socket.getaddrinfo(host, None)}
except Exception as e:
raise ValueError(f'Could not resolve download host {host!r}: {e}')
if all(ipaddress.ip_address(ip).is_loopback for ip in ips):
return # loopback dev host — http or https both fine
if scheme == 'http':
raise ValueError(
'Plain-http extension downloads are only allowed for loopback dev hosts')
if env_bool('SERVERKIT_ALLOW_PRIVATE_DOWNLOADS'):
return
bad = sorted(ip for ip in ips if not _ip_is_public(ip))
if bad:
raise ValueError(
f'Download host {host!r} resolves to a non-public address ({bad[0]}) '
f'— refusing to fetch (SSRF guard). Set '
f'SERVERKIT_ALLOW_PRIVATE_DOWNLOADS=1 to allow private-zone registries.')
def _safe_get(url, timeout, headers, max_bytes):
"""GET ``url`` with per-hop SSRF re-validation (redirects are followed
manually so each hop is re-checked), a hard byte cap, and the GitHub
token re-computed per hop (never leaked cross-host). Returns bytes."""
from urllib.parse import urljoin
current = url
for _ in range(_MAX_REDIRECTS + 1):
_assert_url_safe(current)
hop_headers = dict(headers or {})
token = _github_token()
if token and _is_github_host(current):
hop_headers['Authorization'] = f'Bearer {token}'
resp = requests.get(current, timeout=timeout, stream=True,
headers=hop_headers, allow_redirects=False)
try:
# status_code check rather than is_redirect: equivalent semantics,
# and tolerant of minimal test doubles.
if resp.status_code in (301, 302, 303, 307, 308):
location = resp.headers.get('Location')
if not location:
raise ValueError('Redirect without a Location header')
current = urljoin(current, location)
continue
resp.raise_for_status()
buf = io.BytesIO()
for chunk in resp.iter_content(chunk_size=8192):
buf.write(chunk)
if buf.tell() > max_bytes:
raise ValueError(
f'Download exceeds the {max_bytes // (1024 * 1024)} MB cap '
f'— aborting (possible zip bomb or misconfigured source)')
buf.seek(0)
return buf.getvalue()
finally:
close = getattr(resp, 'close', None)
if callable(close):
close()
raise ValueError(f'Too many redirects fetching {url}')
def _download_resolved(resolved):
"""Download an already-resolved zip URL and return a BytesIO buffer."""
logger.info(f'Downloading plugin from: {resolved}')
headers = {
'Accept': 'application/octet-stream',
'User-Agent': 'ServerKit-Plugin-Installer/1.0',
}
data = _safe_get(resolved, timeout=120, headers=headers,
max_bytes=_MAX_DOWNLOAD_BYTES)
return io.BytesIO(data)
def _find_manifest(zf):
"""Find plugin.json inside the zip, handling nested directories (GitHub zipball nesting)."""
for name in zf.namelist():
basename = os.path.basename(name)
if basename == 'plugin.json':
# Return the directory prefix so we can strip it
prefix = name[: -len('plugin.json')]
return name, prefix
return None, None
# 'module:attr' references (entry_point, socket_entry, models, lifecycle hooks,
# job handlers). Module part may be dotted; attr must be a bare identifier.
_MODULE_REF_RE = re.compile(r'^[A-Za-z_][\w.]*:[A-Za-z_]\w*$')
# Required keys per contribution kind (mirrors GET /plugins/manifest-spec and
# docs/EXTENSIONS.md — keep the three in sync).
_REQUIRED_CONTRIB_KEYS = {
'nav': ('id', 'label', 'route'),
'routes': ('path', 'component'),
'tabs': ('group', 'to', 'label'),
'command_palette': ('label', 'path'),
'widgets': ('slot', 'component'),
'dashboard_widgets': ('id', 'name', 'component'),
'layouts': ('id', 'component'),
'walkthroughs': ('id', 'title', 'description', 'steps'),
}
_KNOWN_CONTRIB_KINDS = set(_REQUIRED_CONTRIB_KEYS) | {'page_titles', 'ai'}
def _validate_manifest(manifest):
"""Validate the manifest: required fields plus the SHAPE of every
declarative block the platform consumes (#52).
A malformed block used to be silently skipped at runtime (contribution
dropped, socket never registered, job never scheduled) — hard to debug.
Now the install fails with a message naming each problem. Unknown
contribution kinds only warn, so newer manifests stay installable on
older panels (forward compat).
"""
required = ['name', 'display_name', 'version']
missing = [f for f in required if f not in manifest]
if missing:
raise ValueError(f"Manifest missing required fields: {', '.join(missing)}")
# Sanitize the name for use as a directory
name = manifest['name']
if not re.match(r'^[a-zA-Z0-9_-]+$', name):
raise ValueError(f"Plugin name must be alphanumeric/dashes/underscores: {name}")
problems = []
for field in ('entry_point', 'socket_entry', 'models'):
val = manifest.get(field)
if val and not (isinstance(val, str) and _MODULE_REF_RE.match(val)):
problems.append(f"{field} must be a 'module:attr' string (got {val!r})")
lifecycle = manifest.get('lifecycle')
if lifecycle is not None:
if not isinstance(lifecycle, dict):
problems.append('lifecycle must be an object of phase -> module:func')
else:
for phase, target in lifecycle.items():
if not (isinstance(target, str) and _MODULE_REF_RE.match(target)):
problems.append(
f"lifecycle.{phase} must be a 'module:func' string (got {target!r})")
jobs = manifest.get('jobs')
if jobs is not None:
if not isinstance(jobs, list):
problems.append('jobs must be a list of {kind, handler}')
else:
for i, j in enumerate(jobs):
if not (isinstance(j, dict) and j.get('kind')
and isinstance(j.get('handler'), str)
and _MODULE_REF_RE.match(j['handler'])):
problems.append(f"jobs[{i}] must be {{kind, handler: 'module:func'}}")
schedules = manifest.get('schedules')
if schedules is not None:
if not isinstance(schedules, list):
problems.append('schedules must be a list of {name, kind, cron?|interval_seconds?}')
else:
for i, s in enumerate(schedules):
if not (isinstance(s, dict) and s.get('name') and s.get('kind')):
problems.append(f'schedules[{i}] needs name and kind')
contrib = manifest.get('contributions')
if contrib is not None and not isinstance(contrib, dict):
problems.append('contributions must be an object')
elif isinstance(contrib, dict):
for key in contrib:
if key not in _KNOWN_CONTRIB_KINDS:
logger.warning(
f"Manifest for {name}: unknown contribution kind '{key}' (ignored)")
for kind, req in _REQUIRED_CONTRIB_KEYS.items():
entries = contrib.get(kind)
if entries is None:
continue
if not isinstance(entries, list):
problems.append(f'contributions.{kind} must be a list')
continue
for i, entry in enumerate(entries):
if not isinstance(entry, dict):
problems.append(f'contributions.{kind}[{i}] must be an object')
continue
missing_keys = [k for k in req if not entry.get(k)]
if missing_keys:
problems.append(
f"contributions.{kind}[{i}] missing {', '.join(missing_keys)}")
titles = contrib.get('page_titles')
if titles is not None and not isinstance(titles, dict):
problems.append('contributions.page_titles must be an object of path -> title')
walkthroughs = contrib.get('walkthroughs')
if walkthroughs is not None:
from app.services.walkthrough_definition_service import (
validate_walkthrough_definitions,
)
problems.extend(validate_walkthrough_definitions(
walkthroughs,
field='contributions.walkthroughs',
))
if problems:
raise ValueError(
'Manifest validation failed: ' + '; '.join(problems)
+ '. See GET /api/v1/plugins/manifest-spec or docs/EXTENSIONS.md.')
return True
def _assert_manifest_panel_compatible(manifest):
"""Enforce a manifest's min/max_panel_version at install (#28), for every
install source (URL/upload/local/builtin), not just the registry. A manifest
without bounds installs anywhere."""
from app.utils.version import version_satisfies, get_panel_version
minv = manifest.get('min_panel_version')
maxv = manifest.get('max_panel_version')
if not (minv or maxv):
return
panel = get_panel_version()
if not version_satisfies(panel, minv, maxv):
raise ValueError(
f"{manifest.get('display_name') or manifest.get('name')} "
f"v{manifest.get('version')} needs panel {minv or '*'}–{maxv or '*'} "
f"(this panel is {panel})."
)
def _assert_manifest_sdk_compatible(manifest):
"""Enforce a manifest's ``sdk_version`` range at install.
Two places already documented this check as existing — this module's own
loader comment in the frontend and ``app/utils/sdk.py``'s docstring ("checked
at install (manifest lint) and at load") — but nothing called
``sdk_version_satisfies`` outside its unit test. The consequences differed by
extension kind, and both were bad:
* a runtime ESM extension installed cleanly and only failed later, at load,
on the version gate;
* a builtin/in-repo extension is compiled in and never passes the load
gate at all, so its range was never checked anywhere.
Fail-open on a missing range is deliberate and matches the load-time
'grace' decision: an extension that pins nothing installs anywhere.
"""
from app.utils.sdk import SDK_VERSION, sdk_version_satisfies
range_str = manifest.get('sdk_version')
if not range_str or not str(range_str).strip():
return
if not sdk_version_satisfies(range_str):
raise ValueError(
f"{manifest.get('display_name') or manifest.get('name')} "
f"v{manifest.get('version')} needs panel SDK {range_str} "
f"(this panel offers {SDK_VERSION}) — update the panel, or install "
f"a build of the extension made for this SDK."
)
def _safe_extract_path(dest_root, rel_path):
"""Resolve `rel_path` under `dest_root` and assert it stays inside.
Defense against Zip Slip: a malicious archive can carry entries like
`../../etc/cron.d/foo` or absolute paths. Without this check, the
extract loop happily writes outside the plugin directory and into
anything the backend process can touch.
Returns the validated absolute output path, or raises ValueError.
"""
if not rel_path:
raise ValueError('empty path in archive')
normalized = rel_path.replace('\\', '/').lstrip('/')
if normalized.startswith('/') or ':' in normalized.split('/')[0]:
raise ValueError(f'absolute path in archive: {rel_path!r}')
if '..' in normalized.split('/'):
raise ValueError(f'path traversal in archive: {rel_path!r}')
out = os.path.normpath(os.path.join(dest_root, normalized))
root = os.path.normpath(dest_root) + os.sep
if not (out + os.sep).startswith(root):
raise ValueError(f'archive entry escapes destination: {rel_path!r}')
return out
def _update_plugin_metadata(plugin, manifest):
"""Sync mutable manifest-derived fields onto an existing plugin row.
Used on reinstall: the new manifest may have a fresh display_name,
author, description, etc. Without this, those fields go stale and
the UI keeps showing the original values.
"""
plugin.name = manifest['name']
plugin.display_name = manifest['display_name']
plugin.description = manifest.get('description', '')
plugin.author = manifest.get('author', '')
plugin.homepage = manifest.get('homepage', '')
plugin.repository = manifest.get('repository', '')
plugin.license = manifest.get('license', '')
plugin.category = manifest.get('category', 'utility')
def _record_signature_status(plugin, sig_result):
"""Stamp the install-time signature verdict on the plugin's config under
the panel-managed ``_signature`` reserved key (same trust-machinery
namespace as ``_frontend_hashes`` — config PUTs can't forge it). The
extension detail surfaces this so an installed extension's origin
verification stays visible after install."""
cfg = dict(plugin.config or {})
if sig_result and sig_result.get('status') != 'unsigned':
cfg['_signature'] = {
k: sig_result.get(k)
for k in ('status', 'key_id', 'publisher')
if sig_result.get(k)
}
else:
cfg['_signature'] = {'status': 'unsigned'}
plugin.config = cfg
db.session.commit()
def install_from_url(url, user_id=None, expected_sha256=None, force=False,
hot_load=True, source_type=None,
expected_signature=None, expected_key_id=None):
"""Download and install a plugin from a URL.
Args:
url: GitHub repo URL, release URL, or direct zip URL
user_id: ID of the user performing the install
expected_sha256: if given, the downloaded zip must match this digest
before extraction — a mismatch is a hard failure (no partial install)
expected_signature / expected_key_id: if given, the downloaded zip is
verified against this base64 ed25519 signature envelope (plan 55
D3) under the pinned publisher key named by expected_key_id. A bad
signature is a hard failure; an unpinned key installs but is
recorded as 'untrusted_key'.
force: allow reinstalling over an already-active plugin (used by updates)
hot_load: register the blueprint into the running app immediately.
The boot-time repair pass (#48) passes False — load_all_plugins
registers right after, and a double registration would error.
source_type: override the stamped source_type (defaults to 'url').
Registry installs pass 'registry' so the row records where the
plugin really came from.
Returns:
InstalledPlugin instance
"""
url = _normalize_source_url(url)
try:
buf = _download_zip(url)
except Exception as e:
raise ValueError(f'Failed to download plugin: {e}')
if expected_sha256:
import hashlib
digest = hashlib.sha256(buf.getvalue()).hexdigest()
if digest.lower() != str(expected_sha256).lower():
raise ValueError(
f'Checksum mismatch — refusing to install. '
f'Expected sha256 {expected_sha256}, got {digest}.'
)
buf.seek(0)
sig_result = None
if expected_signature:
from app.services import signing_service
sig_result = signing_service.verify_for_install(
buf.getvalue(), expected_signature, expected_key_id)
buf.seek(0)
plugin = _install_from_buffer(
buf, source_url=url, source_type=source_type or 'url',
user_id=user_id, force=force,
hot_load=hot_load, sig_result=sig_result,
)
return plugin
def _read_manifest_from_buffer(buf):
"""Read and JSON-parse plugin.json from a plugin zip buffer, handling the
GitHub zipball directory nesting. Raises ValueError with an actionable
message when the archive is not a valid plugin package."""
try:
zf = zipfile.ZipFile(buf)
except zipfile.BadZipFile:
raise ValueError('The downloaded file is not a valid zip archive.')
manifest_path, _prefix = _find_manifest(zf)
if not manifest_path:
raise ValueError(
'No plugin.json found at the archive root. A ServerKit extension '
'needs plugin.json at the top level (see the layout in '
'docs/EXTENSIONS.md).')
try:
with zf.open(manifest_path) as f:
return json.loads(f.read().decode('utf-8'))
except Exception as e:
raise ValueError(f'plugin.json could not be parsed: {e}')
def _fetch_detached_signature(resolved_url):
"""Best-effort fetch of the minisign-style detached signature published
beside a release zip (``<zip URL>.minisig`` — the convention
scripts/sign-extension.mjs writes). Returns the base64 envelope string,
or None when the release carries no signature (or the fetch fails —
preview must never hard-fail on a missing optional asset)."""
if not (resolved_url or '').startswith(('http://', 'https://')):
return None
try:
headers = {
'Accept': 'text/plain, application/octet-stream',
'User-Agent': 'ServerKit-Plugin-Installer/1.0',
}
data = _safe_get(resolved_url + '.minisig', timeout=15, headers=headers,
max_bytes=_MAX_SIGNATURE_BYTES)
text = data.decode('utf-8', errors='replace').strip()
return text or None
except Exception as e:
logger.debug(f'No detached signature at {resolved_url}.minisig: {e}')
return None
def preview_from_url(url):
"""Resolve + download a GitHub/zip source and read its manifest WITHOUT
installing. Returns the metadata a user needs to consent to the install:
slug, display_name, version, description, permissions, panel-version gate,
the resolved download URL, its sha256, the signature verdict (signed by a
pinned publisher / unsigned / untrusted key / invalid), and any warnings.
Stateless by design — the subsequent install re-downloads (extension zips
are small) and pins the previewed sha256 so the installed bytes are
byte-identical to the previewed bytes.
"""
from app.utils.version import version_satisfies, get_panel_version
from app.services import signing_service
import hashlib
normalized = _normalize_source_url(url)
_LAST_RESOLUTION['zipball_fallback'] = False
resolved = _resolve_github_url(normalized, strict=True)
try:
buf = _download_resolved(resolved)
except GitHubResolveError:
raise
except Exception as e:
raise ValueError(f'Failed to download the extension: {e}')
digest = hashlib.sha256(buf.getvalue()).hexdigest()
buf.seek(0)
manifest = _read_manifest_from_buffer(buf)
# Signature verdict for the consent card (plan 55): the detached .minisig
# beside the release asset, verified against the panel's pinned publisher
# keys. Unsigned is a verdict, not an error — the card says so honestly.
sig_b64 = _fetch_detached_signature(resolved)
sig_result = signing_service.verify_detached(buf.getvalue(), sig_b64)
signature = {
'status': sig_result['status'],
'key_id': sig_result.get('key_id'),
'publisher': sig_result.get('publisher'),
# The raw envelope is echoed back so the install step can re-verify
# the exact previewed signature against the pinned sha256 bytes.
'signature': sig_b64 if sig_result['status'] != 'unsigned' else None,
}
if sig_result['status'] == 'invalid':
signature['error'] = sig_result.get('error')
# Validate shape early so preview surfaces manifest problems too.
_validate_manifest(manifest)
slug = manifest['name']
minv = manifest.get('min_panel_version')
maxv = manifest.get('max_panel_version')
warnings = []
if _LAST_RESOLUTION.get('zipball_fallback'):
warnings.append(
'No release asset found — installing the default-branch source '
'archive. Publish a release with a plugin .zip asset for '
'reproducible, checksum-pinned installs.')
if (minv or maxv):
panel = get_panel_version()
if not version_satisfies(panel, minv, maxv):
warnings.append(
f'This extension targets panel {minv or "*"}-{maxv or "*"}, '
f'but this panel is {panel}. Install will be blocked.')
if InstalledPlugin.query.filter_by(slug=slug).first():
warnings.append(
f'An extension with the slug "{slug}" is already installed. '
f'Installing will reinstall/overwrite it.')
return {
'slug': slug,
'display_name': manifest.get('display_name') or slug,
'version': manifest.get('version'),
'description': manifest.get('description', ''),
'author': manifest.get('author', ''),
'homepage': manifest.get('homepage', ''),
'permissions': manifest.get('permissions', []) if isinstance(
manifest.get('permissions'), list) else [],
'min_panel_version': minv,
'max_panel_version': maxv,
'resolved_url': resolved,
'sha256': digest,
'signature': signature,
'warnings': warnings,
}
def install_from_path(path, user_id=None, force=False, hot_load=True,
source_type=None):
"""Install a plugin from a local directory on the panel host.
Useful during plugin development: point at the working tree, install,
iterate. Internally we zip the folder in memory and reuse the same
install pipeline as URL/upload installs so behavior is identical.
Note: "local" here means local to the *panel backend*, not to the
user's browser. If the backend runs in Docker, browser-host paths
won't resolve — the user should either bind-mount their plugin
source into the container or use install_from_zip via the upload
endpoint instead.
"""
if not path:
raise ValueError('path is required')
raw = path
# Windows path on a non-Windows panel host is the most common
# foot-gun (typical case: backend in Docker, dev on Windows). Detect
# and reject with a helpful message before os.path.abspath turns it
# into garbage like /app/C:\Users\...
if os.name != 'nt' and len(path) >= 3 and path[1:3] == ':\\':
raise ValueError(
f"'{raw}' looks like a Windows path, but the panel backend is "
f"running on {os.name!r} (likely a Linux container). The folder "
f"install runs on the panel host's filesystem, not your browser's. "
f"Either bind-mount your plugin source into the backend container, "
f"or use 'Upload Zip' instead."
)
path = os.path.abspath(os.path.expanduser(path))
if not os.path.isdir(path):
raise ValueError(
f"Not a directory: {path}\n"
f"This path is resolved on the panel backend ({os.name!r}), not "
f"your browser. If the backend runs in Docker, the path must "
f"exist inside the container — bind-mount the source folder or "
f"use 'Upload Zip'."
)
if not os.path.exists(os.path.join(path, 'plugin.json')):
raise ValueError(f'No plugin.json in {path}')
# Zip the folder in memory, skipping dev junk that bloats the bundle.
skip_dirs = {
'.git', '.github', 'node_modules', '__pycache__',
'.venv', 'venv', 'dist', 'build', '.pytest_cache',
'.idea', '.vscode',
}
skip_files_endswith = ('.pyc', '.pyo')
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
for root, dirs, files in os.walk(path):
# mutate dirs in place so os.walk skips them
dirs[:] = [d for d in dirs if d not in skip_dirs]
for name in files:
if name.endswith(skip_files_endswith):
continue
full = os.path.join(root, name)
rel = os.path.relpath(full, path).replace(os.sep, '/')
zf.write(full, rel)
buf.seek(0)
return _install_from_buffer(
buf, source_url=path, source_type=source_type or 'local',
user_id=user_id, force=force, hot_load=hot_load,
)
def install_from_zip(zip_bytes, user_id=None, source_name=None):
"""Install a plugin from raw zip bytes (e.g. a multipart upload)."""
if not zip_bytes:
raise ValueError('Empty upload')
buf = io.BytesIO(zip_bytes)
return _install_from_buffer(
buf,
source_url=source_name or 'uploaded.zip',
source_type='upload',
user_id=user_id,
)
def _install_from_buffer(buf, source_url, source_type, user_id=None, force=False,
hot_load=True, sig_result=None):
"""Shared install pipeline: takes a seekable BytesIO containing a zip,
extracts it into the panel's plugin dirs, and registers / hot-loads
the resulting blueprint. All public install_* helpers funnel here so
behavior matches across URL / local / upload sources.
`force=True` allows reinstalling over an active plugin (the update path);
normal installs still refuse to clobber an active install. `hot_load=False`
skips the immediate blueprint registration (the boot repair pass, #48).
`sig_result` is the signature verdict produced by the caller (only
install_from_url can produce one — local/upload/builtin installs have no
detached signature). It is stamped on EVERY successful install so a
reinstall via a different source can never inherit a stale 'verified'
badge from an earlier signed install (audit M1).
"""
_ensure_dirs()
# Open zip
try:
zf = zipfile.ZipFile(buf)
except zipfile.BadZipFile:
raise ValueError('File is not a valid zip archive')
# Find and read manifest
manifest_path, prefix = _find_manifest(zf)
if not manifest_path:
raise ValueError('No plugin.json found in archive')
manifest = json.loads(zf.read(manifest_path))
_validate_manifest(manifest)
_assert_manifest_panel_compatible(manifest)
_assert_manifest_sdk_compatible(manifest)
slug = manifest['name']
# Check if already installed
existing = InstalledPlugin.query.filter_by(slug=slug).first()
if existing and existing.status in ('active', 'installing') and not force:
raise ValueError(f"Plugin '{slug}' is already installed (v{existing.version}). Uninstall first to reinstall.")
# Remember the version we're replacing so we can run lifecycle.upgrade when
# it actually changes (reinstall/update over an existing row).
previous_version = existing.version if existing else None
# Create DB record early so we can track errors
if existing:
plugin = existing
plugin.status = InstalledPlugin.STATUS_INSTALLING
plugin.error_message = None
plugin.version = manifest['version']
plugin.source_url = source_url
plugin.source_type = source_type
plugin.manifest = manifest
# Refresh display/description/author/etc from the new manifest so
# the UI reflects the just-installed version, not the original.
_update_plugin_metadata(plugin, manifest)
else:
plugin = InstalledPlugin(
name=manifest['name'],
display_name=manifest['display_name'],
slug=slug,
version=manifest['version'],
description=manifest.get('description', ''),
author=manifest.get('author', ''),
homepage=manifest.get('homepage', ''),
repository=manifest.get('repository', ''),
license=manifest.get('license', ''),
category=manifest.get('category', 'utility'),
source_url=source_url,
source_type=source_type,
installed_by=user_id,
status=InstalledPlugin.STATUS_INSTALLING,
)
plugin.manifest = manifest
db.session.add(plugin)
db.session.commit()
try:
# First pass — figure out what the archive contains. This lets us
# bail early with a useful error if the plugin needs a frontend
# dir we can't write to (the dockerized panel case).
has_backend = False
has_frontend = False
for member in zf.namelist():
rel = member[len(prefix):] if prefix else member
if not rel or rel.endswith('/'):
continue
if rel.startswith('backend/'):
has_backend = True
elif rel.startswith('frontend/'):
has_frontend = True
if has_frontend:
_ensure_frontend_dir() # raises with a helpful message on failure
backend_dest = os.path.join(BACKEND_PLUGINS_DIR, slug)
frontend_dest = os.path.join(FRONTEND_PLUGINS_DIR, slug)
# Clean old install
if os.path.exists(backend_dest):
shutil.rmtree(backend_dest)
if has_frontend and os.path.exists(frontend_dest):
shutil.rmtree(frontend_dest)
# Decompression cap (audit L5): refuse archives that expand past
# _MAX_EXTRACT_BYTES — a small zip can declare or write gigabytes
# (zip bomb). Pre-check the declared sizes (cheap, catches honest
# bombs before writing a byte), then count actual bytes written
# (the declared size can lie). On abort, remove the partial dest
# dirs so no half-extracted plugin is left behind.
declared_total = sum(
info.file_size for info in zf.infolist() if not info.is_dir())
if declared_total > _MAX_EXTRACT_BYTES:
raise ValueError(
f'Archive expands to {declared_total // (1024 * 1024)} MB, '
f'over the {_MAX_EXTRACT_BYTES // (1024 * 1024)} MB cap — '
f'refusing to extract (possible zip bomb)')
extracted_total = 0
def _write_member(src, dst):
nonlocal extracted_total
while True:
chunk = src.read(1024 * 1024)
if not chunk:
break
extracted_total += len(chunk)
if extracted_total > _MAX_EXTRACT_BYTES:
raise ValueError(
f'Archive expands past the '
f'{_MAX_EXTRACT_BYTES // (1024 * 1024)} MB cap — '
f'refusing to extract (possible zip bomb)')
dst.write(chunk)
try:
for member in zf.namelist():
# Strip the GitHub zipball prefix
rel_path = member[len(prefix):] if prefix else member
if not rel_path or rel_path.endswith('/'):
continue
if rel_path.startswith('backend/'):
# Zip Slip defense: _safe_extract_path rejects absolute
# paths, .. segments, and anything that resolves outside
# backend_dest after normalization.
out_path = _safe_extract_path(
backend_dest, rel_path[len('backend/'):]
)
os.makedirs(os.path.dirname(out_path), exist_ok=True)
with zf.open(member) as src, open(out_path, 'wb') as dst:
_write_member(src, dst)
elif rel_path.startswith('frontend/'):
out_path = _safe_extract_path(
frontend_dest, rel_path[len('frontend/'):]
)
os.makedirs(os.path.dirname(out_path), exist_ok=True)
with zf.open(member) as src, open(out_path, 'wb') as dst:
_write_member(src, dst)
elif rel_path == 'requirements.txt':
# Plugin Python deps are sandboxed by default: installing
# them runs pip with the backend's privileges, which means
# arbitrary code via setup.py hooks. Operators must opt in
# by setting SERVERKIT_ALLOW_PLUGIN_PIP=1.
# Same predicate the admin surface reports (plan 55 task 6)
# — if the two ever disagreed, the page would describe a
# behaviour the installer does not have.
req_content = zf.read(member).decode('utf-8')
if req_content.strip() and plugin_pip_enabled():
_install_requirements(req_content, slug)
elif req_content.strip():
# Persist the requirements file so the admin can review
# and install it manually if they want.
req_out = _safe_extract_path(backend_dest, 'requirements.txt')
os.makedirs(os.path.dirname(req_out), exist_ok=True)
with open(req_out, 'w', encoding='utf-8') as f:
f.write(req_content)
logger.warning(
f"Plugin {slug} ships requirements.txt; skipping install "
f"(set SERVERKIT_ALLOW_PLUGIN_PIP=1 to enable). "
f"File saved to {req_out} for manual review."
)
except ValueError:
# Extraction aborted (zip bomb cap) — drop the partial dest dirs so
# no half-extracted plugin is left behind, then re-raise into the
# outer error handler (marks the row STATUS_ERROR).
for dest in (backend_dest, frontend_dest):
if os.path.isdir(dest):
shutil.rmtree(dest, ignore_errors=True)
raise
# Also write the manifest into the backend plugin dir for runtime access.
# The trailing newline matters: these manifests are tracked files that
# scripts/new-extension.mjs and scripts/sync-builtin-frontends.mjs also
# write, and both append one. json.dump does not, so without it every
# install rewrote the file with the newline stripped and left a dirty
# working tree whose diff contained no actual change.
# newline='\n' matters for the same reason: .gitattributes pins the repo
# to `text=auto eol=lf`, but Python's text mode translates \n to \r\n on
# Windows, so a dev-machine install rewrote every tracked manifest with
# CRLF.
if has_backend:
manifest_out = os.path.join(backend_dest, 'plugin.json')
with open(manifest_out, 'w', newline='\n') as f:
json.dump(manifest, f, indent=2)
f.write('\n')
# Also write manifest to frontend dir
if has_frontend:
manifest_out = os.path.join(frontend_dest, 'plugin.json')
with open(manifest_out, 'w', newline='\n') as f:
json.dump(manifest, f, indent=2)
f.write('\n')
# Determine blueprint info from manifest
entry_point = manifest.get('entry_point', '')
url_prefix = manifest.get('url_prefix', f'/api/v1/{slug}')
plugin.has_backend = has_backend
plugin.has_frontend = has_frontend
plugin.backend_path = f'app/plugins/{slug}' if has_backend else None
plugin.frontend_path = f'src/plugins/{slug}' if has_frontend else None
plugin.entry_point = entry_point
plugin.url_prefix = url_prefix
plugin.frontend_entry = manifest.get('frontend_entry', '')
# Runtime-frontend integrity (plan 25 #6, core-slim #39): pin the sha256
# of the prebuilt ESM bundle so the client loader can verify the bytes it
# blob-imports match what was installed. No-op for baked .jsx builtins.
if has_frontend:
_record_frontend_hashes(plugin, manifest, frontend_dest)
plugin.status = InstalledPlugin.STATUS_ACTIVE
db.session.commit()
# Stamp the signature verdict on every successful install (None for
# local/upload/builtin sources → 'unsigned'), so a reinstall via a
# different source never keeps a stale 'verified' badge.
_record_signature_status(plugin, sig_result)
# Try to register the blueprint immediately (hot-load)
if has_backend and entry_point and hot_load:
try:
_register_plugin_blueprint(plugin)
except Exception as e:
logger.warning(f'Blueprint hot-load failed for {slug} (will load on restart): {e}')
# Regenerate frontend plugin manifest
if has_frontend:
_regenerate_frontend_manifest()
# Install template dependencies declared in manifest
# (e.g. a "git-server" extension declaring "templates": ["gitea"]).
# Best effort — record per-template result on the plugin row so the
# UI can show what happened, but a template install failure does
# not roll back the plugin install.
template_results = _install_template_dependencies(plugin, manifest)
# Register plugin-owned data models (create ext_<slug>_ tables) and
# declarative jobs/schedules before the install hook runs, so the hook
# can seed rows into those tables. Best-effort (never fail the install).
if has_backend:
try:
from app.services import extension_lifecycle
extension_lifecycle.register_models(plugin, manifest)
extension_lifecycle.register_jobs(plugin, manifest)
extension_lifecycle.register_capabilities(plugin, manifest)
except Exception as e:
logger.warning(f'Extension lifecycle setup failed for {slug}: {e}')
try:
from app.plugins_sdk import sockets as _ext_sockets
_ext_sockets.register_from_manifest(plugin, manifest)
except Exception as e:
logger.warning(f'Extension socket setup failed for {slug}: {e}')
# Run plugin's lifecycle.install hook if declared. Failure here is
# also non-fatal — the plugin is installed; the hook is for
# convenience setup (e.g. creating default rows).
_run_lifecycle_hook(plugin, manifest, 'install')
# If this was an in-place version change, run the upgrade hook too.
if previous_version and previous_version != manifest['version']:
_run_lifecycle_hook(plugin, manifest, 'upgrade')
# If the plugin declares an "ai" block, register its tools/context with
# the core assistant now so installing it teaches the assistant new
# abilities without a restart. Best effort.
_refresh_plugin_ai(plugin)
logger.info(f'Plugin {slug} v{manifest["version"]} installed successfully')
if template_results:
plugin._template_install_results = template_results # surfaced via to_dict if needed
return plugin
except Exception as e:
plugin.status = InstalledPlugin.STATUS_ERROR
plugin.error_message = str(e)
db.session.commit()
raise
def _install_requirements(req_content, plugin_name):
"""Install Python requirements for a plugin."""
if not req_content.strip():
return
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
f.write(req_content)
req_path = f.name
try:
logger.info(f'Installing requirements for plugin {plugin_name}')
install = run_checked(
[sys.executable, '-m', 'pip', 'install', '-r', req_path, '--quiet'],
timeout=300,
)
if not install['success']:
logger.error('Failed to install requirements for %s: %s',
plugin_name, install['error'])
raise ValueError(
f"Failed to install Python dependencies: {install['error']}")
finally:
os.unlink(req_path)
def _attach_status_guard(bp, slug):
"""Attach a before_request that 503s if the plugin is disabled.
Flask blueprints can't be unregistered from a running app, so once a
plugin's routes are loaded they stay reachable until restart. This
guard makes the in-DB status authoritative at request time, so
disabling a plugin actually stops serving its routes.
"""
# A blueprint may be mounted at more than one url_prefix (e.g. an alias),
# but the guard only needs to run once per blueprint object.
if getattr(bp, '_sk_status_guarded', False):
return
def _check():
from flask import jsonify
p = InstalledPlugin.query.filter_by(slug=slug).first()
if not p or p.status != InstalledPlugin.STATUS_ACTIVE:
return jsonify({
'error': f"Plugin '{slug}' is not active",
'status': p.status if p else 'uninstalled',
}), 503
return None
bp.before_request(_check)
bp._sk_status_guarded = True
def _register_extra_blueprints(register, plugin, manifest):
"""Register a plugin's ``extra_blueprints`` beyond the single ``entry_point``.
Some extensions (WordPress) expose several blueprints and even mount one
blueprint at more than one prefix (a deprecation-window alias). The manifest
declares them as::
"extra_blueprints": [
{"module": "wordpress_sites", "attr": "wordpress_sites_bp",
"url_prefix": "/api/v1/wordpress"},
{"module": "environment_pipeline", "attr": "environment_pipeline_bp",
"url_prefix": "/api/v1/wordpress/pipelines",
"name": "environment_pipeline_pipelines"}
]
``register`` is a ``callable(bp, url_prefix, name=None)`` — either
``current_app.register_blueprint`` (hot-load) or ``app.register_blueprint``
(boot). Each blueprint gets the same status guard as the entry blueprint.
"""
import importlib
for extra in (manifest or {}).get('extra_blueprints', []):
module_name = extra.get('module')
attr = extra.get('attr')
url_prefix = extra.get('url_prefix')
name = extra.get('name')
if not (module_name and attr):
continue
full_module = f'app.plugins.{plugin.slug}.{module_name}'
mod = importlib.import_module(full_module)
bp = getattr(mod, attr)
_attach_status_guard(bp, plugin.slug)
# Only pass name= when the manifest supplies one (mounting the SAME
# blueprint at a second prefix needs a distinct name, e.g. the WordPress
# /pipelines alias); passing name=None would clobber the blueprint name.
opts = {'url_prefix': url_prefix}
if name:
opts['name'] = name
register(bp, **opts)
logger.info(f'Registered extra blueprint {attr} at {url_prefix}'
+ (f' (as {name})' if name else ''))
def _register_blueprint_live(app, bp, **options):
"""Register a blueprint on an app that may already be serving requests.
Flask refuses setup calls once the app has handled its first request
(``AssertionError: The setup method 'register_blueprint' can no longer be
called...``), so on any live panel — where login and dashboard requests
long precede the first extension install — hot-load always failed and the
plugin's routes stayed missing until a restart: unmatched POSTs then fell
through to the static route and surfaced as a 405 (#144).
Late registration is safe in this deployment shape: the panel runs a
single gunicorn worker with threads (the process installing the plugin is
the process serving its routes), and werkzeug rebuilds its URL matcher
lazily on the next bind while in-flight requests keep the matcher they
already bound. Temporarily clearing the flag only waives Flask's setup
guard; it does not touch request state.
"""
if not app._got_first_request:
app.register_blueprint(bp, **options)
return
app._got_first_request = False
try:
app.register_blueprint(bp, **options)
finally:
app._got_first_request = True
def _register_plugin_blueprint(plugin):
"""Dynamically register a plugin's Flask blueprint into the running app."""
from flask import current_app
import importlib
if not plugin.entry_point:
return
# entry_point format: "blueprint:ai_assistant_bp"
parts = plugin.entry_point.split(':')
if len(parts) != 2:
raise ValueError(f'Invalid entry_point format: {plugin.entry_point}')
module_name, bp_name = parts
full_module = f'app.plugins.{plugin.slug}.{module_name}'
try:
mod = importlib.import_module(full_module)
bp = getattr(mod, bp_name)
_attach_status_guard(bp, plugin.slug)
_register_blueprint_live(
current_app, bp, url_prefix=plugin.url_prefix)
logger.info(f'Registered blueprint {bp_name} at {plugin.url_prefix}')
_register_extra_blueprints(
lambda bp, **opts: _register_blueprint_live(current_app, bp, **opts),
plugin, plugin.manifest or {})
except Exception as e:
raise ValueError(f'Failed to register blueprint: {e}')
def _regenerate_frontend_manifest():
"""Generate a plugins-manifest.json for the frontend build system.
This file tells the frontend which plugins are installed and where
their components/styles live so Vite can include them.
Best-effort: if the frontend dir doesn't exist (e.g. a pure backend
plugin install where no frontend was ever extracted, or a Docker
panel without the bind mount), we just log and move on rather than
failing the install.
Never runs under the testing config: the test DB only has whatever a given
test installed, so regenerating would clobber the repo's checked-in
plugins-manifest.json with partial state (the flagship seed runs on every
test boot). Tests that care about the manifest monkeypatch the dir.
"""
try:
from flask import current_app
if current_app and current_app.config.get('TESTING'):
return
except Exception:
pass
if not os.path.isdir(FRONTEND_PLUGINS_DIR):
logger.info(
f'Skipping frontend manifest regeneration: '
f'{FRONTEND_PLUGINS_DIR} does not exist'
)
return
manifest_path = os.path.join(FRONTEND_PLUGINS_DIR, 'plugins-manifest.json')
plugins = InstalledPlugin.query.filter(
InstalledPlugin.has_frontend == True,
InstalledPlugin.status.in_(['active']),
).all()
entries = []
for p in plugins:
entry = {
'name': p.name,
'slug': p.slug,
'display_name': p.display_name,
'version': p.version,
'frontend_entry': p.frontend_entry,
'path': p.slug,
}
# Check for styles
style_dir = os.path.join(FRONTEND_PLUGINS_DIR, p.slug, 'styles')
if os.path.isdir(style_dir):
styles = [
f for f in os.listdir(style_dir)
if f.endswith('.scss') or f.endswith('.css') or f.endswith('.less')
]
entry['styles'] = [f'plugins/{p.slug}/styles/{s}' for s in styles]
entries.append(entry)
# Trailing newline and LF for the same reasons as the per-plugin manifests
# above: scripts/sync-builtin-frontends.mjs writes this same tracked file
# with one, and .gitattributes pins the repo to eol=lf.
with open(manifest_path, 'w', newline='\n') as f:
json.dump({'plugins': entries}, f, indent=2)
f.write('\n')
logger.info(f'Frontend plugin manifest regenerated with {len(entries)} plugin(s)')
def repair_missing_plugins():
"""Self-heal installed plugins whose files are gone (#48).
A panel update deploys a fresh source tree preserving only `.env` and the
database, so a URL/upload-installed plugin's extracted files (and a
copy-installed builtin's backend under ``app/plugins/``) can vanish while
the InstalledPlugin row stays active — the boot loader would then flip it
to `error`. Before the loader runs, restore what we can:
- builtin slug → reinstall from ``builtin-extensions/`` (in-repo,
always present in the fresh tree)
- source_type 'url' or 'registry'
→ re-download from ``source_url`` (no checksum —
the original install verified it; best effort)
- anything else → mark `error` with a re-upload hint
Reinstalls run with ``hot_load=False`` — load_all_plugins registers the
blueprints right after this pass. Never raises; each repair is
best-effort and logged. The update.sh carry-forward is the first line of
defense; this is the backstop (e.g. Docker image updates).
"""
builtin_by_slug = {}
try:
for entry in list_builtin_extensions():
builtin_by_slug[entry['slug']] = entry
except Exception:
pass
plugins = InstalledPlugin.query.filter(
InstalledPlugin.status.in_([
InstalledPlugin.STATUS_ACTIVE,
InstalledPlugin.STATUS_ERROR,
]),
).all()
for plugin in plugins:
slug = plugin.slug
# Flagships load in-place from builtin-extensions/ (repo-tracked, never
# copy-installed) — nothing on disk to go missing.
if slug in FLAGSHIP_SLUGS:
continue
backend_missing = bool(plugin.has_backend) and not os.path.isdir(
os.path.join(BACKEND_PLUGINS_DIR, slug))
frontend_missing = bool(plugin.has_frontend) and not os.path.isdir(
os.path.join(FRONTEND_PLUGINS_DIR, slug))
if not backend_missing and not frontend_missing:
continue
halves = ' + '.join(h for h, m in (('backend', backend_missing),
('frontend', frontend_missing)) if m)
logger.warning(
f'Plugin {slug}: {halves} files missing (panel update?) — attempting repair')
try:
if slug in builtin_by_slug:
install_from_path(builtin_by_slug[slug]['path'],
user_id=plugin.installed_by,
force=True, hot_load=False,
source_type='builtin')
logger.info(f'Plugin {slug}: restored from builtin-extensions/')
elif plugin.source_type in ('url', 'registry') and plugin.source_url:
install_from_url(plugin.source_url, user_id=plugin.installed_by,
force=True, hot_load=False,
source_type=plugin.source_type)
logger.info(f'Plugin {slug}: reinstalled from {plugin.source_url}')
else:
plugin.status = InstalledPlugin.STATUS_ERROR
plugin.error_message = (
'Plugin files are missing (likely removed by a panel update) '
'and there is no source URL to reinstall from. Re-upload the '
'plugin zip from the Marketplace to restore it.'
)
db.session.commit()
except Exception as e:
logger.error(f'Plugin {slug}: repair failed: {e}')
try:
plugin.status = InstalledPlugin.STATUS_ERROR
plugin.error_message = f'Repair after panel update failed: {e}'
db.session.commit()
except Exception:
db.session.rollback()
def load_all_plugins(app):
"""Load all active plugin blueprints at app startup.
Called from create_app() to register all installed plugin blueprints.
"""
_ensure_dirs()
with app.app_context():
# Restore any install whose files a panel update wiped (#48) BEFORE
# querying/loading, so a repaired plugin loads normally this boot.
try:
repair_missing_plugins()
except Exception as e:
logger.warning(f'Plugin repair pass failed: {e}')
plugins = InstalledPlugin.query.filter_by(
status=InstalledPlugin.STATUS_ACTIVE,
has_backend=True,
).all()
for plugin in plugins:
if not plugin.entry_point:
continue
try:
parts = plugin.entry_point.split(':')
if len(parts) != 2:
continue
module_name, bp_name = parts
full_module = f'app.plugins.{plugin.slug}.{module_name}'
import importlib
mod = importlib.import_module(full_module)
bp = getattr(mod, bp_name)
_attach_status_guard(bp, plugin.slug)
app.register_blueprint(bp, url_prefix=plugin.url_prefix)
logger.info(f'Loaded plugin: {plugin.display_name} v{plugin.version} at {plugin.url_prefix}')
manifest = plugin.manifest or {}
_register_extra_blueprints(app.register_blueprint, plugin, manifest)
# Re-establish the plugin's data models, jobs, core-hook
# registrations, and socket namespace on boot (install-time
# registration doesn't survive a restart).
try:
from app.services import extension_lifecycle
extension_lifecycle.register_models(plugin, manifest)
extension_lifecycle.register_jobs(plugin, manifest)
extension_lifecycle.register_capabilities(plugin, manifest)
from app.plugins_sdk import sockets as _ext_sockets
_ext_sockets.register_from_manifest(plugin, manifest)
except Exception as e:
logger.warning(f'Extension runtime setup for {plugin.slug}: {e}')
except Exception as e:
logger.error(f'Failed to load plugin {plugin.slug}: {e}')
plugin.status = InstalledPlugin.STATUS_ERROR
plugin.error_message = f'Failed to load: {e}'
db.session.commit()
def uninstall_plugin(plugin_id, purge=False):
"""Uninstall a plugin by removing its files and DB record.
`purge=True` also drops the plugin's ``ext_<slug>_`` data tables (keep-data
is the default, mirroring the installer's --purge/--keep-data semantics).
"""
plugin = InstalledPlugin.query.get(plugin_id)
if not plugin:
return False
slug = plugin.slug
manifest = plugin.manifest or {}
# Run lifecycle.uninstall hook *before* we delete the files (the hook
# may need to read its own files). It's told whether the caller asked to
# purge data. Best effort — never block teardown.
_run_lifecycle_hook(plugin, manifest, 'uninstall', purge=purge)
# Tear down declarative schedules, and (only on purge) the data tables and
# the plugin's key/value store. Store rows follow the same rule as the
# ext_* tables: an ordinary uninstall leaves them, so a reinstall finds its
# state where it left it, and "uninstall and delete data" removes them.
try:
from app.services import extension_lifecycle
extension_lifecycle.remove_jobs(plugin, manifest)
# Audit F1: seam registrations (backup kinds, event types, template
# providers) must not keep executing after uninstall.
extension_lifecycle.unregister_capabilities(plugin)
if purge:
extension_lifecycle.purge_models(plugin)
from app.plugins_sdk.store_sdk import purge as purge_store
removed = purge_store(slug)
if removed:
logger.info(f'Purged {removed} store row(s) for {slug}')
except Exception as e:
logger.warning(f'Extension lifecycle teardown for {slug}: {e}')
# Drop any AI tools/context this plugin contributed to the assistant.
_unregister_plugin_ai(slug)
if slug in FLAGSHIP_SLUGS:
# Flagships live in-place (backend in builtin-extensions/, frontend
# pre-bundled under src/plugins/ — both tracked in the repo). Never delete
# those; just record the user's choice so the boot seed leaves it out, and
# drop the row (the status guard 503s any still-registered routes).
_set_flagship_uninstalled(slug, True)
else:
# Remove backend files
backend_dest = os.path.join(BACKEND_PLUGINS_DIR, slug)
if os.path.exists(backend_dest):
shutil.rmtree(backend_dest)
# Remove frontend files
frontend_dest = os.path.join(FRONTEND_PLUGINS_DIR, slug)
if os.path.exists(frontend_dest):
shutil.rmtree(frontend_dest)
db.session.delete(plugin)
db.session.commit()
# Regenerate frontend manifest
_regenerate_frontend_manifest()
logger.info(f'Plugin {slug} uninstalled')
return True
def enable_plugin(plugin_id):
"""Enable a disabled plugin."""
plugin = InstalledPlugin.query.get(plugin_id)
if not plugin:
return None
plugin.status = InstalledPlugin.STATUS_ACTIVE
plugin.error_message = None
db.session.commit()
_regenerate_frontend_manifest()
_refresh_plugin_ai(plugin)
# Resume the plugin's scheduled jobs (status-guard parity for background work).
try:
from app.services import extension_lifecycle
extension_lifecycle.resume_jobs(plugin, plugin.manifest or {})
# Audit F4: re-register the core seams disable tore down (idempotent).
extension_lifecycle.register_capabilities(plugin, plugin.manifest or {})
except Exception as e:
logger.warning(f'Could not resume jobs for {plugin.slug}: {e}')
return plugin
def disable_plugin(plugin_id):
"""Disable a plugin without removing files."""
plugin = InstalledPlugin.query.get(plugin_id)
if not plugin:
return None
plugin.status = InstalledPlugin.STATUS_DISABLED
db.session.commit()
_regenerate_frontend_manifest()
_unregister_plugin_ai(plugin.slug)
# Pause the plugin's scheduled jobs so a disabled plugin does no background work.
try:
from app.services import extension_lifecycle
extension_lifecycle.pause_jobs(plugin, plugin.manifest or {})
# Audit F1: seam registrations (backup kinds, event types, template
# providers) must not keep executing while disabled.
extension_lifecycle.unregister_capabilities(plugin)
except Exception as e:
logger.warning(f'Could not pause jobs for {plugin.slug}: {e}')
return plugin
def _refresh_plugin_ai(plugin):
"""Register a plugin's AI tools/context with the core assistant (if it has an
``ai`` manifest block). Idempotent; safe whether or not AI is initialized."""
try:
manifest = plugin.manifest or {}
if isinstance(manifest.get('ai'), dict):
from app.services.ai_tool_registry import ai_tool_registry
ai_tool_registry.reload_plugin(plugin.slug)
except Exception:
logger.warning(f'AI tool refresh failed for plugin {plugin.slug}', exc_info=True)
def _unregister_plugin_ai(slug):
"""Drop a plugin's AI tools/context from the core assistant."""
try:
from app.services.ai_tool_registry import ai_tool_registry
ai_tool_registry.unregister_plugin(slug)
except Exception:
logger.warning(f'AI tool unregister failed for plugin {slug}', exc_info=True)
def _run_lifecycle_hook(plugin, manifest, phase, **kwargs):
"""Execute a plugin's lifecycle hook.
Manifest format:
"lifecycle": { "install": "module:func", "upgrade": "module:func",
"uninstall": "module:func" }
The module path is resolved under ``app.plugins.<slug>``. The hook receives
the InstalledPlugin row as its single positional arg; the uninstall hook may
additionally accept ``purge`` (whether the caller asked to drop data). Hooks
written as ``func(plugin)`` still work — extra kwargs are only passed when the
signature accepts them. Return value is ignored; failure is logged and
swallowed — lifecycle hooks are convenience, not correctness.
"""
lifecycle = (manifest or {}).get('lifecycle') or {}
target = lifecycle.get(phase)
if not target or ':' not in target:
return
module_name, func_name = target.split(':', 1)
full_module = f'app.plugins.{plugin.slug}.{module_name}'
try:
import importlib
import inspect
mod = importlib.import_module(full_module)
func = getattr(mod, func_name, None)
if not callable(func):
logger.warning(
f'Lifecycle {phase} hook {target} for {plugin.slug} is not callable'
)
return
# Only forward kwargs the hook actually declares (keeps func(plugin) valid).
if kwargs:
try:
params = inspect.signature(func).parameters
accepts_kwargs = any(
p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()
)
passable = {k: v for k, v in kwargs.items()
if accepts_kwargs or k in params}
except (TypeError, ValueError):
passable = {}
func(plugin, **passable)
else:
func(plugin)
logger.info(f'Ran lifecycle.{phase} hook for {plugin.slug}')
except Exception as e:
logger.warning(f'Lifecycle {phase} hook for {plugin.slug} failed: {e}')
def _install_template_dependencies(plugin, manifest):
"""Install app templates declared in the manifest's `templates` key.
Format:
"templates": ["gitea", "umami"] # template ids
"templates": [{ "id": "gitea",
"app_name": "git-server", # default: template id
"variables": {"PORT": "3000"} }]
Returns a list of {template_id, success, message} dicts. Failures are
logged but do not abort the plugin install — the user can install the
template manually from /templates if it didn't auto-install.
"""
deps = (manifest or {}).get('templates') or []
if not deps:
return []
try:
from app.services.template_service import TemplateService
except Exception as e:
logger.warning(f'TemplateService unavailable; skipping template deps: {e}')
return []
results = []
for dep in deps:
if isinstance(dep, str):
template_id, app_name, variables = dep, dep, {}
elif isinstance(dep, dict):
template_id = dep.get('id') or dep.get('template_id')
app_name = dep.get('app_name') or template_id
variables = dep.get('variables') or {}
else:
continue
if not template_id:
continue
try:
res = TemplateService.install_template(
template_id=template_id,
app_name=app_name,
user_variables=variables,
user_id=plugin.installed_by,
)
ok = bool(res and res.get('success'))
results.append({
'template_id': template_id,
'success': ok,
'message': res.get('error') if not ok else 'installed',
})
if ok:
logger.info(f'Plugin {plugin.slug} installed template {template_id}')
else:
logger.warning(
f'Plugin {plugin.slug} template dep {template_id} failed: '
f'{res.get("error")}'
)
except Exception as e:
logger.warning(
f'Plugin {plugin.slug} template dep {template_id} raised: {e}'
)
results.append({
'template_id': template_id,
'success': False,
'message': str(e),
})
return results
def list_plugins(status=None):
"""List installed plugins."""
query = InstalledPlugin.query
if status:
query = query.filter_by(status=status)
return query.order_by(InstalledPlugin.display_name).all()
def get_plugin(plugin_id):
"""Get a single plugin by ID."""
return InstalledPlugin.query.get(plugin_id)
def get_plugin_by_slug(slug):
"""Get a plugin by its slug."""
return InstalledPlugin.query.filter_by(slug=slug).first()
# Opt-in for installing a plugin's Python dependencies. Off by default: pip runs
# with the backend's privileges and a setup.py hook is arbitrary code.
PLUGIN_PIP_ENV = 'SERVERKIT_ALLOW_PLUGIN_PIP'
# How big a requirements.txt we are willing to echo back to the browser.
_MAX_REQUIREMENTS_BYTES = 64 * 1024
def plugin_pip_enabled():
"""Whether the operator has opted into installing plugin Python deps."""
return env_bool(PLUGIN_PIP_ENV)
def pending_requirements(plugin):
"""The Python dependencies an install declined to pip-install (plan 55).
When the opt-in is off, install writes the plugin's requirements.txt into
its backend dir and moves on with a log line nobody reads — the extension
then runs with imports it may not have. This makes that state answerable:
the file's contents, where it is, and the exact env var that changes the
behaviour.
Read-only by design. Nothing here installs anything, and the plan is
explicit that this surface introduces no auto-install behaviour change.
"""
info = {
'slug': getattr(plugin, 'slug', None),
'pending': False,
'pip_enabled': plugin_pip_enabled(),
'env_var': PLUGIN_PIP_ENV,
'path': None,
'content': '',
'packages': [],
'truncated': False,
}
backend_path = getattr(plugin, 'backend_path', None)
if not backend_path:
return info
# backend_path is stored relative to the backend package root; join it the
# same way the loader does rather than trusting it as absolute.
candidate = backend_path if os.path.isabs(backend_path) else os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '..',
backend_path)
req_path = os.path.normpath(os.path.join(candidate, 'requirements.txt'))
if not os.path.isfile(req_path):
return info
try:
size = os.path.getsize(req_path)
with open(req_path, 'r', encoding='utf-8', errors='replace') as fh:
content = fh.read(_MAX_REQUIREMENTS_BYTES)
except OSError as exc:
logger.warning('could not read %s: %s', req_path, exc)
return info
info.update({
'pending': True,
'path': req_path,
'content': content,
'truncated': size > _MAX_REQUIREMENTS_BYTES,
# One line per requirement, comments and blanks dropped — enough for a
# compact list without pretending to parse PEP 508.
'packages': [ln.strip() for ln in content.splitlines()
if ln.strip() and not ln.strip().startswith('#')],
})
return info
# Plugin-config keys the panel owns — never user-editable, preserved across a
# config PUT. The runtime-frontend loader's per-bundle sha256 hashes live here,
# and so does the install-time signature verdict (plan 55).
RESERVED_PLUGIN_CONFIG_KEYS = ('_frontend_hashes', '_signature')
def _record_frontend_hashes(plugin, manifest, frontend_dest):
"""Pin the sha256 of a runtime-frontend ESM bundle at install time.
Stored on the plugin's config under the panel-managed ``_frontend_hashes``
key (a reserved namespace preserved across config edits). Only ESM bundles
(manifest ``frontend_entry`` ending ``.mjs``) are hashed — baked ``.jsx``
builtins load via the build-time glob and need no integrity pin. Best
effort: a missing bundle file just records nothing (the loader then refuses
to import an extension with no recorded hash).
"""
entry = (manifest.get('frontend_entry') or '').strip()
if not entry.endswith('.mjs'):
return
bundle_path = os.path.join(frontend_dest, entry)
if not os.path.isfile(bundle_path):
return
import hashlib
with open(bundle_path, 'rb') as f:
digest = hashlib.sha256(f.read()).hexdigest()
cfg = dict(plugin.config or {})
hashes = dict(cfg.get('_frontend_hashes') or {})
hashes[entry] = digest
cfg['_frontend_hashes'] = hashes
plugin.config = cfg
def update_plugin_config(slug, config):
"""Save a plugin's user config, preserving panel-managed reserved keys.
A config PUT from the Marketplace replaces the user-visible values but must
never drop (or let the caller forge) the panel-managed ``_frontend_hashes``
integrity pins. Reserved keys are stripped from the incoming payload and the
existing values are re-applied on top.
"""
plugin = get_plugin_by_slug(slug)
if not plugin:
raise ValueError('Plugin not found')
existing = plugin.config or {}
new_cfg = {
k: v for k, v in (config or {}).items()
if k not in RESERVED_PLUGIN_CONFIG_KEYS
}
for key in RESERVED_PLUGIN_CONFIG_KEYS:
if key in existing:
new_cfg[key] = existing[key]
plugin.config = new_cfg
db.session.commit()
return plugin
def list_builtin_extensions():
"""Enumerate folders in BUILTIN_EXTENSIONS_DIR that look like plugins.
Each entry includes the manifest, the absolute source path, and the
install state (installed/active/disabled/error/not_installed). The
Marketplace UI uses this to render one-click installs.
"""
out = []
if not os.path.isdir(BUILTIN_EXTENSIONS_DIR):
return out
for name in sorted(os.listdir(BUILTIN_EXTENSIONS_DIR)):
folder = os.path.join(BUILTIN_EXTENSIONS_DIR, name)
manifest_path = os.path.join(folder, 'plugin.json')
if not os.path.isfile(manifest_path):
continue
try:
with open(manifest_path, 'r', encoding='utf-8') as f:
manifest = json.load(f)
except Exception as e:
logger.warning(f'Skipping builtin {name}: bad plugin.json ({e})')
continue
slug = manifest.get('name') or name
existing = InstalledPlugin.query.filter_by(slug=slug).first()
installed_state = existing.status if existing else 'not_installed'
out.append({
'folder': name,
'path': folder,
'slug': slug,
'manifest': manifest,
'installed': existing is not None,
'install_id': existing.id if existing else None,
'status': installed_state,
})
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', 'serverkit-analytics'],
'web-apps': ['serverkit-git', 'serverkit-status', 'serverkit-analytics'],
'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
# Wizard round 2 (plan 47 Ph5): "How much security tooling?" The extracted
# security suite installs by chosen posture, not per-tool nagging. Slugs here
# resolve through the same catalog index as the use-case map — an entry that
# is not yet published (or a registry that is unreachable) silently drops out,
# so the wizard only ever offers what is actually installable right now.
# Deliberately NOT folded into RECOMMENDED_EXTENSIONS_BY_USE_CASE: posture is
# persisted separately (`onboarding_security_posture`) and orthogonal to what
# the server is for.
SECURITY_POSTURE_LEVELS = {
'minimal': [],
'recommended': ['serverkit-fail2ban', 'serverkit-auto-updates'],
'hardened': [
'serverkit-fail2ban', 'serverkit-auto-updates', 'serverkit-clamav',
'serverkit-lynis', 'serverkit-image-scan', 'serverkit-crowdsec',
],
}
def recommend_security_extensions(posture):
"""Resolve a security posture to installable extension metadata.
Same contract as ``recommend_extensions_for_use_cases``: order-preserving,
and slugs that don't resolve to a catalog entry are dropped rather than
surfaced as dead install buttons."""
index = _recommendation_index()
out = []
for slug in SECURITY_POSTURE_LEVELS.get(posture or 'minimal', []):
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.<slug>.<module>`` (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):
raise ValueError(f'Builtin extensions dir not found: {BUILTIN_EXTENSIONS_DIR}')
# Flagships (D4) re-install in-place: clear the user-uninstall marker and
# re-seed the active row — no file copy, since the backend loads from
# builtin-extensions/ and the frontend is pre-bundled.
if slug in FLAGSHIP_SLUGS:
_set_flagship_uninstalled(slug, False)
for entry in list_builtin_extensions():
if entry['slug'] == slug:
_ensure_builtin_backend_importable(slug)
existing = InstalledPlugin.query.filter_by(slug=slug).first()
if existing:
existing.status = InstalledPlugin.STATUS_ACTIVE
db.session.commit()
# A builtin re-install carries no signature — never keep a
# verdict stamped by some earlier install of the slug.
_record_signature_status(existing, None)
return existing
plugin = _seed_flagship_row(entry)
try:
_register_plugin_blueprint(plugin)
except Exception as e:
logger.warning(
f'Flagship blueprint hot-load failed for {slug} '
f'(will load on restart): {e}')
return plugin
raise ValueError(f"No builtin extension with slug '{slug}'")
for entry in list_builtin_extensions():
if entry['slug'] == slug:
return install_from_path(entry['path'], user_id=user_id,
source_type='builtin')
raise ValueError(f"No builtin extension with slug '{slug}'")
# ── Flagship (D4) default-install machinery ──
def _flagship_uninstalled_set():
"""Slugs the user explicitly uninstalled — never re-seeded on boot."""
from app.services.settings_service import SettingsService
raw = SettingsService.get(_FLAGSHIP_UNINSTALLED_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 _set_flagship_uninstalled(slug, uninstalled):
from app.services.settings_service import SettingsService
current = _flagship_uninstalled_set()
if uninstalled:
current.add(slug)
else:
current.discard(slug)
SettingsService.set(_FLAGSHIP_UNINSTALLED_KEY, json.dumps(sorted(current)))
def _seed_flagship_row(entry):
"""Create an active InstalledPlugin row for a flagship without copying files.
The backend is loaded in-place from builtin-extensions/ (see
``_ensure_builtin_backend_importable``) and the frontend is pre-bundled under
``frontend/src/plugins/<slug>`` (D5), so the row only records install state
for the Marketplace/contributions surfaces and drives the status guard.
"""
manifest = entry['manifest']
slug = entry['slug']
has_backend = os.path.isdir(os.path.join(entry['path'], 'backend'))
has_frontend = os.path.isdir(os.path.join(entry['path'], 'frontend'))
plugin = InstalledPlugin(
name=manifest['name'],
display_name=manifest.get('display_name', slug),
slug=slug,
version=manifest['version'],
description=manifest.get('description', ''),
author=manifest.get('author', ''),
homepage=manifest.get('homepage', ''),
repository=manifest.get('repository', ''),
license=manifest.get('license', ''),
category=manifest.get('category', 'utility'),
source_type='builtin',
status=InstalledPlugin.STATUS_ACTIVE,
)
plugin.manifest = manifest
plugin.has_backend = has_backend
plugin.has_frontend = has_frontend
plugin.backend_path = f'builtin-extensions/{slug}/backend' if has_backend else None
plugin.frontend_path = f'src/plugins/{slug}' if has_frontend else None
plugin.entry_point = manifest.get('entry_point', '')
plugin.url_prefix = manifest.get('url_prefix', f'/api/v1/{slug}')
plugin.frontend_entry = manifest.get('frontend_entry', '')
db.session.add(plugin)
db.session.commit()
# Register plugin-owned data models / jobs / core hooks / sockets, same as
# a copy-install.
try:
from app.services import extension_lifecycle
extension_lifecycle.register_models(plugin, manifest)
extension_lifecycle.register_jobs(plugin, manifest)
extension_lifecycle.register_capabilities(plugin, manifest)
from app.plugins_sdk import sockets as _ext_sockets
_ext_sockets.register_from_manifest(plugin, manifest)
except Exception as e:
logger.warning(f'Flagship lifecycle setup for {slug}: {e}')
_refresh_plugin_ai(plugin)
# Only touch the frontend build manifest if the flagship actually ships a
# frontend (WordPress is backend-only — its UI stays core for now). A
# pre-bundled flagship frontend (D5) is already represented in the checked-in
# manifest, so there's nothing to regenerate here.
if plugin.has_frontend:
try:
_regenerate_frontend_manifest()
except Exception as e:
logger.warning(f'Frontend manifest regen after flagship seed for {slug}: {e}')
logger.info(f'Seeded flagship extension row: {slug} v{manifest["version"]}')
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.
Called from ``create_app`` right before ``load_all_plugins`` so the loader
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
# Make the backend importable in-place regardless (the bridge and the
# loader both rely on app.plugins.<slug> resolving).
_ensure_builtin_backend_importable(slug)
existing = InstalledPlugin.query.filter_by(slug=slug).first()
if existing:
# Heal a row left in ERROR by a prior failed load so the flagship
# comes back active on the next boot (the seed is authoritative for a
# flagship the user hasn't uninstalled). A deliberate DISABLE is kept.
if existing.status == InstalledPlugin.STATUS_ERROR:
existing.status = InstalledPlugin.STATUS_ACTIVE
existing.error_message = None
db.session.commit()
continue
try:
_seed_flagship_row(entry)
except Exception as e:
logger.warning(f'Flagship seed for {slug} failed (retry next boot): {e}')
def _assert_panel_compatible(entry):
"""Raise if the panel version is outside an entry's min/max_panel_version."""
from app.utils.version import version_satisfies, get_panel_version
panel = get_panel_version()
minv = entry.get('min_panel_version')
maxv = entry.get('max_panel_version')
if not version_satisfies(panel, minv, maxv):
raise ValueError(
f"{entry.get('display_name') or entry.get('slug')} "
f"v{entry.get('version')} needs panel "
f"{minv or '*'}–{maxv or '*'} (this panel is {panel})."
)
def install_registry_extension(slug, user_id=None):
"""Install an extension listed in the remote registry (checksum-verified).
When the index entry carries a ``signature``/``publisher_key_id`` pair
(index schema v3, plan 55 D3) the downloaded zip is also verified against
the panel's pinned publisher keys — a bad signature is a hard failure
even when the sha256 matches."""
from app.services import registry_service
entry = registry_service.get_entry(slug)
if not entry:
raise ValueError(f"No registry extension with slug '{slug}'")
_assert_panel_compatible(entry)
source = (entry.get('source') or '').strip()
if not source:
raise ValueError(f"Registry entry '{slug}' has no source URL.")
return install_from_url(
source, user_id=user_id, expected_sha256=entry.get('sha256'),
source_type='registry',
expected_signature=entry.get('signature'),
expected_key_id=entry.get('publisher_key_id'),
)
def check_for_updates():
"""Compare installed plugins to the registry; return update candidates.
Update v1 is registry-driven: a plugin whose slug is in the registry with a
higher version than installed is flagged. Panel-version compatibility is
surfaced so the UI can gray out an update the panel can't run yet. The
installed plugin's ``source_type`` is included so callers can see the
provenance the update would cross (audit M2 — a registry entry only ever
matches on slug; the update route's consent gate is the enforcement).
"""
from app.services import registry_service
from app.utils.version import compare_versions, version_satisfies, get_panel_version
panel = get_panel_version()
out = []
for p in InstalledPlugin.query.all():
entry = registry_service.get_entry(p.slug)
if not entry:
continue
available = entry.get('version')
out.append({
'slug': p.slug,
'plugin_id': p.id,
'installed_version': p.version,
'available_version': available,
'update_available': compare_versions(available, p.version) > 0,
'compatible': version_satisfies(
panel, entry.get('min_panel_version'), entry.get('max_panel_version')
),
'source': entry.get('source'),
'installed_source_type': p.source_type,
})
return out
def update_plugin(plugin_id, user_id=None):
"""Update an installed plugin to the registry's version (reinstall in place)."""
from app.services import registry_service
plugin = InstalledPlugin.query.get(plugin_id)
if not plugin:
raise ValueError('Plugin not found')
entry = registry_service.get_entry(plugin.slug)
if not entry:
raise ValueError(
f"No registry entry for '{plugin.slug}' — cannot update from the registry."
)
_assert_panel_compatible(entry)
source = (entry.get('source') or '').strip()
if not source:
raise ValueError('Registry entry has no source URL.')
# The update itself is registry-driven, so plain url/registry rows get
# stamped 'registry'; builtin/local/upload rows keep their identity.
if plugin.source_type in (None, '', 'url', 'registry'):
stamped = 'registry'
else:
stamped = plugin.source_type
return install_from_url(
source, user_id=user_id, expected_sha256=entry.get('sha256'), force=True,
source_type=stamped,
expected_signature=entry.get('signature'),
expected_key_id=entry.get('publisher_key_id'),
)