The goal of the “Hello world” module we’re creating in the example is to
control a program on our system named “testConnection.py”, which is part
of the example package available on GitHub. It will try to send an email
using plain smtp and will respond with a json text message about the
result of that attempt.

Our application will need some settings to operate correctly, like an ip
address and an email address and we need to be able to run that
application. Because this application returns some valuable data for our
users, we need to be able to fetch the response data back.

Always make sure there’s a clear separation of concerns, back-end calls
(like shell scripts) should be implemented using the configd system, all
communication to the client should be handled from an api endpoint. (the
example provides more insights on how this works).

Back-end programs should not access the config.xml directly, if data is
needed let the template system handle the desired output (most
applications, daemons and tools deliver their own desired configuration
format). There’s generally no good reason to avoid the standards that
are already there.

If you follow this basic rules, you’re automatically building a command
structure for the system administrators and provide a connector to third
party tools to the API of your component (as of version 16.1).

The content of the mount tag is very important, this is the location
within the config.xml file where this model is responsible. Other models
cannot write data into the same area. You should name this location with
your vendor and module name to make sure others could easily identify
it.

Use the description tag to identify your model, the last tag in place is
the items tag, where the actual definition will live. We leave it empty
for now as we proceed with the next step of creating our skeleton.

Next step is to add controllers, which will be automatically picked up
by the system routing. A controller connects the user interaction to
logic and presentation.

Every OPNsense module should separate presentation from logic, that’s
why there should always be multiple controllers per module.

Our first controller handles the template rendering to the user and
connects the user view we just created. We start by creating a php file
in controllers/OPNsense/HelloWorld/ with the following name
IndexController.php and contents:

