From d1500bb5d64d60e3fa4ec7ddb60501162002db3e Mon Sep 17 00:00:00 2001 From: Paul Donald Date: Sun, 16 Nov 2025 13:47:34 +0100 Subject: [PATCH] all: implement RFC8910 captive portal (CP) option MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit 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 Link: https://github.com/openwrt/odhcpd/pull/315 Signed-off-by: Álvaro Fernández Rojas --- README.md | 1 + src/config.c | 11 +++++++++++ src/dhcpv6.c | 20 ++++++++++++++++++++ src/dhcpv6.h | 2 ++ src/odhcpd.h | 7 +++++++ src/router.c | 28 ++++++++++++++++++++++++++++ 6 files changed, 69 insertions(+) diff --git a/README.md b/README.md index 1b468f9..1ac7742 100644 --- 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 |``| 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" diff --git a/src/config.c b/src/config.c index 84e2f73..b2991ef 100644 --- a/src/config.c +++ b/src/config.c @@ -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; diff --git a/src/dhcpv6.c b/src/dhcpv6.c index 9d81262..37a778d 100644 --- a/src/dhcpv6.c +++ b/src/dhcpv6.c @@ -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)); diff --git a/src/dhcpv6.h b/src/dhcpv6.h index 7aed782..a2b08c6 100644 --- a/src/dhcpv6.h +++ b/src/dhcpv6.h @@ -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 diff --git a/src/odhcpd.h b/src/odhcpd.h index 06cc332..018426f 100644 --- a/src/odhcpd.h +++ b/src/odhcpd.h @@ -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; diff --git a/src/router.c b/src/router.c index 43a0ab1..f8fa759 100644 --- a/src/router.c +++ b/src/router.c @@ -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; -- 2.30.2