Plugins Overview
FreeSDN extends its built-in capabilities through two distinct tiers: native first-party modules and SDK plugins. The line between them is not aesthetic - it determines trust, authority, and what the code is permitted to do at runtime.
This page explains the two-tier model, the trust contract each tier operates under, and the boundaries that apply to SDK plugins.
Two tiers of extensibility
Section titled “Two tiers of extensibility”Tier 1 - Native modules
Section titled “Tier 1 - Native modules”Native modules are compiled into the platform itself. They run with full trust inside the backend process, have unrestricted access to the database layer, and are governed by the core AGPL-3.0 licence.
The ten modules that ship with FreeSDN are all native: Network, Cameras, VoIP, Firewall, Access Control, Backup, AI Assistant, Observability, Compute, and Storage. Nine of the ten mount REST endpoints under /api/v1/{module-id}/. The exception is Storage, which is a Fabric-only participant with no HTTP routes of its own - live storage reads are served under /api/v1/controllers/{id}/storage via the Proxmox and TrueNAS adapter endpoints.
You cannot author a native module as an outsider - that requires a pull request to the core codebase and acceptance under AGPL-3.0.
Tier 2 - SDK plugins
Section titled “Tier 2 - SDK plugins”SDK plugins are external Python packages installed at runtime as ZIP archives. They run inside the same backend process as the core, but operate under a declared-permission model enforced by the plugin loader. The published developer kit is the freesdn-sdk package (MIT licence, v0.1.0).
The key properties of SDK plugins:
| Property | Value |
|---|---|
| Install authority | super_admin only |
| Permission model | Declared in plugin.yaml; enforced per-call |
| Event namespace | Always plugin.{id}.* - cannot emit into core namespaces |
| Route prefix | /api/v1/{api_prefix or plugin-id}/ |
| Device registration cap | 1,000 devices per plugin |
| Automation triggers / actions | 50 each |
| AI tools | 20 |
| Per-request HTTP response cap | 10 MB |
| AI tool result cap | 256 KB |
| Settings blob cap | 32 KiB |
The Fabric universal-interconnect endpoint (GET /api/v1/fabric/catalog) projects plugin triggers, actions, and AI tools into a tier-tagged catalog alongside native operations. Plugin-tier operations are labelled distinctly and are not invocable through the generic Fabric invoke path - they run through the plugin’s own (plugin, org) runtime.
What a plugin can do
Section titled “What a plugin can do”A plugin may:
- Expose REST endpoints under its assigned prefix, protected by the standard JWT + RBAC stack.
- Read and write devices in the core inventory (within its org, subject to the
devices.read/devices.writecapability). - Create, list, and resolve alerts (subject to
alerts.read/alerts.write). - Emit events namespaced to
plugin.{id}.*onto the event bus. - Subscribe to core event patterns declared in
plugin.yaml(e.g.alert.created,sla.breach_created,vpn.connection_down). - Register automation triggers and actions, which appear in the Automation builder.
- Register AI tools, which appear in the AI Assistant (subject to a mandatory permission declaration - see the trust model section).
- Make outbound HTTP requests through the SSRF-protected
PluginHTTPClient. - Store per-org settings (plain JSONB or Fernet-encrypted secrets) via the settings SDK.
- Declare SQLAlchemy models and create tables via
on_install. - Expose unauthenticated inbound webhook routes protected by HMAC-SHA256.
What a plugin cannot do
Section titled “What a plugin cannot do”A plugin may not:
- Exceed its declared permission set. Every SDK call checks the capability at runtime.
- Exercise a capability the calling user does not themselves hold (the confused-deputy guard, CAN-015). If a user hits a plugin REST route, the plugin’s authority is the intersection of its declared permissions and the caller’s own permissions.
- Emit events into the core event namespace - all plugin events are force-prefixed
plugin.{id}.. Declare inter-module dependencies(informational only - thedependenciesfield inplugin.yamlis parsed and stored but is not enforced at install time or at runtime. It documents intent only; no SDK or loader code gates module access on declared dependencies. See the caution box in the Manifest section.)- Overwrite another plugin’s registered action handler or AI tool.
- Register more than 50 triggers, 50 actions, or 20 AI tools.
- Register more than 1,000 devices in the core inventory.
- Make outbound HTTP requests to private, loopback, or link-local IP ranges. The HTTP client DNS-pins addresses and disables redirects to defeat DNS rebind attacks.
- Use an unsigned marketplace catalog - the catalog sync endpoint returns
403unless a valid Ed25519 signature is verified against a pinned publisher key (or unsigned mode is explicitly enabled by the operator). - Declare loose Python dependency specifiers (
>=,~=). The runtime requires exact pinning (name==version) and a hash-pinnedrequirements.txt. A manifest that passes the published SDK validator may still be rejected by the runtime on this point - see manifest gotchas.
The trust model
Section titled “The trust model”Given that constraint, the trust model works as follows:
Installation is gated at super_admin. Only a superuser can install, upgrade, or uninstall a plugin via POST /api/v1/plugins/install. Marketplace installs via POST /api/v1/marketplace/plugins/{slug}/install carry the same requirement. Review the plugin source before installing.
Supply-chain hardening applies in layers:
- Marketplace catalog entries are Ed25519-signed by the publisher. By default, an unsigned catalog is refused with
403- the operator must explicitly setMARKETPLACE_ALLOW_UNSIGNED=trueto override this, and even then a loud warning is emitted. - If a plugin declares Python dependencies, the runtime requires a
requirements.txtwith--hash=sha256:annotations (generated withpip-compile --generate-hashes). The install uses--require-hashes --no-deps --only-binary :all:to block sdist code execution and transitive upgrades. This feature is also off by default (PLUGIN_ALLOW_RUNTIME_PYTHON_DEPS=false). - Marketplace installs verify the downloaded archive against the catalog’s
checksum_sha256before invoking the loader. - ZIP extraction rejects zip-slip path traversal and enforces a 50 MB compressed / 200 MB uncompressed size cap.
Every privileged operation is audited. Install, uninstall, enable, disable, upgrade, and HMAC-secret rotation all produce audit log entries tagged plugin and supply-chain.
The confused-deputy guard (CAN-015) runs on every authenticated route. When a logged-in user hits a plugin REST endpoint, the plugin may only exercise a capability the user could exercise directly. A viewer-role user hitting a plugin route cannot cause the plugin to write devices, even if the plugin declares devices.write.
AI tool registration is fail-closed. If a plugin registers an AI tool without declaring a permission, the runtime coerces it to a sentinel permission that no role grants - effectively making the tool accessible only to super_admin. Authors must declare a real permission string. A plugin_* AI tool with no declared permission returns “Permission denied” at execution regardless of how it was registered.
Load-time hygiene blocks the most obvious abuse vectors even though it is not a full sandbox. The module blocklist covers os, sys, subprocess, socket, http, urllib, importlib, ctypes, pickle, threading, multiprocessing, gc, inspect, and several others. Restricted builtins remove exec, eval, compile, open, and __import__ from the plugin module at load time.
Manifest - plugin.yaml
Section titled “Manifest - plugin.yaml”Every plugin ships a plugin.yaml at the ZIP root. The runtime validates it against the PluginManifest Pydantic model.
Required fields:
| Field | Rules |
|---|---|
id | Lowercase alphanumeric + hyphens, 2-100 chars. Must not be a reserved ID. |
name | 1-200 chars. |
version | Strict semver MAJOR.MINOR.PATCH (all digits, exactly three parts). |
class_name | Valid Python identifier, must not start with _. |
Key optional fields:
| Field | Default | Notes |
|---|---|---|
permissions | [] | List of {code, name, description}. Declare every capability the plugin will use. |
entry_point | plugin.py | Relative path, must end .py, no .. or leading slash. |
api_prefix | /{id} | Mounted at /api/v1{api_prefix}. Regex ^/[a-z0-9][a-z0-9/_-]*$. |
event_subscriptions | [] | Event-bus patterns the plugin may subscribe to. |
python_dependencies | [] (max 50) | Must be name==version exact pins. |
settings_schema | none | JSON Schema for the settings UI (informational, not enforced by core). |
Permission codes the SDK recognises:
| Code | Maps to core permission |
|---|---|
devices.read | device:read |
devices.write | device:write |
alerts.read | alert:read |
alerts.write | alert:write |
Manifest gotchas
Section titled “Manifest gotchas”Plugin lifecycle
Section titled “Plugin lifecycle”A plugin moves through these states: installed → enabled per-org (default) → disabled per-org or globally → uninstalled. The loader serialises all lifecycle transitions per plugin-id via a non-reentrant lock.
Install sequence (abbreviated):
- Upload ZIP to
POST /api/v1/plugins/install(super_admin). - Loader extracts and validates the ZIP (size caps, zip-slip check,
plugin.yamlparse). - If Python dependencies are declared and
PLUGIN_ALLOW_RUNTIME_PYTHON_DEPS=true, a per-plugin.venvis created and deps are installed with hash verification. - The plugin class is loaded under the import-hygiene layer.
on_install(db)is called once. Errors are logged but do not fail the install.- An
InstalledPluginrow is written (status=installed,is_active=True). - The plugin starts for every organisation that has not explicitly disabled it.
Per-org start sequence:
- A fresh plugin runtime is instantiated.
on_start(organization_id, db)is called. Always callawait super().on_start(...)first to initialiseself.ctx.- Event subscriptions declared in
plugin.yamlare bound to the event bus. The handler filters byorganization_idso a plugin runtime only sees its own org’s events. - Automation triggers, actions, and AI tools registered in
on_startbecome live.
Disable vs uninstall:
Disabling a plugin (globally via super_admin, or per-org via org_admin) makes its routes return 410 Gone via the route guard. The routes remain registered in FastAPI’s router until the next process restart - there is no runtime route removal. Uninstall calls on_uninstall(db), removes files, and writes status=uninstalled.
SDK interfaces
Section titled “SDK interfaces”Access all SDK objects through self.ctx after on_start is called.
| Attribute | Class | What it provides |
|---|---|---|
self.ctx.devices | DeviceSDK | list, get, get_ports, register_device |
self.ctx.alerts | AlertSDK | list, create, resolve |
self.ctx.events | EventSDK | emit, subscribe (validation) |
self.ctx.settings | PluginSettingsSDK | get, set, get_secret, set_secret |
self.ctx.http | PluginHTTPClient | get, post, put, delete (SSRF-protected) |
self.ctx.logger | logging.Logger | Named freesdn.plugin.{id} |
Plugin management API
Section titled “Plugin management API”All plugin management routes sit under /api/v1/plugins. The marketplace routes are at /api/v1/marketplace/plugins.
| Method | Path | Purpose | Required role |
|---|---|---|---|
GET | /api/v1/plugins | List installed plugins | org_admin or plugins.admin |
GET | /api/v1/plugins/{id} | Plugin detail and cached manifest | org_admin or plugins.admin |
POST | /api/v1/plugins/install | Install from ZIP upload | super_admin |
POST | /api/v1/plugins/install-url | Install by URL (requires PLUGIN_ENABLE_DIRECT_URL_INSTALLS=true and allowed domain) | super_admin |
DELETE | /api/v1/plugins/{id} | Uninstall | super_admin |
POST | /api/v1/plugins/{id}/enable | Enable globally or per-org | super_admin (global) / org_admin or plugins.admin (per-org) |
POST | /api/v1/plugins/{id}/disable | Disable globally or per-org | super_admin (global) / org_admin or plugins.admin (per-org) |
POST | /api/v1/plugins/{id}/upgrade | Upgrade via new ZIP | super_admin |
GET | /api/v1/plugins/{id}/settings | Read org-scoped settings | org_admin or plugins.admin |
PUT | /api/v1/plugins/{id}/settings | Write org-scoped settings | org_admin or plugins.admin |
GET | /api/v1/plugins/{id}/health | Runtime health for caller’s org | org_admin or plugins.admin |
POST | /api/v1/plugins/{id}/public-auth/rotate-secret | Rotate HMAC webhook secret (returns plaintext once) | org_admin or plugins.admin |
POST | /api/v1/marketplace/plugins/{slug}/install | Install from marketplace (SHA-256 verified) | super_admin |
POST | /api/v1/marketplace/plugins/sync | Sync Ed25519-signed catalog from registry | super_admin |
Developer quick-start
Section titled “Developer quick-start”Install the dev kit:
pip install freesdn-sdkScaffold a new plugin:
freesdn-sdk init my-plugin --author "Your Name" --description "What it does"This creates my-plugin/plugin.yaml, my-plugin/plugin.py, and a test file. Edit plugin.py to implement your logic:
from freesdn_sdk import FreeSDNPlugin
class MyPlugin(FreeSDNPlugin):
async def on_start(self, organization_id, db=None): await super().on_start(organization_id, db) # self.ctx is now available self.register_automation_trigger( "my_event", "Fires when something happens", {"type": "object", "properties": {"device_id": {"type": "string"}}}, )
async def on_event(self, event): if event.event_type == "alert.created": await self.ctx.events.emit("my_event", {"source": event.payload})Validate, check for blocked imports, and package:
freesdn-sdk validate my-plugin/freesdn-sdk check my-plugin/freesdn-sdk package my-plugin/ -o my-plugin-1.0.0.zipInstall into a running FreeSDN instance (super_admin credential required):
curl -X POST https://your-instance/api/v1/plugins/install \ -H "Authorization: Bearer $TOKEN" \ -F "file=@my-plugin-1.0.0.zip"Key environment variables
Section titled “Key environment variables”| Variable | Default | Purpose |
|---|---|---|
PLUGIN_ALLOW_RUNTIME_PYTHON_DEPS | false | Must be true for plugins with python_dependencies to install. |
PLUGIN_ENABLE_DIRECT_URL_INSTALLS | false | Enables POST /plugins/install-url. |
PLUGIN_ALLOWED_DOMAINS | (empty - blocks all) | Comma-separated allowlist for URL installs. |
PLUGIN_DIR | /data/plugins | Where plugin files are stored. |
MARKETPLACE_PUBLISHER_PUBLIC_KEY | (empty) | Hex Ed25519 key. Required for signed catalog verification. |
MARKETPLACE_ALLOW_UNSIGNED | (unset) | Set true to allow an unsigned catalog. Not recommended. |
MARKETPLACE_REGISTRY_URL | https://registry.freesdn.org/plugins.json | Remote catalog URL. |
Next steps
Section titled “Next steps”- Plugin manifest reference - every
plugin.yamlfield with validation rules. - SDK API reference -
DeviceSDK,AlertSDK,EventSDK,PluginSettingsSDK, andPluginHTTPClientmethod signatures. - Automation & AI tools - registering triggers, actions, and AI tools from a plugin.
- Inbound webhooks - HMAC-authenticated public routes and secret rotation.
- Marketplace - browsing, installing, and syncing the signed plugin catalog.
- Security model - the broader platform security architecture that plugin trust builds on top of.