diff --git a/README.md b/README.md index a6e4c19..ca19e69 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,18 @@ client.Send("Hello World", 11); std::vector data = client.RecieveSync(); ``` +The host is resolved for IPv4 and IPv6 alike and every address it resolves to is tried in turn, as Python's `socket.create_connection` does, so `localhost` reaches a server that listens on `::1` only. The optional third argument is a connect timeout, applied to each address on its own. A failed resolve or connect throws `SocketErrorException`: still a `std::runtime_error` with the same message, but carrying a `kind` (`SocketErrorKind::System` for an errno such as `ECONNREFUSED`, `SocketErrorKind::Resolver` for a getaddrinfo result such as `EAI_NONAME`) and the `code`, so a caller can report the cause without parsing the text. + +```cpp +try { + Crafter::ClientTCP client("localhost", 5432, std::chrono::seconds(5)); +} catch (const Crafter::SocketErrorException& e) { + std::println("[Errno {}] {}", e.code, std::strerror(e.code)); // [Errno 111] Connection refused +} catch (const Crafter::SocketTimeoutException&) { + std::println("timed out"); +} +``` + #### ListenerTCP ```cpp // Create a TCP listener @@ -287,6 +299,8 @@ The library includes tests covering: - HTTPS round-trip (`ShouldSendRecieveHTTPS1`) — the plaintext round-trip replayed over TLS, so a transport regression surfaces as an HTTP failure, plus what only exists under TLS: ALPN, `scheme=https` reaching handlers, a body spanning many records, verification failing for an untrusted certificate *and* for a trusted certificate presented under the wrong name, and a plaintext peer on the TLS port being counted and shrugged off - HTTPS interop (`ShouldInteropCurlHTTPS1`) — `curl` verifying our certificate with `--cacert` (not `--insecure`): keep-alive reuse, POST, HEAD, negotiated version, and an h2-only client being refused rather than mis-served; then `ClientHTTP1` against python3's `http.server` behind `ssl.wrap_socket`, whose HTTP/1.0 `Connection: close` frames the body by `close_notify` - Mutual TLS and the raw stream (`ShouldRequireClientCertificateHTTPS1`) — a client certificate the listener's CA vouches for is served and one absent is refused; `TLSStream` is also driven directly with hand-written HTTP/1.1 to keep `:TLS` usable without `:ClientHTTP1` on top +- Dual-stack connect (`ShouldConnectDualStack`) — `localhost` reaches a server on `::1` only and one on `127.0.0.1` only, an IPv6 literal connects over IPv6, and the last failure is what is thrown when no address answers; skips on a host without IPv6 loopback +- Connect errors (`ShouldReportConnectErrors`) — a refused connect and an unresolvable name throw `SocketErrorException` with their errno or getaddrinfo code and the message ClientTCP always threw, and still reach a `std::runtime_error` handler The external-interop test requires outbound UDP/443; if your network blocks it the test will fail. `ShouldInteropCurlHTTP1` and `ShouldInteropCurlHTTPS1` skip whichever half is unavailable when `curl` or `python3` is not installed, and the mutual-TLS half of `ShouldRequireClientCertificateHTTPS1` skips without the `openssl` CLI (a *client* certificate needs the `clientAuth` extended key usage, which the built-in development certificate does not carry) — so all three pass on a bare machine. Install `curl`, `python3` and `openssl` to actually exercise them. diff --git a/implementations/Crafter.Network-ClientTCP.cpp b/implementations/Crafter.Network-ClientTCP.cpp index e4c791c..defeae6 100755 --- a/implementations/Crafter.Network-ClientTCP.cpp +++ b/implementations/Crafter.Network-ClientTCP.cpp @@ -43,6 +43,60 @@ namespace { if (errno == EAGAIN || errno == EWOULDBLOCK) throw SocketTimeoutException(); throw std::runtime_error(std::strerror(errno)); } + + // One connect attempt to one address: a connected, blocking socket, or a + // throw that leaves nothing open. + int ConnectTo(const sockaddr* address, socklen_t length, std::chrono::milliseconds timeout) { + const int fd = socket(address->sa_family, SOCK_STREAM, 0); + if (fd == -1) { + // An address family the host cannot open — IPv6 switched off, + // say — fails this address alone; the caller moves on. + const int failure = errno; + throw SocketErrorException(SocketErrorKind::System, failure, std::string("Could not open socket: ") + std::strerror(failure)); + } + + // With a timeout the connect runs non-blocking, so poll can bound the + // wait; the socket is made blocking again once it is connected. + const bool bounded = timeout > std::chrono::milliseconds::zero(); + const int flags = fcntl(fd, F_GETFL, 0); + if (bounded) { + fcntl(fd, F_SETFL, flags | O_NONBLOCK); + } + int failure = 0; + if (connect(fd, address, length) == -1) { + failure = errno; + if (failure == EINPROGRESS) { + pollfd connecting { .fd = fd, .events = POLLOUT, .revents = 0 }; + const int waitMs = static_cast(std::min(timeout.count(), std::numeric_limits::max())); + int ready; + do { + ready = poll(&connecting, 1, waitMs); + } while (ready == -1 && errno == EINTR); + if (ready == 0) { + close(fd); + throw SocketTimeoutException(); + } + if (ready == -1) { + failure = errno; + } else { + // The connect's own result. + socklen_t size = sizeof(failure); + getsockopt(fd, SOL_SOCKET, SO_ERROR, &failure, &size); + } + } + } + if (failure != 0) { + // Report the failure instead of handing back a socket that is not + // connected to anything — every later send/recv on it would fail + // with a far less obvious error. + close(fd); + throw SocketErrorException(SocketErrorKind::System, failure, std::string("Could not connect to server: ") + std::strerror(failure)); + } + if (bounded) { + fcntl(fd, F_SETFL, flags); + } + return fd; + } } ClientTCP::ClientTCP(int socketid) : socketid(socketid) @@ -55,17 +109,28 @@ ClientTCP::ClientTCP(const char* hostName, std::uint16_t port, std::chrono::mill // getaddrinfo rather than gethostbyname: the latter is not thread safe, // and it signals failure by returning null — which was then dereferenced // straight into a crash on any unresolvable host. + // + // AF_UNSPEC, and every result kept: `localhost` resolves to ::1 and + // 127.0.0.1, and a server may listen on either one alone. Asking for + // AF_INET only, as this used to, made a server bound to ::1 unreachable + // by name. No AI_ADDRCONFIG, as Python's create_connection passes none + // either: it hides ::1 on a host whose only IPv6 address is loopback. addrinfo hints{}; - hints.ai_family = AF_INET; + hints.ai_family = AF_UNSPEC; hints.ai_socktype = SOCK_STREAM; addrinfo* resolved = nullptr; const std::string service = std::to_string(port); const int status = getaddrinfo(hostName, service.c_str(), &hints, &resolved); if (status != 0 || resolved == nullptr) { - throw std::runtime_error(std::string("Could not resolve host '") + hostName + "': " - + gai_strerror(status)); + throw SocketErrorException(SocketErrorKind::Resolver, status, + std::string("Could not resolve host '") + hostName + "': " + gai_strerror(status)); + } + for (const addrinfo* entry = resolved; entry != nullptr; entry = entry->ai_next) { + Address address {}; + std::memcpy(&address.storage, entry->ai_addr, entry->ai_addrlen); + address.length = entry->ai_addrlen; + addresses.push_back(address); } - serv_addr = *reinterpret_cast(resolved->ai_addr); freeaddrinfo(resolved); Connect(connectTimeout); @@ -79,7 +144,7 @@ ClientTCP::ClientTCP(std::string hostName, std::uint16_t port, std::chrono::mill // The moved-from object gives up ownership; the socket itself stays open — // closing it here (as this used to) meant moving a ClientTCP silently // dropped the connection it was carrying. -ClientTCP::ClientTCP(ClientTCP&& other) noexcept : socketid(other.socketid) { +ClientTCP::ClientTCP(ClientTCP&& other) noexcept : socketid(other.socketid), addresses(std::move(other.addresses)) { other.socketid = -1; } @@ -92,52 +157,23 @@ ClientTCP::~ClientTCP() } void ClientTCP::Connect(std::chrono::milliseconds timeout) { - if((socketid = socket(AF_INET, SOCK_STREAM, 0)) == -1){ - throw std::runtime_error(std::string("Could not open socket: ") + std::strerror(errno)); - } - - // With a timeout the connect runs non-blocking, so poll can bound the - // wait; the socket is made blocking again once it is connected. - const bool bounded = timeout > std::chrono::milliseconds::zero(); - const int flags = fcntl(socketid, F_GETFL, 0); - if (bounded) { - fcntl(socketid, F_SETFL, flags | O_NONBLOCK); + if (addresses.empty()) { + throw std::logic_error("ClientTCP::Connect: no address to connect to; a socket handed over by accept() cannot connect again"); } - int failure = 0; - if(connect(socketid,(sockaddr*)&serv_addr, sizeof(sockaddr)) == -1){ - failure = errno; - if (failure == EINPROGRESS) { - pollfd connecting { .fd = socketid, .events = POLLOUT, .revents = 0 }; - const int waitMs = static_cast(std::min(timeout.count(), std::numeric_limits::max())); - int ready; - do { - ready = poll(&connecting, 1, waitMs); - } while (ready == -1 && errno == EINTR); - if (ready == 0) { - close(socketid); - socketid = -1; - throw SocketTimeoutException(); - } - if (ready == -1) { - failure = errno; - } else { - // The connect's own result. - socklen_t length = sizeof(failure); - getsockopt(socketid, SOL_SOCKET, SO_ERROR, &failure, &length); - } + // Each address in turn, in the order getaddrinfo ranked them; when none + // connects, the caller sees what the last one failed with, as with + // Python's create_connection. + std::exception_ptr last; + for (const Address& address : addresses) { + try { + socketid = ConnectTo(reinterpret_cast(&address.storage), address.length, timeout); + return; + } catch (...) { + last = std::current_exception(); } - } - if (failure != 0) { - // Report the failure instead of handing back a socket that is not - // connected to anything — every later send/recv on it would fail - // with a far less obvious error. - close(socketid); - socketid = -1; - throw std::runtime_error(std::string("Could not connect to server: ") + std::strerror(failure)); - } - if (bounded) { - fcntl(socketid, F_SETFL, flags); } + socketid = -1; + std::rethrow_exception(last); } void ClientTCP::Stop() { diff --git a/interfaces/Crafter.Network-ClientTCP.cppm b/interfaces/Crafter.Network-ClientTCP.cppm index 181c830..2481c4a 100755 --- a/interfaces/Crafter.Network-ClientTCP.cppm +++ b/interfaces/Crafter.Network-ClientTCP.cppm @@ -37,14 +37,44 @@ namespace Crafter { } }; + // Where the code of a SocketErrorException comes from. + export enum class SocketErrorKind { + // An errno: ECONNREFUSED, ENETUNREACH, EMFILE, ... + System, + // A getaddrinfo result: EAI_NONAME, EAI_AGAIN, ... + Resolver, + }; + + // A resolve, a socket() or a connect that failed, with the code it failed + // with. what() is the text ClientTCP has always thrown, and catching + // std::runtime_error still catches it; the code is for callers that + // report the cause in their own words — a Python-style + // "[Errno 111] Connection refused", say — and would otherwise have to + // parse it back out of the message. + export class SocketErrorException : public std::runtime_error { + public: + SocketErrorException(SocketErrorKind kind, std::int32_t code, const std::string& message) + : std::runtime_error(message), kind(kind), code(code) {} + SocketErrorKind kind; + std::int32_t code; + }; + export class ClientTCP { public: int socketid; ClientTCP(int socket); + // The host is resolved for IPv4 and IPv6 alike and every address it + // resolves to is tried in turn, the way Python's + // socket.create_connection does: `localhost` reaches a server that + // listens on ::1 only, as a dev server on a recent Node does. The + // first address that connects wins; when none does, what the last + // one failed with is thrown — SocketErrorException, or + // SocketTimeoutException for a timeout. + // // A non-zero connectTimeout makes a connect nobody answers throw // SocketTimeoutException after that long, where it would otherwise - // wait out the kernel's retries, about two minutes. A refused connect - // fails at once either way. + // wait out the kernel's retries, about two minutes. It bounds each + // address on its own. A refused connect fails at once either way. ClientTCP(const char* host, std::uint16_t port, std::chrono::milliseconds connectTimeout = std::chrono::milliseconds::zero()); ClientTCP(std::string host, std::uint16_t port, std::chrono::milliseconds connectTimeout = std::chrono::milliseconds::zero()); ~ClientTCP(); @@ -83,7 +113,13 @@ namespace Crafter { // Into `buffer`, which must outlive the receive; the callback gets the count. void RecieveAsync(std::uint32_t bufferSize, std::function recieveCallback, char* buffer, std::function onError) const; private: - sockaddr_in serv_addr; + struct Address { + sockaddr_storage storage; + socklen_t length; + }; + // What the host resolved to, in getaddrinfo's order; empty for a + // socket handed over by accept(). + std::vector
addresses; }; } #endif \ No newline at end of file diff --git a/project.cpp b/project.cpp index 4060c1d..66b1019 100644 --- a/project.cpp +++ b/project.cpp @@ -174,6 +174,8 @@ extern "C" Configuration CrafterBuildProject(std::span a if (cfg.target.find("linux") != std::string::npos) { cfg.AddTest("ShouldConfigureTCP").Dependencies({ &cfg }); cfg.AddTest("ShouldRecieveTCP").Dependencies({ &cfg }); + cfg.AddTest("ShouldConnectDualStack").Dependencies({ &cfg }); + cfg.AddTest("ShouldReportConnectErrors").Dependencies({ &cfg }); cfg.AddTest("ShouldInteropCurlHTTP1").Dependencies({ &cfg }); cfg.AddTest("ShouldInteropCurlHTTPS1").Dependencies({ &cfg }); cfg.AddTest("ShouldParseHTTP1").Dependencies({ &cfg }); diff --git a/tests/ShouldConnectDualStack/main.cpp b/tests/ShouldConnectDualStack/main.cpp new file mode 100644 index 0000000..17707f8 --- /dev/null +++ b/tests/ShouldConnectDualStack/main.cpp @@ -0,0 +1,151 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// ClientTCP resolves a host for IPv4 and IPv6 alike and tries every address +// the name resolves to, in order, the way Python's socket.create_connection +// does. Before, it asked getaddrinfo for AF_INET only and kept the first +// answer, so a server listening on ::1 alone — a dev server on a recent Node +// binds `localhost` that way — could not be reached by name, while every +// Python client reached it. +// +// The listeners are raw sockets, so nothing here depends on ListenerTCP: one +// IPv6-only on ::1, one on 127.0.0.1. Skips (77) on a host without IPv6 +// loopback. + +#include +#include +#include +#include +#include +#include +#include + +import Crafter.Network; +import std; +using namespace Crafter; +using namespace std::chrono_literals; + +namespace { + +int failures = 0; + +void Check(bool ok, std::string_view what) { + std::println("{} {}", ok ? "PASS" : "FAIL", what); + if (!ok) ++failures; +} + +// A socket listening on the loopback address of `family`, on a port the +// kernel picks; fd stays -1 when the host cannot open one. +struct LoopbackListener { + int fd = -1; + std::uint16_t port = 0; + + explicit LoopbackListener(int family) { + fd = socket(family, SOCK_STREAM, 0); + if (fd == -1) return; + sockaddr_storage address {}; + socklen_t length = 0; + if (family == AF_INET6) { + // v6 only, or the socket would also take IPv4 connects and the + // checks below would not tell the two apart. + int only = 1; + setsockopt(fd, IPPROTO_IPV6, IPV6_V6ONLY, &only, sizeof(only)); + auto* v6 = reinterpret_cast(&address); + v6->sin6_family = AF_INET6; + v6->sin6_addr = in6addr_loopback; + length = sizeof(sockaddr_in6); + } else { + auto* v4 = reinterpret_cast(&address); + v4->sin_family = AF_INET; + v4->sin_addr.s_addr = htonl(INADDR_LOOPBACK); + length = sizeof(sockaddr_in); + } + if (bind(fd, reinterpret_cast(&address), length) != 0 || listen(fd, 8) != 0) { + close(fd); + fd = -1; + return; + } + getsockname(fd, reinterpret_cast(&address), &length); + port = family == AF_INET6 ? ntohs(reinterpret_cast(&address)->sin6_port) + : ntohs(reinterpret_cast(&address)->sin_port); + } + ~LoopbackListener() { + if (fd != -1) close(fd); + } +}; + +// The address family of the peer a connected client reached. +int PeerFamily(const ClientTCP& client) { + sockaddr_storage address {}; + socklen_t length = sizeof(address); + getpeername(client.socketid, reinterpret_cast(&address), &length); + return address.ss_family; +} + +// Whether `localhost` resolves to ::1 here; /etc/hosts decides. +bool LocalhostResolvesToIPv6() { + addrinfo hints {}; + hints.ai_family = AF_INET6; + hints.ai_socktype = SOCK_STREAM; + addrinfo* resolved = nullptr; + if (getaddrinfo("localhost", nullptr, &hints, &resolved) != 0) return false; + freeaddrinfo(resolved); + return true; +} + +} // namespace + +int main() { + LoopbackListener v6(AF_INET6); + if (v6.fd == -1) { + std::println("SKIP no IPv6 loopback on this host"); + return 77; + } + + { + ClientTCP client("::1", v6.port, 2s); + Check(PeerFamily(client) == AF_INET6, "an IPv6 literal connects over IPv6"); + } + + if (LocalhostResolvesToIPv6()) { + try { + ClientTCP client("localhost", v6.port, 2s); + Check(PeerFamily(client) == AF_INET6, "localhost reaches a server that listens on ::1 only"); + } catch (const std::exception& e) { + Check(false, std::format("localhost reaches a server that listens on ::1 only (threw: {})", e.what())); + } + } else { + std::println("SKIP localhost does not resolve to ::1 on this host"); + } + + { + // Whichever address the resolver ranks first, a refused one is not + // the end: the next is tried. + LoopbackListener v4(AF_INET); + try { + ClientTCP client("localhost", v4.port, 2s); + Check(PeerFamily(client) == AF_INET, "localhost reaches a server that listens on 127.0.0.1 only"); + } catch (const std::exception& e) { + Check(false, std::format("localhost reaches a server that listens on 127.0.0.1 only (threw: {})", e.what())); + } + } + + { + std::uint16_t closedPort = 0; + { + LoopbackListener gone(AF_INET); + closedPort = gone.port; + } + bool refused = false; + try { + ClientTCP client("localhost", closedPort, 2s); + } catch (const SocketErrorException& e) { + refused = e.kind == SocketErrorKind::System && e.code == ECONNREFUSED; + } catch (const std::exception&) { + } + Check(refused, "when no address answers, the last failure is thrown, with its errno"); + } + + std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed"); + return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/tests/ShouldReportConnectErrors/main.cpp b/tests/ShouldReportConnectErrors/main.cpp new file mode 100644 index 0000000..c919907 --- /dev/null +++ b/tests/ShouldReportConnectErrors/main.cpp @@ -0,0 +1,93 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// A failed resolve or connect throws SocketErrorException, which carries the +// code it failed with: the errno for a connect, the getaddrinfo result for a +// name. Before, both were a bare std::runtime_error, so a caller that reports +// the cause in its own words — a Python-style "[Errno 111] Connection +// refused" in front of a language model reading the output — had to parse the +// message back apart. The message itself stays what it always was, and the +// exception is still a std::runtime_error, so existing handlers keep working. + +#include +#include +#include +#include +#include +#include +#include +#include + +import Crafter.Network; +import std; +using namespace Crafter; +using namespace std::chrono_literals; + +namespace { + +int failures = 0; + +void Check(bool ok, std::string_view what) { + std::println("{} {}", ok ? "PASS" : "FAIL", what); + if (!ok) ++failures; +} + +// A port on 127.0.0.1 nothing listens on: bound by the kernel, then released. +std::uint16_t ClosedPort() { + const int fd = socket(AF_INET, SOCK_STREAM, 0); + sockaddr_in address {}; + address.sin_family = AF_INET; + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + bind(fd, reinterpret_cast(&address), sizeof(address)); + socklen_t length = sizeof(address); + getsockname(fd, reinterpret_cast(&address), &length); + close(fd); + return ntohs(address.sin_port); +} + +} // namespace + +int main() { + { + bool thrown = false; + try { + ClientTCP client("127.0.0.1", ClosedPort(), 2s); + } catch (const SocketErrorException& e) { + thrown = true; + Check(e.kind == SocketErrorKind::System, "a refused connect is a system error"); + Check(e.code == ECONNREFUSED, std::format("it carries ECONNREFUSED (got {})", e.code)); + Check(std::string_view(e.what()) == std::string("Could not connect to server: ") + std::strerror(ECONNREFUSED), + std::format("its message is the one ClientTCP always threw (got '{}')", e.what())); + } + Check(thrown, "a refused connect throws SocketErrorException"); + } + + { + bool caught = false; + try { + ClientTCP client("127.0.0.1", ClosedPort(), 2s); + } catch (const std::runtime_error&) { + caught = true; + } + Check(caught, "it is still caught as std::runtime_error"); + } + + { + // .invalid never resolves (RFC 6761); without a network the resolver + // fails differently (EAI_AGAIN), but always as a resolver error. + bool thrown = false; + try { + ClientTCP client("no-such-host.invalid", 80, 2s); + } catch (const SocketErrorException& e) { + thrown = true; + Check(e.kind == SocketErrorKind::Resolver, "a name that does not resolve is a resolver error"); + Check(e.code != 0, std::format("it carries the getaddrinfo code (got {})", e.code)); + Check(std::string_view(e.what()).starts_with("Could not resolve host 'no-such-host.invalid': "), + std::format("its message names the host (got '{}')", e.what())); + } + Check(thrown, "an unresolvable host throws SocketErrorException"); + } + + std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed"); + return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +}