Zone Definitions in TAPPaaS¶
zones.json defines the security zones of a TAPPaaS installation: each top-level key is a zone with its network, its access policy and its DHCP settings. Keys beginning with _ are documentation blocks and are ignored. The template ships in this directory; the site's own copy is config/zones.json.
This page is the zone model: what a zone is, and the trust it is meant to express. Two pages complete it:
- network-manager — the commands that create, change, check and distribute zones.
network-managerowns the whole zone lifecycle; nothing else editszones.json. - FIREWALL-RULES.md — how the zones, and the modules in them, become the firewall's rule set.
The zone object¶
| Field | Meaning |
|---|---|
type / typeId | Security classification (below); typeId is its numeric band. |
state | Activation state (below). |
subId | 0–99, the zone within its type band. |
vlantag | VLAN tag, computed typeId * 100 + subId (0 = untagged). |
ip | Subnet, computed 10.<typeId>.<subId>.0/24. |
bridge | The trunk the VLAN rides (lan / wan / opt1 / opt2); for an overlay, the interface its traffic arrives on. |
access-to | Zones (and internet) this zone may reach — whole subnet, every port. |
pinhole-allowed-from | Zones whose modules may open a pinhole into this zone. |
snat-allowed-from | Zones whose traffic may be masqueraded into this zone. |
tier | Trust rank 0–6 (0 = most trusted). Absent on Overlay/WAN. |
isolated | Inbound quarantine: no zone may reach it zone-wide. Default false. |
environment | The environment the zone belongs to: for a Service zone (and mgmt), the environment whose zone it is, or — as a list — the environments a shared service zone serves; for a Client, Guest, IoT or DMZ zone, the environment it serves (below). |
DHCP-start / DHCP-end | DHCP range offsets within the subnet (default 50–250). |
SSID | Optional WiFi network broadcast on this zone's VLAN (below). |
ipv6Subnet | The zone's IPv6 network, 0–255: its /64 within the site's prefix. Assigned by default; remove it to keep the zone IPv4-only (IPV6.md). |
mdns | The zone takes part in mDNS discovery across zones (below). Default false. |
description | Human-readable purpose. |
Zone keys are camelCase (^[a-z][a-zA-Z0-9]*$), as in srvHome / iotCams. The client zones home / guest stay unprefixed: the key is the client DNS domain <zone>.internal, so renaming one re-domains every device. Every field, its values and defaults: schemas/zones-fields.json.
Zone types¶
| Type | typeId | Purpose |
|---|---|---|
| Management | 0 | TAPPaaS nodes and self-management; locked down. Usually one zone, mgmt. |
| Service | 2 | Application and service modules. A site may have several. |
| Client | 3 | End-user devices. |
| IoT | 4 | IoT devices (often less secure). |
| Guest | 5 | Untrusted or guest access. |
| DMZ | 6 | Services exposed to the internet. |
| Overlay | 7 | A non-VLAN overlay (WireGuard, NetBird): no interface or DHCP, always Manual, vlantag 0. |
Zone state¶
| State | Meaning |
|---|---|
| Active | network-manager creates and maintains the zone's interface, DHCP scope and firewall rules. |
| Mandatory | As Active; the zone must exist and cannot be disabled. |
| Inactive | Defined but not provisioned; removed if it exists. The schema default. |
| Disabled | As Inactive. |
| Manual | network-manager neither creates nor removes it; it is managed outside TAPPaaS (mgmt, the overlays). |
The trust model¶
Two mechanisms control reachability between zones:
| Mechanism | What it grants | Direction | Set by |
|---|---|---|---|
access-to | the whole target subnet, every port | downward only | the zone, in zones.json |
pinhole-allowed-from | a module's declared ports, to the zones listed | the only way trust flows up | the module, in its own JSON |
How each becomes a firewall rule is in FIREWALL-RULES.md.
The lattice¶
tier is a strict trust rank, there so the access model can be checked by machine rather than by eye. It is checked, not enforced: the rules come from access-to and pinhole-allowed-from, and the invariants warn when those break the lattice.
R1 (monotonic
access-to) — for every edgeA access-to B,tier(A) ≤ tier(B). Never upward.internetcounts as tier 5.
| Tier | Name | access-to (downward) | Reached from above via | Members |
|---|---|---|---|---|
| 0 | Control plane | all zones | nothing | mgmt |
| 1 | Service backend | internet, IoT it controls | pinhole from clients and the reverse proxy | <defaultEnvironment> |
| 2 | Trusted client | internet, its own IoT | its own devices | home, work |
| 3 | Untrusted edge | internet only | — | guest, iotCloud, iotUntrust |
| 4 | DMZ (exposed) | internet only | pinhole from the internet | dmz |
| 5 | Internet | — | — | (a token, not a zone) |
| 6 | Isolated / no egress | none | pinhole only, when isolated | iotCams, iotLocal |
- No zone reaches the DMZ zone-wide. A DMZ host is internet-exposed and assumed breached, so it sits below an internal backend, and
access-to: dmzis refused (I5): the DMZ gateway is the firewall itself, so the edge would give the whole source zone the firewall's GUI and SSH. Published names are reached through Caddy, and a DMZ workload by pinhole. - Service sits above trusted clients. A client reaching its service uses an upward pinhole to specific ports, never the backend's whole subnet.
tierandtypeare orthogonal: aGuestzone andiotCloudshare tier 3.OverlayandWANzones carry no tier and are skipped by the tier checks.
Isolation is a flag, not a tier¶
R2 (isolation floor) — a zone with
"isolated": trueis in no zone'saccess-to(mgmtaside).
Isolation is independent of rank: iotCams (tier 6, no egress) and iotUntrust (tier 3, internet egress) are both quarantined. They are reached only by pinhole. iotLocal shares tier 6 with iotCams but is not isolated: its serving zone reaches it zone-wide (Home Assistant → local IoT), which R2 forbids for iotCams: camera footage stays reachable only by the modules that need it.
mgmt reaches every zone: all¶
mgmt says "access-to": ["internet", "all"]. all stands for internet plus every zone that is not Disabled, an Overlay or the WAN hand-off, so adding or deleting a zone never edits mgmt. Only mgmt may hold all (I2).
A zone and its environment: environment¶
A zone names the environment it belongs to; the environment's own file names no zone.
- A Service zone names the environment whose zone it is, as a string: that environment's own zone, the segment its modules run in by default. Every environment has exactly one own zone — the control plane's is the Management zone
mgmt, which names themgmtenvironment. - A Service zone that names a list of environments — even a list of one — is a shared service zone: it serves each environment it lists and is the own zone of none (see below).
- A Client or Guest zone names the environment it serves — or a list, when it serves several — and gets each one's zone added to its
access-to. - An IoT zone names the environment(s) whose modules drive its devices, and gets each one's zone added to its
pinhole-allowed-from, so anisolatedzone never gains inbound reach. - A DMZ zone may name the environment(s) it fronts; that adds nothing.
- Overlay and WAN zones name none.
Naming the service zone literally would break when network-manager init renames srv → <defaultEnvironment>, so a client or IoT zone names the environment, and the edge is derived from its current zone on every reconcile:
"warmelo": {
"type": "Service", "state": "Active", "ip": "10.2.0.0/24", "tier": 1,
"environment": "warmelo" // this is the warmelo environment's zone
},
"home": {
"type": "Client", "state": "Active", "ip": "10.3.10.0/24", "tier": 2,
"environment": "warmelo", // home serves warmelo
"access-to": ["internet"], // the service-zone edge is derived, not listed
"pinhole-allowed-from": []
}
The derived edges live in config/zones.effective.json, never in zones.json. A client zone that reaches several environments — a business's office zone reaching each customer's work environment, say — lists them: "environment": ["custA", "custB"], and gets the union of their edges. An authored access-to entry is kept alongside: the derived edges are added to it, never replace it.
Set it with network-manager bind (network-manager), or for an environment's own zone with environment-manager add|modify <env> --zone <zone>:
| Command | Effect |
|---|---|
bind <zone> --environment <env> | the zone names <env>, replacing any link it had |
bind <zone> --environment <env> --add | the zone names <env> as well; on a Service zone this makes it a shared service zone |
bind <zone> --unbind --environment <env> | the zone no longer names <env>; the others stay (a shared service zone keeps a list, even of one) |
bind <zone> --unbind --environment <env> --own | a shared service zone left with one environment becomes that environment's own zone |
bind <zone> --unbind | every link is cleared |
network-manager validate refuses a zone naming an environment that does not exist, an environment with no own zone or with two, a list on the Management zone, a list that is empty or repeats a name, and environment on an Overlay or WAN zone.
Shared service zones¶
Some sites run several environments that each have a Service zone of their own and also share one: a business with a work environment per customer, each in its own zone, plus a zone of work services every customer environment uses. The shared zone lists them:
"custA": { "type": "Service", "environment": "custA" }, // custA's own zone
"custB": { "type": "Service", "environment": "custB" }, // custB's own zone
"srvWork": { "type": "Service", "environment": ["custA", "custB"] } // shared by both
The form of environment says which role a Service zone has:
environment on a Service zone | the zone is | |
|---|---|---|
"custA" | custA's own zone: its default install place and its domain | exactly one per environment |
["custA", "custB"] | a shared service zone serving custA and custB | the own zone of neither |
["custA"] | a shared service zone serving custA alone | custA still has its own zone |
A shared service zone:
- is a valid
--zone0for a module of any environment it lists (module-manager add <module> --environment custA --zone0 srvWork); its modules are named<module>.srvWork.internal.module-manager validateand the install refuse it for a module of an environment it does not list; - is never an environment's own zone:
environment-manager showandlistgive the own zone and name the shared ones apart, the default install place stays the own zone, andenvironment-manager add|modify --zonerefuses a shared zone; - derives no edge. A client zone serving custA reaches custA's own zone, not the shared one; the shared zone's own
access-toandpinhole-allowed-fromare authored like any zone's. Adding or removing a shared zone changes nothing else in the firewall graph.
bind srvWork --environment custC --add adds an environment to it. bind custA --environment custB --add turns custA's own zone into a shared one — custA then needs another own zone (environment-manager modify custA --zone <zone>), and validate says so until it has one.
Archetypes¶
An archetype is a named bundle of tier-correct defaults: network-manager add <name> --archetype <A> sets type, tier, isolated and the access-to seed, then allocates subId, vlantag and ip. The tag and subnet are then checked against what the firewall already carries, not only against zones.json: a segment built by hand in OPNsense, or left behind by a deleted zone, refuses the add with its label, tag and subnet named. Nothing is re-allocated — the site's own segment moves to a free tag, TAPPaaS's numbers stay. init and merge refuse (or skip) a template zone the same way, which is how a site whose VLAN 100 is already something else meets the template's wan.
| Archetype | type | tier | isolated | access-to seed | reference zone |
|---|---|---|---|---|---|
control | Management | 0 | no | all | mgmt |
service | Service | 1 | no | internet | srv → <env> |
trusted-client | Client | 2 | no | internet (service via pinhole) | home, work |
guest | Guest | 3 | no | internet | guest |
iot-cloud | IoT | 3 | no | internet | iotCloud |
iot-untrust | IoT | 3 | yes | internet | iotUntrust |
dmz | DMZ | 4 | no | internet (inbound via pinhole) | dmz |
iot-local | IoT | 6 | no | (none) | iotLocal |
iot-cams | IoT | 6 | yes | (none) | iotCams |
The catalogue is authoritative in schemas/zones-fields.json (archetypes.catalog).
Security invariants (network-manager validate)¶
Each is a warning, and an error under --strict:
| ID | Rule | Notes |
|---|---|---|
| I1 | R1 — tier(A) ≤ tier(B) for every access-to edge | mgmt exempt; Overlay/WAN skipped; names every edge it could not check for a missing tier |
| I2 | R2 — an isolated zone is in nobody's access-to; only mgmt holds all | mgmt exempt |
| I3 | A tier-6 zone does not list internet | Overlay/WAN skipped |
| I4 | (type, tier, isolated) matches an archetype | catches a zone configured against its intent |
| I5 | No zone but mgmt has dmz in access-to | the DMZ is reached through Caddy or by pinhole |
pinhole-allowed-from is deliberately not tier-gated: upward is what pinholes are for.
Discovery across zones: mdns¶
Printers, media players, speakers and smart-home hubs announce themselves with mDNS, which stays within one VLAN. A zone with "mdns": true joins a single discovery group: a device in it finds devices in every other zone of the group, and is found by them.
network-manager modify home --set mdns=true
network-manager modify iotCloud --set mdns=true
module-manager update network
- Discovery only. Finding a device does not let a client reach it: that is still the zone's
access-toor a module's pinhole. A phone inhomethat discovers a speaker iniotCloudconnects to it only ifhomemay reachiotCloud. - One group. Every zone in it hears every other; there are no pairs. It takes two zones to relay anything.
- IPv4. Announcements are relayed over IPv4; the connection that follows may use either family.
- Which zones. VLAN zones that are not
Management,WANorOverlay;validaterefuses the others. - Modules. A module that needs its own zone discoverable declares
discoveryMdns(the zones that should find it), which adds its zone and those zones to the same group.
The firewall's mDNS repeater carries it, with one rule letting each zone of the group reach it (FIREWALL-RULES.md). Setting the field back to false takes the zone out at the next network update; with fewer than two zones left the repeater is switched off.
Zones on a new and an updated site¶
A fresh install applies profiles, not the whole template: core (mgmt, wan, the overlays, the default service zone, home, guest, dmz) and optionally iot (iotLocal, iotCloud, iotCams, iotUntrust). init renames srv to the site's default environment and rewrites every reference to it; home and guest keep their names. Commands: network-manager init.
On every update, network-manager merge reconciles the site's zones with the new template by a 3-way merge of zones.json (current), zones.json.orig (what it was last merged from) and zones.rename.json (the new template with the site's rename applied): a field the site never changed follows the release, a field it changed is kept; state and environment are always the site's. A zone new in the template is added; a zone the site has and the template dropped is kept with a warning, and removed only by network-manager retire, never while a module still runs in it.
Installed modules stay in their zone: TAPPaaS never moves a running service between zones. Moving one means backing up, uninstalling and reinstalling it in the target zone.
Example¶
{
"mgmt": {
"type": "Management", "state": "Manual", "typeId": "0", "subId": "0",
"vlantag": 0, "ip": "10.0.0.0/24", "bridge": "lan", "tier": 0,
"access-to": ["internet", "all"],
"pinhole-allowed-from": [],
"description": "Control plane: hypervisors, backup, firewall, identity, cicd"
},
"warmelo": {
"type": "Service", "state": "Active", "typeId": "2", "subId": "0",
"vlantag": 200, "ip": "10.2.0.0/24", "bridge": "lan", "tier": 1,
"environment": "warmelo",
"access-to": ["internet", "iotCloud", "iotLocal"],
"pinhole-allowed-from": ["dmz"],
"description": "Service zone for the warmelo environment"
},
"home": {
"type": "Client", "state": "Active", "typeId": "3", "subId": "10",
"vlantag": 310, "ip": "10.3.10.0/24", "bridge": "lan", "tier": 2,
"environment": "warmelo",
"access-to": ["internet", "iotCloud", "iotLocal"],
"pinhole-allowed-from": [],
"description": "Trusted personal devices: laptops, phones, tablets"
},
"iotCams": {
"type": "IoT", "state": "Active", "typeId": "4", "subId": "30",
"vlantag": 430, "ip": "10.4.30.0/24", "bridge": "lan", "tier": 6,
"isolated": true,
"environment": "warmelo",
"access-to": [],
"pinhole-allowed-from": [],
"description": "Surveillance: cameras + NVR, fully isolated"
}
}
warmelo is the warmelo environment's zone; home reaches it through its environment link, and iotCams accepts pinholes from it through its own — neither names the service zone. An NVR in warmelo reaches the cameras only through its module's pinhole, never zone-wide.
WiFi: the SSID field¶
An optional SSID is the WiFi network broadcast on the zone's VLAN, used by the WiFi tooling (network/scripts/README.md): setup-wlan-secrets.sh sets the real name and stores the passphrase in a 0600 secrets file (never in zones.json), and ap-manager maps each SSID to its zone's VLAN. A zone without WiFi omits it or sets null; the shipped <PLACEHOLDER> means "not chosen yet". Only an Active zone broadcasts.