Packaging & Publishing
This page covers the full journey from a working plugin directory to an installed, running plugin: building and validating the ZIP, understanding the size and path guards the loader applies, declaring hash-pinned Python dependencies correctly, and choosing between the three install paths - local ZIP upload, direct URL, and the marketplace.
Before you reach this page you should already have a plugin.yaml, a working FreeSDNPlugin subclass, and passing local tests. If not, start with Getting Started and the Manifest Reference.
Build the ZIP with the SDK CLI
Section titled “Build the ZIP with the SDK CLI”The freesdn-sdk CLI assembles the archive for you.
pip install freesdn-sdkFrom your plugin directory:
freesdn-sdk validate # parse manifest, check entry_point + class existfreesdn-sdk check # static AST scan for blocked imports and dangerous builtinsfreesdn-sdk package # write {id}-{version}.zip next to the plugin dirfreesdn-sdk package -o /path/to/out.zip # explicit output pathRun validate and check before package. Both commands exit 1 on failure. package skips junk directories, symlinks, and .env files automatically.
What check catches
Section titled “What check catches”freesdn-sdk check scans your source for imports from the loader’s blocked-module list (os, subprocess, socket, importlib, ctypes, pickle, and many others) and calls to dangerous builtins (exec, eval, compile, __import__, open, breakpoint, globals, locals, vars). It is a static scan only - it cannot catch obfuscated patterns. Fix every error before submitting.
ZIP size limits
Section titled “ZIP size limits”The loader enforces two hard limits on every ZIP regardless of install path:
| Limit | Value |
|---|---|
| Compressed archive size | 50 MB |
| Uncompressed total size | 200 MB |
These are enforced before any file is extracted. An archive that exceeds either limit is rejected with a PluginLoadError. The SDK CLI enforces the same numbers during package so you get the error locally rather than at install time.
Zip-slip and path-traversal protection
Section titled “Zip-slip and path-traversal protection”The loader inspects every member of the archive before extraction. Any member whose resolved path escapes the target extraction directory is rejected. This protects against zip-slip attacks where a crafted archive entry (e.g. ../../etc/cron.d/evil) writes outside the plugin directory.
Plugin authors do not need to do anything to benefit from this - the guard runs unconditionally on every install. If your archive is built with freesdn-sdk package you will not trip it; symlinks are excluded by the CLI for the same reason.
Zip-bomb protection
Section titled “Zip-bomb protection”The loader tracks the total uncompressed size as it extracts each member. If the running total crosses 200 MB it stops and raises PluginLoadError. Streaming extraction means the check does not require loading the full decompressed content into memory first.
Plugin directory layout
Section titled “Plugin directory layout”After a successful install the loader writes the plugin to:
/data/plugins/<plugin_id>/ plugin.yaml plugin.py requirements.txt # required only when python_dependencies is non-empty .venv/ # per-plugin isolated venv (created only when deps are installed)PLUGIN_DIR defaults to /data/plugins and is read from the environment variable of the same name. You can override it in your deployment.
Python dependencies
Section titled “Python dependencies”When you need them
Section titled “When you need them”Most plugins should have no Python dependencies beyond freesdn-sdk (which is pre-aliased at runtime - do not list it). If your plugin wraps a vendor SDK or needs a library the core does not provide, you must declare it.
Exact-pin requirement
Section titled “Exact-pin requirement”Every entry in python_dependencies in plugin.yaml must be an exact version pin:
python_dependencies: - "httpx==0.27.0" - "pydantic==2.13.0"Loose specifiers (httpx>=0.25, httpx~=0.27) are rejected by the runtime manifest validator with a PluginLoadError. Extras syntax (httpx[http2]) and environment markers (;) are also rejected.
Hash-pinned requirements.txt
Section titled “Hash-pinned requirements.txt”Declaring dependencies in plugin.yaml is not enough. You must also ship a requirements.txt with --hash=sha256: annotations. The loader refuses to install dependencies without it.
Generate the lockfile with pip-compile:
pip install pip-toolspip-compile --generate-hashes --output-file requirements.txt requirements.inThe loader cross-checks the manifest: every bare package name in python_dependencies must appear in the lockfile. A mismatch is a PluginLoadError.
What the install command looks like
Section titled “What the install command looks like”The loader runs pip in a subprocess (thread-offloaded, 180 s timeout) with these flags:
pip install \ --require-hashes \ --no-deps \ --no-cache-dir \ --only-binary :all: \ --index-url <PLUGIN_PYPI_INDEX_URL> \ --no-input \ -r requirements.txt--no-deps means you must pin every transitive dependency yourself. --only-binary :all: blocks sdist builds that execute arbitrary code. --no-cache-dir prevents cache poisoning. The index URL defaults to https://pypi.org/simple/ and can be overridden with PLUGIN_PYPI_INDEX_URL for air-gapped environments.
The venv is installed into .venv/ inside the plugin directory and appended to sys.path at load time - never inserted at position 0, so it cannot shadow core packages.
The runtime gate
Section titled “The runtime gate”Python dependency installation requires PLUGIN_ALLOW_RUNTIME_PYTHON_DEPS=true in the backend environment. It defaults to false.
# docker-compose override or .envPLUGIN_ALLOW_RUNTIME_PYTHON_DEPS=trueIf the flag is not set and python_dependencies is non-empty, install is refused before pip is ever called. This is intentional: production deployments should evaluate dependency installation explicitly rather than having it happen silently.
| Env var | Default | Purpose |
|---|---|---|
PLUGIN_ALLOW_RUNTIME_PYTHON_DEPS | false | Allow pip to install plugin deps at install time |
PLUGIN_PYPI_INDEX_URL | https://pypi.org/simple/ | PyPI index for hash-pinned installs |
Install paths
Section titled “Install paths”Path 1 - Local ZIP upload
Section titled “Path 1 - Local ZIP upload”Upload a built ZIP directly from your workstation via the management API. This is the standard path for private or internally-developed plugins.
Required role: super_admin.
POST /api/v1/plugins/installContent-Type: multipart/form-datafile: <your-plugin.zip>The loader runs the full pipeline: size and path guards, manifest parse, lock acquire, plugin directory write, optional dep install, class load and import hygiene, on_install() call, database row creation. On success it returns 201 with the plugin detail.
If the plugin is already in installed state the loader returns an error - use the upgrade path instead.
Path 2 - Direct URL install
Section titled “Path 2 - Direct URL install”Install a plugin by having the backend download it from a URL. This path is disabled by default and requires two configuration changes.
Required role: super_admin.
Required configuration:
PLUGIN_ENABLE_DIRECT_URL_INSTALLS=truePLUGIN_ALLOWED_DOMAINS=plugins.example.com,cdn.vendor.ioPLUGIN_ALLOWED_DOMAINS is a comma-separated list. If it is empty the install is blocked even when PLUGIN_ENABLE_DIRECT_URL_INSTALLS=true. There is no wildcard support.
POST /api/v1/plugins/install-urlContent-Type: application/json
{"url": "https://plugins.example.com/my-plugin-1.0.0.zip"}The backend download enforces the same SSRF guards as PluginHTTPClient: HTTPS/HTTP only, DNS-resolves and validates the host against the allowlist, pins to the resolved IP, disables redirects, streams with the 50 MB cap. Private and loopback IPs are rejected even if they appear in PLUGIN_ALLOWED_DOMAINS.
The downloaded bytes are then handed to the same install pipeline as a local ZIP upload.
| Env var | Default | Purpose |
|---|---|---|
PLUGIN_ENABLE_DIRECT_URL_INSTALLS | false | Enable the install-url endpoint |
PLUGIN_ALLOWED_DOMAINS | "" (block all) | Comma-separated allowlist of permitted download hosts |
Path 3 - Marketplace
Section titled “Path 3 - Marketplace”Install a plugin that appears in the FreeSDN marketplace catalog.
Required role: super_admin.
POST /api/v1/marketplace/plugins/{slug}/installThe backend fetches the archive from download_url in the catalog using the same DNS-safe, redirect-disabled streamed download as the URL path, then verifies the archive SHA-256 against checksum_sha256 from the catalog before passing it to the install pipeline. A checksum mismatch aborts the install.
Browse the catalog (no authentication required at the read layer):
| METHOD path | Purpose |
|---|---|
GET /api/v1/marketplace/plugins | Paginated list; filter by q, category, sort |
GET /api/v1/marketplace/plugins/featured | Up to 6 featured plugins |
GET /api/v1/marketplace/plugins/categories | Category list with plugin counts |
GET /api/v1/marketplace/plugins/{slug} | Plugin detail |
GET /api/v1/marketplace/plugins/{slug}/versions | Version history |
POST /api/v1/marketplace/plugins/sync | Sync catalog from remote registry (super_admin) |
The catalog is fetched from MARKETPLACE_REGISTRY_URL (default https://registry.freesdn.org/plugins.json). The registry response is capped at 5 MB. The sync endpoint verifies an Ed25519 signature against MARKETPLACE_PUBLISHER_PUBLIC_KEY (pinned hex key). With no key configured and MARKETPLACE_ALLOW_UNSIGNED not set, sync returns 403 - it does not silently trust unsigned catalogs.
Management API reference
Section titled “Management API reference”All plugin management endpoints require an authenticated session. Install, uninstall, and upgrade require super_admin. Enable, disable, and settings management require org_admin (or the plugins.admin permission).
| METHOD path | Purpose | Required role |
|---|---|---|
POST /api/v1/plugins/install | Install from uploaded ZIP | super_admin |
POST /api/v1/plugins/install-url | Install by downloading a URL | super_admin + PLUGIN_ENABLE_DIRECT_URL_INSTALLS |
POST /api/v1/plugins/{id}/upgrade | Upgrade via new ZIP (stops everywhere, reinstalls, restarts) | super_admin |
DELETE /api/v1/plugins/{id} | Uninstall (runs on_uninstall, removes files) | super_admin |
GET /api/v1/plugins | List installed plugins with org-effective status | org_admin |
GET /api/v1/plugins/{id} | Plugin detail including cached manifest | org_admin |
POST /api/v1/plugins/{id}/enable | Enable (globally if super_admin with no org, else org-scoped) | org_admin |
POST /api/v1/plugins/{id}/disable | Disable without removing | org_admin |
GET /api/v1/plugins/{id}/settings | Org-scoped settings | org_admin |
PUT /api/v1/plugins/{id}/settings | Upsert org-scoped settings (max 32 KiB) | org_admin |
GET /api/v1/plugins/{id}/health | Runtime health for caller’s org | org_admin |
GET /api/v1/plugins/{id}/public-auth | Public-route HMAC auth status (has_secret, public routes, required header names) | org_admin (org required) |
POST /api/v1/plugins/{id}/public-auth/rotate-secret | Generate new org-scoped HMAC secret for public/webhook routes; returns plaintext secret once | org_admin (org required) |
Every install, uninstall, upgrade, enable, disable, and secret-rotation action is written to the audit log with the plugin and supply-chain tags.
Enable and disable semantics
Section titled “Enable and disable semantics”Enabling and disabling behaves differently depending on the caller’s scope:
- Global scope (super_admin with no org context): toggles
InstalledPlugin.is_activeand starts or stops the plugin for all organisations. - Org scope (org_admin): writes or removes a
PluginOrganizationStaterow for that organisation. Absence of a row means the plugin inherits the global state. A globally-disabled plugin returns409if an org tries to enable it.
Disabling a plugin does not unregister its routes - those remain until the backend restarts. Requests to the plugin’s routes return 410 Gone while it is disabled.
Upgrade procedure
Section titled “Upgrade procedure”- Build and validate the new ZIP with
freesdn-sdk validate,freesdn-sdk check, andfreesdn-sdk package. - Upload to
POST /api/v1/plugins/{id}/upgradewith the new ZIP as multipartfile. - The loader acquires the per-plugin lifecycle lock, stops the plugin for all organisations, reinstalls the plugin files and optional dependencies (via the same pipeline as a fresh install), loads the new class, calls
on_upgrade(from_version, db)on the newly-loaded instance, and then starts the plugin for every active org that has not explicitly disabled it. - Check
GET /api/v1/plugins/{id}/healthfor each org to confirm the runtime is healthy after upgrade.
Pre-publish checklist
Section titled “Pre-publish checklist”Work through this list before distributing a plugin.
freesdn-sdk validateexits 0 with no warnings.freesdn-sdk checkexits 0 - no blocked imports, no dangerous builtins.- All
python_dependenciesentries arename==versionexact pins. requirements.txtis present and generated with--generate-hashesif there are any deps.- Every permission the plugin exercises is declared in
plugin.yamlpermissions[]. - AI tools each declare a real
permissionstring - an undeclared permission coerces to a super_admin-only sentinel. plugin_iddoes not clash with a reserved ID (see Manifest Reference).versionis strict semverMAJOR.MINOR.PATCH(all digits, exactly three parts).homepagestartshttp://orhttps://if provided.- The ZIP is under 50 MB compressed and would be under 200 MB uncompressed.
Hard limits summary
Section titled “Hard limits summary”| Resource | Limit |
|---|---|
| ZIP compressed size | 50 MB |
| ZIP uncompressed size | 200 MB |
| Python dependencies (manifest entries) | 50 |
| Automation triggers per plugin | 50 |
| Automation actions per plugin | 50 |
| AI tools per plugin | 20 |
| Devices registerable per plugin | 1,000 |
| AI tool result payload | 256 KB |
| Plugin settings blob | 32 KiB |
| Outbound HTTP timeout | 60 s |
| Outbound HTTP response | 10 MB |
| HMAC timestamp skew | 300 s |
Next steps
Section titled “Next steps”- Manifest Reference - every
plugin.yamlfield with validation rules and the SDK-vs-runtime divergences. - Getting Started - scaffold, implement, and test your first plugin end-to-end.
- Plugins Overview - the two-tier trust model and what SDK plugins can and cannot do.