Skip to content

Monitoring Interfaces: alias IP support for duplicate management IPs across WANs#963

Open
Optic00 wants to merge 5 commits into
Ozark-Connect:mainfrom
Optic00:feature/monitoring-interfaces-duplicate-ip
Open

Monitoring Interfaces: alias IP support for duplicate management IPs across WANs#963
Optic00 wants to merge 5 commits into
Ozark-Connect:mainfrom
Optic00:feature/monitoring-interfaces-duplicate-ip

Conversation

@Optic00

@Optic00 Optic00 commented Jul 5, 2026

Copy link
Copy Markdown

Motivation

TODO.md documents this exact gap under "Monitoring Interfaces: Duplicate Reachable IP (DNAT + SNAT to alternate IP)": two devices sharing the same management IP on different WANs can't both be monitored, and v1 correctly detects the collision and bails. My network is the motivating case verbatim - a Telekom Glasfaser-Modem 2 ONT on WAN1 and a Starlink dish on WAN2, both answering at 192.168.100.1. This PR builds the alternate-IP support the TODO sketched. (Related: #962 adds the ONT provider whose live verification on my network depended on this feature - the two are independent code-wise.)

Design

A nullable AliasIp on MonitoringInterface. When set, the LAN polls the alias instead of the real IP, and the gateway translates:

  • mangle PREROUTING marks packets to the alias. The mark namespace is the top fwmark byte (mask 0xff000000), disjoint from UniFi's own multi-WAN marks (which live under 0x7e0000-style masks, confirmed on live hardware). The interface Id is shifted INTO the masked byte (Id << 24) - it has to be, because iptables' MARK --set-xmark only writes bits covered by the mask, and the kernel's -m mark match compares the packet's masked mark against the raw unmasked value, so id bits outside the mask would make the rule unmatchable. Ids above 254 don't fit the byte and are rejected before deploy; removal and status intentionally keep working for such rows so they can always be cleaned up.
  • A private per-interface routing table (numerically equal to the mark) routes the marked traffic out the interface's macvlan; nat PREROUTING DNATs alias -> real target; the existing output-interface-keyed SNAT covers the return path unchanged.
  • Nothing about the real target IP ever enters the main routing table. That address may legitimately belong to another WAN's device - and, as verified live, UniFi's own native Starlink dashboard polling depends on the main table resolving it toward the Starlink WAN.

Uniqueness across rows can't be plain column indexes (two rows sharing TargetIp, distinguished by alias, is the whole point): the effective polled IP (AliasIp ?? TargetIp), Name, and GatewayLocalIp must each be unique, including cross-field collisions. That's enforced inside one BEGIN IMMEDIATE transaction in the repository. The migration failure modes on dirty existing databases produce a friendly startup message naming the offending column with a runnable diagnostic query, instead of a raw SQLite exception.

