Skip to content

Captive portal: IPv6 support#9745

Merged
swhite2 merged 32 commits intomasterfrom
captive-portal-ipv6
Mar 16, 2026
Merged

Captive portal: IPv6 support#9745
swhite2 merged 32 commits intomasterfrom
captive-portal-ipv6

Conversation

@swhite2
Copy link
Copy Markdown
Member

@swhite2 swhite2 commented Feb 6, 2026

Closes #8761

agoodkind and others added 6 commits January 2, 2026 18:16
- Show IPv4 and IPv6 addresses in IP Address column
- Aggregate traffic statistics across all IPs for a session
- Add tooltip to display full IP addresses when truncated
- Update accounting to include traffic from all associated IPs
…tack support

- Resolved conflicts in captiveportal.inc: Use upstream's getValues() method while keeping IPv6 rules
- Resolved conflicts in AccessController.php: Merged upstream's hostwatch dump with our IPv6 NDP fallback
- Resolved conflicts in clients.volt: Use upstream's zone selection placement while keeping tooltip initialization
- Resolved conflicts in pf.py: Preserved IPv6 protocol handling (0x86dd) and accounting methods
- Resolved conflicts in db.py: Merged our aggregation logic with upstream's prev_* fields for counter reset detection
- Resolved conflicts in cp-background-process.py: Adapted dual-stack MAC handling to use upstream's helper methods

All IPv6 dual-stack functionality is preserved while incorporating upstream improvements.
@swhite2 swhite2 self-assigned this Feb 6, 2026
@swhite2 swhite2 marked this pull request as draft February 6, 2026 08:56
Comment on lines +55 to +57
$backend = new Backend();
$backend->configdRun('template reload OPNsense/IPFW');
$backend->configdRun("ipfw reload");
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.

is this side effect a good idea? feels a bit too clever :)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I expected comments here ;)

To be honest this was the quickest win here, the IPFW reload is a requirement though, and missing since the re-introduction of it and I didn't spot any other obvious hooks in the right place.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

for context: the spot where this code resided previously was cleaned up in d8519a0

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.

Looks duplicate now, as in the following line both dnctl and ipfw are triggered when no argument is specified:

mwexecf('/usr/local/opnsense/scripts/shaper/start.sh');

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@AdSchellevis Coming back to this as it's related to #9902.. the start.sh script does not trigger the template generation for https://github.com/opnsense/core/blob/master/src/opnsense/service/templates/OPNsense/IPFW/rc.conf.d, which means that configuring a zone in a clean install will likely not kickstart ipfw until a reboot.

In this case I'd opt for

$backend->configdRun('template reload OPNsense/IPFW');

To remain in here.

Comment thread src/opnsense/mvc/app/models/OPNsense/CaptivePortal/CaptivePortal.xml Outdated
<label>Allow multiple client IPs</label>
<help>Allow a connecting client to use multiple IPs (bound to the same MAC). For IPv4, these can be virtual IPs on the client. For IPv6, this option is needed for maximum compatibility because a client may actively use multiple IPv6 addresses.</help>
<label>Client roaming</label>
<help>Allow a connecting client to use multiple IPs (bound to the same MAC) over the course of its session. For IPv4, these can be virtual IPs on the client. For IPv6, this option is needed for maximum compatibility because a client may actively use multiple IPv6 addresses.</help>
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.

strictly speaking the more common term is "alias" for an extra address (as per ifconfig). Both could still be confusing. Perhaps we can remove the IPv4 explanation or change it to a minor note "This also affects IPv4."

Wouldn't that also allow login from multiple IPs? Didn't we have a setting for that? Concurrent something?

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.

(if it's strictly tied to the MAC/DUID that's something else and please ignore me)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

"roaming" keys clients (with IP aliases) to a MAC address, if they connect from a different device the "concurrent user logins" setting still applies, different MAC.

