Basic concepts¶
Before starting, let’s quickly introduce the main concepts used in netjsonconfig:
- NetJSON configuration dictionary: python dictionary representing the configuration of a router
- Backend: python class used to convert the configuration dictionary to the format used natively by a router firmware and vice versa
- Schema: each backend has a JSON-Schema which defines the useful configuration options that the backend is able to process
- Validation: the configuration is validated against its JSON-Schema before being processed by the backend
- Template: common configuration options shared among routers (eg: VPNs, SSID) which can be passed to backends
- Multiple template inheritance: possibility to inherit common configuration options from more than one template
- Context (configuration variables): variables that can be referenced from the configuration dictionary
NetJSON configuration dictionary¶
netjsonconfig is an implementation of the NetJSON format,
more specifically the DeviceConfiguration
object, therefore to understand the
configuration format that the library uses to generate the final router configurations
it is essential to read at least the relevant DeviceConfiguration section in the
NetJSON RFC.
Here it is a simple NetJSON DeviceConfiguration object represented with a python dictionary:
{
"type": "DeviceConfiguration",
"general": {
"hostname": "RouterA"
},
"interfaces": [
{
"name": "eth0",
"type": "ethernet",
"addresses": [
{
"address": "192.168.1.1",
"mask": 24,
"proto": "static",
"family": "ipv4"
}
]
}
]
}
The previous example describes a device named RouterA
which has a single
network interface named eth0
with a statically assigned ip address 192.168.1.1/24
(CIDR notation).
Because netjsonconfig deals only with DeviceConfiguration
objects, the type
attribute can be omitted.
The previous configuration object therefore can be shortened to:
{
"general": {
"hostname": "RouterA"
},
"interfaces": [
{
"name": "eth0",
"type": "ethernet",
"addresses": [
{
"address": "192.168.1.1",
"mask": 24,
"proto": "static",
"family": "ipv4"
}
]
}
]
}
From now on we will use the term configuration dictionary to refer to NetJSON DeviceConfiguration objects.
Backend¶
A backend is a python class used to convert the configuration dictionary to the format used natively by the router (forward conversion, from NetJSON to native) and vice versa (backward conversion, from native to NetJSON), each supported firmware or opearting system will have its own backend and third parties can write their own custom backends.
The current implemented backends are:
- OpenWrt
- OpenWisp (based on the
OpenWrt
backend)- OpenVpn (custom backend implementing only OpenVPN configuration)
- WireGuard (custom backend implementing only WireGuard configuration)
- VXLAN over WireGuard (custom backend implementing only VXLAN over WireGuard configuration)
Example initialization of OpenWrt
backend:
from netjsonconfig import OpenWrt
ipv6_router = OpenWrt({
"interfaces": [
{
"name": "eth0.1",
"type": "ethernet",
"addresses": [
{
"address": "fd87::1",
"mask": 128,
"proto": "static",
"family": "ipv6"
}
]
}
]
})
Each backend will implement parsers, renderers and converters to accomplish its configuration generation or parsing goals.
The process is best explained with the following diagram:
Converters take care of converting between NetJSON and the intermediate data structure (and vice versa).
Renderers take care of rendering the intermediate data structure to the native format.
Parsers perform the opposite operation of Renderers
: they take care of parsing native format and
build the intermediate data structure.
Schema¶
Each backend has a JSON-Schema, all the backends have a schema which is derived
from the same parent schema, defined in netjsonconfig.backends.schema
(view source).
Since different backends may support different features each backend may extend its schema by adding custom definitions.
Validation¶
All the backends have a validate
method which is called automatically before
trying to process the configuration.
If the passed configuration violates the schema the validate
method will raise
a ValidationError
.
An instance of validation error has two public attributes:
message
: a human readable message explaining the errordetails
: a reference to the instance ofjsonschema.exceptions.ValidationError
which contains more details about what has gone wrong; for a complete reference see the python-jsonschema documentation
You may call the validate
method in your application arbitrarily, eg: before
trying to save the configuration dictionary into a database.
Template¶
If you have devices with very similar configuration dictionaries you can store the shared blocks in one or more reusable templates which will be used as a base to build the final configuration.
Combining different templates¶
Let’s illustrate this with a practical example, we have two devices:
- Router1
- Router2
Both devices have an eth0
interface in DHCP mode; Router2 additionally has
an eth1
interface with a statically assigned ipv4 address.
The two routers can be represented with the following code:
from netjsonconfig import OpenWrt
router1 = OpenWrt({
"general": {"hostname": "Router1"}
"interfaces": [
{
"name": "eth0",
"type": "ethernet",
"addresses": [
{
"proto": "dhcp",
"family": "ipv4"
}
]
}
]
})
router2 = OpenWrt({
"general": {"hostname": "Router2"},
"interfaces": [
{
"name": "eth0",
"type": "ethernet",
"addresses": [
{
"proto": "dhcp",
"family": "ipv4"
}
]
},
{
"name": "eth1",
"type": "ethernet",
"addresses": [
{
"address": "192.168.1.1",
"mask": 24,
"proto": "static",
"family": "ipv4"
}
]
}
]
})
The two configuration dictionaries share the same settings for the eth0
interface, therefore we can make the eth0
settings our template and
refactor the previous code as follows:
from netjsonconfig import OpenWrt
dhcp_template = {
"interfaces": [
{
"name": "eth0",
"type": "ethernet",
"addresses": [
{
"proto": "dhcp",
"family": "ipv4"
}
]
}
]
}
router1 = OpenWrt(config={"general": {"hostname": "Router1"}},
templates=[dhcp_template])
router2_config = {
"general": {"hostname": "Router2"},
"interfaces": [
{
"name": "eth1",
"type": "ethernet",
"addresses": [
{
"address": "192.168.1.1",
"mask": 24,
"proto": "static",
"family": "ipv4"
}
]
}
]
}
router2 = OpenWrt(router2_config, templates=[dhcp_template])
Overriding a template¶
In many occasions you may want to define a general template which can be overridden in some specific occasions.
A common use case is to define a general radio template and override its channel on certain access points:
from netjsonconfig import OpenWrt
general_radio_template = {
"radios": [
{
"name": "radio0",
"phy": "phy0",
"protocol": "802.11n",
"driver": "mac80211",
"channel": 0, # zero means "auto"
"channel_width": 20,
"country": "US",
"disabled": False
}
]
}
specific_radio_config = {
"radios": [
{
"name": "radio0",
"channel": 10,
}
]
}
router1 = OpenWrt(config=specific_radio_config,
templates=[general_radio_template])
print(router1.render())
Will generater the following output:
package wireless
config wifi-device 'radio0'
option channel '10'
option country 'US'
option disabled '0'
option htmode 'HT20'
option hwmode '11g'
option phy 'phy0'
option type 'mac80211'
Multiple template inheritance¶
You might have noticed that the templates
argument is a list; that’s because
it’s possible to pass multiple templates that will be added one on top of the
other to build the resulting configuration dictionary, allowing to reduce or
even eliminate repetitions.
Note
When using multiple templates, their order is important. Templates that are specified afterwards override the ones that come first.
To understand this, read the section Multiple overrides.
Multiple overrides¶
Here’s a more complex example involving multiple overrides:
from netjsonconfig import OpenWrt
general_radio_template = {
"radios": [
{
"name": "radio0",
"phy": "phy0",
"protocol": "802.11n",
"driver": "mac80211",
"channel": 0, # zero means "auto"
"channel_width": 20,
"country": "00", # world
"disabled": False
}
]
}
united_states_radio_template = {
"radios": [
{
"name": "radio0",
"country": "US"
}
]
}
specific_radio_config = {
"radios": [
{
"name": "radio0",
"channel": 10,
}
]
}
router1 = OpenWrt(config=specific_radio_config,
templates=[general_radio_template,
united_states_radio_template])
print(router1.render())
Will generater the following output:
package wireless
config wifi-device 'radio0'
option channel '10'
option country 'US'
option disabled '0'
option htmode 'HT20'
option hwmode '11g'
option phy 'phy0'
option type 'mac80211'
Implementation details¶
The functions used under the hood to merge configurations and templates
are netjsonconfig.utils.merge_config
and netjsonconfig.utils.merge_list
:
-
netjsonconfig.utils.
merge_config
(template, config, list_identifiers=None)[source]¶ Merges
config
on top oftemplate
.Conflicting keys are handled in the following way:
- simple values (eg:
str
,int
,float
, ecc) inconfig
will overwrite the ones intemplate
- values of type
list
in bothconfig
andtemplate
will be merged using to themerge_list
function - values of type
dict
will be merged recursively
Parameters: - template – template
dict
- config – config
dict
- list_identifiers –
list
orNone
Returns: merged
dict
- simple values (eg:
-
netjsonconfig.utils.
merge_list
(list1, list2, identifiers=None)[source]¶ Merges
list2
on top oflist1
.If both lists contain dictionaries which have keys specified in
identifiers
which have equal values, those dicts will be merged (dicts inlist2
will override dicts inlist1
). The remaining elements will be summed in order to create a list which contains elements of both lists.Parameters: - list1 –
list
from template - list2 –
list
from config - identifiers –
list
orNone
Returns: merged
list
- list1 –
Context (configuration variables)¶
Without variables, many bits of configuration cannot be stored in templates, because some parameters are unique to the device, think about things like a UUID or a public ip address.
With this feature it is possible to reference variables in the configuration dictionary, these variables will be evaluated when the configuration is rendered/generated.
Here’s an example from the real world, pay attention to the two variables,
{{ UUID }}
and {{ KEY }}
:
from netjsonconfig import OpenWrt
openwisp_config_template = {
"openwisp": [
{
"config_name": "controller",
"config_value": "http",
"url": "http://controller.examplewifiservice.com",
"interval": "60",
"verify_ssl": "1",
"uuid": "{{ UUID }}",
"key": "{{ KEY }}"
}
]
}
context = {
'UUID': '9d9032b2-da18-4d47-a414-1f7f605479e6',
'KEY': 'xk7OzA1qN6h1Ggxy8UH5NI8kQnbuLxsE'
}
router1 = OpenWrt(config={"general": {"hostname": "Router1"}},
templates=[openwisp_config_template],
context=context)
Let’s see the result with:
>>> print(router1.render())
package system
config system
option hostname 'Router1'
option timezone 'UTC'
option zonename 'UTC'
package openwisp
config controller 'http'
option interval '60'
option key 'xk7OzA1qN6h1Ggxy8UH5NI8kQnbuLxsE'
option url 'http://controller.examplewifiservice.com'
option uuid '9d9032b2-da18-4d47-a414-1f7f605479e6'
option verify_ssl '1'
Warning
When using variables, keep in mind the following rules:
- variables must be written in the form of
{{ var_name }}
or{{var_name}}
;- variable names can contain only alphanumeric characters and underscores;
- unrecognized variables will be ignored;
Project goals¶
If you are interested in this topic you can read more about the Goals and Motivations of this project.