Getting Started
Plugins let you add new behavior to FreeSDN without touching the core codebase. They run
inside the backend process alongside the rest of the application, expose optional REST
endpoints, react to platform events, raise alerts, and register automation triggers,
actions, and AI tools - all within the boundaries of what your plugin.yaml declares.
This page covers the minimum you need to go from nothing to a locally installed,
working plugin. It focuses on the three files every plugin requires, the FreeSDNPlugin
base class and its lifecycle hooks, and the super_admin-only local install flow.
Prerequisites
Section titled “Prerequisites”| Requirement | Purpose |
|---|---|
| Python ≥ 3.11 | Local dev environment |
pip install freesdn-sdk | Scaffold, validate, and package CLI |
| A running FreeSDN instance | Target for local install |
A super_admin account | Required to install plugins (no exceptions) |
Install the dev SDK:
pip install freesdn-sdkThe SDK package (freesdn-sdk, MIT-licensed) provides the freesdn-sdk CLI, the
FreeSDNPlugin stub base class, type stubs, a test helper, and the PluginManifest
schema. Its classes raise NotImplementedError outside the FreeSDN runtime; at runtime
the backend aliases the real implementations over the freesdn_sdk import name, so your
plugin code runs unmodified in both environments.
1. Scaffold a new plugin
Section titled “1. Scaffold a new plugin”Run freesdn-sdk init with your plugin’s ID and options:
freesdn-sdk init my-plugin \ --author "Your Name" \ --description "A minimal hello-world plugin" \ --api-prefix /my-plugin \ --output-dir ./my-pluginThis creates:
my-plugin/ plugin.yaml plugin.py requirements.txt # empty unless you add python_dependencies tests/ test_plugin.py README.mdThe scaffolded files are a starting point. You will edit plugin.yaml and plugin.py
before installing.
Plugin ID rules
Section titled “Plugin ID rules”Your plugin ID must:
- Be 2-100 characters, lowercase alphanumeric and hyphens only (
^[a-z0-9][a-z0-9\-]{0,98}[a-z0-9]$). - Not match a reserved ID.
Reserved IDs (any of these will be rejected at install):
admin, auth, api, core, system, devices, alerts, users, settings,
backup, vpn, automation, webhooks, ai, collector, integrations, plugins,
marketplace, health, status, metrics, internal.
2. The three required files
Section titled “2. The three required files”plugin.yaml - the manifest
Section titled “plugin.yaml - the manifest”Every field the runtime reads comes from plugin.yaml. The Pydantic model that validates
it is PluginManifest (backend/app/plugins/schema.py).
id: my-pluginname: My Pluginversion: 1.0.0description: A minimal hello-world plugin.author: Your Namelicense: MIT
# Entry point inside the plugin directory. No ".." or leading slash.entry_point: plugin.py
# Python class inside entry_point that subclasses FreeSDNPlugin.class_name: MyPlugin
# Permissions this plugin may exercise. Declare only what you need.permissions: - code: devices.read name: Read devices description: List devices visible to the caller's organization.
# Optional: expose a sidebar link in the FreeSDN UI.nav_items: - path: /my-plugin label: My Plugin icon: Package order: 50
# Optional: override the default URL prefix (defaults to /<id>).api_prefix: /my-plugin
# Optional: subscribe to platform event patterns.event_subscriptions: - alert.created - alert.resolved
# Exact-pinned Python dependencies. See the pinning section below.python_dependencies: []Key fields at a glance:
| Field | Required | Default | Notes |
|---|---|---|---|
id | yes | - | Lowercase alnum + hyphen, 2-100 chars |
name | yes | - | 1-200 chars |
version | yes | - | Strict semver MAJOR.MINOR.PATCH |
entry_point | no | plugin.py | Path inside plugin dir; must end .py |
class_name | yes | - | Valid Python identifier, no leading _ |
permissions | no | [] | Declare every capability you use |
api_prefix | no | /<id> | Prefix for your REST endpoints |
nav_items | no | [] | Sidebar links shown in the UI |
event_subscriptions | no | [] | Event-bus patterns the plugin may subscribe to |
python_dependencies | no | [] | Must use ==-exact pins; max 50 |
Pinning Python dependencies
Section titled “Pinning Python dependencies”If your plugin has Python dependencies, you must also ship a requirements.txt with
--hash=sha256:... annotations generated by pip-compile --generate-hashes. The loader
installs deps into a per-plugin isolated .venv and refuses to proceed without the
lockfile. The runtime gate PLUGIN_ALLOW_RUNTIME_PYTHON_DEPS must also be enabled (it
defaults to false).
For a hello-world plugin with no external dependencies, leave python_dependencies: []
and leave requirements.txt empty (or omit it).
plugin.py - the implementation
Section titled “plugin.py - the implementation”# SPDX-License-Identifier: MITimport logging
from freesdn_sdk import FreeSDNPlugin, PluginContextfrom fastapi import APIRouter
logger = logging.getLogger(__name__)
class MyPlugin(FreeSDNPlugin): """Minimal hello-world plugin."""
async def on_install(self, db) -> None: """Called once when the plugin is first installed.""" logger.info("MyPlugin installed.")
async def on_start(self, organization_id, db=None) -> None: """Called per-org when the plugin is enabled for that organization.
Always call super() first - it populates self.ctx. """ await super().on_start(organization_id, db=db) self.ctx.logger.info("MyPlugin started for org %s", organization_id)
async def on_stop(self, organization_id, db=None) -> None: """Called when the plugin is disabled or the server shuts down.""" await super().on_stop(organization_id, db=db)
async def on_uninstall(self, db) -> None: """Called just before the plugin is removed. Clean up any data you own.""" logger.info("MyPlugin uninstalled.")
async def on_event(self, event) -> None: """Receives events matching your event_subscriptions patterns.""" self.ctx.logger.info("Received event: %s payload=%s", event.event_type, event.payload)
def get_router(self) -> APIRouter: """Return a FastAPI router. Mounted at /api/v1<api_prefix>.""" router = APIRouter()
@router.get("/hello") async def hello(): return {"message": "Hello from MyPlugin!"}
return routerrequirements.txt
Section titled “requirements.txt”If you have no Python dependencies, an empty file is fine:
# No external dependencies.If you do have dependencies, generate the file with hashes:
pip-compile requirements.in --generate-hashes -o requirements.txt3. FreeSDNPlugin base class and lifecycle hooks
Section titled “3. FreeSDNPlugin base class and lifecycle hooks”The base class is FreeSDNPlugin in freesdn_sdk.base (stub) and
backend/app/plugins/sdk.py (runtime). Subclass it and override the hooks you need.
Lifecycle sequence
Section titled “Lifecycle sequence”Install ZIP └── on_install(db) # once, on first install; non-fatal if it raisesEnable for org └── on_start(org_id, db) # per-org; populates self.ctx └── binds event subscriptions # loader calls bind_event_subscriptions() after on_start returnsRequest arrives at plugin route └── route guard validates org → binds runtime + caller └── handler uses self.ctxOrg disables plugin └── on_stop(org_id, db) # unbinds event subsUninstall └── on_uninstall(db) # clean up; then files removedOverridable members
Section titled “Overridable members”| Hook / member | Signature | When to override |
|---|---|---|
on_install(db) | async | Create database tables, seed initial data |
on_start(organization_id, db=None) | async | Initialize per-org state; always await super().on_start(...) first |
on_upgrade(from_version, db) | async | Migrate data after a version bump |
on_uninstall(db) | async | Remove data, external registrations, files you created |
on_event(event) | async | React to declared event_subscriptions |
on_stop(organization_id, db=None) | async | Cleanup per-org state; always await super().on_stop(...) |
get_router() | → APIRouter | Expose REST endpoints; default returns an empty router |
get_models() | → list[type] | Declare SQLAlchemy models your plugin defines |
health_check() | async → dict | Override for a richer health response |
self.ctx - the SDK surface
Section titled “self.ctx - the SDK surface”After on_start, self.ctx is a PluginContext with these attributes:
| Attribute | Type | What it gives you |
|---|---|---|
self.ctx.devices | DeviceSDK | List, get, register, and read ports for devices in the org |
self.ctx.alerts | AlertSDK | List, create, and resolve alerts scoped to the org |
self.ctx.events | EventSDK | Emit namespaced events; validate subscription patterns |
self.ctx.settings | PluginSettingsSDK | Read and write per-org plugin settings (plain or encrypted) |
self.ctx.http | PluginHTTPClient | SSRF-protected outbound HTTP (DNS-pinned, no redirects, 10 MB body cap) |
self.ctx.logger | logging.Logger | Named freesdn.plugin.<id> |
self.ctx.organization_id | UUID | The org this runtime instance serves |
Emitted events are always namespaced: self.ctx.events.emit("my.thing", {...}) publishes
plugin.my-plugin.my.thing on the bus - your plugin cannot spoof core event types.
Permission enforcement
Section titled “Permission enforcement”Every SDK method that touches data checks two things before executing:
- The capability (
devices.read,devices.write,alerts.read,alerts.write) must be declared in yourplugin.yamlpermissionslist, or you get aPermissionError. - On authenticated user routes, the plugin may only exercise a capability the requesting user could exercise directly (the confused-deputy guard). On public, automation, AI, or scheduled paths, no user is bound and the plugin acts with its own declared authority.
Declare only the permissions your plugin actually uses.
4. Validate and package
Section titled “4. Validate and package”Before installing, run the SDK tools:
# Parse manifest, check entry_point, warn on missing fields.freesdn-sdk validate ./my-plugin
# Static AST scan for blocked imports and dangerous builtins.freesdn-sdk check ./my-plugin
# Build the install ZIP.freesdn-sdk package ./my-plugin -o my-plugin-1.0.0.zipThe packager enforces the same size limits the runtime does: 50 MB compressed, 200 MB uncompressed.
5. Local install (super_admin only)
Section titled “5. Local install (super_admin only)”Plugin install is restricted to super_admin. There is no role below that which can
install a plugin.
Via the web UI
Section titled “Via the web UI”- Log in as
super_admin. - Open Settings → Plugins.
- Click Install plugin and upload your ZIP.
- The backend validates the manifest, checks for reserved IDs, and (if
python_dependenciesare present) installs them into an isolated per-plugin.venv. - Once installed, the plugin is active globally. Individual organizations can disable it via the org-scoped enable/disable controls.
Via the API
Section titled “Via the API”curl -X POST https://your-freesdn-host/api/v1/plugins/install \ -H "Authorization: Bearer <super_admin_jwt>" \ -H "X-CSRF-Token: <csrf_token>" \ -F "file=@my-plugin-1.0.0.zip"A successful install returns 201 Created.
Key install endpoints
Section titled “Key install endpoints”| Method | Path | Purpose | Required role |
|---|---|---|---|
POST | /api/v1/plugins/install | Install from uploaded ZIP | super_admin |
GET | /api/v1/plugins | List installed plugins and their status | org_admin |
GET | /api/v1/plugins/{plugin_id} | Plugin detail including cached manifest | org_admin |
POST | /api/v1/plugins/{plugin_id}/enable | Enable globally or for a single org | org_admin (global = super_admin) |
POST | /api/v1/plugins/{plugin_id}/disable | Disable without removing | org_admin (global = super_admin) |
POST | /api/v1/plugins/{plugin_id}/upgrade | Upgrade via new ZIP | super_admin |
DELETE | /api/v1/plugins/{plugin_id} | Uninstall and remove files | super_admin |
GET | /api/v1/plugins/{plugin_id}/health | Runtime health for the caller’s org | org_admin |
GET | /api/v1/plugins/{plugin_id}/settings | Read org-scoped plugin settings | org_admin |
PUT | /api/v1/plugins/{plugin_id}/settings | Write org-scoped plugin settings | org_admin |
Verify the install
Section titled “Verify the install”After installing, hit your plugin’s health endpoint:
curl https://your-freesdn-host/api/v1/plugins/my-plugin/health \ -H "Authorization: Bearer <org_admin_jwt>"If the plugin started successfully for your org you get:
{ "plugin_id": "my-plugin", "status": "ok", "is_loaded": true, "is_active": true, "organization_id": "<your-org-uuid>", "details": {"status": "ok", "organization_id": "<your-org-uuid>"}}Then test your hello-world endpoint:
curl https://your-freesdn-host/api/v1/my-plugin/hello \ -H "Authorization: Bearer <token>"{"message": "Hello from MyPlugin!"}6. Enabling and disabling per organization
Section titled “6. Enabling and disabling per organization”Installing a plugin makes it globally active - every organization that has the plugin enabled can use it. Disabling it for a single org does not remove it globally.
- Global disable (super_admin, no org context): sets the plugin inactive everywhere.
- Org-scoped disable (org_admin): writes a per-org state row; the plugin remains installed and active for other orgs.
- A globally-disabled plugin returns
409if an org tries to re-enable it. - Disabling a plugin makes its mounted routes return
410 Gonevia the route guard. The routes themselves are not deregistered until the backend restarts.
7. Limits to be aware of
Section titled “7. Limits to be aware of”| Limit | Value |
|---|---|
| ZIP size (compressed) | 50 MB |
| ZIP size (uncompressed) | 200 MB |
python_dependencies entries | 50 max |
| Automation triggers per plugin | 50 |
| Automation actions per plugin | 50 |
| AI tools per plugin | 20 |
| Devices registered per plugin | 1,000 |
| Outbound HTTP timeout | 60 s |
| Outbound HTTP response body | 10 MB |
| Plugin settings blob | 32 KiB |
| AI-tool result size | 256 KB |
Next steps
Section titled “Next steps”- Plugin manifest reference - every
plugin.yamlfield, thePluginPermission/PluginNavItem/PluginPublicRoutenested models, and the reserved ID list. - SDK interfaces -
DeviceSDK,AlertSDK,EventSDK,PluginSettingsSDK, andPluginHTTPClientwith full method signatures and permission requirements. - Automation and AI integration -
register_automation_trigger,register_automation_action,register_ai_tool, and the PS-11 permission gate. - Security model - what the import hygiene layer does and does not protect against, the confused-deputy guard, public-route HMAC auth, and supply-chain controls.
- Marketplace publishing - Ed25519-signed catalog, SHA-256-verified
install, and the
freesdn-sdk package→ upload workflow.