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

RFC8910 defines a captive portal API URI for a CP client to consume.

With captive_portal_uri set to 'https://test.example.com'

Produces in RA:

ICMPv6 Option (DHCP Captive-Portal)
    Type: DHCP Captive-Portal (37)
    Length: 4 (32 bytes)
    Captive Portal: https://test.example.com

And in DHCPv6 Reply:

Captive Portal
    Option: Captive Portal (103)
    Length: 24
    Captive Portal: https://test.example.com

Signed-off-by: Paul Donald <[email protected]>
Link: https://github.com/openwrt/odhcpd/pull/315
Signed-off-by: Álvaro Fernández Rojas <[email protected]>
README.md
src/config.c
src/dhcpv6.c
src/dhcpv6.h
src/odhcpd.h
src/router.c

index 1b468f9aba5d25eb868dffb5b72372beb6129027..1ac77420d96c326eee7f62e0f8503c2379339e80 100644 (file)
--- a/README.md
+++ b/README.md
@@ -121,6 +121,7 @@ and may also receive information from ubus
 | prefix_filter                |string |`::/0` | Only advertise on-link prefixes within the provided IPv6 prefix; others are filtered out. [IPv6 prefix] |
 | ntp                  |list   |`<local address>`| NTP servers to announce accepts IPv4 and IPv6 |
 | upstream             |list   | -     | A list of interfaces which can be used as a source of configuration information (e.g. for NTP servers, if not set explicitly). |
