<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>Carp on Dexome</title>
        <link>https://blog.dexome.com/tags/carp/</link>
        <description>Recent content in Carp 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/carp/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>
        
    </channel>
</rss>
