- PHP 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
All checks were successful
CI / PHP lint (push) Successful in 2m12s
rc.bootup brings WAN up before the first CARP event, and with every CARP link down OPNsense's boot doesn't finish, so neither an event nor the reconcile runs and the node keeps its WAN up until a link returns. Refs #21 Co-Authored-By: Claude Opus 5.5 <[email protected]> |
||
| .forgejo/workflows | ||
| tests | ||
| .gitignore | ||
| 10-wancarp | ||
| actions_wancarp.conf | ||
| CHANGELOG.md | ||
| LICENSE | ||
| README.md | ||
| SECURITY.md | ||
10-wancarp — OPNsense CARP Helper
When a CARP event occurs on an OPNsense firewall in an HA (High Availability) pair, this script runs and enables or disables interfaces based on the node's overall CARP role (worked out from all of its VHIDs, not just the one that fired the event):
- MASTER firewall → enables the configured interface(s).
- BACKUP firewall → disables the configured interface(s).
This keeps the uplink UP on exactly one node at a time. Typical use is a WAN link that must not be active on both firewalls simultaneously (e.g. a single ISP handoff, a modem in bridge mode, or any upstream that gets confused by two devices claiming the same MAC/IP).
You can manage one or many interfaces. The common case is WAN only.
On its own, this script only runs on transitions. After you first set up HA, all interfaces are UP. To force a quick event for testing, go to Interfaces → Virtual IPs → Status → Enter Persistent CARP Maintenance Mode on the MASTER (then leave maintenance mode to flip back). The reconcile cron job is part of Installation for this reason: without it, OPNsense can bring the BACKUP's WAN back up between transitions.
AI Disclosure
- I used Claude Code (Opus 4.8) to write this script in its entirety including the README.
- I have reviewed the README for accuracy based on my experience using the script.
- I have fully tested the script in my Homelab to verify it works as intended.
- I have only read the script header and not performed a full source review
- I had Claude Code use Context7 while developing this script so the latest relevant documentation would be used
How it works
OPNsense executes every file in /usr/local/etc/rc.syshook.d/carp/ on a CARP
transition and passes two arguments:
| Arg | Meaning | Example |
|---|---|---|
$argv[1] |
subsystem | 1@vtnet1 (<vhid>@<interface>) |
$argv[2] |
type | MASTER, BACKUP, or INIT |
So the system effectively runs:
/usr/local/etc/rc.syshook.d/carp/10-wancarp 1@vtnet1 MASTER
The event only wakes the script up. Each VHID fires its own event, so with
several VIPs the "last event wins" approach would leave WAN wherever the last
one put it, even if the VHIDs disagree. Instead, the script reads every VHID's
live state from ifconfig and acts on the node's role:
| Counted VHIDs | Role | Action |
|---|---|---|
any one is BACKUP |
BACKUP | take interfaces down |
all are MASTER |
MASTER | bring interfaces up |
none is MASTER, at least one INIT (e.g. all INIT) |
BACKUP | take interfaces down |
any, with CARP disabled on the node (net.inet.carp.allow=0) |
BACKUP | take interfaces down |
anything else (MASTER+INIT mix, none) |
undecided | no change |
BACKUP wins a disagreement on purpose: the peer is then MASTER for something, and two live WANs is exactly what this script exists to prevent.
INIT means CARP isn't running on that VIP, typically because its link is
down. The peer then promotes itself and brings its WAN up, so a node that is
MASTER for nothing takes its WAN down.
Interfaces → Virtual IPs → Status → Temporarily Disable CARP doesn't leave
the VHIDs in INIT: it sets net.inet.carp.allow=0 and removes them, so
ifconfig shows no carp: lines at all. The script reads that sysctl and
treats a node with CARP disabled the same way, as BACKUP. Two consequences:
- If CARP is
INITon both nodes at once, both WANs go down. That fails safe, and with the LAN dead the WAN is no use anyway. - A
MASTER+INITmix stays undecided: the node is still MASTER for something.
While the node is booting, every VHID is briefly INIT, so the INIT and
CARP-disabled rules are off then (see Boot behaviour below).
"Counted" means every VHID on the node, or only those listed in $carp_vhids
(see Configuration). An interface this script toggles must
not carry a CARP VIP at all (see below); as a safeguard, any VHID found on one
is never counted, since taking that interface down on the BACKUP would push it
to INIT.
On MASTER: brings the real device link up and reconfigures it. On BACKUP: resets each interface, forces the link down, and (optionally) re-points the default route.
Runs are serialized with a root-only (0600) lock (/var/run/wancarp.lock), so concurrent per-VHID
events can't interleave: the last run to finish always acted on the latest
state. An event run waits up to 120 seconds for the lock (enough for a MASTER run
that brings up two PPP WANs); if it's still held, the
run logs timed out waiting for lock and ends aborted=lock, and the next
reconcile re-applies the role.
Runtime-only — does not write config
The script makes interface changes at runtime only; it does not call
write_config() and never modifies config.xml. This is deliberate:
- Persisting the per-node
enableflag during a CARP event raced OPNsense's own config writes and could clobber the persistent CARP maintenance-mode flag, stranding a node demoted with no GUI button to recover. - The
enableflag is a static prerequisite (enabled on both nodes, set manually on each —interfacesis not an XMLRPC-syncable section), not a per-event value. Persistingenable=0on a demoted node would fight that prerequisite and leave it booting with the gateway subsystem skipping the interface.
Because nothing is persisted, the idempotency guard checks live state, not
the config enable flag (which by prerequisite reads 1 on both nodes
regardless of who is MASTER). BACKUP skips an interface whose UP flag is
already clear. MASTER skips one only if it is UP and configured, meaning
it has an IPv4 or non-link-local IPv6 address or a running dhclient; an
interface that is UP with neither is reconfigured. Note status: is not
used — on virtio (vtnet) NICs it stays active even when the interface is
down; only the UP flag is honest.
A PPP-type interface (PPPoE, PPTP, L2TP; a <ppps> entry for its device) is
judged by its mpd5 daemon (/var/run/<type>_<alias>.pid), since mpd5
creates the device (e.g. pppoe0), raises it only once the session is up, and
destroys it on exit. MASTER skips the interface while mpd5 runs, connected or
still dialing, so duplicate events and reconciles don't restart it
mid-negotiation. BACKUP takes it down while either the device is UP or mpd5
runs. After interface_reset() has stopped mpd5 the device is gone, so the
final ifconfig down is skipped. The parent NIC is left up: it may carry other
VLANs, and nothing dials on it without mpd5. PPPoE has only been tested
off-box (see Tested on).
Boot behaviour: since state isn't persisted, a rebooting node reaches the correct interface state from the CARP
MASTER/BACKUPevent fired as it rejoins. While OPNsense is still booting, an all-INITrole is left undecided so the script doesn't fightrc.bootup's own interface setup; if the node is stillINITonce boot finishes (say its LAN link is down), the next reconcile takes WAN down. Confirmed by hard-rebooting both the MASTER and the BACKUP: each hands off and reclaims cleanly, WAN ending up live on exactly one node.Two gaps the script can't close, since it only runs on a CARP event or from cron:
rc.bootupbrings WAN up (it's enabled in config) before CARP fires its first event, so a rebooting node has WAN up for a few seconds alongside the peer's until that event takes it down.- If every CARP interface has no link at boot, OPNsense's boot doesn't finish until the links return: on the test pair it stalled past
unbound_configure_do()for over 11 minutes, and completed a few minutes after reconnecting. The VHIDs sit inINITfrom the start, so no CARP event fires, and cron (and with it the reconcile) doesn't run until boot ends. The node keepsrc.bootup's WAN up the whole time, alongside the peer's. When a link returns, the first CARP event takes it down. If only some CARP links are down, boot finishes normally and a VHID that reachesBACKUPtakes WAN down at once.
Prerequisite — interfaces must be enabled in config on BOTH firewalls. Because the script is runtime-only and never persists the
enableflag, every alias in$iface_aliasesmust be enabled in config on both nodes (Interfaces → [WAN] → Enable interface → Save/Apply). Do this separately on each node —interfacesis not an XMLRPC-syncable section, so HA config sync does not carry this flag to the peer. If an interface is disabled in config, OPNsense's gateway/routing subsystem skips it and won't install its default route even when the script brings the link up — the promoted MASTER then gets a DHCP IP but no default route and no Internet (symptom: WAN can ping its own gateway, butnetstat -rnshows nodefaultand external pings say "No route to host").
Keeping state from drifting (reconcile)
Runtime-only has a cost. The BACKUP's WAN is down in memory only, while
config.xml says it's enabled (the prerequisite above requires that). OPNsense
has its own path that re-reads config and brings interfaces up:
devdturns an ethernetLINK_UPintoconfigctl interface linkup start <dev>.rc.linkupchecks only the on-diskenableflag, never CARP, and callsinterface_configure(). That brings WAN up, starts DHCP and restores the default route.
So on the BACKUP, any of these bring WAN back up with no CARP event to undo it, and both nodes now have the WAN:
- the modem or ISP equipment reboots, or a cable is re-seated,
- on virtio, the hypervisor toggles the link (vtnet still reports carrier
while administratively down, so it sends
LINK_UP), - someone clicks Save/Apply on the WAN interface page,
configctl interface reconfigure wanis run.
(rc.linkup skips all of this during boot, which is why the reboot tests pass.)
The fix is to re-apply the role on a schedule. 10-wancarp --reconcile
works out the role exactly like an event would and corrects any interface
that's in the wrong state. It is quiet when there's nothing to fix: its
lines only reach the system log if it changed something or hit an error. A
correction is logged at error level as reconcile: interface/route state had drifted from CARP role ... so you can see that drift happened. It does nothing
during boot, and backs off if a CARP event run currently holds the lock.
Cron runs it through a configd action,
actions_wancarp.conf, once a minute, so a drifted WAN
is taken back down within about a minute. Steps 3, 6 and 7 of
Installation set this up.
Configuration
Edit the USER CONFIGURATION block near the top of 10-wancarp:
$iface_aliases = ['wan']; // OPNsense interface ALIASES
$carp_vhids = []; // VHIDs that decide the role ([] = all)
$lan_vip = '192.168.0.1'; // default route target on BACKUP (optional)
$remove_backup_route = false; // set true to repoint default route on BACKUP
-
$iface_aliases— use OPNsense aliases (the internal names likewan,lan,opt1,opt2— the keys under<interfaces>inconfig.xml). Do not use device names (igc0,em0) or descriptions; the script resolves the real device automatically viaget_real_interface().- Single uplink:
['wan'] - Multiple:
['wan', 'opt2']
No interface in
$iface_aliasesmay carry a CARP VIP. List only uplinks with no VHID on them (typically a WAN with a DHCP/PPPoE address). Taking such an interface down on the BACKUP puts its VHID intoINIT, so the node can never become MASTER through it, and raises the node's CARP demotion counter (net.inet.carp.ifdown_demotion_factor), which skews the election on every other VHID too. That breaks failover, including maintenance-mode failover. The dry-run preflight reports any VHID it finds on a listed interface. - Single uplink:
-
$carp_vhids: which CARP VHIDs decide the node's role (see How it works). Leave it empty to count every VHID, which is right when all your VIPs fail over together (preempt on, the OPNsense default). List specific VHIDs, e.g.[1], to tie the WAN to just those VIPs. A VHID on an interface in$iface_aliases(a misconfiguration, see above) is never counted either way. -
$remove_backup_route/$lan_vip— leavefalsefor almost everyone. See "Giving the BACKUP node Internet access" below for what this is for and why a failover gateway is the better answer.
Giving the BACKUP node Internet access
When a firewall becomes BACKUP, this script disables its WAN — so that box now has no path to the Internet of its own. That's usually a problem: a firewall still wants outbound access while it's the backup (package updates, DNS, NTP, gateway monitoring, etc.). It needs an alternate default route — typically out through the other firewall (the current MASTER) via the LAN.
There are two ways to provide that, and they solve the same problem:
-
A failover gateway (recommended). Configure an OPNsense gateway group with WAN as Tier 1 and a LAN-side gateway (pointing at the peer/MASTER) as Tier 2, monitored by dpinger. When WAN goes down, OPNsense automatically and dynamically shifts the default route to the LAN gateway, and shifts it back when WAN returns. This is native, monitored, and self-healing. If you have this, leave
$remove_backup_route = falseand ignore$lan_vip— the script does not need to touch routing at all. -
$remove_backup_route(the blunt fallback). If you have no failover gateway, set$remove_backup_route = trueand$lan_vipto a LAN-side gateway (often a shared LAN CARP VIP). Whenever the node is BACKUP and its default route doesn't point at$lan_vip, the script deletes it and adds one that does. Whenever the node is MASTER and its default route still points at$lan_vip, the script deletes it and has OPNsense reinstall its own (system_routing_configure()). If WAN is still waiting on DHCP at that point, OPNsense briefly uses whatever other gateway it has (or none) until the lease arrives and it switches the default back to WAN. Both checks run on every CARP event and every--reconcile, so drift is corrected within a minute — but there's no gateway monitoring, which is why a failover gateway is preferred.
Do not use both. If you configure a failover gateway and set
$remove_backup_route = true, the two will fight over the default route on the BACKUP node. Pick one — the gateway group if you can.
Installation
Do steps 1–7 on each firewall (step 7 can come from HA sync instead, see there), then step 8 once. Run the commands as root in a shell (SSH, or console menu option 8).
-
Enable the interface in config. For every alias in
$iface_aliases(justwanby default): Interfaces → [WAN], tick Enable interface, Save, Apply. HA config sync does not carry this flag, so set it on each node. See the prerequisite for why. -
Download the release and verify it. Each release on the releases page publishes the script, its configd action file and a
10-wancarp.sha256checksum covering both. Replace1.1.1with the release you want:cd /tmp curl -fsSLO https://git.pickysysadmin.ca/eric/10-wancarp/releases/download/1.1.1/10-wancarp curl -fsSLO https://git.pickysysadmin.ca/eric/10-wancarp/releases/download/1.1.1/actions_wancarp.conf curl -fsSLO https://git.pickysysadmin.ca/eric/10-wancarp/releases/download/1.1.1/10-wancarp.sha256 sha256sum -c 10-wancarp.sha256You want
OKon both lines. If anything printsFAILED, stop and do not install. (Releases before 1.1.0 have noactions_wancarp.confand no reconcile; don't use them.) -
Install both files and reload configd:
mv 10-wancarp /usr/local/etc/rc.syshook.d/carp/10-wancarp chmod 0700 /usr/local/etc/rc.syshook.d/carp/10-wancarp mv actions_wancarp.conf /usr/local/opnsense/service/conf/actions.d/actions_wancarp.conf service configd restart -
Configure (only if the defaults don't fit). The defaults toggle
wan, count every VHID and leave routing alone, which suits most setups. To change that, edit the USER CONFIGURATION block in/usr/local/etc/rc.syshook.d/carp/10-wancarp(see Configuration). Use the same settings on both nodes. Upgrading overwrites the file, so re-apply your edits after each upgrade. -
Dry-run it. This changes nothing:
/usr/local/etc/rc.syshook.d/carp/10-wancarp --dryrun --reconcileCheck that no line says
MISSING,NOT presentorresolves to NO real device, that each alias showsconfig=enabled, and that therole=line matches this node (MASTER or BACKUP). See Dry-run. -
Check the configd action:
configctl wancarp reconcileIt should print
OK. On the BACKUP this also takes WAN down if it is currently up, which is the intended state. -
Schedule the reconcile. System → Settings → Cron → +: Minutes
*, Hours*, all other fields*, Command Reconcile WAN with CARP role (10-wancarp), Save, Apply. If your HA sync includes Cron, add it on the primary and sync; otherwise add it on each node. The command only appears in the list on a node where step 3 has been done. -
Test a failover. On the MASTER, Interfaces → Virtual IPs → Status → Enter Persistent CARP Maintenance Mode. WAN should go down there and come up on the other node; leave maintenance mode to flip back. Follow it with
opnsense-log -f | grep wancarp(see Verifying).
Installing from main instead (no checksum)
main moves with every commit, so there is no published hash for it. If you
want the newest changes and accept that, replace steps 2 and 3 with:
curl -fsSL https://git.pickysysadmin.ca/eric/10-wancarp/raw/branch/main/10-wancarp -o /usr/local/etc/rc.syshook.d/carp/10-wancarp
chmod 0700 /usr/local/etc/rc.syshook.d/carp/10-wancarp
curl -fsSL https://git.pickysysadmin.ca/eric/10-wancarp/raw/branch/main/actions_wancarp.conf -o /usr/local/opnsense/service/conf/actions.d/actions_wancarp.conf
service configd restart
Dry-run (test before going live)
Pass --dryrun (or -n) to run the script from the CLI without changing
anything — no interface_configure/interface_reset, no ifconfig/route.
Output is echoed to the terminal as well as the system log.
./10-wancarp --dryrun 1@vtnet1 MASTER
./10-wancarp --dryrun 1@vtnet1 BACKUP
(The flag may go in any position; the <vhid>@<interface> source and the role
are still required. A malformed source string only warns in dry-run instead
of aborting.)
With a role argument, dry-run is a what-if: it acts on the role you pass,
so you can exercise both branches on either node (INIT acts as BACKUP, as
on a node that is MASTER for nothing). It also prints the live role a real
event would act on right now:
wancarp: role=BACKUP counted=[1@vtnet0=BACKUP, 2@vtnet2=BACKUP]
wancarp: DRYRUN: acting on the role you passed ('MASTER') as a what-if; a real event now would act as BACKUP.
To see what a reconcile would do right now (live role, no what-if), use:
./10-wancarp --dryrun --reconcile
Before printing what each role would do, dry-run runs a preflight that verifies the things most likely to make a live failover silently fail:
- the legacy global
$configis actually populated (if not, a live run resolves nothing and no-ops), - every OPNsense PHP API the script calls exists on this build — including
interface_reset(), which has varied between releases, and, with--reconcile, theexit_on_bootup()boot guard (a missing one doesn't fatal; a live reconcile falls back toproduct::booting()), - the
product::booting()boot check theINITrule relies on, and whether the node is booting right now, - each configured alias resolves to a real device, with its current enable flag
and live link state (
flags/status), - the current default route (when
$remove_backup_routeis set), - every CARP VHID on the node, which ones were counted or skipped (and why), and the resulting role,
- that no CARP VHID sits on an interface in
$iface_aliases(reported as an error naming the VHID and alias).
It is safe to run on a live firewall; it makes no changes.
Verifying / troubleshooting
Every log line is prefixed with wancarp, so one grep pulls the whole story of
any event:
opnsense-log -f | grep wancarp
opnsense-log replaced clog in 21.x; -f follows, and the log type
defaults to system. The logs are plain text now, so this works too:
grep wancarp /var/log/system/*.log
(GUI: System → Log Files → General, filter on wancarp.)
For each event you get:
- a START banner with the script version and the raw arguments (proves the hook fired, which version is installed, and with what),
- a
role=line listing each VHID's state, which were counted or skipped, and the role the script acted on, - one line per interface action — or a skip line with the reason,
- explicit errors (return code + captured output) for any failed shell command,
- explicit errors for an unknown/typo'd interface alias,
- any PHP exception: one thrown while handling an interface is logged as
'<alias>' (<device>) failed: ...and the run carries on with the next alias; anything else is caught by the top-level handler, - an END banner: a normal run reports
role=MASTER|BACKUP|noneandresult=ok|partial|exceptionwithchanged=yes|no(dry-run useswould-change=yes|no).partialmeans at least one action failed (a shell command such asifconfig downorroute add, or an exception on one interface, logged as an error above it), so the node may be only partly switched; a rejected event reportsaborted=invalid-type|bad-source|lock changed=no.
A cron reconcile logs nothing when state is already correct. If it does log, it changed something or hit an error, and you get the full START…END story for that run.
If you see a START banner but no END banner, the script died mid-run on an uncaught fatal — check
opnsense-log php(/var/lib/php/tmp/PHP_errors.log), if PHP error logging is on.
Manually simulate an event (testing only — prefer --dryrun for a no-op run):
/usr/local/etc/rc.syshook.d/carp/10-wancarp 1@vtnet1 BACKUP
Off-box tests
tests/ runs the real script on any machine with PHP 8 (no OPNsense needed),
against stub OPNsense functions and fake ifconfig/route/pgrep that share
one simulated firewall state. CI runs it on every push.
php tests/run.php # every case
php tests/run.php backup # only cases whose name contains "backup"
php tests/run.php -v # show calls and log for anything not passing
Each case in tests/run.php sets a starting state (interface UP/addresses,
each VHID's CARP state, default route, running dhclient), optional
USER CONFIGURATION values and the script arguments, then checks the final
state, the OPNsense calls made, the log lines and the exit code. Results:
PASS/FAIL: as you'd expect; anyFAILfails the run.XFAIL: a case marked'xfail' => '#N'for a known open bug, still broken.XPASS: anxfailcase now passes. The run fails so you remember to delete its'xfail'line.
To reproduce a bug, add a case that describes the correct behaviour and mark it
xfail with the issue number; the fix then removes the marker. The stubs model
current opnsense/core closely enough for the script's logic (for example,
interface_configure() returns at once when enable is unset, and
interface_reset() doesn't down the link), but they are not OPNsense: a change
to how the script talks to OPNsense still needs a --dryrun and a failover on
a real pair.
Tested on
OPNsense 26.1.x (PHP 8, FreeBSD 14.x), validated on a live two-node HA pair
(virtio NICs, DHCP WAN, config sync enabled): maintenance-mode and
hard-reboot failover in both directions, WAN moving with its IP + default route,
idempotent across the duplicate per-VIP CARP events. Uses only stable OPNsense
PHP APIs (get_real_interface, legacy_interface_flags, interface_configure,
interface_reset, log_msg / log_error). --reconcile also calls
exit_on_bootup() (falling back to product::booting() if it's missing), the
role rule asks product::getInstance()->booting() (treated as not booting if
the class is missing), and $remove_backup_route calls
system_routing_configure().
OPNsense 26.7.x (PHP 8.5, FreeBSD 15.1) — reviewed against the 26.1 -> 26.7
core diff and confirmed with --dryrun on an upgraded node. Nothing the script
depends on changed:
- The invocation contract is byte-identical:
devd/carp.confstill matches<vhid>@<device>+MASTER|BACKUP|INIT,rc.syshookis unchanged, andrc.syshook.d/carp/still exists. An in-place upgrade leaves the script installed and executable. - All five PHP APIs above are unchanged, except that
interface_configure()gained an optional 5th parameter ($batch = false). It defaults to the 26.1 behaviour, so the four-argument calls here are unaffected. - "Interface assignments migrated to MVC/API" is not a storage change. The
new
NetworkInterfacemodel is<mount>:memory:</mount>and reads/writes straight through to legacyconfig.xml<interfaces>, so$configaliases (wan,opt1, ...) and theenableflag are untouched. The prerequisite above still applies exactly as written.
One behavioural note, benign here: 26.7 marks VLAN devices 'reload' => false,
so a VLAN parent is no longer re-created by the $reload = true path in
interface_configure(). The VLAN already exists at runtime, and this does not
apply at all to a plain physical/virtio WAN.
Not yet validated on a live pair: the node-role logic (
$carp_vhids, role from all VHIDs) and--reconcile/ the configd action. They have been tested only off-box, in the off-box tests. Before relying on them, run--dryrun --reconcileon each node and check therole=line. Then, on the BACKUP, runconfigctl interface reconfigure wanto force the drift and confirm the next cron run takes WAN back down.
PPPoE (and other
mpd5WANs) is untested on a live pair. The live validation above used a DHCP WAN. PPP handling follows currentinterfaces.incand is covered off-box only. If you run it on PPPoE, check that the--dryrunalias line showsppp daemon=runningon the MASTER, that a failover ends withresult=okon both nodes, and that a reconcile while the MASTER is redialing logs nothing. Please report how it goes.
ifconfig LOWER_UP (FreeBSD 15.1)
On 26.7 a downed WAN reports:
flags=[BROADCAST,SIMPLEX,MULTICAST,LOWER_UP] status=active
LOWER_UP is a synthetic token (bit 24, IFF_NETLINK_1) emitted by ifconfig's
netlink output path — it reports carrier state, not administrative state,
and is absent from sys/net/if.h. So on a BACKUP node three separate signals
(LOWER_UP, status: active, and virtio's always-live link) all claim the
interface is up while it is administratively DOWN.
iface_is_up() therefore matches the exact token UP with a strict
in_array(..., true) against the comma-split flag list. Do not loosen this to
strpos() / str_contains() — a substring test matches LOWER_UP, the
MASTER branch concludes the link is already up and skips it, and the promoted
node comes up with no WAN. Both dry-run directions were verified on 26.7:
MASTER reports WOULD enable, BACKUP reports already down, skipping.
HA note: CARP-aware services
This script handles the WAN, but it's worth auditing anything else that runs
on both nodes. A multicast reflector running on both firewalls at once is a
classic trap — e.g. the mDNS Repeater (Avahi) with two nodes reflecting
between the same VLANs forms a reflection loop (224.0.0.251 flood) that looks
like a CARP/L2 storm but isn't. Enable its "Enable CARP Failover" option (or
otherwise make it run on the MASTER only). Rule of thumb: any service that
should be active on just one node must be CARP-aware or single-node.