all: implement RFC8910 captive portal (CP) option
authorPaul Donald <[email protected]>
Sun, 16 Nov 2025 12:47:10 +0000 (13:47 +0100)
committerÁlvaro Fernández Rojas <[email protected]>
Mon, 24 Nov 2025 09:29:49 +0000 (10:29 +0100)
https://www.rfc-editor.org/rfc/rfc8910.html

RFC8908 defines a captive portal client (CPC) that handles the API URI returned.

The captive portal (CP) option presence signals a portal that requires
authentication beyond what odhcp6c currently handles.

It's possible that a user connecting through an openwrt gateway encounters the
portal anyway (though this behaviour depends on the portal), but if this is not
the case, with this addition we can:
 - surface a message in a UI as to presence of the CP
 - signal that users install a CPC (a chicken/egg problem behind a CP)
 - provide the API URI for downstream consumers.

This should ease the use of travel router scenarios.

Downstream consumers of the API URI can find it via the CAPTIVE_PORTAL_URI
environment/ubus property.

The strengths of having this option handled means downstream consumers get one
unified environment variable since ra.c does not yet handle CUSTOM_* the way
DHCPv6 does.

Signed-off-by: Paul Donald <[email protected]>
Link: https://github.com/openwrt/odhcp6c/pull/127
Signed-off-by: Álvaro Fernández Rojas <[email protected]>
README.md
src/dhcpv6.c
src/odhcp6c.c
src/odhcp6c.h
src/ra.c
src/ra.h
src/script.c
src/ubus.c

index 5397cea5b4e344f777d339d290f65c824ffd1f87..121dffd560b330f6fc2195660331d7f0e7c491f5 100644 (file)
--- a/README.md
+++ b/README.md
@@ -89,6 +89,7 @@ The script is called with the following parameters: `<interface>` `<state>`
 | `RA_RETRANSMIT`                                      | ND Retransmit time                                                                                            |
 | `AFTR`                                                       | The DS-Lite AFTR domain name                                                                          |
 | `MAPE` / `MAPT` / `LW4O6`            | Softwire rules for MAPE, MAPT and LW4O6                                                       |
+| `CAPTIVE_PORTAL_URI`                                 | RFC8910 captive portal API URI received from upstream                         |
 | `PASSTHRU`                                           | The content of the last packet relayed                                                        |
 
 
index 4077e0fee4aeff1deb3baeddfe677e8912ab0c9a..71b163cc3109ffd7b29c401fd21dd1b66b42350d 100644 (file)
@@ -617,6 +617,8 @@ int init_dhcpv6(const char *ifname)
                        htons(DHCPV6_OPT_SNTP_SERVERS),
                        htons(DHCPV6_OPT_NTP_SERVER),
                        htons(DHCPV6_OPT_PD_EXCLUDE),
+                       /* RFC8910: Clients that support this option SHOULD include it */
+                       htons(DHCPV6_OPT_CAPTIVE_PORTAL),
                };
                odhcp6c_add_state(STATE_ORO, oro, sizeof(oro));
        }