Deploys are gated: subnet-overlap preflight for target, gateway-local, AND alias addresses against known UniFi networks; reachability check with a re-apply carve-out keyed on our own artifact signature; and a mark/table ownership probe that classifies the reserved range as free/ours/foreign (exact canonical iptables-save line matching), so a foreign user of the range blocks the deploy instead of getting swept. The boot script is idempotent per artifact, tracks critical-step failures into a nonzero exit (a partial apply can't report success), and removal surfaces cleanup failures - the UI keeps the row on a failed removal and arms an explicit second-click force-remove instead of stranding gateway state with no handle to retry.

Field notes from live verification (real UCG-Fiber, real dual-WAN duplicate IP)

Two findings from testing on real hardware that shaped the implementation:

  • noprefixroute matters. Assigning the macvlan address with a subnet prefix makes the kernel auto-install a connected route for the WHOLE subnet in the main table - which silently hijacked 192.168.100.1 toward the ONT and broke UniFi's native Starlink widget, even in alias mode which never installs an explicit route there. The address assignment now uses noprefixroute, and the idempotency check requires the flag specifically so previously-deployed interfaces self-migrate on the next watchdog run.
  • Which side gets the alias is not symmetric. The device sharing its IP with a WAN that has UniFi-native monitoring (Starlink today) must stay the plain side; the OTHER device gets the alias. Documented in TODO.md, along with a flagged follow-up idea to proactively warn when a natively-monitored WAN is picked as the plain side.

End state on my gateway, all simultaneously working: the ONT polled through its alias (192.168.101.1 -> 192.168.100.1 via eth4 macvlan), a plain Monitoring Interface for the Starlink dish (192.168.100.1 via eth2), and UniFi's own native Starlink widget untouched.

Testing

  • 60+ unit tests: structural assertions on the generated boot script (aliased and plain), mark/table derivation incl. the range guard, validation rules, preflight command construction, repository uniqueness (real SQLite, real transactions - not the EF InMemory provider), and file-backed migration tests through the actual migration pipeline for both the clean path and constructed collisions.
  • Full solution test suite is green.
  • Live-verified on the hardware above: deploy, re-apply (idempotent, no duplicate rules), edit, remove, and the cron watchdog surviving UniFi reprovisioning.

Tooling disclosure

Per CODING_STANDARDS.md: built with heavy AI assistance (Claude Code - Sonnet/Opus/Fable - for implementation, GPT-5.5/Codex as an independent adversarial reviewer; several of the findings above, including the mark-encoding subtlety, came out of those review rounds and were then confirmed on hardware). Design decisions, review rounds, and all live verification were driven and checked by me. Happy to adjust anything that doesn't meet the bar.

🤖 Generated with Claude Code

Optic00 added 4 commits July 5, 2026 22:06
Groundwork for monitoring two devices that share the same management
IP on different WANs: a nullable AliasIp column on MonitoringInterface
plus the uniqueness model the feature needs. TargetIp is deliberately
NOT unique anymore (two rows sharing it, distinguished by alias, is
the whole point) - instead the effective polled IP (AliasIp ?? TargetIp),
Name, and GatewayLocalIp must each be unique, including cross-field
collisions (an alias must not equal another row's target or
gateway-local address and vice versa). Those rules span rows and
fields, so they are enforced inside one BEGIN IMMEDIATE transaction in
the repository rather than as plain column indexes - that also closes
the race where two Blazor circuits could both pass an app-level check
before either saves.

The migration tightens the (WanIfName, Name) index to Name alone and
adds unique indexes on AliasIp and GatewayLocalIp. Because existing
databases can hold rows that violate the new indexes, Migrate() is
wrapped so those specific failures produce a friendly message naming
the offending column with a runnable diagnostic query, instead of a
raw SQLite exception at startup. Migration tests run against real
file-backed SQLite through the actual migration pipeline (not
EnsureCreated) for both the clean path and constructed collisions.
The core mechanism for the duplicate-IP case: when AliasIp is set, the
LAN polls the alias while the gateway marks matching packets in mangle
PREROUTING, routes them through a private per-interface table pinned
to the interface's macvlan, DNATs alias -> real target, and reuses the
existing output-interface-keyed SNAT for the return path. No route for
the real target IP ever enters the main table - that address may
legitimately belong to another WAN's device (and, as verified on real
hardware, UniFi's own Starlink dashboard polling depends on the main
table resolving it toward the Starlink WAN).

