Skip to content

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.


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.

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:

PropertyValue
Install authoritysuper_admin only
Permission modelDeclared in plugin.yaml; enforced per-call
Event namespaceAlways plugin.{id}.* - cannot emit into core namespaces
Route prefix/api/v1/{api_prefix or plugin-id}/
Device registration cap1,000 devices per plugin
Automation triggers / actions50 each
AI tools20
Per-request HTTP response cap10 MB
AI tool result cap256 KB
Settings blob cap32 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.


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.write capability).
  • 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.

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 - the dependencies field in plugin.yaml is 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 403 unless 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-pinned requirements.txt. A manifest that passes the published SDK validator may still be rejected by the runtime on this point - see manifest gotchas.

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:

  1. Marketplace catalog entries are Ed25519-signed by the publisher. By default, an unsigned catalog is refused with 403 - the operator must explicitly set MARKETPLACE_ALLOW_UNSIGNED=true to override this, and even then a loud warning is emitted.
  2. If a plugin declares Python dependencies, the runtime requires a requirements.txt with --hash=sha256: annotations (generated with pip-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).
  3. Marketplace installs verify the downloaded archive against the catalog’s checksum_sha256 before invoking the loader.
  4. 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.


Every plugin ships a plugin.yaml at the ZIP root. The runtime validates it against the PluginManifest Pydantic model.

Required fields:

FieldRules
idLowercase alphanumeric + hyphens, 2-100 chars. Must not be a reserved ID.
name1-200 chars.
versionStrict semver MAJOR.MINOR.PATCH (all digits, exactly three parts).
class_nameValid Python identifier, must not start with _.

Key optional fields:

FieldDefaultNotes
permissions[]List of {code, name, description}. Declare every capability the plugin will use.
entry_pointplugin.pyRelative 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_schemanoneJSON Schema for the settings UI (informational, not enforced by core).

Permission codes the SDK recognises:

CodeMaps to core permission
devices.readdevice:read
devices.writedevice:write
alerts.readalert:read
alerts.writealert:write

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):

  1. Upload ZIP to POST /api/v1/plugins/install (super_admin).
  2. Loader extracts and validates the ZIP (size caps, zip-slip check, plugin.yaml parse).
  3. If Python dependencies are declared and PLUGIN_ALLOW_RUNTIME_PYTHON_DEPS=true, a per-plugin .venv is created and deps are installed with hash verification.
  4. The plugin class is loaded under the import-hygiene layer.
  5. on_install(db) is called once. Errors are logged but do not fail the install.
  6. An InstalledPlugin row is written (status=installed, is_active=True).
  7. The plugin starts for every organisation that has not explicitly disabled it.

Per-org start sequence:

  1. A fresh plugin runtime is instantiated.
  2. on_start(organization_id, db) is called. Always call await super().on_start(...) first to initialise self.ctx.
  3. Event subscriptions declared in plugin.yaml are bound to the event bus. The handler filters by organization_id so a plugin runtime only sees its own org’s events.
  4. Automation triggers, actions, and AI tools registered in on_start become 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.


Access all SDK objects through self.ctx after on_start is called.

AttributeClassWhat it provides
self.ctx.devicesDeviceSDKlist, get, get_ports, register_device
self.ctx.alertsAlertSDKlist, create, resolve
self.ctx.eventsEventSDKemit, subscribe (validation)
self.ctx.settingsPluginSettingsSDKget, set, get_secret, set_secret
self.ctx.httpPluginHTTPClientget, post, put, delete (SSRF-protected)
self.ctx.loggerlogging.LoggerNamed freesdn.plugin.{id}

All plugin management routes sit under /api/v1/plugins. The marketplace routes are at /api/v1/marketplace/plugins.

MethodPathPurposeRequired role
GET/api/v1/pluginsList installed pluginsorg_admin or plugins.admin
GET/api/v1/plugins/{id}Plugin detail and cached manifestorg_admin or plugins.admin
POST/api/v1/plugins/installInstall from ZIP uploadsuper_admin
POST/api/v1/plugins/install-urlInstall by URL (requires PLUGIN_ENABLE_DIRECT_URL_INSTALLS=true and allowed domain)super_admin
DELETE/api/v1/plugins/{id}Uninstallsuper_admin
POST/api/v1/plugins/{id}/enableEnable globally or per-orgsuper_admin (global) / org_admin or plugins.admin (per-org)
POST/api/v1/plugins/{id}/disableDisable globally or per-orgsuper_admin (global) / org_admin or plugins.admin (per-org)
POST/api/v1/plugins/{id}/upgradeUpgrade via new ZIPsuper_admin
GET/api/v1/plugins/{id}/settingsRead org-scoped settingsorg_admin or plugins.admin
PUT/api/v1/plugins/{id}/settingsWrite org-scoped settingsorg_admin or plugins.admin
GET/api/v1/plugins/{id}/healthRuntime health for caller’s orgorg_admin or plugins.admin
POST/api/v1/plugins/{id}/public-auth/rotate-secretRotate HMAC webhook secret (returns plaintext once)org_admin or plugins.admin
POST/api/v1/marketplace/plugins/{slug}/installInstall from marketplace (SHA-256 verified)super_admin
POST/api/v1/marketplace/plugins/syncSync Ed25519-signed catalog from registrysuper_admin

Install the dev kit:

Terminal window
pip install freesdn-sdk

Scaffold a new plugin:

Terminal window
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:

Terminal window
freesdn-sdk validate my-plugin/
freesdn-sdk check my-plugin/
freesdn-sdk package my-plugin/ -o my-plugin-1.0.0.zip

Install into a running FreeSDN instance (super_admin credential required):

Terminal window
curl -X POST https://your-instance/api/v1/plugins/install \
-H "Authorization: Bearer $TOKEN" \
-F "file=@my-plugin-1.0.0.zip"

VariableDefaultPurpose
PLUGIN_ALLOW_RUNTIME_PYTHON_DEPSfalseMust be true for plugins with python_dependencies to install.
PLUGIN_ENABLE_DIRECT_URL_INSTALLSfalseEnables POST /plugins/install-url.
PLUGIN_ALLOWED_DOMAINS(empty - blocks all)Comma-separated allowlist for URL installs.
PLUGIN_DIR/data/pluginsWhere 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_URLhttps://registry.freesdn.org/plugins.jsonRemote catalog URL.