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.


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:

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:

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

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 / 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

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.

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>" \

A successful install returns 201 Created.

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

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.

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

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

All product names, logos, and brands are property of their respective owners. FreeSDN is an independent project and is not affiliated with or endorsed by the vendors it integrates with. See Trademarks.