OPNsense Plugin Development
Konten ini belum tersedia dalam bahasa Anda.
Artikel ini belum diterjemahkan. Isi berikut berbahasa Inggris. Buka versi asli bahasa Inggris.
OPNsense Plugin Development
Section titled “OPNsense Plugin Development”Build and package OPNsense plugins using the MVC framework (Phalcon/PHP) and the configd backend (Python). This guide walks through creating a plugin from scratch, enhancing existing plugins, and submitting pull requests.
Source material: This guide synthesizes @mimugmail’s plugin development series (LLDP and FreeRADIUS examples) and the official OPNsense HelloWorld documentation. Updated for OPNsense 26.x (FreeBSD 14.x).
When to Build a Plugin vs. Install a Package
Section titled “When to Build a Plugin vs. Install a Package”| Approach | Use case | Complexity |
|---|---|---|
| Community Repo | Pre-built software (AdGuard, Grafana, Caddy) | Zero — pkg install |
| Build from Ports | Custom software, not in any repo | Medium — tools.git |
| Build a Plugin | Native OPNsense UI integration, config management, service control | Medium-High |
A plugin gives you: native OPNsense web UI, config.xml integration, service start/stop/reload, and API endpoints accessible from other tools.
Architecture
Section titled “Architecture”┌──────────────────────────────────────────────────┐│ Frontend (PHP/Phalcon MVC) ││ ┌──────────┐ ┌──────────┐ ┌────────────────┐ ││ │ Models │ │ Views │ │ Controllers │ ││ │ XML+PHP │ │ .volt │ │ Index, Api/* │ ││ │ (config │ │ (HTML │ │ (routing, JSON │ ││ │ schema) │ │ render) │ │ API endpoints)│ ││ └──────────┘ └──────────┘ └───────┬────────┘ ││ │ │├──────────────────────────────────────┼────────────┤│ Unix socket │ ││ ┌───────────────────────────────────┴─────────┐ ││ │ Backend (configd / Python) │ ││ │ ┌──────────┐ ┌────────────┐ ┌─────────┐ │ ││ │ │ Actions │ │ Templates │ │ Scripts │ │ ││ │ │ .conf │ │ Jinja2 │ │ .py/.sh │ │ ││ │ │ (daemon │ │ (config │ │ (custom │ │ ││ │ │ control)│ │ generation)│ │ logic) │ │ ││ │ └──────────┘ └────────────┘ └─────────┘ │ ││ └─────────────────────────────────────────────┘ │└──────────────────────────────────────────────────┘Key principles:
- PHP never calls shell commands directly. All backend work goes through configd actions.
- Backend templates (Jinja2) ≠ frontend templates (Volt). They serve different layers.
- Models define the data schema in config.xml. Forms define the UI. Templates generate service config files.
Prerequisites
Section titled “Prerequisites”# On your dev machine:git clone https://github.com/opnsense/tools # Build toolkitgit clone https://github.com/<your-user>/plugins # Your fork of the plugins repo
# On your OPNsense test box: nothing extra needed.# All dev files live at /usr/local/opnsense/ on the firewall.Directory Structure
Section titled “Directory Structure”Every plugin follows this canonical layout. The package name is always prefixed os- (e.g., os-helloworld-1.4_2.pkg).
/usr/plugins/<category>/<pluginname>/├── Makefile├── pkg-descr└── src/ └── opnsense/ ├── mvc/app/ │ ├── controllers/OPNsense/<PluginName>/ │ │ ├── IndexController.php # Page routing │ │ ├── Api/ │ │ │ ├── SettingsController.php # get/set config via API │ │ │ └── ServiceController.php # start/stop/reload service │ │ └── forms/ │ │ └── general.xml # UI field definitions │ ├── models/OPNsense/<PluginName>/ │ │ ├── <PluginName>.php # Model class (PHP) │ │ ├── <PluginName>.xml # Data schema (config.xml mount) │ │ ├── ACL/ACL.xml # Access control list │ │ └── Menu/Menu.xml # Sidebar menu placement │ └── views/OPNsense/<PluginName>/ │ └── index.volt # UI template (Volt) ├── scripts/<pluginname>/ # Shell/Python utilities └── service/ ├── conf/actions.d/ │ └── actions_<pluginname>.conf # configd command registry └── templates/OPNsense/<PluginName>/ ├── +TARGETS # Template output mappings └── <configfile>.conf # Jinja2 config generatorNaming convention: OPNsense/<PluginName> — VendorName/ModuleName. Case matters everywhere. PHP namespaces match directory paths exactly.
Step-by-Step: New Plugin from Scratch
Section titled “Step-by-Step: New Plugin from Scratch”1. Pick a Template Plugin
Section titled “1. Pick a Template Plugin”Don’t start from nothing — copy a plugin similar to what you’re building:
| If you need… | Copy from… | Why |
|---|---|---|
| Single page, some fields | collectd | Simplest structure, just one form page |
| Multi-entry CRUD (list + add/edit/delete) | freeradius, siproxd | Grid-based with user management pattern |
| Diagnostics/dashboard tab | quagga (Diagnostics section) | Shows command output in the UI |
| Service with daemon control | lldpd | Has service start/stop + config generation |
cd plugins# Example: building a simple plugin with one config pagecp -r net-mgmt/collectd net-mgmt/mynewplugin2. Search & Replace — The Core Loop
Section titled “2. Search & Replace — The Core Loop”This is where most of the work happens. Replace ALL occurrences, case-sensitive:
| Find | Replace with | Notes |
|---|---|---|
collectd (lowercase) | mynewplugin | File/directory names, URLs, config paths |
Collectd (CamelCase) | Mynewplugin | Class names, namespace segments |
Files to check:
src/opnsense/mvc/app/models/OPNsense/Mynewplugin/*.php,*.xmlsrc/opnsense/mvc/app/controllers/OPNsense/Mynewplugin/*.php,Api/*.phpsrc/opnsense/mvc/app/views/OPNsense/Mynewplugin/*.voltsrc/opnsense/service/templates/OPNsense/Mynewplugin/+TARGETS,*.confsrc/opnsense/service/conf/actions.d/actions_*.confsrc/opnsense/scripts/OPNsense/Mynewplugin/setup.sh
From mimugmail: “No knowledge of PHP needed — just search & replace. That’s all.”
3. Makefile
Section titled “3. Makefile”PLUGIN_NAME= mynewpluginPLUGIN_VERSION= 0.1PLUGIN_REVISION= 1PLUGIN_COMMENT= My New Plugin — does something usefulPLUGIN_MAINTAINER= [email protected]PLUGIN_DEVEL= YES # Remove before submitting PR
.include "../../Mk/plugins.mk"PLUGIN_DEVEL=YESskips ports dependency checks during development. Remove before PR.- Final package:
os-mynewplugin-0.1_1.pkg
4. Model — Data Schema
Section titled “4. Model — Data Schema”<PluginName>.php — boilerplate class:
<?phpnamespace OPNsense\Mynewplugin;
use OPNsense\Base\BaseModel;
class Mynewplugin extends BaseModel{}<PluginName>.xml — defines where data lives in config.xml and what fields exist.
Each model should include a <version> tag (current convention):
<model> <mount>//OPNsense/mynewplugin</mount> <description> My New Plugin — manages something useful </description> <version>1.0.0</version> <items> <general> <Enabled type="BooleanField"> <Default>1</Default> <Required>Y</Required> </Enabled> <ServerAddress type="NetworkField"> <Required>Y</Required> <ValidationMessage>Please enter a valid IP address.</ValidationMessage> </ServerAddress> <Port type="PortField"> <Default>8080</Default> <Required>Y</Required> </Port> <Protocol type="OptionField"> <Required>Y</Required> <Default>http</Default> <OptionValues> <http>HTTP</http> <https>HTTPS</https> </OptionValues> </Protocol> </general> </items></model>Note:
<Default>uses a capital D.<Required>can beYorN. The<version>tag is optional but present in all current plugins.
Available field types from /usr/local/opnsense/mvc/app/models/OPNsense/Base/FieldTypes/:
| Field Type | Use for |
|---|---|
TextField | Free-text strings |
BooleanField | Checkboxes (on/off) |
IntegerField | Whole numbers |
NetworkField | IP addresses (v4/v6) |
EmailField | Email addresses (validated) |
PortField | TCP/UDP port numbers |
OptionField | Dropdown select menus |
CSVListField | Comma-separated lists |
CertificateField | Certificate references |
InterfaceField | Network interface references |
5. Forms — UI Layout
Section titled “5. Forms — UI Layout”forms/general.xml defines what the user sees. Field <id> paths are relative to the model mount point:
| Mount path | Form ID example | Resolves to |
|---|---|---|
//OPNsense/mynewplugin | mynewplugin.general.Enabled | //OPNsense/mynewplugin/general/Enabled |
//OPNsense/lldpd/general | general.cdp | //OPNsense/lldpd/general/cdp |
<form> <field> <id>mynewplugin.general.Enabled</id> <label>Enable My Plugin</label> <type>checkbox</type> <help>Enable or disable this feature.</help> </field> <field> <id>mynewplugin.general.ServerAddress</id> <label>Server Address</label> <type>text</type> <help>IP address of the target server.</help> <hint>e.g. 192.168.1.100</hint> </field> <field> <id>mynewplugin.general.Port</id> <label>Port</label> <type>text</type> </field> <field> <id>mynewplugin.general.Protocol</id> <label>Protocol</label> <type>dropdown</type> </field></form>The <id> must match the path in the model: pluginname.container.field.
6. Controllers
Section titled “6. Controllers”IndexController.php — handles page routing:
<?phpnamespace OPNsense\Mynewplugin;
class IndexController extends \OPNsense\Base\IndexController{ public function indexAction() { // Pass the form definition to the view $this->view->generalForm = $this->getForm("general"); // Pick the Volt template to render $this->view->pick('OPNsense/Mynewplugin/index'); }}Api/SettingsController.php — REST API for loading/saving config:
<?phpnamespace OPNsense\Mynewplugin\Api;
use \OPNsense\Base\ApiMutableModelControllerBase;
class SettingsController extends ApiMutableModelControllerBase{ protected static $internalModelClass = 'OPNsense\Mynewplugin\Mynewplugin'; protected static $internalModelName = 'mynewplugin';}This automatically provides get and set API endpoints at:
GET/POST /api/mynewplugin/settings/getPOST /api/mynewplugin/settings/set
Test it (as root): curl -k -u root:password https://<firewall>/api/mynewplugin/settings/get
Api/ServiceController.php — backend actions (reload, start, stop):
<?phpnamespace OPNsense\Mynewplugin\Api;
use \OPNsense\Base\ApiMutableServiceControllerBase;use \OPNsense\Core\Backend;
class ServiceController extends ApiMutableServiceControllerBase{ protected static $internalServiceClass = '\OPNsense\Mynewplugin\Mynewplugin'; protected static $internalServiceTemplate = 'OPNsense/Mynewplugin'; protected static $internalServiceEnabled = 'general.Enabled'; // path relative to model mount protected static $internalServiceName = 'mynewplugin';
public function reloadAction() { if ($this->request->isPost()) { $status = strtolower(trim( (new Backend())->configdRun('template reload OPNsense/Mynewplugin') )); return ["status" => $status]; } return ["status" => "failed"]; }}7. View — Volt Template
Section titled “7. View — Volt Template”index.volt renders the page. Start simple:
<script type="text/javascript"> $( document ).ready(function() { // Load saved data into the form mapDataToFormUI({'frm_GeneralSettings':"/api/mynewplugin/settings/get"});
// Wire up the Save button $("#saveAct").click(function(){ saveFormToEndpoint("/api/mynewplugin/settings/set",'frm_GeneralSettings',function(){ // After save, tell the backend to regenerate config ajaxCall(url="/api/mynewplugin/service/reload", sendData={},callback=function(data,status) {}); }); }); });</script>
<div class="col-md-12"> <button class="btn btn-primary" id="saveAct" type="button"> <b>{{ lang._('Save') }}</b> </button></div>
{{ partial("layout_partials/base_form",['fields':generalForm,'id':'frm_GeneralSettings'])}}Multi-tab pages (like LLDP with General + Neighbors tabs):
<ul class="nav nav-tabs" data-tabs="tabs" id="maintabs"> <li class="active"><a data-toggle="tab" href="#general">{{ lang._('General') }}</a></li> <li><a data-toggle="tab" href="#status">{{ lang._('Status') }}</a></li></ul>
<div class="tab-content"> <div id="general" class="tab-pane fade in active"> <!-- form content here --> </div> <div id="status" class="tab-pane fade in"> <pre id="outputArea"></pre> </div></div>For the status tab, add an API endpoint in ServiceController that calls a configd action and returns output:
public function statusAction(){ if ($this->request->isPost()) { $response = (new Backend())->configdRun("mynewplugin status"); return ["response" => $response]; } return ["response" => ""];}ajaxCall(url="/api/mynewplugin/service/status", sendData={}, callback=function(data,status) { $("#outputArea").text(data['response']);});8. ACL — Access Control
Section titled “8. ACL — Access Control”ACL/ACL.xml — who can access the plugin. Without this, only root can see it.
<acl> <page-services-mynewplugin> <name>WebCfg - Services: My New Plugin</name> <description>Allow access to My New Plugin configuration</description> <patterns> <pattern>ui/mynewplugin/*</pattern> <pattern>api/mynewplugin/*</pattern> </patterns> </page-services-mynewplugin></acl>Clear cache after changes: rm -f /tmp/opnsense_acl_cache.json
9. Menu — Sidebar Entry
Section titled “9. Menu — Sidebar Entry”Menu/Menu.xml — where it appears in the left sidebar:
<menu> <Services order="50"> <Mynewplugin VisibleName="My New Plugin" url="/ui/mynewplugin/" cssClass="fa fa-cog fa-fw" /> </Services></menu>Available menu sections: Services, VPN, Interfaces, Firewall, System, Reporting, User, Diagnostics.
Clear cache: rm -f /tmp/opnsense_menu_cache.xml
10. Backend Templates — Config File Generation
Section titled “10. Backend Templates — Config File Generation”Templates live in service/templates/OPNsense/<PluginName>/. They use Jinja2 to read config.xml values and generate service configuration files.
+TARGETS — maps template source to output file on the filesystem:
mynewplugin.conf:/usr/local/etc/mynewplugin/mynewplugin.confTemplate file (mynewplugin.conf):
{% if helpers.exists('OPNsense.mynewplugin.general.Enabled') and OPNsense.mynewplugin.general.Enabled == '1' %}[server]address={{ OPNsense.mynewplugin.general.ServerAddress|default("") }}port={{ OPNsense.mynewplugin.general.Port|default("8080") }}protocol={{ OPNsense.mynewplugin.general.Protocol }}{% endif %}Template helpers:
| Expression | Meaning |
|---|---|
helpers.exists('path') | Check if config path exists |
OPNsense.plugin.field | Read value from config.xml |
| ` | default(“fallback”)` |
!= "" | Check for text/string fields |
== '1' | Check for checkbox/BooleanField |
{% raw %}...{% endraw %} | Escape everything inside (pass through literally) |
Gotcha: If your service config contains
}}or{%(common in FreeRADIUS ldap.conf), wrap that section in{% raw %}...{% endraw %}or the Jinja2 parser will choke.
11. configd Actions — Daemon Control
Section titled “11. configd Actions — Daemon Control”service/conf/actions.d/actions_mynewplugin.conf registers commands that configd can execute:
[start]command:/usr/local/etc/rc.d/mynewplugin startparameters:type:scriptmessage:starting mynewplugin daemon
[stop]command:/usr/local/etc/rc.d/mynewplugin stopparameters:type:script
[restart]command:/usr/local/etc/rc.d/mynewplugin restartparameters:type:script
[status]command:/usr/local/sbin/mynewpluginctl statusparameters:type:script_outputmessage:checking mynewplugin statusAction types:
| Type | Use for | Returns |
|---|---|---|
script | RC scripts, service control | OK/Fail |
script_output | Commands that produce output (diagnostics) | stdout as text |
stream_output | Long-running streams | Stream handle |
inline | Template generation (built-in) | Template engine output |
Test from CLI: configctl mynewplugin status
12. setup.sh — /var Ramdisk Support
Section titled “12. setup.sh — /var Ramdisk Support”OPNsense often runs /var as a tmpfs (ramdisk). Directories under /var/ vanish on reboot. setup.sh recreates them:
#!/bin/shmkdir -p /var/run/mynewpluginchown mynewplugin:mynewplugin /var/run/mynewpluginPlace at src/opnsense/scripts/OPNsense/Mynewplugin/setup.sh.
13. Service RC Template
Section titled “13. Service RC Template”For plugins that control a daemon, add the rc.d file to your templates. In +TARGETS:
mynewplugin:/usr/local/etc/rc.d/mynewpluginmynewplugin.conf:/usr/local/etc/mynewplugin/mynewplugin.confThe mynewplugin template generates the FreeBSD rc.d script with proper start/stop commands.
Build & Test Cycle
Section titled “Build & Test Cycle”Build the package
Section titled “Build the package”cd plugins/net-mgmt/mynewpluginmake packageInstall on test OPNsense
Section titled “Install on test OPNsense”# From the build machine:scp work/pkg/os-*.pkg root@firewall:/tmp/ssh root@firewall pkg install /tmp/os-*.pkg
# Or build directly on the OPNsense box:pkg install gitgit clone https://github.com/<user>/pluginscd plugins/net-mgmt/mynewpluginmake packagepkg install work/pkg/os-*.pkgReplace an existing plugin
Section titled “Replace an existing plugin”pkg remove os-mynewplugin# Then install the new versionTest via API (as root)
Section titled “Test via API (as root)”# Read current configcurl -k -u root:password https://<firewall>/api/mynewplugin/settings/get
# Watch configd logtail -f /var/log/system.log | grep configdDebugging
Section titled “Debugging”| Problem | Check |
|---|---|
| Template generation fails | Jinja2 syntax error. Run configctl template reload OPNsense/Mynewplugin manually. Check /var/log/system.log for traceback. |
| Page 404 after creating controller | URL is /ui/mynewplugin/ (lowercase). Clear opnsense_menu_cache.xml. |
| Form fields empty | Check model mount path + form IDs match. Test API endpoint directly. |
| Save button does nothing | Check browser console for JS errors. Verify API endpoint responds. |
| ACL not showing in user manager | rm /tmp/opnsense_acl_cache.json |
| Menu item not appearing | rm /tmp/opnsense_menu_cache.xml |
make package fails with cryptic error | Usually an XML syntax issue (mismatched tags, unescaped chars). Check model/form XML. |
tag name expected error | You have }} or {% in literal config content. Wrap in {% raw %}...{% endraw %}. |
| API functions not found | Action methods MUST end with Action (e.g., neighborAction(), NOT neighbor()). |
Walkthrough A: LLDP Plugin from Scratch
Section titled “Walkthrough A: LLDP Plugin from Scratch”This 4-part walkthrough follows @mimugmail’s original series. The LLDP daemon plugin was built by copying the
collectdplugin and adapting it. It was merged into official plugins.
Part 1: Copy & Rename
Section titled “Part 1: Copy & Rename”Strategy: LLDP needs one config page (like collectd) and one diagnostics tab to show neighbors (like quagga — Diagnostics → Show running-config). Both collectd and lldpd live in net-mgmt in the ports tree, so we stay in that category.
cd pluginscp -r net-mgmt/collectd net-mgmt/lldpdNow rename everything, case-sensitive:
# Files and directoriesfind net-mgmt/lldpd -name '*collectd*' -exec rename collectd lldpd {} \;find net-mgmt/lldpd -name '*Collectd*' -exec rename Collectd Lldpd {} \;
# File contentsfind net-mgmt/lldpd -type f -exec sed -i '' 's/collectd/lldpd/g' {} \;find net-mgmt/lldpd -type f -exec sed -i '' 's/Collectd/Lldpd/g' {} \;Makefile:
PLUGIN_NAME= lldpdPLUGIN_VERSION= 0.1PLUGIN_REVISION= 1PLUGIN_COMMENT= LLDP daemon configurationPLUGIN_MAINTAINER= [email protected]PLUGIN_DEVEL= YES
.include "../../Mk/plugins.mk"pkg-descr: Copy the LLDPD project description from its website.
Service control (src/etc/inc/plugins.inc.d/lldpd.inc): Check the correct PID file location for lldpd. Replace all collectd references.
setup.sh (src/opnsense/scripts/OPNsense/Lldpd/setup.sh): Check the ports tree’s pkg-* files for /var directories that lldpd creates. Add them so they survive reboot when /var is a ramdisk.
Part 2: Templates, Actions, MVC Files
Section titled “Part 2: Templates, Actions, MVC Files”src/opnsense/service/templates/OPNsense/Lldpd/+TARGETS:
lldpd:/usr/local/etc/rc.d/lldpdlldpd.conf:/usr/local/etc/lldpd.confThe lldpd file controls auto start/stop of the daemon. Remove everything between the first if and last endif, replace collectd with lldpd.
src/opnsense/service/conf/actions.d/actions_lldpd.conf: Rename from actions_collectd.conf. This registers the daemon in configd:
[start]command:/usr/local/etc/rc.d/lldpd startparameters:type:scriptmessage:starting lldpd
[stop]command:/usr/local/etc/rc.d/lldpd stopparameters:type:scriptmessage:stopping lldpdMVC files — search & replace in every file under src/opnsense/mvc/app/:
| Directory | Key Files | What to do |
|---|---|---|
views/OPNsense/Lldpd/ | general.volt | UI content — just search & replace |
models/OPNsense/Lldpd/ | General.php, General.xml | Replace Collectd → Lldpd; model XML done in part 3 |
models/OPNsense/Lldpd/ACL/ | ACL.xml | Defines URLs for access control |
models/OPNsense/Lldpd/Menu/ | Menu.xml | Sidebar placement + icon (use Font Awesome) |
controllers/OPNsense/Lldpd/ | GeneralController.php | Page definition — search & replace |
controllers/OPNsense/Lldpd/Api/ | *Controller.php | API + service actions — search & replace |
controllers/OPNsense/Lldpd/forms/ | general.xml | Field definitions — done in part 3 |
Part 3: Forms, Model, and Templating
Section titled “Part 3: Forms, Model, and Templating”LLDPD works via CLI arguments (e.g., -c enables CDP). No traditional .conf file — just command-line flags.
forms/general.xml — checkboxes for each protocol (IDs relative to mount //OPNsense/lldpd/general):
<form> <field> <id>general.enabled</id> <label>Enable LLDP Daemon</label> <type>checkbox</type> <help>This will activate the LLDPD service.</help> </field> <field> <id>general.cdp</id> <label>Enable CDP</label> <type>checkbox</type> <help>This will activate the Cisco Discovery Protocol.</help> </field> <field> <id>general.sonmp</id> <label>Enable SONMP</label> <type>checkbox</type> </field> <field> <id>general.edp</id> <label>Enable EDP</label> <type>checkbox</type> </field> <field> <id>general.fdp</id> <label>Enable FDP</label> <type>checkbox</type> </field></form>Model General.xml:
<model> <mount>//OPNsense/lldpd/general</mount> <description>LLDP daemon configuration</description> <version>1.0.1</version> <items> <enabled type="BooleanField"> <Default>1</Default> <Required>Y</Required> </enabled> <cdp type="BooleanField"> <Default>0</Default> <Required>Y</Required> </cdp> <sonmp type="BooleanField"> <Default>0</Default> <Required>Y</Required> </sonmp> <edp type="BooleanField"> <Default>0</Default> <Required>Y</Required> </edp> <fdp type="BooleanField"> <Default>0</Default> <Required>Y</Required> </fdp> </items></model>Note: Fields sit directly under
<items>(no<general>container) because the mount path itself ends atgeneral. Form IDs usegeneral.cdp(relative to mount), notlldpd.general.cdp.
Template lldpd.conf — generates CLI arguments (all on one line):
{% if helpers.exists('OPNsense.lldpd.general.cdp') and OPNsense.lldpd.general.cdp == '1' %}-c {% endif %}{% if helpers.exists('OPNsense.lldpd.general.sonmp') and OPNsense.lldpd.general.sonmp == '1' %}-s {% endif %}{% if helpers.exists('OPNsense.lldpd.general.edp') and OPNsense.lldpd.general.edp == '1' %}-e {% endif %}{% if helpers.exists('OPNsense.lldpd.general.fdp') and OPNsense.lldpd.general.fdp == '1' %}-f {% endif %}Verification: After saving, check System > Log Files > General:
Dec 27 13:44:23 OPNsense lldpd[22894]: protocol LLDP enabledDec 27 13:44:23 OPNsense lldpd[22894]: protocol CDPv1 enabledDec 27 13:44:23 OPNsense lldpd[22894]: protocol CDPv2 enabledDec 27 13:44:23 OPNsense lldpd[22894]: protocol SONMP enabledDec 27 13:44:23 OPNsense lldpd[22894]: protocol EDP enabledDec 27 13:44:23 OPNsense lldpd[22894]: protocol FDP disabledPart 4: Adding a Diagnostics Tab
Section titled “Part 4: Adding a Diagnostics Tab”Now we want a tab showing lldpcli show neighbors output — similar to how the Quagga plugin shows “Show running-config” in a text box.
Step 1: Register the command in actions_lldpd.conf:
[neighbor]command:/usr/local/sbin/lldpcli show neighborsparameters:type:script_outputmessage:show lldp neighborsStep 2: Create API endpoint in Api/ServiceController.php (exact code from the merged plugin):
namespace OPNsense\Lldpd\Api;
use OPNsense\Base\ApiMutableServiceControllerBase;use OPNsense\Core\Backend;
class ServiceController extends ApiMutableServiceControllerBase{ protected static $internalServiceClass = '\OPNsense\Lldpd\General'; protected static $internalServiceTemplate = 'OPNsense/Lldpd'; protected static $internalServiceEnabled = 'enabled'; protected static $internalServiceName = 'lldpd';
/** * show lldpd neighbors * @return array */ public function neighborAction() { $backend = new Backend(); $response = $backend->configdRun("lldpd neighbor"); return array("response" => $response); }}Pattern: Look at existing plugins for reference. This was copied from
plugins/net/quagga/src/opnsense/mvc/app/controllers/OPNsense/Quagga/Api/DiagnosticsController.php(line 63). Method names MUST end withAction.
Step 3: Wire up the UI in general.volt:
Add Bootstrap tabs:
<ul class="nav nav-tabs" data-tabs="tabs" id="maintabs"> <li class="active"><a data-toggle="tab" href="#general">{{ lang._('General') }}</a></li> <li><a data-toggle="tab" href="#neighbor">{{ lang._('Neighbors') }}</a></li></ul>Add the API call:
ajaxCall(url="/api/lldpd/service/neighbor", sendData={}, callback=function(data,status) { $("#listneighbor").text(data['response']);});Add the output container:
<div id="neighbor" class="tab-pane fade in"> <pre id="listneighbor"></pre></div>Step 4: Build and test:
git clone https://github.com/<user>/pluginscd pluginsgit checkout lldpd # if using a branchcd net-mgmt/lldpdmake packagepkg install work/pkg/os-*.txzResult: A plugin with two tabs — General (config) and Neighbors (live output from lldpcli). View the final merged code.
From mimugmail: “If you’re wondering how I figured out the API call pattern — I just looked at the Quagga controller code. And when I was stuck on templating the daemon CLI arguments, I asked in a GitHub issue.”
Walkthrough B: Enhancing FreeRADIUS with LDAP Support
Section titled “Walkthrough B: Enhancing FreeRADIUS with LDAP Support”This 4-part walkthrough follows @mimugmail’s original series. The goal: add an LDAP submenu to the existing FreeRADIUS plugin with full UI configuration.
Part 1: Verify Dependencies & Scaffold the MVC
Section titled “Part 1: Verify Dependencies & Scaffold the MVC”First, verify LDAP works with FreeRADIUS on the system:
cd /usr/local/etc/raddb/mods-enabledln -s ../mods-available/ldap .service radiusd stopradiusd -XOutput shows the LDAP module loads correctly (just can’t connect since no server configured yet):
rlm_ldap (ldap): Initialising connection poolrlm_ldap (ldap): Bind with (anonymous) to ldap://localhost:389 failed: Can't contact LDAP serverGood — all FreeRADIUS LDAP dependencies are met.
Add the menu entry in models/OPNsense/Freeradius/Menu/Menu.xml:
<LDAP url="/ui/freeradius/ldap/index" order="50"/>Scaffold the Model: Copy Eap.xml and Eap.php → Ldap.xml and Ldap.php. Replace EAP → LDAP, eap → ldap, Eap → Ldap (case-sensitive).
Scaffold the Controllers:
- Copy
controllers/OPNsense/Freeradius/EapController.php→LdapController.php - Copy
controllers/OPNsense/Freeradius/Api/EapController.php→Api/LdapController.php - Replace
Eap→Ldapeverywhere
Scaffold the View: Copy views/OPNsense/Freeradius/eap.volt → ldap.volt. Search & replace.
Now you have the (M)odel, (C)ontroller, and (V)iew skeleton.
From mimugmail: “No PHP knowledge needed. Just search & replace FTW!”
Part 2: Define Fields (Forms + Model)
Section titled “Part 2: Define Fields (Forms + Model)”Looking at /usr/local/etc/raddb/mods-enabled/ldap, the interesting config values are:
server, port, identity, password, base_dn.
Since the server config supports both ldap:// and ldaps:// URIs, we can combine protocol+host into a dropdown + text field instead of a separate port field.
forms/ldap.xml:
<form> <field> <id>freeradius.ldap.protocol</id> <label>Protocol</label> <type>dropdown</type> </field> <field> <id>freeradius.ldap.server</id> <label>Server</label> <type>text</type> <help>IP address or hostname of LDAP server</help> </field> <field> <id>freeradius.ldap.identity</id> <label>Identity</label> <type>text</type> <help>Bind DN for LDAP connection</help> </field> <field> <id>freeradius.ldap.password</id> <label>Password</label> <type>password</type> </field> <field> <id>freeradius.ldap.base_dn</id> <label>Base DN</label> <type>text</type> <help>Search base for user lookups</help> </field></form>Model Ldap.xml:
<model> <mount>//OPNsense/freeradius/ldap</mount> <description>FreeRADIUS LDAP configuration</description> <version>1.0.0</version> <items> <protocol type="OptionField"> <Default>ldap</Default> <Required>Y</Required> <OptionValues> <ldap>LDAP</ldap> <ldaps>LDAPS</ldaps> </OptionValues> </protocol> <server type="TextField"> <Required>Y</Required> </server> <identity type="TextField"> <Required>Y</Required> </identity> <password type="TextField"> <Required>Y</Required> </password> <base_dn type="TextField"> <Required>Y</Required> </base_dn> </items></model>Part 3: Configuration Template
Section titled “Part 3: Configuration Template”The LDAP module config at /usr/local/etc/raddb/mods-enabled/ldap needs to be generated from the UI values. Copy the existing mods-enabled/ldap content into templates/OPNsense/Freeradius/mods-enabled-ldap.
+TARGETS:
mods-enabled-ldap:/usr/local/etc/raddb/mods-enabled/ldapTemplate logic — use Jinja2 conditionals to only output values when set:
{% if helpers.exists('OPNsense.freeradius.general.enabled') and OPNsense.freeradius.general.enabled == '1' %}{% if helpers.exists('OPNsense.freeradius.general.ldap_enabled') and OPNsense.freeradius.general.ldap_enabled == '1' %}
{% if helpers.exists('OPNsense.freeradius.ldap.server') and OPNsense.freeradius.ldap.server != "" %}server = '{{ OPNsense.freeradius.ldap.protocol }}://{{ OPNsense.freeradius.ldap.server }}'{% endif %}
{% if helpers.exists('OPNsense.freeradius.ldap.identity') and OPNsense.freeradius.ldap.identity != "" %}identity = '{{ OPNsense.freeradius.ldap.identity }}'{% endif %}
{% if helpers.exists('OPNsense.freeradius.ldap.password') and OPNsense.freeradius.ldap.password != "" %}password = {{ OPNsense.freeradius.ldap.password }}{% endif %}
{% if helpers.exists('OPNsense.freeradius.ldap.base_dn') and OPNsense.freeradius.ldap.base_dn != "" %}base_dn = '{{ OPNsense.freeradius.ldap.base_dn }}'{% endif %}
{% endif %}{% endif %}Template pattern:
!= ""for text fields → only output when user filled something in== '1'for checkboxes → only output when enabled- The outer
ifchecks FreeRADIUS is enabled; innerifchecks LDAP sub-feature is enabled
Part 4: Add Enable Checkbox, Build, and Debug
Section titled “Part 4: Add Enable Checkbox, Build, and Debug”We need a checkbox in the General tab to toggle LDAP on/off, so LDAP config is only generated when the user explicitly enables it.
Add field to forms/general.xml (third field):
<field> <id>freeradius.general.ldap_enabled</id> <label>Enable LDAP</label> <type>checkbox</type> <help>This allows you to bind to an external LDAP server. Use the LDAP submenu for configuration.</help></field>Add to model General.xml (third field):
<ldap_enabled type="BooleanField"> <Default>0</Default> <Required>N</Required></ldap_enabled>No Volt template changes needed for a simple checkbox.
Build and install:
pkg install gitgit clone https://github.com/mimugmail/pluginscd pluginsgit checkout frldap # if using a branchcd net/freeradiusmake packagepkg install work/pkg/os-*.txzIf the stable
os-freeradiuspackage is already installed, remove it first:pkg remove os-freeradius
Common error during make package:
OPNsense configd.py: Inline action failed with OPNsense/FreeradiusOPNsense/Freeradius/mods-enabled-ldap tag name expectedCause: The original mods-enabled/ldap contains }} in its config:
user { base_dn = "${..base_dn}" filter = "(uid=%{%{Stripped-User-Name}:-%{User-Name}})"}The }} is interpreted as a Jinja2 closing tag. Fix: Wrap the entire default config content in {% raw %}...{% endraw %} — but keep the conditional if/endif blocks outside the raw block:
{% if helpers.exists('OPNsense.freeradius.general.ldap_enabled') and OPNsense.freeradius.general.ldap_enabled == '1' %}{% raw %}ldap { server = ... # ... all the standard FreeRADIUS LDAP module config ...}{% endraw %}{% endif %}After this fix, make package succeeds and the plugin installs cleanly.
Final result: FreeRADIUS now has a working LDAP submenu with protocol, server, identity, password, and base_dn fields. All values are persisted to config.xml and templated into /usr/local/etc/raddb/mods-enabled/ldap on save.
Submitting a Pull Request
Section titled “Submitting a Pull Request”- Remove
PLUGIN_DEVEL=YESfrom Makefile - Bump
PLUGIN_VERSIONappropriately, setPLUGIN_REVISION=1 - Write a meaningful
pkg-descr(copy from the project’s description page) - Test on a clean OPNsense install
- Push to your fork
- Create PR against
opnsense/plugins
From mimugmail: “If you are nearly complete, you can also create a PR and ask the guys via GitHub to help. I had no idea how to manage templating either — here’s the issue where I asked: https://github.com/opnsense/core/issues/2022. As you can see, it doesn’t have to be perfect from the beginning.”
Multi-language Support
Section titled “Multi-language Support”OPNsense uses gettext for translations. Wrap user-visible strings:
In Volt templates:
{{ lang._('Save') }}{{ lang._('Enable this feature') }}In PHP:
<?= gettext('Configuration saved') ?>In PHP with variables:
<?= sprintf(gettext('Restarted %s processes'), $count) ?>Package Naming & Versioning
Section titled “Package Naming & Versioning”All OPNsense plugins follow the os- prefix convention. The versioning scheme:
os-helloworld-1.4_2.pkg│ │ ││ │ └─ PLUGIN_REVISION (packaging revision)│ └─── PLUGIN_VERSION (plugin version)└───────────────── Always os- prefixed (handled by build system)PLUGIN_VERSION: Your plugin version. Semantic versioning encouraged.PLUGIN_REVISION: Bump when the packaging changes but code is the same. Reset to 1 on version bump.
Hosting Your Own Plugin Repository
Section titled “Hosting Your Own Plugin Repository”You don’t need to submit your plugin to the official opnsense/plugins repo. You can manage everything in your own Git repository and distribute it via a custom package repository — just like mimugmail’s community repo.
Three approaches, from simplest to most production-ready:
Option A: Manual Install (No Repo)
Section titled “Option A: Manual Install (No Repo)”Build and install directly — best for single firewalls or testing.
# On your build machinecd your-plugins-repo/net-mgmt/mypluginmake package# Copy to firewallscp work/pkg/os-*.pkg root@firewall:/tmp/ssh root@firewall pkg install /tmp/os-*.pkgPros: Zero infrastructure. Cons: Manual updates, no version tracking.
Option B: Self-Hosted pkg Repository
Section titled “Option B: Self-Hosted pkg Repository”Create a proper FreeBSD package repository that users add to their OPNsense. This is the standard approach.
Step 1: Build all your plugin packages
# In your plugins repocd /path/to/your-pluginsfor dir in category/*/; do make -C "$dir" packagedone
# Collect all built packagesmkdir -p /tmp/repo-packagesfind . -name 'os-*.pkg' -exec cp {} /tmp/repo-packages/ \;Step 2: Generate the repository metadata
pkg repo /tmp/repo-packagesThis creates:
/tmp/repo-packages/├── packagesite.txz # Package catalog├── packagesite.yaml # Human-readable index├── meta.txz # Repository metadata├── meta.conf # Repo config├── digests.txz # Package checksums├── filesite.txz # File listing└── All/ # All .pkg files └── os-myplugin-0.1_1.pkgStep 3: Host on a web server
Upload the entire directory to any static file host:
| Host | Example URL |
|---|---|
| Nginx/Apache on your server | https://repo.example.com/opnsense/ |
| Cloudflare R2 | https://pub-xxx.r2.dev/opnsense/ |
| GitHub Releases | Upload as release assets |
| AWS S3 + CloudFront | https://cdn.example.com/opnsense/ |
Critical: The server MUST support HTTP range requests (for partial package downloads).
Step 4: Create a repo config file
Create myrepo.conf:
myrepo: { url: "https://repo.example.com/opnsense/${ABI}", priority: 5, enabled: yes}${ABI}resolves to the OPNsense ABI string (e.g.,FreeBSD:14:amd64)- Place packages at
https://repo.example.com/opnsense/FreeBSD:14:amd64/
Step 5: Users install the repo
# On the OPNsense firewallfetch -o /usr/local/etc/pkg/repos/myrepo.conf https://repo.example.com/myrepo.confpkg updateStep 6: Install your plugin
pkg install os-mypluginYour plugin now appears in the OPNsense UI immediately after install — no reboot needed.
Keeping your repo updated:
# 1. Rebuild packages with new PLUGIN_VERSIONmake package
# 2. Copy new .pkg to repo directory, remove old versionscp work/pkg/os-myplugin-1.0_1.pkg /var/www/repo/FreeBSD:14:amd64/All/rm /var/www/repo/FreeBSD:14:amd64/All/os-myplugin-0.9_*.pkg
# 3. Regenerate repo metadatapkg repo /var/www/repo/FreeBSD:14:amd64/Real-world example: mimugmail’s opn-repo uses this exact pattern. Their repo config:
https://www.routerperformance.net/mimugmail.conf. Install command:fetch -o /usr/local/etc/pkg/repos/mimugmail.conf https://www.routerperformance.net/mimugmail.conf
Option C: Keep Source in Your Own Git Repo
Section titled “Option C: Keep Source in Your Own Git Repo”You don’t need to fork opnsense/plugins at all. Structure your own repo following the same layout:
your-opnsense-plugins/├── Makefile # Root Makefile (optional, for batch builds)├── README.md├── Mk/│ └── plugins.mk # Copy from opnsense/plugins├── net-mgmt/│ └── myplugin/│ ├── Makefile│ ├── pkg-descr│ └── src/opnsense/...├── security/│ └── myfirewall/│ ├── Makefile│ └── src/opnsense/...└── www/ └── myproxy/ ├── Makefile └── src/opnsense/...Build with OPNsense tools:
The Mk/plugins.mk from opnsense/plugins includes all the build logic. Copy it to your repo. To build, you still need the OPNsense build environment (FreeBSD 14 VM with opnsense/tools).
CI/CD approach:
If you set up a FreeBSD build VM, you can automate the entire pipeline:
# .github/workflows/build.yml (runs on self-hosted FreeBSD runner)steps: - run: | cd your-plugins for dir in */*/; do make -C "$dir" package; done - run: | mkdir -p repo/FreeBSD:14:amd64 find . -name 'os-*.pkg' -exec cp {} repo/FreeBSD:14:amd64/All/ \; pkg repo repo/FreeBSD:14:amd64/ - uses: actions/upload-artifact@v4 with: path: repo/Then sync the artifacts to your web server or serve directly from GitHub Releases.
GitHub Actions on FreeBSD note: You’ll need a self-hosted runner on FreeBSD since GitHub’s hosted runners are Linux/macOS/Windows only. Alternatively, build in a FreeBSD VM and upload manually.
Quick Comparison
Section titled “Quick Comparison”| Approach | Setup time | Maintenance | Best for |
|---|---|---|---|
| Manual install | 5 min | Manual every update | 1-2 firewalls, internal tools |
| Self-hosted repo | 30 min | Update + pkg repo per release | Team/department deployment |
| Git repo + CI/CD | 2-3 hours | Push → build → deploy | Production, multiple users/customers |
| Official PR | Days (review) | Zero (OPNsense team maintains) | General-purpose plugins |
Walkthrough C: Repository Manager Plugin
Section titled “Walkthrough C: Repository Manager Plugin”A plugin that lets you manage custom pkg repositories from the OPNsense web UI — no SSH needed. Inspired by the m.a.x. it professional plugins. This is a practical project that ties together everything from the guide above.
What it does: Instead of manually creating /usr/local/etc/pkg/repos/*.conf files and running pkg update via SSH, you get a UI panel to add, edit, enable/disable, and remove custom repos.
Design
Section titled “Design”This plugin is simpler than LLDP or FreeRADIUS — no daemon to control, no complex config generation. Just:
- Form — name, URL, priority, enabled toggle
- Model — stores repo list in config.xml
- Template — generates
.conffiles from model data - configd action — runs
pkg updateafter changes
┌─────────────────────────────────────┐│ Services → Repo Manager ││ ││ ┌──────────────────────────────┐ ││ │ [+] Add Repository │ ││ │ │ ││ │ ☑ myrepo https://... [✎] │ ││ │ ☐ testrepo https://... [✎] │ ││ │ │ ││ │ [Save] [Update All Repos] │ ││ └──────────────────────────────┘ ││ ││ ┌─ Output ─────────────────────┐ ││ │ pkg update output here... │ ││ └──────────────────────────────┘ │└─────────────────────────────────────┘Step 1: Directory Setup
Section titled “Step 1: Directory Setup”mkdir -p src/opnsense/mvc/app/{controllers,models,views}/OPNsense/RepoManagermkdir -p src/opnsense/mvc/app/controllers/OPNsense/RepoManager/{Api,forms}mkdir -p src/opnsense/mvc/app/models/OPNsense/RepoManager/{ACL,Menu}mkdir -p src/opnsense/service/{conf/actions.d,templates/OPNsense/RepoManager}Step 2: Model
Section titled “Step 2: Model”models/OPNsense/RepoManager/RepoManager.php:
<?phpnamespace OPNsense\RepoManager;
use OPNsense\Base\BaseModel;
class RepoManager extends BaseModel{}models/OPNsense/RepoManager/RepoManager.xml:
<model> <mount>//OPNsense/repomanager</mount> <description>Custom pkg repository manager</description> <version>1.0.0</version> <items> <repos> <repo type="ArrayField"> <Required>N</Required> <enabled type="BooleanField"> <Default>1</Default> <Required>Y</Required> </enabled> <name type="TextField"> <Required>Y</Required> <mask>/^[a-zA-Z0-9_-]{1,32}$/u</mask> <ValidationMessage>Only alphanumeric, dash, underscore. Max 32 chars.</ValidationMessage> </name> <url type="TextField"> <Required>Y</Required> <mask>/^https?:\/\/.+/u</mask> <ValidationMessage>Must be a valid HTTP(S) URL.</ValidationMessage> </url> <priority type="IntegerField"> <Default>5</Default> <Required>Y</Required> <MinimumValue>1</MinimumValue> <MaximumValue>20</MaximumValue> </priority> </repo> </repos> </items></model>This creates a repeatable array of repo entries. Each entry has: name, URL, priority, enabled toggle, and optional authentication credentials.
Private repo support: Add a credentials container for repos that require authentication:
<credentials type="ContainerField"> <Required>N</Required> <username type="TextField"> <Required>N</Required> </username> <password type="TextField"> <Required>N</Required> </password></credentials>The Python script then embeds credentials in the URL if provided:
if creds.find('username') is not None and creds.find('username').text: from urllib.parse import quote user = creds.find('username').text pw = creds.find('password').text if creds.find('password') is not None else '' # Embed in URL for basic auth url_text = url_text.replace('://', f'://{quote(user)}:{quote(pw)}@')Step 3: Forms
Section titled “Step 3: Forms”controllers/OPNsense/RepoManager/forms/general.xml:
<form> <field> <id>repomanager.repos.repo.enabled</id> <label>Enabled</label> <type>checkbox</type> <help>Enable or disable this repository.</help> </field> <field> <id>repomanager.repos.repo.name</id> <label>Name</label> <type>text</type> <help>Short name for the repo (used as the .conf filename).</help> <hint>e.g. myrepo</hint> </field> <field> <id>repomanager.repos.repo.url</id> <label>URL</label> <type>text</type> <help>Repository URL. Use ${ABI} for ABI-dependent paths.</help> <hint>https://repo.example.com/opnsense/${ABI}</hint> </field> <field> <id>repomanager.repos.repo.priority</id> <label>Priority</label> <type>text</type> <help>Lower number = higher priority. Default is 5.</help> </field> <field> <id>repomanager.repos.repo.credentials.username</id> <label>Username (optional)</label> <type>text</type> <help>For private/authenticated repos. Leave empty for public repos.</help> </field> <field> <id>repomanager.repos.repo.credentials.password</id> <label>Password (optional)</label> <type>password</type> <help>Repo password or access token.</help> </field></form>Step 4: Template — Generate .conf Files
Section titled “Step 4: Template — Generate .conf Files”Since we need dynamic filenames (one .conf per repo), the standard +TARGETS approach won’t work. We use a configd Python script instead that reads config.xml and writes each .conf file directly.
service/conf/actions.d/actions_repomanager.conf:
[generate]command:/usr/local/opnsense/scripts/repomanager/generate_repos.pyparameters:type:script_outputmessage:generating repo config files
[update]command:/usr/sbin/pkg updateparameters:type:script_outputmessage:updating pkg repositoriesscripts/repomanager/generate_repos.py:
#!/usr/local/bin/python3"""Generate /usr/local/etc/pkg/repos/*.conf from config.xml"""import xml.etree.ElementTree as ETimport jsonimport os
CONFIG = '/conf/config.xml'REPO_DIR = '/usr/local/etc/pkg/repos'
# Parse config.xmltree = ET.parse(CONFIG)root = tree.getroot()
# Find repomanager sectionrepos = root.findall(".//repomanager/repos/repo")
# Remove old conf filesfor f in os.listdir(REPO_DIR): if f.endswith('.conf') and f not in ['OPNsense.conf']: # Only remove files that match our managed repos pass # Be careful here
# Generate new onesfor repo in repos: enabled = repo.find('enabled') name = repo.find('name') url = repo.find('url') priority = repo.find('priority') creds = repo.find('credentials')
if enabled is None or name is None or url is None: continue if enabled.text != '1': continue
url_text = url.text # Embed credentials if provided (for private repos) if creds is not None: cred_user = creds.find('username') cred_pass = creds.find('password') if cred_user is not None and cred_user.text: from urllib.parse import quote user = quote(cred_user.text, safe='') pw = quote(cred_pass.text, safe='') if cred_pass is not None and cred_pass.text else '' url_text = url_text.replace('://', f'://{user}:{pw}@')
conf_path = os.path.join(REPO_DIR, f"{name.text}.conf") content = f"""{name.text}: {{ url: "{url_text}", priority: {priority.text or '5'}, enabled: yes}}""" with open(conf_path, 'w') as f: f.write(content)
print(json.dumps({"status": "ok", "generated": len(repos)}))Step 5: Controllers
Section titled “Step 5: Controllers”controllers/OPNsense/RepoManager/IndexController.php:
<?phpnamespace OPNsense\RepoManager;
class IndexController extends \OPNsense\Base\IndexController{ public function indexAction() { $this->view->generalForm = $this->getForm("general"); $this->view->pick('OPNsense/RepoManager/index'); }}controllers/OPNsense/RepoManager/Api/SettingsController.php:
<?phpnamespace OPNsense\RepoManager\Api;
use \OPNsense\Base\ApiMutableModelControllerBase;
class SettingsController extends ApiMutableModelControllerBase{ protected static $internalModelClass = '\OPNsense\RepoManager\RepoManager'; protected static $internalModelName = 'repomanager';}controllers/OPNsense/RepoManager/Api/ServiceController.php:
<?phpnamespace OPNsense\RepoManager\Api;
use \OPNsense\Base\ApiControllerBase;use \OPNsense\Core\Backend;
class ServiceController extends ApiControllerBase{ public function generateAction() { if ($this->request->isPost()) { $backend = new Backend(); $response = $backend->configdRun("repomanager generate"); return json_decode($response, true) ?: ["status" => "error"]; } return ["status" => "failed"]; }
public function updateAction() { if ($this->request->isPost()) { $backend = new Backend(); $response = $backend->configdRun("repomanager update"); return ["output" => $response]; } return ["output" => ""]; }}Step 6: View
Section titled “Step 6: View”views/OPNsense/RepoManager/index.volt:
<script type="text/javascript"> $( document ).ready(function() { // Load repo list mapDataToFormUI({'frm_repos':"/api/repomanager/settings/get"});
// Save to config.xml $("#saveAct").click(function(){ saveFormToEndpoint("/api/repomanager/settings/set",'frm_repos',function(){ // After save, regenerate .conf files ajaxCall(url="/api/repomanager/service/generate", sendData={},callback=function(data,status) { $("#genStatus").html(data['status'] == 'ok' ? '✓ Configs generated' : '✗ Error'); }); }); });
// Update pkg repos $("#updateAct").click(function(){ $("#updateOutput").html("Running pkg update..."); ajaxCall(url="/api/repomanager/service/update", sendData={},callback=function(data,status) { $("#updateOutput").text(data['output']); }); }); });</script>
<div class="col-md-12"> <button class="btn btn-primary" id="saveAct" type="button"> <b>{{ lang._('Save') }}</b> </button> <button class="btn btn-default" id="updateAct" type="button"> <b>{{ lang._('Update Repositories') }}</b> </button> <span id="genStatus" style="margin-left:10px;"></span></div>
<br/>{{ partial("layout_partials/base_form",['fields':generalForm,'id':'frm_repos'])}}
<br/><div><pre id="updateOutput" style="max-height:300px;overflow-y:scroll;"></pre></div>Step 7: Menu & ACL
Section titled “Step 7: Menu & ACL”models/OPNsense/RepoManager/Menu/Menu.xml:
<menu> <System order="60"> <RepoManager VisibleName="Repo Manager" url="/ui/repomanager/" cssClass="fa fa-database fa-fw"/> </System></menu>models/OPNsense/RepoManager/ACL/ACL.xml:
<acl> <page-system-repomanager> <name>WebCfg - System: Repo Manager</name> <description>Allow access to the custom repository manager</description> <patterns> <pattern>ui/repomanager/*</pattern> <pattern>api/repomanager/*</pattern> </patterns> </page-system-repomanager></acl>Step 8: Makefile
Section titled “Step 8: Makefile”PLUGIN_NAME= repomanagerPLUGIN_VERSION= 1.0PLUGIN_REVISION= 1PLUGIN_COMMENT= Manage custom pkg repositories from the web UIPLUGIN_MAINTAINER= [email protected]PLUGIN_DEVEL= YES
.include "../../Mk/plugins.mk"Result
Section titled “Result”After installing os-repomanager, users get:
- Add/remove custom repos from the web UI
- Enable/disable repos without deleting them
- Regenerate
.conffiles on save - Run
pkg updatewith one click and see output inline - No SSH needed for repo management
This is a ~200 line plugin. The core logic is the Python script that reads config.xml and writes .conf files — the rest is standard MVC boilerplate.
Extension ideas: Add a “Test connection” button, show last
pkg updatetimestamp, support for repo fingerprints/signing keys, batch enable/disable.
Appendix: Hello World Plugin — Full Source Listing
Section titled “Appendix: Hello World Plugin — Full Source Listing”Complete, build-ready source for a minimal OPNsense plugin. Every file shown in full — copy into your plugins directory, fetch the real
Mk/plugins.mk, andmake package. This is the simplest possible plugin with the entire MVC + configd stack working end-to-end.
File Tree
Section titled “File Tree”plugins/devel/helloworld/├── Makefile├── pkg-descr└── src/opnsense/ ├── mvc/app/ │ ├── controllers/OPNsense/HelloWorld/ │ │ ├── IndexController.php │ │ ├── Api/ │ │ │ ├── SettingsController.php │ │ │ └── ServiceController.php │ │ └── forms/general.xml │ ├── models/OPNsense/HelloWorld/ │ │ ├── HelloWorld.php │ │ ├── HelloWorld.xml │ │ ├── ACL/ACL.xml │ │ └── Menu/Menu.xml │ └── views/OPNsense/HelloWorld/ │ └── index.volt ├── scripts/helloworld/ │ └── test_message.py └── service/ ├── conf/actions.d/ │ └── actions_helloworld.conf └── templates/OPNsense/HelloWorld/ ├── +TARGETS └── helloworld.confSource Code
Section titled “Source Code”Makefile — plugin metadata:
PLUGIN_NAME= helloworldPLUGIN_VERSION= 1.0PLUGIN_REVISION= 1PLUGIN_COMMENT= Hello World test plugin — says hello from configdPLUGIN_MAINTAINER= [email protected]PLUGIN_DEVEL= YES
.include "../../Mk/plugins.mk"pkg-descr — package description:
A simple Hello World plugin for OPNsense, demonstrating the MVC + configdarchitecture. Includes a form, service control, and a configd-powered actionthat returns JSON.src/opnsense/mvc/app/models/OPNsense/HelloWorld/HelloWorld.php — model class:
<?phpnamespace OPNsense\HelloWorld;
use OPNsense\Base\BaseModel;
class HelloWorld extends BaseModel{}src/opnsense/mvc/app/models/OPNsense/HelloWorld/HelloWorld.xml — data schema:
<model> <mount>//OPNsense/helloworld</mount> <description>Hello World test plugin</description> <version>1.0.0</version> <items> <general> <Enabled type="BooleanField"> <Default>1</Default> <Required>Y</Required> </Enabled> <Greeting type="TextField"> <Default>Hello from OPNsense!</Default> <Required>Y</Required> </Greeting> <Mood type="OptionField"> <Default>happy</Default> <Required>Y</Required> <OptionValues> <happy>😊 Happy</happy> <excited>🚀 Excited</excited> <debugging>🐛 Debugging</debugging> </OptionValues> </Mood> </general> </items></model>src/opnsense/mvc/app/models/OPNsense/HelloWorld/ACL/ACL.xml — access control:
<acl> <page-user-helloworld> <name>WebCfg - Users: Hello World!</name> <description>Allow access to the Hello World module</description> <patterns> <pattern>ui/helloworld/*</pattern> <pattern>api/helloworld/*</pattern> </patterns> </page-user-helloworld></acl>src/opnsense/mvc/app/models/OPNsense/HelloWorld/Menu/Menu.xml — sidebar entry:
<menu> <User order="99"> <HelloWorld VisibleName="Hello World" url="/ui/helloworld/" cssClass="fa fa-smile-o fa-fw" /> </User></menu>src/opnsense/mvc/app/controllers/OPNsense/HelloWorld/IndexController.php — page routing:
<?phpnamespace OPNsense\HelloWorld;
class IndexController extends \OPNsense\Base\IndexController{ public function indexAction() { $this->view->generalForm = $this->getForm("general"); $this->view->pick('OPNsense/HelloWorld/index'); }}src/opnsense/mvc/app/controllers/OPNsense/HelloWorld/forms/general.xml — UI form:
<form> <field> <id>helloworld.general.Enabled</id> <label>Enabled</label> <type>checkbox</type> <help>Enable the Hello World service.</help> </field> <field> <id>helloworld.general.Greeting</id> <label>Greeting Message</label> <type>text</type> <help>The message to greet with.</help> </field> <field> <id>helloworld.general.Mood</id> <label>Mood</label> <type>dropdown</type> <help>Current developer mood.</help> </field></form>src/opnsense/mvc/app/controllers/OPNsense/HelloWorld/Api/SettingsController.php — config get/set:
<?phpnamespace OPNsense\HelloWorld\Api;
use \OPNsense\Base\ApiMutableModelControllerBase;
class SettingsController extends ApiMutableModelControllerBase{ protected static $internalModelClass = '\OPNsense\HelloWorld\HelloWorld'; protected static $internalModelName = 'helloworld';}src/opnsense/mvc/app/controllers/OPNsense/HelloWorld/Api/ServiceController.php — backend actions:
<?phpnamespace OPNsense\HelloWorld\Api;
use \OPNsense\Base\ApiControllerBase;use \OPNsense\Core\Backend;
class ServiceController extends ApiControllerBase{ /** * reload template */ public function reloadAction() { $status = "failed"; if ($this->request->isPost()) { $status = strtolower(trim( (new Backend())->configdRun('template reload OPNsense/HelloWorld') )); } return ["status" => $status]; }
/** * run the test action via configd */ public function testAction() { if ($this->request->isPost()) { $result = json_decode( trim((new Backend())->configdRun("helloworld test")), true ); if ($result !== null) { return $result; } } return ["message" => "unable to run config action"]; }}src/opnsense/mvc/app/views/OPNsense/HelloWorld/index.volt — page template:
<script type="text/javascript"> $( document ).ready(function() { // Load saved data into form mapDataToFormUI({'frm_GeneralSettings':"/api/helloworld/settings/get"});
// Save button $("#saveAct").click(function(){ saveFormToEndpoint("/api/helloworld/settings/set",'frm_GeneralSettings',function(){ ajaxCall(url="/api/helloworld/service/reload", sendData={},callback=function(data,status) { console.log("Template reload: " + data['status']); }); }); });
// Test button — runs configd action and displays JSON result $("#testAct").SimpleActionButton({ onAction: function(data) { $("#responseMsg").removeClass("hidden").html( "<strong>" + data['message'] + "</strong><br/>" + "Greeting: " + data['greeting'] + "<br/>" + "Mood: " + data['mood'] ); } }); });</script>
<div class="col-md-12"> <button class="btn btn-primary" id="saveAct" type="button"> <b>{{ lang._('Save') }}</b> </button> <button class="btn btn-default" id="testAct" data-endpoint="/api/helloworld/service/test" data-label="{{ lang._('Test') }}"> </button></div>
<br/><div class="alert alert-info hidden" role="alert" id="responseMsg"></div>
{{ partial("layout_partials/base_form",['fields':generalForm,'id':'frm_GeneralSettings'])}}src/opnsense/scripts/helloworld/test_message.py — configd worker that reads config and returns JSON:
#!/usr/local/bin/python3"""Hello World test script — reads config and returns JSON."""import xml.etree.ElementTree as ETimport json
CONFIG = '/conf/config.xml'
tree = ET.parse(CONFIG)root = tree.getroot()
# Defaultsenabled = "0"greeting = "Hello from OPNsense!"mood = "unknown"
# Read our config from config.xmlnode = root.find(".//helloworld/general")if node is not None: e = node.find("Enabled") if e is not None and e.text: enabled = e.text g = node.find("Greeting") if g is not None and g.text: greeting = g.text m = node.find("Mood") if m is not None and m.text: mood = m.text
result = { "message": f"Hello World plugin is {'enabled' if enabled == '1' else 'disabled'}!", "greeting": greeting, "mood": mood}
print(json.dumps(result))src/opnsense/service/conf/actions.d/actions_helloworld.conf — configd action registry:
[test]command:/usr/local/opnsense/scripts/helloworld/test_message.pyparameters:type:script_outputmessage:hello world test actionsrc/opnsense/service/templates/OPNsense/HelloWorld/+TARGETS — template output mapping:
helloworld.conf:/usr/local/etc/helloworld/helloworld.confsrc/opnsense/service/templates/OPNsense/HelloWorld/helloworld.conf — Jinja2 config generator:
{% if helpers.exists('OPNsense.helloworld.general.Enabled') and OPNsense.helloworld.general.Enabled == '1' %}# HelloWorld Configuration# Generated by OPNsense configdenabled=1greeting={{ OPNsense.helloworld.general.Greeting|default("Hello!") }}mood={{ OPNsense.helloworld.general.Mood|default("happy") }}{% endif %}Setup on First Use
Section titled “Setup on First Use”# Clone (or copy these files) into your plugins directorymkdir -p /usr/plugins/devel/helloworld# ... copy all files above ...
# One-time: get the real build infrastructurefetch -o /usr/plugins/Mk/plugins.mk \ https://raw.githubusercontent.com/opnsense/plugins/master/Mk/plugins.mk
# Buildcd /usr/plugins/devel/helloworldmake package
# Installpkg install work/pkg/os-helloworld-*.pkg
# Clear caches (first install only)rm -f /tmp/opnsense_menu_cache.xmlrm -f /tmp/opnsense_acl_cache.jsonBrowse to https://<firewall>/ui/helloworld/. You’ll see:
- A form with Enabled (checkbox), Greeting (text), Mood (dropdown 😊🚀🐛)
- Save button — persists to config.xml, triggers template regeneration
- Test button — fires the configd action, displays JSON result inline
What This Plugin Demonstrates
Section titled “What This Plugin Demonstrates”| Layer | File(s) | What it shows |
|---|---|---|
| Model | HelloWorld.php + .xml | Minimal BaseModel, mount path, field types (BooleanField, TextField, OptionField with values) |
| View | index.volt | Standard OPNsense JS patterns: mapDataToFormUI, saveFormToEndpoint, SimpleActionButton |
| Controller | IndexController.php | Page routing, form injection |
| API — Settings | SettingsController.php | ApiMutableModelControllerBase auto-generates get/set endpoints |
| API — Service | ServiceController.php | Manual actions: template reload + configd invocation with JSON parsing |
| Forms | general.xml | Field IDs matching model mount path |
| ACL | ACL.xml | URL patterns for UI + API, unique ACL key |
| Menu | Menu.xml | Sidebar placement, Font Awesome icon |
| configd | actions_helloworld.conf | Action type script_output, registering Python scripts |
| Script | test_message.py | Reading config.xml from disk, parsing XML, returning JSON to configd |
| Template | helloworld.conf + +TARGETS | Jinja2 conditionals, helpers.exists(), ` |
From here, you can adapt this exact skeleton to build your real plugin:
- Add more fields to the form and model
- Replace
test_message.pywith your actual backend logic - Switch
ApiControllerBase→ApiMutableServiceControllerBaseif you need daemon control - Add more tabs to the volt template for multi-page plugins