Comment thread src/etc/inc/plugins.inc.d/captiveportal.inc
@swhite2 swhite2 marked this pull request as ready for review February 11, 2026 13:50
Comment thread src/opnsense/service/templates/OPNsense/Captiveportal/lighttpd-zone.conf Outdated
Comment thread src/etc/inc/plugins.inc.d/captiveportal.inc Outdated
Comment thread src/etc/inc/plugins.inc.d/captiveportal.inc Outdated
Comment thread src/etc/inc/plugins.inc.d/captiveportal.inc Outdated
Comment thread src/opnsense/mvc/app/controllers/OPNsense/CaptivePortal/Api/AccessController.php Outdated
Comment thread src/opnsense/scripts/captiveportal/lib/db.py
Comment thread src/opnsense/scripts/captiveportal/lib/db.py Outdated
Comment thread src/opnsense/scripts/captiveportal/lib/db.py Outdated
Comment thread src/opnsense/scripts/captiveportal/sql/init.sql
Comment thread src/opnsense/scripts/captiveportal/listClients.py
@agoodkind
Copy link
Copy Markdown
Contributor

Thanks for taking this over!

@swhite2 swhite2 requested a review from AdSchellevis March 4, 2026 09:06
Copy link
Copy Markdown
Member

@AdSchellevis AdSchellevis left a comment

Choose a reason for hiding this comment

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

@swhite2 I think we're almost there, missed a couple of spots we discussed last time and added one question about ipv4 on dual stack nets.

Comment thread src/opnsense/scripts/captiveportal/sql/init.sql

session_ips = {args.ip_address}
if args.roaming:
session_ips = db.update_roaming_ips(args.zoneid, response.sessionId, arp.get_all_addresses_by_mac(arp_entry['mac']))
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.

as discussed we likely better only add the connected address and let the background process take care of the rest.

[allow]
command:/usr/local/opnsense/scripts/captiveportal/allow.py
parameters:--zoneid=%s --username=%s --ip_address=%s --authenticated_via=%s
parameters:--zoneid=%s --username=%s --ip_address=%s --authenticated_via=%s --roaming=%s
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.

when roaming isn't relevant, we can ditch it from here as well.