<?phpnamespaceOPNsense\HelloWorld;classIndexControllerextends\OPNsense\Base\IndexController{publicfunctionindexAction(){// pick the template to serve to our users.$this->view->pick('OPNsense/HelloWorld/index');}}

At this point you should be able to test if your work so far was
successful, by going to the following location (after being logged in to
the firewall as root user):

http[s]://<yourip>/ui/helloworld/

Which should serve you the “Hello World!” text you’ve added in the
template.

Next two controllers we are going to create are to be used for the api
to the system, they should take care of service actions and the
retrieval/changing of configuration data.

They should live in a subdirectory of the controller called Api and
extend the corresponding class.

For our modules we create two api controllers, one for controlling
settings and one for performing service actions. (Named
SettingsController.php and ServiceController.php) Both should look like
this (replace Settings with Service for the other one):

When building the skeleton, we have created an empty model (xml), which
we are going to fill with some attributes now. The items section of the
model xml should contain the structure you want to use for your
application, you can create trees to hold data in here. All leaves
should contain a field type to identify and validate it’s content. The
list of attributes for our application can be translated to this:

All available field types can be found in the
models/OPNsense/Base/FieldTypes directory. If specific field types
support additional parameters, for example for validation, they should
be registered in the model as well (just like the default tag in
Enabled).

Because creating forms is one of the key assets of the system, we have
build some easy to use wrappers to guide you through the process. First
we create an xml file for the presentation, which defines fields to use
and adds some information for your template to render. Create a file in
your controller directory using the sub directory forms and name it
general.xml. Next copy in the following content:

All items should contain at least an id (where to map data from/to), a
type (how to display) and a label, which identifies it to the user.
Optional you may add additional fields like help or mark features as
being only for advanced users. (The Volt template defines which
attributes are usable.)

Now we need to tell the controller to use this information and pass it
to your template, so change the IndexController.php and add this line:

$this->view->generalForm=$this->getForm("general");

And we are ready to update the (Volt) template with this information.
Let’s remove the “<h1>Hello World!</h1>” line and replace it with
something like this:

This tells the template system to add a form using the contents of
generalForm and name it frm_GeneralSettings in the html page. Based on
a standard template part which is already part of the standard system,
named base_form.volt.

The framework provides some helpful utilities to get and set data from
and to the configuration xml by using your defined model. First step in
binding your model to the system is to add a method to the
SettingsController to fetch the data from our configuration (or provide
the defaults if there is no content).

We start by adding the model to our SettingsController, by adding this
in the “use” section:

use\OPNsense\HelloWorld\HelloWorld;

Which includes our model into the controller. Next we create an action
to get data from our system, and put it into a json object for the
client (browser) to parse, by using the wrappers already in our model.

You will probably notice the return value of the action, it’s a standard
array which uses “helloworld” for all attributes from getNodes() which
will automatically be converted by the framework to a json object for
the client. The getNodes method itself returns a tree a values, as
defined by your model.

You can test the result (while logged in as root), by going to this
address:

http[s]://<yourip>/api/helloworld/settings/get

For saving the data back, we need a similar kind of call, let’s name it
“set” and add this to the same php file:

Now we need to link the events to the backend code to be able to load
and save our form, by using the OPNsense libraries you can validate your
data automatically.

Add this to the index.volt template from the HelloWorld module:

<scripttype="text/javascript">$(document).ready(function(){vardata_get_map={'frm_GeneralSettings':"/api/helloworld/settings/get"};mapDataToFormUI(data_get_map).done(function(data){// place actions to run after load, for example update form styles.});// link save button to API set action$("#saveAct").click(function(){saveFormToEndpoint(url="/api/helloworld/settings/set",formid='frm_GeneralSettings',callback_ok=function(){// action to run after successful save, for example reconfigure service.});});});</script><divclass="col-md-12"><buttonclass="btn btn-primary"id="saveAct"type="button"><b>{{ lang._('Save') }}</b></button></div>

The first piece of javascript code handles the loading of data when
opening the form, then a button is linked to the save event.

Let’s give it a try and save our data, without modifying it first.

Next correct the errors and save again, on successful save the data
should be stored in the config.xml. If you want to change validation
messages, just edit the model xml and add your message in the
ValidationMessage tag. For example:

Our basic module provides a way to read and modify configuration data
using the web interface (and in time also other consumers using the
api). Next step is to add some activity to our system, all backend
applications should use their own configuration, which in real life we
would keep as standard as possible.

For our example we will follow the same process as for any other service
and start writing some configuration data for our sample application.
Which means, creating a template and hooking it into our save action.

The configd system is responsible for updating the contents of that file
when requested, it does so by using a definition found in its template
folder. This sample will use the following path to store the backend
templates:

/usr/local/opnsense/service/templates/OPNsense/HelloWorld/

First we add a content definition, by creating a file named +TARGETS,
which should hold the following information:

helloworld.conf:/usr/local/etc/helloworld/helloworld.conf

This basically tells the engine that there will be a file in the same
folder named “helloworld.conf” which provides, together with config.xml,
data for the file in /usr/local/etc/helloworld/helloworld.conf

Next thing to do is create that helloworld.conf file in the templates
directory. We will keep things very simple for this one and just copy in
our data into an ini file structured configuration, when the module is
enabled.

Now we need to be able to reload this module (or in real life, this
would probably be a service) by adding a service action into our
ServiceController. Edit
controllers/OPNsense/HelloWorld/Api/ServiceController.php and add the
backend module to the use section, like this:

use\OPNsense\Core\Backend;

By doing this we can use the backend communication in this class. Next
add a new action to the class called “reloadAction” using this piece of
code:

This validates the type of action (it should always be POST to enable
csrf protection) and adds a backend action for reloading the template.
When successful the action will return “status”:”ok” as json object back
to the client.

Now we are able to refresh the template content, but the user interface
doesn’t know about it yet. To hook loading of the template into the save
action, we will go back to the index.volt view and add the following
jQuery / framework code between the braces of “saveFormToEndPoint”.

ajaxCall(url="/api/helloworld/service/reload",sendData={},callback=function(data,status){// action to run after reload});

What have we accomplished now, we can input data, validate it and save
it to the corresponding format of the actual service or application,
which uses this data. So if you have a third party application, which
you want to integrate into the user interface. You should be able to
generate what it needs now. (there’s more to learn, but these are the
basics).

But how do should we control that third part program now? That’s the
next step.

In stead of running all kinds of shell commands directly from the php
code, which very often need root access (starting/stopping services,
etc), we should always communicate to our backend process which holds
templates of possible things to run and protects your system from
executing arbitrary commands.

Another advantage of this approach is that all commands defined here,
can also be ran from the command line of the firewall providing easier
serviceability. For example, the command to refresh the helloworld
configuration can be run from the command line by running:

configctltemplatereloadOPNsense/HelloWorld

First thing to do when registering new actions to the system for a new
application is to create a config template.

Let’s test our new command by restarting configd from the command line:

serviceconfigdrestart

And test our new command using:

configctlhelloworldtest

Which should return some response in json format.

Next step is to use this command in our controller (middleware), just
like we did with the template action. For consistency we call our action
testAction and let it pass json data to our clients when using a POST
type request.

And now we can make our user interface aware of the action, place a
button and link an action in the index.volt. Using the following
elements:

(in script section)

$("#testAct").click(function(){$("#responseMsg").removeClass("hidden");ajaxCall(url="/api/helloworld/service/test",sendData={},callback=function(data,status){// action to run after reload$("#responseMsg").html(data['message']);});});

OPNsense is available in may different languages like english, german or japanese.
This works because we are using the gettext library which is available to all GUI components.
While the XML based user interfaces are supporting it automatically,
there may still the need to call it manually (buttons, tabs etc.).

If you have a static string, you should add it like this into a classic php page:

<?=gettext('your string here')?>

And this way into a volt template:

{{lang._('your string here')}}

If your string is not only plaintext because it contains non-static words, HTML tags and other dynamic content,
you need to use a format string. This way, you can use placeholders for such elements which should not land in
the translation file.

For php it works this way:

<?=sprintf(gettext('your %s here'),$data)?>

And for volt templates it works this way:

{{lang._('your %s here')|format(data)}}

Note

You should NEVER split strings which should belong together like a sentence.
This makes plugins hard to translate and will decrease the quality of OPNsense in other languages.

If we want to authorize users to access this module, we can add an acl
to this module. Without it, only admin users can access it. Create an
xml file in the model directory name ACL/ACL.xml and place the following
content in it:

All files are created in their original locations (on the OPNsense
machine /usr/local/…), now we are ready to create a package from them.
To fully use this process and create the actual package, it’s best to
setup a full build environment (explained over here:
https://github.com/opnsense/tools )

When everything is in place, we will create a new plugin directory. For
this example we will use the following:

With everything in place, you could build the plugin package using the
“make plugins” command in the /usr/tools directory. The result of this
will be a standard pkg package, which you can install on any OPNsense
system and will be usable right after installing. All plugins are
prefixed with os-, our new package file will be called: