Liberator = single-box on-prem stack. 1x Mikrotik hAP ax2 + 1x Proxmox host running OPNsense + NixDns + Win Server DC + Nextcloud + Mail + PMG LXC + optional Win clients, all VLAN-segmented.

Roles

Role discriminator = host.Description. Pattern already in use at src/NETSetup/Config/Translators/DeviceTranslator.cs:297.

Role host.Description Mikrotik upstream OPNsense VM VLAN flow

Liberator master

Liberator

yes

yes

full

Power Station

Power Station

future (cluster)

no

none, flat vmbr0

Vanilla Proxmox

Proxmox or other

no

no

none, flat vmbr0

Example customer config (TestCompany style):

.AddDevice("Liberator", DeviceTypes.Server)
    .WithSerialNumber("lib")
    .WithDescription("Liberator")

.AddDevice("Power-1", DeviceTypes.Server)
    .WithSerialNumber("power-1")
    .WithDescription("Power Station")

Detection

Two-layer mechanism:

  1. Translator-time (handled by LiberatorWithWinDCTranslator.InjectImplicitLiberatorStack): customer .cs builder marks single host with WithDescription("Liberator"). Translator, after the device-foreach loop, synthesises 5 child VMs into config.DirectoryService.Machines. VMIDs sit in the 10xx range (Proxmox rejects anything < 100); IPs pin to the default OU’s /24 by last octet = vmid mod 1000, except OPNsense which takes the gateway slot .254. Names align with LiberatorVmHostnames so DnsKey lookups in NixTemplateFactory resolve.

    Name VMID LastOctet Serial pattern Entity type

    OPNSense

    1001

    .254

    1001@<libSerial>

    NETSetupFirewall

    Dns

    1002

    .2

    1002@<libSerial>

    NETSetupDnsServer

    Nextcloud

    1020

    .20

    1020@<libSerial>

    Server

    Mail

    1090

    .90

    1090@<libSerial>

    Server

    PMG

    1091

    .91

    1091@<libSerial>

    Server

    Power Station hosts (WithDescription("Power Station")) get NO child synthesis. Description is consumed here and not propagated to Machine - by design. Conflict with an explicit AddDevice of the same name throws at translate time.

  2. Runtime gate in UpdateMethods.RunProxmoxUpdate: the resulting config shape determines the flow. Single check:

var runFullNetworkFlow =
    GetOrderedVmsForHost(config, host.SerialNumber)
        .Any(v => v.OperatingSystem == OS.OPNSense);

OPNsense-presence IS the canonical runtime signal. Liberator hosts always have OPNsense synthesised; Power Station / vanilla Proxmox hosts never do.

Translator-time Description Resulting config runFullNetworkFlow

Liberator

OPNsense + Nextcloud + Mail + Dns Machines synthesised

true

Power Station

bare host, no synthesised VMs

false

Proxmox / empty / other

bare host

false

If !runFullNetworkFlow: skip OPNsense provision, skip Mikrotik API push, skip OPNsense API push, skip Proxmox VLAN bridge config, skip VM NIC retag. VMs (Nix / Win / PMG) explicitly listed in config still get provisioned on flat vmbr0 untagged via the same GetOrderedVmsForHost serial-matching code path.

Topology

@startuml Liberator-Network

title Liberator network topology - hAP ax2 + Proxmox + OPNsense + VLANs

' Role discriminator = host.Description (DeviceTranslator.cs:297 pattern):
'   "Liberator"     -> full flow: Mikrotik upstream + OPNsense on host + VLANs
'   "Power Station" -> bare Proxmox, future cluster compute, no router/VLAN
'   "Proxmox" / other -> bare Proxmox, no router/VLAN
' Defensive double-gate: isLiberator && hasOpnsenseVm in config for host.SerialNumber.
'
' VLAN map: 10 = server tier, 20 = LAN clients, 250 = OOB/MGMT, 254 = Guest.
' WAN on Mikrotik: dual-WAN auto-detect. ether1 untagged (plain DHCP) AND ether1-vlan10 tagged
'   (XGS-PON modem delivers WAN on VLAN10). dhcp-client on BOTH; only one leases (same wire);
'   both are members of the WAN interface-list. Built by flash-mikrotik .rsc (MikrotikRscBuilder).
' DHCP source of truth: OPNsense per VLAN. Static reservations from config.Computers[].MAC.
' DNS resolver: NixDns VM running Unbound. OPNsense forwards all clients to NixDns IP.

skinparam rectangle {
  BackgroundColor<<infra>> #E8F0FE
  BackgroundColor<<svc>>   #E6F4EA
  BackgroundColor<<fw>>    #FCE8E6
  BackgroundColor<<dns>>   #FEF7E0
  BackgroundColor<<net>>   #F3E8FD
}

