MurOS [BETA]

Architecture

MurOS is a fork of OPNsense, ported from FreeBSD to Debian 13. The web UI, the authentication stack and the configuration model are the proven OPNsense codebase. The system layer underneath has been rebuilt for Linux. This page maps the moving parts and how an action in the UI reaches the kernel.

What is kept, what is rebuilt

Layer OPNsense (FreeBSD) MurOS (Debian)
Packet filter pf / pfctl nftables
Service supervision rc.d + configd systemd + configd bridge
Packages and updates pkg / opnsense-update apt
Interface configuration ifconfig iproute2 (ip)
Web UI and config model PHP and Phalcon MVC, config.xml unchanged

The web UI, the data model and the operator workflow are therefore the same as OPNsense. What changes is everything behind the Apply button.

Configuration model

There is no database. The whole state of the firewall lives in a single XML file, /conf/config.xml, read and written through the OPNsense configuration classes. Rules, interfaces, users, VPN peers, services: all of it is one versioned document. That is what makes a configuration portable between nodes and simple to back up.

Web stack

The GUI is served on Debian by lighttpd and php-fpm, with the Phalcon PHP extension that the OPNsense MVC layer requires. Two GUI layers coexist, as upstream: the classic PHP pages (login, dashboard) and the Phalcon MVC layer that serves /ui and the API. HTTPS is mandatory and the session cookie is secure-only, so the UI cannot be used over plain HTTP.

System layer

systemd unit Role
muros-firewall.service renders config.xml into an nftables ruleset (table inet muros, chains input / forward / output) and loads it at boot and on reload
muros-interface-assign.service maps the logical interfaces (wan, lan, optN) to the real Linux devices at boot, then reloads the firewall
muros-interfaces.service applies the assigned interfaces from config.xml via iproute2 (link, MTU, static v4/v6 addresses)
muros-configd.service control-plane bridge: the UI asks it to apply a change, it runs the matching Linux action
lighttpd, php-fpm serve the web UI over HTTPS

The generated ruleset always carries an anti-lockout baseline (SSH and the web UI from the management network), so an applied ruleset cannot strand the operator.

Apply pipeline

  1. The UI validates the change and writes it to /conf/config.xml.
  2. The configd bridge renders the affected configuration. For filtering, the ruleset is generated and loaded with nft -f, an atomic kernel swap.
  3. The matching systemd unit is reloaded or restarted.
  4. For a firewall apply the ruleset is first validated with nft -c; an invalid ruleset is rejected and the running one is left untouched. Every loaded ruleset carries a mandatory anti-lockout rule, so an apply cannot strand the operator.

Rule order

The two packet filters do not decide the same way. pf evaluated every rule and let the last match decide, unless the rule was marked quick, which decided on the spot. nftables stops at the first match. The two agree once the list is rewritten: under pf the winner is the first matching quick rule, and when no quick rule matches it is the last matching non quick rule, so MurOS emits the quick rules in their original order followed by the non quick ones reversed. A first match walk over that list picks the rule pf would have picked. Only a floating rule can be non quick; an interface rule always is.

A rule limited to a schedule is evaluated when the ruleset is built and simply not emitted while it is outside its window, and the filter is rebuilt on a timer so the ruleset follows the clock.

Who owns the packet mark

Two features write into the mark a packet carries through the kernel. Policy based routing puts the number of the gateway a flow was pinned to, and reads it back in a routing rule. Inline intrusion detection hands packets to a queue suricata reads, and needs a way to tell a packet that has already been through it, otherwise it would loop; suricata writes that flag into the mark too.

They used to overlap. Gateway numbers start at 1000 and the inspection flag was the lowest bit, so a gateway with an odd number produced traffic the handover chain read as already inspected and let through without inspecting it, and a packet that had been inspected came back with its gateway number changed and no longer matched the routing rule, leaving through the default gateway. Both failures are silent and depend on a number nobody chooses deliberately.

The mark is now divided: gateway numbers stay in the low sixteen bits, the two flags of the intrusion detection sit above them, and the routing rules match with a mask so the flags do not disturb them.

