From 674838457b2dd60078f0b80bdf68478ae676f83a Mon Sep 17 00:00:00 2001 From: David Yu Date: Mon, 21 Sep 2026 21:15:09 -0700 Subject: [PATCH 01/16] dns: reach nameservers over IPv6 The c-ares integration created every UDP socket as AF_INET and rejected any AF_INET6 sockaddr with "No ipv6 yet", so a host whose resolv.conf lists an IPv6 nameserver could not resolve anything: c-ares reported ARES_ECONNREFUSED for every server. That is what an IPv6-only Kubernetes pod looks like (CoreDNS over IPv6), and what an IPv6-only VM with a link-local or ULA resolver looks like. - do_socket creates the datagram channel with the family c-ares asked for instead of hardcoding AF_INET. - sock_addr accepts AF_INET6 and checks the sockaddr length. - do_recvfrom copies the source address by its real length; sockaddr is only wide enough for AF_INET, so an IPv6 source was being truncated and its length misreported. - options.servers is installed with ares_set_servers_csv after channel creation; ARES_OPT_SERVERS carries in_addr only. - ARES_ECONNREFUSED is rendered with c-ares' own text, "Could not contact DNS servers", so it stops reading like a refused TCP connect. IPv4 nameservers take the same AF_INET branches as before. Adds a unit test that answers a query from a mock nameserver bound to [::1]. --- src/net/dns.cc | 67 +++++++++++++++++++++++++++--------------- tests/unit/dns_test.cc | 65 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 108 insertions(+), 24 deletions(-) diff --git a/src/net/dns.cc b/src/net/dns.cc index a2bd89aa4c3..93629891d19 100644 --- a/src/net/dns.cc +++ b/src/net/dns.cc @@ -43,6 +43,7 @@ #include #include +#include #include #include #include @@ -115,7 +116,9 @@ class ares_error_category : public std::error_category { case ARES_EBADNAME: return "Bad name"; case ARES_EBADFAMILY: return "Bad family"; case ARES_EBADRESP: return "Bad response"; - case ARES_ECONNREFUSED :return "Connection refused"; + // c-ares raises this when no nameserver could be reached at + // all, not for a refused TCP connect; use its own wording. + case ARES_ECONNREFUSED: return "Could not contact DNS servers"; case ARES_ETIMEOUT: return "Timeout"; case ARES_EOF: return "EOF"; case ARES_EFILE: return "File error"; @@ -370,19 +373,8 @@ dns_resolver::impl::impl(network_stack& stack, const options& opts) a_opts.flags = ARES_FLAG_USEVC | ARES_FLAG_PRIMARY; flags |= ARES_OPT_FLAGS; } - std::vector addr_tmp; - if (opts.servers) { - std::transform(opts.servers->begin(), opts.servers->end(), std::back_inserter(addr_tmp), [](const inet_address& a) { - if (a.in_family() != inet_address::family::INET) { - throw std::invalid_argument("Servers must be ipv4 addresses"); - } - in_addr in = a; - return in; - }); - a_opts.servers = addr_tmp.data(); - a_opts.nservers = int(addr_tmp.size()); - flags |= ARES_OPT_SERVERS; - } + // ARES_OPT_SERVERS carries in_addr only, so servers are installed after + // ares_init_options instead. std::vector dom_tmp; if (opts.domains) { std::transform(opts.domains->begin(), opts.domains->end(), std::back_inserter(dom_tmp), [](const sstring& s) { @@ -403,6 +395,18 @@ dns_resolver::impl::impl(network_stack& stack, const options& opts) check_ares_error(ares_init_options(&_channel, &a_opts, flags)); + if (opts.servers) { + // Without a port a server inherits ARES_OPT_UDP_PORT/ARES_OPT_TCP_PORT. + sstring csv; + for (auto& server : *opts.servers) { + if (!csv.empty()) { + csv += ","; + } + csv += fmt::format("{}", server); + } + check_ares_error(ares_set_servers_csv(_channel, csv.c_str())); + } + // Set up custom socket functions to integrate with Seastar's networking stack // Note: These work together with ARES_OPT_SOCK_STATE_CB (when available) #if ARES_VERSION >= 0x012200 // ares_set_socket_functions_ex available since 1.34.0 @@ -1112,8 +1116,8 @@ dns_resolver::impl::do_socket(int af, int type, int protocol) { dns_log.trace("Created tcp socket {}", fd); break; case SOCK_DGRAM: - _sockets.emplace(fd, _stack.make_unbound_datagram_channel(AF_INET)); - dns_log.trace("Created udp socket {}", fd); + _sockets.emplace(fd, _stack.make_unbound_datagram_channel(af)); + dns_log.trace("Created udp socket {} (family {})", fd, af); break; default: return -1; } @@ -1168,11 +1172,21 @@ dns_resolver::impl::do_close(ares_socket_t fd) { socket_address dns_resolver::impl::sock_addr(const sockaddr * addr, socklen_t len) { - if (addr->sa_family != AF_INET) { - throw std::invalid_argument("No ipv6 yet"); + switch (addr->sa_family) { + case AF_INET: + if (len < static_cast(sizeof(sockaddr_in))) { + throw std::invalid_argument("Truncated AF_INET address"); + } + return *reinterpret_cast(addr); + case AF_INET6: + if (len < static_cast(sizeof(sockaddr_in6))) { + throw std::invalid_argument("Truncated AF_INET6 address"); + } + return *reinterpret_cast(addr); + default: + throw std::invalid_argument( + format("Unsupported address family {}", addr->sa_family)); } - auto in = reinterpret_cast(addr); - return *in; } int @@ -1303,10 +1317,17 @@ dns_resolver::impl::do_recvfrom(ares_socket_t fd, void * dst, size_t len, int fl dns_log.trace("Read {}. {} bytes available from {}", fd, available, udp.in->get_src()); if (from != nullptr) { - *from = socket_address(udp.in->get_src()).as_posix_sockaddr(); + // from_len is value-result: it bounds the caller's + // buffer on entry and reports the source size on exit, + // which for AF_INET6 exceeds a sockaddr. + auto src = socket_address(udp.in->get_src()); + auto capacity = from_len != nullptr + ? *from_len + : static_cast(sizeof(sockaddr)); + std::memcpy(from, &src.as_posix_sockaddr(), + std::min(src.length(), capacity)); if (from_len != nullptr) { - // TODO: ipvv6 - *from_len = sizeof(sockaddr_in); + *from_len = src.length(); } } diff --git a/tests/unit/dns_test.cc b/tests/unit/dns_test.cc index 89d8c51036d..067317a57b7 100644 --- a/tests/unit/dns_test.cc +++ b/tests/unit/dns_test.cc @@ -21,6 +21,7 @@ */ #include #include +#include #include #include @@ -54,7 +55,7 @@ static void write_be32(std::vector& out, uint32_t v) { out.push_back(char(v)); } -static std::vector make_tcp_dns_a_response(const temporary_buffer& query) { +static std::vector make_dns_a_response(const temporary_buffer& query) { BOOST_REQUIRE_GE(query.size(), 12); BOOST_REQUIRE_EQUAL(read_be16(query.get() + 4), 1); @@ -87,12 +88,29 @@ static std::vector make_tcp_dns_a_response(const temporary_buffer& q msg.push_back(char(0)); msg.push_back(char(42)); + return msg; +} + +static std::vector make_tcp_dns_a_response(const temporary_buffer& query) { + auto msg = make_dns_a_response(query); std::vector tcp_response; write_be16(tcp_response, msg.size()); tcp_response.insert(tcp_response.end(), msg.begin(), msg.end()); return tcp_response; } +// Answers exactly one query, over whatever family the channel is bound to. +static future<> serve_udp_dns_response(net::datagram_channel& chan) { + auto dg = co_await chan.receive(); + auto bufs = dg.get_buffers(); + BOOST_REQUIRE(!bufs.empty()); + // A DNS query for a single name fits one buffer. + auto query = temporary_buffer(bufs.front().get(), bufs.front().size()); + auto response = make_dns_a_response(query); + auto out = temporary_buffer(response.data(), response.size()); + co_await chan.send(dg.get_src(), std::span>(&out, 1)); +} + static future<> serve_split_tcp_dns_response(server_socket& listener) { auto ar = co_await listener.accept(); auto socket = std::move(ar.connection); @@ -276,6 +294,51 @@ SEASTAR_TEST_CASE(test_resolve_tcp_split_response) { } } +// The resolver must be able to *talk to* an IPv6 nameserver. The record it +// asks for is still an A record: nameserver transport family and answer +// family are independent, and only the former was broken. +SEASTAR_TEST_CASE(test_resolve_udp_ipv6_nameserver) { + if (!engine().net().supports_ipv6()) { + BOOST_TEST_MESSAGE("No ipv6 support, skipping test"); + co_return; + } + + auto chan = make_bound_datagram_channel(socket_address(ipv6_addr{"::1", 0})); + auto server = serve_udp_dns_response(chan); + + dns_resolver::options opts; + opts.servers = std::vector({ inet_address("::1") }); + opts.udp_port = chan.local_address().port(); + opts.timeout = std::chrono::seconds(30); + + auto d = ::make_lw_shared(engine().net(), opts); + + std::exception_ptr ex; + try { + auto h = co_await with_timeout(timer<>::clock::now() + std::chrono::seconds(5), + d->get_host_by_name("v6ns.seastar.test", inet_address::family::INET)); + BOOST_REQUIRE_EQUAL(h.addr_entries.size(), 1); + BOOST_REQUIRE_EQUAL(h.addr_entries.front().addr, inet_address("127.0.0.42")); + } catch (...) { + ex = std::current_exception(); + } + + co_await d->close(); + chan.shutdown_input(); + try { + co_await std::move(server); + } catch (...) { + if (!ex) { + ex = std::current_exception(); + } + } + chan.close(); + + if (ex) { + std::rethrow_exception(ex); + } +} + SEASTAR_TEST_CASE(test_resolve_tcp, *enable_if_with_networking()) { dns_resolver::options opts; From c288f02ed18acb0aca2ed3c9c881731bd050b31c Mon Sep 17 00:00:00 2001 From: David Yu Date: Mon, 21 Sep 2026 21:15:28 -0700 Subject: [PATCH 02/16] dns: resolve numeric literals without c-ares, for any requested family get_host_by_name short-circuited numeric literals only when no address family was requested. With a family set the literal went to c-ares, which cannot parse a scope id ("fe80::1%eth0" turned into a real DNS query that failed with ARES_ENOTFOUND) and which returns an IPv4 node for an AF_INET6 request, so the family was not a filter for literals. Parse literals with inet_address::parse_numerical regardless of the requested family. A literal of the other family fails with ARES_EBADFAMILY, matching what the resolver does for names that only have records of the other family. --- src/net/dns.cc | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/net/dns.cc b/src/net/dns.cc index 93629891d19..36039408317 100644 --- a/src/net/dns.cc +++ b/src/net/dns.cc @@ -534,11 +534,14 @@ dns_resolver::impl::get_host_by_name(sstring name, opt_family family) { dns_log.debug("Query name {} ({})", name, family); - if (!family) { - auto res = inet_address::parse_numerical(name); - if (res) { - return make_ready_future(hostent({std::move(name)}, {{*res}}, {{*res}})); + // c-ares cannot parse a scope id ("fe80::1%eth0") and ignores the + // requested family for literals, so resolve them here. The family is a + // filter: a literal of the other family is an error, not a fallback. + if (auto res = inet_address::parse_numerical(name)) { + if (family && res->in_family() != *family) { + return make_exception_future(std::system_error(ARES_EBADFAMILY, dns::error_category())); } + return make_ready_future(hostent({std::move(name)}, {{*res}}, {{*res}})); } auto p = new promise_wrap(std::move(name)); From dafabe7f9490b57362d0b18e0bc5ba15250c4956 Mon Sep 17 00:00:00 2001 From: David Yu Date: Mon, 21 Sep 2026 21:15:49 -0700 Subject: [PATCH 03/16] dns: take error text from c-ares and wire the interface-name callbacks The error category kept a hand-written copy of c-ares' error table that stopped at ARES_ECANCELLED, so ARES_ESERVICE and ARES_ENOSERVER ("No DNS servers were configured", the result of every configured server being unusable) rendered as "Unknown error". Use ares_strerror. ares_socket_functions_ex had aif_nametoindex/aif_indextoname set to null. c-ares uses them to resolve the %iface suffix of link-local nameservers and silently drops such servers without them, so a resolv.conf of "nameserver fe80::1%eth0" lost its only server. --- src/net/dns.cc | 61 +++++++++++++------------------------------------- 1 file changed, 16 insertions(+), 45 deletions(-) diff --git a/src/net/dns.cc b/src/net/dns.cc index 36039408317..9455e21f02f 100644 --- a/src/net/dns.cc +++ b/src/net/dns.cc @@ -46,6 +46,7 @@ #include #include #include +#include #include #include @@ -102,49 +103,9 @@ class ares_error_category : public std::error_category { return "C-Ares"; } std::string message(int error) const { - switch (error) { - /* Server error codes (ARES_ENODATA indicates no relevant answer) */ - case ARES_ENODATA: return "No data"; - case ARES_EFORMERR: return "Form error"; - case ARES_ESERVFAIL: return "Server failure"; - case ARES_ENOTFOUND: return "Not found"; - case ARES_ENOTIMP: return "Not implemented"; - case ARES_EREFUSED: return "Refused"; - - /* Locally generated error codes */ - case ARES_EBADQUERY: return "Bad query"; - case ARES_EBADNAME: return "Bad name"; - case ARES_EBADFAMILY: return "Bad family"; - case ARES_EBADRESP: return "Bad response"; - // c-ares raises this when no nameserver could be reached at - // all, not for a refused TCP connect; use its own wording. - case ARES_ECONNREFUSED: return "Could not contact DNS servers"; - case ARES_ETIMEOUT: return "Timeout"; - case ARES_EOF: return "EOF"; - case ARES_EFILE: return "File error"; - case ARES_ENOMEM: return "No memory"; - case ARES_EDESTRUCTION: return "Destruction"; - case ARES_EBADSTR: return "Bad string"; - - /* ares_getnameinfo error codes */ - case ARES_EBADFLAGS: return "Invalid flags"; - - /* ares_getaddrinfo error codes */ - case ARES_ENONAME: return "No name"; - case ARES_EBADHINTS: return "Bad hints"; - - /* Uninitialized library error code */ - case ARES_ENOTINITIALIZED: return "Not initialized"; - - /* ares_library_init error codes */ - case ARES_ELOADIPHLPAPI: return "Load PHLPAPI"; - case ARES_EADDRGETNETWORKPARAMS: return "Get network parameters"; - - /* More error codes */ - case ARES_ECANCELLED: return "Cancelled"; - default: - return "Unknown error"; - } + // c-ares owns the table; the copy this replaced stopped at + // ARES_ECANCELLED. + return ares_strerror(error); } }; @@ -440,8 +401,18 @@ dns_resolver::impl::impl(network_stack& stack, const options& opts) }, .agetsockname = nullptr, // Not needed .abind = nullptr, // Not needed - .aif_nametoindex = nullptr, // Not needed - .aif_indextoname = nullptr, // Not needed + // c-ares resolves "%iface" on link-local nameservers through these; + // with them null it silently drops such servers. + .aif_nametoindex = [](const char * ifname, void *) -> unsigned int { + return if_nametoindex(ifname); + }, + .aif_indextoname = [](unsigned int ifindex, char * ifname_buf, size_t ifname_buf_len, void *) -> const char * { + char tmp[IF_NAMESIZE]; + if (if_indextoname(ifindex, tmp) == nullptr || strlen(tmp) >= ifname_buf_len) { + return nullptr; + } + return strcpy(ifname_buf, tmp); + }, }; ares_status_t status = ares_set_socket_functions_ex(_channel, &callbacks_ex, this); From 654fa05b95d9202e961ffb7a3b115109e5903c4a Mon Sep 17 00:00:00 2001 From: David Yu Date: Mon, 21 Sep 2026 21:17:56 -0700 Subject: [PATCH 04/16] tests: cover the IPv6 side of the resolver The mock nameserver only ever answered A records, so make_hostent's AF_INET6 branches, reverse lookups of 16-byte addresses, the TCP query path over an IPv6 transport and numeric-literal handling with an explicit family had no coverage. Drive the mock by the question type (A, AAAA, PTR) and add: - test_resolve_aaaa_from_ipv6_nameserver - test_reverse_lookup_ipv6_from_ipv6_nameserver (ip6.arpa PTR) - test_resolve_tcp_ipv6_nameserver - test_resolve_numeric_with_family (literals never reach c-ares; a literal of the other family fails; a %scope suffix is preserved) All IPv6 cases skip when the host has no IPv6 loopback. --- tests/unit/dns_test.cc | 202 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 193 insertions(+), 9 deletions(-) diff --git a/tests/unit/dns_test.cc b/tests/unit/dns_test.cc index 067317a57b7..9b6bc9476da 100644 --- a/tests/unit/dns_test.cc +++ b/tests/unit/dns_test.cc @@ -34,6 +34,8 @@ #include #include +#include + using namespace seastar; using namespace seastar::net; @@ -55,7 +57,27 @@ static void write_be32(std::vector& out, uint32_t v) { out.push_back(char(v)); } -static std::vector make_dns_a_response(const temporary_buffer& query) { +// Encodes "a.b.c" as DNS labels. +static void write_name(std::vector& out, std::string_view name) { + size_t start = 0; + while (start <= name.size()) { + auto dot = name.find('.', start); + auto label = name.substr(start, dot == std::string_view::npos ? std::string_view::npos : dot - start); + out.push_back(char(label.size())); + out.insert(out.end(), label.begin(), label.end()); + if (dot == std::string_view::npos) { + break; + } + start = dot + 1; + } + out.push_back(0); +} + +// Answers the single question in `query` with one record of the type it asked +// for: A -> 127.0.0.42, AAAA -> ::42, PTR -> "v6.seastar.test". Everything an +// IPv6 resolver does differently from an IPv4 one is in the answer type, so +// one builder driven by QTYPE covers all of them. +static std::vector make_dns_response(const temporary_buffer& query) { BOOST_REQUIRE_GE(query.size(), 12); BOOST_REQUIRE_EQUAL(read_be16(query.get() + 4), 1); @@ -68,6 +90,7 @@ static std::vector make_dns_a_response(const temporary_buffer& query BOOST_REQUIRE_LT(question_end, query.size()); question_end += 1 + sizeof(uint16_t) + sizeof(uint16_t); BOOST_REQUIRE_LE(question_end, query.size()); + const auto qtype = read_be16(query.get() + question_end - 4); std::vector msg; write_be16(msg, read_be16(query.get())); @@ -79,20 +102,35 @@ static std::vector make_dns_a_response(const temporary_buffer& query msg.insert(msg.end(), query.get() + 12, query.get() + question_end); write_be16(msg, 0xc00c); - write_be16(msg, 1); + write_be16(msg, qtype); write_be16(msg, 1); write_be32(msg, 60); - write_be16(msg, 4); - msg.push_back(char(127)); - msg.push_back(char(0)); - msg.push_back(char(0)); - msg.push_back(char(42)); + switch (qtype) { + case 1: // A + write_be16(msg, 4); + msg.insert(msg.end(), {char(127), char(0), char(0), char(42)}); + break; + case 28: // AAAA + write_be16(msg, 16); + msg.insert(msg.end(), 15, char(0)); + msg.push_back(char(0x42)); + break; + case 12: { // PTR + std::vector name; + write_name(name, "v6.seastar.test"); + write_be16(msg, name.size()); + msg.insert(msg.end(), name.begin(), name.end()); + break; + } + default: + BOOST_FAIL("unexpected qtype " + std::to_string(qtype)); + } return msg; } static std::vector make_tcp_dns_a_response(const temporary_buffer& query) { - auto msg = make_dns_a_response(query); + auto msg = make_dns_response(query); std::vector tcp_response; write_be16(tcp_response, msg.size()); tcp_response.insert(tcp_response.end(), msg.begin(), msg.end()); @@ -106,7 +144,7 @@ static future<> serve_udp_dns_response(net::datagram_channel& chan) { BOOST_REQUIRE(!bufs.empty()); // A DNS query for a single name fits one buffer. auto query = temporary_buffer(bufs.front().get(), bufs.front().size()); - auto response = make_dns_a_response(query); + auto response = make_dns_response(query); auto out = temporary_buffer(response.data(), response.size()); co_await chan.send(dg.get_src(), std::span>(&out, 1)); } @@ -339,6 +377,152 @@ SEASTAR_TEST_CASE(test_resolve_udp_ipv6_nameserver) { } } +static future<> serve_udp_dns_responses(net::datagram_channel& chan, size_t n_queries) { + for (size_t i = 0; i < n_queries; ++i) { + co_await serve_udp_dns_response(chan); + } +} + +// Runs `query` against a mock nameserver bound to [::1] over UDP (the server +// answers `n_queries` questions) and returns the resolver's answer. +template +static future ask_ipv6_udp_nameserver(size_t n_queries, Query query) { + auto chan = make_bound_datagram_channel(socket_address(ipv6_addr{"::1", 0})); + auto server = serve_udp_dns_responses(chan, n_queries); + + dns_resolver::options opts; + opts.servers = std::vector({ inet_address("::1") }); + opts.udp_port = chan.local_address().port(); + opts.timeout = std::chrono::seconds(30); + auto d = ::make_lw_shared(engine().net(), opts); + + std::exception_ptr ex; + std::optional answer; + try { + answer = co_await with_timeout(timer<>::clock::now() + std::chrono::seconds(5), query(*d)); + } catch (...) { + ex = std::current_exception(); + } + co_await d->close(); + chan.shutdown_input(); + try { + co_await std::move(server); + } catch (...) { + if (!ex) { + ex = std::current_exception(); + } + } + chan.close(); + if (ex) { + std::rethrow_exception(ex); + } + co_return std::move(*answer); +} + +// The answer side of IPv6: an AAAA record has to survive make_hostent. +SEASTAR_TEST_CASE(test_resolve_aaaa_from_ipv6_nameserver) { + if (!engine().net().supports_ipv6()) { + BOOST_TEST_MESSAGE("No ipv6 support, skipping test"); + co_return; + } + auto h = co_await ask_ipv6_udp_nameserver(1, [](dns_resolver& d) { + return d.get_host_by_name("v6.seastar.test", inet_address::family::INET6); + }); + BOOST_REQUIRE_EQUAL(h.addr_entries.size(), 1); + BOOST_REQUIRE_EQUAL(h.addr_entries.front().addr, inet_address("::42")); + BOOST_REQUIRE_EQUAL(h.addr_entries.front().ttl, std::chrono::seconds(60)); +} + +// Reverse lookup of an IPv6 address: 16-byte ip6.arpa PTR through get_host_by_addr. +// Not ::1, which /etc/hosts answers before c-ares ever asks the nameserver. +SEASTAR_TEST_CASE(test_reverse_lookup_ipv6_from_ipv6_nameserver) { + if (!engine().net().supports_ipv6()) { + BOOST_TEST_MESSAGE("No ipv6 support, skipping test"); + co_return; + } + auto h = co_await ask_ipv6_udp_nameserver(1, [](dns_resolver& d) { + return d.get_host_by_addr(inet_address("2001:db8::42")); + }); + BOOST_REQUIRE(!h.names.empty()); + BOOST_REQUIRE_EQUAL(h.names.front(), "v6.seastar.test"); +} + +// TCP transport (use_tcp_query) to a nameserver that is only reachable over IPv6. +SEASTAR_TEST_CASE(test_resolve_tcp_ipv6_nameserver) { + if (!engine().net().supports_ipv6()) { + BOOST_TEST_MESSAGE("No ipv6 support, skipping test"); + co_return; + } + listen_options lo; + lo.reuse_address = true; + lo.set_fixed_cpu(this_shard_id()); + + auto listener = seastar::listen(socket_address(ipv6_addr{"::1", 0}), lo); + auto server = serve_split_tcp_dns_response(listener); + + dns_resolver::options opts; + opts.servers = std::vector({ inet_address("::1") }); + opts.use_tcp_query = true; + opts.tcp_port = listener.local_address().port(); + opts.timeout = std::chrono::seconds(30); + auto d = ::make_lw_shared(engine().net(), opts); + + std::exception_ptr ex; + try { + auto h = co_await with_timeout(timer<>::clock::now() + std::chrono::seconds(5), + d->get_host_by_name("tcp6.seastar.test", inet_address::family::INET)); + BOOST_REQUIRE_EQUAL(h.addr_entries.size(), 1); + BOOST_REQUIRE_EQUAL(h.addr_entries.front().addr, inet_address("127.0.0.42")); + } catch (...) { + ex = std::current_exception(); + } + co_await d->close(); + listener.abort_accept(); + try { + co_await std::move(server); + } catch (...) { + if (!ex) { + ex = std::current_exception(); + } + } + if (ex) { + std::rethrow_exception(ex); + } +} + +// Numeric literals never reach c-ares, for either family, and the requested +// family is a filter rather than a hint. +SEASTAR_TEST_CASE(test_resolve_numeric_with_family) { + auto d = ::make_lw_shared(engine().net(), dns_resolver::options()); + + auto v6 = co_await d->get_host_by_name("::1", inet_address::family::INET6); + BOOST_REQUIRE_EQUAL(v6.addr_entries.size(), 1); + BOOST_REQUIRE_EQUAL(v6.addr_entries.front().addr, inet_address("::1")); + + auto v4 = co_await d->get_host_by_name("127.0.0.1", inet_address::family::INET); + BOOST_REQUIRE_EQUAL(v4.addr_entries.front().addr, inet_address("127.0.0.1")); + + for (auto [literal, family] : {std::pair{"::1", inet_address::family::INET}, std::pair{"127.0.0.1", inet_address::family::INET6}}) { + try { + co_await d->get_host_by_name(literal, family); + BOOST_FAIL("literal of the other family must not resolve"); + } catch (const std::system_error& e) { + // c-ares is not part of seastar's public interface, so compare the + // rendered text instead of the ARES_EBADFAMILY constant. + BOOST_REQUIRE(e.code().category() == dns::error_category()); + BOOST_REQUIRE_EQUAL(e.code().message(), dns::error_category().message(e.code().value())); + BOOST_REQUIRE_NE(e.code().message().find("family"), std::string::npos); + } + } + + // A scoped link-local literal must keep its scope; c-ares cannot parse one. + if (auto lo_index = if_nametoindex("lo")) { + auto scoped = co_await d->get_host_by_name("fe80::1%lo", inet_address::family::INET6); + BOOST_REQUIRE_EQUAL(scoped.addr_entries.front().addr.scope(), lo_index); + } + co_await d->close(); +} + SEASTAR_TEST_CASE(test_resolve_tcp, *enable_if_with_networking()) { dns_resolver::options opts; From bdef5c04e23757b9cbfdb97d8b5930af08fec145 Mon Sep 17 00:00:00 2001 From: David Yu Date: Mon, 21 Sep 2026 22:04:54 -0700 Subject: [PATCH 05/16] net: let listen_options pin IPV6_V6ONLY Whether a wildcard [::] listener also accepts IPv4 clients (as IPv4-mapped IPv6 peers) has so far followed the host's net.ipv6.bindv6only default, and a [::]:P listener could never share a port with a 0.0.0.0:P one. Add listen_options::ipv6_only. When set, posix_listen applies it with IPV6_V6ONLY before bind on AF_INET6 sockets; other families are untouched and the default (unset) keeps today's behaviour. Tested by a listener on [::]:0 reached from 127.0.0.1 with the option false (accepted, peer ::ffff:127.0.0.1) and true (ECONNREFUSED). --- include/seastar/net/api.hh | 5 +++++ src/core/reactor.cc | 4 ++++ tests/unit/ipv6_test.cc | 44 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/include/seastar/net/api.hh b/include/seastar/net/api.hh index 685652831a7..d7197342a26 100644 --- a/include/seastar/net/api.hh +++ b/include/seastar/net/api.hh @@ -432,6 +432,11 @@ struct listen_options { /// setting it directly on the already-accepted socket is ineffective (see TCP(7)). std::optional so_rcvbuf; + /// If set, applied as IPV6_V6ONLY before bind on an AF_INET6 socket, so a + /// `[::]` listener's dual-stackness does not follow net.ipv6.bindv6only. + /// Ignored for other families. + std::optional ipv6_only; + void set_fixed_cpu(unsigned cpu) { lba = server_socket::load_balancing_algorithm::fixed; fixed_cpu = cpu; diff --git a/src/core/reactor.cc b/src/core/reactor.cc index 34a99a64de5..6b08383176d 100644 --- a/src/core/reactor.cc +++ b/src/core/reactor.cc @@ -1676,6 +1676,10 @@ pollable_fd posix_listen(socket_address sa, listen_options opts) { fd.setsockopt(SOL_SOCKET, SO_RCVBUF, *opts.so_rcvbuf); } + if (opts.ipv6_only && sa.u.sa.sa_family == AF_INET6) { + fd.setsockopt(IPPROTO_IPV6, IPV6_V6ONLY, int(*opts.ipv6_only)); + } + try { fd.bind(sa.u.sa, sa.length()); diff --git a/tests/unit/ipv6_test.cc b/tests/unit/ipv6_test.cc index 4770ad7c57c..d87db4e6d6e 100644 --- a/tests/unit/ipv6_test.cc +++ b/tests/unit/ipv6_test.cc @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -103,6 +104,49 @@ SEASTAR_TEST_CASE(tcp_packet_test) { }); } +// A wildcard IPv6 listener decides for itself whether IPv4 clients may reach it, +// instead of inheriting the host's net.ipv6.bindv6only default. +SEASTAR_TEST_CASE(dual_stack_listen_test) { + if (!check_ipv6_support()) { + co_return; + } + for (bool ipv6_only : {false, true}) { + listen_options lo; + lo.reuse_address = true; + lo.ipv6_only = ipv6_only; + auto ss = server_socket(engine().net().listen(ipv6_addr{"::", 0}, lo)); + auto accepted = ss.accept(); + std::optional cs; + std::exception_ptr refused; + try { + cs = co_await connect(ipv4_addr("127.0.0.1", ss.local_address().port())); + } catch (...) { + refused = std::current_exception(); + } + if (cs) { + BOOST_REQUIRE_MESSAGE(!ipv6_only, "an IPv4 client reached an IPV6_V6ONLY listener"); + auto ar = co_await std::move(accepted); + // the kernel hands the IPv4 peer over as ::ffff:127.0.0.1 + auto& peer = ar.remote_address.as_posix_sockaddr_in6(); + BOOST_REQUIRE_EQUAL(ar.remote_address.family(), AF_INET6); + BOOST_REQUIRE(IN6_IS_ADDR_V4MAPPED(&peer.sin6_addr)); + cs->shutdown_output(); + ar.connection.shutdown_output(); + } else { + BOOST_REQUIRE_MESSAGE(ipv6_only, "IPv4 client refused by a dual-stack listener"); + try { + std::rethrow_exception(refused); + } catch (const std::system_error& e) { + BOOST_REQUIRE_EQUAL(e.code().value(), ECONNREFUSED); + } + ss.abort_accept(); + co_await std::move(accepted).then_wrapped([](future f) { + f.ignore_ready_future(); + }); + } + } +} + SEASTAR_TEST_CASE(ipv6_equal_test) { const uint16_t port{8080}; const uint16_t port2{8088}; From c4593cef51d497ac6e084d195c9315e7ca2b6f7a Mon Sep 17 00:00:00 2001 From: David Yu Date: Mon, 21 Sep 2026 22:04:54 -0700 Subject: [PATCH 06/16] net, http, rpc: treat IPv4-mapped IPv6 peers as IPv4 An IPv4 client of a dual-stack [::] listener is reported by the kernel as ::ffff:a.b.c.d. inet_address already unwrapped that form in its in_addr conversion but nowhere else: operator==, hashing and is_loopback() treated it as a different address from a.b.c.d, and http_server / rpc::server handed the mapped form to handlers and filter_connection, so address-keyed policies written as IPv4 never matched. Add inet_address::is_ipv4_mapped() and unmapped() (the in_addr conversion now reuses them), socket_address::unmapped(), and normalise the peer and local addresses http_server and rpc::server record. Non-mapped addresses of either family are returned unchanged. --- include/seastar/http/httpd.hh | 8 +++--- include/seastar/net/inet_address.hh | 5 ++++ include/seastar/net/socket_defs.hh | 3 +++ src/net/inet_address.cc | 19 +++++++++++--- src/net/socket_address.cc | 8 +++++- src/rpc/rpc.cc | 1 + tests/unit/httpd_test.cc | 38 ++++++++++++++++++++++++++++ tests/unit/network_interface_test.cc | 28 ++++++++++++++++++++ 8 files changed, 101 insertions(+), 9 deletions(-) diff --git a/include/seastar/http/httpd.hh b/include/seastar/http/httpd.hh index 2e72fd24f4e..fd5e12752b1 100644 --- a/include/seastar/http/httpd.hh +++ b/include/seastar/http/httpd.hh @@ -82,8 +82,8 @@ public: , _fd(std::move(fd)) , _read_buf(_fd.input()) , _write_buf(_fd.output()) - , _client_addr(_fd.remote_address()) - , _server_addr(_fd.local_address()) + , _client_addr(_fd.remote_address().unmapped()) + , _server_addr(_fd.local_address().unmapped()) , _tls(tls) { on_new_connection(); } @@ -93,8 +93,8 @@ public: , _fd(std::move(fd)) , _read_buf(_fd.input()) , _write_buf(_fd.output()) - , _client_addr(std::move(client_addr)) - , _server_addr(std::move(server_addr)) + , _client_addr(client_addr.unmapped()) + , _server_addr(server_addr.unmapped()) , _tls(tls) { on_new_connection(); } diff --git a/include/seastar/net/inet_address.hh b/include/seastar/net/inet_address.hh index 1ca9e713aea..92e1679664e 100644 --- a/include/seastar/net/inet_address.hh +++ b/include/seastar/net/inet_address.hh @@ -93,6 +93,11 @@ public: bool is_ipv4() const noexcept { return _in_family == family::INET; } + /// An IPv4 address carried inside an IPv6 one (::ffff:a.b.c.d), which is how + /// the kernel reports IPv4 peers of a dual-stack `[::]` listener. + bool is_ipv4_mapped() const noexcept; + /// The IPv4 address an IPv4-mapped address stands for; any other address unchanged. + inet_address unmapped() const noexcept; size_t size() const noexcept; const void * data() const noexcept; diff --git a/include/seastar/net/socket_defs.hh b/include/seastar/net/socket_defs.hh index 9e32cfbadd7..3ac6dd76ffb 100644 --- a/include/seastar/net/socket_defs.hh +++ b/include/seastar/net/socket_defs.hh @@ -91,6 +91,9 @@ public: net::inet_address addr() const noexcept; ::in_port_t port() const noexcept; bool is_wildcard() const noexcept; + /// The AF_INET form of an IPv4-mapped IPv6 address, same port; any other + /// address unchanged. See net::inet_address::unmapped(). + socket_address unmapped() const noexcept; bool operator==(const socket_address&) const noexcept; bool operator!=(const socket_address& a) const noexcept { diff --git a/src/net/inet_address.cc b/src/net/inet_address.cc index 7692e1cb62f..c0d4d291568 100644 --- a/src/net/inet_address.cc +++ b/src/net/inet_address.cc @@ -144,12 +144,23 @@ bool seastar::net::inet_address::operator==(const inet_address& o) const noexcep } } +bool seastar::net::inet_address::is_ipv4_mapped() const noexcept { + return _in_family == family::INET6 && IN6_IS_ADDR_V4MAPPED(&_in6); +} + +seastar::net::inet_address seastar::net::inet_address::unmapped() const noexcept { + if (!is_ipv4_mapped()) { + return *this; + } + ::in_addr in; + in.s_addr = _in6.s6_addr32[3]; + return inet_address(in); +} + seastar::net::inet_address::operator ::in_addr() const { if (_in_family != family::INET) { - if (IN6_IS_ADDR_V4MAPPED(&_in6)) { - ::in_addr in; - in.s_addr = _in6.s6_addr32[3]; - return in; + if (is_ipv4_mapped()) { + return unmapped()._in; } throw std::invalid_argument("Not an IPv4 address"); } diff --git a/src/net/socket_address.cc b/src/net/socket_address.cc index 4316f1c3d96..88628b0f574 100644 --- a/src/net/socket_address.cc +++ b/src/net/socket_address.cc @@ -103,6 +103,13 @@ bool socket_address::is_unspecified() const noexcept { return u.sa.sa_family == AF_UNSPEC; } +socket_address socket_address::unmapped() const noexcept { + if (u.sa.sa_family != AF_INET6 || !addr().is_ipv4_mapped()) { + return *this; + } + return socket_address(addr().unmapped(), port()); +} + static int adjusted_path_length(const socket_address& a) noexcept { int l = std::max(0, (int)a.addr_length-(int)(offsetof(sockaddr_un, sun_path))); // "un-count" a trailing null in filesystem-namespace paths @@ -175,7 +182,6 @@ std::ostream& operator<<(std::ostream& os, const socket_address& a) { } auto addr = a.addr(); - // CMH. maybe skip brackets for ipv4-mapped auto bracket = addr.in_family() == seastar::net::inet_address::family::INET6; if (bracket) { diff --git a/src/rpc/rpc.cc b/src/rpc/rpc.cc index 2c65628a491..4e63252dd54 100644 --- a/src/rpc/rpc.cc +++ b/src/rpc/rpc.cc @@ -1363,6 +1363,7 @@ void server::accept() { // The caller has to call server::stop() to synchronize. (void)keep_doing([this] () mutable { return _ss.accept().then([this] (accept_result ar) mutable { + ar.remote_address = ar.remote_address.unmapped(); if (_options.filter_connection && !_options.filter_connection(ar.remote_address)) { return; } diff --git a/tests/unit/httpd_test.cc b/tests/unit/httpd_test.cc index 68c5230a4dc..4625fff8dec 100644 --- a/tests/unit/httpd_test.cc +++ b/tests/unit/httpd_test.cc @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -2389,6 +2390,43 @@ BOOST_AUTO_TEST_CASE(test_http_status_classification) { BOOST_REQUIRE_EQUAL(unclassified, 300); } +// An IPv4 client of a `[::]` listener is an IPv4 client to the handler, not the +// ::ffff:a.b.c.d the kernel reports. +SEASTAR_THREAD_TEST_CASE(test_dual_stack_listener_reports_ipv4_client) { + if (!engine().net().supports_ipv6()) { + BOOST_TEST_MESSAGE("No ipv6 support, skipping test"); + return; + } + std::optional observed_client, observed_server; + + listen_options lo; + lo.reuse_address = true; + lo.ipv6_only = false; + lo.set_fixed_cpu(this_shard_id()); + + http_server server("test"); + server._routes.put(GET, "/test", new function_handler([&](const_req req) { + observed_client = req.get_client_address(); + observed_server = req.get_server_address(); + return ""; + }, "txt")); + server.listen(socket_address(ipv6_addr("::", 0)), lo).get(); + auto port = http_server_tester::listeners(server)[0].local_address().port(); + + http::client cln(socket_address(ipv4_addr("127.0.0.1", port))); + cln.make_request(http::request::make("GET", "test", "/test"), [](const http::reply&, input_stream&& in) { + return in.close(); + }, http::reply::status_type::ok).get(); + cln.close().get(); + server.stop().get(); + + BOOST_REQUIRE(observed_client); + BOOST_REQUIRE(observed_client->addr().is_ipv4()); + BOOST_REQUIRE_EQUAL(observed_client->addr(), net::inet_address("127.0.0.1")); + BOOST_REQUIRE(observed_server->addr().is_ipv4()); + BOOST_REQUIRE_EQUAL(observed_server->port(), port); +} + // #2661. Check that trying a http connection with a wire error // can be handled. SEASTAR_THREAD_TEST_CASE(test_http_with_broken_wire) { diff --git a/tests/unit/network_interface_test.cc b/tests/unit/network_interface_test.cc index 6a0f41fd28f..86f354c149a 100644 --- a/tests/unit/network_interface_test.cc +++ b/tests/unit/network_interface_test.cc @@ -108,6 +108,34 @@ SEASTAR_TEST_CASE(is_standard_addresses_sanity) { return make_ready_future<>(); } +SEASTAR_TEST_CASE(test_ipv4_mapped_addresses) { + const net::inet_address mapped("::ffff:127.0.0.1"); + const net::inet_address plain("127.0.0.1"); + const net::inet_address v6("::1"); + + BOOST_REQUIRE(mapped.is_ipv4_mapped()); + BOOST_REQUIRE(!plain.is_ipv4_mapped()); + BOOST_REQUIRE(!v6.is_ipv4_mapped()); + + // unmapped() is the IPv4 twin, and an identity everywhere else + BOOST_REQUIRE(mapped.unmapped().is_ipv4()); + BOOST_REQUIRE_EQUAL(mapped.unmapped(), plain); + BOOST_REQUIRE(mapped.unmapped().is_loopback()); + BOOST_REQUIRE_EQUAL(std::hash()(mapped.unmapped()), std::hash()(plain)); + BOOST_REQUIRE_EQUAL(plain.unmapped(), plain); + BOOST_REQUIRE_EQUAL(v6.unmapped(), v6); + // the mapped form itself is still a distinct IPv6 address + BOOST_REQUIRE_NE(mapped, plain); + BOOST_REQUIRE_EQUAL(fmt::to_string(mapped), "::ffff:127.0.0.1"); + + const socket_address sa(ipv6_addr("::ffff:10.0.0.1", 9092)); + BOOST_REQUIRE_EQUAL(sa.unmapped(), socket_address(ipv4_addr("10.0.0.1", 9092))); + BOOST_REQUIRE_EQUAL(fmt::to_string(sa.unmapped()), "10.0.0.1:9092"); + BOOST_REQUIRE_EQUAL(socket_address(ipv6_addr("::1", 1)).unmapped(), socket_address(ipv6_addr("::1", 1))); + BOOST_REQUIRE_EQUAL(socket_address(ipv4_addr("10.0.0.1", 1)).unmapped(), socket_address(ipv4_addr("10.0.0.1", 1))); + return make_ready_future(); +} + SEASTAR_TEST_CASE(test_inet_address_format) { const std::string tests[] = { // IPv4 addresses From b1fbb45ffcb7564d134054d0f8e0d97547ebb989 Mon Sep 17 00:00:00 2001 From: David Yu Date: Mon, 21 Sep 2026 22:09:11 -0700 Subject: [PATCH 07/16] net: fix the datagram pktinfo control buffer and request IPV6_RECVPKTINFO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The control buffer posix_datagram_channel hands to recvmsg was declared as `struct cmsghdrcmh;` — a forward declaration of a nested type, not a cmsghdr member — so it was 20 bytes where CMSG_SPACE(in_pktinfo) is 28 and CMSG_SPACE(in6_pktinfo) is 36. The kernel truncated every pktinfo message (MSG_CTRUNC, never checked) and receive() then read a whole in_pktinfo past the end of the buffer. On top of that only IP_PKTINFO was ever enabled, so an AF_INET6 socket never saw the IPv6 destination at all, and msg_namelen was neither reset between receives nor copied back into the source address, whose length() stayed at sizeof(sockaddr_storage). Size the buffer with CMSG_SPACE, reset msg_namelen/msg_controllen before each recvmsg, skip cmsg parsing on MSG_CTRUNC, request IPV6_RECVPKTINFO on AF_INET6 sockets (IP_PKTINFO stays for IPv4-mapped traffic) and record the source address length the kernel reports. datagram::get_dst() is now asserted unconditionally in udp_packet_test, and two new cases receive on a wildcard socket of each family and check the destination and source-length the pktinfo message provides. --- src/net/posix-stack.cc | 45 +++++++++++++++++++++-------------------- tests/unit/ipv6_test.cc | 33 +++++++++++++++++++++++++----- 2 files changed, 51 insertions(+), 27 deletions(-) diff --git a/src/net/posix-stack.cc b/src/net/posix-stack.cc index 1dddec67472..948c882cb9e 100644 --- a/src/net/posix-stack.cc +++ b/src/net/posix-stack.cc @@ -951,14 +951,6 @@ posix_ap_network_stack::listen(socket_address sa, listen_options opt) { server_socket(std::make_unique(protocol, sa, _allocator)); } -struct cmsg_with_pktinfo { - struct cmsghdrcmh; - union { - struct in_pktinfo pktinfo; - struct in6_pktinfo pkt6info; - }; -}; - class posix_datagram_channel : public datagram_channel_impl { private: static constexpr int MAX_DATAGRAM_SIZE = 65507; @@ -967,32 +959,32 @@ class posix_datagram_channel : public datagram_channel_impl { struct iovec _iov; socket_address _src_addr; char* _buffer; - cmsg_with_pktinfo _cmsg; + // Room for one pktinfo control message of either family, with its + // header; recvmsg truncates (MSG_CTRUNC) whatever does not fit. + alignas(struct cmsghdr) char _cmsg[CMSG_SPACE(sizeof(struct in6_pktinfo))]; + const bool _use_pktinfo; - recv_ctx(bool use_pktinfo) { + recv_ctx(bool use_pktinfo) : _use_pktinfo(use_pktinfo) { + static_assert(sizeof(_cmsg) >= CMSG_SPACE(sizeof(struct in_pktinfo))); memset(&_hdr, 0, sizeof(_hdr)); _hdr.msg_iov = &_iov; _hdr.msg_iovlen = 1; _hdr.msg_name = &_src_addr.u.sa; - _hdr.msg_namelen = sizeof(_src_addr.u.sas); - - if (use_pktinfo) { - memset(&_cmsg, 0, sizeof(_cmsg)); - _hdr.msg_control = &_cmsg; - _hdr.msg_controllen = sizeof(_cmsg); - } else { - _hdr.msg_control = nullptr; - _hdr.msg_controllen = 0; - } + _hdr.msg_control = use_pktinfo ? _cmsg : nullptr; } recv_ctx(const recv_ctx&) = delete; recv_ctx(recv_ctx&&) = delete; + // recvmsg shrinks msg_namelen and msg_controllen to what it wrote and + // sets msg_flags, so every receive starts from the full sizes. void prepare() { _buffer = new char[MAX_DATAGRAM_SIZE]; _iov.iov_base = _buffer; _iov.iov_len = MAX_DATAGRAM_SIZE; + _hdr.msg_namelen = sizeof(_src_addr.u.sas); + _hdr.msg_controllen = _use_pktinfo ? sizeof(_cmsg) : 0; + _hdr.msg_flags = 0; } }; struct send_ctx { @@ -1028,7 +1020,13 @@ class posix_datagram_channel : public datagram_channel_impl { file_desc fd = file_desc::socket(family, SOCK_DGRAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0); if (is_inet(family)) { - fd.setsockopt(SOL_IP, IP_PKTINFO, true); + // IP_PKTINFO also covers IPv4 datagrams reaching an AF_INET6 socket + // as IPv4-mapped traffic; native IPv6 ones need IPV6_RECVPKTINFO. + fd.setsockopt(SOL_IP, IP_PKTINFO, int(1)); + if (family == AF_INET6) { + // an int, not a bool: the IPv6 layer rejects shorter values with EINVAL + fd.setsockopt(IPPROTO_IPV6, IPV6_RECVPKTINFO, int(1)); + } if (engine().posix_reuseport_available()) { fd.setsockopt(SOL_SOCKET, SO_REUSEPORT, 1); } @@ -1150,8 +1148,11 @@ future posix_datagram_channel::receive() { _recv.prepare(); return _fd.recvmsg(&_recv._hdr).then([this] (size_t size) { + _recv._src_addr.addr_length = _recv._hdr.msg_namelen; std::optional dst; - for (auto* cmsg = CMSG_FIRSTHDR(&_recv._hdr); cmsg != nullptr; cmsg = CMSG_NXTHDR(&_recv._hdr, cmsg)) { + // A truncated control buffer holds no complete message to read. + auto* cmsg = (_recv._hdr.msg_flags & MSG_CTRUNC) ? nullptr : CMSG_FIRSTHDR(&_recv._hdr); + for (; cmsg != nullptr; cmsg = CMSG_NXTHDR(&_recv._hdr, cmsg)) { if (cmsg->cmsg_level == IPPROTO_IP && cmsg->cmsg_type == IP_PKTINFO) { dst = ipv4_addr(copy_reinterpret_cast(CMSG_DATA(cmsg)).ipi_addr, _address.port()); break; diff --git a/tests/unit/ipv6_test.cc b/tests/unit/ipv6_test.cc index d87db4e6d6e..2e1fd3a4a97 100644 --- a/tests/unit/ipv6_test.cc +++ b/tests/unit/ipv6_test.cc @@ -61,15 +61,38 @@ SEASTAR_TEST_CASE(udp_packet_test) { auto a = sc.local_address(); sc.close(); BOOST_REQUIRE_EQUAL(src, pkt.get_src()); - auto dst = pkt.get_dst(); - // Don't always get a dst address. - if (dst != socket_address()) { - BOOST_REQUIRE_EQUAL(a, pkt.get_dst()); - } + BOOST_REQUIRE_EQUAL(pkt.get_src().length(), sizeof(::sockaddr_in6)); + BOOST_REQUIRE_EQUAL(a, pkt.get_dst()); }); }); } +// The destination a datagram arrived on is only interesting for a wildcard +// socket, and it is only known through the pktinfo control message. +static future<> check_datagram_dst(socket_address wildcard, net::inet_address peer, socklen_t addr_len) { + auto server = make_bound_datagram_channel(wildcard); + auto client = make_bound_datagram_channel(socket_address(peer, 0)); + co_await client.send(socket_address(peer, server.local_address().port()), "apa"); + auto pkt = co_await server.receive(); + BOOST_REQUIRE_EQUAL(pkt.get_src(), client.local_address()); + BOOST_REQUIRE_EQUAL(pkt.get_src().length(), addr_len); + BOOST_REQUIRE_EQUAL(pkt.get_dst().addr(), peer); + BOOST_REQUIRE_EQUAL(pkt.get_dst().port(), server.local_address().port()); + client.close(); + server.close(); +} + +SEASTAR_TEST_CASE(udp_wildcard_dst_ipv6_test) { + if (!check_ipv6_support()) { + co_return; + } + co_await check_datagram_dst(ipv6_addr{"::", 0}, net::inet_address("::1"), sizeof(::sockaddr_in6)); +} + +SEASTAR_TEST_CASE(udp_wildcard_dst_ipv4_test) { + co_await check_datagram_dst(ipv4_addr{"0.0.0.0", 0}, net::inet_address("127.0.0.1"), sizeof(::sockaddr_in)); +} + SEASTAR_TEST_CASE(tcp_packet_test) { if (!check_ipv6_support()) { return make_ready_future<>(); From e3835a1e3599ce91fba899be272d346152b2b724 Mon Sep 17 00:00:00 2001 From: David Yu Date: Mon, 21 Sep 2026 22:09:11 -0700 Subject: [PATCH 08/16] net: keep IPv6 zone 0 and the invalid_scope marker apart inet_address::invalid_scope (0xffffffff) is an in-memory "no zone" marker, but socket_address wrote it straight into sin6_scope_id, and addr() copied the kernel's sin6_scope_id (0 for the common no-zone case) straight back into _scope. Every accepted IPv6 peer therefore printed as [addr%0]:port and reported scope() == 0, while connect()/sendto() were handed a bogus zone for link-local destinations. Translate at the boundary: write 0 for invalid_scope, read 0 as invalid_scope, and key the link-local zone lookup in resolve_outgoing_address on sin6_scope_id == 0. Also fix the inet_pton result check in the /proc/net/ipv6_route parser (0 means 'not an address', not < 0), pin the sin_port/sin6_port aliasing that port() relies on with a static_assert, and document that socket_address equality deliberately ignores the zone. --- src/net/inet_address.cc | 9 +++++++-- src/net/posix-stack.cc | 6 ++++-- src/net/socket_address.cc | 6 +++++- tests/unit/network_interface_test.cc | 30 ++++++++++++++++++++++++++++ 4 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/net/inet_address.cc b/src/net/inet_address.cc index c0d4d291568..f0146464bad 100644 --- a/src/net/inet_address.cc +++ b/src/net/inet_address.cc @@ -329,13 +329,18 @@ seastar::net::inet_address seastar::socket_address::addr() const noexcept { switch (family()) { case AF_INET: return net::inet_address(as_posix_sockaddr_in().sin_addr); - case AF_INET6: - return net::inet_address(as_posix_sockaddr_in6().sin6_addr, as_posix_sockaddr_in6().sin6_scope_id); + case AF_INET6: { + auto& in6 = as_posix_sockaddr_in6(); + return net::inet_address(in6.sin6_addr, in6.sin6_scope_id ? in6.sin6_scope_id : net::inet_address::invalid_scope); + } default: return net::inet_address(); } } +// The port is read through the sockaddr_in view for every family. +static_assert(offsetof(::sockaddr_in, sin_port) == offsetof(::sockaddr_in6, sin6_port)); + ::in_port_t seastar::socket_address::port() const noexcept { return net::ntoh(u.in.sin_port); } diff --git a/src/net/posix-stack.cc b/src/net/posix-stack.cc index 948c882cb9e..57f3aefba78 100644 --- a/src/net/posix-stack.cc +++ b/src/net/posix-stack.cc @@ -357,9 +357,11 @@ class posix_proxied_connected_socket_impl final : public posix_connected_socket_ } }; +// A link-local destination without a zone cannot be routed; pick the zone of +// the interface whose route covers it. static void resolve_outgoing_address(socket_address& a) { if (a.family() != AF_INET6 - || a.as_posix_sockaddr_in6().sin6_scope_id != inet_address::invalid_scope + || a.as_posix_sockaddr_in6().sin6_scope_id != 0 || !IN6_IS_ADDR_LINKLOCAL(&a.as_posix_sockaddr_in6().sin6_addr) ) { return; @@ -433,7 +435,7 @@ static void resolve_outgoing_address(socket_address& a) { dest_str[39] = '\0'; struct in6_addr addr; - if (inet_pton(AF_INET6, dest_str, &addr) < 0) { + if (inet_pton(AF_INET6, dest_str, &addr) != 1) { /* not an Ipv6 address */ continue; } diff --git a/src/net/socket_address.cc b/src/net/socket_address.cc index 88628b0f574..c50b68cf3e0 100644 --- a/src/net/socket_address.cc +++ b/src/net/socket_address.cc @@ -75,7 +75,9 @@ socket_address::socket_address(const ipv6_addr& addr, uint32_t scope) noexcept u.in6.sin6_family = AF_INET6; u.in6.sin6_port = htons(addr.port); u.in6.sin6_flowinfo = 0; - u.in6.sin6_scope_id = scope; + // The kernel's "no zone" is 0; inet_address::invalid_scope is only a + // marker for the in-memory type. + u.in6.sin6_scope_id = scope == net::inet_address::invalid_scope ? 0 : scope; std::copy(addr.ip.begin(), addr.ip.end(), u.in6.sin6_addr.s6_addr); } @@ -151,6 +153,8 @@ bool socket_address::operator==(const socket_address& a) const noexcept { auto& in1 = as_posix_sockaddr_in6(); auto& in2 = a.as_posix_sockaddr_in6(); + // sin6_scope_id is deliberately not compared (std::hash agrees), so a + // literal parsed with %zone equals the same address without one. return IN6_ARE_ADDR_EQUAL(&in1.sin6_addr, &in2.sin6_addr); } diff --git a/tests/unit/network_interface_test.cc b/tests/unit/network_interface_test.cc index 86f354c149a..b3881386b32 100644 --- a/tests/unit/network_interface_test.cc +++ b/tests/unit/network_interface_test.cc @@ -28,6 +28,8 @@ #include #include +#include + using namespace seastar; static logger niflog("network_interface_test"); @@ -136,6 +138,34 @@ SEASTAR_TEST_CASE(test_ipv4_mapped_addresses) { return make_ready_future(); } +// Zone index 0 is the kernel's "no zone": it must not surface as a scope or +// print as %0, and the in-memory "no scope" marker must not reach the kernel. +SEASTAR_TEST_CASE(test_ipv6_scope_round_trip) { + ::sockaddr_in6 in6{}; + in6.sin6_family = AF_INET6; + in6.sin6_port = net::hton(uint16_t(9092)); + BOOST_REQUIRE_EQUAL(::inet_pton(AF_INET6, "2001:db8::1", &in6.sin6_addr), 1); + const socket_address from_kernel(in6); + BOOST_REQUIRE_EQUAL(from_kernel.addr().scope(), net::inet_address::invalid_scope); + BOOST_REQUIRE_EQUAL(fmt::to_string(from_kernel), "[2001:db8::1]:9092"); + BOOST_REQUIRE_EQUAL(fmt::to_string(from_kernel.addr()), "2001:db8::1"); + + const socket_address unscoped(ipv6_addr("2001:db8::1", 9092)); + BOOST_REQUIRE_EQUAL(unscoped.as_posix_sockaddr_in6().sin6_scope_id, 0u); + BOOST_REQUIRE_EQUAL(socket_address(net::inet_address("2001:db8::1"), 9092).as_posix_sockaddr_in6().sin6_scope_id, 0u); + BOOST_REQUIRE_EQUAL(unscoped, from_kernel); + + // a real zone survives in both directions + if (auto lo = if_nametoindex("lo")) { + const socket_address scoped(ipv6_addr("fe80::1", 1), lo); + BOOST_REQUIRE_EQUAL(scoped.as_posix_sockaddr_in6().sin6_scope_id, lo); + BOOST_REQUIRE_EQUAL(scoped.addr().scope(), lo); + BOOST_REQUIRE_EQUAL(fmt::to_string(scoped), fmt::format("[fe80::1%{}]:1", lo)); + BOOST_REQUIRE_EQUAL(socket_address(net::inet_address(fmt::format("fe80::1%{}", lo)), 1), scoped); + } + return make_ready_future(); +} + SEASTAR_TEST_CASE(test_inet_address_format) { const std::string tests[] = { // IPv4 addresses From f4a5233b0f7cf209ba341bc81d7ac8649c3c2646 Mon Sep 17 00:00:00 2001 From: David Yu Date: Mon, 21 Sep 2026 22:09:11 -0700 Subject: [PATCH 09/16] net: parse IPv6 literals strictly: zones must exist or be numeric, ports must fit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit inet_address::parse_numerical dropped a %zone that matched no interface on the box, leaving the literal silently unscoped, and accepted a zone on an IPv4 literal. Follow RFC 4007 §11 instead: an interface name that matches nothing makes the literal invalid, and a numeric zone is kept as given so literals written for another host survive. ipv6_addr(const std::string&) converted the port with std::stoul and a bare uint16_t cast, so "[::1]:70000" became port 4464. Parse the bracketed form explicitly, range-check the port and reject stray text after the bracket; an unbracketed string is the address alone ("::1:9092" is a valid address, so it cannot carry a port). Zoned literals still throw: ipv6_addr has no field for a zone, which the header now says. --- include/seastar/net/socket_defs.hh | 3 +- src/net/inet_address.cc | 72 ++++++++++++++++++---------- tests/unit/network_interface_test.cc | 32 +++++++++++++ 3 files changed, 80 insertions(+), 27 deletions(-) diff --git a/include/seastar/net/socket_defs.hh b/include/seastar/net/socket_defs.hh index 3ac6dd76ffb..86d288ea4d8 100644 --- a/include/seastar/net/socket_defs.hh +++ b/include/seastar/net/socket_defs.hh @@ -139,7 +139,8 @@ struct ipv6_addr { ipv6_addr(const ipv6_bytes&, uint16_t port = 0) noexcept; ipv6_addr(uint16_t port = 0) noexcept; - // throws if not a valid ipv6 addr + // "addr" or "[addr]:port". A zoned literal (fe80::1%eth0) has no + // representation here: use net::inet_address. ipv6_addr(const std::string&); ipv6_addr(const std::string&, uint16_t port); ipv6_addr(const net::inet_address&, uint16_t = 0) noexcept; diff --git a/src/net/inet_address.cc b/src/net/inet_address.cc index f0146464bad..0515b8404af 100644 --- a/src/net/inet_address.cc +++ b/src/net/inet_address.cc @@ -29,6 +29,8 @@ #include #include #include + +#include #include #include #include @@ -70,32 +72,36 @@ seastar::net::inet_address::parse_numerical(const sstring& addr) { in._in_family = family::INET; return in; } + if (::inet_pton(AF_INET6, addr.c_str(), &in._in6)) { + in._in_family = family::INET6; + return in; + } + // "%" (RFC 4007). An interface name that matches nothing makes + // the literal invalid; a numeric zone is kept as given, so a literal written + // for another host survives. auto i = addr.find_last_of('%'); - if (i != sstring::npos) { - auto ext = addr.substr(i + 1); - auto src = addr.substr(0, i); - auto res = parse_numerical(src); - - if (res) { - uint32_t index = std::numeric_limits::max(); - try { - index = std::stoul(ext); - } catch (...) { - } - for (auto& nwif : engine().net().network_interfaces()) { - if (nwif.index() == index || nwif.name() == ext || nwif.display_name() == ext) { - res->_scope = nwif.index(); - break; - } - } + if (i == sstring::npos) { + return {}; + } + auto ext = addr.substr(i + 1); + auto res = parse_numerical(addr.substr(0, i)); + if (!res || !res->is_ipv6() || ext.empty()) { + return {}; + } + uint32_t index = invalid_scope; + auto [end, ec] = std::from_chars(ext.data(), ext.data() + ext.size(), index); + bool numeric = ec == std::errc() && end == ext.data() + ext.size(); + for (auto& nwif : engine().net().network_interfaces()) { + if ((numeric && nwif.index() == index) || nwif.name() == ext || nwif.display_name() == ext) { + res->_scope = nwif.index(); return *res; } } - if (::inet_pton(AF_INET6, addr.c_str(), &in._in6)) { - in._in_family = family::INET6; - return in; + if (!numeric) { + return {}; } - return {}; + res->_scope = index; + return *res; } seastar::net::inet_address::inet_address(const sstring& addr) @@ -296,11 +302,25 @@ seastar::ipv6_addr::ipv6_addr(const ::in6_addr& in6, uint16_t p) noexcept seastar::ipv6_addr::ipv6_addr(const std::string& s) : ipv6_addr([&] { - auto lc = s.find_last_of(']'); - auto cp = s.find_first_of(':', lc); - auto port = cp != std::string::npos ? std::stoul(s.substr(cp + 1)) : 0; - auto ss = lc != std::string::npos ? s.substr(1, lc - 1) : s; - return ipv6_addr(net::ipv6_address(ss).bytes(), uint16_t(port)); + // "::1:9092" is a valid address, so only a bracketed host can carry + // a port. + if (s.empty() || s.front() != '[') { + return ipv6_addr(net::ipv6_address(s).bytes(), 0); + } + auto rb = s.find(']'); + if (rb == std::string::npos) { + throw std::invalid_argument(fmt::format("Unterminated bracket in IPv6 address {}", s)); + } + uint16_t port = 0; + if (rb + 1 < s.size()) { + auto* first = s.data() + rb + 1; + auto* last = s.data() + s.size(); + auto [end, ec] = *first == ':' ? std::from_chars(first + 1, last, port) : std::from_chars_result{first, std::errc::invalid_argument}; + if (ec != std::errc() || end != last) { + throw std::invalid_argument(fmt::format("Invalid port in IPv6 address {}", s)); + } + } + return ipv6_addr(net::ipv6_address(s.substr(1, rb - 1)).bytes(), port); }()) {} diff --git a/tests/unit/network_interface_test.cc b/tests/unit/network_interface_test.cc index b3881386b32..2ab63387d0a 100644 --- a/tests/unit/network_interface_test.cc +++ b/tests/unit/network_interface_test.cc @@ -166,6 +166,34 @@ SEASTAR_TEST_CASE(test_ipv6_scope_round_trip) { return make_ready_future(); } +// RFC 4007 §11: a numeric zone is an interface index and is kept as given. +SEASTAR_TEST_CASE(test_inet_address_numeric_zone) { + const net::inet_address foreign("fe80::1%4000000"); + BOOST_REQUIRE_EQUAL(foreign.scope(), 4000000u); + BOOST_REQUIRE_EQUAL(fmt::to_string(foreign), "fe80::1%4000000"); + BOOST_REQUIRE(!net::inet_address::parse_numerical("fe80::1%no-such-interface")); + if (auto lo = if_nametoindex("lo")) { + BOOST_REQUIRE_EQUAL(net::inet_address("fe80::1%lo").scope(), lo); + BOOST_REQUIRE_EQUAL(net::inet_address(fmt::format("fe80::1%{}", lo)).scope(), lo); + } + return make_ready_future(); +} + +SEASTAR_TEST_CASE(test_ipv6_addr_string_forms) { + BOOST_REQUIRE_EQUAL(ipv6_addr("::1").port, 0); + BOOST_REQUIRE_EQUAL(ipv6_addr("[::1]").port, 0); + BOOST_REQUIRE_EQUAL(ipv6_addr("[::1]:9092").port, 9092); + BOOST_REQUIRE_EQUAL(socket_address(ipv6_addr("[::1]:9092")), socket_address(ipv6_addr("::1", 9092))); + // an unbracketed trailing group is part of the address, not a port + BOOST_REQUIRE_EQUAL(socket_address(ipv6_addr("::1:9092")), socket_address(ipv6_addr("0:0:0:0:0:0:1:9092", 0))); + + for (auto bad : {"[::1]:70000", "[::1]:-1", "[::1]:x", "[::1]9092", "[::1]:", "[::1"}) { + BOOST_CHECK_THROW(ipv6_addr{bad}, std::invalid_argument); + } + BOOST_CHECK_THROW(ipv6_addr("[fe80::1%lo]:9092"), std::runtime_error); + return make_ready_future(); +} + SEASTAR_TEST_CASE(test_inet_address_format) { const std::string tests[] = { // IPv4 addresses @@ -213,6 +241,10 @@ SEASTAR_TEST_CASE(test_inet_address_parse_invalid) { "fe80:2030:12345", "fe80:2030:12345%%", ":", + // zones belong to IPv6 link-local addresses, and must name something + "127.0.0.1%lo", + "fe80::1%", + "fe80::1%no-such-interface", }; for (auto s : tests) { From c050fda5c90876d25dd538542077fae2d65fec4b Mon Sep 17 00:00:00 2001 From: David Yu Date: Mon, 21 Sep 2026 22:11:27 -0700 Subject: [PATCH 10/16] tls: do not send IP literals in the SNI extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tls_options::server_name is the name verification checks the certificate against, and callers that connect by address pass the address. Both backends copied it verbatim into the server_name extension, which RFC 6066 §3 forbids for IPv4 and IPv6 literals; servers that validate the extension reject the ClientHello. Skip the extension for a literal (bracketed or not) in both the OpenSSL and the GnuTLS backend. Verification is unchanged: the literal is still matched against the certificate's IP SANs. The GnuTLS call's return value is now checked instead of dropped. Tested by a plain socket that reads the ClientHello a client sends and looks for the extension: DNS names appear, IPv4/IPv6 literals do not. --- src/net/tls-impl.hh | 23 +++++++++++++ src/net/tls_gnutls.cc | 7 ++-- src/net/tls_openssl.cc | 2 +- tests/unit/tls_test.cc | 73 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 102 insertions(+), 3 deletions(-) diff --git a/src/net/tls-impl.hh b/src/net/tls-impl.hh index eb3ba9ea9eb..c46be8e2fc1 100644 --- a/src/net/tls-impl.hh +++ b/src/net/tls-impl.hh @@ -34,6 +34,7 @@ #include #include #include +#include namespace seastar { @@ -117,6 +118,28 @@ void visit_blobs(Blobs& blobs, Visitor&& visitor) { namespace seastar::tls { +// The host part of an authority: "[::1]" -> "::1", anything else unchanged. +inline std::string_view unbracket(std::string_view name) { + if (name.size() > 2 && name.front() == '[' && name.back() == ']') { + name.remove_prefix(1); + name.remove_suffix(1); + } + return name; +} + +// Callers connecting by address pass the address as server_name, and RFC 6066 +// §3 forbids an IP literal in the SNI extension (verification still uses it). +inline bool is_ip_literal(std::string_view name) { + return net::inet_address::parse_numerical(sstring(unbracket(name))).has_value(); +} + +// The identity to check the certificate against: server_name without brackets +// or a zone, neither of which appears in a certificate. +inline sstring verification_name(std::string_view server_name) { + auto name = unbracket(server_name); + return sstring(name.substr(0, name.find('%'))); +} + /// Abstract interface for DH parameters. /// /// GnuTLS has a full implementation; OpenSSL's is a stub. diff --git a/src/net/tls_gnutls.cc b/src/net/tls_gnutls.cc index ae516ab5a17..245713eb154 100644 --- a/src/net/tls_gnutls.cc +++ b/src/net/tls_gnutls.cc @@ -590,8 +590,11 @@ class session : public enable_shared_from_this, public tls::session_imp } future<> do_handshake_invoke(int (*func)(gnutls_session_t)) { - if (_type == type::CLIENT && !_options.server_name.empty()) { - gnutls_server_name_set(*this, GNUTLS_NAME_DNS, _options.server_name.data(), _options.server_name.size()); + if (_type == type::CLIENT && !_options.server_name.empty() && !is_ip_literal(_options.server_name)) { + auto res = gnutls_server_name_set(*this, GNUTLS_NAME_DNS, _options.server_name.data(), _options.server_name.size()); + if (res < 0) { + return make_exception_future<>(std::system_error(res, local_error_category())); + } } try { auto res = func(*this); diff --git a/src/net/tls_openssl.cc b/src/net/tls_openssl.cc index d18f96bde5e..5b7a77fd650 100644 --- a/src/net/tls_openssl.cc +++ b/src/net/tls_openssl.cc @@ -842,7 +842,7 @@ class openssl_session : public enable_shared_from_this, public if (_type == session_type::SERVER) { SSL_set_accept_state(_ssl.get()); } else { - if (!_options.server_name.empty()) { + if (!_options.server_name.empty() && !is_ip_literal(_options.server_name)) { SSL_set_tlsext_host_name( _ssl.get(), _options.server_name.c_str()); } diff --git a/tests/unit/tls_test.cc b/tests/unit/tls_test.cc index 90cab4bc54d..31a1a0bc478 100644 --- a/tests/unit/tls_test.cc +++ b/tests/unit/tls_test.cc @@ -445,6 +445,79 @@ SEASTAR_TEST_CASE(test_failed_connect) { return connect_to_ssl_addr(b.build_certificate_credentials(), ipv4_addr()).handle_exception([](auto) {}); } +// The server_name extension of the ClientHello sent on `s`, read off the +// wire by a server that never answers. +static future> read_client_hello_sni(connected_socket s) { + auto in = s.input(); + auto record = co_await in.read_exactly(5); + BOOST_REQUIRE_EQUAL(record.size(), 5u); + BOOST_REQUIRE_EQUAL(uint8_t(record[0]), 0x16u); // handshake record + auto body = co_await in.read_exactly((uint8_t(record[3]) << 8) | uint8_t(record[4])); + co_await in.close(); + + auto* p = reinterpret_cast(body.get()); + auto* end = p + body.size(); + auto take = [&](size_t n) { BOOST_REQUIRE_GE(size_t(end - p), n); auto* r = p; p += n; return r; }; + auto u8 = [&] { return size_t(*take(1)); }; + auto u16 = [&] { auto* q = take(2); return size_t((q[0] << 8) | q[1]); }; + BOOST_REQUIRE_EQUAL(u8(), 1u); // ClientHello + take(3); // length + take(2); // legacy_version + take(32); // random + take(u8()); // session id + take(u16()); // cipher suites + take(u8()); // compression methods + if (p == end) { + co_return std::nullopt; + } + auto* extensions_end = p + u16(); + while (p < extensions_end) { + auto type = u16(); + auto length = u16(); + auto* ext = take(length); + if (type == 0) { // server_name + BOOST_REQUIRE_GE(length, 5u); + BOOST_REQUIRE_EQUAL(ext[2], 0); // host_name + size_t name_length = (ext[3] << 8) | ext[4]; + co_return sstring(reinterpret_cast(ext + 5), name_length); + } + } + co_return std::nullopt; +} + +// RFC 6066 §3: an IP literal is not a valid SNI host_name, so a client that +// connects by address must not send one (RFC 6066 §3). A DNS name still goes out. +SEASTAR_THREAD_TEST_CASE(test_sni_omits_ip_literals) { + ::listen_options opts; + opts.reuse_address = true; + opts.set_fixed_cpu(this_shard_id()); + auto server = seastar::listen(socket_address(ipv4_addr("127.0.0.1", 0)), opts); + auto addr = server.local_address(); + auto creds = tls::credentials_builder().build_certificate_credentials(); + + struct { sstring server_name; std::optional sni; } cases[] = { + {"localhost", "localhost"}, + {"test.scylladb.org", "test.scylladb.org"}, + {"127.0.0.1", std::nullopt}, + {"::1", std::nullopt}, + {"[::1]", std::nullopt}, + {"2001:db8::1", std::nullopt}, + }; + for (auto& c : cases) { + auto accepted = server.accept(); + auto client = tls::connect(creds, addr, tls::tls_options{.server_name = c.server_name}).get(); + // the handshake, and with it the ClientHello, starts on first use + auto out = client.output(); + auto sent = out.write("x").then([&out] { return out.flush(); }); + auto sni = read_client_hello_sni(std::move(accepted.get().connection)).get(); + BOOST_REQUIRE_MESSAGE(sni == c.sni, fmt::format("server_name {:?}: SNI sent = {:?}", c.server_name, sni.value_or(""))); + // the server hung up mid-handshake; that failure is not under test + std::move(sent).then_wrapped([](future<> f) { f.ignore_ready_future(); }).get(); + out.close().then_wrapped([](future<> f) { f.ignore_ready_future(); }).get(); + } + server.abort_accept(); +} + SEASTAR_TEST_CASE(test_non_tls) { ::listen_options opts; opts.reuse_address = true; From 787387aa620ec3d998cc9f0ce62078f712df9473 Mon Sep 17 00:00:00 2001 From: David Yu Date: Mon, 21 Sep 2026 22:18:21 -0700 Subject: [PATCH 11/16] tls: verify the peer certificate against server_name on OpenSSL, opt-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GnuTLS backend passes server_name to gnutls_certificate_verify_peers3, so a certificate that chains to a trusted CA but is issued for a different host (or a different IP SAN) fails verification. The OpenSSL backend only ever checked the chain: SSL_VERIFY_PEER with no expected host, so any trusted certificate was accepted for any server_name. Add tls_options::verify_server_name. On OpenSSL it registers the expected identity on the session's X509_VERIFY_PARAM — an IP literal (brackets and zone stripped) via X509_VERIFY_PARAM_set1_ip_asc, a DNS name via SSL_set1_host without partial wildcards — so the mismatch surfaces through SSL_get_verify_result and the existing verify() path. It defaults to false: clients that connect by address to servers whose certificates carry no IP SAN keep working until they opt in. GnuTLS keeps its always-on check; the header documents the difference. The test certificate gains an IP SAN for ::1 next to 127.0.0.1, and test_alt_names asserts both values. New cases run the echo test with the flag set: matching DNS name and IP literals (127.0.0.1, ::1, [::1]) pass, a wrong DNS name and wrong addresses raise verification_error. --- include/seastar/net/tls.hh | 12 +++++- src/net/tls_gnutls.cc | 5 ++- src/net/tls_openssl.cc | 20 ++++++++++ tests/unit/CMakeLists.txt | 3 ++ tests/unit/cert.cfg.in | 2 +- tests/unit/tls_test.cc | 78 +++++++++++++++++++++++++++++++++++--- 6 files changed, 110 insertions(+), 10 deletions(-) diff --git a/include/seastar/net/tls.hh b/include/seastar/net/tls.hh index ed3e6f75657..c402e2112e9 100644 --- a/include/seastar/net/tls.hh +++ b/include/seastar/net/tls.hh @@ -445,13 +445,23 @@ namespace tls { public: /// \brief whether to wait for EOF from server on session termination deprecated_wait_for_eof_on_shutdown wait_for_eof_on_shutdown; - /// \brief server name to be used for the SNI TLS extension + /// \brief the server being connected to: a DNS name, or an IP literal + /// (brackets allowed) which is not sent as SNI (RFC 6066 §3). Also the + /// name verification checks, see verify_server_name. sstring server_name = {}; /// \brief whether server certificate should be verified. May be set to false /// in test environments. bool verify_certificate = true; + /// \brief whether the peer certificate must be issued for server_name + /// (a DNS SAN for names, an IP SAN for literals) as well as chaining to + /// a trusted CA. GnuTLS always checks this when server_name is set; + /// OpenSSL only when this is true. Off by default, so clients + /// connecting by address to certificates without an IP SAN keep + /// working until they opt in. + bool verify_server_name = false; + /// \brief Optional session resume data. Must be retrieved via /// get_session_resume_data below. session_data session_resume_data; diff --git a/src/net/tls_gnutls.cc b/src/net/tls_gnutls.cc index 245713eb154..1564bf0d1a2 100644 --- a/src/net/tls_gnutls.cc +++ b/src/net/tls_gnutls.cc @@ -750,8 +750,9 @@ class session : public enable_shared_from_this, public tls::session_imp } unsigned int status; - auto res = gnutls_certificate_verify_peers3(*this, _type != type::CLIENT || _options.server_name.empty() - ? nullptr : _options.server_name.c_str(), &status); + auto name = verification_name(_options.server_name); + auto res = gnutls_certificate_verify_peers3(*this, _type != type::CLIENT || name.empty() + ? nullptr : name.c_str(), &status); if (res == GNUTLS_E_NO_CERTIFICATE_FOUND && _type != type::CLIENT && _creds->get_client_auth() != client_auth::REQUIRE) { return; } diff --git a/src/net/tls_openssl.cc b/src/net/tls_openssl.cc index 5b7a77fd650..8954d3a4098 100644 --- a/src/net/tls_openssl.cc +++ b/src/net/tls_openssl.cc @@ -846,6 +846,9 @@ class openssl_session : public enable_shared_from_this, public SSL_set_tlsext_host_name( _ssl.get(), _options.server_name.c_str()); } + if (_options.verify_server_name && !_options.server_name.empty()) { + expect_peer_name(_options.server_name); + } SSL_set_connect_state(_ssl.get()); } @@ -868,6 +871,23 @@ class openssl_session : public enable_shared_from_this, public tls_options options = {}) : openssl_session(t, std::move(creds), net::get_impl::get(std::move(sock)), options) {} + // OpenSSL then reports a mismatch through SSL_get_verify_result, so + // verify() sees it like any other chain error. + void expect_peer_name(std::string_view name) { + auto host = verification_name(name); + auto* param = SSL_get0_param(_ssl.get()); + if (is_ip_literal(host)) { + if (1 != X509_VERIFY_PARAM_set1_ip_asc(param, host.c_str())) { + throw make_openssl_error("Failed to set the expected peer address"); + } + return; + } + X509_VERIFY_PARAM_set_hostflags(param, X509_CHECK_FLAG_NO_PARTIAL_WILDCARDS); + if (1 != SSL_set1_host(_ssl.get(), host.c_str())) { + throw make_openssl_error("Failed to set the expected peer name"); + } + } + ~openssl_session() { SEASTAR_ASSERT(_output_pending.available()); } diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index 061780beafc..19c2d5f6717 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -632,6 +632,9 @@ function(seastar_add_certgen name) if (NOT CERT_ALT_IP_1) set(CERT_ALT_IP_1 127.0.0.1) endif() + if (NOT CERT_ALT_IP_2) + set(CERT_ALT_IP_2 ::1) + endif() if (NOT CERT_ALT_DNS) set(CERT_ALT_DNS ${CERT_COMMON}) endif() diff --git a/tests/unit/cert.cfg.in b/tests/unit/cert.cfg.in index f4b894418d1..e48768bb894 100644 --- a/tests/unit/cert.cfg.in +++ b/tests/unit/cert.cfg.in @@ -23,4 +23,4 @@ basicConstraints = CA:FALSE keyUsage = nonRepudiation, digitalSignature, keyEncipherment [req_ext] -subjectAltName=email:@CERT_ALT_EMAIL_1@,email:@CERT_ALT_EMAIL_2@,IP:@CERT_ALT_IP_1@,DNS:@CERT_ALT_DNS@ +subjectAltName=email:@CERT_ALT_EMAIL_1@,email:@CERT_ALT_EMAIL_2@,IP:@CERT_ALT_IP_1@,IP:@CERT_ALT_IP_2@,DNS:@CERT_ALT_DNS@ diff --git a/tests/unit/tls_test.cc b/tests/unit/tls_test.cc index 31a1a0bc478..2d924160a34 100644 --- a/tests/unit/tls_test.cc +++ b/tests/unit/tls_test.cc @@ -745,9 +745,10 @@ static future<> echo_client_session(::shared_ptr msg, socket_address addr, const sstring& name, int loops, - bool do_read) + bool do_read, + bool verify_server_name = false) { - auto s = co_await tls::connect(certs, addr, tls::tls_options{.server_name = name}); + auto s = co_await tls::connect(certs, addr, tls::tls_options{.server_name = name, .verify_server_name = verify_server_name}); auto strms = ::make_lw_shared(std::move(s)); auto echo = [strms, msg, loops]() -> future<> { @@ -779,7 +780,9 @@ static future<> run_echo_test(sstring message, sstring client_key = {}, bool do_read = true, bool use_dh_params = true, - tls::dn_callback distinguished_name_callback = {} + tls::dn_callback distinguished_name_callback = {}, + bool verify_server_name = false, + std::optional listen_addr = {} ) { static const auto port = 4711; @@ -787,7 +790,7 @@ static future<> run_echo_test(sstring message, auto msg = ::make_shared(std::move(message)); auto certs = ::make_shared(); auto server = ::make_shared>(); - auto addr = ::make_ipv4_address( {0x7f000001, port}); + auto addr = listen_addr.value_or(::make_ipv4_address( {0x7f000001, port})); SEASTAR_ASSERT(do_read || loops == 1); @@ -806,7 +809,7 @@ static future<> run_echo_test(sstring message, server_trust = trust; } co_await server->invoke_on_all(&echoserver::listen, addr, crt, key, ca, server_trust); - co_await echo_client_session(msg, certs, addr, name, loops, do_read); + co_await echo_client_session(msg, certs, addr, name, loops, do_read, verify_server_name); }().finally([server] { return server->stop(); }); @@ -878,6 +881,58 @@ SEASTAR_TEST_CASE(test_x509_client_server_cert_validation_fail_name) { }); } +// run_echo_test against the local server with verify_server_name set; +// test.crt is issued for test.scylladb.org with IP SANs 127.0.0.1 and ::1. +static future<> run_verified_echo_test(sstring name, std::optional addr = {}) { + return run_echo_test(message, 1, certfile("catest.pem"), std::move(name), certfile("test.crt"), certfile("test.key"), + tls::client_auth::NONE, {}, {}, true, true, {}, true, std::move(addr)); +} + +static future<> expect_verification_error(future<> f) { + return f.then([] { + BOOST_FAIL("Should have gotten validation error"); + }).handle_exception([](auto ep) { + try { + std::rethrow_exception(ep); + } catch (tls::verification_error&) { + // ok. + } catch (...) { + BOOST_FAIL(fmt::format("Unexpected exception: {}", std::current_exception())); + } + }); +} + +SEASTAR_TEST_CASE(test_verify_server_name_dns_match) { + return run_verified_echo_test("test.scylladb.org"); +} + +SEASTAR_TEST_CASE(test_verify_server_name_dns_mismatch) { + // trusted CA, wrong name: the only thing standing between the client and + // this server is the name check + return expect_verification_error(run_verified_echo_test("nils.holgersson.gov")); +} + +SEASTAR_TEST_CASE(test_verify_server_name_ip_san_ipv4) { + return run_verified_echo_test("127.0.0.1"); +} + +SEASTAR_TEST_CASE(test_verify_server_name_ip_san_mismatch) { + return expect_verification_error(run_verified_echo_test("127.0.0.2")); +} + +SEASTAR_TEST_CASE(test_verify_server_name_ip_san_ipv6) { + if (!engine().net().supports_ipv6()) { + BOOST_TEST_MESSAGE("No ipv6 support, skipping test"); + return make_ready_future<>(); + } + auto addr = socket_address(ipv6_addr("::1", 4711)); + return run_verified_echo_test("::1", addr).then([addr] { + return run_verified_echo_test("[::1]", addr); + }).then([addr] { + return expect_verification_error(run_verified_echo_test("::2", addr)); + }); +} + SEASTAR_TEST_CASE(test_large_message_x509_client_server) { // Make sure we load our own auth trust pem file, otherwise our certs // will not validate @@ -1621,9 +1676,20 @@ SEASTAR_THREAD_TEST_CASE(test_alt_names) { BOOST_FAIL("Missing " + std::to_string(min_count) + " alt name attributes of type " + std::to_string(int(type))); }; - ensure_alt_name(tls::subject_alt_name_type::ipaddress, 1); + ensure_alt_name(tls::subject_alt_name_type::ipaddress, 2); ensure_alt_name(tls::subject_alt_name_type::rfc822name, 2); ensure_alt_name(tls::subject_alt_name_type::dnsname, 1); + + // and the IP SANs come back as addresses of the right family + std::vector ips; + for (auto& v : alt_names) { + if (v.type == tls::subject_alt_name_type::ipaddress) { + ips.push_back(std::get(v.value)); + } + } + BOOST_REQUIRE_EQUAL(ips.size(), 2u); + BOOST_REQUIRE(std::ranges::find(ips, net::inet_address("127.0.0.1")) != ips.end()); + BOOST_REQUIRE(std::ranges::find(ips, net::inet_address("::1")) != ips.end()); } } From ce560b37997235ccd1d77189acd37fb4da6cd389 Mon Sep 17 00:00:00 2001 From: David Yu Date: Mon, 21 Sep 2026 22:39:42 -0700 Subject: [PATCH 12/16] tests: fail instead of skip when SEASTAR_TEST_REQUIRE_IPV6 is set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every IPv6 test gates on engine().net().supports_ipv6() and skips with a log line when it is false. supports_ipv6() is a probe that swallows every exception, so a regression in IPv6 socket setup turns the whole IPv6 part of the suite into silent passes — which is exactly what happened while developing the previous commits: an int-sized socket option passed as a bool made the probe fail and hid the breakage behind green runs. Add tests/unit/ipv6_support.hh with ipv6_available_or_skip(): the same skip by default, a failure when SEASTAR_TEST_REQUIRE_IPV6 is set in the environment, for CI and containers that are known to have IPv6. Use it at every gate in ipv6_test, dns_test, httpd_test and tls_test. --- tests/unit/dns_test.cc | 14 +++++------- tests/unit/httpd_test.cc | 4 ++-- tests/unit/ipv6_support.hh | 47 ++++++++++++++++++++++++++++++++++++++ tests/unit/ipv6_test.cc | 10 +++----- tests/unit/tls_test.cc | 4 ++-- 5 files changed, 60 insertions(+), 19 deletions(-) create mode 100644 tests/unit/ipv6_support.hh diff --git a/tests/unit/dns_test.cc b/tests/unit/dns_test.cc index 9b6bc9476da..ff92e3ee993 100644 --- a/tests/unit/dns_test.cc +++ b/tests/unit/dns_test.cc @@ -36,6 +36,8 @@ #include +#include "ipv6_support.hh" + using namespace seastar; using namespace seastar::net; @@ -336,8 +338,7 @@ SEASTAR_TEST_CASE(test_resolve_tcp_split_response) { // asks for is still an A record: nameserver transport family and answer // family are independent, and only the former was broken. SEASTAR_TEST_CASE(test_resolve_udp_ipv6_nameserver) { - if (!engine().net().supports_ipv6()) { - BOOST_TEST_MESSAGE("No ipv6 support, skipping test"); + if (!seastar::testing::ipv6_available_or_skip()) { co_return; } @@ -421,8 +422,7 @@ static future ask_ipv6_udp_nameserver(size_t n_queries, Query query) { // The answer side of IPv6: an AAAA record has to survive make_hostent. SEASTAR_TEST_CASE(test_resolve_aaaa_from_ipv6_nameserver) { - if (!engine().net().supports_ipv6()) { - BOOST_TEST_MESSAGE("No ipv6 support, skipping test"); + if (!seastar::testing::ipv6_available_or_skip()) { co_return; } auto h = co_await ask_ipv6_udp_nameserver(1, [](dns_resolver& d) { @@ -436,8 +436,7 @@ SEASTAR_TEST_CASE(test_resolve_aaaa_from_ipv6_nameserver) { // Reverse lookup of an IPv6 address: 16-byte ip6.arpa PTR through get_host_by_addr. // Not ::1, which /etc/hosts answers before c-ares ever asks the nameserver. SEASTAR_TEST_CASE(test_reverse_lookup_ipv6_from_ipv6_nameserver) { - if (!engine().net().supports_ipv6()) { - BOOST_TEST_MESSAGE("No ipv6 support, skipping test"); + if (!seastar::testing::ipv6_available_or_skip()) { co_return; } auto h = co_await ask_ipv6_udp_nameserver(1, [](dns_resolver& d) { @@ -449,8 +448,7 @@ SEASTAR_TEST_CASE(test_reverse_lookup_ipv6_from_ipv6_nameserver) { // TCP transport (use_tcp_query) to a nameserver that is only reachable over IPv6. SEASTAR_TEST_CASE(test_resolve_tcp_ipv6_nameserver) { - if (!engine().net().supports_ipv6()) { - BOOST_TEST_MESSAGE("No ipv6 support, skipping test"); + if (!seastar::testing::ipv6_available_or_skip()) { co_return; } listen_options lo; diff --git a/tests/unit/httpd_test.cc b/tests/unit/httpd_test.cc index 4625fff8dec..4bcfa490318 100644 --- a/tests/unit/httpd_test.cc +++ b/tests/unit/httpd_test.cc @@ -28,6 +28,7 @@ #include #include #include +#include "ipv6_support.hh" #include "loopback_socket.hh" #include "memory-data-sink.hh" #include "tmpdir.hh" @@ -2393,8 +2394,7 @@ BOOST_AUTO_TEST_CASE(test_http_status_classification) { // An IPv4 client of a `[::]` listener is an IPv4 client to the handler, not the // ::ffff:a.b.c.d the kernel reports. SEASTAR_THREAD_TEST_CASE(test_dual_stack_listener_reports_ipv4_client) { - if (!engine().net().supports_ipv6()) { - BOOST_TEST_MESSAGE("No ipv6 support, skipping test"); + if (!seastar::testing::ipv6_available_or_skip()) { return; } std::optional observed_client, observed_server; diff --git a/tests/unit/ipv6_support.hh b/tests/unit/ipv6_support.hh new file mode 100644 index 00000000000..91e73e47706 --- /dev/null +++ b/tests/unit/ipv6_support.hh @@ -0,0 +1,47 @@ +/* + * This file is open source software, licensed to you under the terms + * of the Apache License, Version 2.0 (the "License"). See the NOTICE file + * distributed with this work for additional information regarding copyright + * ownership. You may not use this file except in compliance with the License. + * + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +/* + * Copyright (C) 2026 ScyllaDB Ltd. + */ + +#pragma once + +#include + +#include + +#include +#include + +namespace seastar::testing { + +// Tests that need IPv6 skip on hosts without it. With SEASTAR_TEST_REQUIRE_IPV6 +// set in the environment they fail instead, so an environment that is meant +// to have IPv6 (CI, an IPv6-only container) notices when they stop running. +inline bool ipv6_available_or_skip() { + if (engine().net().supports_ipv6()) { + return true; + } + if (std::getenv("SEASTAR_TEST_REQUIRE_IPV6")) { + BOOST_FAIL("IPv6 is required (SEASTAR_TEST_REQUIRE_IPV6) but the network stack reports no support"); + } + BOOST_TEST_MESSAGE("No ipv6 support, skipping test"); + return false; +} + +} diff --git a/tests/unit/ipv6_test.cc b/tests/unit/ipv6_test.cc index 2e1fd3a4a97..6e344467ef5 100644 --- a/tests/unit/ipv6_test.cc +++ b/tests/unit/ipv6_test.cc @@ -27,16 +27,12 @@ #include #include -using namespace seastar; +#include "ipv6_support.hh" -static logger iplog("ipv6"); +using namespace seastar; static bool check_ipv6_support() { - if (!engine().net().supports_ipv6()) { - iplog.info("No IPV6 support detected. Skipping..."); - return false; - } - return true; + return seastar::testing::ipv6_available_or_skip(); } SEASTAR_TEST_CASE(udp_packet_test) { diff --git a/tests/unit/tls_test.cc b/tests/unit/tls_test.cc index 2d924160a34..462652f8596 100644 --- a/tests/unit/tls_test.cc +++ b/tests/unit/tls_test.cc @@ -49,6 +49,7 @@ #include +#include "ipv6_support.hh" #include "loopback_socket.hh" #include "tmpdir.hh" @@ -921,8 +922,7 @@ SEASTAR_TEST_CASE(test_verify_server_name_ip_san_mismatch) { } SEASTAR_TEST_CASE(test_verify_server_name_ip_san_ipv6) { - if (!engine().net().supports_ipv6()) { - BOOST_TEST_MESSAGE("No ipv6 support, skipping test"); + if (!seastar::testing::ipv6_available_or_skip()) { return make_ready_future<>(); } auto addr = socket_address(ipv6_addr("::1", 4711)); From fb41e54625c1f9910f29604a80c93cc7f84cffbe Mon Sep 17 00:00:00 2001 From: David Yu Date: Tue, 22 Sep 2026 09:58:37 -0700 Subject: [PATCH 13/16] net: give the IPv6 address types the toolkit the IPv4 ones have MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ipv4_addr has operator==, std::hash and make_ipv4_address(); ipv6_addr had none of them, so a caller working in IPv6 could not put an address in a hash map or spell a wildcard without knowing that socket_address(port) is IPv4-only. Add operator== and std::hash for ipv6_addr, make_ipv6_address(), and socket_address::wildcard(family, port) for an any-address of an explicit family. Delete std::hash<::sockaddr_in> and operator==(sockaddr_in, sockaddr_in) from reactor.hh: nothing uses them, and they read only the IPv4 arm of what callers hold as a union. Also drop the stale FIXME on network_stack::connect — the local address has not assumed IPv4 since the ephemeral-port loop started taking its family from the destination. --- include/seastar/core/reactor.hh | 13 ------------- include/seastar/net/api.hh | 8 +++++++- include/seastar/net/socket_defs.hh | 12 ++++++++++++ src/core/reactor.cc | 4 ---- src/net/inet_address.cc | 6 ++++++ src/net/socket_address.cc | 11 +++++++++++ tests/unit/ipv6_test.cc | 27 +++++++++++++++++++++++++++ 7 files changed, 63 insertions(+), 18 deletions(-) diff --git a/include/seastar/core/reactor.hh b/include/seastar/core/reactor.hh index fa1b082a190..f83e3699c54 100644 --- a/include/seastar/core/reactor.hh +++ b/include/seastar/core/reactor.hh @@ -87,19 +87,6 @@ class reactor; } -namespace std { - -template <> -struct hash<::sockaddr_in> { - size_t operator()(::sockaddr_in a) const { - return a.sin_port ^ a.sin_addr.s_addr; - } -}; - -} - -bool operator==(const ::sockaddr_in a, const ::sockaddr_in b); - namespace seastar { class thread_pool; diff --git a/include/seastar/net/api.hh b/include/seastar/net/api.hh index d7197342a26..3f1561ba58a 100644 --- a/include/seastar/net/api.hh +++ b/include/seastar/net/api.hh @@ -56,6 +56,11 @@ socket_address make_ipv4_address(uint32_t ip, uint16_t port) noexcept { return make_ipv4_address(ipv4_addr(ip, port)); } +inline +socket_address make_ipv6_address(const ipv6_addr& addr) noexcept { + return socket_address(addr); +} + namespace net { // see linux tcp(7) for parameter explanation @@ -495,7 +500,8 @@ class network_stack { public: virtual ~network_stack() {} virtual server_socket listen(socket_address sa, listen_options opts) = 0; - // FIXME: local parameter assumes ipv4 for now, fix when adding other AF + // `local` may be of either family; unspecified means the wildcard address + // of the destination's family, with a port the system picks. future connect(socket_address sa, socket_address = {}, transport proto = transport::TCP); virtual ::seastar::socket socket() = 0; diff --git a/include/seastar/net/socket_defs.hh b/include/seastar/net/socket_defs.hh index 86d288ea4d8..17a9bfdafc1 100644 --- a/include/seastar/net/socket_defs.hh +++ b/include/seastar/net/socket_defs.hh @@ -76,6 +76,10 @@ public: socket_address(uint32_t, uint16_t p = 0) noexcept; + /// The "any address" of a family: 0.0.0.0:port or [::]:port. Unlike the + /// port-only constructor, which is IPv4, the family is explicit. + static socket_address wildcard(sa_family_t family, uint16_t port = 0) noexcept; + socklen_t length() const noexcept { return addr_length; }; bool is_af_unix() const noexcept { @@ -160,6 +164,10 @@ std::ostream& operator<<(std::ostream&, const ipv6_addr&); inline bool operator==(const ipv4_addr &lhs, const ipv4_addr& rhs) noexcept { return lhs.ip == rhs.ip && lhs.port == rhs.port; } + +inline bool operator==(const ipv6_addr &lhs, const ipv6_addr& rhs) noexcept { + return lhs.ip == rhs.ip && lhs.port == rhs.port; +} } namespace std { @@ -172,6 +180,10 @@ struct hash { size_t operator()(const seastar::ipv4_addr&) const; }; template<> +struct hash { + size_t operator()(const seastar::ipv6_addr&) const; +}; +template<> struct hash { size_t operator()(const seastar::unix_domain_addr&) const; }; diff --git a/src/core/reactor.cc b/src/core/reactor.cc index 6b08383176d..788dca4c87a 100644 --- a/src/core/reactor.cc +++ b/src/core/reactor.cc @@ -3939,10 +3939,6 @@ void schedule_urgent(task* t) noexcept { } -bool operator==(const ::sockaddr_in a, const ::sockaddr_in b) { - return (a.sin_addr.s_addr == b.sin_addr.s_addr) && (a.sin_port == b.sin_port); -} - namespace seastar { static bool kernel_supports_aio_fsync() { diff --git a/src/net/inet_address.cc b/src/net/inet_address.cc index 0515b8404af..33278625709 100644 --- a/src/net/inet_address.cc +++ b/src/net/inet_address.cc @@ -432,3 +432,9 @@ size_t std::hash::operator()(const seastar::ipv4_addr& x) co boost::hash_combine(h, x.port); return h; } + +size_t std::hash::operator()(const seastar::ipv6_addr& x) const { + size_t h = boost::hash_range(x.ip.begin(), x.ip.end()); + boost::hash_combine(h, x.port); + return h; +} diff --git a/src/net/socket_address.cc b/src/net/socket_address.cc index c50b68cf3e0..dddc3492beb 100644 --- a/src/net/socket_address.cc +++ b/src/net/socket_address.cc @@ -89,6 +89,17 @@ socket_address::socket_address(uint32_t ipv4, uint16_t p) noexcept : socket_address(make_ipv4_address(ipv4, p)) {} +socket_address socket_address::wildcard(sa_family_t family, uint16_t port) noexcept { + switch (family) { + case AF_INET: + return socket_address(ipv4_addr(port)); + case AF_INET6: + return socket_address(ipv6_addr(port)); + default: + return socket_address(); + } +} + socket_address::socket_address(const net::inet_address& a, uint16_t p) noexcept : socket_address(a.is_ipv6() ? socket_address(ipv6_addr(a, p), a.scope()) : socket_address(ipv4_addr(a, p))) {} diff --git a/tests/unit/ipv6_test.cc b/tests/unit/ipv6_test.cc index 6e344467ef5..20e6857d1f8 100644 --- a/tests/unit/ipv6_test.cc +++ b/tests/unit/ipv6_test.cc @@ -166,6 +166,33 @@ SEASTAR_TEST_CASE(dual_stack_listen_test) { } } +// The IPv6 address type has the same value-type toolkit as the IPv4 one. +SEASTAR_TEST_CASE(ipv6_addr_value_type_test) { + const ipv6_addr a("2001:db8::1", 9092); + const ipv6_addr same("2001:db8::1", 9092); + const ipv6_addr other_port("2001:db8::1", 9093); + const ipv6_addr other_addr("2001:db8::2", 9092); + + BOOST_REQUIRE(a == same); + BOOST_REQUIRE(!(a == other_port)); + BOOST_REQUIRE(!(a == other_addr)); + BOOST_REQUIRE_EQUAL(std::hash()(a), std::hash()(same)); + BOOST_REQUIRE_NE(std::hash()(a), std::hash()(other_port)); + + BOOST_REQUIRE_EQUAL(make_ipv6_address(a), socket_address(a)); + BOOST_REQUIRE_EQUAL(fmt::to_string(make_ipv6_address(a)), "[2001:db8::1]:9092"); + + // wildcard() says which family it means, unlike socket_address(port) + BOOST_REQUIRE_EQUAL(socket_address::wildcard(AF_INET6, 9092).family(), AF_INET6); + BOOST_REQUIRE_EQUAL(fmt::to_string(socket_address::wildcard(AF_INET6, 9092)), "[::]:9092"); + BOOST_REQUIRE_EQUAL(fmt::to_string(socket_address::wildcard(AF_INET, 9092)), "0.0.0.0:9092"); + BOOST_REQUIRE_EQUAL(socket_address::wildcard(AF_INET, 9092), socket_address(ipv4_addr(9092))); + BOOST_REQUIRE(socket_address::wildcard(AF_INET6).is_wildcard()); + BOOST_REQUIRE(socket_address::wildcard(AF_INET).is_wildcard()); + BOOST_REQUIRE(socket_address::wildcard(AF_UNSPEC).is_unspecified()); + return make_ready_future(); +} + SEASTAR_TEST_CASE(ipv6_equal_test) { const uint16_t port{8080}; const uint16_t port2{8088}; From 705f4598f595fc931252da3c6219415b029ab89e Mon Sep 17 00:00:00 2001 From: David Yu Date: Tue, 22 Sep 2026 09:59:15 -0700 Subject: [PATCH 14/16] net: listen on an unspecified address binds any family, not just IPv4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit posix_network_stack::listen rewrote a default-constructed (AF_UNSPEC) socket_address to the IPv4 wildcard, so listen(socket_address{}) — "any address, any port" — bound 0.0.0.0 and was unreachable on an IPv6-only host, with no way to ask for any family. Bind the IPv6 wildcard instead where the stack has IPv6, with IPV6_V6ONLY off so IPv4 peers still arrive, and keep the IPv4 wildcard on a host without IPv6. A caller who wants exactly one family passes that family's wildcard (socket_address::wildcard) or sets listen_options::ipv6_only, which is honoured here if it was set explicitly. Tested by listening on socket_address{} and connecting from 127.0.0.1: the listener is AF_INET6 and on the any-address, and the peer reads as IPv4 after unmapping. --- src/net/posix-stack.cc | 22 ++++++++++++++++++---- tests/unit/ipv6_test.cc | 19 +++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/net/posix-stack.cc b/src/net/posix-stack.cc index 57f3aefba78..caecb9ad52e 100644 --- a/src/net/posix-stack.cc +++ b/src/net/posix-stack.cc @@ -913,9 +913,16 @@ posix_network_stack::posix_network_stack(const program_options::option_group& op server_socket posix_network_stack::listen(socket_address sa, listen_options opt) { using server_socket = seastar::server_socket; - // allow unspecified bind address -> default to ipv4 wildcard + // "Any address" is the IPv6 wildcard with IPV6_V6ONLY off, which also + // accepts IPv4 peers; IPv4 only where the host has no IPv6. One family is + // requested with that family's wildcard or listen_options::ipv6_only. if (sa.is_unspecified()) { - sa = inet_address(inet_address::family::INET); + if (supports_ipv6()) { + sa = socket_address::wildcard(AF_INET6, sa.port()); + opt.ipv6_only = opt.ipv6_only.value_or(false); + } else { + sa = socket_address::wildcard(AF_INET, sa.port()); + } } if (sa.is_af_unix()) { return server_socket(std::make_unique(0, sa, internal::posix_listen(sa, opt), opt.lba, opt.fixed_cpu, opt.proxy_protocol, _allocator)); @@ -939,9 +946,16 @@ posix_ap_network_stack::posix_ap_network_stack(const program_options::option_gro server_socket posix_ap_network_stack::listen(socket_address sa, listen_options opt) { using server_socket = seastar::server_socket; - // allow unspecified bind address -> default to ipv4 wildcard + // "Any address" is the IPv6 wildcard with IPV6_V6ONLY off, which also + // accepts IPv4 peers; IPv4 only where the host has no IPv6. One family is + // requested with that family's wildcard or listen_options::ipv6_only. if (sa.is_unspecified()) { - sa = inet_address(inet_address::family::INET); + if (supports_ipv6()) { + sa = socket_address::wildcard(AF_INET6, sa.port()); + opt.ipv6_only = opt.ipv6_only.value_or(false); + } else { + sa = socket_address::wildcard(AF_INET, sa.port()); + } } if (sa.is_af_unix()) { return server_socket(std::make_unique(0, sa, _allocator)); diff --git a/tests/unit/ipv6_test.cc b/tests/unit/ipv6_test.cc index 20e6857d1f8..4d3f8a0739f 100644 --- a/tests/unit/ipv6_test.cc +++ b/tests/unit/ipv6_test.cc @@ -166,6 +166,25 @@ SEASTAR_TEST_CASE(dual_stack_listen_test) { } } +// listen() with no address at all listens for both families where it can, +// instead of quietly picking IPv4 and being unreachable on an IPv6-only host. +SEASTAR_TEST_CASE(listen_unspecified_address_test) { + if (!check_ipv6_support()) { + co_return; + } + auto ss = server_socket(engine().net().listen(socket_address(), {})); + BOOST_REQUIRE_EQUAL(ss.local_address().family(), AF_INET6); + BOOST_REQUIRE(ss.local_address().addr().is_addr_any()); + + auto accepted = ss.accept(); + auto cs = co_await connect(ipv4_addr("127.0.0.1", ss.local_address().port())); + auto ar = co_await std::move(accepted); + // an IPv4 peer arrives, and (per the v4-mapped normalisation) reads as IPv4 + BOOST_REQUIRE_EQUAL(ar.remote_address.unmapped().addr(), net::inet_address("127.0.0.1")); + cs.shutdown_output(); + ar.connection.shutdown_output(); +} + // The IPv6 address type has the same value-type toolkit as the IPv4 one. SEASTAR_TEST_CASE(ipv6_addr_value_type_test) { const ipv6_addr a("2001:db8::1", 9092); From 46c78877546467cbfebe36c876469a8f0d64bb49 Mon Sep 17 00:00:00 2001 From: David Yu Date: Tue, 22 Sep 2026 09:59:15 -0700 Subject: [PATCH 15/16] net: report an error instead of terminating on ipv4_addr(socket_address) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The socket_address to ipv4_addr conversion is implicit and was noexcept, but it delegates to ipv4_addr(const inet_address&, uint16_t), which throws for an IPv6 address that is not IPv4-mapped — so passing an IPv6 socket_address anywhere an ipv4_addr is expected terminated the process instead of raising std::invalid_argument. Drop the noexcept. The only caller inside a noexcept function is socket_address::is_wildcard(), in its AF_INET arm, where the conversion cannot throw; that is now commented. The native stack's make_bound_datagram_channel checked nothing and reached an assert on a non-AF_INET address, while its unbound counterpart throws: make it throw too. --- include/seastar/net/socket_defs.hh | 3 ++- src/net/inet_address.cc | 1 + src/net/native-stack.cc | 3 +++ src/net/net.cc | 2 +- tests/unit/network_interface_test.cc | 13 +++++++++++++ 5 files changed, 20 insertions(+), 2 deletions(-) diff --git a/include/seastar/net/socket_defs.hh b/include/seastar/net/socket_defs.hh index 17a9bfdafc1..0181fbea166 100644 --- a/include/seastar/net/socket_defs.hh +++ b/include/seastar/net/socket_defs.hh @@ -124,7 +124,8 @@ struct ipv4_addr { ipv4_addr(const std::string &addr, uint16_t port); // throws if not an ipv4 addr ipv4_addr(const net::inet_address&, uint16_t); - ipv4_addr(const socket_address &) noexcept; + // throws if the address is IPv6 and not IPv4-mapped + ipv4_addr(const socket_address &); ipv4_addr(const ::in_addr&, uint16_t = 0) noexcept; bool is_ip_unspecified() const noexcept { diff --git a/src/net/inet_address.cc b/src/net/inet_address.cc index 33278625709..4dd462c186b 100644 --- a/src/net/inet_address.cc +++ b/src/net/inet_address.cc @@ -368,6 +368,7 @@ ::in_port_t seastar::socket_address::port() const noexcept { bool seastar::socket_address::is_wildcard() const noexcept { switch (family()) { case AF_INET: { + // cannot throw: the address is AF_INET here ipv4_addr addr(*this); return addr.is_ip_unspecified() && addr.is_port_unspecified(); } diff --git a/src/net/native-stack.cc b/src/net/native-stack.cc index fcbf89b22e2..bb4ebadde1c 100644 --- a/src/net/native-stack.cc +++ b/src/net/native-stack.cc @@ -210,6 +210,9 @@ net::datagram_channel native_network_stack::make_unbound_datagram_channel(sa_fam } net::datagram_channel native_network_stack::make_bound_datagram_channel(const socket_address& local) { + if (local.family() != AF_INET && !local.is_unspecified()) { + throw std::runtime_error("Unsupported address family"); + } return _inet.get_udp().make_channel(local); } diff --git a/src/net/net.cc b/src/net/net.cc index a25db26a479..cf8e5395760 100644 --- a/src/net/net.cc +++ b/src/net/net.cc @@ -66,7 +66,7 @@ ipv4_addr::ipv4_addr(const net::inet_address& a, uint16_t port) : ipv4_addr(::in_addr(a), port) {} -ipv4_addr::ipv4_addr(const socket_address &sa) noexcept +ipv4_addr::ipv4_addr(const socket_address &sa) : ipv4_addr(sa.addr(), sa.port()) {} diff --git a/tests/unit/network_interface_test.cc b/tests/unit/network_interface_test.cc index 2ab63387d0a..143d6bc782b 100644 --- a/tests/unit/network_interface_test.cc +++ b/tests/unit/network_interface_test.cc @@ -194,6 +194,19 @@ SEASTAR_TEST_CASE(test_ipv6_addr_string_forms) { return make_ready_future(); } +// The socket_address -> ipv4_addr conversion is reachable with an address of +// either family (it is implicit), so a non-mapped IPv6 one has to report an +// error rather than terminate the process from a noexcept function. +SEASTAR_TEST_CASE(test_ipv4_addr_from_socket_address) { + BOOST_REQUIRE_EQUAL(ipv4_addr(socket_address(ipv4_addr("10.0.0.1", 9092))).port, 9092); + // IPv4-mapped is an IPv4 address in an IPv6 shape, so it converts + BOOST_REQUIRE_EQUAL(ipv4_addr(socket_address(ipv6_addr("::ffff:10.0.0.1", 9092))), + ipv4_addr("10.0.0.1", 9092)); + BOOST_CHECK_THROW(ipv4_addr(socket_address(ipv6_addr("2001:db8::1", 9092))), std::invalid_argument); + BOOST_CHECK_THROW(ipv4_addr(socket_address(ipv6_addr("::1", 0))), std::invalid_argument); + return make_ready_future(); +} + SEASTAR_TEST_CASE(test_inet_address_format) { const std::string tests[] = { // IPv4 addresses From 7b496e25262d1fadb073526e69a519c158553ab0 Mon Sep 17 00:00:00 2001 From: David Yu Date: Tue, 22 Sep 2026 09:59:48 -0700 Subject: [PATCH 16/16] http: build the Host header from an address in authority form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit request::make takes the Host header as a string, so a caller with a socket_address formats it, and the obvious "{}:{}" of address and port produces "fd00::5:8080" for IPv6 — a different address, not an authority. Seastar had no formatter to reach for; Redpanda's own http client has the same defect while its RPC client brackets correctly, so the two disagree. Add http::internal::format_authority(), which brackets an IPv6 address per RFC 3986 §3.2.2 and drops a zone index (an authority cannot carry one), and request::make overloads taking a socket_address. --- include/seastar/http/request.hh | 27 +++++++++++++++++++++++++++ src/http/request.cc | 19 +++++++++++++++++++ tests/unit/httpd_test.cc | 16 ++++++++++++++++ 3 files changed, 62 insertions(+) diff --git a/include/seastar/http/request.hh b/include/seastar/http/request.hh index 3e7757c1eae..a348915222d 100644 --- a/include/seastar/http/request.hh +++ b/include/seastar/http/request.hh @@ -422,6 +422,28 @@ public: */ static request make(httpd::operation_type type, sstring host, sstring path); + /** + * \brief Make simple request to an address + * + * \param method - method to use, e.g. "GET" + * \param addr - the address to contact. The "Host" header is its RFC 3986 + * authority form, i.e. an IPv6 address is bracketed. + * \path - the URL to send the request to + * + */ + static request make(sstring method, const socket_address& addr, sstring path); + + /** + * \brief Make simple request to an address + * + * \param method - method to use, e.g. operation_type::GET + * \param addr - the address to contact. The "Host" header is its RFC 3986 + * authority form, i.e. an IPv6 address is bracketed. + * \path - the URL to send the request to + * + */ + static request make(httpd::operation_type type, const socket_address& addr, sstring path); + sstring request_line() const; future<> write_request_headers(output_stream& out) const; private: @@ -430,6 +452,11 @@ private: }; namespace internal { + +/// The RFC 3986 §3.2.2 authority for \param addr: "host:port", with an IPv6 +/// address bracketed. A zone index is not part of an authority and is dropped. +sstring format_authority(const socket_address& addr); + #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wdeprecated-declarations" inline sstring& deprecated_content(request& req) noexcept { return req.content; } diff --git a/src/http/request.cc b/src/http/request.cc index 169e2d9432a..b4ecdf094ff 100644 --- a/src/http/request.cc +++ b/src/http/request.cc @@ -25,6 +25,7 @@ #include #include +#include #include #include #include @@ -168,5 +169,23 @@ request request::make(httpd::operation_type type, sstring host, sstring path) { return make(httpd::type2str(type), std::move(host), std::move(path)); } +request request::make(sstring method, const socket_address& addr, sstring path) { + return make(std::move(method), internal::format_authority(addr), std::move(path)); +} + +request request::make(httpd::operation_type type, const socket_address& addr, sstring path) { + return make(httpd::type2str(type), internal::format_authority(addr), std::move(path)); +} + +sstring internal::format_authority(const socket_address& addr) { + auto ip = addr.addr(); + if (ip.is_ipv6()) { + // inet_address prints a scope as "%", which an authority cannot + // carry; build the address without one. + return format("[{}]:{:d}", net::inet_address(static_cast<::in6_addr>(ip)), addr.port()); + } + return format("{}:{:d}", ip, addr.port()); +} + } // http namespace } // seastar namespace diff --git a/tests/unit/httpd_test.cc b/tests/unit/httpd_test.cc index 4bcfa490318..4ad0ca99861 100644 --- a/tests/unit/httpd_test.cc +++ b/tests/unit/httpd_test.cc @@ -2391,6 +2391,22 @@ BOOST_AUTO_TEST_CASE(test_http_status_classification) { BOOST_REQUIRE_EQUAL(unclassified, 300); } +SEASTAR_TEST_CASE(test_format_authority) { + BOOST_REQUIRE_EQUAL(http::internal::format_authority(socket_address(ipv4_addr("10.0.0.1", 80))), "10.0.0.1:80"); + BOOST_REQUIRE_EQUAL(http::internal::format_authority(socket_address(ipv6_addr("2001:db8::1", 8080))), "[2001:db8::1]:8080"); + BOOST_REQUIRE_EQUAL(http::internal::format_authority(socket_address(ipv6_addr("::1", 0))), "[::1]:0"); + // a zone belongs to the host, not to the authority + BOOST_REQUIRE_EQUAL(http::internal::format_authority(socket_address(ipv6_addr("fe80::1", 8080), 2)), "[fe80::1]:8080"); + + // and the request overload puts exactly that in the Host header + auto v6 = http::request::make("GET", socket_address(ipv6_addr("::1", 8080)), "/test"); + BOOST_REQUIRE_EQUAL(v6.get_header("Host"), "[::1]:8080"); + BOOST_REQUIRE_EQUAL(v6.get_url(), "http://[::1]:8080/test"); + auto v4 = http::request::make(GET, socket_address(ipv4_addr("127.0.0.1", 80)), "/test"); + BOOST_REQUIRE_EQUAL(v4.get_header("Host"), "127.0.0.1:80"); + return make_ready_future(); +} + // An IPv4 client of a `[::]` listener is an IPv4 client to the handler, not the // ::ffff:a.b.c.d the kernel reports. SEASTAR_THREAD_TEST_CASE(test_dual_stack_listener_reports_ipv4_client) {