Skip to content

network-manager

The network front door. It owns zones.json (the desired network state) and reconciles four infrastructure planes — OPNsense, Proxmox, the switch, and access points — by orchestrating their controllers. Adding or deleting a zone authors the config and drives every plane so a new VLAN actually reaches the firewall, the Proxmox hosts, the physical switch, and the WiFi.

What it owns

zones.json at ${TAPPAAS_CONFIG:-/home/tappaas/config}/zones.json. Each top-level key is a zone (keys beginning _ are documentation blocks, preserved but not treated as zones). A zone records its type / typeId, vlantag, ip (CIDR), bridge, state, reachability (access-to), per-module pinhole allowance (pinhole-allowed-from), and optional variant/SSID. Auto-allocated VLANs use the 60–99 window within each type band; zone names must be camelCase.

Changing a zone's policy — modify --set

network-manager modify rossen --set access-to=internet,mgmt
network-manager modify rossen --set 'pinhole-allowed-from=["home","work"]'

A zone's policy fields — access-to, pinhole-allowed-from, description, isolated, tier, the DHCP options — are changed by a verb rather than by hand-editing zones.json, which is the anti-pattern ADR-014 D1 named. Lists take either the comma form or JSON.

Like enable/disable/bind, this authors zones.json only and prints the reconcile --apply to run: the operator decides when the firewall changes.

What it refuses, and why the two refusals read differently (ADR-020 D6):

Field
vlantag, ip, type, typeId, subId, bridge immutable The zone's identity. Changing a VLAN would strand every guest tagged into it — that is delete-and-re-add, with the guests moved deliberately.
state has a verb enable / disable / manual, which guard the Mandatory transition.
environment has a verb bind --environment, which checks the environment exists and keeps one zone per environment, so a typo fails at the command.

A --set naming any of those is refused before anything is written, and a mixed --set is rejected whole — zones.json and the planes never move apart. Each field's cost is declared as changeClass in schemas/zones-fields.json.

Commands

One compiled CLI, network-manager:

network-manager list                 [--state S] [--type T] [--tier N] [--json]
network-manager exists <name>
network-manager show <name>         (alias: get)
network-manager add <name>          [options]
network-manager delete <name>       [--check]
network-manager enable|disable|manual <name> [--force]
network-manager bind <zone>         --environment <env> [--add] | --unbind [--environment <env> [--own]]
network-manager reconcile           [--apply] [--only opnsense|proxmox|switch|ap]
network-manager topology            [--json] [--health <file> | --no-health] [--no-positions]
network-manager init  [<profile>] --name <N> [--from <tpl>] [--out <f>] [--force]
network-manager retire      [--apply]
network-manager validate    [--zones <file>] [--config-dir <dir>] [--strict]
                            [--effective]
network-manager merge       [--diff] [--config-dir <dir>] [--template <tpl>]
network-manager distribute  [--zones <file>] [--dry-run]
network-manager wgvpn       <setup|add-peer|remove-peer|list|config> [options]
network-manager wan ipv6-relearn [--check | --apply]
network-manager -h | --help

The zone keyword is an optional, legacy prefix — add and zone add are equivalent. (validate keeps the alias zones-check.)

list / exists / show / add / delete — CRUD on zones.json

network-manager list                 # name / state / vlan
network-manager list --state Inactive       # which zones are defined-but-off
network-manager list --type Client          # + tier and environment columns
network-manager list --tier 6               # the no-egress zones
network-manager exists srvHome        # exit 0/1
network-manager show srvHome          # print the zone object (alias: get)

list filters combine (--state Active --type Service); --json emits the matching names as an array. --type Client|IoT|Guest (and any --tier query) adds tier, environment and isolated columns — which is how you answer "which client zones exist, and which environment does each serve?"