The rules nobody wrote

A firewall configuration is not only what the operator typed. A DHCP server needs the requests addressed to it to be let in, an IPsec tunnel needs IKE and ESP from the peer, a captive portal needs name resolution to work before a client has authenticated. Those rules are declared by the component that needs them, at the moment the ruleset is built, and the interface shows them on the rule page marked as automatically generated.

They live in the plugin tree, not in the configuration file, and the generator reads the configuration file. So the page showed rules that were never applied. A small script now runs inside the framework, asks every component what it needs, and writes the answer where the generator can read it. The rules land in the input chain ahead of the rules the operator wrote, which is the order the page shows. Anything a component asks for that cannot be expressed, a reference to a table the generator does not own for instance, is named by the rule check instead of disappearing.

One table, not the whole ruleset

Applying the firewall configuration means loading a generated file with nft -f. The obvious way to start that file is flush ruleset, and that is what it used to do. It is also wrong: the firewall is not the only owner of nftables state on the machine. The captive portal keeps the addresses of its authenticated clients, and the counters that measure what each of them has consumed, in a table of its own. Flushing everything disconnected those clients and reset their accounting on every reload, so a volume quota was never reached on a machine that reloads its firewall for any other reason.

The generated file now begins by adding its own table and deleting it, then defines it. The add makes the delete safe on a machine where the table is not there yet, the whole file is still one transaction, and every other table is left exactly as it was.

Aliases that are not lists of addresses

An alias is often not a list of addresses. It can be a set of GeoIP countries, a list downloaded over HTTP, a host name, a MAC address, an autonomous system number, or the name of another alias. Turning any of those into addresses is the job of a background updater, which resolves them and keeps the result in /var/db/aliastables.

On FreeBSD that was the end of the story, because reloading the ruleset left the contents of the tables in place. nftables has no equivalent: the apply pipeline starts from a flushed ruleset, so every reload used to empty those aliases and the rules using them matched nothing until the updater ran again. The generator therefore reads the resolved content back and writes the addresses into the ruleset itself. A reload arms the alias in the same transaction as the rules that use it, and an alias that resolves to nothing at all is reported by the rule check rather than left as a rule that can never match.

Checking that a rule reached the kernel

A rule the interface accepts is not necessarily a rule the kernel enforces: a configuration item the generator does not handle is stored, listed back, and silently ignored. Every rule generated from a configuration item therefore carries the identifier of that item, and a check walks the configuration to look for it:

configctl filter verify

It separates five cases. Applied, the loaded ruleset carries the rule. Pending, the ruleset the current configuration produces carries it but the loaded one does not, so the filter has not been reloaded since the change. Never applied, neither does, and nothing the box does will change that. Outside its schedule, the rule was left out on purpose until its time window opens. A sixth line lists the rules that are loaded but carry an option with no counterpart on this platform, such as sequence number modulation or a global connection limit: they are in the kernel, they do not do everything they say. Stale, a rule is loaded for an item that no longer exists. The same check runs as part of the health audit on the firmware page, and configctl filter verify.json returns it in a form a script can read. The exit status is non zero as soon as an item is pending or missing.

Porting status

MurOS is in beta. Running on Debian today: the web UI, login, the configuration model, the stateful filter and NAT (config.xml to nftables), address aliases as named nftables sets, the full interface layer via iproute2 (addressing, VLANs, bridges, LAGG as Linux bonding, GRE/GIF tunnels), static routing, account management through the Debian shadow utilities, the package inventory through dpkg/apt, and the WireGuard and OpenVPN runtime devices. Also running: the infrastructure services (Kea DHCP, Unbound, chrony), high availability on keepalived and conntrackd with configuration synchronisation to the backup, traffic shaping on tc, intrusion detection and prevention on Suricata, flow export on softflowd, the captive portal on nftables, host discovery, gateway monitoring with dpinger, and per-rule policy routing with automatic multi-WAN failover between the tiers of a gateway group. Still being ported: SNMP and notifications, the full apt upgrade flow, and the sshd options. Each feature page notes where it stands.

Edit this page on GitHub website/docs-src