diff --git a/CMakeLists.txt b/CMakeLists.txt index 4498070b..b32d3d0d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -71,6 +71,7 @@ set( IXWEBSOCKET_SOURCES ixwebsocket/IXSocketFactory.cpp ixwebsocket/IXSocketServer.cpp ixwebsocket/IXSocketTLSOptions.cpp + ixwebsocket/IXSocks5.cpp ixwebsocket/IXStrCaseCompare.cpp ixwebsocket/IXUdpSocket.cpp ixwebsocket/IXUrlParser.cpp @@ -102,6 +103,7 @@ set( IXWEBSOCKET_HEADERS ixwebsocket/IXHttpServer.h ixwebsocket/IXNetSystem.h ixwebsocket/IXProgressCallback.h + ixwebsocket/IXProxyOptions.h ixwebsocket/IXSelectInterrupt.h ixwebsocket/IXSelectInterruptFactory.h ixwebsocket/IXSelectInterruptPipe.h diff --git a/docs/usage.md b/docs/usage.md index ca8ef6fa..7fab6a2e 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -712,3 +712,38 @@ bind address and uses the framework's TCP listen queue rather than the BSD backl setting. Pending connections are bounded by `maxConnections`. Server TLS handshakes have a ten-second limit and are interrupted when the server stops. Client connection timeouts use the caller's cancellation callback, including HTTP/WebSocket timeouts. + + +### SOCKS5 client proxies + +Configure an outbound SOCKS5 proxy before connecting a WebSocket or issuing HTTP +requests. The same options apply to HTTP, HTTPS, WS, and WSS: + +```cpp +ix::ProxyOptions proxy; +proxy.host = "127.0.0.1"; +proxy.port = 1080; // default +proxy.dnsMode = ix::ProxyOptions::DnsMode::Remote; // default: proxy resolves the destination + +webSocket.setProxyOptions(proxy); +httpClient.setProxyOptions(proxy); +``` + +Use `DnsMode::Local` to resolve the destination locally and send an IPv4 or IPv6 +address to the proxy. Numeric destinations are always sent as numeric addresses. +The proxy's own hostname is resolved locally in both modes. TLS continues to use +the original destination hostname for SNI and certificate verification. + +Set both `proxy.username` and `proxy.password` to offer SOCKS username/password +authentication as well as the unauthenticated method. Each credential must contain +1–255 bytes. Proxy authentication takes place before the destination TLS handshake. +An empty proxy host disables this proxy configuration; call `setProxyOptions({})` +to clear it. Configure options before use; WebSocket reconnections use the current +configuration. Proxy negotiation shares the existing connection timeout and +cancellation mechanism, and failures do not fall back to a direct connection. + +SOCKS5 CONNECT is supported for plain sockets, OpenSSL/LibreSSL, Mbed TLS, and +SecureTransport. SOCKS4 and HTTP CONNECT proxies are not implemented. The native +Network.framework TLS backend currently returns an explicit unsupported-proxy +error. Its native SOCKS API failed routing and fragmented-reply checks during +implementation, so it is not enabled by this feature. diff --git a/ixwebsocket/IXHttpClient.cpp b/ixwebsocket/IXHttpClient.cpp index 748797c0..1f2c7619 100644 --- a/ixwebsocket/IXHttpClient.cpp +++ b/ixwebsocket/IXHttpClient.cpp @@ -53,6 +53,11 @@ namespace ix _tlsOptions = tlsOptions; } + void HttpClient::setProxyOptions(const ProxyOptions& proxyOptions) + { + _proxyOptions = proxyOptions; + } + void HttpClient::setForceBody(bool value) { _forceBody = value; @@ -158,7 +163,7 @@ namespace ix bool tls = protocol == "https"; std::string errorMsg; - _socket = createSocket(tls, -1, errorMsg, _tlsOptions); + _socket = createSocket(tls, -1, errorMsg, _tlsOptions, _proxyOptions); if (!_socket) { diff --git a/ixwebsocket/IXHttpClient.h b/ixwebsocket/IXHttpClient.h index bffc52bf..629e4816 100644 --- a/ixwebsocket/IXHttpClient.h +++ b/ixwebsocket/IXHttpClient.h @@ -7,6 +7,7 @@ #pragma once #include "IXHttp.h" +#include "IXProxyOptions.h" #include "IXSocket.h" #include "IXSocketTLSOptions.h" #include "IXWebSocketExport.h" @@ -81,6 +82,9 @@ namespace ix // TLS void setTLSOptions(const SocketTLSOptions& tlsOptions); + // Configure the outbound SOCKS5 proxy before issuing requests. + void setProxyOptions(const ProxyOptions& proxyOptions); + std::string serializeHttpParameters(const HttpParameters& httpParameters); std::string serializeHttpFormDataParameters( @@ -118,6 +122,7 @@ namespace ix // might be called recursively to follow HTTP redirections SocketTLSOptions _tlsOptions; + ProxyOptions _proxyOptions; bool _forceBody; }; diff --git a/ixwebsocket/IXProxyOptions.h b/ixwebsocket/IXProxyOptions.h new file mode 100644 index 00000000..86bbba48 --- /dev/null +++ b/ixwebsocket/IXProxyOptions.h @@ -0,0 +1,28 @@ +/* + * IXProxyOptions.h + * Copyright (c) 2026 Machine Zone, Inc. All rights reserved. + */ +#pragma once + +#include + +namespace ix +{ + struct ProxyOptions + { + enum class DnsMode + { + Local, + Remote + }; + + // An empty host disables the SOCKS5 proxy. Configure before connecting. + std::string host; + int port = 1080; + DnsMode dnsMode = DnsMode::Remote; + + // Optional username/password authentication; supply both fields. + std::string username; + std::string password; + }; +} // namespace ix diff --git a/ixwebsocket/IXSocket.cpp b/ixwebsocket/IXSocket.cpp index a85f4307..b9cc7211 100644 --- a/ixwebsocket/IXSocket.cpp +++ b/ixwebsocket/IXSocket.cpp @@ -10,6 +10,7 @@ #include "IXSelectInterrupt.h" #include "IXSelectInterruptFactory.h" #include "IXSocketConnect.h" +#include "IXSocks5.h" #include #include #include @@ -236,10 +237,31 @@ namespace ix if (!_selectInterrupt->clear()) return false; - _sockfd = SocketConnect::connect(host, port, errMsg, isCancellationRequested); + _sockfd = connectToHost(host, port, errMsg, isCancellationRequested); return _sockfd != -1; } + void Socket::setProxyOptions(const ProxyOptions& options) + { + _proxyOptions = options; + } + + int Socket::connectToHost(const std::string& host, + int port, + std::string& error, + const CancellationRequest& cancelled) + { + if (_proxyOptions.host.empty()) return SocketConnect::connect(host, port, error, cancelled); + if (!Socks5::validate(_proxyOptions, error)) return -1; + int fd = SocketConnect::connect(_proxyOptions.host, _proxyOptions.port, error, cancelled); + if (fd != -1 && !Socks5::connect(fd, host, port, _proxyOptions, error, cancelled)) + { + Socket::closeSocket(fd); + return -1; + } + return fd; + } + void Socket::close() { std::lock_guard lock(_socketMutex); diff --git a/ixwebsocket/IXSocket.h b/ixwebsocket/IXSocket.h index 92fc1396..27027238 100644 --- a/ixwebsocket/IXSocket.h +++ b/ixwebsocket/IXSocket.h @@ -9,6 +9,7 @@ #include "IXCancellationRequest.h" #include "IXNetSystem.h" #include "IXProgressCallback.h" +#include "IXProxyOptions.h" #include "IXSelectInterrupt.h" #include "IXWebSocketExport.h" #include @@ -37,6 +38,8 @@ namespace ix Socket(int fd = -1); virtual ~Socket(); bool init(std::string& errorMsg); + // Configure before connecting; proxy options are immutable during connection setup. + void setProxyOptions(const ProxyOptions& options); // Functions to check whether there is activity on the socket PollResultType poll(int timeoutMs = kDefaultPollTimeout); @@ -86,6 +89,11 @@ namespace ix const SelectInterruptPtr& selectInterrupt); protected: + int connectToHost(const std::string& host, + int port, + std::string& error, + const CancellationRequest& cancelled); + ProxyOptions _proxyOptions; std::atomic _sockfd; std::mutex _socketMutex; diff --git a/ixwebsocket/IXSocketAppleSSL.cpp b/ixwebsocket/IXSocketAppleSSL.cpp index 84799aac..68b7261e 100644 --- a/ixwebsocket/IXSocketAppleSSL.cpp +++ b/ixwebsocket/IXSocketAppleSSL.cpp @@ -196,7 +196,7 @@ namespace ix { std::lock_guard lock(_mutex); - _sockfd = SocketConnect::connect(host, port, errMsg, isCancellationRequested); + _sockfd = connectToHost(host, port, errMsg, isCancellationRequested); if (_sockfd == -1) return false; _sslContext = SSLCreateContext(kCFAllocatorDefault, kSSLClientSide, kSSLStreamType); diff --git a/ixwebsocket/IXSocketFactory.cpp b/ixwebsocket/IXSocketFactory.cpp index 565bf29a..443abd76 100644 --- a/ixwebsocket/IXSocketFactory.cpp +++ b/ixwebsocket/IXSocketFactory.cpp @@ -30,7 +30,8 @@ namespace ix std::unique_ptr createSocket(bool tls, int fd, std::string& errorMsg, - const SocketTLSOptions& tlsOptions) + const SocketTLSOptions& tlsOptions, + const ProxyOptions& proxyOptions) { (void) tlsOptions; errorMsg.clear(); @@ -64,6 +65,7 @@ namespace ix #endif } + socket->setProxyOptions(proxyOptions); if (!socket->init(errorMsg)) { socket.reset(); diff --git a/ixwebsocket/IXSocketFactory.h b/ixwebsocket/IXSocketFactory.h index de1eeda6..93b6d026 100644 --- a/ixwebsocket/IXSocketFactory.h +++ b/ixwebsocket/IXSocketFactory.h @@ -7,6 +7,7 @@ #pragma once +#include "IXProxyOptions.h" #include "IXSocketTLSOptions.h" #include #include @@ -17,5 +18,6 @@ namespace ix std::unique_ptr createSocket(bool tls, int fd, std::string& errorMsg, - const SocketTLSOptions& tlsOptions); + const SocketTLSOptions& tlsOptions, + const ProxyOptions& proxyOptions = {}); } // namespace ix diff --git a/ixwebsocket/IXSocketMbedTLS.cpp b/ixwebsocket/IXSocketMbedTLS.cpp index d3369e00..7a85ef7d 100644 --- a/ixwebsocket/IXSocketMbedTLS.cpp +++ b/ixwebsocket/IXSocketMbedTLS.cpp @@ -308,7 +308,7 @@ namespace ix { { std::lock_guard lock(_mutex); - _sockfd = SocketConnect::connect(host, port, errMsg, isCancellationRequested); + _sockfd = connectToHost(host, port, errMsg, isCancellationRequested); if (_sockfd == -1) return false; } diff --git a/ixwebsocket/IXSocketNetwork.cpp b/ixwebsocket/IXSocketNetwork.cpp index f2d59917..58216a37 100644 --- a/ixwebsocket/IXSocketNetwork.cpp +++ b/ixwebsocket/IXSocketNetwork.cpp @@ -382,6 +382,11 @@ namespace ix error = "Invalid TLS port"; return false; } + if (!_proxyOptions.host.empty()) + { + error = "SOCKS5 is not supported by the Network.framework backend"; + return false; + } nw_parameters_t params = makeParameters(_options, false, host, error); if (!params) return false; nw_endpoint_t endpoint = diff --git a/ixwebsocket/IXSocketOpenSSL.cpp b/ixwebsocket/IXSocketOpenSSL.cpp index 8591015c..19a1c357 100644 --- a/ixwebsocket/IXSocketOpenSSL.cpp +++ b/ixwebsocket/IXSocketOpenSSL.cpp @@ -760,7 +760,7 @@ namespace ix return false; } - _sockfd = SocketConnect::connect(host, port, errMsg, isCancellationRequested); + _sockfd = connectToHost(host, port, errMsg, isCancellationRequested); if (_sockfd == -1) return false; _ssl_context = openSSLCreateContext(errMsg); diff --git a/ixwebsocket/IXSocks5.cpp b/ixwebsocket/IXSocks5.cpp new file mode 100644 index 00000000..de497d7a --- /dev/null +++ b/ixwebsocket/IXSocks5.cpp @@ -0,0 +1,231 @@ +/* + * IXSocks5.cpp + * Copyright (c) 2026 Machine Zone, Inc. All rights reserved. + */ +#include "IXSocks5.h" + +#include "IXDNSLookup.h" +#include "IXSocket.h" +#include +#include +#include +#include + +namespace ix +{ + namespace + { + bool transfer(socket_t fd, + uint8_t* data, + size_t size, + bool reading, + std::string& error, + const CancellationRequest& cancelled) + { + size_t offset = 0; + while (offset < size) + { + if (cancelled && cancelled()) + { + error = "SOCKS5 connection cancelled"; + return false; + } + auto ready = Socket::poll(reading, 10, fd, nullptr); + if (ready == PollResultType::Timeout) continue; + if (ready == PollResultType::Error) + { + error = "SOCKS5 socket readiness error"; + return false; + } + // The socket is nonblocking. Never request bytes beyond this protocol field. + int count = static_cast((std::min) (size - offset, size_t(65536))); + int flags = 0; +#ifndef _WIN32 +#ifdef MSG_NOSIGNAL + if (!reading) flags = MSG_NOSIGNAL; +#endif +#endif + auto result = + reading + ? ::recv(fd, reinterpret_cast(data + offset), count, 0) + : ::send(fd, reinterpret_cast(data + offset), count, flags); + if (result > 0) + offset += static_cast(result); + else if (result == 0) + { + error = "SOCKS5 proxy closed the connection"; + return false; + } + else if (!Socket::isWaitNeeded() && Socket::getErrno() != EINTR) + { + error = "SOCKS5 socket I/O error"; + return false; + } + } + return true; + } + + bool numericAddress(const std::string& host, std::vector& address) + { + std::array bytes; + if (inet_pton(AF_INET, host.c_str(), bytes.data()) == 1) + { + address.push_back(1); + address.insert(address.end(), bytes.begin(), bytes.begin() + 4); + return true; + } + if (inet_pton(AF_INET6, host.c_str(), bytes.data()) == 1) + { + address.push_back(4); + address.insert(address.end(), bytes.begin(), bytes.end()); + return true; + } + return false; + } + } // namespace + + bool Socks5::validate(const ProxyOptions& options, std::string& error) + { + if (options.host.empty()) return true; + if (options.port < 1 || options.port > 65535) + error = "Invalid SOCKS5 proxy port"; + else if (options.dnsMode != ProxyOptions::DnsMode::Local && + options.dnsMode != ProxyOptions::DnsMode::Remote) + error = "Invalid SOCKS5 DNS mode"; + else if ((!options.username.empty() || !options.password.empty()) && + (options.username.empty() || options.password.empty() || + options.username.size() > 255 || options.password.size() > 255)) + error = "SOCKS5 username and password must each contain 1 to 255 bytes"; + else + return true; + return false; + } + + std::string Socks5::resolveHost(const std::string& host, + int port, + std::string& error, + const CancellationRequest& cancelled) + { + auto lookup = std::make_shared(host, port); + auto addresses = lookup->resolve(error, cancelled); + if (!addresses) return {}; + for (auto address = addresses.get(); address; address = address->ai_next) + { + char result[INET6_ADDRSTRLEN]; + const void* bytes = nullptr; + if (address->ai_family == AF_INET) + bytes = &reinterpret_cast(address->ai_addr)->sin_addr; + else if (address->ai_family == AF_INET6) + bytes = &reinterpret_cast(address->ai_addr)->sin6_addr; + if (bytes && inet_ntop(address->ai_family, bytes, result, sizeof(result))) + return result; + } + error = "SOCKS5 destination has no IPv4 or IPv6 address"; + return {}; + } + + bool Socks5::connect(socket_t fd, + const std::string& host, + int port, + const ProxyOptions& options, + std::string& error, + const CancellationRequest& cancelled) + { + if (!validate(options, error)) return false; + if (port < 1 || port > 65535 || host.empty()) + { + error = "Invalid SOCKS5 destination"; + return false; + } + std::vector address; + if (!numericAddress(host, address)) + { + if (options.dnsMode == ProxyOptions::DnsMode::Local) + { + auto resolved = resolveHost(host, port, error, cancelled); + if (resolved.empty() || !numericAddress(resolved, address)) return false; + } + else + { + if (host.size() > 255) + { + error = "SOCKS5 destination hostname exceeds 255 bytes"; + return false; + } + address = {3, static_cast(host.size())}; + address.insert(address.end(), host.begin(), host.end()); + } + } + const bool credentials = !options.username.empty(); + std::vector greeting = {5, static_cast(credentials ? 2 : 1), 0}; + if (credentials) greeting.push_back(2); + uint8_t response[4]; + if (!transfer(fd, greeting.data(), greeting.size(), false, error, cancelled) || + !transfer(fd, response, 2, true, error, cancelled)) + return false; + if (response[0] != 5 || (response[1] != 0 && !(credentials && response[1] == 2))) + { + error = "SOCKS5 proxy rejected the offered authentication method"; + return false; + } + if (response[1] == 2) + { + std::vector authentication = {1, + static_cast(options.username.size())}; + authentication.insert( + authentication.end(), options.username.begin(), options.username.end()); + authentication.push_back(static_cast(options.password.size())); + authentication.insert( + authentication.end(), options.password.begin(), options.password.end()); + if (!transfer( + fd, authentication.data(), authentication.size(), false, error, cancelled) || + !transfer(fd, response, 2, true, error, cancelled)) + return false; + if (response[0] != 1 || response[1] != 0) + { + error = "SOCKS5 username/password authentication failed"; + return false; + } + } + std::vector request = {5, 1, 0}; + request.insert(request.end(), address.begin(), address.end()); + request.push_back(static_cast(port >> 8)); + request.push_back(static_cast(port & 255)); + if (!transfer(fd, request.data(), request.size(), false, error, cancelled) || + !transfer(fd, response, 4, true, error, cancelled)) + return false; + if (response[0] != 5 || response[2] != 0) + { + error = "Invalid SOCKS5 CONNECT reply"; + return false; + } + if (response[1] != 0) + { + error = "SOCKS5 CONNECT failed (reply " + std::to_string(response[1]) + ")"; + return false; + } + size_t size = 0; + if (response[3] == 1) + size = 4; + else if (response[3] == 4) + size = 16; + else if (response[3] == 3) + { + uint8_t length; + if (!transfer(fd, &length, 1, true, error, cancelled)) return false; + size = length; + if (!size) + { + error = "Invalid SOCKS5 reply hostname length"; + return false; + } + } + else + { + error = "Invalid SOCKS5 reply address type"; + return false; + } + std::array bound; + return transfer(fd, bound.data(), size + 2, true, error, cancelled); + } +} // namespace ix diff --git a/ixwebsocket/IXSocks5.h b/ixwebsocket/IXSocks5.h new file mode 100644 index 00000000..654019ec --- /dev/null +++ b/ixwebsocket/IXSocks5.h @@ -0,0 +1,29 @@ +/* + * IXSocks5.h + * Copyright (c) 2026 Machine Zone, Inc. All rights reserved. + */ +#pragma once + +#include "IXCancellationRequest.h" +#include "IXNetSystem.h" +#include "IXProxyOptions.h" +#include + +namespace ix +{ + class Socks5 + { + public: + static bool validate(const ProxyOptions& options, std::string& error); + static std::string resolveHost(const std::string& host, + int port, + std::string& error, + const CancellationRequest& cancelled); + static bool connect(socket_t fd, + const std::string& host, + int port, + const ProxyOptions& options, + std::string& error, + const CancellationRequest& cancelled); + }; +} // namespace ix diff --git a/ixwebsocket/IXWebSocket.cpp b/ixwebsocket/IXWebSocket.cpp index d9bcb6f9..7fd72fa3 100644 --- a/ixwebsocket/IXWebSocket.cpp +++ b/ixwebsocket/IXWebSocket.cpp @@ -104,6 +104,12 @@ namespace ix _socketTLSOptions = socketTLSOptions; } + void WebSocket::setProxyOptions(const ProxyOptions& proxyOptions) + { + std::lock_guard lock(_configMutex); + _proxyOptions = proxyOptions; + } + const WebSocketPerMessageDeflateOptions WebSocket::getPerMessageDeflateOptions() const { std::lock_guard lock(_configMutex); @@ -228,8 +234,11 @@ namespace ix std::string url; { std::lock_guard lock(_configMutex); - _ws.configure( - _perMessageDeflateOptions, _socketTLSOptions, _enablePong, _pingIntervalSecs); + _ws.configure(_perMessageDeflateOptions, + _socketTLSOptions, + _enablePong, + _pingIntervalSecs, + _proxyOptions); headers = _extraHeaders; disabledDefaultHeaders = _disabledDefaultHeaders; subProtocols = _subProtocols; @@ -295,8 +304,11 @@ namespace ix } { std::lock_guard lock(_configMutex); - _ws.configure( - _perMessageDeflateOptions, _socketTLSOptions, _enablePong, _pingIntervalSecs); + _ws.configure(_perMessageDeflateOptions, + _socketTLSOptions, + _enablePong, + _pingIntervalSecs, + _proxyOptions); } WebSocketInitResult status = _ws.connectToSocket( diff --git a/ixwebsocket/IXWebSocket.h b/ixwebsocket/IXWebSocket.h index 1ad0332b..0ee1d53a 100644 --- a/ixwebsocket/IXWebSocket.h +++ b/ixwebsocket/IXWebSocket.h @@ -10,6 +10,7 @@ #pragma once #include "IXProgressCallback.h" +#include "IXProxyOptions.h" #include "IXSocketTLSOptions.h" #include "IXWebSocketCloseConstants.h" #include "IXWebSocketErrorInfo.h" @@ -58,6 +59,7 @@ namespace ix void setPerMessageDeflateOptions( const WebSocketPerMessageDeflateOptions& perMessageDeflateOptions); void setTLSOptions(const SocketTLSOptions& socketTLSOptions); + void setProxyOptions(const ProxyOptions& proxyOptions); void setPingMessage(const std::string& sendMessage, SendMessageKind pingType = SendMessageKind::Ping); void setPingInterval(int pingIntervalSecs); @@ -152,6 +154,7 @@ namespace ix WebSocketPerMessageDeflateOptions _perMessageDeflateOptions; SocketTLSOptions _socketTLSOptions; + ProxyOptions _proxyOptions; mutable std::mutex _configMutex; // protect all config variables access diff --git a/ixwebsocket/IXWebSocketTransport.cpp b/ixwebsocket/IXWebSocketTransport.cpp index 35333f05..1411595b 100644 --- a/ixwebsocket/IXWebSocketTransport.cpp +++ b/ixwebsocket/IXWebSocketTransport.cpp @@ -93,11 +93,13 @@ namespace ix const WebSocketPerMessageDeflateOptions& perMessageDeflateOptions, const SocketTLSOptions& socketTLSOptions, bool enablePong, - int pingIntervalSecs) + int pingIntervalSecs, + const ProxyOptions& proxyOptions) { _perMessageDeflateOptions = perMessageDeflateOptions; _enablePerMessageDeflate = _perMessageDeflateOptions.enabled(); _socketTLSOptions = socketTLSOptions; + _proxyOptions = proxyOptions; _enablePong = enablePong; _pingIntervalSecs = pingIntervalSecs; } @@ -130,7 +132,7 @@ namespace ix std::string errorMsg; bool tls = protocol == "wss"; - _socket = createSocket(tls, -1, errorMsg, _socketTLSOptions); + _socket = createSocket(tls, -1, errorMsg, _socketTLSOptions, _proxyOptions); _perMessageDeflate = ix::make_unique(); if (!_socket) diff --git a/ixwebsocket/IXWebSocketTransport.h b/ixwebsocket/IXWebSocketTransport.h index 498159b5..51931e83 100644 --- a/ixwebsocket/IXWebSocketTransport.h +++ b/ixwebsocket/IXWebSocketTransport.h @@ -14,6 +14,7 @@ #include "IXCancellationRequest.h" #include "IXProgressCallback.h" +#include "IXProxyOptions.h" #include "IXSocketTLSOptions.h" #include "IXUtf8Validator.h" #include "IXWebSocketCloseConstants.h" @@ -80,7 +81,8 @@ namespace ix void configure(const WebSocketPerMessageDeflateOptions& perMessageDeflateOptions, const SocketTLSOptions& socketTLSOptions, bool enablePong, - int pingIntervalSecs); + int pingIntervalSecs, + const ProxyOptions& proxyOptions = {}); // Client WebSocketInitResult connectToUrl( @@ -229,6 +231,7 @@ namespace ix // Used to control TLS connection behavior SocketTLSOptions _socketTLSOptions; + ProxyOptions _proxyOptions; // Used to cancel dns lookup + socket connect + http upgrade std::atomic _requestInitCancellation; diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 37d8ab3c..991abe64 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -10,6 +10,7 @@ option(USE_TLS "Add TLS support" ON) # Shared sources set (TEST_TARGET_NAMES IXSocketTest + IXSocks5Test IXSocketConnectTest IXWebSocketServerTest IXWebSocketTestConnectionDisconnection @@ -124,6 +125,8 @@ add_test(NAME IXWebSocketBuildSettings -G "${CMAKE_GENERATOR}") set_tests_properties(IXWebSocketBuildSettings PROPERTIES TIMEOUT 30) +set_tests_properties(IXSocks5Test PROPERTIES TIMEOUT 30) + set_tests_properties(IXWebSocketReconnectTest IXWebSocketDispatchTest IXWebSocketSendFailureTest PROPERTIES TIMEOUT 30) if (USE_NETWORK_FRAMEWORK OR (UNIX AND USE_TLS AND (USE_OPEN_SSL OR USE_LIBRE_SSL OR USE_MBED_TLS))) @@ -138,6 +141,8 @@ if (USE_NETWORK_FRAMEWORK OR (UNIX AND USE_TLS AND (USE_OPEN_SSL OR USE_LIBRE_SS DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/generate_network_certs.sh" VERBATIM) add_custom_target(tls_test_certs DEPENDS "${TLS_TEST_CERTS}/ready") + add_dependencies(IXSocks5Test tls_test_certs) + target_compile_definitions(IXSocks5Test PRIVATE IX_SOCKS_TEST_CERTS="${TLS_TEST_CERTS}") add_executable(IXSocketTLSServerTest IXSocketTLSServerTest.cpp) add_dependencies(IXSocketTLSServerTest tls_test_certs) target_compile_definitions(IXSocketTLSServerTest PRIVATE IX_TLS_SERVER_TEST_CERTS="${TLS_TEST_CERTS}") diff --git a/test/IXSocks5Test.cpp b/test/IXSocks5Test.cpp new file mode 100644 index 00000000..9e7f2e17 --- /dev/null +++ b/test/IXSocks5Test.cpp @@ -0,0 +1,630 @@ +/* + * IXSocks5Test.cpp + * Copyright (c) 2026 Machine Zone, Inc. All rights reserved. + */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + using Clock = std::chrono::steady_clock; + enum class Reply + { + IPv4, + Domain, + IPv6, + Truncated, + Version, + Method, + UnofferedAuth, + ConnectVersion, + Reserved, + Auth, + AuthVersion, + Rejected, + AddressType, + EmptyDomain, + Stall + }; + + class SocksProxy final : public ix::SocketServer + { + public: + struct Request + { + unsigned addressType = 0; + std::string host; + int port = 0; + std::string username; + std::string password; + }; + explicit SocksProxy(Reply reply = Reply::IPv4, bool auth = false, bool tunnel = false) + : SocketServer(ix::getFreePort(), "127.0.0.1") + , _reply(reply) + , _auth(auth) + , _tunnel(tunnel) + { + } + ~SocksProxy() override + { + stop(); + } + void stop() override + { + _stopping = true; + SocketServer::stop(); + } + void listenAndStart() + { + REQUIRE(listen().first); + start(); + } + ix::ProxyOptions options() + { + ix::ProxyOptions result; + result.host = "127.0.0.1"; + result.port = getPort(); + if (_auth) + { + result.username = "test-user"; + result.password = "test-password"; + } + return result; + } + Request request() + { + std::lock_guard lock(_mutex); + return _request; + } + std::atomic connections {0}; + std::atomic greetings {0}; + + private: + size_t getConnectedClientsCount() override + { + return 0; + } + void handleConnection(std::unique_ptr socket, + std::shared_ptr state) override + { + ++connections; + serve(*socket); + state->setTerminated(); + } + void serve(ix::Socket& socket) + { + const auto deadline = Clock::now() + std::chrono::seconds(5); + auto cancelled = [&] { return _stopping || Clock::now() >= deadline; }; + auto read = [&](size_t size, std::string& value) + { + auto result = socket.readBytes(size, nullptr, nullptr, cancelled); + value = result.second; + return result.first; + }; + auto write = [&](const std::string& value) + { + // Fragment unit-test replies; relay integration uses normal writes. + if (_tunnel) return socket.writeBytes(value, cancelled); + for (char byte : value) + { + if (!socket.writeBytes(std::string(1, byte), cancelled)) return false; + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + return true; + }; + std::string header, value; + if (!read(2, header) || !read(static_cast(header[1]), value)) return; + ++greetings; + if (_reply == Reply::Stall) + { + while (!cancelled()) + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + return; + } + if (_reply == Reply::Version) + { + write(std::string("\x04\x00", 2)); + return; + } + if (_reply == Reply::Method) + { + write(std::string("\x05\x7f", 2)); + return; + } + if (_reply == Reply::UnofferedAuth) + { + write(std::string("\x05\x02", 2)); + return; + } + Request request; + if (!write(std::string("\x05", 1) + char(_auth ? 2 : 0))) return; + if (_auth) + { + if (!read(2, header) || !read(static_cast(header[1]), request.username) || + !read(1, header) || !read(static_cast(header[0]), request.password)) + return; + if (!write(std::string(_reply == Reply::AuthVersion ? "\x02" : "\x01", 1) + + char(_reply == Reply::Auth ? 1 : 0))) + return; + if (_reply == Reply::Auth || _reply == Reply::AuthVersion) return; + } + if (!read(4, header)) return; + request.addressType = static_cast(header[3]); + if (request.addressType == 3) + { + if (!read(1, header) || !read(static_cast(header[0]), request.host)) + return; + } + else + { + if (!read(request.addressType == 1 ? 4 : 16, value)) return; + char numeric[INET6_ADDRSTRLEN]; + if (!ix::inet_ntop(request.addressType == 1 ? AF_INET : AF_INET6, + value.data(), + numeric, + sizeof(numeric))) + return; + request.host = numeric; + } + if (!read(2, value)) return; + request.port = (static_cast(value[0]) << 8) | static_cast(value[1]); + { + std::lock_guard lock(_mutex); + _request = request; + } + if (_reply == Reply::ConnectVersion) + { + write(std::string("\x04\x00\x00\x01", 4)); + return; + } + if (_reply == Reply::Reserved) + { + write(std::string("\x05\x00\x01\x01", 4)); + return; + } + if (_reply == Reply::Truncated) + { + write(std::string("\x05\x00\x00\x01\x7f", 5)); + return; + } + if (_reply == Reply::Rejected) + { + write(std::string("\x05\x05\x00\x01", 4)); + return; + } + if (_reply == Reply::AddressType) + { + write(std::string("\x05\x00\x00\x7f", 4)); + return; + } + if (_reply == Reply::EmptyDomain) + { + write(std::string("\x05\x00\x00\x03\x00", 5)); + return; + } + std::string bound; + if (_reply == Reply::IPv4) + bound = std::string("\x01\x7f\x00\x00\x01", 5); + else if (_reply == Reply::Domain) + bound = std::string("\x03\x09localhost", 11); + else + bound = std::string("\x04", 1) + std::string(15, '\0') + '\x01'; + std::string reply = std::string("\x05\x00\x00", 3) + bound + std::string("\x00\x50", 2); + std::string error; + auto upstream = _tunnel ? ix::createSocket(false, -1, error, {}) : nullptr; + if (_tunnel && + (!upstream || !upstream->connect("127.0.0.1", request.port, error, cancelled))) + return; + if (!write(reply)) return; + if (!_tunnel) + { + write("payload"); + return; + } + char buffer[16384]; + while (!cancelled()) + { + for (int direction = 0; direction != 2; ++direction) + { + auto& from = direction ? *upstream : socket; + auto& to = direction ? socket : *upstream; + auto count = from.recv(buffer, sizeof(buffer)); + if (count > 0) + { + if (!to.writeBytes(std::string(buffer, static_cast(count)), + cancelled)) + return; + } + else if (count == 0 || !ix::Socket::isWaitNeeded()) + return; + } + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + } + Reply _reply; + bool _auth; + bool _tunnel; + std::atomic _stopping {false}; + std::mutex _mutex; + Request _request; + }; + + auto deadline() -> ix::CancellationRequest + { + auto end = Clock::now() + std::chrono::seconds(2); + return [end] { return Clock::now() >= end; }; + } +} // namespace + +TEST_CASE("SOCKS5 consumes exactly the proxy reply", "[socks5]") +{ + auto reply = GENERATE(Reply::IPv4, Reply::Domain, Reply::IPv6); + bool auth = GENERATE(false, true); + SocksProxy proxy(reply, auth); + proxy.listenAndStart(); + std::string error; + auto socket = ix::createSocket(false, -1, error, {}, proxy.options()); + REQUIRE(socket); + REQUIRE(socket->connect("destination.invalid", 80, error, deadline())); + auto body = socket->readBytes(7, nullptr, nullptr, deadline()); + REQUIRE(body.first); + CHECK(body.second == "payload"); + auto request = proxy.request(); + CHECK(request.addressType == 3); + CHECK(request.host == "destination.invalid"); + CHECK(request.port == 80); + if (auth) CHECK(request.username == "test-user"); +} + +TEST_CASE("SOCKS5 rejects malformed and incomplete proxy replies", "[socks5]") +{ + auto reply = GENERATE(Reply::Truncated, + Reply::Version, + Reply::Method, + Reply::UnofferedAuth, + Reply::ConnectVersion, + Reply::Reserved, + Reply::Auth, + Reply::AuthVersion, + Reply::Rejected, + Reply::AddressType, + Reply::EmptyDomain); + bool auth = reply == Reply::Auth || reply == Reply::AuthVersion; + SocksProxy proxy(reply, auth); + proxy.listenAndStart(); + std::string error; + auto socket = ix::createSocket(false, -1, error, {}, proxy.options()); + REQUIRE(socket); + CHECK_FALSE(socket->connect("destination.invalid", 443, error, deadline())); + CHECK_FALSE(error.empty()); +} + +TEST_CASE("SOCKS5 supports local DNS and numeric destinations", "[socks5]") +{ + const std::string host = + GENERATE(std::string("localhost"), std::string("127.0.0.1"), std::string("::1")); + SocksProxy proxy; + proxy.listenAndStart(); + auto options = proxy.options(); + options.dnsMode = ix::ProxyOptions::DnsMode::Local; + std::string error; + auto socket = ix::createSocket(false, -1, error, {}, options); + REQUIRE(socket->connect(host, 443, error, deadline())); + auto request = proxy.request(); + CHECK((request.addressType == 1 || request.addressType == 4)); + if (host != "localhost") CHECK(request.host == host); +} + +TEST_CASE("SOCKS5 validates options before connecting", "[socks5]") +{ + SocksProxy proxy; + proxy.listenAndStart(); + auto options = proxy.options(); + SECTION("Port") + { + options.port = GENERATE(-1, 0, 65536); + } + SECTION("Username length") + { + options.username.assign(256, 'u'); + options.password = "password"; + } + SECTION("Missing password") + { + options.username = "user"; + } + SECTION("DNS mode") + { + options.dnsMode = static_cast(42); + } + std::string error; + auto socket = ix::createSocket(false, -1, error, {}, options); + CHECK_FALSE(socket->connect("destination.invalid", 443, error, deadline())); + CHECK_FALSE(error.empty()); + CHECK(proxy.connections == 0); +} + +TEST_CASE("SOCKS5 connection cancellation remains responsive", "[socks5]") +{ +#if defined(IX_SOCKS_TEST_CERTS) && !defined(IXWEBSOCKET_USE_NETWORK_FRAMEWORK) + bool secure = GENERATE(false, true); +#else + bool secure = false; +#endif + SocksProxy proxy(Reply::Stall); + proxy.listenAndStart(); + ix::WebSocket client; + client.disableAutomaticReconnection(); + client.setUrl(std::string(secure ? "wss" : "ws") + "://destination.invalid/"); + client.setProxyOptions(proxy.options()); + client.setOnMessageCallback([](const ix::WebSocketMessagePtr&) { }); + client.start(); + auto end = Clock::now() + std::chrono::seconds(2); + while (!proxy.greetings && Clock::now() < end) + std::this_thread::yield(); + auto stop = std::async(std::launch::async, [&] { client.stop(); }); + auto ready = stop.wait_for(std::chrono::milliseconds(500)); + proxy.stop(); + stop.get(); + CHECK(proxy.greetings == 1); + CHECK(ready == std::future_status::ready); +} + +namespace +{ + bool secureAvailable() + { +#if defined(IX_SOCKS_TEST_CERTS) && !defined(IXWEBSOCKET_USE_NETWORK_FRAMEWORK) + return true; +#else + return false; +#endif + } + + void configureTLS(ix::HttpServer& server, bool secure) + { +#ifdef IX_SOCKS_TEST_CERTS + if (secure) + { + ix::SocketTLSOptions options; + options.tls = true; + // This certificate is valid only for wrong.invalid, not the proxy address. + options.certFile = std::string(IX_SOCKS_TEST_CERTS) + "/wrong.pem"; + options.keyFile = std::string(IX_SOCKS_TEST_CERTS) + "/wrong.key"; + options.caFile = "NONE"; + server.setTLSOptions(options); + } +#else + (void) server; + (void) secure; +#endif + } + + ix::SocketTLSOptions clientTLS() + { + ix::SocketTLSOptions options; +#ifdef IX_SOCKS_TEST_CERTS + options.caFile = std::string(IX_SOCKS_TEST_CERTS) + "/ca.pem"; +#endif + return options; + } +} // namespace + +TEST_CASE("HTTP and HTTPS retain the origin identity through SOCKS5", "[socks5][http]") +{ + bool secure = GENERATE(false, true); + if (secure && !secureAvailable()) return; + bool auth = GENERATE(false, true); + ix::HttpServer origin(ix::getFreePort(), "127.0.0.1"); + configureTLS(origin, secure); + origin.setOnConnectionCallback( + [](ix::HttpRequestPtr request, std::shared_ptr) + { + return std::make_shared( + 200, "OK", ix::HttpErrorCode::Ok, ix::WebSocketHttpHeaders(), request->uri); + }); + REQUIRE(origin.listen().first); + origin.start(); + SocksProxy proxy(Reply::IPv4, auth, true); + proxy.listenAndStart(); + ix::HttpClient client; + client.setTLSOptions(clientTLS()); + client.setProxyOptions(proxy.options()); + auto request = client.createRequest(); + request->connectTimeout = 2; + request->transferTimeout = 2; + const auto url = std::string(secure ? "https" : "http") + + "://wrong.invalid:" + std::to_string(origin.getPort()) + "/through-proxy"; + auto response = client.get(url, request); + proxy.stop(); + origin.stop(); + INFO(response->errorMsg); + INFO("proxy connections: " << proxy.connections << ", greetings: " << proxy.greetings); + INFO("target: " << proxy.request().host << ":" << proxy.request().port + << ", type: " << proxy.request().addressType); + CAPTURE(secure, auth); + REQUIRE(response->errorCode == ix::HttpErrorCode::Ok); + CHECK(response->statusCode == 200); + CHECK(response->body == "/through-proxy"); + CHECK(proxy.request().host == "wrong.invalid"); +} + +TEST_CASE("WebSocket reconnects through the configured SOCKS5 proxy", "[socks5][websocket]") +{ + bool secure = GENERATE(false, true); + if (secure && !secureAvailable()) return; + bool auth = GENERATE(false, true); + ix::HttpServer origin(ix::getFreePort(), "127.0.0.1"); + configureTLS(origin, secure); + origin.setOnClientMessageCallback( + [](std::shared_ptr, + ix::WebSocket& socket, + const ix::WebSocketMessagePtr& message) + { + if (message->type == ix::WebSocketMessageType::Message) + { + socket.send(message->str); + socket.close(); + } + }); + REQUIRE(origin.listen().first); + origin.start(); + SocksProxy proxy(Reply::IPv4, auth, true); + proxy.listenAndStart(); + std::promise received; + auto result = received.get_future(); + std::string error; + unsigned count = 0; + ix::WebSocket client; + client.setTLSOptions(clientTLS()); + client.setProxyOptions(proxy.options()); + client.setUrl(std::string(secure ? "wss" : "ws") + + "://wrong.invalid:" + std::to_string(origin.getPort()) + "/"); + client.setMinWaitBetweenReconnectionRetries(1); + client.setMaxWaitBetweenReconnectionRetries(10); + client.setOnMessageCallback( + [&](const ix::WebSocketMessagePtr& message) + { + if (message->type == ix::WebSocketMessageType::Open) client.sendText("echo"); + if (message->type == ix::WebSocketMessageType::Message && message->str == "echo" && + ++count == 2) + received.set_value(); + if (message->type == ix::WebSocketMessageType::Error) error = message->errorInfo.reason; + }); + client.start(); + auto ready = result.wait_for(std::chrono::seconds(3)); + client.stop(); + proxy.stop(); + origin.stop(); + INFO(error); + REQUIRE(ready == std::future_status::ready); + CHECK(proxy.connections >= 2); +} + +TEST_CASE("Rejected SOCKS5 tunnels never fall back to a direct connection", "[socks5]") +{ + bool secure = GENERATE(false, true); + if (secure && !secureAvailable()) return; + std::atomic requests {0}; + ix::HttpServer origin(ix::getFreePort(), "127.0.0.1"); + configureTLS(origin, secure); +#ifdef IX_SOCKS_TEST_CERTS + if (secure) + { + ix::SocketTLSOptions tls; + tls.tls = true; + tls.certFile = std::string(IX_SOCKS_TEST_CERTS) + "/server.pem"; + tls.keyFile = std::string(IX_SOCKS_TEST_CERTS) + "/server.key"; + tls.caFile = "NONE"; + origin.setTLSOptions(tls); + } +#endif + origin.setOnConnectionCallback( + [&](ix::HttpRequestPtr, std::shared_ptr) + { + ++requests; + return std::make_shared(200, "OK"); + }); + REQUIRE(origin.listen().first); + origin.start(); + SocksProxy proxy(Reply::Rejected); + proxy.listenAndStart(); + ix::HttpClient client; + client.setTLSOptions(clientTLS()); + client.setProxyOptions(proxy.options()); + auto request = client.createRequest(); + request->connectTimeout = 2; + request->transferTimeout = 2; + auto response = client.get(std::string(secure ? "https" : "http") + + "://localhost:" + std::to_string(origin.getPort()) + "/", + request); + proxy.stop(); + origin.stop(); + CHECK(response->errorCode != ix::HttpErrorCode::Ok); + CHECK(requests == 0); + CHECK(proxy.connections == 1); +} + +#if defined(IX_SOCKS_TEST_CERTS) && !defined(IXWEBSOCKET_USE_NETWORK_FRAMEWORK) +TEST_CASE("TLS through SOCKS5 still rejects the wrong destination certificate", "[socks5][tls]") +{ + ix::HttpServer origin(ix::getFreePort(), "127.0.0.1"); + configureTLS(origin, true); + origin.setOnConnectionCallback([](ix::HttpRequestPtr, std::shared_ptr) + { return std::make_shared(200, "OK"); }); + REQUIRE(origin.listen().first); + origin.start(); + SocksProxy proxy(Reply::IPv4, false, true); + proxy.listenAndStart(); + ix::HttpClient client; + client.setTLSOptions(clientTLS()); + client.setProxyOptions(proxy.options()); + auto request = client.createRequest(); + request->connectTimeout = 2; + auto response = + client.get("https://other.invalid:" + std::to_string(origin.getPort()) + "/", request); + proxy.stop(); + origin.stop(); + CHECK(response->errorCode != ix::HttpErrorCode::Ok); +} +#endif + +#ifdef IXWEBSOCKET_USE_NETWORK_FRAMEWORK +TEST_CASE("Network.framework rejects unsupported proxy configuration", "[socks5][network_tls]") +{ + SocksProxy proxy; + proxy.listenAndStart(); + std::string error; + auto socket = ix::createSocket(true, -1, error, clientTLS(), proxy.options()); + REQUIRE(socket); + CHECK_FALSE(socket->connect("localhost", 443, error, deadline())); + CHECK(error == "SOCKS5 is not supported by the Network.framework backend"); + CHECK(proxy.connections == 0); +} +#endif + +TEST_CASE("SOCKS5 bounds hostname and credential lengths", "[socks5]") +{ + const size_t size = GENERATE(size_t(255), size_t(256)); + SocksProxy proxy(Reply::IPv4, true); + proxy.listenAndStart(); + auto options = proxy.options(); + options.username.assign(255, 'u'); + options.password.assign(255, 'p'); + std::string error; + auto socket = ix::createSocket(false, -1, error, {}, options); + bool connected = socket->connect(std::string(size, 'a'), 443, error, deadline()); + CHECK(connected == (size == 255)); + if (connected) + { + CHECK(proxy.request().host.size() == 255); + CHECK(proxy.request().username == options.username); + CHECK(proxy.request().password == options.password); + } + else + CHECK_FALSE(error.empty()); +} + +TEST_CASE("SOCKS5 observes the connection deadline", "[socks5]") +{ + SocksProxy proxy(Reply::Stall); + proxy.listenAndStart(); + std::string error; + auto socket = ix::createSocket(false, -1, error, {}, proxy.options()); + const auto start = Clock::now(); + CHECK_FALSE(socket->connect("destination.invalid", + 443, + error, + [start] + { return Clock::now() - start >= std::chrono::milliseconds(80); })); + CHECK(Clock::now() - start < std::chrono::milliseconds(500)); + CHECK_FALSE(error.empty()); +} diff --git a/test/cmake/windows-dll/main.cpp b/test/cmake/windows-dll/main.cpp index 234a2ac0..b419f047 100644 --- a/test/cmake/windows-dll/main.cpp +++ b/test/cmake/windows-dll/main.cpp @@ -11,6 +11,11 @@ int main() if (!ix::initNetSystem()) return 1; { ix::WebSocket client; + ix::ProxyOptions proxy; + proxy.host = "127.0.0.1"; + client.setProxyOptions(proxy); + ix::HttpClient httpClient; + httpClient.setProxyOptions(proxy); client.close(); ix::WebSocketServer server; ix::HttpServer http;