add <name> authors a new zone and reconciles all four planes (so the VLAN reaches everything). Options:

  • --archetype <A> — the preferred way to create a zone (ADR-014 D5). Stamps a tier-correct type/typeId/tier/isolated and the access-to seed, then auto-allocates the VLAN/IP — no hand editing, and the result passes validate unmodified. One of: control, service, trusted-client, guest, dmz, iot-local, iot-cloud, iot-cams, iot-untrust. Cannot be combined with --type/--typeId/--from-zone (the archetype already defines them); the combination is refused rather than resolved.
  • --environment <env> — the environment the new zone belongs to, in the same command (see bind below): for a Service zone the environment whose zone it is (refused when that environment already has one), for a Client/IoT/Guest/DMZ zone the environment(s) it serves (a,b for several). The environments must exist.
  • --from-zone <src> — inherit type/typeId/bridge/access-to/pinhole from <src>.
  • --type <T> — zone type (default Service).
  • --typeId <N> — numeric type band (default 2).
  • --vlan <tag> — explicit VLAN tag (else auto-allocated 60–99 in the band).
  • --variant <name> — tag the zone with this variant (metadata).
  • --no-activate — author zones.json only; skip the all-plane reconcile.
  • --check — dry-run: show what would change, mutate nothing.

Before it authors, add asks the firewall what it carries (opnsense-firewall segments). A VLAN tag or subnet that is already there on a segment zones.json never knew — one built by hand in OPNsense, or left behind by a deleted zone — is refused with the holder named, on --check too. The allocation is not retried: TAPPaaS's own numbers stay the same on every site, and the operator moves the site's own segment to a free tag, then adds again. init (a later profile on a running site) and merge (a template zone new in a release) make the same check. A firewall that cannot be asked is said, and the check covers zones.json only, as before.

delete <name> disables the zone, reconciles all planes, then removes the key. --check dry-runs it.

A zone authored the low-level way (--type/--from-zone) is deliberately left untiered — validate notes it — rather than being given a guessed rank.

network-manager add cams   --archetype iot-cams --environment warmelo
network-manager add lab    --archetype service --environment lab
network-manager add labNet --from-zone srvHome --vlan 275   # low-level escape
network-manager add labNet --check          # preview
network-manager delete labNet