+| captive_portal_uri |string | no  | The API URI to be sent in RFC8910 captive portal options, via DHCPv6 and ICMPv6 RA. |
 
 [//]: # "dhcpv6_raw - string - not documented, may change when generic DHCPv4/DHCPv6 options are added"
 
index 84e2f73f980cebb5670796dd018b5c1848a7cc97..b2991ef13994eb513ece281da4167138df76de51 100644 (file)
@@ -139,6 +139,7 @@ enum {
        IFACE_ATTR_MAX_PREFERRED_LIFETIME,
        IFACE_ATTR_MAX_VALID_LIFETIME,
        IFACE_ATTR_NTP,
+       IFACE_ATTR_CAPTIVE_PORTAL_URI,
        IFACE_ATTR_MAX
 };
 
@@ -191,6 +192,7 @@ static const struct blobmsg_policy iface_attrs[IFACE_ATTR_MAX] = {
        [IFACE_ATTR_MAX_PREFERRED_LIFETIME] = { .name = "max_preferred_lifetime", .type = BLOBMSG_TYPE_STRING },
        [IFACE_ATTR_MAX_VALID_LIFETIME] = { .name = "max_valid_lifetime", .type = BLOBMSG_TYPE_STRING },
        [IFACE_ATTR_NTP] = { .name = "ntp", .type = BLOBMSG_TYPE_ARRAY },
+       [IFACE_ATTR_CAPTIVE_PORTAL_URI] = { .name = "captive_portal_uri", .type = BLOBMSG_TYPE_STRING },
 };
 
 const struct uci_blob_param_list interface_attr_list = {
@@ -315,6 +317,7 @@ static void set_interface_defaults(struct interface *iface)
        iface->dhcp_leasetime = 43200;
        iface->max_preferred_lifetime = ND_PREFERRED_LIMIT;
        iface->max_valid_lifetime = ND_VALID_LIMIT;
+       iface->captive_portal_uri = NULL;
        iface->dhcpv4_start.s_addr = htonl(START_DEFAULT);
        iface->dhcpv4_end.s_addr = htonl(START_DEFAULT + LIMIT_DEFAULT - 1);
        iface->dhcpv6_assignall = true;
@@ -349,6 +352,7 @@ static void clean_interface(struct interface *iface)
        free(iface->dhcpv4_ntp);
        free(iface->dhcpv6_ntp);
        free(iface->dhcpv6_sntp);
+       free(iface->captive_portal_uri);
        for (unsigned i = 0; i < iface->dnr_cnt; i++) {
                free(iface->dnr[i].adn);
                free(iface->dnr[i].addr4);
@@ -1303,6 +1307,13 @@ int config_parse_interface(void *data, size_t len, const char *name, bool overwr
                }
        }
 
+       if ((c = tb[IFACE_ATTR_CAPTIVE_PORTAL_URI])) {
+               iface->captive_portal_uri = strdup(blobmsg_get_string(c));
+               iface->captive_portal_uri_len = strlen(iface->captive_portal_uri);
+               debug("Set RFC8910 captive portal URI: '%s' for interface '%s'",
+                       iface->captive_portal_uri, iface->name);
+       }
+
        if ((c = tb[IFACE_ATTR_DNS])) {
                struct blob_attr *cur;
                unsigned rem;
index 9d812626617d96a8ed18bff6f9e69e7c6bfb4957..37a778dbad9c594633da94d4582b6975d46a37cc 100644 (file)
@@ -188,6 +188,8 @@ enum {
        IOV_POSIX_TZ_STR,
        IOV_TZDB_TZ,
        IOV_TZDB_TZ_STR,
+       IOV_CAPT_PORTAL,
+       IOV_CAPT_PORTAL_URI,
        IOV_TOTAL
 };
 
@@ -489,6 +491,21 @@ static void handle_client_request(void *addr, void *data, size_t len,
        struct dhcpv6_dnr *dnrs = NULL;
        size_t dnrs_len = 0;
 
+       /* RFC8910 Captive-Portal URI */
+       uint8_t *capt_portal_ptr = (uint8_t *)iface->captive_portal_uri;
+       size_t capt_portal_len = iface->captive_portal_uri_len;
+       struct {
+               uint16_t type;
+               uint16_t len;
+       } capt_portal;
+
+       /* RFC8910 §2:
+        * DHCP servers MAY send the Captive Portal option without any explicit request
+        * If it is configured, send it.
+        */
+       capt_portal.type = htons(DHCPV6_OPT_CAPTIVE_PORTAL);
+       capt_portal.len = htons(capt_portal_len);
+
        uint16_t otype, olen;
        uint8_t *odata;
        uint16_t *reqopts = NULL;
@@ -645,6 +662,8 @@ static void handle_client_request(void *addr, void *data, size_t len,
                [IOV_DNR] = {dnrs, dnrs_len},
                [IOV_RELAY_MSG] = {NULL, 0},
                [IOV_DHCPV4O6_SERVER] = {&dhcpv4o6_server, 0},
+               [IOV_CAPT_PORTAL] = {&capt_portal, capt_portal_len ? sizeof(capt_portal) : 0},
+               [IOV_CAPT_PORTAL_URI] = {capt_portal_ptr, capt_portal_len ? capt_portal_len : 0},
                [IOV_BOOTFILE_URL] = {NULL, 0}
        };
 
@@ -782,6 +801,7 @@ static void handle_client_request(void *addr, void *data, size_t len,
                                      iov[IOV_SNTP].iov_len + iov[IOV_SNTP_ADDR].iov_len +
                                      iov[IOV_POSIX_TZ].iov_len + iov[IOV_POSIX_TZ_STR].iov_len +
                                      iov[IOV_TZDB_TZ].iov_len + iov[IOV_TZDB_TZ_STR].iov_len +
+                                     iov[IOV_CAPT_PORTAL].iov_len + iov[IOV_CAPT_PORTAL_URI].iov_len +
                                      iov[IOV_DNR].iov_len + iov[IOV_BOOTFILE_URL].iov_len -
                                      (4 + opts_end - opts));
 
index 7aed782f08e71254ee83f07ea40e8a24c09d5254..a2b08c6c199b89b0739ffb4ac10e39c10b49939b 100644 (file)
@@ -74,6 +74,8 @@
 #define DHCPV6_OPT_INF_MAX_RT 83
 #define DHCPV6_OPT_DHCPV4_MSG 87
 #define DHCPV6_OPT_4O6_SERVER 88
+/* RFC8910 */
+#define DHCPV6_OPT_CAPTIVE_PORTAL 103
 #define DHCPV6_OPT_DNR 144
 
 #define DHCPV6_DUID_VENDOR 2
index 06cc332ec5ba57bbdd1d79cdcf2d97bc7d543b9b..018426f7c1d81cb9073825e10bcca8aa7a53940d 100644 (file)
@@ -41,6 +41,9 @@
 #define ND_OPT_RECURSIVE_DNS 25
 #define ND_OPT_DNS_SEARCH 31
 
+// RFC 8910 defines captive portal option
+#define ND_OPT_CAPTIVE_PORTAL 37
+
 // RFC 8781 defines PREF64 option
 #define ND_OPT_PREF64 38
 
@@ -383,6 +386,10 @@ struct interface {
        struct avl_tree dhcpv4_leases;
        struct list_head dhcpv4_fr_ips;
 
+       // RFC8910
+       char *captive_portal_uri;
+       size_t captive_portal_uri_len;
+
        // Services
        enum odhcpd_mode ra;
        enum odhcpd_mode dhcpv6;
index 43a0ab16e32eb465ab5d991ef9addd91a4fa238f..f8fa759d27011aa9c26fbdb5acbfd224feed05e3 100644 (file)
@@ -385,6 +385,7 @@ enum {
        IOV_RA_PREF64,
        IOV_RA_DNR,
        IOV_RA_ADV_INTERVAL,
+       IOV_RA_CAPT_PORTAL,
        IOV_RA_TOTAL,
 };
 
@@ -437,6 +438,12 @@ struct nd_opt_dnr_info {
        uint8_t body[];
 };
 
+struct nd_opt_capt_portal {
+       uint8_t type;
+       uint8_t len;
+       uint8_t data[];
+};
+
 /* IPv6 RA PIOs */
 static struct ra_pio *router_find_ra_pio(struct interface *iface,
        struct odhcpd_ipaddr *addr)
@@ -559,11 +566,13 @@ static int send_router_advert(struct interface *iface, const struct in6_addr *fr
        struct nd_opt_pref64_info *pref64 = NULL;
        struct nd_opt_dnr_info *dnrs = NULL;
        struct nd_opt_adv_interval adv_interval;
+       struct nd_opt_capt_portal *capt_portal = NULL;
        struct iovec iov[IOV_RA_TOTAL];
        struct sockaddr_in6 dest;
        size_t dns_sz = 0, search_sz = 0, pref64_sz = 0, dnrs_sz = 0;
        size_t pfxs_cnt = 0, routes_cnt = 0;
        size_t total_addr_cnt = 0, valid_addr_cnt = 0;
+       size_t capt_portal_sz = 0;
        /*
         * lowest_found_lifetime stores the lowest lifetime of all prefixes;
         * necessary to find longest adv interval necessary
@@ -1015,6 +1024,25 @@ static int send_router_advert(struct interface *iface, const struct in6_addr *fr
        iov[IOV_RA_ADV_INTERVAL].iov_base = (char *)&adv_interval;
        iov[IOV_RA_ADV_INTERVAL].iov_len = adv_interval.nd_opt_adv_interval_len * 8;
 
+       /* RFC 8910 Captive Portal */
+       uint8_t *captive_portal_uri = (uint8_t *)iface->captive_portal_uri;
+       if (iface->captive_portal_uri_len > 0) {
+               /* compute pad so that (header + data + pad) is a multiple of 8 */
+               capt_portal_sz = (sizeof(struct nd_opt_capt_portal) + iface->captive_portal_uri_len + 7) & ~7;
+
+               capt_portal = alloca(capt_portal_sz);
+               memset(capt_portal, 0, capt_portal_sz);
+
+               capt_portal->type = ND_OPT_CAPTIVE_PORTAL;
+               capt_portal->len = capt_portal_sz / 8;
+
+               memcpy(capt_portal->data, captive_portal_uri, iface->captive_portal_uri_len);
+               /* remaining padding bytes already set to 0x00 */
+       }
+
+       iov[IOV_RA_CAPT_PORTAL].iov_base = capt_portal;
+       iov[IOV_RA_CAPT_PORTAL].iov_len = capt_portal_sz;
+
        memset(&dest, 0, sizeof(dest));
        dest.sin6_family = AF_INET6;