[feature] Listener match-by-interface (e.g. tailscale0), not just match_local_ip #26

Open
opened 2026-08-11 13:51:11 +00:00 by james.oates · 0 comments
Owner

Request (from the homelab setup agent)

Let a listener match on its interface (e.g. tailscale0) instead of a hardcoded IP (100.89.101.76), so a tailnet re-IP can't silently break the tailscale/admin listener.

⚠️ Likely resolved without code — verify first

The stated problem (a re-IP breaking the listener) is caused by pinning a /32. Every Tailscale address comes from the fixed CGNAT range, so matching the range instead fixes it today with zero code:

[[listeners]]
name = "tailscale"
parent = "https"
match_local_ip = "100.64.0.0/10"   # + fd7a:115c:a1e0::/48 if using Tailscale v6

Why it's safe / equivalent to "match tailscale0": resolve_listener matches the connection's OS-reported local_addr() — the host IP the client connected to, which must be an address this host actually holds. This host holds exactly one address in 100.64.0.0/10 (its own Tailscale IP, which lives only on tailscale0), so "local addr ∈ 100.64.0.0/10" is precisely "arrived on the Tailscale interface." A re-IP swaps one 100.x for another — still in range. No new spoofing surface (still OS-truth). match_local_ip already accepts any CIDR (not just /32).

This ticket stays open until the CGNAT-range config is verified in the homelab. If it suffices, close as won't-fix (superseded by config). Build the feature below only if the general interface-matching capability is still wanted.

Shared assumption (both approaches)

Depends on the edge-router process seeing the real Tailscale address in local_addr() — i.e. host networking or Tailscale on the host. Under bridged Docker, DNAT rewrites the destination to the container IP and neither approach works (and tailscale0 wouldn't exist inside the container). Same caveat already documented for match_local_ip.

If we build it anyway: match_interface

Legitimately useful beyond Tailscale — a generic eth1/VLAN trust zone with no well-known CIDR, an interface carrying multiple/both-family addresses, or config that states intent literally.

This is security-sensitive (listeners gate trust zones, incl. the admin dashboard) and needs a design pass before code, not straight-to-implementation. Sketch of the cost:

  • New config field match_interface: Option<String> on [[listeners]], mutually exclusive with match_local_ip (validate).
  • New dep (if-addrs or similar) to enumerate interface → {addresses}.
  • Background refresh task: listeners are startup-only, but interface IPs change at runtime, so resolve interface → {IPs} periodically into a shared map (Arc<RwLock<…>>) that resolve_listener consults. This threads a new dependency into the accept path and adds a new moving part (cf. the config watcher).
  • Fail-closed during the refresh window: a re-IP briefly drops connections until the next poll, then self-heals — still strictly better than today's permanent break.
  • Cross-platform interface naming (tailscale0 on Linux, utunN on macOS); interface absent at startup (Tailscale not up yet) → empty set, matches nothing until it appears (fail-closed).
  • Tests: interface→IP resolution, resolve_listener honoring the live map, fail-closed on unknown/stale, mutual-exclusivity validation.

Priority

Low — blocked on verifying the CGNAT-range config solves the homelab's actual problem. Only escalate to a design + build if the general capability is wanted.

## Request (from the homelab setup agent) Let a listener match on its **interface** (e.g. `tailscale0`) instead of a hardcoded IP (`100.89.101.76`), so a tailnet re-IP can't silently break the tailscale/admin listener. ## ⚠️ Likely resolved without code — verify first The stated problem (a re-IP breaking the listener) is caused by pinning a **`/32`**. Every Tailscale address comes from the fixed CGNAT range, so matching the range instead fixes it today with **zero code**: ```toml [[listeners]] name = "tailscale" parent = "https" match_local_ip = "100.64.0.0/10" # + fd7a:115c:a1e0::/48 if using Tailscale v6 ``` **Why it's safe / equivalent to "match tailscale0":** `resolve_listener` matches the connection's OS-reported `local_addr()` — the host IP the client connected *to*, which must be an address this host actually holds. This host holds exactly one address in `100.64.0.0/10` (its own Tailscale IP, which lives only on `tailscale0`), so "local addr ∈ 100.64.0.0/10" is precisely "arrived on the Tailscale interface." A re-IP swaps one `100.x` for another — still in range. No new spoofing surface (still OS-truth). `match_local_ip` already accepts any CIDR (not just `/32`). **This ticket stays open until the CGNAT-range config is verified in the homelab.** If it suffices, close as won't-fix (superseded by config). Build the feature below only if the general interface-matching capability is still wanted. ## Shared assumption (both approaches) Depends on the edge-router process seeing the real Tailscale address in `local_addr()` — i.e. **host networking or Tailscale on the host**. Under bridged Docker, DNAT rewrites the destination to the container IP and neither approach works (and `tailscale0` wouldn't exist inside the container). Same caveat already documented for `match_local_ip`. ## If we build it anyway: `match_interface` Legitimately useful beyond Tailscale — a generic `eth1`/VLAN trust zone with no well-known CIDR, an interface carrying multiple/both-family addresses, or config that states intent literally. **This is security-sensitive** (listeners gate trust zones, incl. the admin dashboard) and **needs a design pass before code**, not straight-to-implementation. Sketch of the cost: - New config field `match_interface: Option<String>` on `[[listeners]]`, mutually exclusive with `match_local_ip` (validate). - New dep (`if-addrs` or similar) to enumerate interface → {addresses}. - **Background refresh task**: listeners are startup-only, but interface IPs change at runtime, so resolve `interface → {IPs}` periodically into a shared map (`Arc<RwLock<…>>`) that `resolve_listener` consults. This threads a new dependency into the accept path and adds a new moving part (cf. the config watcher). - Fail-closed during the refresh window: a re-IP briefly drops connections until the next poll, then self-heals — still strictly better than today's permanent break. - Cross-platform interface naming (`tailscale0` on Linux, `utunN` on macOS); interface absent at startup (Tailscale not up yet) → empty set, matches nothing until it appears (fail-closed). - Tests: interface→IP resolution, resolve_listener honoring the live map, fail-closed on unknown/stale, mutual-exclusivity validation. ## Priority Low — blocked on verifying the CGNAT-range config solves the homelab's actual problem. Only escalate to a design + build if the general capability is wanted.
Sign in to join this conversation.
No labels
No milestone
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
IsoHex/edge-router#26
No description provided.