Skip to content

Captive portal: fix track6 IPv6 redirect host and bracketed redirurl#9973

Closed
agoodkind wants to merge 8 commits intoopnsense:captive-portal-ipv6from
agoodkind:agoodkind/captive-portal-track6-redirect-fixes
Closed

Captive portal: fix track6 IPv6 redirect host and bracketed redirurl#9973
agoodkind wants to merge 8 commits intoopnsense:captive-portal-ipv6from
agoodkind:agoodkind/captive-portal-track6-redirect-fixes

Conversation

@agoodkind
Copy link
Copy Markdown
Contributor

@agoodkind agoodkind commented Mar 15, 2026

Upstream PR: #9745

Summary

This follow-up to the comment: #9745 (comment) and fixes two IPv6 redirect bugs that are still present on top of

  • When the portal interface uses track6, the template currently reads the literal
    config value track6 and falls back to the IPv4 portal host. An IPv6-only client
    then receives a redirect to an unreachable IPv4 address.
  • When a client reaches the portal using a bracketed IPv6 literal, the current
    host-match regex truncates the value at the first :, so redirurl becomes
    incomplete.

Why

track6 is a normal OPNsense IPv6 deployment mode, not a corner case. DHCPv6-PD is required for CE routers by RFC 7084 (WPD-1), and RIPE-690 documents it as the standard operator practice for prefix assignment to end-users. OPNsense documents track6 as the normal way to place a delegated /64 on a LAN.

Captive portal users are often deploying on ISP-managed or upstream-managed connections where the firewall receives a delegated prefix rather than a static IPv6 assignment. The portal needs the live routed IPv6 address, not the static config token.

RIPE's deployment survey reports that 37.5% of ISPs still rotate prefixes, so the routed LAN IPv6 can legitimately change underneath the portal. OPNsense already uses runtime address handling in other places for track6, notably the web GUI binding work in #5966. The captive portal faces the same problem and should use the same pattern.

