#3 - DNS support + IPv6

This commit is contained in:
Martin Slachta
2026-08-05 17:14:04 +02:00
parent 0e639abcd2
commit b71f740b9a
13 changed files with 291 additions and 92 deletions
+25 -19
View File
@@ -1,6 +1,5 @@
#include "ClientArgs.hpp"
#include <arpa/inet.h>
#include <charconv>
#include <algorithm>
#include <cctype>
@@ -25,12 +24,6 @@ std::string_view trim(std::string_view str) {
return str.substr(start, end - start);
}
bool is_valid_ipv4(std::string_view ip_str) {
// Use inet_pton to validate IPv4 format
struct in_addr addr;
return inet_pton(AF_INET, std::string(ip_str).c_str(), &addr) == 1;
}
tl::expected<int, std::string> parse_port(std::string_view port_str) {
if(port_str.empty()) {
return 8080; // Default port
@@ -63,15 +56,27 @@ tl::expected<net::Address, std::string> parse_address(std::string_view text) {
return tl::make_unexpected("address cannot be empty");
}
// Find the colon to split host and port
size_t colon_pos = text.rfind(':');
std::string_view host;
std::string_view port_str;
if(colon_pos == std::string_view::npos) {
// No colon found: treat entire string as port or host
// If it's all digits, treat as port; otherwise as host (will fail validation)
// An IPv6 literal carries colons of its own, so the brackets it is written
// in are what says where the host ends. This is the form to_string() emits.
if(text.front() == '[') {
size_t closing = text.find(']');
if(closing == std::string_view::npos) {
return tl::make_unexpected("address is missing a closing bracket");
}
host = text.substr(1, closing - 1);
port_str = text.substr(closing + 1);
if(!port_str.empty()) {
if(port_str.front() != ':') {
return tl::make_unexpected("expected a port after the closing bracket");
}
port_str.remove_prefix(1);
}
} else if(size_t colon_pos = text.rfind(':'); colon_pos == std::string_view::npos) {
bool all_digits = !text.empty() && std::all_of(text.begin(), text.end(),
[](unsigned char c) { return std::isdigit(c); });
@@ -79,7 +84,6 @@ tl::expected<net::Address, std::string> parse_address(std::string_view text) {
host = "127.0.0.1";
port_str = text;
} else {
// Treat as host with no port
host = text;
port_str = "";
}
@@ -93,10 +97,6 @@ tl::expected<net::Address, std::string> parse_address(std::string_view text) {
return tl::make_unexpected("host cannot be empty");
}
if(!is_valid_ipv4(host)) {
return tl::make_unexpected("not a valid IPv4 address");
}
// Parse port
auto port_result = parse_port(port_str);
if(!port_result) {
@@ -104,7 +104,13 @@ tl::expected<net::Address, std::string> parse_address(std::string_view text) {
}
int port = port_result.value();
return net::Address(std::optional<std::string>(std::string(host)), port);
auto address_r = net::Address::resolve(std::string(host), port);
if(!address_r) {
return tl::make_unexpected(address_r.error().message());
}
return address_r.value();
}
std::optional<std::string> server_arg(int argc, char** argv) {
+7 -4
View File
@@ -12,10 +12,13 @@ namespace tw::app {
/**
* Parse an address string into a network address.
*
* Accepts "host:port" or a bare port number. Bare port uses 127.0.0.1.
* Missing port defaults to 8080. Trims surrounding whitespace.
* Validates the host with inet_pton and returns an error string
* for non-IPv4 addresses or invalid ports.
* Accepts "host:port" or a bare port number, where the host may be a name as
* well as an address literal; an IPv6 literal has to be bracketed, as
* "[::1]:8080". Bare port uses 127.0.0.1. Missing port defaults to 8080.
* Trims surrounding whitespace.
*
* Looks the host up, so it blocks for as long as that takes, and returns an
* error string for a host that does not resolve or an invalid port.
*/
tl::expected<net::Address, std::string> parse_address(std::string_view text);
+52
View File
@@ -0,0 +1,52 @@
#pragma once
#include <cerrno>
#include <cstring>
#include <netdb.h>
#include <string>
namespace tw::net {
/**
* A failed host lookup.
*
* Kept apart from NetworkError because getaddrinfo reports EAI_ codes, which
* are their own mostly-negative space: sharing one enum would map a lookup
* failure onto whichever errno happened to carry the same number.
*/
struct ResolutionError {
int m_code;
int m_errno;
public:
/**
* Only EAI_SYSTEM defers to errno, and errno will not have survived by the
* time message() runs, so it is captured here.
*/
static ResolutionError from_gai(int code) {
return { code, errno };
}
std::string message() const {
switch (m_code) {
case EAI_NONAME:
return "The host name is not known.";
case EAI_AGAIN:
return "The name server is unreachable or busy; the lookup may succeed later.";
case EAI_FAIL:
return "The name server returned a permanent failure.";
case EAI_FAMILY:
return "The requested address family is not supported.";
case EAI_SERVICE:
return "The requested port is not available for this socket type.";
case EAI_MEMORY:
return "Insufficient memory was available to complete the lookup.";
case EAI_SYSTEM:
return std::string(strerror(m_errno));
default:
return std::string(gai_strerror(m_code));
}
}
};
}
+57
View File
@@ -0,0 +1,57 @@
#pragma once
#include "ResolutionError.hpp"
#include "tl/expected.hpp"
#include <cstring>
#include <netdb.h>
#include <string>
#include <sys/socket.h>
namespace tw::net {
/**
* Turns a host name or an address literal into a socket address.
*
* `family` is the family of the socket the result will be given to. AF_INET6
* asks for IPv4-only names as ::ffff: mapped addresses, so that one dual-stack
* socket reaches both; AF_UNSPEC takes the name as it comes and suits addresses
* that are only being validated, displayed or stored.
*
* Blocks for the length of a DNS round trip when the name is not already known,
* so it belongs at connect time rather than anywhere periodic.
*/
inline tl::expected<sockaddr_storage, ResolutionError>
resolve_host(const std::string& host, int port, sa_family_t family = AF_UNSPEC) {
addrinfo hints {};
hints.ai_family = family;
hints.ai_socktype = SOCK_DGRAM;
// AI_ADDRCONFIG is deliberately absent. Together with AF_INET6 it discards
// every result on a host that carries no global IPv6 address, which is the
// default state of a container on a bridge network.
if(family == AF_INET6) {
hints.ai_flags = AI_V4MAPPED | AI_ALL;
}
// Passing the port as the service spares us setting sin_port or sin6_port
// by hand once the family of the answer is known.
const std::string service = std::to_string(port);
addrinfo* results = nullptr;
const int rc = ::getaddrinfo(host.c_str(), service.c_str(), &hints, &results);
if(rc != 0) {
return tl::make_unexpected(ResolutionError::from_gai(rc));
}
// The list arrives ordered by RFC 6724, so the head is the address the
// system would have picked for itself.
sockaddr_storage storage {};
std::memcpy(&storage, results->ai_addr, results->ai_addrlen);
::freeaddrinfo(results);
return storage;
}
}
@@ -70,7 +70,13 @@ MessageConnection* MessageEndpoint::add_peer(net::quicr::QuicrConnection* connec
}
tl::expected<MessageConnection*, MessageError> MessageEndpoint::connect(const std::string& host, int port) {
auto connection_r = m_endpoint->connect(net::quicr::QuicrAddress(host, port));
auto address_r = net::quicr::QuicrAddress::resolve(host, port, m_endpoint->family());
if(!address_r) {
return tl::make_unexpected(
MessageError(MessageErrorType::ConnectFailed, address_r.error().message()));
}
auto connection_r = m_endpoint->connect(address_r.value());
if(!connection_r) {
return tl::make_unexpected(
MessageError(MessageErrorType::ConnectFailed, connection_r.error().message()));
+1 -1
View File
@@ -113,7 +113,7 @@ public:
};
int main() {
const int NUM_CLIENTS = 10;
const int NUM_CLIENTS = 300;
tw::net::Address address = {"127.0.0.1", 8101};
std::vector<std::thread> threads;
+21
View File
@@ -1,5 +1,7 @@
#pragma once
#include "io/HostResolver.hpp"
#include <arpa/inet.h>
#include <spdlog/spdlog.h>
#include <string>
@@ -7,6 +9,7 @@
#include <optional>
#include <sys/socket.h>
#include <format>
#include <tl/expected.hpp>
namespace tw::net {
@@ -38,6 +41,24 @@ public:
: m_storage(storage)
{ }
/**
* Look a host up, accepting a name where the constructor above takes only
* an address literal.
*
* `family` should be the family of the socket the address will be used
* with. The default suits an address that is only being validated or
* displayed, and takes whatever the name resolves to.
*/
static tl::expected<Address, ResolutionError>
resolve(const std::string& host, int port, sa_family_t family = AF_UNSPEC) {
auto storage_r = resolve_host(host, port, family);
if(!storage_r) {
return tl::make_unexpected(storage_r.error());
}
return Address(std::move(storage_r.value()));
}
/** Return a const pointer suitable for connect / sendto / bind. */
const struct sockaddr* sockaddr() const {
return reinterpret_cast<const struct sockaddr*>(&m_storage);
+12 -1
View File
@@ -15,7 +15,18 @@ QuicrPeerLink::QuicrPeerLink(uint32_t self_id, uint16_t port)
{}
void QuicrPeerLink::connect_to(uint32_t peer_id, const tw::net::Address& addr) {
auto r = m_endpoint->connect(net::quicr::QuicrAddress(addr.ip_string(), addr.port()));
// The address is rebuilt from its text, which for a mapped or IPv6 peer is
// more than the literal constructor can parse, so it goes back through the
// resolver — into the endpoint's family, since that is what will send it.
auto address_r = net::quicr::QuicrAddress::resolve(addr.ip_string(), addr.port(),
m_endpoint->family());
if (!address_r) {
spdlog::warn("QuicrPeerLink[{}]: address of peer {} failed to resolve: {}",
m_self_id, peer_id, address_r.error().message());
return;
}
auto r = m_endpoint->connect(address_r.value());
if (!r) {
spdlog::warn("QuicrPeerLink[{}]: connect to peer {} failed", m_self_id, peer_id);
return;
@@ -1,5 +1,7 @@
#pragma once
#include "io/HostResolver.hpp"
#include <arpa/inet.h>
#include <spdlog/spdlog.h>
#include <string>
@@ -7,6 +9,7 @@
#include <optional>
#include <sys/socket.h>
#include <format>
#include <tl/expected.hpp>
namespace tw::net::quicr {
@@ -38,6 +41,24 @@ public:
: m_storage(storage)
{ }
/**
* Look a host up, accepting a name where the constructor above takes only
* an address literal.
*
* `family` should be the family of the endpoint socket the address will be
* sent from, so that a dual-stack socket is handed a mapped address rather
* than a bare IPv4 one.
*/
static tl::expected<QuicrAddress, ResolutionError>
resolve(const std::string& host, int port, sa_family_t family = AF_UNSPEC) {
auto storage_r = resolve_host(host, port, family);
if(!storage_r) {
return tl::make_unexpected(storage_r.error());
}
return QuicrAddress(std::move(storage_r.value()));
}
/** Return a const pointer suitable for connect / sendto / bind. */
const struct sockaddr* sockaddr() const {
return reinterpret_cast<const struct sockaddr*>(&m_storage);
@@ -18,6 +18,7 @@ class QuicrConnectionListener;
class QuicrEndpoint {
int32_t m_socket_fd;
sa_family_t m_family;
std::unordered_map<uint64_t, std::shared_ptr<QuicrConnection>> m_connections;
std::vector<std::byte> m_inbound_buffer;
@@ -26,7 +27,7 @@ class QuicrEndpoint {
void process_datagram(std::span<std::byte> datagram, QuicrAddress from);
QuicrEndpoint(int socket_fd);
QuicrEndpoint(int socket_fd, sa_family_t family);
public:
QuicrEndpoint(const QuicrEndpoint&) = delete;
@@ -47,6 +48,12 @@ public:
return result;
}
/**
* The family the socket was opened with. Addresses have to be resolved
* into it before they can be sent to.
*/
sa_family_t family() const { return m_family; }
static tl::expected<std::unique_ptr<QuicrEndpoint>, QuicrError> create();
/**
+38 -10
View File
@@ -10,8 +10,8 @@
namespace tw::net::quicr {
QuicrEndpoint::QuicrEndpoint(int socket_fd)
: m_inbound_buffer(64 * 1024), m_socket_fd(socket_fd),
QuicrEndpoint::QuicrEndpoint(int socket_fd, sa_family_t family)
: m_inbound_buffer(64 * 1024), m_socket_fd(socket_fd), m_family(family),
m_new_connection_handler(nullptr) {
}
@@ -31,29 +31,57 @@ tl::expected<std::unique_ptr<QuicrEndpoint>, QuicrError> QuicrEndpoint::create_a
}
tl::expected<std::unique_ptr<QuicrEndpoint>, QuicrError> QuicrEndpoint::create() {
const int domain = AF_INET;
// An IPv6 socket with IPV6_V6ONLY cleared also carries IPv4 peers, which
// arrive as ::ffff: mapped addresses. A host with IPv6 switched off answers
// EAFNOSUPPORT instead, and there the endpoint stays IPv4 as it was.
sa_family_t domain = AF_INET6;
int socket_fd = socket(domain, SOCK_DGRAM, IPPROTO_UDP);
if(socket_fd < 0 && errno == EAFNOSUPPORT) {
domain = AF_INET;
socket_fd = socket(domain, SOCK_DGRAM, IPPROTO_UDP);
}
if(socket_fd < 0) {
spdlog::error("Failed to create socket: {}", strerror(errno));
return tl::make_unexpected(QuicrError::from_errno(errno));
}
if(domain == AF_INET6) {
const int v6_only = 0;
if(setsockopt(socket_fd, IPPROTO_IPV6, IPV6_V6ONLY, &v6_only, sizeof(v6_only)) < 0) {
spdlog::error("Failed to accept IPv4 peers on the socket: {}", strerror(errno));
::close(socket_fd);
return tl::make_unexpected(QuicrError::from_errno(errno));
}
}
if(fcntl(socket_fd, F_SETFL, fcntl(socket_fd, F_GETFL, 0) | O_NONBLOCK, 1) == -1) {
spdlog::error("Failed to set non-blocking mode: {}", strerror(errno));
return tl::make_unexpected(QuicrError::from_errno(errno));
}
return std::unique_ptr<QuicrEndpoint>(new QuicrEndpoint(socket_fd));
return std::unique_ptr<QuicrEndpoint>(new QuicrEndpoint(socket_fd, domain));
}
tl::expected<void, QuicrError> QuicrEndpoint::bind(int port) {
const int domain = AF_INET;
struct sockaddr_in addr = {};
addr.sin_family = domain;
addr.sin_port = htons(port);
addr.sin_addr.s_addr = INADDR_ANY;
sockaddr_storage storage = {};
socklen_t length;
if(::bind(m_socket_fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
if(m_family == AF_INET6) {
auto& addr = reinterpret_cast<sockaddr_in6&>(storage);
addr.sin6_family = AF_INET6;
addr.sin6_port = htons(port);
addr.sin6_addr = in6addr_any;
length = sizeof(sockaddr_in6);
} else {
auto& addr = reinterpret_cast<sockaddr_in&>(storage);
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
addr.sin_addr.s_addr = INADDR_ANY;
length = sizeof(sockaddr_in);
}
if(::bind(m_socket_fd, reinterpret_cast<struct sockaddr*>(&storage), length) < 0) {
spdlog::error("Failed to bind socket: {}", strerror(errno));
return tl::make_unexpected(QuicrError::from_errno(errno));
}