@@ -1370,6 +1372,7 @@ static int dhcpv6_handle_reply(enum dhcpv6_msg orig, _o_unused const int rc,
                odhcp6c_clear_state(STATE_S46_MAPT);
                odhcp6c_clear_state(STATE_S46_MAPE);
                odhcp6c_clear_state(STATE_S46_LW);
+               odhcp6c_clear_state(STATE_CAPT_PORT);
                odhcp6c_clear_state(STATE_PASSTHRU);
                odhcp6c_clear_state(STATE_CUSTOM_OPTS);
 
@@ -1530,6 +1533,28 @@ static int dhcpv6_handle_reply(enum dhcpv6_msg orig, _o_unused const int rc,
                                odhcp6c_add_state(STATE_S46_LW, odata, olen);
                                break;
 
+                       case DHCPV6_OPT_CAPTIVE_PORTAL: /* RFC8910 §2.2 */
+                               size_t ref_len = sizeof(URN_IETF_CAPT_PORT_UNRESTR) - 1;
+                               /* RFC8910 §2:
+                                * Networks with no captive portals may explicitly indicate this
+                                * condition by using this option with the IANA-assigned URI for
+                                * this purpose. Clients observing the URI value ... may forego
+                                * time-consuming forms of captive portal detection. */
+                               if (memcmp(odata, URN_IETF_CAPT_PORT_UNRESTR, ref_len)) {
+                                       /* RFC8910 §2.2:
+                                        * Note that the URI parameter is not null terminated.
+                                        * Allocate new buffer including room for '\0' */
+                                       size_t uri_len = olen + 1;
+                                       uint8_t *copy = malloc(uri_len);
+                                       if (!copy)
+                                               continue;
+                                       memcpy(copy, odata, olen);
+                                       copy[uri_len] = '\0';
+                                       odhcp6c_add_state(STATE_CAPT_PORT, odata, olen);
+                                       free(copy);
+                               }
+                               break;
+
                        default:
                                odhcp6c_add_state(STATE_CUSTOM_OPTS, &odata[-DHCPV6_OPT_HDR_SIZE], olen + DHCPV6_OPT_HDR_SIZE);
                                break;
index a8e826a9c06b62ae36fa7a7f44ca63ccc70d2d69..cda9ef4e2fc5d51674fe6385bbfcad554bf14598 100644 (file)
@@ -510,6 +510,7 @@ int main(_o_unused int argc, char* const argv[])
                        odhcp6c_clear_state(STATE_NTP_FQDN);
                        odhcp6c_clear_state(STATE_SIP_IP);
                        odhcp6c_clear_state(STATE_SIP_FQDN);
+                       odhcp6c_clear_state(STATE_CAPT_PORT);
                        bound = false;
 
                        size_t oro_len = 0;
index b0094fe942d210c6edec68d9f1ad2639b3883df0..0bd686cf4da986d636e703eb0f15ddf77f819905 100644 (file)
 
 #define RA_MIN_ADV_INTERVAL 3   /* RFC 4861 paragraph 6.2.1 */
 
+/* RFC8910 §2 */
+static const uint8_t URN_IETF_CAPT_PORT_UNRESTR[] = "urn:ietf:params:capport:unrestricted";
+#define CAPT_PORT_URI_STR "CAPTIVE_PORTAL_URI"
+
 enum dhcvp6_opt {
        /* RFC8415(bis) */
        DHCPV6_OPT_CLIENTID = 1,
@@ -155,6 +159,8 @@ enum dhcvp6_opt {
        DHCPV6_OPT_LQ_BASE_TIME = 100,
        DHCPV6_OPT_LQ_START_TIME = 101,
        DHCPV6_OPT_LQ_END_TIME = 102,
+       /* RFC8910 */
+       DHCPV6_OPT_CAPTIVE_PORTAL = 103,
        /* RFC7839 */
        DHCPV6_OPT_ANI_ATT = 105,
        DHCPV6_OPT_ANI_NETWORK_NAME = 106,
@@ -429,6 +435,7 @@ enum odhcp6c_state {
        STATE_S46_MAPT,
        STATE_S46_MAPE,
        STATE_S46_LW,
+       STATE_CAPT_PORT,
        STATE_PASSTHRU,
        _STATE_MAX
 };
index b1011be1a171ef689905d6efacf341c807d264cf..d44b9f0066e5e102aec12c67e94c92ffe3bd05bd 100644 (file)
--- a/src/ra.c
+++ b/src/ra.c
@@ -26,6 +26,7 @@
 #include <stdbool.h>
 #include <stddef.h>
 #include <stdio.h>
+#include <stdlib.h>
 #include <string.h>
 #include <syslog.h>
 #include <sys/ioctl.h>
@@ -557,6 +558,34 @@ bool ra_process(void)
                                                                        ra_holdoff_interval);
                                        entry->auxlen = 0;
                                }
+                       } else if (opt->type == ND_OPT_CAPTIVE_PORTAL) {
+                               /* RFC8910 Captive-Portal §2.3 */
+                               if (opt->len <= 1)
+                                       continue;
+
+                               struct icmpv6_opt_captive_portal *capt_port = (struct icmpv6_opt_captive_portal*)opt;
+                               uint8_t *buf = &capt_port->data[0];
+                               size_t ref_len = sizeof(URN_IETF_CAPT_PORT_UNRESTR) - 1;
+
+                               /* RFC8910 §2:
+                                * Networks with no captive portals may explicitly indicate this
+                                * condition by using this option with the IANA-assigned URI for
+                                * this purpose. Clients observing the URI value ... may forego
+                                * time-consuming forms of captive portal detection. */
+                               if (memcmp(buf, URN_IETF_CAPT_PORT_UNRESTR, ref_len)) {
+                                       /* URI are not guaranteed to be \0 terminated if data is unpadded */
+                                       size_t uri_len = (capt_port->len * 8) - 2;
+                                       /* Allocate new buffer including room for '\0' */
+                                       uint8_t *copy = malloc(uri_len + 1);
+                                       if (!copy)
+                                               continue;
+
+                                       memcpy(copy, buf, uri_len);
+                                       copy[uri_len] = '\0';
+                                       odhcp6c_clear_state(STATE_CAPT_PORT);
+                                       odhcp6c_add_state(STATE_CAPT_PORT, copy, uri_len);
+                                       free(copy);
+                               }
                        }
                }
 