A zone names its environment in one field, environment; the environment file names no zone.

  • A Service zone names the environment whose zone it is, as a string — its own zone, where its modules run by default. Every environment has exactly one (the Management zone mgmt is the mgmt environment's). environment-manager add|modify --zone sets it for you.
  • A Service zone naming a list of environments (even of one) is a shared service zone: it serves each, owns none, and derives no edge. A module of any environment it lists may be installed there (--zone0 <zone>); see ZONES.md.
  • A Client, Guest or IoT zone names the environment it serves, or a list of them when it serves several (it gets the edges of each). A client zone reaches its services, and an IoT zone is reached by them; writing that as a literal service-zone name does not survive install (init renames srv → <defaultEnvironment>, and the literal is stranded), so the edge is derived from the environment's current zone on every reconcile.
  • A DMZ zone may name the environment(s) it fronts; that derives nothing.
  • Overlay and WAN zones name none.
network-manager bind home    --environment warmelo        # home serves warmelo
network-manager bind home    --environment lab --add      # ... and lab as well
network-manager bind home    --unbind --environment lab   # ... no longer lab
network-manager bind lab     --environment lab            # lab is the lab environment's zone
network-manager bind srvWork --environment custB --add    # srvWork is shared: it serves custB too
network-manager bind srvWork --unbind --environment custB # ... no longer custB (a list of one stays shared)
network-manager bind srvWork --unbind --environment custA --own  # ... the last one left owns it
network-manager bind iotCams --unbind                     # clears the link

A Service zone's own environment is one name; --add makes it a shared service zone (the environment it was the own zone of then needs another), and a plain bind on a shared zone is refused. --own turns a shared zone left with one environment into that environment's own zone, unless it already has one. The Management zone is never shared. bind refuses an unknown environment, a second Service zone for an environment, and taking a Service zone away from the environment it belongs to (move the environment with environment-manager modify <env> --zone <zone>), and prints the edge the link derives. Like enable/disable it does not reconcile; apply with network-manager reconcile --apply.

Authored vs. effective — zones.json and zones.effective.json

zones.json is authored state: what the operator wrote. The edges an environment link derives are never written back into it — if they were, the 3-way merge would treat them as operator edits and pin them, so clearing a link would strand its edges.

Instead, every authored write (and the start of every reconcile) renders zones.effective.json beside it, and that is what the consumers read: zone-manager (the OPNsense plane), rules_manager (per-module pinhole/egress validation) and the Caddy access lists. It is generated — never hand-edit it. distribute still pushes the authored file, since the nodes need only vlantag/ip.

An unresolvable environment link (an environment that does not exist, or one no Service zone names) is non-fatal on a zone add (a missing environment file must not block authoring) but fatal to reconcile and an error in validate, which also refuses an environment named by no Service or Management zone, or by two.

enable / disable / manual — atomic zone state change

The operator state verbs (was zone-state.sh): enable → Active, disable → Inactive, manual → Manual. They mutate zones.json atomically and deliberately do not touch the planes — apply when ready with network-manager reconcile --apply. Guards: an unknown zone is refused (the known zones are listed), a same-state verb is a no-op (exit 0), and leaving the Mandatory state (e.g. dmz) is refused without --force. Mandatory and Disabled are intentionally not exposed as verbs (Disabled is reserved for the delete lifecycle).

network-manager enable srvTest
network-manager disable srvTest
network-manager disable dmz --force   # Mandatory zones refused otherwise

reconcile — the 4-plane converge loop

network-manager reconcile                  # dry-run: report drift on every plane
network-manager reconcile --apply          # converge every plane
network-manager reconcile --only switch    # one plane only
network-manager reconcile --apply --only proxmox

--only <plane> is one of opnsense | proxmox | switch | ap. Default is a non-mutating dry-run. Exit 0 = in sync, 2 = drift reported (dry-run, not a failure), 1 = a hard error (a plane errored, or Proxmox still drifts after --apply).

The opnsense plane's dry-run runs zone-manager in check mode with --detailed-exitcode, which lists every firewall rule --apply would create, renumber or delete — deletions by the orphan reaper included — and then the pinhole validator (#645). Before this, a dry-run listed no rule at all, so a deletion could not be reviewed before it happened.

init [<profile>] (alias zones-init) — composable install profiles

init applies an additive, idempotent profile bundle to zones.json (ADR-014 D7). It replaces the old single flat transform that shipped ~23 zones and then inactivated the ones it did not want.

Profile Zones
core (default) mgmt · wan · netbird/edge/admin overlays · <N> (the renamed srv, Active) · home · guest · dmz (Mandatory)
iot iotLocal · iotCloud · iotCams · iotUntrust — all Active, each bound to the default environment
network-manager init core --name acme     # the minimal coherent install
network-manager init iot  --name acme     # opt in to the IoT segment set

Properties, all unit-asserted:

  • Additive — a profile only ever adds its zones, plus the access-to entries its zones need on zones from another profile (init iot gives the service and client zones reach to the devices). It never removes or deactivates anything.
  • Idempotent — re-applying a profile is a byte-level no-op.
  • Order-independent — core then iot == iot then core.
  • Non-destructive — existing zones always win. A re-run can never rebuild a live file from template defaults; operator zones, custom states and edited access lists survive. --force re-stamps this profile's zones from the template.
  • srv → <N> is the only rename, applied to keys, to every zone reference, and to the environment placeholder. home/guest keep their names: the zone key drives the client DNS domain <zone>.internal.

A site with no smart-home devices simply never runs init iot and never carries those zones. Extra service zones, a second client segment and so on come from add --archetype or environment add --create-zone, not from dormant template entries.

retire — remove zones a release stopped shipping

Deleting a zone from the template does not remove it from an existing install: the 3-way merge keeps anything present locally but absent upstream (which is right — it is what stops a release silently deleting an operator's zone). retire is the explicit, guarded cleanup.

network-manager retire            # dry-run: what would go, and what is kept
network-manager retire --apply    # commit

It considers exactly srvHome, srvWork, srvCust, srvDev, srvTest, iot, test, testAllowA, testAllowB, testPinhole — an explicit list, never "everything Inactive". A zone is removed only when it is both

  • not Active/Mandatory/Manual (it provisions nothing today), and
  • not named by any installed module's zone/zone0.

Anything else is kept and reported with the reason. References to a retired zone are stripped from every other zone's access-to/pinhole-allowed-from. srv (the rename source) and work (a legitimate client zone that is merely switched off) are never retired.

validate (alias zones-check) — offline consistency audit

validate is the standardized verb (ADR-007); zones-check is kept as an alias. Both run the same read-only zones.json audit.

network-manager validate                   # = zones-check
network-manager zones-check
network-manager validate --strict          # warnings become errors
  • --zones <file> — zones.json to check (default $TAPPAAS_CONFIG/zones.json).
  • --config-dir <dir> — installed module configs dir (default $TAPPAAS_CONFIG).
  • --strict — promote warnings to errors.

Exit 0 ok, 1 on dangling references / missing required fields / lost zones.

validate --effective — audit the graph the planes actually receive

validate audits the authored zones.json by default. A client or IoT zone's environment link contributes an edge that exists only in the rendered document, so an authored-scope run cannot judge it — and says so in its output. Add --effective to render the links and check the real graph:

network-manager validate               # authored: authoring mistakes
network-manager validate --effective   # rendered: what zone-manager receives

This matters for the tier checks: a client zone's edge to its service zone lives only in the render, so --effective is where I1 can see it.

distribute (alias zones-distribute) — push zones.json to the Proxmox nodes

zones-distribute is kept as an alias.

network-manager distribute
network-manager distribute --dry-run  # list target nodes, no copy
  • --zones <file> — zones.json to distribute (default $TAPPAAS_CONFIG/zones.json).
  • --dry-run — list the nodes that would receive it; copy nothing.

topology — the physical site, derived (ADR-034 D5)

network-manager topology                      # the WAN path, nodes, switch ports, LAGs, findings
network-manager topology --json               # the document the console's Physical view draws
network-manager topology --json --health v.json   # reuse a `health-manager validate --json`

One read-only document joined from what the other verbs already report: site.json, site-manager node list and node network show (bonds, legs, LLDP peers, the wan port), switch-manager list / list-ports / lag list (ports, VLANs, LAGs, drift), ap-manager list, zones.json, module-manager module list (guests and their run state), backup-manager peers and health-manager validate (findings). It stores nothing.

The join reconciles the sources' spellings: a switch compares by name without case or punctuation (USW Pro XG 10 PoE is LLDP's USW-Pro-XG-10-PoE), an LLDP Port 11 is port 11, a NIC is matched by name or MAC with LLDP first, a device port whose target is a node is that node's wan uplink, and a device port in the wan zone with no target is the ISP hand-off. What does not reconcile — a node LLDP sees on a port the inventory does not give it, a switch nobody registered — is a topology finding. Health findings are placed on the object they name; a lan-uplink finding is also put on the node's LAG and switch ports. A source that does not answer is listed under sources and its part is left empty.

On a site with more than one switch a node's legs read <switch>:<port>. An MLAG pair (from switch-configuration-actual.json) is drawn under Switches with its peer link; its member ports read peer-link, and a LAG across the pair is drawn once, under the pair (mlagPairs in --json). A LAG is formed only when every member is enabled, has link and is aggregated; otherwise it says how many members are active and why. A disabled port reads disabled. The pair's live state is switch-manager mlag-pair list.

The positions come from position-manager export (config/positions.json and the faceplate catalog): positions in --json holds the site's locations (the primary first, each with its place, its rooms and the devices at it placed nowhere), the rooms with their location, each rack with its placements resolved to their devices and faceplates (live port state on every plate port), the devices placed nowhere, and the cables — every link the switches and LLDP see, mapped to faceplate ports, plus the ones position-manager records (the ISP's modem, a patch panel). devices lists the machines, the devices switch ports name, the devices position-manager records and the devices a switch port sees that no record names.

