Monitoring Interfaces: alias IP support for duplicate management IPs across WANs#963
Monitoring Interfaces: alias IP support for duplicate management IPs across WANs#963Optic00 wants to merge 5 commits into
Conversation
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.
|
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.
|
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. |
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
AliasIponMonitoringInterface. When set, the LAN polls the alias instead of the real IP, and the gateway translates:0xff000000), disjoint from UniFi's own multi-WAN marks (which live under0x7e0000-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-xmarkonly writes bits covered by the mask, and the kernel's-m markmatch 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.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 oneBEGIN IMMEDIATEtransaction 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-saveline 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:
noprefixroutematters. 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 usesnoprefixroute, and the idempotency check requires the flag specifically so previously-deployed interfaces self-migrate on the next watchdog run.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
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