<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>Networking on Dexome</title>
        <link>https://blog.dexome.com/tags/networking/</link>
        <description>Recent content in Networking on Dexome</description>
        <generator>Hugo -- gohugo.io</generator>
        <language>en</language>
        <lastBuildDate>Tue, 04 Aug 2026 00:00:00 +0530</lastBuildDate><atom:link href="https://blog.dexome.com/tags/networking/index.xml" rel="self" type="application/rss+xml" /><item>
        <title>Setting up OPNsense HA with CARP and pfsync</title>
        <link>https://blog.dexome.com/post/ha-firewall-carp-state-sync/</link>
        <pubDate>Tue, 04 Aug 2026 00:00:00 +0530</pubDate>
        
        <guid>https://blog.dexome.com/post/ha-firewall-carp-state-sync/</guid>
        <description>&lt;p&gt;I run two OPNsense VMs, one on &lt;code&gt;metalbox&lt;/code&gt; and another on &lt;code&gt;heavymetal&lt;/code&gt;. I wanted
the second firewall to take over the gateway, DNS, DHCP and existing connections
when the first host was unavailable.&lt;/p&gt;
&lt;p&gt;CARP itself was the easy part. The difficult parts were the services around it.
When I first booted the backup firewall, the network reached around 160,000
packets per second and became unusable. Later, CARP moved the IP correctly but
DNS stopped and existing TCP connections were lost.&lt;/p&gt;
&lt;p&gt;Below is how I configured the full setup and the issues I found while testing it.&lt;/p&gt;
&lt;h2 id=&#34;the-ha-setup&#34;&gt;The HA setup
&lt;/h2&gt;&lt;p&gt;The finished design runs two OPNsense VMs on separate NixOS hypervisors. Node A
runs on &lt;code&gt;metalbox&lt;/code&gt;; node B runs on &lt;code&gt;heavymetal&lt;/code&gt;. Clients keep using the familiar
&lt;code&gt;.1&lt;/code&gt; gateways on each internal VLAN, now implemented as CARP VIPs. The nodes have
real &lt;code&gt;.6&lt;/code&gt; and &lt;code&gt;.7&lt;/code&gt; management addresses, and a dedicated VLAN 67 sync link at
&lt;code&gt;10.100.67.6/28&lt;/code&gt; and &lt;code&gt;10.100.67.7/28&lt;/code&gt;.&lt;/p&gt;
&lt;figure class=&#34;article-diagram&#34;&gt;
    &lt;pre class=&#34;mermaid&#34;&gt;
flowchart TB
	CLIENTS[LAN, THINGS, QUANTUM and SERVERS clients] --&gt; VIPS[CARP gateway VIPs ending in .1]
	INTERNET[PPPoE uplink] --&gt; WANVIP[WAN CARP VIP 192.168.1.5]

	subgraph M[metalbox NixOS hypervisor]
		A[OPNsense A - preferred MASTER]
		AREAL[Real addresses ending in .6]
		A --- AREAL
	end

	subgraph H[heavymetal NixOS hypervisor]
		B[OPNsense B - BACKUP]
		BREAL[Real addresses ending in .7]
		B --- BREAL
	end

	VIPS --&gt; A
	VIPS -. failover .-&gt; B
	WANVIP --&gt; A
	WANVIP -. failover .-&gt; B
	A ==&gt;|VLAN 67: XMLRPC, pfsync, Kea HA| B
&lt;/pre&gt;
    &lt;figcaption&gt;The completed OPNsense HA topology separates client VIPs from node management and the VLAN 67 control plane.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;There are four separate parts in this setup:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;CARP moves gateway and service IP addresses between nodes.&lt;/li&gt;
&lt;li&gt;Configuration sync keeps rules and services aligned.&lt;/li&gt;
&lt;li&gt;State sync copies the firewall state table so established connections live.&lt;/li&gt;
&lt;li&gt;Application-level HA makes services such as DHCP and mDNS behave correctly.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I configured and tested them one at a time.&lt;/p&gt;
&lt;h2 id=&#34;configure-carp-on-the-first-node&#34;&gt;Configure CARP on the first node
&lt;/h2&gt;&lt;p&gt;Before introducing a backup, I converted the existing firewall into a single
CARP master. Its old gateway addresses became virtual IPs, while the firewall
received separate real addresses for management. The WAN source-NAT rule also
changed to use the WAN virtual IP, so outbound traffic would retain the same
source after a failover.&lt;/p&gt;
&lt;p&gt;This stage sounds redundant: why configure failover with only one node? Because
it isolates the address migration from the redundancy problem. I could prove that
clients still reached their familiar gateways, outbound NAT used the expected
address, and management remained available before another system was allowed to
advertise anything.&lt;/p&gt;
&lt;p&gt;It also established a useful naming pattern:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;one stable name for the firewall service, resolving to the virtual IP;&lt;/li&gt;
&lt;li&gt;one node-specific name per firewall, resolving to its real address.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;When HA itself is broken, the node-specific addresses are the way back in.&lt;/p&gt;
&lt;h2 id=&#34;booting-the-backup-caused-an-mdns-storm&#34;&gt;Booting the backup caused an mDNS storm
&lt;/h2&gt;&lt;p&gt;The second node started from a copy of the first node&amp;rsquo;s configuration. Its CARP
advertisement priority was lower, so it should have stayed in BACKUP. Within
roughly 30 to 60 seconds, however, the network flooded.&lt;/p&gt;
&lt;p&gt;The timing made a split-brain theory persuasive. I checked the usual suspects:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;shared CARP passwords matched;&lt;/li&gt;
&lt;li&gt;virtual host IDs matched;&lt;/li&gt;
&lt;li&gt;the master and backup had the intended advertisement skew;&lt;/li&gt;
&lt;li&gt;multicast advertisements arrived on every VLAN;&lt;/li&gt;
&lt;li&gt;the backup remained silent in BACKUP instead of advertising as another master.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These checks showed CARP was working as expected. I then captured the traffic
causing the packet storm.&lt;/p&gt;
&lt;p&gt;The breakthrough was to stop looking only at CARP packets and inspect the storm
itself. Almost all of it was UDP port 5353: multicast DNS. Both node MAC addresses
were flooding at similar rates.&lt;/p&gt;
&lt;p&gt;The copied configuration had enabled an mDNS repeater on both firewalls. Each
reflector received packets repeated by the other and reflected them again across
the same interfaces. A small amount of multicast became an exponential loop.&lt;/p&gt;
&lt;figure class=&#34;article-diagram&#34;&gt;
    &lt;pre class=&#34;mermaid&#34;&gt;
sequenceDiagram
	participant Device as mDNS device
	participant A as OPNsense A reflector
	participant B as OPNsense B reflector
	participant LAN as Other VLANs

	Device-&gt;&gt;A: Multicast query on UDP 5353
	A-&gt;&gt;LAN: Reflect query
	LAN-&gt;&gt;B: Reflected query arrives
	B-&gt;&gt;LAN: Reflect it again
	LAN-&gt;&gt;A: Re-reflected query arrives
	loop Exponential amplification
		A-&gt;&gt;LAN: Reflect B&#39;s copy
		LAN-&gt;&gt;B: Deliver A&#39;s copy
		B-&gt;&gt;LAN: Reflect A&#39;s copy
		LAN-&gt;&gt;A: Deliver B&#39;s copy
	end
&lt;/pre&gt;
    &lt;figcaption&gt;OPNsense B inherited the active mDNS repeater configuration. Each node reflected the other&amp;#39;s reflected packets until the LAN reached roughly 160,000 packets per second.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;The firewall software had the correct feature for this situation: enable the
repeater only while the node is CARP master. I configured the service identically
on both nodes but enabled its CARP-aware failover mode. The backup retained the
configuration without running the reflector until promotion.&lt;/p&gt;
&lt;p&gt;The same check is needed for any service that broadcasts or reflects traffic.
Copying the configuration to the backup can make both instances active at the
same time.&lt;/p&gt;
&lt;h2 id=&#34;dedicated-network-between-the-firewalls&#34;&gt;Dedicated network between the firewalls
&lt;/h2&gt;&lt;p&gt;I added VLAN 67 between the firewalls for control traffic. It had no client
gateway and no virtual IP. OPNsense A used &lt;code&gt;10.100.67.6/28&lt;/code&gt;, OPNsense B used
&lt;code&gt;10.100.67.7/28&lt;/code&gt;, and a tightly scoped firewall rule allowed traffic only within
that sync subnet.&lt;/p&gt;
&lt;p&gt;The lack of a default pass rule on a newly assigned firewall interface was an
early trap. Both addresses existed, but all layer-3 traffic was silently dropped
until the sync-network rule was installed. Link state and correct addresses do
not prove that the control plane can communicate.&lt;/p&gt;
&lt;p&gt;The dedicated link carried config sync, pfsync, and DHCP peer communication. It
also kept that traffic away from client VLANs and gave packet captures a much
cleaner place to answer &amp;ldquo;did the peers actually talk?&amp;rdquo;&lt;/p&gt;
&lt;figure class=&#34;article-diagram&#34;&gt;
    &lt;pre class=&#34;mermaid&#34;&gt;
flowchart LR
	A[OPNsense A 10.100.67.6] ==&gt;|XMLRPC: configuration A to B| B[OPNsense B 10.100.67.7]
	A &lt;--&gt;|pfsync: firewall states| B
	A &lt;--&gt;|Kea HA: leases and peer health| B

	CARP[CARP advertisements on client VLANs] -.-&gt; A
	CARP -.-&gt; B
&lt;/pre&gt;
    &lt;figcaption&gt;CARP is only the address-ownership layer; three separate protocols cross VLAN 67 to preserve configuration, leases, and live connections.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;h2 id=&#34;problems-with-configuration-sync&#34;&gt;Problems with configuration sync