Every device — node, switch, access point, device — carries location (a name of site.json locations: a recorded device's own, a switch's or an access point's when its location text names one, else the primary), placement ({room, rack, u}, or null) and source: cluster, module, switch-configuration, recorded or seen. A seen device comes from a switch port: each MAC its controller reports behind the port (neighbors in switch-manager list-ports --json, with what LLDP and the lease say), or a port that is up with nothing reported or recorded on it; a port of a node, an access point, a switch or the ISP, a port a recorded cable lands on and a MAC a recorded device or a node's NIC has are accounted for. It is named by its MAC (else <switch>:<port>), at the switch's location, placed nowhere, and position-manager device record records it. A placement whose device is gone, and once the site has a rack a device placed nowhere at a location that has a room, are topology findings. --no-positions leaves them out (what position-manager itself reads, to check device names).

wan ipv6-relearn — re-learn the WAN's IPv6 router

network-manager wan ipv6-relearn            # = --check
network-manager wan ipv6-relearn --apply

For the day the ISP replaces its router: the firewall keeps routing IPv6 to the old one and health-manager's ipv6-egress gate warns, naming this verb. --check sends one router solicitation on the WAN and prints what the firewall records (the router, the default route and whether its router answers, the WAN_DHCP6 gateway, the delegated prefix, whether rtsold runs), the router answering now, and what --apply would do. --apply, through the opnsense controller (ipv6-manager relearn), in this order:

  1. records the router that answers, as rtsold's own script does (ifctl -6rd);
  2. re-runs the firewall's IPv6 WAN event (configctl interface newipv6 <wan> force): the default route and the WAN_DHCP6 gateway move to it, the filter is reloaded;
  3. starts rtsold only if it is not running;
  4. runs the network module's IPv6 converge (ipv6-manager apply), so the zones follow a changed prefix.

Why not configctl interface reconfigure wan, which re-learns the router too, or a restart of rtsold: both make rtsold's script reload dhcp6c, which releases the DHCPv6 lease and solicits a new one, and a new lease can bring a new delegated prefix: every zone renumbers and devices keep the old addresses for an hour. The IPv6 WAN event alone changes only the route and the gateway, and the lease stays. The network module's DESIGN.md (The WAN's IPv6 router) has the reasoning from OPNsense's scripts.

wgvpn — the admin VPN (ADR-010 §6, §8.4.6)

The operator's WireGuard session into the management plane. It terminates on OPNsense and lands in the admin overlay zone (10.255.1.0/24), which one least-privilege rule lets reach mgmt. No satellite is needed: a Site with a public IP is reached directly, a CGNAT Site through a satellite carrying the admin-vpn role, which only relays the UDP.

network-manager wgvpn setup                                   # idempotent; the cicd install runs it
network-manager wgvpn add-peer --name laptop --pubkey <key>   # prints the device's client config
network-manager wgvpn list
network-manager wgvpn remove-peer laptop
network-manager wgvpn config laptop [<host:51821>]            # print a config again (v2: DNS + published names)

add-peer's endpoint is --endpoint if given, else the address of a registered satellite with the admin-vpn role, else a placeholder. The tool is bash (wgvpn/wgvpn.sh, linked as network-manager-wgvpn); it was satellite-manager admin. Runbook for Mac and Linux devices: ADMIN-VPN.md.

Legacy bash tools

Only zone-reconcile is still linked onto PATH during the transition (plus network-manager-wgvpn, which network-manager wgvpn runs). migrate-zone-keys-*.sh is a one-shot migration helper, not an on-PATH tool. Retired (ADR-007 Phase 7.5 / post-implementation refactor): apply-zones-merge.sh → network-manager merge (alias zones-merge, ADR-007 "Design A"); zone-controller.sh → network-manager add/delete (the native zone lifecycle); zone-state.sh → network-manager enable/disable/manual.