Skip to content

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.


RequirementPurpose
Python ≥ 3.11Local dev environment
pip install freesdn-sdkScaffold, validate, and package CLI
A running FreeSDN instanceTarget for local install
A super_admin accountRequired to install plugins (no exceptions)

Install the dev SDK:

Terminal window
pip install freesdn-sdk

The 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.


Run freesdn-sdk init with your plugin’s ID and options:

Terminal window
freesdn-sdk init my-plugin \
--author "Your Name" \
--description "A minimal hello-world plugin" \
--api-prefix /my-plugin \
--output-dir ./my-plugin

This creates:

my-plugin/
plugin.yaml
plugin.py
requirements.txt # empty unless you add python_dependencies
tests/
test_plugin.py
README.md

The scaffolded files are a starting point. You will edit plugin.yaml and plugin.py before installing.

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.


Every field the runtime reads comes from plugin.yaml. The Pydantic model that validates it is PluginManifest (backend/app/plugins/schema.py).

id: my-plugin
name: My Plugin
version: 1.0.0
description: A minimal hello-world plugin.
author: Your Name
license: 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:

FieldRequiredDefaultNotes
idyes-Lowercase alnum + hyphen, 2-100 chars
nameyes-1-200 chars
versionyes-Strict semver MAJOR.MINOR.PATCH
entry_pointnoplugin.pyPath inside plugin dir; must end .py
class_nameyes-Valid Python identifier, no leading _
permissionsno[]Declare every capability you use
api_prefixno/<id>Prefix for your REST endpoints
nav_itemsno[]Sidebar links shown in the UI
event_subscriptionsno[]Event-bus patterns the plugin may subscribe to
python_dependenciesno[]Must use ==-exact pins; max 50

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

# SPDX-License-Identifier: MIT
import logging
from freesdn_sdk import FreeSDNPlugin, PluginContext
from 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 router

If you have no Python dependencies, an empty file is fine:

# No external dependencies.

If you do have dependencies, generate the file with hashes:

Terminal window
pip-compile requirements.in --generate-hashes -o requirements.txt

3. 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.

Install ZIP
└── on_install(db) # once, on first install; non-fatal if it raises
Enable for org
└── on_start(org_id, db) # per-org; populates self.ctx
└── binds event subscriptions # loader calls bind_event_subscriptions() after on_start returns
Request arrives at plugin route
└── route guard validates org → binds runtime + caller
└── handler uses self.ctx
Org disables plugin
└── on_stop(org_id, db) # unbinds event subs
Uninstall
└── on_uninstall(db) # clean up; then files removed
Hook / memberSignatureWhen to override
on_install(db)asyncCreate database tables, seed initial data
on_start(organization_id, db=None)asyncInitialize per-org state; always await super().on_start(...) first
on_upgrade(from_version, db)asyncMigrate data after a version bump
on_uninstall(db)asyncRemove data, external registrations, files you created
on_event(event)asyncReact to declared event_subscriptions
on_stop(organization_id, db=None)asyncCleanup per-org state; always await super().on_stop(...)
get_router()→ APIRouterExpose REST endpoints; default returns an empty router
get_models()→ list[type]Declare SQLAlchemy models your plugin defines
health_check()async → dictOverride for a richer health response

After on_start, self.ctx is a PluginContext with these attributes:

AttributeTypeWhat it gives you
self.ctx.devicesDeviceSDKList, get, register, and read ports for devices in the org
self.ctx.alertsAlertSDKList, create, and resolve alerts scoped to the org
self.ctx.eventsEventSDKEmit namespaced events; validate subscription patterns
self.ctx.settingsPluginSettingsSDKRead and write per-org plugin settings (plain or encrypted)
self.ctx.httpPluginHTTPClientSSRF-protected outbound HTTP (DNS-pinned, no redirects, 10 MB body cap)
self.ctx.loggerlogging.LoggerNamed freesdn.plugin.<id>
self.ctx.organization_idUUIDThe 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.

Every SDK method that touches data checks two things before executing:

  1. The capability (devices.read, devices.write, alerts.read, alerts.write) must be declared in your plugin.yaml permissions list, or you get a PermissionError.
  2. 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.


Before installing, run the SDK tools:

Terminal window
# 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.zip

The packager enforces the same size limits the runtime does: 50 MB compressed, 200 MB uncompressed.


Plugin install is restricted to super_admin. There is no role below that which can install a plugin.

  1. Log in as super_admin.
  2. Open Settings → Plugins.
  3. Click Install plugin and upload your ZIP.
  4. The backend validates the manifest, checks for reserved IDs, and (if python_dependencies are present) installs them into an isolated per-plugin .venv.
  5. Once installed, the plugin is active globally. Individual organizations can disable it via the org-scoped enable/disable controls.
Terminal window
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.

MethodPathPurposeRequired role
POST/api/v1/plugins/installInstall from uploaded ZIPsuper_admin
GET/api/v1/pluginsList installed plugins and their statusorg_admin
GET/api/v1/plugins/{plugin_id}Plugin detail including cached manifestorg_admin
POST/api/v1/plugins/{plugin_id}/enableEnable globally or for a single orgorg_admin (global = super_admin)
POST/api/v1/plugins/{plugin_id}/disableDisable without removingorg_admin (global = super_admin)
POST/api/v1/plugins/{plugin_id}/upgradeUpgrade via new ZIPsuper_admin
DELETE/api/v1/plugins/{plugin_id}Uninstall and remove filessuper_admin
GET/api/v1/plugins/{plugin_id}/healthRuntime health for the caller’s orgorg_admin
GET/api/v1/plugins/{plugin_id}/settingsRead org-scoped plugin settingsorg_admin
PUT/api/v1/plugins/{plugin_id}/settingsWrite org-scoped plugin settingsorg_admin

After installing, hit your plugin’s health endpoint:

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

Terminal window
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 409 if an org tries to re-enable it.
  • Disabling a plugin makes its mounted routes return 410 Gone via the route guard. The routes themselves are not deregistered until the backend restarts.

LimitValue
ZIP size (compressed)50 MB
ZIP size (uncompressed)200 MB
python_dependencies entries50 max
Automation triggers per plugin50
Automation actions per plugin50
AI tools per plugin20
Devices registered per plugin1,000
Outbound HTTP timeout60 s
Outbound HTTP response body10 MB
Plugin settings blob32 KiB
AI-tool result size256 KB

  • Plugin manifest reference - every plugin.yaml field, the PluginPermission / PluginNavItem / PluginPublicRoute nested models, and the reserved ID list.
  • SDK interfaces - DeviceSDK, AlertSDK, EventSDK, PluginSettingsSDK, and PluginHTTPClient with 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.