&lt;/h2&gt;&lt;p&gt;The config-sync page looked straightforward: peer address, username, password,
and a list of areas to synchronize. It failed for five independent reasons.&lt;/p&gt;
&lt;h3 id=&#34;management-ports-were-different&#34;&gt;Management ports were different
&lt;/h3&gt;&lt;p&gt;One node exposed its GUI directly on the default HTTPS port. The other listened
on a non-default local port behind a reverse proxy. Config sync contacted the
peer&amp;rsquo;s real address, not the public proxy name, so both web services needed a
reachable and explicitly matching port.&lt;/p&gt;
&lt;h3 id=&#34;gui-was-not-listening-on-the-sync-interface&#34;&gt;GUI was not listening on the sync interface
&lt;/h3&gt;&lt;p&gt;The backup&amp;rsquo;s web server listened only on its LAN interface. A TCP connection over
the dedicated sync address therefore had nowhere to land. Adding the sync
interface to the GUI&amp;rsquo;s listen scope fixed the transport without widening client
access.&lt;/p&gt;
&lt;h3 id=&#34;certificate-verification-failed&#34;&gt;Certificate verification failed
&lt;/h3&gt;&lt;p&gt;The peers used a self-signed management certificate over a private, dedicated L2
link. Certificate verification failed before credentials were considered. In
this topology I disabled peer verification for the sync call. A better option,
when supported, is to give each node a certificate chaining to an internal CA.&lt;/p&gt;
&lt;h3 id=&#34;the-sync-user-needed-another-privilege&#34;&gt;The sync user needed another privilege
&lt;/h3&gt;&lt;p&gt;The user could open the HA configuration page but could not call the XMLRPC
library. &amp;ldquo;High Availability&amp;rdquo; GUI access and XMLRPC execution were separate
permissions. Granting only the narrowly required library privilege fixed the API
call without turning the account into an administrator.&lt;/p&gt;
&lt;h3 id=&#34;changes-were-not-pushed-automatically&#34;&gt;Changes were not pushed automatically
&lt;/h3&gt;&lt;p&gt;The largest conceptual surprise was that saving a configuration did not
necessarily push it to the backup. The GUI&amp;rsquo;s explicit &amp;ldquo;synchronize all&amp;rdquo; action
worked, but ordinary changes and infrastructure-as-code applies did not invoke it.&lt;/p&gt;
&lt;p&gt;I added a post-apply hook that calls the firewall&amp;rsquo;s sync endpoint. GUI-only edits
still require the operator to trigger synchronization, so the operating rule is
simple: one node is authoritative; the backup is not a second place to edit.&lt;/p&gt;
&lt;p&gt;The reusable lesson is to test config sync as an action, not as a checkbox. Change
a harmless object on the primary, trigger the documented sync path, and prove it
appears on the backup.&lt;/p&gt;
&lt;h2 id=&#34;kea-dhcp-hot-standby&#34;&gt;Kea DHCP hot standby
&lt;/h2&gt;&lt;p&gt;CARP moving the gateway does not automatically make the backup DHCP server safe.
Two independent DHCP servers with copied configuration can both answer clients,
while a master-only DHCP service can leave an availability gap during promotion.&lt;/p&gt;
&lt;p&gt;I used Kea&amp;rsquo;s hot-standby mode over the sync network. The primary serves leases
under normal conditions; the standby receives lease updates and takes over after
it decides the partner is down.&lt;/p&gt;
&lt;p&gt;The configuration failed when both peers used the same server name. Kea requires
&lt;code&gt;this-server-name&lt;/code&gt; to match one of the declared peer names. Explicitly writing the
primary&amp;rsquo;s name into synchronized configuration made both nodes identify as the
primary.&lt;/p&gt;
&lt;p&gt;I gave the firewalls distinct hostnames, declared those as peer names, and left
&lt;code&gt;this-server-name&lt;/code&gt; empty so each node derives its identity locally. The node
identity cannot be copied from primary to backup.&lt;/p&gt;
&lt;p&gt;Hot standby also has a detection interval. In this case the standby waited about
a minute before entering partner-down mode. Existing clients with leases stayed
online; a new or renewing client might wait. That is not a broken failover, but it
is part of the service-level objective and should be measured rather than assumed.&lt;/p&gt;
&lt;h2 id=&#34;unbound-stopped-answering-after-failover&#34;&gt;Unbound stopped answering after failover
&lt;/h2&gt;&lt;p&gt;With CARP working, the virtual gateway moved cleanly to the backup. Clients could
route, but DNS queries to the gateway timed out.&lt;/p&gt;
&lt;p&gt;The resolver had been configured to bind selected interfaces. On the backup,
virtual IPs do not exist while it is in BACKUP. When the resolver configuration
was regenerated, the absent virtual address disappeared from the generated
listen set. CARP later promoted the node and created the address, but the resolver
was not listening there.&lt;/p&gt;
&lt;p&gt;The fix was to let the resolver listen on all local interfaces using its automatic
interface behavior. Firewall policy, rather than a brittle application bind list,
controlled which networks could reach port 53.&lt;/p&gt;
&lt;p&gt;This is a broader HA pattern: applications should not permanently compile a list
of addresses whose presence changes with ownership. Either bind wildcard/automatic
and enforce access in the firewall, or use a service hook that reacts correctly
to every promotion and demotion.&lt;/p&gt;
&lt;h2 id=&#34;keeping-connections-with-pfsync&#34;&gt;Keeping connections with pfsync
&lt;/h2&gt;&lt;p&gt;At this point a cable pull promoted the backup, moved the virtual IPs, and kept
DNS and DHCP available. Yet established TCP sessions still died.&lt;/p&gt;
&lt;p&gt;That was expected in retrospect. A stateful firewall tracks sequence numbers,
NAT translations, timeouts, and policy decisions in a state table. The backup
cannot infer that state from a newly arrived midstream packet.&lt;/p&gt;
&lt;p&gt;I enabled pfsync on both nodes using explicit unicast peer addresses on the sync
network. Configuring only one direction was not sufficient; each node needed to
send and receive state because either could be active after maintenance or a
second failure.&lt;/p&gt;
&lt;p&gt;The acceptance test was deliberately mundane: start a long-lived transfer, pull
the active firewall&amp;rsquo;s network cable, and watch whether the transfer continues.
After bidirectional state sync was working, it did.&lt;/p&gt;
&lt;p&gt;That test proved something a successful ping never could.&lt;/p&gt;
&lt;h2 id=&#34;how-i-tested-the-failover&#34;&gt;How I tested the failover
&lt;/h2&gt;&lt;p&gt;I ended with a matrix instead of a single &amp;ldquo;HA works&amp;rdquo; result:&lt;/p&gt;
&lt;table&gt;
  &lt;thead&gt;
      &lt;tr&gt;
          &lt;th&gt;Test&lt;/th&gt;
          &lt;th&gt;What it proves&lt;/th&gt;
      &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
      &lt;tr&gt;
          &lt;td&gt;Primary enters maintenance mode&lt;/td&gt;
          &lt;td&gt;Graceful CARP demotion and backup promotion&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;Primary loses its cable&lt;/td&gt;
          &lt;td&gt;Failure detection without operator help&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;Fail back to the original node&lt;/td&gt;
          &lt;td&gt;Prior master can safely resume ownership&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;DNS query during promotion&lt;/td&gt;
          &lt;td&gt;Resolver listens correctly on the new owner&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;New DHCP request after peer timeout&lt;/td&gt;
          &lt;td&gt;Standby has leases and enters partner-down&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;Long TCP transfer during cable pull&lt;/td&gt;
          &lt;td&gt;State and NAT translations were synchronized&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;Config change on primary&lt;/td&gt;
          &lt;td&gt;Explicit sync path updates the backup&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;Boot both nodes from cold&lt;/td&gt;
          &lt;td&gt;Broadcast services do not duplicate or loop&lt;/td&gt;
      &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Running the matrix in both directions exposed assumptions hidden by testing only
the preferred primary.&lt;/p&gt;
&lt;h2 id=&#34;final-checks&#34;&gt;Final checks
&lt;/h2&gt;&lt;p&gt;Moving the virtual IP was only one of the checks. The setup was complete when:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;there is exactly one active owner for each singleton or broadcast service;&lt;/li&gt;
&lt;li&gt;both nodes carry compatible configuration without becoming competing writers;&lt;/li&gt;
&lt;li&gt;DHCP and DNS continue according to measured failover timing;&lt;/li&gt;
&lt;li&gt;established stateful connections survive a hard loss;&lt;/li&gt;
&lt;li&gt;operators can reach either node through a real, node-specific address;&lt;/li&gt;
&lt;li&gt;the behavior has been proven by removing the active node, not inferred from a
green status page.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The main issue I found was assuming that copying a service configuration also
made the service HA-aware. The mDNS repeater had to run only on the CARP master,
Kea needed its own peer state and Unbound needed to listen on an address which
appears only after promotion.&lt;/p&gt;
&lt;p&gt;Finally, test with a real cable pull and a long-running connection. A successful
ping after pressing the CARP maintenance button does not verify pfsync or the
hard-failure path.&lt;/p&gt;
</description>
        </item>
        <item>
        <title>Moving My PPPoE WAN to VLAN 999</title>
        <link>https://blog.dexome.com/post/migrate-live-isp-wan-without-lockout/</link>
        <pubDate>Thu, 30 Jul 2026 00:00:00 +0530</pubDate>
        
        <guid>https://blog.dexome.com/post/migrate-live-isp-wan-without-lockout/</guid>
        <description>&lt;p&gt;I wanted to move the WAN connection from a dedicated port on OPNsense to VLAN
999 through my Omada switch. This would allow both OPNsense nodes to reach the
same ONT connection and was needed for the HA setup.&lt;/p&gt;
&lt;p&gt;The PPPoE username, password and ONT were not changing. I had already configured
the VLAN and expected the Internet to be down only for the time needed to move
the cable and connect again.&lt;/p&gt;
&lt;p&gt;It did not work that way. The first connection failed because I had left the old
PPPoE session active at the ISP. Once I fixed that, the connection dropped again
because the Omada switch was sending loop-detection packets towards the ONT.&lt;/p&gt;
&lt;p&gt;This post covers both issues and the order I now use for a PPPoE cutover.&lt;/p&gt;
&lt;h2 id=&#34;the-setup&#34;&gt;The setup
&lt;/h2&gt;&lt;p&gt;The old topology was direct. The Syrotech GPON ONT connected to the bare-metal
OPNsense firewall&amp;rsquo;s &lt;code&gt;igc0&lt;/code&gt; interface. The new design moved that Ethernet segment
onto VLAN 999 through a TP-Link Omada TL-SG2008P, then carried the tag over a
trunk to OPNsense as &lt;code&gt;igc2_vlan999&lt;/code&gt;.&lt;/p&gt;
&lt;figure class=&#34;article-diagram&#34;&gt;
    &lt;pre class=&#34;mermaid&#34;&gt;