index a869b5e276bc8e359fc9ea2c29d1f9d2ba2c7b26..6f6f59cd3f0789e8a17076eb6187f5c338961637 100644 (file)
--- a/src/ra.h
+++ b/src/ra.h
@@ -32,6 +32,13 @@ struct icmpv6_opt {
        uint8_t data[6];
 };
 
+/* RFC8910 Captive-Portal §2.3 */
+struct icmpv6_opt_captive_portal {
+       uint8_t type; /* 37 */
+       uint8_t len; /* includes the Type and Length fields, in units of 8 bytes */
+       uint8_t data[]; /* padded with NUL (0x00) to make length multiple of 8 */
+};
+
 struct icmpv6_opt_route_info {
        uint8_t type;
        uint8_t len;
@@ -42,6 +49,7 @@ struct icmpv6_opt_route_info {
 };
 
 #define ND_OPT_ROUTE_INFORMATION 24
+#define ND_OPT_CAPTIVE_PORTAL 37
 
 
 #define icmpv6_for_each_option(opt, start, end)\
index 10c348221e7cd38f2851daf8aa73a0e6b306021b..5a6168da2a848863b2fb60eb4cffe7aa5dd72bbb 100644 (file)
@@ -142,6 +142,31 @@ static void fqdn_to_env(const char *name, const uint8_t *fqdn, size_t len)
        putenv(buf);
 }
 