cloud "ISP / GPON CPE\nuntagged DHCP OR XGS-PON tagged VLAN10" as NET

node "Mikrotik hAP ax2 (L2 only post phase 2)\nmgmt IP VLAN250 .1\nNO NAT, NO DHCP server" as MT <<net>> {
  rectangle "ether1 (2.5G) WAN\ndual-WAN: ether1 untagged DHCP\n+ ether1-vlan10 tagged DHCP (XGS-PON)\nboth in WAN interface-list" as MT_E1
  rectangle "ether2 + ether3 trunk\n(LACP 802.3ad from flash-mikrotik;\nMikrotikDesiredStateBuilder pushes\nbridge-ports + VLANs only)\ntrunk: 10,20,250,254" as MT_BOND
  rectangle "ether4 access\nVLAN20 untagged" as MT_E4
  rectangle "ether5 access (spare LAN port)\nVLAN20 untagged" as MT_E5
  rectangle "wlan 2.4 + 5 GHz\nSSID Liberator\nWPA2/3 -> VLAN20" as MT_W_LAN
  rectangle "wlan 2.4 GHz\nSSID Liberator-Guest\nopen, no portal -> VLAN254" as MT_W_GUEST
  rectangle "wlan 5 GHz hidden\nSSID admin-oob-lib\nWPA3 -> VLAN250" as MT_W_ADMIN
}

NET <--> MT_E1 : public IP

node "Proxmox host (Liberator)\nbond0 = enp1s0 + enp2s0 LACP\nvmbr0 VLAN-aware trunk\nPhase 1 (fresh install, answer.toml):\n  10.0.0.200/24 gw 10.0.0.1 dns 10.0.0.1 (Mikrotik LAN)\nPhase 2 (every netsetup update cycle):\n  vmbr0v250 = 10.0.250.2/24 gw 10.0.250.1 dns NixDns\n  (ConfigureProxmoxBridges Phase 3e)" as PVE <<infra>> {

  rectangle "OPNsense VM (Option Y, 5 vNIC)\nserial 1001@lib\n  net0 untagged   = WAN\n  net1 tag=10     = .1 server tier gw\n  net2 tag=20     = .1 LAN gw + DHCP\n  net3 tag=250    = .3 MGMT\n  net4 tag=254    = .1 Guest gw\nFW deny-default inter-VLAN\nNAT outbound\nDNS forwarder -> NixDns\nREST API creds deterministic (HMAC),\nopnsense-api.env (re)written every update run" as OPN <<fw>>

  rectangle "NixDns VM (coredns + Traefik)\nserial 1002@lib\nVLAN10\nIP = OU default /24 .2\nresolver + reverse + DDNS\nSINGLE inbound ingress (Traefik):\n  80/443 HTTP host-route\n  25/587/993 TCP passthrough" as DNS <<dns>>
  rectangle "Win Server DC\nserial dc@lib\nvNIC tag=10\nVLAN10" as DC <<svc>>
  rectangle "Nextcloud VM\nserial 1020@lib\nvNIC tag=10 = .20\nVLAN10" as NC <<svc>>
  rectangle "Mail VM\nserial 1090@lib\nvNIC tag=10 = .90\nVLAN10" as MAIL <<svc>>
  rectangle "PMG LXC\nctid 1091@lib\nvNIC tag=10 = .91\nVLAN10" as PMG <<svc>>
  rectangle "Win11 client VM\nvNIC tag=20\nDHCP from OPNsense VLAN20" as W11 <<svc>>
}

MT_BOND <==> PVE : LACP 802.3ad trunk\n10 / 20 / 250 / 254

OPN -[hidden]- DNS
OPN -[hidden]- DC

DNS --> OPN  : VLAN10
DC --> OPN   : VLAN10
NC --> OPN   : VLAN10
MAIL --> OPN : VLAN10
PMG --> OPN  : VLAN10
W11 --> OPN  : VLAN20

MT_E4 ..> OPN : VLAN20 access (LAN clients reach OPNsense via Mikrotik bridge)
MT_W_LAN ..> W11 : VLAN20 (WiFi clients)
MT_W_ADMIN ..> PVE : VLAN250 OOB (admin laptop -> Proxmox host independent of OPNsense)

note bottom of OPN
  **Option Y: 5 tagged vNICs**
  Hypervisor enforces VLAN, defence in depth.
  qm create:
    -net0 virtio,bridge=vmbr0
    -net1 virtio,bridge=vmbr0,tag=10
    -net2 virtio,bridge=vmbr0,tag=20
    -net3 virtio,bridge=vmbr0,tag=250
    -net4 virtio,bridge=vmbr0,tag=254
  Other VMs: single tagged vNIC each.