flowchart TB
	BEFORE[&#34;Before&lt;br/&gt;Syrotech ONT to OPNsense igc0 to PPPoE&#34;]
	AFTER[&#34;After&lt;br/&gt;Syrotech ONT to Omada TL-SG2008P to VLAN 999 to OPNsense igc2_vlan999 to PPPoE&#34;]
	BEFORE --&gt;|Move WAN onto the managed-switch trunk| AFTER
&lt;/pre&gt;
    &lt;figcaption&gt;The WAN migration inserted an Omada switch and VLAN 999 between the Syrotech ONT and OPNsense.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;The VLAN setup looked correct. A test device on the same path could reach the
management IP of the ONT and packet captures showed the expected VLAN traffic.
So I pulled the cable from the old port, moved it and enabled the new interface.&lt;/p&gt;
&lt;p&gt;The connection never completed.&lt;/p&gt;
&lt;p&gt;Packet captures showed PADI discovery frames leaving the firewall, but the
expected PADO response did not come back. At other points the logs showed LCP
negotiation repeating with new magic numbers, never reaching a usable session.
It looked close enough to working to keep sending me down the wrong paths.&lt;/p&gt;
&lt;h2 id=&#34;checking-the-vlan-first&#34;&gt;Checking the VLAN first
&lt;/h2&gt;&lt;p&gt;VLANs are an obvious suspect because they can fail silently. An access port can
be untagged in the wrong VLAN, a trunk can omit the tag, or a native VLAN can
consume traffic that was supposed to remain tagged.&lt;/p&gt;
&lt;p&gt;I tested the transport independently of PPPoE. A client attached to the same
logical path could reach the optical terminal&amp;rsquo;s management network. Captures on
the firewall showed the expected Ethernet discovery frames on the expected VLAN.
That did not prove every switch behavior was correct, but it proved that this was
not simply a missing tag.&lt;/p&gt;
&lt;h2 id=&#34;checking-the-wan-mac&#34;&gt;Checking the WAN MAC
&lt;/h2&gt;&lt;p&gt;Many providers bind service to a router or ONT MAC address, so MAC locking was
the next theory. I tried preserving the previous WAN MAC and inspected the
provider-facing frames. It was a reasonable theory, but it did not explain the
behavior.&lt;/p&gt;
&lt;p&gt;Some ISPs lock the connection to a MAC address, so this was still worth checking.
There are three separate things which can cause a new router or port to fail:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Does the provider authenticate only with a username and password?&lt;/li&gt;
&lt;li&gt;Does it also remember the client MAC?&lt;/li&gt;
&lt;li&gt;Does it permit more than one simultaneous PPPoE session on the line?&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;All three look similar from OPNsense. In my case, the third one was the problem.&lt;/p&gt;
&lt;h2 id=&#34;closing-the-old-pppoe-session&#34;&gt;Closing the old PPPoE session
&lt;/h2&gt;&lt;p&gt;I had moved the cable while PPPoE was still active. That is not a clean session
shutdown.&lt;/p&gt;
&lt;p&gt;A graceful PPPoE disconnect sends a PADT (PPPoE Active Discovery Terminate), or
terminates the link through PPP before removing the carrier. A cable pull sends
neither. From the ISP access concentrator&amp;rsquo;s point of view, the old session can
remain alive until its own timeout expires.&lt;/p&gt;
&lt;p&gt;The provider allowed one session on the line. My new firewall interface was not
replacing the old session; it was asking for a second one while the first still
existed upstream.&lt;/p&gt;
&lt;p&gt;That explained why waiting sometimes appeared to fix the problem. A long
power-off interval gave the stale session time to age out. It also explained why
rechecking credentials and VLAN tags accomplished nothing. The obstacle was not
in my current configuration at all.&lt;/p&gt;
&lt;p&gt;The reliable cutover was:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Leave the old WAN connected.&lt;/li&gt;
&lt;li&gt;Disconnect or disable PPPoE on the old interface.&lt;/li&gt;
&lt;li&gt;Verify that a PADT leaves the old interface.&lt;/li&gt;
&lt;li&gt;Move the cable or change the VLAN assignment.&lt;/li&gt;
&lt;li&gt;Enable PPPoE on the new interface.&lt;/li&gt;
&lt;/ol&gt;
&lt;figure class=&#34;article-diagram&#34;&gt;
    &lt;pre class=&#34;mermaid&#34;&gt;
sequenceDiagram
	participant Old as OPNsense igc0
	participant BRAS as ISP access concentrator
	participant New as OPNsense igc2_vlan999

	Old-&gt;&gt;BRAS: PPPoE session established
	Note over Old,BRAS: Cable pulled without LCP Terminate or PADT
	New-&gt;&gt;BRAS: PADI from VLAN 999
	BRAS--xNew: One-session policy blocks new session
	Note over BRAS: Old session remains until timeout
	Old-&gt;&gt;BRAS: PADT during controlled retry
	BRAS--&gt;&gt;Old: Old session removed
	New-&gt;&gt;BRAS: PADI then PADR
	BRAS--&gt;&gt;New: PADO then PADS
	Note over New,BRAS: New PPPoE session succeeds
&lt;/pre&gt;
    &lt;figcaption&gt;The cable pull left the old session alive at the ISP; a PADT-first cutover explicitly closed it before the new interface dialled.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;On a BSD-based firewall, a capture like this is enough to observe the discovery
traffic:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;
&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-sh&#34; data-lang=&#34;sh&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;tcpdump -ni &amp;lt;wan-interface&amp;gt; -e ether proto 0x8863
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The exact command used to disconnect varies by platform. I prefer the firewall&amp;rsquo;s
normal interface disconnect action because it follows the same control path the
system uses in production. The capture is there to prove the shutdown emitted a
termination frame before any cable moved.&lt;/p&gt;
&lt;p&gt;With the old session closed first, the new interface authenticated normally.&lt;/p&gt;
&lt;p&gt;The connection came up on VLAN 999 after this. But it did not stay up.&lt;/p&gt;
&lt;h2 id=&#34;omada-loop-detection-on-the-ont-port&#34;&gt;Omada loop detection on the ONT port
&lt;/h2&gt;&lt;p&gt;The PPPoE session later failed again, this time after it had already been stable.
The pattern was different: discovery could be blocked outright, or an established
session would disappear several minutes later.&lt;/p&gt;
&lt;p&gt;The managed switch between the firewall and optical terminal had loop-detection
features enabled. It injected control traffic into the provider-facing bridged
segment: first spanning-tree-style frames, then vendor loopback-detection probes.
On an ordinary LAN those features can be useful. On a transparent WAN transport,
the ISP side is not an ordinary LAN and did not appreciate the extra frames.&lt;/p&gt;
&lt;p&gt;The fix was not to disable protection across the whole switch. I disabled
loopback control only on the port connected to the optical terminal. The WAN
VLAN remained isolated and tagged exactly as before; only the switch&amp;rsquo;s active
probing stopped.&lt;/p&gt;
&lt;figure class=&#34;article-diagram&#34;&gt;
    &lt;pre class=&#34;mermaid&#34;&gt;
flowchart TD
	MOVE[Move ONT cable from igc0 to VLAN 999] --&gt; IMMEDIATE{When does it fail?}
	IMMEDIATE --&gt;|Immediately| STALE[Old PPPoE session still held by ISP]
	STALE --&gt; PADT[Disconnect old WAN and verify PADT]
	PADT --&gt; UP[PPPoE connects on igc2_vlan999]
	IMMEDIATE --&gt;|Minutes after connecting| PROBES[Omada loopback-control probes enter WAN segment]
	PROBES --&gt; DROP[Established PPPoE session drops]
	DROP --&gt; PORT[Disable Loopback Control on ONT-facing port]
	PORT --&gt; STABLE[Session remains stable through soak and reboot]
&lt;/pre&gt;
    &lt;figcaption&gt;Two independent faults occupied the same WAN path and failed on different timelines.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;After that per-port change, the PPPoE session stayed up through the observation
window and survived reboots of the relevant equipment.&lt;/p&gt;
&lt;p&gt;Initially I thought the earlier fix was incomplete, but this was a separate
problem on the same path:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the cutover failed immediately because the old PPPoE session was never closed;&lt;/li&gt;
&lt;li&gt;the repaired connection later failed because the switch injected control
traffic into the WAN bridge.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&#34;the-migration-steps-i-use-now&#34;&gt;The migration steps I use now
&lt;/h2&gt;&lt;p&gt;Below is the order I now use when moving a live PPPoE WAN between ports, VLANs or
firewall nodes.&lt;/p&gt;
&lt;h3 id=&#34;before-the-maintenance-window&#34;&gt;Before the maintenance window
&lt;/h3&gt;&lt;ul&gt;
&lt;li&gt;Record the working parent interface, VLAN ID, MTU, credentials source, and WAN
MAC.&lt;/li&gt;
&lt;li&gt;Confirm whether the provider documents MAC binding or single-session limits.&lt;/li&gt;
&lt;li&gt;Preconfigure the destination interface without enabling it.&lt;/li&gt;
&lt;li&gt;Verify the VLAN at layer 2 independently of PPPoE when possible.&lt;/li&gt;
&lt;li&gt;Review the ISP-facing switch port for spanning tree, loop detection, LLDP,
discovery, or other active control protocols.&lt;/li&gt;
&lt;li&gt;Keep a management path that does not depend on the WAN being migrated.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;during-the-cutover&#34;&gt;During the cutover
&lt;/h3&gt;&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;
&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt; 1
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 2
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 3
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 4
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 5
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 6
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 7
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 8
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 9
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;10
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;11
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;12
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;13
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;14
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;15
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;16
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-text&#34; data-lang=&#34;text&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;old PPPoE connected
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;	│
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;	▼
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;disconnect old PPPoE cleanly
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;	│
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;	▼
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;observe PADT / termination
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;	│
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;	▼
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;move cable or VLAN ownership
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;	│
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;	▼
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;enable new PPPoE interface
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;	│
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;	▼
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;verify discovery, LCP, IPCP, route and DNS
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Do not clone the MAC reflexively. Preserve it only when you know the provider
requires it or when a controlled test demonstrates that it matters. Unnecessary
MAC duplication is especially hazardous if old and new devices can be online at
the same time.&lt;/p&gt;
&lt;h3 id=&#34;after-the-session-comes-up&#34;&gt;After the session comes up
&lt;/h3&gt;&lt;p&gt;Do more than check that a public IP appeared:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;inspect the PPP logs for successful LCP and IPCP negotiation;&lt;/li&gt;
&lt;li&gt;confirm the default route uses the new interface;&lt;/li&gt;
&lt;li&gt;test DNS through the normal client path;&lt;/li&gt;
&lt;li&gt;verify the negotiated MTU with don&amp;rsquo;t-fragment pings;&lt;/li&gt;
&lt;li&gt;keep the session under observation long enough to catch periodic switch probes;&lt;/li&gt;
&lt;li&gt;reboot the switch or firewall if reboot survival is part of the acceptance
criteria.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The distinction between &amp;ldquo;connected once&amp;rdquo; and &amp;ldquo;operationally complete&amp;rdquo; saved me
from calling the migration finished before the loop-detection failure surfaced.&lt;/p&gt;
&lt;h2 id=&#34;key-things-to-check&#34;&gt;Key things to check
&lt;/h2&gt;&lt;p&gt;The immediate failure and the delayed failure had different causes. If PPPoE
does not connect after moving a cable, first check whether the old session was
closed properly. If it connects and drops later, check what the switch is sending
on the WAN VLAN.&lt;/p&gt;
&lt;p&gt;Also, a managed switch is not always transparent. Disable STP, loop detection or
similar active features on the ONT-facing port unless you know the ISP path can
handle them.&lt;/p&gt;
&lt;p&gt;Most importantly, disconnect PPPoE before moving the cable and verify the PADT in
a packet capture. Waiting for the ISP timeout also worked, but it made a simple
cutover take much longer than needed.&lt;/p&gt;
</description>
        </item>
        <item>
        <title>Managing My Existing OPNsense Setup with OpenTofu</title>
        <link>https://blog.dexome.com/post/firewall-as-code/</link>
        <pubDate>Tue, 07 Jul 2026 00:00:00 +0530</pubDate>
        
        <guid>https://blog.dexome.com/post/firewall-as-code/</guid>
        <description>&lt;p&gt;I wanted to manage my existing OPNsense configuration using OpenTofu. The
firewall was already running DNS, DHCP, several VLANs, VPN connections and all
the rules for my home network. Recreating everything from code was not an option.&lt;/p&gt;
&lt;p&gt;I used the &lt;code&gt;browningluke/opnsense&lt;/code&gt; provider and started with one Unbound DNS
setting. After importing it, I did not continue until the plan showed:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;
&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-text&#34; data-lang=&#34;text&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;No changes. Your infrastructure matches the configuration.
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;This worked for DNS, but the next step caused a DHCP outage because one provider
default removed the gateway and DNS options from client leases. This post covers
the order I used after that incident and the provider limitations I found.&lt;/p&gt;
&lt;h2 id=&#34;import-first-and-make-no-changes&#34;&gt;Import first and make no changes
&lt;/h2&gt;&lt;p&gt;There are two separate things we may want to do during this migration:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;represent the current system in code;&lt;/li&gt;
&lt;li&gt;clean up the system while doing it.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;I would not combine them.&lt;/p&gt;
&lt;p&gt;The first goal is adoption. Its success criterion is boring: after import, the
configuration describes the live object exactly enough that a plan proposes no
change. Only after that baseline is stable should a separate change improve the
object.&lt;/p&gt;
&lt;p&gt;This matters most for routers and firewalls because the management path is one
of the resources being changed. An incorrect web-server deployment can return a
500. An incorrect gateway, DHCP option, or anti-lockout rule can remove the path
you need to repair it.&lt;/p&gt;
&lt;p&gt;I used four gates for every subsystem:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;
&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;2
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-text&#34; data-lang=&#34;text&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;inventory ──&amp;gt; import ──&amp;gt; zero-diff plan ──&amp;gt; one-object apply
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;		│             │             │                  │
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;		└── stop ─────┴── stop ─────┴── stop on drift ─┘
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The first real apply was always deliberately small.&lt;/p&gt;
&lt;figure class=&#34;article-diagram&#34;&gt;
    &lt;pre class=&#34;mermaid&#34;&gt;
flowchart TB
	U[Unbound DNS: 66 objects] --&gt;|zero-diff plan| K[Kea DHCP: 64 objects]
	K --&gt;|client lease test| O[Omada VLANs, profiles, ports and SSIDs]
	O --&gt;|controller no-op| C[Guest firewall canary]
	C --&gt;|compiled pf order| F[Per-interface filter migration]
	F --&gt; N[NAT and VPN resources]
&lt;/pre&gt;
    &lt;figcaption&gt;The adoption moved outward from lower-risk DNS objects to connectivity-critical filters and NAT, with a stop gate after every phase.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;h2 id=&#34;start-with-unbound-dns&#34;&gt;Start with Unbound DNS
&lt;/h2&gt;&lt;p&gt;Resolver settings and host overrides were a good first target. Sixty-six Unbound
objects were numerous enough to test import automation but less dangerous than
rewriting the firewall ruleset.&lt;/p&gt;
&lt;p&gt;The import revealed an important category of provider behavior: fields that
exist on the appliance but not in the provider schema. One host override generated
a reverse record, yet the provider did not expose that switch. Importing and
planning the resource produced no change, so the appliance-only field survived.&lt;/p&gt;
&lt;p&gt;That was acceptable. IaC coverage does not need to be 100 percent to be useful.
It does need to be honest.&lt;/p&gt;
&lt;p&gt;Another DNS feature exposed the opposite problem: the provider could read a
blocklist setting but failed when writing it. Rather than force ownership, I left
that feature GUI-managed and documented the boundary. A provider that cannot
round-trip a field does not own that field.&lt;/p&gt;
&lt;p&gt;The first phase ended with dozens of objects imported and a no-op plan. The point
was not the count. It was proving the API credentials, import identifiers, schema,
and state storage before touching client connectivity.&lt;/p&gt;
&lt;h2 id=&#34;kea-dhcp-and-the-auto_collect-issue&#34;&gt;Kea DHCP and the &lt;code&gt;auto_collect&lt;/code&gt; issue
&lt;/h2&gt;&lt;p&gt;Kea DHCP import covered 64 objects and looked equally clean until the first apply.
Clients on THINGS and QUANTUM renewed and still received valid addresses, but
they lost their default gateway and DNS server.&lt;/p&gt;
&lt;p&gt;The provider exposed an &lt;code&gt;auto_collect&lt;/code&gt; option. Its default was enabled, suggesting
that the appliance would derive subnet options automatically. On this system it
did not. Applying the resource removed the stored router, DNS, and NTP values.&lt;/p&gt;
&lt;p&gt;A simplified version of the dangerous assumption looked like this:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;
&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;2
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;3
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-hcl&#34; data-lang=&#34;hcl&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;resource&lt;/span&gt; &lt;span class=&#34;s2&#34;&gt;&amp;#34;firewall_dhcp_subnet&amp;#34; &amp;#34;clients&amp;#34;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;n&#34;&gt;	subnet&lt;/span&gt;       &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;s2&#34;&gt;&amp;#34;10.20.0.0/24&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;n&#34;&gt;	auto_collect&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;kt&#34;&gt;true&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The repaired declaration made every client-visible option explicit. This is a
simplified version of the QUANTUM subnet, whose gateway and resolver are
&lt;code&gt;10.100.30.1&lt;/code&gt;:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;
&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;2
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;3
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;4
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;5
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;6
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;7
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;8
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-hcl&#34; data-lang=&#34;hcl&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;resource&lt;/span&gt; &lt;span class=&#34;s2&#34;&gt;&amp;#34;firewall_dhcp_subnet&amp;#34; &amp;#34;clients&amp;#34;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;n&#34;&gt;	subnet&lt;/span&gt;       &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;s2&#34;&gt;&amp;#34;10.100.30.0/24&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;n&#34;&gt;	auto_collect&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;kt&#34;&gt;false&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;n&#34;&gt;	routers&lt;/span&gt;     &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;[&lt;/span&gt;&lt;span class=&#34;s2&#34;&gt;&amp;#34;10.100.30.1&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;n&#34;&gt;	dns_servers&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;[&lt;/span&gt;&lt;span class=&#34;s2&#34;&gt;&amp;#34;10.100.30.1&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;n&#34;&gt;	ntp_servers&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;[&lt;/span&gt;&lt;span class=&#34;s2&#34;&gt;&amp;#34;10.100.30.1&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;figure class=&#34;article-diagram&#34;&gt;
    &lt;pre class=&#34;mermaid&#34;&gt;
flowchart LR
		CLIENT[QUANTUM client] --&gt;|DHCP Discover| KEA[OPNsense Kea]
		KEA --&gt;|Offer: address only| CLIENT
		CLIENT --&gt; IP[Client has a 10.100.30.x address]
		CLIENT -. missing .-&gt; GW[Default gateway 10.100.30.1]
		CLIENT -. missing .-&gt; DNS[DNS server 10.100.30.1]
		IP --&gt; SYMPTOM[Looks connected but cannot route or resolve]
&lt;/pre&gt;
    &lt;figcaption&gt;The DHCP daemon stayed healthy while `auto_collect` removed the information clients needed to use their leases.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;The important thing here is that the provider default did not match the existing
OPNsense behavior. I now set every client-visible DHCP option explicitly.&lt;/p&gt;
&lt;p&gt;After restoring the option data, I verified DHCP as a client would:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;obtain a new lease;&lt;/li&gt;
&lt;li&gt;inspect the offered router and DNS options;&lt;/li&gt;
&lt;li&gt;reach the gateway;&lt;/li&gt;
&lt;li&gt;resolve a name;&lt;/li&gt;
&lt;li&gt;cross the firewall to an external address.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&amp;ldquo;The service is running&amp;rdquo; would not have caught this failure. DHCP was running
perfectly while handing out incomplete leases.&lt;/p&gt;
&lt;h2 id=&#34;importing-omada-configuration&#34;&gt;Importing Omada configuration
&lt;/h2&gt;&lt;p&gt;The managed-switch controller added another translation layer. The API endpoint
behind the normal reverse-proxy address redirected login requests, while the
provider expected to talk directly to the controller. Connecting to the direct
management origin fixed authentication.&lt;/p&gt;
&lt;p&gt;Imports then showed several values whose controller defaults differed from the
provider defaults: multicast snooping, relay booleans, and profile flags. To
reach a zero-diff plan, I had to write values that the GUI had previously left
implicit.&lt;/p&gt;
&lt;p&gt;Wireless credentials were particularly important. The controller returned a
non-null pre-shared key. Omitting the field in code did not mean &amp;ldquo;leave it alone&amp;rdquo;;
it meant &amp;ldquo;clear it.&amp;rdquo; The secret therefore had to be supplied at runtime from an
encrypted source so the plan could preserve the live network without committing
the key.&lt;/p&gt;
&lt;p&gt;Hardware controls deserve the same caution. On this controller, Power over
Ethernet belonged to a port profile. Applying a profile with PoE disabled to a
live access point would cut power to the device carrying the management traffic.
I treated profile changes as physical operations, not harmless metadata edits.&lt;/p&gt;
&lt;h2 id=&#34;firewall-rules-and-their-real-order&#34;&gt;Firewall rules and their real order
&lt;/h2&gt;&lt;p&gt;Firewall filters were the highest-risk phase because the appliance had two rule
stores:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;legacy rules created in the traditional per-interface GUI;&lt;/li&gt;
&lt;li&gt;automation rules created through the API and managed by OpenTofu.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The provider could not import legacy rules because they were not the same kind of
object. They had to be recreated in the automation store.&lt;/p&gt;
&lt;p&gt;That raised a more important question than whether the declarations looked
equivalent: where would the new rules land in the effective packet-filter order?&lt;/p&gt;
&lt;p&gt;Firewall evaluation is ordered. Two identical sets of rules can behave
differently if a broad pass or block moves above a specific exception. The GUI&amp;rsquo;s
visual order was not enough because it separated the two stores.&lt;/p&gt;
&lt;p&gt;I found an API endpoint that returned the compiled packet-filter rules in actual
evaluation order, including labels that distinguished automation objects from
legacy objects. I wrapped it in a small read-only script and made its output a
mandatory gate for every interface migration.&lt;/p&gt;
&lt;figure class=&#34;article-diagram&#34;&gt;
    &lt;pre class=&#34;mermaid&#34;&gt;
flowchart TB
	TF[OpenTofu resources] --&gt; AUTO[os-firewall Automation store]
	GUI[Existing GUI rules] --&gt; LEGACY[Legacy interface store]
	AUTO --&gt; COMPILE[OPNsense rule compiler]
	LEGACY --&gt; COMPILE
	SYSTEM[Anti-lockout and generated rules] --&gt; COMPILE
	COMPILE --&gt; PF[Effective pf rules in @N order]
	PF --&gt; CHECK[pf-rule-order.sh verification]
	CHECK --&gt;|Automation safely shadows legacy| REMOVE[Remove legacy twin]
	CHECK --&gt;|Unexpected order| STOP[Stop and repair sequence]
&lt;/pre&gt;
    &lt;figcaption&gt;OPNsense displayed legacy and Automation rules separately, so I queried the compiled pf order before removing any legacy rule.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;The sequence per interface became:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Recreate a small set of legacy rules as automation resources.&lt;/li&gt;
&lt;li&gt;Apply them while the legacy originals remain enabled.&lt;/li&gt;
&lt;li&gt;Query the compiled ruleset.&lt;/li&gt;
&lt;li&gt;Confirm the automation rules sit in the intended order and shadow the legacy
copies safely.&lt;/li&gt;
&lt;li&gt;Test traffic through that interface.&lt;/li&gt;
&lt;li&gt;Disable, then remove, the legacy copies.&lt;/li&gt;
&lt;li&gt;Plan again and confirm no unexpected drift.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;I started with a low-risk guest network containing only three rules. It was a
canary for the ordering model. Only after its compiled order and behavior were
correct did I migrate management, server, VPN, and WAN interfaces one at a time.&lt;/p&gt;
&lt;p&gt;Explicit sequence values were essential. Relying on every resource&amp;rsquo;s default
sequence created ties and non-deterministic placement. I reserved sequence ranges
per interface so both humans and the provider had one stable ordering model.&lt;/p&gt;
&lt;h2 id=&#34;disabled-rules-can-still-block-deletion&#34;&gt;Disabled rules can still block deletion
&lt;/h2&gt;&lt;p&gt;One migration exposed another appliance quirk. A disabled legacy rule still
referenced an alias, and that reference prevented OpenTofu from deleting the
alias. From an operator&amp;rsquo;s perspective the rule was inactive. From the appliance&amp;rsquo;s
validation perspective it still existed.&lt;/p&gt;
&lt;p&gt;The fix was to remove the obsolete legacy rule, not merely disable it.&lt;/p&gt;
&lt;p&gt;This is why I avoided bulk cleanup during adoption. Relationships that do not
affect packet evaluation can still affect schema validation and deletion order.&lt;/p&gt;
&lt;h2 id=&#34;migrating-nat-separately&#34;&gt;Migrating NAT separately
&lt;/h2&gt;&lt;p&gt;Filter rules and NAT rules may appear together in the GUI, but they are not the
same ownership boundary. Some legacy firewall rules carried an association to a
generated NAT rule that the provider could not preserve. I migrated NAT in a
later phase after filter behavior was stable.&lt;/p&gt;
&lt;p&gt;The NAT provider also had schema gaps: some labels were unavailable, some port
fields rejected aliases, and protocol values normalized differently from the
appliance. These limitations did not invalidate the whole migration. They defined
which details stayed appliance-managed and which needed a different expression.&lt;/p&gt;
&lt;p&gt;I left settings in the GUI when the provider could not safely read and write
them. It is better to document that boundary than force an incomplete resource
to own it.&lt;/p&gt;
&lt;h2 id=&#34;secrets-and-state&#34;&gt;Secrets and state
&lt;/h2&gt;&lt;p&gt;An API-driven firewall migration touches credentials in several places:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;firewall API keys;&lt;/li&gt;
&lt;li&gt;VPN static keys and certificates;&lt;/li&gt;
&lt;li&gt;wireless pre-shared keys;&lt;/li&gt;
&lt;li&gt;remote-state access credentials.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I kept secrets encrypted outside the HCL and injected them into provider or
resource variables only for the command that needed them. That keeps plaintext
out of source files, but it does not automatically keep secrets out of state.
Provider schemas may still serialize sensitive values into the state backend.&lt;/p&gt;
&lt;p&gt;The state backend therefore needs the same protection as the firewall backup:
access control, encryption, and a tested recovery procedure. If the backend has
no locking, only one writer can safely apply at a time.&lt;/p&gt;
&lt;p&gt;Import declarations are worth retaining as disaster-recovery documentation. A
state loss otherwise also loses the mapping between stable resource names and
opaque appliance UUIDs.&lt;/p&gt;
&lt;h2 id=&#34;checks-used-for-each-resource-type&#34;&gt;Checks used for each resource type
&lt;/h2&gt;&lt;p&gt;For every new resource family, I now ask:&lt;/p&gt;
&lt;h3 id=&#34;before-import&#34;&gt;Before import
&lt;/h3&gt;&lt;ul&gt;
&lt;li&gt;Does the provider read and write the same API representation?&lt;/li&gt;
&lt;li&gt;Which live fields are absent from the schema?&lt;/li&gt;
&lt;li&gt;Which provider defaults differ from appliance defaults?&lt;/li&gt;
&lt;li&gt;Can this resource interrupt the management path, power, DHCP, DNS, or WAN?&lt;/li&gt;
&lt;li&gt;Is there a read-only way to inspect the compiled/effective result?&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;before-the-first-apply&#34;&gt;Before the first apply
&lt;/h3&gt;&lt;ul&gt;
&lt;li&gt;Is the plan a no-op after import?&lt;/li&gt;
&lt;li&gt;Are secret values present at runtime but absent from source?&lt;/li&gt;
&lt;li&gt;Is the first apply limited to one object or one low-risk segment?&lt;/li&gt;
&lt;li&gt;Is there an independent management path and a rollback artifact?&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;after-apply&#34;&gt;After apply
&lt;/h3&gt;&lt;ul&gt;
&lt;li&gt;Did a real client receive the expected service, not merely a green status?&lt;/li&gt;
&lt;li&gt;Does the compiled firewall order match the intended order?&lt;/li&gt;
&lt;li&gt;Did the appliance preserve fields the provider does not expose?&lt;/li&gt;
&lt;li&gt;Does a second plan return to no changes?&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&#34;final-setup&#34;&gt;Final setup
&lt;/h2&gt;&lt;p&gt;Not every OPNsense setting is managed by OpenTofu. Some remain in the GUI because
the provider cannot represent or write them safely. What I have now is a clear
list of which tool owns each resource and a repeatable process:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;import live state;&lt;/li&gt;
&lt;li&gt;insist on zero drift;&lt;/li&gt;
&lt;li&gt;change one boundary at a time;&lt;/li&gt;
&lt;li&gt;inspect the effective system, not just the tool&amp;rsquo;s model;&lt;/li&gt;
&lt;li&gt;preserve a way back in.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The main rule is to get a no-change plan after import and then apply one small
change. Also verify from a real client. In the DHCP incident, the daemon was
healthy and the apply succeeded, but clients received leases without a gateway
or DNS server.&lt;/p&gt;
</description>
        </item>
        <item>
        <title>Moving My NixOS Network to systemd-networkd</title>
        <link>https://blog.dexome.com/post/scripted-to-systemd-networkd/</link>
        <pubDate>Thu, 18 Jun 2026 00:00:00 +0530</pubDate>
        
        <guid>https://blog.dexome.com/post/scripted-to-systemd-networkd/</guid>
        <description>&lt;p&gt;My NixOS server &lt;code&gt;heavymetal&lt;/code&gt; already had multiple VLANs, static IP addresses and
macvlans used by containers. They were created using startup scripts and worked,
but restarts and NixOS activations did not always recreate them in the correct
order.&lt;/p&gt;
&lt;p&gt;I first moved the same setup to systemd-networkd. Later, when I needed to attach
VM tap interfaces to the VLANs, I added per-VLAN bridges and finally replaced
them with one VLAN-aware bridge.&lt;/p&gt;
&lt;p&gt;The migration happened in three steps:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;replace imperative networking with networkd units;&lt;/li&gt;
&lt;li&gt;move host addresses from VLAN devices onto stable per-VLAN bridges;&lt;/li&gt;
&lt;li&gt;consolidate those bridges into a single VLAN-aware trunk.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;I did not apply the bridge migrations live because they moved the same management
IP used by SSH. Those changes were staged for boot with console access available.&lt;/p&gt;
&lt;figure class=&#34;article-diagram&#34;&gt;
    &lt;pre class=&#34;mermaid&#34;&gt;
flowchart TB
	S[&#34;Scripted VLAN and&lt;br/&gt;macvlan devices&#34;] --&gt; N[&#34;systemd-networkd owns&lt;br/&gt;the same topology&#34;]
	N --&gt;|Parent restart exposes child lifecycle| P[&#34;Per-VLAN bridges&lt;br/&gt;brvlan20 / brvlan30 / brvlan100&#34;]
	P --&gt;|Stable parents enable VM taps| T[&#34;VLAN-aware bridge&lt;br/&gt;brtrunk&#34;]
&lt;/pre&gt;
    &lt;figcaption&gt;The network evolved in three controlled steps rather than jumping directly from scripts to the final VLAN-aware bridge.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;h2 id=&#34;the-old-startup-scripts&#34;&gt;The old startup scripts
&lt;/h2&gt;&lt;p&gt;The old system created VLAN and macvlan devices through a mixture of higher-level
configuration and startup services. Conceptually it did this:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;
&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;2
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;3
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;4
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;5
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;6
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-sh&#34; data-lang=&#34;sh&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;ip link add link enp5s0 name enp5s0.100 &lt;span class=&#34;nb&#34;&gt;type&lt;/span&gt; vlan id &lt;span class=&#34;m&#34;&gt;100&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;ip address add 10.100.100.20/24 dev enp5s0.100
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;ip link &lt;span class=&#34;nb&#34;&gt;set&lt;/span&gt; enp5s0.100 up
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;ip link add link enp5s0.100 name tfkshim &lt;span class=&#34;nb&#34;&gt;type&lt;/span&gt; macvlan mode bridge
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;ip link &lt;span class=&#34;nb&#34;&gt;set&lt;/span&gt; tfkshim up
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Imperative commands are easy to prototype, but they hide ownership questions:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Which service owns the VLAN device?&lt;/li&gt;
&lt;li&gt;What should restart when the parent disappears?&lt;/li&gt;
&lt;li&gt;Is the address ready before a dependent service binds it?&lt;/li&gt;
&lt;li&gt;What removes stale devices after a failed partial run?&lt;/li&gt;
&lt;li&gt;Does a config reload destroy children created by another service?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The scripts encoded answers in execution order rather than in the topology.&lt;/p&gt;
&lt;h2 id=&#34;recreate-the-same-network-with-networkd&#34;&gt;Recreate the same network with networkd
&lt;/h2&gt;&lt;p&gt;I moved each concern into an explicit networkd object: VLAN &lt;code&gt;.netdev&lt;/code&gt; units,
matching &lt;code&gt;.network&lt;/code&gt; units for addresses and routes, and dependencies for services
that needed those links.&lt;/p&gt;
&lt;p&gt;On NixOS, a simplified VLAN looks like this:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;
&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt; 1
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 2
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 3
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 4
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 5
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 6
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 7
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 8
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 9
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;10
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;11
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;12
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;13
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;14
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-nix&#34; data-lang=&#34;nix&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;n&#34;&gt;systemd&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;network&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;netdevs&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;s2&#34;&gt;&amp;#34;10-vlan20&amp;#34;&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;	&lt;span class=&#34;n&#34;&gt;netdevConfig&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;		&lt;span class=&#34;n&#34;&gt;Name&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;s2&#34;&gt;&amp;#34;vlan20&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;		&lt;span class=&#34;n&#34;&gt;Kind&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;s2&#34;&gt;&amp;#34;vlan&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;	&lt;span class=&#34;p&#34;&gt;};&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;	&lt;span class=&#34;n&#34;&gt;vlanConfig&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;Id&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;mi&#34;&gt;20&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;};&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;n&#34;&gt;systemd&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;network&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;networks&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;s2&#34;&gt;&amp;#34;40-vlan20&amp;#34;&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;	&lt;span class=&#34;n&#34;&gt;matchConfig&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;Name&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;s2&#34;&gt;&amp;#34;vlan20&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;	&lt;span class=&#34;n&#34;&gt;address&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;[&lt;/span&gt; &lt;span class=&#34;s2&#34;&gt;&amp;#34;10.20.0.10/24&amp;#34;&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;];&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;	&lt;span class=&#34;n&#34;&gt;routes&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;[&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;{&lt;/span&gt; &lt;span class=&#34;n&#34;&gt;Gateway&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;s2&#34;&gt;&amp;#34;10.20.0.1&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;}&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;];&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;	&lt;span class=&#34;n&#34;&gt;networkConfig&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;RequiredForOnline&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;s2&#34;&gt;&amp;#34;routable&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;};&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The exact syntax is distribution-specific; the systemd concepts are not. A
&lt;code&gt;.netdev&lt;/code&gt; creates a virtual device. A &lt;code&gt;.network&lt;/code&gt; matches a device and assigns
addresses, routes, VLAN membership, bridge membership, and online-state
requirements.&lt;/p&gt;
&lt;p&gt;The important part was preserving topology exactly. This was not the moment to
rename every interface or collapse five networks into one bridge. First I needed
networkd to reproduce the working system.&lt;/p&gt;
&lt;h2 id=&#34;systemd-resolved-broke-container-dns&#34;&gt;systemd-resolved broke container DNS
&lt;/h2&gt;&lt;p&gt;Enabling networkd on NixOS also enabled systemd-resolved through a distribution
default. The host continued to resolve names, so the change initially looked
healthy. But &lt;code&gt;/etc/resolv.conf&lt;/code&gt; now pointed at the loopback stub:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;
&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-text&#34; data-lang=&#34;text&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;nameserver 127.0.0.53
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Containers copied that file into their own network namespaces. From inside a
container, &lt;code&gt;127.0.0.53&lt;/code&gt; meant the container itself, not the host&amp;rsquo;s resolved
service. External name resolution failed even though host DNS worked.&lt;/p&gt;
&lt;p&gt;I explicitly disabled resolved and retained a static resolver address reachable
from both the host and containers. Running resolved would also have been valid if
the container DNS path had been designed for it. The failure came from changing
resolver architecture as an accidental side effect of changing interface
management.&lt;/p&gt;
&lt;p&gt;The check I added was simple:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;
&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;2
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-sh&#34; data-lang=&#34;sh&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;cat /etc/resolv.conf
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;podman &lt;span class=&#34;nb&#34;&gt;exec&lt;/span&gt; &amp;lt;container&amp;gt; cat /etc/resolv.conf
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;podman &lt;span class=&#34;nb&#34;&gt;exec&lt;/span&gt; &amp;lt;container&amp;gt; getent hosts example.com
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;A host-only lookup is not enough after a network-manager migration.&lt;/p&gt;
&lt;h2 id=&#34;restarting-a-vlan-removed-its-macvlan-children&#34;&gt;Restarting a VLAN removed its macvlan children
&lt;/h2&gt;&lt;p&gt;An older deployment had already revealed a more disruptive lifecycle problem.
When the service owning a VLAN netdev restarted, it deleted and recreated the
parent device. Linux also deleted every macvlan child attached to that parent.&lt;/p&gt;
&lt;p&gt;The container runtime&amp;rsquo;s database still believed the container was attached. The
actual network namespace contained only loopback and an unrelated bridge. The
reverse proxy logged that its interface had been removed and lost its virtual
address.&lt;/p&gt;
&lt;p&gt;The journal made the sequence visible:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;
&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;2
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;3
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-text&#34; data-lang=&#34;text&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;VLAN netdev service stopped
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;parent link deleted
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;macvlan child removed by kernel
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;container remains running without expected interface
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;figure class=&#34;article-diagram&#34;&gt;
    &lt;pre class=&#34;mermaid&#34;&gt;
sequenceDiagram
	participant Apply as NixOS activation
	participant Parent as enp5s0.100 netdev
	participant Kernel as Linux kernel
	participant Child as tfkshim macvlan
	participant Podman as Traefik container

	Apply-&gt;&gt;Parent: Stop and delete VLAN netdev
	Parent-&gt;&gt;Kernel: ip link del enp5s0.100
	Kernel--xChild: Delete every macvlan child
	Child--xPodman: eth0 removed from namespace
	Note over Podman: Container keeps running and DB still says attached
	Apply-&gt;&gt;Parent: Recreate VLAN netdev
	Note over Parent,Podman: Parent returns but child does not
&lt;/pre&gt;
    &lt;figcaption&gt;Deleting and recreating the VLAN parent also deleted the live macvlan child, while Podman&amp;#39;s stored attachment remained stale.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;At first I coupled the container-network service lifecycle to the parent netdev
service: if the parent restarted, the container networks and their consumers
restarted and reattached. That repaired the immediate inconsistency.&lt;/p&gt;
&lt;p&gt;It also made the weakness of the topology clear. Long-lived workloads were
attached directly to a device that configuration reconciliation was allowed to
destroy.&lt;/p&gt;
&lt;h2 id=&#34;adding-one-bridge-per-vlan&#34;&gt;Adding one bridge per VLAN
&lt;/h2&gt;&lt;p&gt;I needed the same VLANs to be shared with ThingsHQ microVM tap devices. A macvlan
parent cannot serve that role cleanly, so I created always-on host bridges such
as &lt;code&gt;brvlan20&lt;/code&gt; and &lt;code&gt;brvlan100&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The VLAN uplink became an addressless bridge port. The host address moved to the
bridge. Containers and VMs attached to the bridge rather than directly to the
VLAN netdev.&lt;/p&gt;
&lt;p&gt;A simplified networkd definition looks like:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;
&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt; 1
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 2
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 3
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 4
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 5
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 6
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 7
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 8
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 9
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;10
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;11
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;12
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;13
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;14
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;15
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;16
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;17
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;18
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;19
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-nix&#34; data-lang=&#34;nix&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;n&#34;&gt;systemd&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;network&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;netdevs&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;s2&#34;&gt;&amp;#34;10-br-vlan20&amp;#34;&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;	&lt;span class=&#34;n&#34;&gt;netdevConfig&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;		&lt;span class=&#34;n&#34;&gt;Name&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;s2&#34;&gt;&amp;#34;br-vlan20&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;		&lt;span class=&#34;n&#34;&gt;Kind&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;s2&#34;&gt;&amp;#34;bridge&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;	&lt;span class=&#34;p&#34;&gt;};&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;};&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;n&#34;&gt;systemd&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;network&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;networks&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;s2&#34;&gt;&amp;#34;40-vlan20&amp;#34;&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;	&lt;span class=&#34;n&#34;&gt;matchConfig&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;Name&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;s2&#34;&gt;&amp;#34;vlan20&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;	&lt;span class=&#34;n&#34;&gt;networkConfig&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;Bridge&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;s2&#34;&gt;&amp;#34;br-vlan20&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;	&lt;span class=&#34;n&#34;&gt;linkConfig&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;RequiredForOnline&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;s2&#34;&gt;&amp;#34;enslaved&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;};&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;n&#34;&gt;systemd&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;network&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;networks&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;s2&#34;&gt;&amp;#34;40-br-vlan20&amp;#34;&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;	&lt;span class=&#34;n&#34;&gt;matchConfig&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;Name&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;s2&#34;&gt;&amp;#34;br-vlan20&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;	&lt;span class=&#34;n&#34;&gt;address&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;[&lt;/span&gt; &lt;span class=&#34;s2&#34;&gt;&amp;#34;10.20.0.10/24&amp;#34;&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;];&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;	&lt;span class=&#34;n&#34;&gt;routes&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;[&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;{&lt;/span&gt; &lt;span class=&#34;n&#34;&gt;Gateway&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;s2&#34;&gt;&amp;#34;10.20.0.1&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;}&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;];&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;	&lt;span class=&#34;n&#34;&gt;networkConfig&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;RequiredForOnline&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;s2&#34;&gt;&amp;#34;routable&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;};&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;This transition could not be safely applied live. A macvlan cannot be re-parented
in place. Networkd could not enslave the VLAN uplink to the new bridge while the
live macvlan child still depended on it, and deleting the child would remove the
remote management path carried by the reverse proxy.&lt;/p&gt;
&lt;p&gt;Instead of a live configuration switch, I staged the new boot generation and
rebooted through a console-backed maintenance path. At boot, every device was
created in the new topology from an empty state. The previous generation remained
selectable in the boot menu if the new management address did not come up.&lt;/p&gt;
&lt;p&gt;That was not excessive caution. &amp;ldquo;Same IP, different owning device&amp;rdquo; is still a
remote access migration.&lt;/p&gt;
&lt;h2 id=&#34;moving-to-one-vlan-aware-bridge&#34;&gt;Moving to one VLAN-aware bridge
&lt;/h2&gt;&lt;p&gt;Per-VLAN bridges solved the VM-sharing problem, but the host eventually needed a
larger trunk topology. Five bridges and five VLAN uplinks repeated the same
structure and made VM trunk attachment awkward.&lt;/p&gt;
&lt;p&gt;The next design used a single bridge named &lt;code&gt;brtrunk&lt;/code&gt; with VLAN filtering.&lt;/p&gt;
&lt;figure class=&#34;article-diagram&#34;&gt;
    &lt;pre class=&#34;mermaid&#34;&gt;
flowchart LR
	SWITCH[Omada trunk] ==&gt;|tagged VLANs| NIC[enp5s0]
	NIC --&gt; BR[brtrunk VLAN-aware bridge]
	BR --&gt; ADMIN[brtrunk.100: host admin 10.100.100.20]
	BR --&gt; THINGS[brtrunk.20: Things services]
	BR --&gt; QUANTUM[brtrunk.30: VPN services]
	BR --&gt; VMS[OPNsense and ThingsHQ VM tap ports]
	BR --&gt; ISOLATED[VLAN 67 sync and VLAN 999 WAN transport: no host L3]
&lt;/pre&gt;
    &lt;figcaption&gt;In the final design, enp5s0 is a bridge port; brtrunk owns VLAN filtering, while host L3 exists only on selected VLAN interfaces.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;In networkd terms, the bridge enables VLAN filtering and the physical interface
is enslaved as a trunk port:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;
&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt; 1
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 2
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 3
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 4
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 5
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 6
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 7
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 8
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 9
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;10
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;11
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;12
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;13
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;14
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;15
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;16
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-nix&#34; data-lang=&#34;nix&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;n&#34;&gt;systemd&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;network&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;netdevs&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;s2&#34;&gt;&amp;#34;10-br-trunk&amp;#34;&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;	&lt;span class=&#34;n&#34;&gt;netdevConfig&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;		&lt;span class=&#34;n&#34;&gt;Name&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;s2&#34;&gt;&amp;#34;brtrunk&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;		&lt;span class=&#34;n&#34;&gt;Kind&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;s2&#34;&gt;&amp;#34;bridge&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;	&lt;span class=&#34;p&#34;&gt;};&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;	&lt;span class=&#34;n&#34;&gt;bridgeConfig&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;		&lt;span class=&#34;n&#34;&gt;VLANFiltering&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;no&#34;&gt;true&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;		&lt;span class=&#34;n&#34;&gt;DefaultPVID&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;mi&#34;&gt;1&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;	&lt;span class=&#34;p&#34;&gt;};&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;};&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;n&#34;&gt;systemd&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;network&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;networks&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;s2&#34;&gt;&amp;#34;40-physical-trunk&amp;#34;&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;	&lt;span class=&#34;n&#34;&gt;matchConfig&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;Name&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;s2&#34;&gt;&amp;#34;enp5s0&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;	&lt;span class=&#34;n&#34;&gt;networkConfig&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;Bridge&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;s2&#34;&gt;&amp;#34;brtrunk&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;	&lt;span class=&#34;n&#34;&gt;linkConfig&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;RequiredForOnline&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;s2&#34;&gt;&amp;#34;enslaved&amp;#34;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;};&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The host&amp;rsquo;s management address moved from a per-VLAN bridge onto a VLAN interface
of the trunk bridge. Some transport VLANs existed only inside the bridge and had
no host-layer address at all.&lt;/p&gt;
&lt;p&gt;Again, I staged the configuration for the next boot rather than attempting to
replace the bridge beneath an active SSH session. The migration changed the
master of the physical NIC, removed several bridges, recreated VLAN interfaces,
and moved the default route. A reboot was the deterministic path.&lt;/p&gt;
&lt;h2 id=&#34;conflicting-forwarding-sysctls&#34;&gt;Conflicting forwarding sysctls
&lt;/h2&gt;&lt;p&gt;After one deployment, host-to-container routing failed even though
&lt;code&gt;net.ipv4.ip_forward&lt;/code&gt; appeared in the configuration.&lt;/p&gt;
&lt;p&gt;Linux exposes closely related forwarding sysctls:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;
&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-text&#34; data-lang=&#34;text&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;net.ipv4.ip_forward
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;net.ipv4.conf.all.forwarding
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Modules had written both aliases with conflicting values. The canonical
per-family setting remained zero at runtime, leaving forwarding disabled. I made
both values explicit:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;
&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;2
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;3
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;4
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;5
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-nix&#34; data-lang=&#34;nix&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;n&#34;&gt;boot&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;kernel&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;sysctl&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;	&lt;span class=&#34;s2&#34;&gt;&amp;#34;net.ipv4.ip_forward&amp;#34;&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;mi&#34;&gt;1&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;	&lt;span class=&#34;s2&#34;&gt;&amp;#34;net.ipv4.conf.all.forwarding&amp;#34;&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;mi&#34;&gt;1&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;	&lt;span class=&#34;s2&#34;&gt;&amp;#34;net.ipv6.conf.all.forwarding&amp;#34;&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;mi&#34;&gt;1&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;};&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Both names refer to closely related kernel settings and another module can write
one after the other. So I check the runtime values after activation:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;
&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;2
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-sh&#34; data-lang=&#34;sh&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;sysctl net.ipv4.ip_forward
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;sysctl net.ipv4.conf.all.forwarding
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;sysctl net.ipv6.conf.all.forwarding
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The kernel is the final source of truth.&lt;/p&gt;
&lt;h2 id=&#34;source-only-addresses-and-automatic-routes&#34;&gt;Source-only addresses and automatic routes
&lt;/h2&gt;&lt;p&gt;The host also had secondary interfaces used only to originate scans or reach
macvlan workloads. Those addresses must not automatically install connected
prefix routes if another interface owns the real return path.&lt;/p&gt;
&lt;p&gt;I set &lt;code&gt;AddPrefixRoute=false&lt;/code&gt; on those source-only legs and kept reverse-path
filtering loose where asymmetric routes were intentional. Otherwise Linux could
prefer the newly connected &lt;code&gt;/24&lt;/code&gt;, send replies out the wrong interface, and turn
a harmless scan address into an SSH lockout.&lt;/p&gt;
&lt;p&gt;This is a niche detail with a broad principle: every new address can also create
a route. During migrations, compare the routing table, not just the address list.&lt;/p&gt;
&lt;h2 id=&#34;checks-after-each-migration&#34;&gt;Checks after each migration
&lt;/h2&gt;&lt;p&gt;After each stage I checked the system from the bottom up.&lt;/p&gt;
&lt;h3 id=&#34;link-ownership&#34;&gt;Link ownership
&lt;/h3&gt;&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;
&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;2
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-sh&#34; data-lang=&#34;sh&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;networkctl status
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;ip -br link
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;ip -br address
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;ul&gt;
&lt;li&gt;Is the physical NIC enslaved to the intended bridge?&lt;/li&gt;
&lt;li&gt;Are VLAN devices attached to the intended parent?&lt;/li&gt;
&lt;li&gt;Is each host address on the bridge/VLAN device, not the addressless port?&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;routes-and-forwarding&#34;&gt;Routes and forwarding
&lt;/h3&gt;&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;
&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;2
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-sh&#34; data-lang=&#34;sh&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;ip route
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;ip -6 route
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;sysctl net.ipv4.conf.all.forwarding
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;ul&gt;
&lt;li&gt;Is there exactly one intended default route?&lt;/li&gt;
&lt;li&gt;Did source-only addresses add unwanted connected routes?&lt;/li&gt;
&lt;li&gt;Is forwarding enabled in runtime state?&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;namespaces&#34;&gt;Namespaces
&lt;/h3&gt;&lt;ul&gt;
&lt;li&gt;Can a container resolve DNS?&lt;/li&gt;
&lt;li&gt;Does its namespace contain every expected interface?&lt;/li&gt;
&lt;li&gt;Can it reach both an internal backend and an external endpoint?&lt;/li&gt;
&lt;li&gt;Does restarting the parent/network service reattach the child cleanly?&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;reboot-behavior&#34;&gt;Reboot behavior
&lt;/h3&gt;&lt;ul&gt;
&lt;li&gt;Does the host return on the management address without manual intervention?&lt;/li&gt;
&lt;li&gt;Do services wait for the interface state they actually require?&lt;/li&gt;
&lt;li&gt;Does a second ordinary configuration apply leave the topology intact?&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&#34;final-setup&#34;&gt;Final setup
&lt;/h2&gt;&lt;p&gt;systemd-networkd did not make the network itself simple, but it made device
ownership and startup dependencies visible. The order I would use again is:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;reproduce the working topology declaratively;&lt;/li&gt;
&lt;li&gt;observe the lifecycle failures that the old scripts concealed;&lt;/li&gt;
&lt;li&gt;introduce stable bridge ownership;&lt;/li&gt;
&lt;li&gt;consolidate only after the dependencies are understood;&lt;/li&gt;
&lt;li&gt;stage non-live-reparentable changes for reboot with console rollback.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Do not assume a declarative network change is safe to apply over SSH. Moving a
management IP from a VLAN to a bridge still removes and recreates interfaces. For
those changes, I used a boot-time migration and kept the previous NixOS generation
available from the console.&lt;/p&gt;
</description>
        </item>
        <item>
        <title>Moving Traefik into a Podman Container</title>
        <link>https://blog.dexome.com/post/containerize-reverse-proxy-gotchas/</link>
        <pubDate>Thu, 11 Jun 2026 00:00:00 +0530</pubDate>
        
        <guid>https://blog.dexome.com/post/containerize-reverse-proxy-gotchas/</guid>
        <description>&lt;p&gt;I was running Traefik as a native NixOS service. Over time, I had added proxy
services to let it reach applications in Podman networks. Instead of keeping
those extra proxies, I decided to run Traefik itself in Podman and attach it to
the required networks.&lt;/p&gt;
&lt;p&gt;The container started and its health check passed, but some routes returned
&lt;code&gt;502 Bad Gateway&lt;/code&gt;. Requests from another VLAN timed out, and after recreating
the container the ingress IP stopped responding.&lt;/p&gt;
&lt;p&gt;There were three separate issues:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;loopback stopped meaning the host;&lt;/li&gt;
&lt;li&gt;a multi-homed network namespace chose the wrong return route;&lt;/li&gt;
&lt;li&gt;the macvlan MAC changed while the gateway still remembered the old one.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&#34;the-container-network-setup&#34;&gt;The container network setup
&lt;/h2&gt;&lt;p&gt;Native Traefik had accumulated proxy shims to reach services spread across host
processes, Podman bridges, and network containers. Moving Traefik itself into
Podman let it join application networks directly and removed a layer of socket
forwarding.&lt;/p&gt;
&lt;p&gt;The resulting Traefik container joined three networks:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;podman_network_vlan_100&lt;/code&gt; for the ingress VIP &lt;code&gt;10.100.100.21&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;podman_rproxy&lt;/code&gt; at &lt;code&gt;10.89.0.2&lt;/code&gt; for host and internal backends;&lt;/li&gt;
&lt;li&gt;an internet bridge at &lt;code&gt;172.28.0.12&lt;/code&gt; for outbound access.&lt;/li&gt;
&lt;/ul&gt;
&lt;figure class=&#34;article-diagram&#34;&gt;
    &lt;pre class=&#34;mermaid&#34;&gt;
flowchart TB
	CLIENTS[Clients and OPNsense] --&gt;|HTTPS to 10.100.100.21| VLAN[macvlan VLAN 100]
	VLAN --&gt; T[Traefik 3.7.5]
	T --&gt;|10.89.0.1 backend ports| HOST[podman_rproxy host gateway]
	HOST --&gt; SERVICES[Netdata, Glances, Cockpit, go2rtc, Home Assistant]
	T --&gt;|container DNS and updates| INET[172.28.0.0/24 Internet bridge]
&lt;/pre&gt;
    &lt;figcaption&gt;Containerized Traefik is multi-homed: ingress, private backends, and Internet access are separate network legs.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;The three networks worked, but the old Traefik configuration still assumed it
was running in the host network namespace.&lt;/p&gt;
&lt;h2 id=&#34;host-services-returned-502&#34;&gt;Host services returned 502
&lt;/h2&gt;&lt;p&gt;Several file-provider routes still targeted backends such as:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;
&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-yaml&#34; data-lang=&#34;yaml&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;nt&#34;&gt;servers&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;  &lt;/span&gt;- &lt;span class=&#34;nt&#34;&gt;url&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;l&#34;&gt;http://127.0.0.1:19999&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;That URL worked when Traefik was a host process. Inside the container,
&lt;code&gt;127.0.0.1&lt;/code&gt; referred to the Traefik container itself. Netdata, go2rtc, Cockpit,
and other host services had not moved with it.&lt;/p&gt;
&lt;p&gt;Services on the same Podman bridge continued to work. Only host services and
systemd-nspawn backends failed, while the Traefik health check remained green.&lt;/p&gt;
&lt;p&gt;I tested from both sides of the namespace boundary:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;
&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;2
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;3
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;4
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;5
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-sh&#34; data-lang=&#34;sh&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;# Works from the NixOS host&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;curl -fsS http://10.89.0.1:8123/
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;# Initially failed from Traefik&amp;#39;s namespace&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;podman &lt;span class=&#34;nb&#34;&gt;exec&lt;/span&gt; traefik wget -qO- -T2 http://10.89.0.1:8123/
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The second command showed the problem without involving DNS, TLS or OPNsense.&lt;/p&gt;
&lt;p&gt;The fix had three parts:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Bind host services to the &lt;code&gt;podman_rproxy&lt;/code&gt; gateway, &lt;code&gt;10.89.0.1&lt;/code&gt;, rather than
relying on loopback.&lt;/li&gt;
&lt;li&gt;Allow only the required backend ports on the &lt;code&gt;podman_rproxy&lt;/code&gt; interface in the
NixOS firewall.&lt;/li&gt;
&lt;li&gt;Change Traefik&amp;rsquo;s backend URLs from &lt;code&gt;127.0.0.1&lt;/code&gt; to &lt;code&gt;10.89.0.1&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;For Netdata, I also widened its application-level allow list from localhost to
the &lt;code&gt;10.89.0.0/24&lt;/code&gt; proxy subnet. Opening the host firewall alone would not have
overridden the service&amp;rsquo;s own filter.&lt;/p&gt;
&lt;figure class=&#34;article-diagram&#34;&gt;
    &lt;pre class=&#34;mermaid&#34;&gt;
flowchart TB
	subgraph NATIVE[Native Traefik]
		NT[Traefik process] --&gt;|127.0.0.1| NS[Host service]
	end
	subgraph CONTAINER[Containerized Traefik]
		CT[Traefik container] --&gt;|127.0.0.1| SELF[Traefik container loopback]
		CT --&gt;|10.89.0.1| HS[Host service on podman_rproxy]
	end
	NT -. moved into container .-&gt; CT
&lt;/pre&gt;
    &lt;figcaption&gt;The same URL points at a different machine after Traefik crosses into a container namespace.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;The 502s disappeared, but off-subnet clients still timed out.&lt;/p&gt;
&lt;h2 id=&#34;requests-from-other-vlans-timed-out&#34;&gt;Requests from other VLANs timed out
&lt;/h2&gt;&lt;p&gt;Traefik had more than one default route because netavark attached multiple
networks. The route selected during one container creation was not guaranteed to
be the same route selected after another.&lt;/p&gt;
&lt;p&gt;Clients on VLAN 100 were on-link with the macvlan VIP, so replies used the
connected route and worked. A client from another VLAN reached &lt;code&gt;10.100.100.21&lt;/code&gt;
through OPNsense, but Traefik&amp;rsquo;s reply followed the internet bridge&amp;rsquo;s default
route. The request entered through the macvlan and the response tried to leave
through a different gateway.&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;
&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-text&#34; data-lang=&#34;text&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;off-subnet request: client -&amp;gt; OPNsense -&amp;gt; Traefik macvlan
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;wrong reply:        Traefik -&amp;gt; Podman Internet bridge -&amp;gt; nowhere useful
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Testing only from VLAN 100 made the setup look correct. I had to test from
another VLAN and inspect the route inside Traefik&amp;rsquo;s network namespace.&lt;/p&gt;
&lt;p&gt;Inside Traefik&amp;rsquo;s network namespace, &lt;code&gt;ip route get &amp;lt;client-address&amp;gt;&lt;/code&gt; exposed the
selected gateway. I installed an explicit lower-metric default route through the
VLAN 100 gateway:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;
&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-sh&#34; data-lang=&#34;sh&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;ip route replace default via 10.100.100.1 metric &lt;span class=&#34;m&#34;&gt;50&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The internet-bridge route remained at metric 100. This was applied by the NixOS
unit after Podman created the namespace, so every recreation restored the same
decision.&lt;/p&gt;
&lt;figure class=&#34;article-diagram&#34;&gt;
    &lt;pre class=&#34;mermaid&#34;&gt;
flowchart TB
	C[Client on another VLAN] --&gt;|request| FW[OPNsense]
	FW --&gt;|10.100.100.21| T[Traefik]
	T -. wrong default before fix .-&gt; I[Internet bridge gateway]
	I -. dropped reply .-&gt; C
	T --&gt;|metric 50 after fix| FW
	FW --&gt;|symmetric reply| C
&lt;/pre&gt;
    &lt;figcaption&gt;Equal default routes made off-subnet replies nondeterministic; the lower-metric VLAN route restored symmetry.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;After the route fix, off-subnet curls returned HTTP 200 and &lt;code&gt;ip route get&lt;/code&gt;
showed the macvlan gateway. Then a later container recreation broke ingress once
more.&lt;/p&gt;
&lt;h2 id=&#34;the-macvlan-mac-changed-after-recreation&#34;&gt;The macvlan MAC changed after recreation
&lt;/h2&gt;&lt;p&gt;The macvlan attachment had a static IP but no static MAC. Podman generated a new
MAC when Traefik was recreated. OPNsense and the upstream switching path still
associated &lt;code&gt;10.100.100.21&lt;/code&gt; with the previous MAC.&lt;/p&gt;
&lt;p&gt;The container health and route table still looked correct because the stale
entry was on OPNsense and the switching path, not inside the container.&lt;/p&gt;
&lt;p&gt;I pinned the attachment identity in the Podman network specification:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;
&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-nix&#34; data-lang=&#34;nix&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;s2&#34;&gt;&amp;#34;podman_network_vlan_100:ip=10.100.100.21,mac=02:42:0a:64:64:15&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The locally administered &lt;code&gt;02:42&lt;/code&gt; prefix avoids claiming a vendor identity. The
remaining octets encode the private address, which makes the value easy to audit.
More importantly, recreation no longer changes the layer-2 identity.&lt;/p&gt;
&lt;h2 id=&#34;checks-after-the-migration&#34;&gt;Checks after the migration
&lt;/h2&gt;&lt;p&gt;A reverse proxy health check proves only that Traefik can answer inside its own
namespace. My final test matrix covered each boundary explicitly:&lt;/p&gt;
&lt;table&gt;
  &lt;thead&gt;
      &lt;tr&gt;
          &lt;th&gt;Test&lt;/th&gt;
          &lt;th&gt;What it proves&lt;/th&gt;
      &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
      &lt;tr&gt;
          &lt;td&gt;Curl backend from NixOS host&lt;/td&gt;
          &lt;td&gt;Backend process is listening&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;Curl backend from Traefik container&lt;/td&gt;
          &lt;td&gt;Host firewall, bind address, and app allow list work&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;Request ingress from VLAN 100&lt;/td&gt;
          &lt;td&gt;Macvlan listener and on-link return path work&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;Request ingress from a different VLAN&lt;/td&gt;
          &lt;td&gt;Routed return path uses the correct gateway&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;Inspect &lt;code&gt;ip route get&lt;/code&gt; in the namespace&lt;/td&gt;
          &lt;td&gt;Route choice is deterministic&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;Recreate Traefik and repeat all tests&lt;/td&gt;
          &lt;td&gt;MAC and post-start route configuration persist&lt;/td&gt;
      &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;I also checked the gateway neighbor entry after recreation rather than waiting
for a browser symptom.&lt;/p&gt;
&lt;h2 id=&#34;key-points&#34;&gt;Key points
&lt;/h2&gt;&lt;p&gt;Moving Traefik into a container changed more than the process manager. The native
service could reach host loopback and used the host routing table. The container
could use only its attached interfaces and had multiple default routes.&lt;/p&gt;
&lt;p&gt;For a similar migration, check all backend bind addresses, test from inside the
Traefik container, test ingress from another subnet and recreate the container
once before considering it complete. A static macvlan IP should also have a
stable MAC when upstream devices keep neighbor entries for it.&lt;/p&gt;
</description>
        </item>
        
    </channel>
</rss>