Mark/table encoding reserves the top fwmark byte (mask 0xff000000,
disjoint from UniFi's own 0x7e0000-masked multi-WAN marks) and shifts
the interface Id into that byte. The id must live inside the masked
byte: iptables' MARK --set-xmark only writes bits covered by the mask,
and the kernel's -m mark match compares the packet's masked mark
against the raw unmasked value, so any id bits outside the mask would
make the DNAT rule unmatchable. Ids above 254 don't fit and are
rejected before deploy; removal and status intentionally keep working
for such rows so they can always be cleaned up.

Deploys are gated by preflight checks: alias and target subnets vs
known UniFi networks, reachability with a re-apply carve-out keyed on
our own artifact signature, and a mark/table ownership probe that
classifies the reserved range as free/ours/foreign (exact canonical
iptables-save line matching) so a foreign user of the range blocks the
deploy instead of being swept. The boot script is idempotent per
artifact, tracks critical-step failures into a nonzero exit so a
partial apply cannot report success, and removal surfaces cleanup
failures instead of swallowing them.

Includes noprefixroute on the macvlan address assignment: without it,
the kernel auto-installs a connected route for the whole subnet in the
main table just from assigning the address, silently hijacking every
other address in that subnet - found live when it broke UniFi's native
Starlink widget. The idempotency check requires the flag specifically,
so interfaces deployed before this change migrate themselves on the
next watchdog run.
Adds the alias field to the Advanced section with collision-aware
suggestions (third-octet+1 with RFC 2544 fallback, skipping other
rows' addresses). When a deploy is blocked because the target is
already reachable - the duplicate-IP signature - the form re-enters
edit mode with a suggested alias and the Advanced section force-opened,
so the user lands directly on the fix instead of a dead-end error.
Aliased rows show the polled address in the table, and the deploy
success message points monitoring at the alias instead of the target.

A failed removal now keeps the row and arms an explicit second-click
force-remove (any other interaction disarms it), so a temporarily
unreachable gateway can't strand live routing/NAT state with no UI
handle left to retry the cleanup.
Marks the "Duplicate Reachable IP" item as built and live-verified,
documents the UniFi-native Starlink monitoring conflict discovered
during verification (the device sharing its IP with a natively
monitored WAN must be the plain side; the other device gets the
alias), and flags two follow-up ideas: proactively warning when a
natively monitored WAN is picked as the plain side, and an opt-in
LAN-client passthrough to the real IP for browsing an aliased device's
web UI past its own redirect-to-real-IP firmware behavior.
@tvancott42

Copy link
Copy Markdown
Collaborator

Amazing. I am very time-consumed on multi-site work right now, but I will try to fold this and your other PR into the next v2.0.0 beta release, definitely by the time I do the official v2 launch. Thanks!

…de deploys

A row deployed plain and later switched to alias via "Save without deploying"
+ the row-level Deploy button (which, unlike Save & Deploy, never tears down
the old config) kept its old main-table host route. In alias mode that route
still captures the shared TARGET_IP in the main table ahead of the other
WAN's device - the exact hijack alias mode exists to prevent.

The boot script's alias branch now deletes this interface's main-table host
route (dev-scoped, so another row legitimately routing the same TARGET_IP
via its own interface is untouched), and the idempotency guard requires its
absence - so the cron watchdog also self-heals state left behind by earlier
builds. Verified end-to-end in a NET_ADMIN container: plain deploy, alias
deploy over it, idempotent re-run.
@Optic00

Optic00 commented Jul 6, 2026

Copy link
Copy Markdown
Author

Pushed a follow-up fix (da6122e) for an edge case in the alias deploy path.

Bug: A row deployed without an alias and later switched to alias mode via "Save without deploying" + the row-level Deploy button kept its old main-table host route. Save & Deploy tears the old config down first, but the row-level Deploy goes straight to the deploy path, and the boot script's alias branch never removed main-table routes. The stale route keeps capturing the shared target IP in the main table ahead of the other WAN's device - exactly the hijack alias mode is supposed to prevent.

Fix: The boot script's alias branch now deletes this interface's main-table host route (dev-scoped, so another row legitimately routing the same target IP via its own interface is untouched), and the idempotency guard requires its absence. Since the cron watchdog re-runs the script, it also self-heals stale state left behind by earlier deployments.

Verified with a new unit test plus an end-to-end run in a NET_ADMIN container: plain deploy, alias deploy over it, main-table route gone, private-table route in place, re-run idempotent. Full suite green.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants