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/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/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/include/seastar/net/api.hh b/include/seastar/net/api.hh index 685652831a7..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 @@ -432,6 +437,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; @@ -490,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/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..0181fbea166 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 { @@ -91,6 +95,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 { @@ -117,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 { @@ -136,7 +144,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; @@ -156,6 +165,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 { @@ -168,6 +181,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/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/core/reactor.cc b/src/core/reactor.cc index 34a99a64de5..788dca4c87a 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()); @@ -3935,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/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/src/net/dns.cc b/src/net/dns.cc index a2bd89aa4c3..9455e21f02f 100644 --- a/src/net/dns.cc +++ b/src/net/dns.cc @@ -43,8 +43,10 @@ #include #include +#include #include #include +#include #include #include @@ -101,47 +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"; - case ARES_ECONNREFUSED :return "Connection refused"; - 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); } }; @@ -370,19 +334,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 +356,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 @@ -436,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); @@ -530,11 +505,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)); @@ -1112,8 +1090,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 +1146,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 +1291,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/src/net/inet_address.cc b/src/net/inet_address.cc index 7692e1cb62f..4dd462c186b 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) @@ -144,12 +150,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"); } @@ -285,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); }()) {} @@ -318,13 +349,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); } @@ -332,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(); } @@ -396,3 +433,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/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/src/net/posix-stack.cc b/src/net/posix-stack.cc index 1dddec67472..caecb9ad52e 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; } @@ -911,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)); @@ -937,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)); @@ -951,14 +967,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 +975,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 +1036,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 +1164,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/src/net/socket_address.cc b/src/net/socket_address.cc index 4316f1c3d96..dddc3492beb 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); } @@ -87,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))) {} @@ -103,6 +116,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 @@ -144,6 +164,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); } @@ -175,7 +197,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/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..1564bf0d1a2 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); @@ -747,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 d18f96bde5e..8954d3a4098 100644 --- a/src/net/tls_openssl.cc +++ b/src/net/tls_openssl.cc @@ -842,10 +842,13 @@ 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()); } + 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/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/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/dns_test.cc b/tests/unit/dns_test.cc index 89d8c51036d..ff92e3ee993 100644 --- a/tests/unit/dns_test.cc +++ b/tests/unit/dns_test.cc @@ -21,6 +21,7 @@ */ #include #include +#include #include #include @@ -33,6 +34,10 @@ #include #include +#include + +#include "ipv6_support.hh" + using namespace seastar; using namespace seastar::net; @@ -54,7 +59,27 @@ 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) { +// 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); @@ -67,6 +92,7 @@ static std::vector make_tcp_dns_a_response(const temporary_buffer& q 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())); @@ -78,21 +104,53 @@ static std::vector make_tcp_dns_a_response(const temporary_buffer& q 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_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_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 +334,193 @@ 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 (!seastar::testing::ipv6_available_or_skip()) { + 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); + } +} + +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 (!seastar::testing::ipv6_available_or_skip()) { + 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 (!seastar::testing::ipv6_available_or_skip()) { + 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 (!seastar::testing::ipv6_available_or_skip()) { + 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; diff --git a/tests/unit/httpd_test.cc b/tests/unit/httpd_test.cc index 68c5230a4dc..4ad0ca99861 100644 --- a/tests/unit/httpd_test.cc +++ b/tests/unit/httpd_test.cc @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -27,6 +28,7 @@ #include #include #include +#include "ipv6_support.hh" #include "loopback_socket.hh" #include "memory-data-sink.hh" #include "tmpdir.hh" @@ -2389,6 +2391,58 @@ 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) { + if (!seastar::testing::ipv6_available_or_skip()) { + 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/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 4770ad7c57c..4d3f8a0739f 100644 --- a/tests/unit/ipv6_test.cc +++ b/tests/unit/ipv6_test.cc @@ -22,20 +22,17 @@ #include #include #include +#include #include #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) { @@ -60,15 +57,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<>(); @@ -103,6 +123,95 @@ 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(); + }); + } + } +} + +// 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); + 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}; diff --git a/tests/unit/network_interface_test.cc b/tests/unit/network_interface_test.cc index 6a0f41fd28f..143d6bc782b 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"); @@ -108,6 +110,103 @@ 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(); +} + +// 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(); +} + +// 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(); +} + +// 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 @@ -155,6 +254,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) { diff --git a/tests/unit/tls_test.cc b/tests/unit/tls_test.cc index 90cab4bc54d..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" @@ -445,6 +446,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; @@ -672,9 +746,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<> { @@ -706,7 +781,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; @@ -714,7 +791,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); @@ -733,7 +810,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(); }); @@ -805,6 +882,57 @@ 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 (!seastar::testing::ipv6_available_or_skip()) { + 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 @@ -1548,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()); } }