The underlying issue is that the configd template system was designed around config.xml, which holds static values. With IPv6 and track6, interface addresses are runtime-derived: the delegated prefix comes from DHCPv6-PD and can change, so the LAN address isn't known until the interface is configured. This isn't unique to the captive portal. The web GUI (https://github.com/opnsense/core/blob/master/src/etc/inc/plugins.inc.d/webgui.inc#L36), Unbound (https://github.com/opnsense/core/blob/master/src/etc/inc/plugins.inc.d/unbound.inc#L45), and OpenSSH (https://github.com/opnsense/core/blob/master/src/etc/inc/plugins.inc.d/openssh.inc#L44) all solved the same problem via newwanip hooks and PHP-side config regeneration (originally driven by #5966).

More broadly, this is probably going to keep coming up as IPv6 support continues to mature, since IPv6's end-to-end model means runtime-derived addresses appear in places that IPv4 never required. It might be worth considering a general-purpose mechanism for passing runtime interface data into templates, rather than solving it service-by-service.

The regex fix is independent and corrects IPv6 literal handling directly.

Lab overview

Reproduces two captive portal IPv6 bugs on OPNsense. kea delegates a /60 via DHCPv6-PD, opnsense tracks it on LAN with track6, IPv6-only client hits captive portal.

Hosts

Host Interfaces Role
kea-pd-test WAN: 3d06:bad:b01:200::1/64 DHCPv6-PD server. Delegates /60s (::210::/60::2f0::/60).
opnsense-dev WAN: 3d06:bad:b01:200::11/128, LAN: 10.240.240.1/24 + 3d06:bad:b01:210:be24:11ff:fef5:c938/64 (track6) Firewall under test. Captive portal enabled.
ubuntu-test-client LAN: 3d06:bad:b01:210::902/64 IPv6-only client behind opnsense-dev.

Addressing

Segment Range
WAN 3d06:bad:b01:200::/64
Delegated PDs 3d06:bad:b01:210::/60::2f0::/60
LAN 3d06:bad:b01:210::/64

Test plan

  • Configure a captive portal zone on a track6 LAN with no hostname override
  • From an IPv6-only client, request http://[lan-ipv6]:8000/
  • Confirm the redirect host uses the live bracketed IPv6 portal address
  • Confirm redirurl preserves the full bracketed IPv6 literal

Testing matrix for the two fixes

Case Upstream result This PR
Dynamic IPv6 portal host on track6 LAN Fails. Redirect is http://10.240.240.1:8000/... Redirect to http://[3d06:bad:b01:210:be24:11ff:fef5:c938]:8000/...
Bracketed IPv6 literal preserved in redirurl Fails. redirurl=[2601/ redirurl=[2606:4700:4700::1111]/

Resolve the live routed interface address for captive portal redirects on track6 interfaces and preserve full bracketed IPv6 literals in redirurl.
…e help text

Move the runtime interface address resolution from an inline PHP string
in template_helpers.py to a standalone script at
scripts/interfaces/get_interface_address.php, following the existing
codebase pattern for PHP scripts called from the Python backend.

Update the captive portal zone hostname help text to note IPv6-only
client compatibility considerations when no hostname is configured.
This file should not have been modified in our branch; restore the
original help text from the upstream base.
@@ -0,0 +1,51 @@
#!/usr/local/bin/php
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There’s pluginctl -6 interface for that kind of operation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pluginctl -6 calls interfaces_primary_address6(), which returns the link-local address (fe80::...) for track6 interfaces. For captive portal redirects we need the routed GUA, which is what interfaces_routed_address6() returns.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which is the reason I think this is over-engineered and premature to address now without getting the IPv6 support out first on dependable scope.

def interface_routed_address4(self, name: str) -> str:
return self._runtime_interface_address(name, 'inet')

def interface_routed_address6(self, name: str) -> str:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would advise not to start doing this in templates too.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need this here because captive portal's lighttpd config is generated through the Jinja-based configd path, and that path only sees the static config model unless runtime interface state is exposed explicitly.

For track6, the correct routed IPv6 address is not present in that static model. It only exists in live interface state after DHCPv6-PD has delegated a prefix and the interface has been configured. So without a helper at this layer, the template has no way to emit the correct IPv6 redirect target.

This is not an edge case. DHCPv6-PD with track6 is a normal IPv6 deployment model, and OPNsense has already had to solve the same runtime-address problem elsewhere. webgui.inc, openssh.inc, and unbound.inc already handle live interface address changes through PHP-side regeneration and newwanip hooks. The difference here is only that captive portal renders its final config through Jinja, so the same class of runtime information has to be exposed differently.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This really needs a different fix for the captive portal template, in practice, when CP is reflecting, it doesn't care which ipv6 address you use as long as its routed and passes the firewall. A manual input might be an option, best practice is and remains adding domain names. Let's focus on the important parts first to avoid getting lost in piles of code which is likely not needed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair. Thanks for reviewing!

Replace the standalone get_interface_address.php script with calls to
pluginctl -4/-6 (for primary addresses) and pluginctl -D (for routed
GUA on track6 interfaces where -6 returns a link-local).

This addresses review feedback to use existing tools rather than adding
new scripts or extending template_helpers with cross-language subprocess
calls.
This reverts commit dc6e2c5, restoring the standalone PHP script
approach from 05effa8.
Strip IPv4 runtime resolution (unnecessary since config.xml has the
static address) and preserve upstream PR opnsense#9745 template structure.
Only two targeted changes on top of swhite2's branch:

1. Runtime IPv6 resolution via get_interface_address.php for track6
   interfaces, falling back to conf_inf.ipaddrv6 for static configs
2. Bracketed IPv6 regex fix in [::] socket blocks so redirurl is not
   truncated at the first colon
lighttpd config does not unescape \\ to \ in double-quoted strings the
way PCRE expects. Double backslashes caused the bracket-matching
alternative to never fire, so redirurl was truncated at the first colon.

Use single backslashes which lighttpd passes through to PCRE correctly,
matching the convention used by the rest of this template.
The runtime call to interfaces_routed_address6() handles both track6
and statically configured IPv6 interfaces. The elif fallback to
conf_inf.ipaddrv6 is unreachable in practice: for track6 it contains
the config token, not a valid address, and for static IPv6 the runtime
call already returns the correct GUA.
@swhite2 swhite2 deleted the branch opnsense:captive-portal-ipv6 March 16, 2026 08:46
@swhite2 swhite2 closed this Mar 16, 2026
agoodkind added a commit to agoodkind/opnsense-core that referenced this pull request Mar 17, 2026
The host-match regex ([^:/]+) in the IPv6 lighttpd socket blocks
truncates bracketed IPv6 literals at the first colon, producing a
broken redirurl (e.g. [2601/ instead of [2606:4700:4700::1111]/).

Change the capture group to (\[[^\]]+\]|[^:/]+) so a full bracketed
literal is matched first, falling back to the original pattern for
IPv4 addresses and hostnames.

Missed in: 369630d (Captive portal: IPv6 support, opnsense#9745)
See also: opnsense#9973
swhite2 pushed a commit that referenced this pull request Mar 17, 2026
The host-match regex ([^:/]+) in the IPv6 lighttpd socket blocks
truncates bracketed IPv6 literals at the first colon, producing a
broken redirurl (e.g. [2601/ instead of [2606:4700:4700::1111]/).

Change the capture group to (\[[^\]]+\]|[^:/]+) so a full bracketed
literal is matched first, falling back to the original pattern for
IPv4 addresses and hostnames.

Missed in: 369630d (Captive portal: IPv6 support, #9745)
See also: #9973
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

4 participants