+static void string_to_env(const char *name, const uint8_t *string, size_t len)
+{
+       size_t buf_len = strlen(name);
+       const uint8_t *string_end = string + len;
+       char *buf = realloc(NULL, len + buf_len + 2);
+
+       memcpy(buf, name, buf_len);
+       buf[buf_len++] = '=';
+
+       while (string < string_end) {
+               int l = strlen((const char *)string);
+               if (l <= 0)
+                       break;
+               string += l;
+               buf_len += strlen(&buf[buf_len]);
+               buf[buf_len++] = ' ';
+       }
+
+       if (buf[buf_len - 1] == ' ')
+               buf_len--;
+
+       buf[buf_len] = '\0';
+       putenv(buf);
+}
+
 static void bin_to_env(uint8_t *opts, size_t len)
 {
        uint8_t *oend = opts + len, *odata;
@@ -436,7 +461,7 @@ void script_call(const char *status, int delay, bool resume)
        } else if (pid == 0) {
                size_t dns_len, search_len, custom_len, sntp_ip_len, ntp_ip_len, ntp_dns_len;
                size_t sip_ip_len, sip_fqdn_len, aftr_name_len, addr_len;
-               size_t s46_mapt_len, s46_mape_len, s46_lw_len, passthru_len;
+               size_t s46_mapt_len, s46_mape_len, s46_lw_len, capt_port_len, passthru_len;
 
                signal(SIGTERM, SIG_DFL);
                if (delay > 0) {
@@ -457,6 +482,7 @@ void script_call(const char *status, int delay, bool resume)
                uint8_t *s46_mapt = odhcp6c_get_state(STATE_S46_MAPT, &s46_mapt_len);
                uint8_t *s46_mape = odhcp6c_get_state(STATE_S46_MAPE, &s46_mape_len);
                uint8_t *s46_lw = odhcp6c_get_state(STATE_S46_LW, &s46_lw_len);
+               uint8_t *capt_port = odhcp6c_get_state(STATE_CAPT_PORT, &capt_port_len);
                uint8_t *passthru = odhcp6c_get_state(STATE_PASSTHRU, &passthru_len);
 
                size_t prefix_len, address_len, ra_pref_len,
@@ -480,6 +506,7 @@ void script_call(const char *status, int delay, bool resume)
                s46_to_env(STATE_S46_MAPE, s46_mape, s46_mape_len);
                s46_to_env(STATE_S46_MAPT, s46_mapt, s46_mapt_len);
                s46_to_env(STATE_S46_LW, s46_lw, s46_lw_len);
+               string_to_env(CAPT_PORT_URI_STR, capt_port, capt_port_len);
                bin_to_env(custom, custom_len);
 
                if (odhcp6c_is_bound()) {
index 664feffbda1f7555d28725857cefc958e8b0d072..ab2e362642ff42be3a6004264f7907a77c1f41d6 100644 (file)
@@ -531,7 +531,7 @@ static int states_to_blob(void)
        char *buf = NULL;
        size_t dns_len, search_len, custom_len, sntp_ip_len, ntp_ip_len, ntp_dns_len;
        size_t sip_ip_len, sip_fqdn_len, aftr_name_len, addr_len;
-       size_t s46_mapt_len, s46_mape_len, s46_lw_len, passthru_len;
+       size_t s46_mapt_len, s46_mape_len, s46_lw_len, capt_port_len, passthru_len;
        struct in6_addr *addr = odhcp6c_get_state(STATE_SERVER_ADDR, &addr_len);
        struct in6_addr *dns = odhcp6c_get_state(STATE_DNS, &dns_len);
        uint8_t *search = odhcp6c_get_state(STATE_SEARCH, &search_len);
@@ -545,6 +545,7 @@ static int states_to_blob(void)
        uint8_t *s46_mapt = odhcp6c_get_state(STATE_S46_MAPT, &s46_mapt_len);
        uint8_t *s46_mape = odhcp6c_get_state(STATE_S46_MAPE, &s46_mape_len);
        uint8_t *s46_lw = odhcp6c_get_state(STATE_S46_LW, &s46_lw_len);
+       uint8_t *capt_port = odhcp6c_get_state(STATE_CAPT_PORT, &capt_port_len);
        uint8_t *passthru = odhcp6c_get_state(STATE_PASSTHRU, &passthru_len);
 
        size_t prefix_len, address_len, ra_pref_len,
@@ -572,6 +573,7 @@ static int states_to_blob(void)
        CHECK(s46_to_blob(STATE_S46_MAPE, s46_mape, s46_mape_len));
        CHECK(s46_to_blob(STATE_S46_MAPT, s46_mapt, s46_mapt_len));
        CHECK(s46_to_blob(STATE_S46_LW, s46_lw, s46_lw_len));
+       blobmsg_add_string(&b, CAPT_PORT_URI_STR, (char *)capt_port);
        CHECK(bin_to_blob(custom, custom_len));
 
        if (odhcp6c_is_bound()) {