Firewall Rules in TAPPaaS¶
How the zones a site declares, and the modules installed in them, become the rule set on the OPNsense firewall: who writes which rules, in what order the firewall evaluates them, and what each kind of rule is for. ZONES.md describes the zones themselves — what a zone is, the trust model it is meant to express, and the zone lifecycle. This page is the other half: what the firewall actually does with them.
It describes the system as it is built. Where the rules do less than the zone model describes, the difference is listed under Current limitations.
In one paragraph¶
Every rule TAPPaaS writes is a pass or block on the interface where traffic arrives, marked quick — the first rule that matches decides. The order is set by a sequence number, and TAPPaaS keeps its rules in fixed bands: a zone's protective blocks first, then the per-module pinholes, then each zone's own baseline (its gateway, the zones it may reach, a block on every other private network, and the internet). Zone rules come from zones.json; module rules come from each module's network:rules and its service dependencies. Everything not passed is dropped by OPNsense's default deny.
What the rules are made from¶
Everything the firewall enforces comes from five places in the configuration. Nothing is written into the firewall by hand.
1. A zone — config/zones.json (ZONES.md has every field). The fields the firewall reads:
"home": {
"state": "Active", // only Active and Mandatory zones get rules
"ip": "10.3.10.0/24", // the zone's subnet: source of its rules
"access-to": ["internet"], // zones it may reach, whole subnet, every port
"pinhole-allowed-from": [], // zones whose modules may open a pinhole into it
"snat-allowed-from": [], // zones whose traffic may be masqueraded into it
"serves": "warmelo" // adds the environment's service zone (derived)
}
tier and isolated are in the same object, but they feed the checks, not the rules.
2. A module's own traffic — in config/<module>.json, for a module whose dependsOn lists network:rules. The ports it offers, who may reach them, and what it may reach beyond its zone's access-to:
"zone0": "warmelo", // the zone the module runs in
"ports": [{ "port": 3478, "protocol": "UDP" }],
"ingress": [{ "from": "home", "ports": [3478], "protocol": "UDP",
"description": "Talk clients relay through coturn" }],
"egress": [{ "to": "internet", "ports": [443], "protocol": "TCP",
"description": "why this is needed" }]
from / to is a zone, another module, internet, or alias:<name> (an alias the module declares under aliases, or one from the site-wide network/aliases.json). Schema and a worked example: network/DESIGN.md.
3. A service pinhole — a provider module ships services/<service>/pinhole.json listing the ports of a service it offers; a consumer opens it by naming the service in its dependsOn or integratesWith (litellm:models). No rule is declared by either side: the pinhole follows from the dependency, if the zones allow it.
4. A port forward or source NAT — in the module's config, for a module that depends on network:nat or network:snat:
"config": {
"network:nat": { "natRules": [{ "externalPort": 2022, "internalPort": 22,
"protocol": "TCP", "description": "Git over SSH" }] },
"network:snat": { "snatFrom": ["home"], "snatReason": "the device only answers its own subnet" }
}
A port forward is accepted only for a module in a zone that lists internet in pinhole-allowed-from (the dmz); the control plane (tappaas-cicd) is the one exception, so an operator may expose its SSH as the way in.
5. The site — config/site.json ipv6 (default false): whether the firewall allows IPv6 at all (Address family). How a site gets IPv6, and which zones carry it, is in IPV6.md.
How the rules get written¶
An administrator works with two managers:
network-manager— the zones:add,delete,bind,reconcile.module-manager— the modules:add,update,modify,delete.
Neither writes to OPNsense itself. Both change the rules through the OPNsense controller, which has one procedure for each kind of rule:
| OPNsense controller procedure | Called by | Writes |
|---|---|---|
zone-manager | network-manager reconcile (which add, delete and bind run too) | each zone's baseline: Zone <zone> … |
rules-manager | module-manager, through the module's network:rules service | module ingress and egress, auto-pinholes: tappaas-module:<vm>:…, tappaas-svcdep:<consumer>:… |
nat-manager | module-manager, through the module's network:nat service | port forwards |
snat-manager | module-manager, through the module's network:snat service | outbound NAT |
ipv6-manager | module-manager, when it updates the network module | the firewall's Allow IPv6 and the site's IPv6 (IPV6.md) |
The diagram below shows the whole path, from the configuration through the two managers and the controller to the rule set. The procedures are the controller's internals: an administrator changes the configuration and runs a manager, never a procedure directly (a few have read-only listings, under Checking it).
network-manager reconcile --only opnsense without --apply shows what would change and changes nothing. A module's install adds its rules; its update reconciles them (adds what is missing, removes what the module no longer declares); its delete removes them. Each writer touches only rules carrying its own names, so a rule an operator adds in the OPNsense GUI under any other description is left alone. A TAPPaaS rule changed on the firewall — disabled, or with another protocol, port, address family or order — is put back as declared at the next reconcile, and the rules of a zone that is removed or renamed are removed with it.
A few modules write their own rules as part of their install — the admin VPN, the satellite, the backup buddy and Caddy (below).
From the configuration to the rule set¶
flowchart TD
subgraph CFG["Configuration you edit"]
direction LR
Z["zones.json<br/>ip · access-to<br/>pinhole-allowed-from<br/>snat-allowed-from · serves"]
E["environments/ENV.json<br/>network.zone"]
S["site.json<br/>ipv6"]
M["MODULE.json<br/>zone0 · ports · ingress · egress<br/>aliases · dependsOn<br/>network:nat · network:snat"]
P["provider<br/>services/SERVICE/pinhole.json"]
A["network/aliases.json"]
end
subgraph RUN["What you run"]
direction LR
NM["network-manager<br/>add · delete · bind · reconcile"]
MM["module-manager<br/>add · update · modify · delete"]
end
subgraph CTL["OPNsense controller"]
direction LR
ZM["zone-manager<br/>each zone's baseline"]
RM["rules-manager<br/>ingress · egress · auto-pinholes"]
NAT["nat-manager · snat-manager<br/>port forwards · outbound NAT"]
V6["ipv6-manager<br/>Allow IPv6"]
end
FW[("OPNsense rule set<br/>first match wins, by sequence")]
Z --> NM
E --> NM
S --> MM
M --> MM
P --> MM
A --> MM
NM -- "zones.effective.json<br/>serves derived · all expanded" --> ZM
NM -. "effective zones" .-> RM
MM -- "network:rules" --> RM
MM -- "network:nat · network:snat" --> NAT
MM -- "network module" --> V6
ZM --> FW
RM --> FW
NAT --> FW
V6 --> FW - Effective zones.
network-manager reconcileadds the edgesservesderives — a Client or Guest zone that serves an environment reaches that environment's service zone; an IoT zone accepts pinholes from it — and writesconfig/zones.effective.json. Only the zone that declaresserveschanges (ZONES.md). all. Wheremgmtsaysall, it is read asinternetplus every zone that is notDisabled, anOverlayor theWANhand-off.- Zone rules.
zone-managerwrites the rules of everyActiveorMandatoryzone, removes the rules of disabled and deleted zones, and applies once. - Module rules.
rules-managerturns each module's ingress, egress and service dependencies into rules and host aliases, checks them against the effective zones, and applies them. Port forwards and source NAT are written by their own tools, and source NAT only into a zone whosesnat-allowed-fromnames the source. - IPv6. Updating the
networkmodule runsipv6-manager, which sets the firewall's Allow IPv6 fromsite.jsonand gives each zone its IPv6 network (IPV6.md).
Evaluation order¶
All rules are quick: the lowest sequence number that matches decides. On a zone's interface the order is:
| Order | Sequence | Rules | Written by |
|---|---|---|---|
| 1 | OPNsense | anti-lockout, DHCP, bogons and system rules | OPNsense |
| 2 | 970 / 971 | anti-spoofing — drop any source outside the zone's networks (IPv4 / IPv6) | zone-manager |
| 3 | 975 | mDNS — pass UDP 5353 to 224.0.0.251 on the zones that share discovery (ZONES.md) | mdns-manager |
| 4 | 980, 981 | admin guard — block TCP 8443 (GUI) and 22 (SSH) to every firewall address | zone-manager |
| 5 | 990, 991 | caddy-reach — pass TCP 443 and 80 to the DMZ gateway (/32), where Caddy listens | zone-manager |
| 6 | 10000–19999 | module ingress and auto-pinholes | rules-manager |
| 7 | 20000–29999 | module egress | rules-manager |
| 8 | 30000–39999 | the zone baseline (table below) | zone-manager |
| 9 | — | rules created without a sequence (other components, the GUI) | various |
| 10 | — | default deny | OPNsense |
Within the module bands each module has 100 slots, picked by a stable hash of its vmname; within band 7 each zone has 100 slots by a hash of its name. Which slot each module has, and which of its sequence numbers are in use, is written to config/firewall/sequence-map.json after every change to its rules (rules-manager list-slots prints it). Two modules can land on the same slot; their rules then share sequence numbers, which loses nothing because they are all passes, and rules-manager says so. The bands are why a pinhole works: it is evaluated before the destination's private-network block that would otherwise drop it.
Zone rules¶
For each Active/Mandatory zone, on the zone's own interface (base = the zone's slot in band 7). A zone with an empty access-to gets the protective rules — anti-spoofing (IPv4 and IPv6) and the admin guard — and its gateway's services, and nothing else.
Zone rules name a zone by its interface: its network as the source, its interface address as the gateway, a target zone by that zone's interface network. A rule therefore covers whichever addresses the zone carries, IPv4 and IPv6 alike. The family column says which.
| Sequence | Rule | Family | Action |
|---|---|---|---|
| 970 | Zone Z block spoofed source | IPv4 | block, source not in the zone's network |
| 971 | Zone Z block spoofed source v6 | IPv6 | block, source not in the zone's network table (__optN_network) |
| 975 | TAPPaaS: mDNS to the repeater | IPv4 | pass UDP 5353 to 224.0.0.251, where the firewall's mDNS repeater listens; one rule on every zone with mdns, none when fewer than two |
| 980 / 981 | Zone Z block firewall webgui / ssh | both | block TCP 8443 / 22 to the firewall itself |
| 990 / 991 | Zone Z -> caddy https / http | both | pass TCP 443 / 80 from the zone's network to the firewall's own addresses, where Caddy listens |
| base+0 | Zone Z -> gateway dns | both | pass TCP/UDP 53 to the zone's interface address |
| base+1 … +85 | Zone Z -> <zone> | both | pass any, to each zone in access-to (its whole network, every port) |
| base+86 / 87 | Zone Z -> gateway ntp / dhcp | both | pass UDP 123 / UDP 67 to the zone's interface address |
| base+88 / 89 | Zone Z -> gateway icmp / icmpv6 | IPv4 / IPv6 | pass ICMP / ICMPv6 to the zone's interface address |
| base+90 … 92 | Zone Z block rfc1918-10 / 172 / 192 | IPv4 | block every other private network (only when internet is in access-to) |
| base+93 | Zone Z block zone-networks | IPv6 | block every zone's network: the alias tappaas_zone_networks (same condition) |
| base+94 | Zone Z block ula | IPv6 | block unique-local addresses, fc00::/7 (same condition) |
| base+95 | Zone Z block firewall | both | block the firewall's own addresses, its WAN address included (same condition) |
| base+96 / 97 | Zone Z block cgnat / link-local | IPv4 | block 100.64.0.0/10 and 169.254.0.0/16 (same condition) |
| base+99 | Zone Z -> internet | both | pass any (only when internet is in access-to) |
tappaas_zone_networks groups OPNsense's network table of every zone's interface, so it holds each zone's current IPv4 and IPv6 networks. IPv6 has no private ranges inside a site — a zone's IPv6 network is public — so this alias is what keeps zones apart over IPv6.
What a zone may ask of the firewall. Its gateway serves it DNS, NTP, DHCP and ICMP, and nothing else: the admin GUI and SSH are blocked from every zone on every firewall address, because an access-to edge grants a whole subnet and every zone's gateway is the firewall. "The internet" does not include the firewall either: its WAN address is not in a private range, so a zone with internet access would otherwise reach everything the firewall serves there. Only Caddy is reachable on the firewall's addresses, through its own rule. Fragments need no rule — OPNsense reassembles them before filtering.
Caddy-reach. Split-horizon DNS answers every published name with the DMZ gateway, where Caddy listens, and no zone may hold access-to: dmz (invariant I5). So a zone reaches Caddy by its own rule at 990/991 — ahead of its private-network block — on two ports, to the firewall's own addresses: Caddy answers on all of them, and a device that resolved a published name through public DNS reaches it on the WAN address. It is reachability only: Caddy's proxyAllowedZones and the identity gate decide who gets in. Candidates are a zone with internet (or all) in access-to, an Overlay with a non-empty access-to (the admin VPN), and dmz itself.
Manual zones get no zone rules. mgmt reaches everything through the firewall's own LAN rules and OPNsense's anti-lockout; the overlays (admin VPN, NetBird, edge) through the rules their own components write. mgmt's all is therefore a statement for the checks and for SNAT, not a rule.
Module rules¶
How rules-manager turns a module's declarations (input 2 and 3) into rules:
- Ingress — who may reach this module, on which ports. From a zone: the rule is on that zone's interface and admits the whole source zone. From another module: only that module's addresses (its
tm_<module>alias). Frominternet: on the WAN. Fromalias:X: on the interfaces of the zones its addresses are in, and the WAN for its internet addresses. A zone whose interface cannot be found on the firewall stops the module's install or update; the rule is never written to another interface instead. - Egress — what this module may reach beyond its zone's
access-to: a zone, a module, an alias, orinternet.internetmeans everything that is not the site: the rule's destination is "nottappaas_not_internet", an alias of the private and special-purpose ranges (RFC 1918,100.64.0.0/10,169.254.0.0/16,fc00::/7) and every zone's network. A module that needs another zone names that zone. - The zones decide. A rule that crosses zones is written only if the zones allow it: the source zone has the target zone in
access-to, or the target zone lists the source zone inpinhole-allowed-from. A module peer counts as the zone it runs in, and traffic within one zone is always allowed. Analias:peer counts as the zones its addresses are in: an address or network inside a zone's network, or a<host>.<zone>.internalname, is that zone; a public address or an outside name is the internet. An alias holding a private range that is no single zone's cannot be checked and is refused — name the zone instead. The path to an NFS share a module mounts is the exception: its share's grant decides it. Ingress from the internet is accepted only by a zone that listsinternetinpinhole-allowed-from(thedmz); the firewall's own entry to Caddy is the exception. A rule the zones do not allow stops the module's install or update, and the message gives thenetwork-manager modifycommand that would allow it. - Auto-pinholes — when a module depends on another module's service (
dependsOn,integratesWith),rules-manageropens the provider's ports from the consumer's address to the provider's, on the consumer's interface — if the provider's zone allows the consumer's zone inpinhole-allowed-from. - What a zone already allows is not written twice. When the source zone's
access-toalready includes the target zone (orall), the zone's own pass lets all of that traffic through, so a module rule or auto-pinhole between the two is left out, with a note saying so. The sequence map records each one left out and theaccess-toit relies on. When an update of thenetworkmodule narrows thataccess-to, it writes the module's rules again in the same update (rules-manager recheck-covered). - Aliases — a module peer is an OPNsense host alias holding the peer's DNS name (
<vm>.<zone>.internal), so a changed DHCP address needs no rule change.
Module rules cover IPv4 and IPv6 like zone rules: a zone peer is that zone's interface network, and a module peer its host alias, which resolves both kinds of address. A rule is IPv4-only when it names a literal IPv4 address or ICMP, and IPv6-only for ICMPv6 (declared as ICMPv6).
Rule names carry their identity: tappaas-module:<vm>:ingress:<peer>:<port>, tappaas-svcdep:<consumer>:<service>:<provider>:<port>, each followed by a human description. A rule is one per identity: changing only its description rewrites the rule in place.
Address family¶
Zone and module rules cover IPv4 and IPv6 (see Zone rules and Module rules); no zone has an IPv6 address yet. Rules written by other modules (below) are IPv4 only. Whether the site supports IPv6 at all is site.json ipv6 (default false). When it is false, the firewall drops all inbound and forwarded IPv6 ahead of every TAPPaaS rule, and DNS resolves over IPv4 only. After changing it, module-manager update network applies it.
Rules written by other modules¶
These modules write their own rules when they are installed or reconfigured, by description, without a sequence number — not through network-manager or network:rules:
| Component | Rules |
|---|---|
Admin VPN (network-manager/wgvpn) | tappaas-admin admin->mgmt (the VPN's peers reach mgmt), tappaas-admin WAN :<port> |
| Satellite | tappaas-satellite edge->caddy, edge->admin-wg, edge->home-pbs, mgmt->pbs <name> |
| Backup buddy | buddy-<n> serve wg (WAN), buddy-<n> pull (mgmt) |
| Caddy setup | TAPPaaS: Allow HTTP/HTTPS to Caddy (WAN) |
No component writes NetBird rules: on a site that runs NetBird, they are set by hand in the OPNsense GUI.
Checking it¶
The managers' own views, and the OPNsense controller's read-only listings — safe to run at any time, they change nothing:
network-manager reconcile --only opnsense # what a zone reconcile would change (no --apply)
network-manager validate --effective # the zone model: references, tiers, invariants
zone-manager --summary # zones, their interfaces, pinhole policy warnings
rules-manager verify-rules <module> # a module's declared rules vs what OPNsense holds
rules-manager expected-pinholes <module> # which auto-pinholes it should carry, and why any are not
rules-manager list-rules --orphans # TAPPaaS rules nothing declares any more
rules-manager overlaps # rules that are shadowed, redundant or in conflict
rules-manager list-slots # each module's slot, rules in use, rules left out
ssh root@firewall.mgmt.internal 'pfctl -sr' # the rules pf actually evaluates, in order
rules-manager overlaps reads the rule set as the firewall holds it — interface networks and every alias's current addresses resolved, both address families — and compares the rules on each interface in their evaluation order:
- shadowed — an earlier rule matches all of a rule's traffic, so the rule never decides anything. A pass behind a block is traffic meant to be allowed that is dropped.
- redundant — a module's pass whose traffic a later, broader pass lets through anyway, with no block in between.
- conflict — part of a module rule's traffic is decided by an earlier rule of the other action.
It changes nothing and always succeeds; verify-rules <module> --detect-overlap adds the findings for one module to its check.
Current limitations¶
Where the rules do less than the zone model describes:
- An
access-toedge includes the firewall's address in the target zone. A zone that may reach another zone reaches that zone's gateway address too, on every port except the admin GUI and SSH. - An overlay's caddy-reach source is
10.0.0.0/8, not its peers' range: the WireGuard interface group has no network of its own to name, and no anti-spoofing narrows it. - A module with
aliasType: networkis reached through an alias of its zone's IPv4 subnet, so its rules cover IPv4 only.