end note

note right of PVE
  Out-of-band mgmt path:
    laptop -> admin-oob-lib WiFi (hidden, VLAN250)
    -> Mikrotik bridges VLAN250 over LACP trunk
    -> Proxmox host .2 reachable
    even if OPNsense down.
end note

note left of MT
  **Phase 1 bootstrap** (today's flash-mikrotik + Liberator-gated answer.toml):
    flat NAT, Mikrotik DHCP, single vmbr0 on host.
    Proxmox IP = 10.0.0.200/24 gw 10.0.0.1 dns 10.0.0.1.
    Mikrotik IP = factory 10.0.0.1/24.
  **Phase 2 cutover** (additive then strip, every netsetup update cycle):
    ConfigureMikrotikVlans  -> bridge VLAN-aware, VLAN 10/20/250/254
                               Mikrotik mgmt 10.0.250.1 (LiberatorNetwork.MikrotikMgmtIp)
    ConfigureProxmoxBridges -> rewrite /etc/network/interfaces:
                               bond0 + vmbr0 + vmbr0v250 = 10.0.250.2/24 gw .1 dns NixDns
                               (idempotent: only ifreload -a on diff)
    RewireVmNicsToVlans     -> qm set <vmid> -netN ...,tag=<vlan> drift fix
    Enable OPNsense WAN VLAN if -> internet flows via OPNsense
    Strip Mikrotik flat NAT + DHCP -> Mikrotik = pure L2.
  All IPs + bridge names come from LiberatorNetwork.cs constants - no literals.
  Re-pushed every cycle, drift-correcting.
end note

note top of NET
  **Role gate**: host.Description == "Liberator"
  AND OPNsense VM in config for host.SerialNumber.
  Otherwise (Power Station / Proxmox / other):
  skip entire Mikrotik + OPNsense + VLAN flow.
  VMs still provisioned on flat vmbr0 untagged.
end note

note as INBOUND
  **Inbound published services (DNAT + firewall-allow)**
  Goal locked 2026-05-22, IMPLEMENTED. On-prem authoritative, NO VPS.
  DNS VM is the **SINGLE ingress** - edge DNAT does NOT split per service.
  Config-driven, ManagedPrefix "netsetup-" (converge deletes only its own).

  Edge DNAT chain (per ServedPort, single target):
    Mikrotik WAN: dst-nat {port} in-interface-list=WAN -> OPNsense WAN IP
        nat   "netsetup-dnat-{port}"  (chain dstnat, action dst-nat,
                  in-interface-list=WAN, in-interface empty)
        filter"netsetup-allow-{port}" (chain forward, action accept,
                  in-interface-list=WAN, in-interface empty)
      Matches WAN LIST (ether1 + ether1-vlan10), NOT literal ether1, so
      port-forward fires on whichever WAN iface leases (plain DHCP vs
      XGS-PON VLAN10). Converge REFERENCES the WAN list but does NOT
      create it - flash-mikrotik .rsc builds the list + members first
      (flash-then-update order). Const MikrotikDesiredStateBuilder.WanInterfaceList.
      ONLY when a NETSetupMikrotik is in config. Else operator
      forwards the modem port -> OPNsense WAN IP by hand.
    OPNsense WAN: d_nat {port} (DestinationNet=wanip) -> NixDns LAN IP
        rdr    "netsetup-dnat-{port}" (descr=identity, DestinationNet wanip,
                  target NixDns LAN IP, local-port {port}, disabled "0"=active)
        filter "netsetup-fwd-{port}"  (pass WAN -> NixDns LAN IP:{port})

  NixDns Traefik sorts (host.dns.reverseProxy):
    80/443    -> HTTP host-route by Host header:
                   nextcloud.<domain> -> Nextcloud .20
                   mail.<domain>      -> Mail .90 (roundcube)
    25        -> TCP passthrough L4 (HostSNI catch-all, tls.passthrough,
                   NO termination, STARTTLS untouched) -> PMG .91
                   -> spam/virus filter + pmgsh domain+transport
                   -> Mail .90 (postfix smtpd 25)
    587, 993  -> TCP passthrough L4 -> Mail .90 (submission / IMAPS)
    tcpForwards { listenPort; backendHost; backendPort; } per mail port.

  PMG is the per-domain inbound relay (NOT a hand-edited main.cf).
    netsetup pmgsh push every cycle:
      relay domain + transport <domain> -> smtp:[mailVmIp]:25 use_mx 0
      default smarthost mailVmIp:25
    Idempotent, self-heals. Replaces OLD broken relayhost mail.<domain>:26.
  Mail VM postfix mynetworks includes PMG /32 so PMG forwards are trusted
    (host.mail.trustedNetworks, resolved by NixTemplateFactory.ResolvePmgIp).

  Internal DNS auto-emit (NixDns Unbound, split-horizon, 2026-05-26 + 2026-05-27):
    mail.<domain>     IN A   Mail .90    (NixDnsTemplate.ExtraARecords)
    proxmox.<domain>  IN A   10.0.250.2  (NixDnsTemplate.ExtraARecords, Liberator only,
                                          LiberatorVmHostnames.ProxmoxDnsKey, 2026-05-27)
    <domain>.         IN MX  10 mail.<domain>.   (NixDnsTemplate.MxRecords)
  Public DNS MX + A at registrar is STILL MANUAL (no DomainProvider MX-write yet).

  Public IP follows dyndns: MX laufentaler.org -> laufentaler.ddns.net (No-IP)
  -> current WAN IP, refreshed by ddns-updater on NixDns.
  Outbound mail: Mail VM -> Brevo SMTP 587 (SASL). No direct MX (dynamic IP).
end note

@enduml

Proxmox Host IP - Two Phases

Liberator Proxmox host IP changes between fresh install (Phase 1) and post-converge steady state (Phase 2). Both IPs + the mgmt VLAN id + bond/bridge names live as public const on src/NETSetup/Helpers/LiberatorNetwork.cs - NO hardcoded literals anywhere else in the code.

Layer Phase 1 (fresh install, answer.toml) Phase 2 (every netsetup update cycle) Set by Why

Proxmox host IP

10.0.0.200/24

10.0.250.2/24

ProxmoxAutoInstallExtensions.ToProxmoxAutoInstall (Liberator gate) → ConfigureProxmoxBridges Phase 3e

At install time OPNsense + NixDns + VLAN 250 trunk do not exist yet; Mikrotik factory IP 10.0.0.1/24 is the only constant. Phase 2 moves Proxmox onto mgmt VLAN 250.

Gateway

10.0.0.1 (Mikrotik factory LAN)

10.0.250.1 (Mikrotik mgmt)

same

Phase 1 = Proxmox on Mikrotik LAN; Phase 2 = Mikrotik is pure L2, mgmt VLAN owns gateway.

DNS

10.0.0.1 (Mikrotik forwards upstream)

NixDns LAN IP

same

NixDns is not up at install time; Phase 2 promotes NixDns once it is healthy (see proxmox_dns_bootstrap_health_gate).

Mikrotik

factory 10.0.0.1/24 (post-flash-mikrotik)

mgmt 10.0.250.1, pure L2

MikrotikDesiredStateBuilder (mgmt IP from LiberatorNetwork.MikrotikMgmtIp)

Cutover strips flat NAT + DHCP.

NixDns auto-emits an ExtraARecords["proxmox"] = 10.0.250.2 for Liberator configs, so proxmox.<domain> resolves once Phase 2 has run. Key constant: LiberatorVmHostnames.ProxmoxDnsKey = "proxmox".

Gate signal (Phase 1 install-time AND Phase 2 runtime): OPNsense VM present in config.DirectoryService.Machines for this host’s serial. host.Description == "Liberator" is the translator input only - not preserved on the Server entity. Same gate logic at both stages:

  • Install-time mirror: ProxmoxAutoInstallExtensions.IsLiberatorHost

  • Runtime: LiberatorGate.IsFullLiberatorFlow

Non-Liberator hosts keep their old behaviour (answer.toml stays DHCP or customer-OU-subnet, no Phase 2 bridge rewrite).

VLAN Map

VLAN Purpose Hosts OPNsense if

(untagged)

WAN

ISP/GPON CPE

vtnet0 DHCP client

10

Server tier

NixDns .53, DC .10, NC .50, Mail .51, PMG .52, OPNsense .1

vtnet1 = .1

20

LAN clients

Win clients, printers, scanners, WiFi Liberator

vtnet2 = .1, DHCP server

250

OOB / MGMT

Mikrotik .1, Proxmox host .2, OPNsense mgmt .3, WiFi admin-oob-lib

vtnet3 = .3

254

Guest

WiFi Liberator-Guest, internet-only

vtnet4 = .1, isolated

Inter-VLAN firewall = deny default. Explicit allows VLAN20 → VLAN10 for AD, DNS, SMB, print, HTTPS. Guest VLAN254 internet-only, no inter-VLAN.

WiFi (hAP ax2)

SSID Band Auth VLAN

Liberator

2.4 + 5 GHz

WPA2/3 PSK

20

Liberator-Guest

2.4 GHz

open, no captive portal

254

admin-oob-lib

5 GHz hidden

WPA3 PSK

250

admin-oob-lib = the one OOB path. If OPNsense dies, admin joins hidden SSID, lands on VLAN250, reaches Proxmox host .2 and Mikrotik .1 directly via L2. No OPNsense in path.

Mikrotik Port Layout

Port Role Config

ether1 (2.5G)

WAN

dual-WAN auto-detect: untagged DHCP on ether1 AND tagged DHCP on ether1-vlan10 (VLAN 10). Both ifaces are members of the WAN interface-list.

ether2, ether3

LACP bond to Proxmox

trunk 10/20/250/254

ether4

LAN client access

VLAN20 untagged

ether5

LAN client access spare

VLAN20 untagged

No hardware LACP offload on hAP ax2 (Aldrin2 switch chip), bond runs in software. Fine for Liberator scale (2x 1 GbE to host).

DUAL-WAN (2026-05-29): flash-mikrotik (MikrotikRscBuilder) lays down a VLAN sub-iface ether1-vlan10 (const WanVlanId="10") on ether1 and runs a dhcp-client on BOTH the untagged ether1 and the tagged ether1-vlan10. Same physical wire, so the ISP delivers WAN EITHER untagged (plain DHCP net) OR tagged on VLAN 10 (XGS-PON modem), never both - only one dhcp-client leases, no default-route conflict. Both ifaces join the WAN interface-list, so srcnat masquerade (out-interface-list=WAN) and the inbound port-forwards (in-interface-list=WAN, pushed by MikrotikDesiredStateBuilder) cover whichever iface carries the WAN. Port-forwards match the WAN LIST not literal ether1 - an ether1-pinned rule would never match XGS-PON ingress (ether1-vlan10) and inbound mail/nextcloud would die. The converge references the WAN list but does NOT create it (flash-then-update order).

OWNERSHIP NOTE: the LACP bond on the Mikrotik side is set up at factory flash time by flash-mikrotik (MikrotikRscBuilder). MikrotikDesiredStateBuilder.Build does NOT push a BondingSpec; converge only manages netsetup-prefixed entries (bridges, bridge-ports, bridge-vlans, IPs, NAT/filter, WiFi, admin user, SystemIdentity). If a customer wires the bond outside the flash flow, that step is out of scope.

Proxmox Host Network (post phase 2)

Rendered + diff’d + applied by src/NETSetup/Helpers/ConfigureProxmoxBridges.cs (Phase 3e, runs between ConfigureMikrotikVlans.Run and RewireVmNicsToVlans.Run). Idempotent: only ifreload -a on diff. Bond slaves resolved via PhysicalNicProvider seam (default = ip -j link JSON parse, sorted alphabetically for deterministic order, first 2 used). DNS = NixDns LAN IP via NixTemplateFactory.ResolveDnsServerIp; falls back to Mikrotik mgmt IP (LiberatorNetwork.MikrotikMgmtIp) on first cycle (NixDns not up yet). Skipped with a warning when fewer than 2 physical NICs are found.

NIC names below (enp1s0/enp2s0) are illustrative for Odroid hardware - the renderer substitutes the detected NIC names; bond/bridge/sub-interface names come from LiberatorNetwork.BondName / BridgeName / MgmtVlanInterface.

auto bond0
iface bond0 inet manual
    bond-slaves enp1s0 enp2s0
    bond-mode 802.3ad
    bond-miimon 100
    bond-xmit-hash-policy layer3+4

auto vmbr0
iface vmbr0 inet manual
    bridge-ports bond0
    bridge-vlan-aware yes
    bridge-vids 2-4094

auto vmbr0v250
iface vmbr0v250 inet static
    address 10.0.250.2/24
    gateway 10.0.250.1
    dns-nameservers <NixDns LAN IP>
    bridge_ports bond0.250
    bridge_stp off
    bridge_fd 0

ifupdown2 reload always run in commit/rollback safe-mode so mgmt loss auto-rolls back. Best-effort: catches all + logs warning; never aborts Phase 3e. Inner gate LiberatorGate.IsFullLiberatorFlow for defence in depth.

LESSON LEARNED (2026-05-27): the host gateway lives on the Mikrotik mgmt IP 10.0.250.1, not on the OPNsense mgmt vNIC (.3). OPNsense routes the LAN VLANs (10/20/254) - it does NOT need to be the host’s default gateway. The earlier doc revision used 10.0.250.3 here and was wrong.

OPNsense VM NIC Layout (Option Y)

Five tagged vNICs, hypervisor enforces tags. Defence in depth: even if OPNsense misconfigures a VLAN child, hypervisor blocks cross-VLAN leak.

qm create <vmid> \
  --bios seabios \
  --memory 2048 --cores 2 \
  --agent enabled=1 \
  --scsihw virtio-scsi-single \
  --net0 virtio,bridge=vmbr0 \
  --net1 virtio,bridge=vmbr0,tag=10 \
  --net2 virtio,bridge=vmbr0,tag=20 \
  --net3 virtio,bridge=vmbr0,tag=250 \
  --net4 virtio,bridge=vmbr0,tag=254
qm importdisk <vmid> /var/lib/vz/template/iso/opnsense.img <storage>
qm set <vmid> --scsi0 <storage>:vm-<vmid>-disk-0 --boot order=scsi0
qm start <vmid>

Per-VLAN tcpdump at host = tcpdump -i tap<vmid>i2 (just VLAN20 traffic) etc.

Other VM NIC Layout

Each non-OPNsense VM gets one tagged vNIC:

VM kind Tag VLAN purpose

NixDns, DC, Nextcloud, Mail, PMG LXC

10

server tier

Win11 clients

20

LAN clients

DHCP and DNS Source of Truth

  • DHCP server: OPNsense per VLAN. Mikrotik DHCP stripped at end of cutover.

  • DHCP static reservations: derived from config.Computers[].MAC (machine map). Not from Employees - employees have no MACs.

  • DNS resolver: NixDns VM (Unbound). OPNsense DNS forwarder points all clients here. NixDns handles resolution, blocking, reverse, DDNS, reverse-proxy hints if configured.

  • Internal A records auto-emitted by NixTemplateFactory.ForMachine (NixDns branch) into NixDnsTemplate.ExtraARecords:

    • mail.<domain> → Mail VM LAN IP (2026-05-26)

    • proxmox.<domain>10.0.250.2 (Liberator only, 2026-05-27 - Phase 2 mgmt IP, key LiberatorVmHostnames.ProxmoxDnsKey)

Update Flow (every netsetup update cycle, idempotent)

@startuml Liberator-Network-Push

title netsetup update push - Liberator network converge (every cycle, idempotent)

actor Admin
participant "netsetup update\n(on Proxmox host)" as NS
participant "UpdateMethods\nRunProxmoxUpdate" as UM
participant "EnsureCustomerVms" as ECV
participant "ProvisionOpnsenseVm" as POPN
participant "OpnsenseInstall\n(SSH first-boot)" as INST
participant "ConfigureOpnsenseVlans\n(REST API push)" as COV
participant "ConfigureMikrotikVlans\n(REST API push)" as CMV
participant "ConfigureProxmoxBridges\n(/etc/network/interfaces +\nsafe ifreload)" as CPB
participant "RewireVmNicsToVlans\n(qm set tag=...)" as RWN
participant "OPNsense VM" as OPN
participant "Mikrotik hAP ax2" as MT
participant "Other VMs (Nix / Win / PMG)" as VMS

note over NS
  Pre-state on fresh Liberator install:
    Proxmox host IP = 10.0.0.200/24 gw 10.0.0.1 dns 10.0.0.1
    (set by Liberator-gated answer.toml emit:
     ProxmoxAutoInstallExtensions.ToProxmoxAutoInstall)
    Mikrotik = factory 10.0.0.1/24 (post-flash-mikrotik)
  This cycle moves the host to 10.0.250.2 mgmt VLAN
  during Phase 3e ConfigureProxmoxBridges.
end note

Admin -> NS: netsetup update
NS -> UM: RunProxmoxUpdate(host, config)
UM -> UM: post-install gate (log file)\napt update + full-upgrade\nEnsureProxmoxResolvConfSecondaryDns

== Role gate ==
UM -> UM: isLiberator = host.Description == "Liberator"
UM -> UM: hasOpnsenseVm = GetOrderedVmsForHost(config, host.SerialNumber)\n  .Any(v => v.OS == OS.OPNSense)
UM -> UM: runFullNetworkFlow = isLiberator && hasOpnsenseVm

alt runFullNetworkFlow

  UM -> ECV: Run(host, config, services)

  == Phase 3d.3: OPNsense provision (Option Y, 5 vNIC) ==
  ECV -> POPN: provision (default raw image, first only)
  POPN -> OPN: qm create <vmid>\n  --net0 virtio,bridge=vmbr0\n  --net1 virtio,bridge=vmbr0,tag=10\n  --net2 virtio,bridge=vmbr0,tag=20\n  --net3 virtio,bridge=vmbr0,tag=250\n  --net4 virtio,bridge=vmbr0,tag=254\nqm importdisk + qm set --scsi0 + qm start

  alt /conf/.netsetup-installed missing
    ECV -> INST: OpnsenseInstall.Install(host, machine, config)
    INST -> INST: build config.xml:\n  vtnet0..4 assigned\n  DHCP per VLAN (10/20/254)\n  static maps from config.Computers[].MAC\n  DNS forwarder -> NixDns .53\n  FW deny-default inter-VLAN\n  allow VLAN20 -> VLAN10 (AD/DNS/SMB/print/HTTPS)\n  admin user + api_key/api_secret
    INST -> OPN: ssh root@<wanIp> (master key)\nscp config.xml -> /conf/\nconfigctl service reload all
    INST -> INST: persist api_key/secret to\n/etc/netsetup/opnsense-api.env
  end

  == Phase 3d.x: other VMs (single tagged vNIC each) ==
  ECV -> VMS: NixDns/DC/NC/Mail/PMG -> tag=10\nWin11 client -> tag=20\n(serial host matching unchanged)

  == Phase 3e: VLAN converge (every cycle, drift-correcting) ==

  UM -> COV: push OPNsense REST API
  COV -> OPN: probe /api/core/system/status (basic auth)
  COV -> OPN: PUT /api/dhcpv4/static_map\nPUT /api/dnsforwarder/settings\nPUT /api/firewall/alias/...\nPUT /api/firewall/filter/...\nPOST /api/firewall/apply

  UM -> CMV: push Mikrotik REST API (via osisa.Mikrotik converge)\n  CMV -> MT: bridge1 vlan-aware\nbridge-ports ether2/ether3 trunk PVID=1 tagged 10,20,250,254\nbridge-ports ether4/ether5 access PVID=20 untagged\nbridge-vlans for 10,20,250,254\nip 10.0.250.1/24 on bridge1 (mgmt)\nwifi SSIDs: Liberator(20)\n             Liberator-Guest(254)\n             admin-oob-lib(250 hidden)\nadmin User + SystemIdentity\ndst-nat WAN ports -> OPNsense WAN IP\n(NOT pushed: LACP bond - factory flash-mikrotik\nstep handles bond + WAN DHCP + NAT/firewall;\nMikrotikDesiredStateBuilder manages only what\ncarries the netsetup- prefix)

  UM -> CPB: rewrite /etc/network/interfaces:\n  bond0 LACP slaves = first 2 physical NICs\n    (PhysicalNicProvider seam, default ip -j link, sorted)\n    bond-mode 802.3ad, miimon 100, xmit-hash-policy layer3+4\n  vmbr0 bridge-vlan-aware (vids 2-4094)\n  vmbr0v250 host mgmt = 10.0.250.2/24\n    gw 10.0.250.1 (Mikrotik mgmt)\n    dns NixDns LAN IP (fallback Mikrotik mgmt)\nAll IPs + bridge names from LiberatorNetwork.cs consts\nDiff vs current; only ifreload -a on change\nifupdown2 reload safe-mode\n(rollback if mgmt lost)\nInner gate: LiberatorGate.IsFullLiberatorFlow re-check

  UM -> RWN: for each existing VM, drift-check tag:\n  qm set <vmid> -netN ...,tag=<vlan>\nonly when tag missing or wrong\n(idempotent)

  UM -> UM: EnsureGatewayTakeover (Phase 3e tail)\n  if OPNsense LAN reachable on HTTPS:443\n  then awk-rewrite gateway under iface vmbr0\n  + ifreload -a + flip in-memory config.GatewayIP\n  (orthogonal to vmbr0v250 mgmt above)\n  see docs/firewall.adoc

  == Phase 3f: btrfs scrub ==
  UM -> UM: btrfs scrub each mounted fs

else !runFullNetworkFlow (Power Station / vanilla Proxmox / other)
  UM -> ECV: Run(host, config, services)
  ECV -> VMS: VMs provisioned on flat vmbr0 untagged\n(serial host matching unchanged)
  note right
    Skipped:
      ProvisionOpnsenseVm
      OpnsenseInstall
      ConfigureOpnsenseVlans
      ConfigureMikrotikVlans
      ConfigureProxmoxBridges
      RewireVmNicsToVlans
    EnsureGatewayTakeover still runs but
    no-ops with NoOpnsense outcome.
    Bare Proxmox path. Power Station
    cluster wiring = future TODO.
  end note
  UM -> UM: btrfs scrub each mounted fs
end

UM --> NS: update complete
NS --> Admin: exit 0

note over UM
  Cutover sequence (additive then strip) handled by the
  ordering above: OPNsense API stages all interfaces first
  (VLAN children created), Mikrotik adds VLAN bridge alongside
  flat, Proxmox swings VM NICs to tags, OPNsense WAN VLAN if
  enabled, finally Mikrotik strips flat NAT + DHCP.
  Every step idempotent so re-running netsetup update
  is always safe and converges drift.
end note

@enduml

Order:

  1. EnsureLatestBinaryOrReexec (Phase 3a)

  2. EnsureProxmoxPostInstall log-gated (Phase 3b)

  3. apt update + full-upgrade (Phase 3c)

  4. EnsureCustomerVms (Phase 3d)

    1. OPNsense first (Option Y 5-NIC, gated)

    2. NixDns + DC + NC + Mail + PMG (single tagged vNIC each)

    3. Win11 clients

  5. ConfigureOpnsenseVlans REST API push (Phase 3e, gated)

  6. ConfigureMikrotikVlans REST API push (Phase 3e, gated)

  7. ConfigureProxmoxBridges interfaces + safe ifreload (Phase 3e, gated)

  8. RewireVmNicsToVlans qm set drift correction (Phase 3e, gated)

  9. EnsureGatewayTakeover vmbr0 gateway flip to OPNsense LAN IP (Phase 3e, gated) - see docs/firewall.adoc

  10. btrfs scrub (Phase 3f)

Steps 5-8 gated on runFullNetworkFlow. Every step idempotent: re-pushing on each cycle is safe and corrects drift.

Cutover Sequence (Phase 1 baseline → Phase 2 target)

Phase 1 baseline = today’s flash-mikrotik config: flat NAT, Mikrotik DHCP, single vmbr0 on host, OPNsense optional standalone.

Phase 2 target = the VLAN topology above.

Cutover = additive then strip, runs inside a single netsetup update cycle on a freshly flashed Liberator:

  1. OPNsense API: stage all interfaces + DHCP + FW. WAN VLAN if disabled.

  2. Mikrotik API: add VLAN-aware bridge + VLANs + bond + WiFi SSIDs alongside flat bridge. Both paths work in parallel. Mikrotik mgmt IP 10.0.250.1 from LiberatorNetwork.MikrotikMgmtIp.

  3. Proxmox: ConfigureProxmoxBridges rewrites /etc/network/interfaces → bond0 + VLAN-aware vmbr0 + vmbr0v250 mgmt at 10.0.250.2/24 gw 10.0.250.1 dns NixDns. ifreload -a only on diff (idempotent), safe-mode commit/rollback. Then RewireVmNicsToVlans swings VM NICs to the right tags. Verify ping to OPNsense LAN .1.

  4. Enable OPNsense WAN VLAN if. Internet now flows via OPNsense.

  5. Mikrotik API: remove flat-NAT + disable Mikrotik DHCP. Mikrotik = pure L2.

LESSON LEARNED (2026-05-27): the Phase 1 answer.toml IS Liberator-gated - it emits 10.0.0.200/24 gw 10.0.0.1 dns 10.0.0.1 (Mikrotik LAN) rather than leaving DHCP on. Earlier design memo "do NOT modify the Proxmox auto-install answer.toml" is SUPERSEDED - the customer-OU subnet that the old plan wanted to fall through to does not actually exist as a routable subnet on a fresh Liberator. See user-memory liberator_proxmox_ip_phases for the canonical Phase 1/2 plan.

If any step breaks: ifupdown2 rollback restores Proxmox mgmt; admin OOB WiFi reaches Mikrotik VLAN250; Mikrotik 5s reset button returns to factory.

Future: Cluster + Power Station

Power Station = Proxmox node added to a Liberator cluster for compute (e.g. AI workloads). Identified by host.Description == "Power Station". Today: bare Proxmox flow only, no router, no VLAN. Implementation deferred.

Planned wiring when implemented:

  • Power Station uplink trunked from Liberator master’s bond (VLAN10 server tier extends, plus a dedicated corosync VLAN TBD).

  • Proxmox cluster join via pvecm from Power Station to Liberator master.

  • VMs hosted on Power Station get same tag=10 vNICs as if hosted on master.

  • No OPNsense duplicate. Power station relies on master’s OPNsense for routing/firewall.

Not implemented. When wired, this section gets rewritten with the real flow.

Skipped Behaviour on Non-Liberator Hosts

For host.Description != "Liberator" OR no OPNsense VM in config for host.SerialNumber:

  • No ProvisionOpnsenseVm

  • No OpnsenseInstall

  • No ConfigureOpnsenseVlans / ConfigureMikrotikVlans

  • No ConfigureProxmoxBridges (host stays single-NIC vmbr0 untagged)

  • No RewireVmNicsToVlans

VMs (Nix / Windows / PMG LXC) still provisioned via the same GetOrderedVmsForHost serial-matching code path, but land on flat vmbr0 with no VLAN tag. Same code, gated flow.