{% if conf_key == intf_tag and conf_inf.ipaddr and conf_inf.ipaddr != 'dhcp' %}
{% do item.update({'interface_hostaddr':conf_inf.ipaddr}) %}
{% if conf_key == intf_tag %}
{# prefer IPv6 if available, fallback to IPv4 #}
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.

what happens with dual stack and an ipv4 only client here?

This makes IPv4 and IPv6 portal entry points behave consistently, fixes proxied client IP detection, and lets roaming sessions discover IPv6 addresses quickly enough to authorize privacy and secondary addresses on the same client.
@agoodkind
Copy link
Copy Markdown
Contributor

@agoodkind If you have the setup... care to give this a spin?

I had some free time and did a matrix of testing and made a PR that tries to address all of @AdSchellevis comments along with bugs I discovered: #9927

agoodkind and others added 7 commits March 11, 2026 21:57
This restores best-effort sibling address authorization at login for already-known addresses on the same MAC, while keeping the background reconciliation path as the source of truth for later convergence and cleanup.
…-dual-stack-support

Follow up for dual-stack captive portal authorization in `CaptivePortal`
@agoodkind
Copy link
Copy Markdown
Contributor

agoodkind commented Mar 13, 2026

Tested with my setup against 9df582a

Setup

opnsense-dev

  • Captive Portal test zone on lan
  • Portal hosts:
    • IPv4: 10.240.240.1:8000
    • IPv6: 3d06:bad:b01:240::1:8000
  • Standard outbound NAT66 for 3d06:bad:b01:240::/64
  • DNS64 enabled in Unbound
  • dns64prefix: 3d06:bad:b01:2464::/96
  • radvd advertises nat64prefix 3d06:bad:b01:2464::/96
  • DHCPv4 option 108 present on lan

ubuntu-vm-test

  • Single NIC behind opnsense-dev
  • systemd-networkd
  • Address types exercised:
    • IPv4 DHCP
    • DHCPv6 IA_NA /128
    • SLAAC / privacy /64
    • extra temporary /64
  • Two guest modes:
    • dual-stack
    • IPv6-only preferred (IPv6OnlyMode=yes)

Current behavior

The login address works immediately.

Sibling addresses do not always work immediately on current upstream. Addresses that are already visible to the firewall may still need the background reconciliation pass before they are consistently usable.

Matrix

Case Result Notes
IPv4-only pre-auth Pass Redirects to IPv4 portal host
IPv4-only auth via IPv4 Pass IPv4 egress works immediately
Dual-stack pre-auth via IPv4 Pass Redirects to IPv4 portal host
Dual-stack pre-auth via IPv6 Pass Redirects to IPv6 portal host
Dual-stack auth via IPv4, same IPv4 address Pass Login address works immediately
Dual-stack auth via IPv4, DHCPv6 /128 sibling, cold cache Delayed Not immediately*
Dual-stack auth via IPv4, already-known IPv6 siblings, warm cache Mixed Better once visible, not consistently immediate
Dual-stack auth via IPv6, same DHCPv6 /128 address Partial First request can still see one 302, retry works
Dual-stack auth via IPv6, IPv4 sibling, cold cache Delayed Not immediately*
Dual-stack auth via IPv6, already-known IPv4 and IPv6 siblings, warm cache Mixed Better once visible, not consistently immediate
IPv6-only preferred pre-auth Pass Redirects to IPv6 portal host
IPv6-only preferred auth via DHCPv6 /128 Partial Same narrow first-request race as above
IPv6-only preferred auth via privacy / SLAAC /64 Pass Immediate post-auth IPv6 egress worked
NAT64 / DNS64 / PREF64 after auth Pass Synthesized 3d06:bad:b01:2464::101:101 returned a real HTTP response
Multi-address IPv6 convergence Pass Newly observed sibling addresses join later through background reconciliation

Main takeaway

Current upstream is usable for the core portal flow, but it still has a convergence model rather than an immediate warm-start model.

The address used for login works immediately. Other addresses on the same client join later as they become visible to the firewall and the background reconciliation loop processes them.

The narrowest remaining rough edge is the DHCPv6 /128 source case, where the first immediate post-login request can still receive one redirect before the next request succeeds.

*Sibling addresses usually became usable within about 5 to 12 seconds, which lined up with the 5 second background reconciliation loop plus the time needed for the address to become visible in neighbor discovery.

<id>zone.servername</id>
<label>Hostname</label>
<type>text</type>
<help><![CDATA[Hostname (of this machine) to redirect login page to, leave blank to use this interface IP address, otherwise make sure the client can access DNS to resolve this location. When using a SSL certificate, make sure both this name and the cert name are equal.]]></help>
Copy link
Copy Markdown
Contributor

@agoodkind agoodkind Mar 13, 2026

Choose a reason for hiding this comment

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

Maybe could change to:

Hostname to redirect the login page to. Leave blank to use the interface IP address. When using an SSL certificate, the hostname and certificate name must match. Note: For IPv6-only client compatibility, either set a hostname with both A and AAAA records, or ensure the selected interface has a static IPv6 address configured. If only a dynamic DHCPv6 address is present and no hostname is set, IPv6-only clients may be redirected to an IPv4 address they cannot reach.

Suggested change
<help><![CDATA[Hostname to redirect the login page to. Leave blank to use the interface IP address. When using an SSL certificate, the hostname and certificate name must match. Note: For IPv6-only client compatibility, either set a hostname with both A and AAAA records, or ensure the selected interface has a static IPv6 address configured. If only a dynamic DHCPv6 address is present and no hostname is set, IPv6-only clients may be redirected to an IPv4 address they cannot reach.]]></help>

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 advice to avoid HTML inside language strings as this will break during translation.

Copy link
Copy Markdown
Contributor

@agoodkind agoodkind Mar 14, 2026

Choose a reason for hiding this comment

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

Fair. I took out the HTML formatting in the string suggestion

@swhite2
Copy link
Copy Markdown
Member Author

swhite2 commented Mar 13, 2026

@agoodkind Thanks for your further testing. Your results seem to align with what is to be expected given the constraints. After some further discussion internally, the best strategy to address the rather complicated lighty template seems to be to stop guessing for IPv6 addresses (which are going to be static less often in comparison to IPv4 anyway) and require that a user sets a proper hostname + associated AAAA records (and possibly DNS64 support in Unbound) to make IPv6 work.

@agoodkind
Copy link
Copy Markdown
Contributor

agoodkind commented Mar 15, 2026

@agoodkind Thanks for your further testing. Your results seem to align with what is to be expected given the constraints. After some further discussion internally, the best strategy to address the rather complicated lighty template seems to be to stop guessing for IPv6 addresses (which are going to be static less often in comparison to IPv4 anyway) and require that a user sets a proper hostname + associated AAAA records (and possibly DNS64 support in Unbound) to make IPv6 work.

Thanks for the follow-up. I understand the appeal of simplifying the template, but requiring a hostname + AAAA for IPv6 captive portal to function at all would be quite a gap IMO. Although it is true that most network admins should set up some type of hostname to handle this, I still feel it is a gap.

track6 is the documented way to deploy DHCPv6-PD on LAN, DHCPv6-PD is required by RFC 7084 for CE routers, and RIPE-690 documents it as the standard operator practice for prefix assignment to end-users.

For example, I've encountered two different ISPs where you are required to use DHCP-PD otherwise the traffic won't route (AT&T Fiber Business, and Webpass Business). A captive portal that silently falls back to an unreachable IPv4 address in that configuration is a real bug, not a corner case.

The fix itself is fairly contained: runtime address resolution for track6 interfaces The configd template system was designed around config.xml, which holds static values. With track6, the LAN address is runtime-derived from DHCPv6-PD and isn't in config.xml at all. 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) solved this same problem via newwanip hooks and PHP-side config regeneration (#5966). The captive portal lighttpd config uses the Jinja2 template system instead, so the address has to enter the template somehow. I opened a PR with both: #9973

{
$clientAddress = $this->request->getClientAddress();
$forwardedFor = $this->request->getHeader('X-Forwarded-For');
$realIp = $this->request->getHeader('X-Real-Ip');
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.

Can we please just go back to the original situation, I have a very strong feeling we're trying to fix an edge case while introducing new security concerns in the process. It's debatable if by default any of these headers should be processed, but since that's already the case (introduced a very long time ago a7033f2), let's try not to increase the impact so we can discuss improvements on this topic in a separate ticket.

$userName,
$clientIp,
$authServerName
$authServerName,
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.

Suggested change
$authServerName,
$authServerName

# Clear pre-auth states so newly authorized sibling addresses can use the updated table match immediately.
subprocess.run(['/sbin/pfctl', '-k', f'{address}'], capture_output=True)
wildcard = '::/0' if PF._is_ipv6(address) else '0.0.0.0/0'
subprocess.run(['/sbin/pfctl', '-k', wildcard, '-k', f'{address}'], capture_output=True)
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 looks rather suspicious and may result in unexpected locked sessions to already accepted destinations.

agoodkind added a commit to agoodkind/opnsense-core that referenced this pull request Mar 15, 2026
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
…e pf state kill, backend listening op IPv4 only so adjust AccessController logic
@swhite2 swhite2 merged commit 369630d into master Mar 16, 2026
@swhite2 swhite2 deleted the captive-portal-ipv6 branch March 16, 2026 08:46
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
fichtner pushed a commit that referenced this pull request Mar 24, 2026
Co-authored-by: Alex Goodkind <alex@goodkind.io>

(cherry picked from commit 369630d)
(cherry picked from commit 5b07e09)
(cherry picked from commit 2ac18ce)
(cherry picked from commit cff0e8d)
(cherry picked from commit 6f00e1e)
fichtner added a commit that referenced this pull request Mar 24, 2026
This reverts commit 497ed54.

Revert for the time being since 26.1.5 doesn't force a reboot.
fichtner pushed a commit that referenced this pull request Apr 7, 2026
Co-authored-by: Alex Goodkind <alex@goodkind.io>

(cherry picked from commit 369630d)
(cherry picked from commit 5b07e09)
(cherry picked from commit 2ac18ce)
(cherry picked from commit cff0e8d)
(cherry picked from commit 6f00e1e)
(cherry picked from commit da2c0bd)
(cherry picked from commit e5effd4)
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.

Captive Portal: IPv6 support

5 participants