Plugin System
Extend PegaProx with custom integrations
Overview
PegaProx supports community plugins that can add custom API endpoints, background tasks, and integrations. Plugins are auto-discovered from the plugins/ directory (where it is on your install) and can be enabled/disabled at runtime via Settings → Server → Plugins - no server restart required.
⚠️ Security Notice: PegaProx takes no responsibility or liability for community plugins. Administrators should always review and scan plugin code before enabling. Only install plugins from trusted sources.
📜 Licensing (AGPL 3.0): PegaProx is licensed under the GNU Affero General Public License v3.0. Plugins that import from pegaprox.* modules are considered derivative works and must be released under AGPL 3.0 or a compatible open-source license. Distributing closed-source or proprietary plugins that link against PegaProx internals violates the AGPL. If you publish a plugin, you must make its source code available under the same terms.
🔄 v0.9.10 Plugin Migration - DB Access: The application database is now encrypted at rest on Linux x86_64 (SQLCipher AES-256-CBC). Plugins that hit the DB directly via sqlite3.connect() will fail with "file is not a database" against the encrypted DB. Route every DB connection through dbcrypto.connect() and use dbcrypto.Row as row factory (the SQLCipher cursor type does not accept sqlite3.Row). Both fall back gracefully to plain SQLite on platforms without the sqlcipher3-binary wheel. See the "v0.9.10 Migration" subsection below for the exact recipe.
How It Works
Unlike traditional Flask blueprints (which can only be registered at startup), PegaProx uses a catch-all proxy architecture. A single pre-registered route /api/plugins/{id}/api/{path} dispatches requests to plugin handler functions stored in an in-memory dictionary. This means plugins can be loaded, unloaded, and reloaded at runtime without restarting the server.
Auto-Discovery
On startup and via the Rescan button, PegaProx scans the plugins/ directory for subdirectories containing a manifest.json file. Each discovered plugin appears in Settings with its name, version, author, and description.
Runtime Loading
When an admin enables a plugin, PegaProx dynamically imports its __init__.py module and calls register(app). The plugin registers route handlers via register_plugin_route() - these are immediately accessible without restart.
Authentication
All plugin API routes are automatically protected by PegaProx authentication. Users need the plugins.view permission to call plugin routes. Only admins with plugins.manage can enable/disable/delete plugins.
Isolation
Each plugin runs in the same Python process but in its own module namespace (plugins.{id}). A crashing plugin does not take down the server - errors are caught and logged. Unloading removes the module from sys.modules.
Where the plugins live
PegaProx loads its plugins, and serves the pages of the Status Page and the Client Portal, from one plugin folder. Which folder that is depends on how PegaProx was installed:
| Install | Plugin folder | Where the bundled plugins come from |
|---|---|---|
Debian package (apt), and the VM and LXC appliances built from it |
/var/lib/pegaprox/plugins |
The package keeps a read-only copy in /usr/share/pegaprox/plugins and brings it into the plugin folder on every install and upgrade. |
| Docker | /app/config/plugins, inside the config volume (PEGAPROX_PLUGINS_DIR) |
The image keeps them in /app/plugins and brings them into the volume every time the container starts. |
deploy.sh, updated with update.sh or the in-app updater |
/opt/PegaProx/plugins |
deploy.sh copies them; update.sh and the in-app updater bring the new release's with every update. |
| git checkout started by hand | plugins/ in the folder PegaProx is started from |
The checkout itself. |
When an install or an update brings the bundled plugins:
- a bundled plugin that is missing in the plugin folder is copied whole
- in one that is there, its code and its pages are replaced with the new release's
- a plugin's
config.json, which holds its settings, stays as it is once it exists, and so does every other file a plugin wrote into its folder - a plugin folder you added yourself is never touched
- on a package install, the bundled plugins' folders belong to the
pegaproxuser afterwards, so a plugin can save its settings
Changed in 1.4.0
The Debian package and the appliances of 1.3.0 and older came without plugins: the plugin list stayed empty, and plugins copied into /var/lib/pegaprox/plugins by hand ran, but the Status Page and the Client Portal said "not installed". The update brings the bundled plugins and serves both pages from the plugin folder. A config.json you placed there yourself is kept.
Docker images up to 1.3.0 kept the plugins inside the container, so every new image put each plugin's settings (the status page key, the notification targets, the portal settings) back to the defaults. From that release on they live in the config volume. To carry your current settings over, copy the plugins into the volume once, before you pull the new image:
docker exec pegaprox sh -c 'mkdir -p /app/config/plugins && cp -a /app/plugins/. /app/config/plugins/'
If you mounted your own plugin into /app/plugins, the new image copies it into /app/config/plugins on every start; its settings are edited there from then on.
update.sh and the in-app updater overwrote each plugin's config.json on every update; they keep it now. With git pull, see Updating PegaProx.
Removing a bundled plugin
Deleting a bundled plugin in the settings removes its folder, and the next package upgrade or container start brings it back, switched off. To keep one out of the way, switch it off instead of deleting it.
Getting Started
Create Plugin Directory
Create a folder in the plugin folder of your install (see above) with your plugin name (lowercase, no spaces), for example on a package install:
mkdir -p /var/lib/pegaprox/plugins/my_plugin
chown -R pegaprox:pegaprox /var/lib/pegaprox/plugins/my_plugin
Create manifest.json
Define your plugin metadata:
{
"id": "my_plugin",
"name": "My Plugin",
"version": "1.0.0",
"author": "Your Name",
"description": "What this plugin does",
"min_pegaprox": "0.9.3"
}
Create __init__.py
Implement the register(app) function and register your routes:
from pegaprox.api.plugins import register_plugin_route
def register(app):
register_plugin_route('my_plugin', 'hello', lambda: {
'message': 'Hello from my plugin!'
})
Enable in Settings
Go to Settings → Server → Plugins, click Rescan, then toggle your plugin on. Your route is immediately available at /api/plugins/my_plugin/api/hello.
Limit a plugin to clusters
From 1.3, a plugin can be limited to chosen clusters in the same settings (#642), or with PUT /api/plugins/{plugin_id}/clusters and a body {"clusters": ["<cluster id>", ...]} (needs plugins.manage). An empty list puts it back on every cluster, which is where every plugin starts. Cluster ids are kept even when a cluster is offline, so a later save does not widen the scope by accident.
Plugin Structure
plugins/
my_plugin/
manifest.json # REQUIRED: plugin metadata (name, version, author, description)
__init__.py # REQUIRED: register(app) function - called when plugin is enabled
background.py # OPTIONAL: start_background_tasks() - long-running daemon threads
Full Example Plugin
This example demonstrates route registration, cluster access, database queries, and request context:
# plugins/hello_world/__init__.py
"""
Hello World Plugin - Example plugin for PegaProx
All plugin routes are automatically protected by authentication.
"""
import logging
from flask import request
from pegaprox.api.plugins import register_plugin_route
from pegaprox.globals import cluster_managers
from pegaprox.core.db import get_db
PLUGIN_NAME = "Hello World"
def _get_status():
"""Route handler: returns plugin status + connected cluster count"""
# Access cluster managers (same API as core PegaProx)
connected = sum(1 for m in cluster_managers.values() if m.is_connected)
total = len(cluster_managers)
# Access request context (user info from auth)
user = getattr(request, 'session', {}).get('user', 'unknown')
return {
'plugin': 'hello_world',
'status': 'running',
'authenticated_user': user,
'clusters': {'connected': connected, 'total': total}
}
def _get_info():
"""Route handler: returns plugin metadata"""
return {
'name': PLUGIN_NAME,
'version': '1.0.0',
'routes': ['status', 'info']
}
def register(app):
"""Called by PegaProx when the plugin is enabled."""
register_plugin_route('hello_world', 'status', _get_status)
register_plugin_route('hello_world', 'info', _get_info)
logging.info("[PLUGINS] Hello World plugin registered")
# Routes are accessible at:
# GET /api/plugins/hello_world/api/status
# GET /api/plugins/hello_world/api/info
Background Tasks
Plugins can run background threads by implementing start_background_tasks() in their module. This function is called after register(app) when the plugin is enabled.
# plugins/my_plugin/background.py (or in __init__.py)
import threading, time, logging
def _my_background_loop():
while True:
logging.info("[MyPlugin] Background check running...")
# do periodic work here
time.sleep(300) # every 5 minutes
def start_background_tasks():
t = threading.Thread(target=_my_background_loop, daemon=True)
t.start()
Available PegaProx APIs
Plugins run in the same process as PegaProx and have full access to internal APIs:
| Import | Description | Example |
|---|---|---|
from pegaprox.globals import cluster_managers |
All connected cluster managers | len(cluster_managers), mgr.get_node_status() |
from pegaprox.globals import pbs_managers |
PBS server connections | mgr.get_datastores() |
from pegaprox.core.db import get_db |
SQLite database (read/write) | db.query('SELECT ...'), db.execute('INSERT ...') |
from pegaprox.utils.audit import log_audit |
Write to audit trail | log_audit('user', 'action', 'details') |
from pegaprox.utils.realtime import broadcast_sse |
Push SSE events to all clients | broadcast_sse('custom_event', data) |
from flask import request |
Current HTTP request context | request.session.get('user'), request.json |
Permissions
| Permission | Who Has It | What It Allows |
|---|---|---|
plugins.view |
Admin, User, Viewer | See the Plugins tab, call plugin API routes |
plugins.manage |
Admin only | Enable/disable plugins, rescan directory, delete plugins |
Plugin Management UI
- Settings → Server → Plugins - Toggle to enable/disable each discovered plugin. Shows name, version, author, description, and error state.
- Rescan button - Re-reads the
plugins/directory for newly added or removed plugins. - Delete button (trash icon) - Unloads the plugin, removes its DB state, and deletes the plugin folder from disk. Requires confirmation.
- Dashboard → Plugins tab - Shows all enabled and loaded plugins as cards with their registered API routes.
API Endpoints
| Method | Endpoint | Permission | Description |
|---|---|---|---|
GET |
/api/plugins |
plugins.view |
List all discovered plugins with enabled/loaded status and registered routes |
POST |
/api/plugins/{id}/enable |
plugins.manage |
Enable and immediately load a plugin (no restart needed) |
POST |
/api/plugins/{id}/disable |
plugins.manage |
Disable and unload a plugin (routes become unavailable immediately) |
POST |
/api/plugins/rescan |
plugins.manage |
Rescan plugins/ directory for new or removed plugins |
DELETE |
/api/plugins/{id} |
plugins.manage |
Unload, remove state, and delete plugin files from disk |
* |
/api/plugins/{id}/api/{path} |
plugins.view |
Catch-all proxy - dispatches to plugin-registered handler functions |
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| Plugin not discovered | Missing manifest.json, or the plugin sits in another folder than the one PegaProx loads from |
Ensure my_plugin/manifest.json exists with valid JSON in the plugin folder of your install |
| No plugins listed on a package install, or the Status Page / Client Portal says "not installed" | The package of 1.3.0 and older shipped no plugins and read those pages from next to the program files | Update the package; it brings the bundled plugins into /var/lib/pegaprox/plugins |
| A plugin's settings are back to the defaults after an update | A Docker image, update.sh or the in-app updater of 1.3.0 and older |
Update; from 1.4.0 a plugin's config.json is kept |
| Plugin shows error after enable | Python import error in __init__.py |
Check server logs for [PLUGINS] Failed to load messages |
| Route returns 404 | Plugin not loaded or wrong path | Verify plugin is enabled (green toggle) and route path matches register_plugin_route() call |
| Route returns 401/403 | Missing authentication or permission | User must be logged in and have plugins.view permission |
| Changes not reflected | Old module cached in memory | Disable then re-enable the plugin, or restart the server |
"file is not a database" after v0.9.10 upgrade |
Plugin uses sqlite3.connect() against the encrypted DB |
Migrate to dbcrypto.connect() - see "v0.9.10 Migration: Encryption-Aware DB Access" below |
TypeError: Row() argument 1 must be sqlite3.Cursor, not sqlcipher3.dbapi2.Cursor |
Plugin sets conn.row_factory = sqlite3.Row on a SQLCipher cursor |
Use dbcrypto.Row instead |
v0.9.10 Migration: Encryption-Aware DB Access
From v0.9.10 onward, the application database is encrypted at rest on Linux x86_64 via SQLCipher (AES-256-CBC + HMAC-SHA512). Plugins that access the DB directly need a one-line import swap and one row-factory swap.
✅ Drop-in compatible: dbcrypto.connect() automatically detects the platform. On Linux x86_64 (with the sqlcipher3-binary wheel installed) it applies the SQLCipher PRAGMA-key handshake transparently. On ARM / macOS / Windows it falls back to plain sqlite3.connect(). Your plugin works on both, without conditionals.
Before (v0.9.9 and earlier)
import sqlite3
from pegaprox.constants import CONFIG_DIR
def _get_db():
conn = sqlite3.connect(os.path.join(CONFIG_DIR, 'pegaprox.db'))
conn.row_factory = sqlite3.Row
return conn
After (v0.9.10+)
from pegaprox.core import dbcrypto
from pegaprox.constants import CONFIG_DIR
def _get_db():
# MK May 2026: route via dbcrypto so the SQLCipher handshake runs first
# on encrypted DBs. Falls back to plain sqlite3 on platforms without the
# sqlcipher3-binary wheel - same plugin works everywhere.
conn = dbcrypto.connect(os.path.join(CONFIG_DIR, 'pegaprox.db'), timeout=5)
conn.row_factory = dbcrypto.Row
return conn
What changed
sqlite3.connect()→dbcrypto.connect()- appliesPRAGMA keyautomatically using the master key resolved by the keystore. Identical kwargs (timeout,check_same_thread, etc.) are forwarded.sqlite3.Row→dbcrypto.Row- the SQLCipher cursor class doesn't acceptsqlite3.Row.dbcrypto.Rowaliases to whichever Row class is active for the current backend, so the same code works on both platforms.- Same applies to exception types:
dbcrypto.IntegrityError,dbcrypto.OperationalError,dbcrypto.DatabaseError. These are re-exports that match the active backend.
If your plugin uses its own SQLite DB (not pegaprox.db)
You can still use dbcrypto.connect() - point it at your plugin's DB file. PegaProx's app.py auto-encrypts config/pegaprox.db and config/syslog.db on boot; if your plugin's DB lives elsewhere it stays plain and dbcrypto won't touch it (the SQLCipher PRAGMA handshake only triggers when the file is already encrypted or when explicitly told to encrypt). If you want your plugin's DB encrypted too, call dbcrypto.ensure_db_encrypted(your_db_path) during register(app) - the migration runs once, idempotent on subsequent boots.
Backend status check
Plugins that want to behave differently based on whether the platform supports full-DB encryption can inspect:
from pegaprox.core import dbcrypto
if dbcrypto.is_encrypted():
# SQLCipher backend active - full DB encryption available
...
else:
# Plain SQLite fallback - sensitive fields should still be Fernet-encrypted
...
# Or get the full status dict:
status = dbcrypto.backend_status()
# {'backend': 'sqlcipher', 'encrypted_at_rest': True,
# 'cipher': 'AES-256-CBC + HMAC-SHA512 (SQLCipher 4)',
# 'note': 'Full database encryption is active.'}
Note on direct file access
Plugins that need to read raw bytes from config/pegaprox.db (e.g. for backup snapshotting outside of SQL) will see encrypted bytes on Linux x86_64. Use SQL-level export (SELECT queries through dbcrypto.connect()) or use the platform's standard SQLCipher dump tools (sqlcipher3 command, .dump) with the master key from keystore.load_master_key().