diff --git a/CMakeLists.txt b/CMakeLists.txt index 11d1c168..d8edcbb0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -121,11 +121,24 @@ set( IXWEBSOCKET_HEADERS option(BUILD_SHARED_LIBS "Build shared libraries (.dll/.so) instead of static ones (.lib/.a)" OFF) option(USE_TLS "Enable TLS support" FALSE) +option(USE_NETWORK_FRAMEWORK "Use Apple's Network.framework TLS backend (macOS 10.15+)" OFF) +if (USE_NETWORK_FRAMEWORK) + if (NOT APPLE OR NOT CMAKE_SYSTEM_NAME STREQUAL "Darwin") + message(FATAL_ERROR "Network.framework backend currently supports macOS only") + endif() + if (USE_OPEN_SSL OR USE_LIBRE_SSL OR USE_MBED_TLS OR USE_SECURE_TRANSPORT) + message(FATAL_ERROR "USE_NETWORK_FRAMEWORK cannot be combined with another TLS backend") + endif() + if (CMAKE_OSX_DEPLOYMENT_TARGET AND CMAKE_OSX_DEPLOYMENT_TARGET VERSION_LESS 10.15) + message(FATAL_ERROR "Network.framework backend requires macOS 10.15 or later") + endif() + set(USE_TLS ON) +endif() if (USE_TLS) # default to securetranport on Apple if nothing is configured if (APPLE) - if (NOT USE_MBED_TLS AND NOT USE_OPEN_SSL AND NOT USE_LIBRE_SSL) # unless we want something else + if (NOT USE_MBED_TLS AND NOT USE_OPEN_SSL AND NOT USE_LIBRE_SSL AND NOT USE_NETWORK_FRAMEWORK) # unless we want something else set(USE_SECURE_TRANSPORT ON) endif() # default to mbedtls on windows if nothing is configured @@ -140,7 +153,10 @@ if (USE_TLS) endif() endif() - if (USE_MBED_TLS) + if (USE_NETWORK_FRAMEWORK) + list(APPEND IXWEBSOCKET_HEADERS ixwebsocket/IXSocketNetwork.h) + list(APPEND IXWEBSOCKET_SOURCES ixwebsocket/IXSocketNetwork.cpp) + elseif (USE_MBED_TLS) list( APPEND IXWEBSOCKET_HEADERS ixwebsocket/IXSocketMbedTLS.h) list( APPEND IXWEBSOCKET_SOURCES ixwebsocket/IXSocketMbedTLS.cpp) elseif (USE_SECURE_TRANSPORT) @@ -156,7 +172,7 @@ endif() if(BUILD_SHARED_LIBS) # Building shared library - + if(MSVC) # Workaround for some projects set(CMAKE_WINDOWS_EXPORT_ALL_SYMBOLS ON) @@ -182,7 +198,9 @@ endif() if (USE_TLS) target_compile_definitions(ixwebsocket PUBLIC IXWEBSOCKET_USE_TLS) - if (USE_MBED_TLS) + if (USE_NETWORK_FRAMEWORK) + target_compile_definitions(ixwebsocket PUBLIC IXWEBSOCKET_USE_NETWORK_FRAMEWORK) + elseif (USE_MBED_TLS) target_compile_definitions(ixwebsocket PUBLIC IXWEBSOCKET_USE_MBED_TLS) elseif (USE_OPEN_SSL) target_compile_definitions(ixwebsocket PUBLIC IXWEBSOCKET_USE_OPEN_SSL) @@ -196,7 +214,11 @@ if (USE_TLS) endif() if (USE_TLS) - if (USE_OPEN_SSL) + if (USE_NETWORK_FRAMEWORK) + message(STATUS "TLS configured to use Network.framework") + target_compile_options(ixwebsocket PRIVATE -fblocks) + target_link_libraries(ixwebsocket PRIVATE "-framework Network" "-framework Security" "-framework CoreFoundation") + elseif (USE_OPEN_SSL) message(STATUS "TLS configured to use openssl") # Help finding Homebrew's OpenSSL on macOS @@ -224,11 +246,11 @@ if (USE_TLS) # This MBEDTLS_FOUND check is to help find a cmake manually configured MbedTLS if (NOT MBEDTLS_FOUND) find_package(MbedTLS REQUIRED) - + if (MBEDTLS_VERSION_GREATER_THAN_3) target_compile_definitions(ixwebsocket PRIVATE IXWEBSOCKET_USE_MBED_TLS_MIN_VERSION_3) endif() - + endif() target_include_directories(ixwebsocket PUBLIC $) target_link_libraries(ixwebsocket PRIVATE ${MBEDTLS_LIBRARIES}) @@ -304,7 +326,7 @@ if (IXWEBSOCKET_INSTALL) configure_file("${CMAKE_CURRENT_LIST_DIR}/ixwebsocket-config.cmake.in" "${CMAKE_CURRENT_BINARY_DIR}/ixwebsocket-config.cmake" @ONLY) install(FILES "${CMAKE_CURRENT_BINARY_DIR}/ixwebsocket-config.cmake" DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/ixwebsocket") - + set(prefix ${CMAKE_INSTALL_PREFIX}) configure_file("${CMAKE_CURRENT_LIST_DIR}/ixwebsocket.pc.in" "${CMAKE_CURRENT_BINARY_DIR}/ixwebsocket.pc" @ONLY) install(FILES "${CMAKE_CURRENT_BINARY_DIR}/ixwebsocket.pc" DESTINATION "${CMAKE_INSTALL_LIBDIR}/pkgconfig") @@ -331,7 +353,7 @@ if (USE_TEST) add_subdirectory(test) endif() -if (BUILD_DEMO) +if (BUILD_DEMO) add_executable(demo main.cpp) - target_link_libraries(demo ixwebsocket) + target_link_libraries(demo ixwebsocket) endif() diff --git a/docs/build.md b/docs/build.md index eb615337..ea6f1150 100644 --- a/docs/build.md +++ b/docs/build.md @@ -47,6 +47,7 @@ Options for building: * `-DUSE_ZLIB=1` will enable zlib support, required for http client + server + websocket per message deflate extension * `-DUSE_TLS=1` will enable TLS support * `-DUSE_OPEN_SSL=1` will use [openssl](https://www.openssl.org/) for the TLS support (default on Linux and Windows). When using a custom version of openssl (say a prebuilt version, odd runtime problems can happens, as in #319, and special cmake trickery will be required (see this [comment](https://github.com/machinezone/IXWebSocket/issues/175#issuecomment-620231032)) +* `-DUSE_NETWORK_FRAMEWORK=ON` enables the experimental native macOS TLS backend and also enables `USE_TLS`. It requires macOS 10.15 or later and links Apple's Network, Security, and CoreFoundation frameworks, with no OpenSSL library dependency. It cannot be combined with another TLS backend; the make target below overrides cached backend selections. Client and server TLS, including mutual TLS, are supported. The existing Apple backend remains the default. * `-DUSE_MBED_TLS=1` will use [mbedlts](https://tls.mbed.org/) for the TLS support * `-DUSE_WS=1` will build the ws interactive command line tool * `-DUSE_TEST=1` will build the unittest @@ -117,3 +118,28 @@ app@ca2340eb9106:~$ ws --help ws is a websocket tool ... ``` + +### Native macOS TLS with Network.framework + +The developer makefile builds the library, `ws`, and tests in `build`: + +```sh +make -f makefile.dev ws_network_framework +make -f makefile.dev test_network_framework +``` + +Or invoke CMake directly: + +```sh +cmake -S . -B build -DUSE_NETWORK_FRAMEWORK=ON \ + -DUSE_OPEN_SSL=OFF -DUSE_LIBRE_SSL=OFF -DUSE_MBED_TLS=OFF -DUSE_SECURE_TRANSPORT=OFF +cmake --build build +``` + +To build the native backend's integration tests, add `-DUSE_TEST=ON`, then run +`ctest --test-dir build -R IXSocketNetworkTest --output-on-failure`. +The tests use the `openssl` command-line tool only to generate temporary credentials +inside the build directory. The library itself does not link OpenSSL. Tests never +modify the system keychain. + +This backend currently targets macOS; iOS, tvOS, and watchOS builds are not enabled. diff --git a/docs/usage.md b/docs/usage.md index c35c7f35..44e8c715 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -661,3 +661,32 @@ For a server, specifying `caFile` implies that: 1. It must be signed by one of the trusted roots in the file By default, a destination's hostname is always validated against the certificate that it presents. To accept certificates with any hostname, set `ix::SocketTLSOptions::disable_hostname_validation` to `true`. + +### Network.framework backend on macOS + +Build with `USE_NETWORK_FRAMEWORK=ON` to use native TLS for `wss://` and `https://` +clients and TLS-enabled WebSocket/HTTP servers. Network.framework handles connection +setup, DNS, and TCP. Application HTTP and WebSocket handling stays in IXWebSocket. +TLS 1.2 is the minimum; TLS 1.3 is negotiated when supported by the peer. + +The existing `SocketTLSOptions` fields apply: + +- `certFile` and `keyFile` accept a PEM certificate chain (leaf first) and matching, + unencrypted PEM private key. Both are required for a TLS server and optional for a + client presenting a certificate. Credentials are imported in memory, without + installing them in a keychain. +- `caFile = "SYSTEM"` uses Apple's trust evaluation and configured system trust. + A PEM CA file or in-memory PEM bundle instead restricts trust to those anchors. +- `caFile = "NONE"` disables peer verification. On a server this also disables the + requirement for a client certificate. Other CA settings require client + authentication, matching the existing server options. +- `disable_hostname_validation` skips the client's hostname check while retaining + certificate trust evaluation. IP literals are checked against IP SAN entries. +- Custom `ciphers` strings are currently rejected; leave `ciphers` at `DEFAULT`. + +The native server uses a Network.framework listener. `createSocket` cannot adopt an +existing BSD descriptor for TLS with this backend. The listener requires a numeric +bind address and uses the framework's TCP listen queue rather than the BSD backlog +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. diff --git a/ixwebsocket/IXSocket.h b/ixwebsocket/IXSocket.h index 919a3762..b9829a62 100644 --- a/ixwebsocket/IXSocket.h +++ b/ixwebsocket/IXSocket.h @@ -6,6 +6,10 @@ #pragma once +#include "IXCancellationRequest.h" +#include "IXNetSystem.h" +#include "IXProgressCallback.h" +#include "IXSelectInterrupt.h" #include #include #include @@ -14,11 +18,6 @@ #include #include -#include "IXCancellationRequest.h" -#include "IXNetSystem.h" -#include "IXProgressCallback.h" -#include "IXSelectInterrupt.h" - namespace ix { enum class PollResultType @@ -43,7 +42,6 @@ namespace ix bool wakeUpFromPoll(uint64_t wakeUpCode); bool isWakeUpFromPollSupported(); - PollResultType isReadyToWrite(int timeoutMs); PollResultType isReadyToRead(int timeoutMs); // Virtual methods @@ -58,6 +56,7 @@ namespace ix virtual std::ptrdiff_t send(char* buffer, size_t length); std::ptrdiff_t send(const std::string& buffer); virtual std::ptrdiff_t recv(void* buffer, size_t length); + virtual PollResultType isReadyToWrite(int timeoutMs); // Blocking and cancellable versions, working with socket that can be set // to non blocking mode. Used during HTTP upgrade. diff --git a/ixwebsocket/IXSocketFactory.cpp b/ixwebsocket/IXSocketFactory.cpp index e542871d..565bf29a 100644 --- a/ixwebsocket/IXSocketFactory.cpp +++ b/ixwebsocket/IXSocketFactory.cpp @@ -13,6 +13,8 @@ #include "IXSocketMbedTLS.h" #elif defined(IXWEBSOCKET_USE_OPEN_SSL) || defined(IXWEBSOCKET_USE_LIBRE_SSL) #include "IXSocketOpenSSL.h" +#elif defined(IXWEBSOCKET_USE_NETWORK_FRAMEWORK) +#include "IXSocketNetwork.h" #elif __APPLE__ #include "IXSocketAppleSSL.h" #endif @@ -45,6 +47,14 @@ namespace ix socket = ix::make_unique(tlsOptions, fd); #elif defined(IXWEBSOCKET_USE_OPEN_SSL) || defined(IXWEBSOCKET_USE_LIBRE_SSL) socket = ix::make_unique(tlsOptions, fd); +#elif defined(IXWEBSOCKET_USE_NETWORK_FRAMEWORK) + if (fd != -1) + { + errorMsg = + "Network.framework requires a native listener; BSD sockets cannot be adopted"; + return nullptr; + } + socket = ix::make_unique(tlsOptions); #elif defined(__APPLE__) socket = ix::make_unique(tlsOptions, fd); #endif diff --git a/ixwebsocket/IXSocketNetwork.cpp b/ixwebsocket/IXSocketNetwork.cpp new file mode 100644 index 00000000..0dcba213 --- /dev/null +++ b/ixwebsocket/IXSocketNetwork.cpp @@ -0,0 +1,752 @@ +/* + * IXSocketNetwork.cpp + * Native macOS TLS with bounded asynchronous I/O and poll notifications. + */ +#ifdef IXWEBSOCKET_USE_NETWORK_FRAMEWORK +#include "IXSocketNetwork.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ix +{ + namespace + { + // Level-triggered notification: one byte remains until all buffered input + // is consumed. The descriptor carries notifications, never TLS payloads. + struct NetworkSignal + { + int readFd = -1; + int writeFd = -1; + bool signalled = false; // guarded by the owning state's mutex + NetworkSignal() + { + int fds[2]; + if (::pipe(fds) != 0) return; + readFd = fds[0]; + writeFd = fds[1]; + for (int fd : fds) + { + int flags = fcntl(fd, F_GETFL); + if (flags == -1 || fcntl(fd, F_SETFL, flags | O_NONBLOCK) == -1 || + !Socket::setCloseOnExec(fd)) + { + ::close(readFd); + ::close(writeFd); + readFd = writeFd = -1; + return; + } + } + } + ~NetworkSignal() + { + if (readFd != -1) ::close(readFd); + if (writeFd != -1) ::close(writeFd); + } + void notify() + { + if (!signalled && writeFd != -1) + { + char byte = 1; + ssize_t written; + do + { + written = ::write(writeFd, &byte, 1); + } while (written < 0 && errno == EINTR); + signalled = written == 1; + } + } + void clear() + { + char byte; + while (::read(readFd, &byte, 1) > 0) + { + } + signalled = false; + } + }; + + std::string networkError(nw_error_t error) + { + if (!error) return "Network.framework connection failed"; + CFErrorRef cfError = nw_error_copy_cf_error(error); + CFStringRef description = cfError ? CFErrorCopyDescription(cfError) : nullptr; + char buffer[1024]; + std::string result = + "Network.framework error " + std::to_string(nw_error_get_error_code(error)); + if (description && + CFStringGetCString(description, buffer, sizeof(buffer), kCFStringEncodingUTF8)) + result += ": " + std::string(buffer); + if (description) CFRelease(description); + if (cfError) CFRelease(cfError); + return result; + } + + std::string readFile(const std::string& path) + { + std::ifstream file(path, std::ios::binary); + return std::string(std::istreambuf_iterator(file), {}); + } + + CFArrayRef importPEM(std::string pem, std::string& error) + { + if (pem.empty()) + { + error = "Empty certificate or private key"; + return nullptr; + } + // Common CA bundles include comments and certificate descriptions. + // SecItemImport expects just the PEM objects. + const std::string begin = "-----BEGIN CERTIFICATE-----"; + const std::string end = "-----END CERTIFICATE-----"; + if (pem.find(begin) != std::string::npos) + { + std::string certificates; + size_t offset = 0; + while ((offset = pem.find(begin, offset)) != std::string::npos) + { + size_t finish = pem.find(end, offset); + if (finish == std::string::npos) + { + error = "Unterminated PEM certificate"; + return nullptr; + } + finish += end.size(); + certificates.append(pem, offset, finish - offset); + certificates += '\n'; + offset = finish; + } + pem = std::move(certificates); + } + CFDataRef data = + CFDataCreate(nullptr, reinterpret_cast(pem.data()), pem.size()); + if (!data) + { + error = "Cannot allocate certificate data"; + return nullptr; + } + SecExternalFormat format = kSecFormatUnknown; + SecExternalItemType type = kSecItemTypeUnknown; + SecItemImportExportKeyParameters params = {}; + params.version = SEC_KEY_IMPORT_EXPORT_PARAMS_VERSION; + params.passphrase = CFSTR(""); // Never prompt for a password. + CFArrayRef items = nullptr; + OSStatus status = + SecItemImport(data, nullptr, &format, &type, 0, ¶ms, nullptr, &items); + CFRelease(data); + if (status != errSecSuccess || !items || CFArrayGetCount(items) == 0) + { + if (items) CFRelease(items); + error = "Cannot import certificate/private key: " + std::to_string(status); + return nullptr; + } + return items; + } + + // Returns owned parameters. All captured CF/dispatch objects are retained + // explicitly because this translation unit uses C++ (not Objective-C ARC). + nw_parameters_t makeParameters(SocketTLSOptions options, + bool server, + std::string host, + std::string& error) + { + if (!options.isUsingDefaultCiphers()) + { + error = "Network.framework backend does not support cipher strings"; + return nullptr; + } + if (options.certFile.empty() != options.keyFile.empty() || + (server && !options.hasCertAndKey())) + { + error = + "Network.framework requires both certFile and keyFile (required for servers)"; + return nullptr; + } + sec_identity_t identity = nullptr; + if (options.hasCertAndKey()) + { + CFArrayRef certs = importPEM(readFile(options.certFile), error); + CFArrayRef keys = certs ? importPEM(readFile(options.keyFile), error) : nullptr; + if (!certs || !keys) + { + if (certs) CFRelease(certs); + return nullptr; + } + auto cert = static_cast( + const_cast(CFArrayGetValueAtIndex(certs, 0))); + auto key = + static_cast(const_cast(CFArrayGetValueAtIndex(keys, 0))); + SecIdentityRef ref = nullptr; + if (CFGetTypeID(cert) == SecCertificateGetTypeID() && + CFGetTypeID(key) == SecKeyGetTypeID()) + ref = SecIdentityCreate(nullptr, cert, key); + if (ref) + { + identity = sec_identity_create_with_certificates(ref, certs); + CFRelease(ref); + } + CFRelease(certs); + CFRelease(keys); + if (!identity) + { + error = "Cannot create TLS identity; check certificate/key match"; + return nullptr; + } + } + CFArrayRef anchors = nullptr; + if (!options.isUsingSystemDefaults() && !options.isPeerVerifyDisabled()) + { + anchors = importPEM(options.isUsingInMemoryCAs() ? options.caFile + : readFile(options.caFile), + error); + if (!anchors) + { + if (identity) sec_release(identity); + return nullptr; + } + for (CFIndex i = 0; i < CFArrayGetCount(anchors); ++i) + { + if (CFGetTypeID(CFArrayGetValueAtIndex(anchors, i)) != + SecCertificateGetTypeID()) + { + error = "CA bundle must contain only certificates"; + CFRelease(anchors); + if (identity) sec_release(identity); + return nullptr; + } + } + } + // Capture smart ownership so verification can outlive this function. + auto anchorOwner = std::shared_ptr(anchors, + [](const void* p) + { + if (p) CFRelease(p); + }); + nw_parameters_t parameters = nw_parameters_create_secure_tcp( + ^(nw_protocol_options_t tls) { + sec_protocol_options_t security = nw_tls_copy_sec_protocol_options(tls); + sec_protocol_options_set_min_tls_protocol_version(security, + tls_protocol_version_TLSv12); + if (identity) sec_protocol_options_set_local_identity(security, identity); + if (!server) sec_protocol_options_set_tls_server_name(security, host.c_str()); + sec_protocol_options_set_peer_authentication_required( + security, !options.isPeerVerifyDisabled()); + sec_protocol_options_set_verify_block( + security, + ^(sec_protocol_metadata_t, + sec_trust_t trustObject, + sec_protocol_verify_complete_t complete) { + if (options.isPeerVerifyDisabled()) + { + complete(true); + return; + } + SecTrustRef trust = sec_trust_copy_ref(trustObject); + CFStringRef hostname = + (!server && !options.disable_hostname_validation) + ? CFStringCreateWithCString( + nullptr, host.c_str(), kCFStringEncodingUTF8) + : nullptr; + SecPolicyRef policy = SecPolicyCreateSSL(!server, hostname); + bool valid = + trust && policy && + (server || options.disable_hostname_validation || hostname); + if (valid) valid = SecTrustSetPolicies(trust, policy) == errSecSuccess; + if (valid && anchorOwner) + { + valid = + SecTrustSetAnchorCertificates( + trust, static_cast(anchorOwner.get())) == + errSecSuccess && + SecTrustSetAnchorCertificatesOnly(trust, true) == errSecSuccess; + } + if (valid) valid = SecTrustEvaluateWithError(trust, nullptr); + if (policy) CFRelease(policy); + if (hostname) CFRelease(hostname); + if (trust) CFRelease(trust); + complete(valid); + }, + dispatch_get_global_queue(QOS_CLASS_UTILITY, 0)); + sec_release(security); + }, + ^(nw_protocol_options_t tcp) { + nw_tcp_options_set_no_delay(tcp, true); + }); + if (identity) sec_release(identity); + if (!parameters) error = "Cannot create Network.framework TLS parameters"; + return parameters; + } + } // namespace + + struct SocketNetwork::State + { + std::mutex mutex; + std::condition_variable changed; + NetworkSignal signal; + nw_connection_t connection = nullptr; + dispatch_queue_t queue = + dispatch_queue_create("ixwebsocket.network", DISPATCH_QUEUE_SERIAL); + std::vector input; + size_t offset = 0; + bool ready = false; + bool closed = false; + bool eof = false; + bool receiving = false; + bool sending = false; + size_t sent = 0; + bool started = false; + bool server = false; + std::string error; + ~State() + { + if (connection) + { + nw_connection_cancel(connection); + nw_release(connection); + } + dispatch_release(queue); + } + // Called under mutex. At most 64 KiB is outstanding or buffered. + static void receive(const std::shared_ptr& state) + { + if (state->receiving || state->closed || state->eof || !state->error.empty() || + !state->input.empty()) + return; + state->receiving = true; + std::weak_ptr weak = state; + nw_connection_receive( + state->connection, + 1, + 65536, + ^(dispatch_data_t data, nw_content_context_t, bool complete, nw_error_t error) { + auto s = weak.lock(); + if (!s) return; + std::lock_guard lock(s->mutex); + s->receiving = false; + if (s->closed) return; + if (data) + { + const void* bytes = nullptr; + size_t size = 0; + dispatch_data_t mapped = dispatch_data_create_map(data, &bytes, &size); + if (mapped) + { + const char* begin = static_cast(bytes); + if (size) s->input.assign(begin, begin + size); + dispatch_release(mapped); + } + else + s->error = "Cannot map received TLS data"; + } + s->eof = complete; + if (error) s->error = networkError(error); + if (!s->input.empty() || s->eof || !s->error.empty()) + s->signal.notify(); + else + receive(s); + s->changed.notify_all(); + }); + } + }; + + SocketNetwork::SocketNetwork(const SocketTLSOptions& options) + : Socket() + , _state(std::make_shared()) + , _options(options) + { + _sockfd = _state->signal.readFd; + } + + SocketNetwork::~SocketNetwork() + { + close(); + } + + bool SocketNetwork::connect(const std::string& host, + int port, + std::string& error, + const CancellationRequest& cancelled) + { + if (port < 1 || port > 65535) + { + error = "Invalid TLS port"; + return false; + } + nw_parameters_t params = makeParameters(_options, false, host, error); + if (!params) return false; + nw_endpoint_t endpoint = + nw_endpoint_create_host(host.c_str(), std::to_string(port).c_str()); + if (!endpoint) + { + nw_release(params); + error = "Cannot create TLS endpoint"; + return false; + } + { + std::lock_guard lock(_state->mutex); + if (_state->closed || _state->connection) + { + error = "TLS socket already used"; + nw_release(endpoint); + nw_release(params); + return false; + } + _state->connection = nw_connection_create(endpoint, params); + } + nw_release(endpoint); + nw_release(params); + return start(error, cancelled); + } + + bool SocketNetwork::start(std::string& error, const CancellationRequest& cancelled) + { + auto s = _state; + std::unique_lock lock(s->mutex); + if (s->signal.readFd == -1 || !s->connection || s->closed || s->started || + !s->error.empty()) + { + error = s->error.empty() ? "Uninitialized or already used Network.framework socket" + : s->error; + return false; + } + s->started = true; + std::weak_ptr weak = s; + nw_connection_set_queue(s->connection, s->queue); + nw_connection_set_state_changed_handler( + s->connection, ^(nw_connection_state_t state, nw_error_t err) { + auto current = weak.lock(); + if (!current) return; + std::lock_guard guard(current->mutex); + if (current->closed) return; + if (state == nw_connection_state_ready) + { + current->ready = true; + State::receive(current); + } + else if (state == nw_connection_state_failed || + state == nw_connection_state_waiting) + { + current->error = networkError(err); + current->signal.notify(); + } + else if (state == nw_connection_state_cancelled) + { + current->closed = true; + current->signal.notify(); + } + current->changed.notify_all(); + }); + nw_connection_start(s->connection); + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(10); + while (!s->ready && !s->closed && s->error.empty()) + { + if ((cancelled && cancelled()) || + (s->server && std::chrono::steady_clock::now() >= deadline)) + { + s->error = "TLS connection cancelled or handshake timed out"; + break; + } + s->changed.wait_for(lock, std::chrono::milliseconds(10)); + } + if (s->ready && !s->closed && s->error.empty()) return true; + error = s->error.empty() ? "TLS connection closed during handshake" : s->error; + lock.unlock(); + close(); + return false; + } + + bool SocketNetwork::accept(std::string& error) + { + return accept(error, CancellationRequest()); + } + bool SocketNetwork::accept(std::string& error, const CancellationRequest& cancelled) + { + return start(error, cancelled); + } + + void SocketNetwork::close() + { + // State owns the notification descriptors; Socket's destructor must not close them. + _sockfd = -1; + auto s = _state; + std::lock_guard lock(s->mutex); + if (s->closed) return; + s->closed = true; + if (s->connection) nw_connection_cancel(s->connection); + s->signal.notify(); + s->changed.notify_all(); + } + + std::ptrdiff_t SocketNetwork::send(char* buffer, size_t length) + { + auto s = _state; + std::lock_guard lock(s->mutex); + if (s->closed || !s->error.empty() || !s->ready) + { + setErrno(ECONNRESET); + return -1; + } + if (!length) return 0; + if (s->sending) + { + setErrno(EWOULDBLOCK); + return -1; + } + if (s->sent) + { + size_t sent = s->sent; + s->sent = 0; + return static_cast(sent); + } + size_t count = std::min(length, size_t(65536)); + // dispatch_data_create copies the bytes when using a null destructor. + dispatch_data_t data = + dispatch_data_create(buffer, count, nullptr, DISPATCH_DATA_DESTRUCTOR_DEFAULT); + if (!data) + { + setErrno(ENOMEM); + return -1; + } + s->sending = true; + std::weak_ptr weak = s; + nw_connection_send( + s->connection, data, NW_CONNECTION_DEFAULT_MESSAGE_CONTEXT, true, ^(nw_error_t error) { + auto current = weak.lock(); + if (!current) return; + std::lock_guard guard(current->mutex); + current->sending = false; + current->sent = count; + if (error && !current->closed) + { + current->error = networkError(error); + current->signal.notify(); + } + current->changed.notify_all(); + }); + dispatch_release(data); + setErrno(EWOULDBLOCK); + // Report bytes only after completion, so writeBytes() cannot return and + // close the connection while the final asynchronous send is still pending. + return -1; + } + + std::ptrdiff_t SocketNetwork::recv(void* buffer, size_t length) + { + auto s = _state; + std::lock_guard lock(s->mutex); + if (!length) return 0; + size_t count = std::min(length, s->input.size() - s->offset); + if (count) + { + memcpy(buffer, s->input.data() + s->offset, count); + s->offset += count; + if (s->offset == s->input.size()) + { + s->input.clear(); + s->offset = 0; + if (!s->closed && !s->eof && s->error.empty()) s->signal.clear(); + State::receive(s); + } + return static_cast(count); + } + if (s->closed || !s->error.empty()) + { + setErrno(ECONNRESET); + return -1; + } + if (s->eof) + { + setErrno(0); + return 0; + } + setErrno(EWOULDBLOCK); + return -1; + } + + PollResultType SocketNetwork::isReadyToWrite(int timeoutMs) + { + auto s = _state; + std::unique_lock lock(s->mutex); + auto available = [&] { return !s->sending || s->closed || !s->error.empty(); }; + if (timeoutMs < 0) + s->changed.wait(lock, available); + else if (!s->changed.wait_for(lock, std::chrono::milliseconds(timeoutMs), available)) + return PollResultType::Timeout; + return s->closed || !s->error.empty() ? PollResultType::Error + : PollResultType::ReadyForWrite; + } + + struct SocketNetworkListener::State + { + std::mutex mutex; + std::condition_variable changed; + NetworkSignal signal; + nw_listener_t listener = nullptr; + dispatch_queue_t queue = + dispatch_queue_create("ixwebsocket.listener", DISPATCH_QUEUE_SERIAL); + std::deque pending; + bool ready = false; + bool stopped = false; + std::string error; + ~State() + { + if (listener) + { + nw_listener_cancel(listener); + nw_release(listener); + } + for (auto connection : pending) + { + nw_connection_cancel(connection); + nw_release(connection); + } + dispatch_release(queue); + } + }; + + SocketNetworkListener::SocketNetworkListener() + : _state(std::make_shared()) + { + } + SocketNetworkListener::~SocketNetworkListener() + { + stop(); + } + + bool SocketNetworkListener::listen(const std::string& host, + int& port, + int addressFamily, + size_t maxPending, + const SocketTLSOptions& options, + std::string& error) + { + _options = options; + struct in6_addr addr; + if ((addressFamily != AF_INET && addressFamily != AF_INET6) || + ix::inet_pton(addressFamily, host.c_str(), &addr) != 1 || port < 0 || port > 65535) + { + error = "Network.framework listener requires a numeric bind address and valid port"; + return false; + } + nw_parameters_t parameters = makeParameters(options, true, "", error); + if (!parameters) return false; + nw_parameters_set_reuse_local_address(parameters, true); + nw_endpoint_t endpoint = + nw_endpoint_create_host(host.c_str(), std::to_string(port).c_str()); + nw_parameters_set_local_endpoint(parameters, endpoint); + nw_release(endpoint); + auto s = _state; + std::unique_lock lock(s->mutex); + if (s->signal.readFd == -1) + { + nw_release(parameters); + error = "Cannot create listener notification pipe"; + return false; + } + s->listener = nw_listener_create(parameters); + nw_release(parameters); + if (!s->listener) + { + error = "Cannot create Network.framework listener"; + return false; + } + std::weak_ptr weak = s; + nw_listener_set_queue(s->listener, s->queue); + nw_listener_set_state_changed_handler( + s->listener, ^(nw_listener_state_t state, nw_error_t err) { + auto current = weak.lock(); + if (!current) return; + std::lock_guard guard(current->mutex); + if (state == nw_listener_state_ready) current->ready = true; + if (state == nw_listener_state_failed || state == nw_listener_state_waiting) + { + current->error = networkError(err); + current->signal.notify(); + } + current->changed.notify_all(); + }); + nw_listener_set_new_connection_handler(s->listener, ^(nw_connection_t connection) { + auto current = weak.lock(); + if (!current) + { + nw_connection_cancel(connection); + return; + } + std::lock_guard guard(current->mutex); + if (current->stopped || current->pending.size() >= maxPending) + { + nw_connection_cancel(connection); + return; + } + nw_retain(connection); + current->pending.push_back(connection); + current->signal.notify(); + }); + nw_listener_start(s->listener); + if (!s->changed.wait_for( + lock, std::chrono::seconds(10), [&] { return s->ready || !s->error.empty(); }) || + !s->error.empty()) + { + error = s->error.empty() ? "Network.framework listener startup timed out" : s->error; + return false; + } + port = nw_listener_get_port(s->listener); + return true; + } + + int SocketNetworkListener::getFd() const + { + return _state->signal.readFd; + } + + std::string SocketNetworkListener::getError() const + { + std::lock_guard lock(_state->mutex); + return _state->error; + } + + std::unique_ptr SocketNetworkListener::take(std::string& remoteIp, + int& remotePort) + { + std::lock_guard lock(_state->mutex); + if (_state->pending.empty()) return nullptr; + auto connection = _state->pending.front(); + _state->pending.pop_front(); + if (_state->pending.empty()) _state->signal.clear(); + nw_endpoint_t endpoint = nw_connection_copy_endpoint(connection); + const char* hostname = nw_endpoint_get_hostname(endpoint); + remoteIp = hostname ? hostname : ""; + remotePort = nw_endpoint_get_port(endpoint); + nw_release(endpoint); + std::unique_ptr socket(new SocketNetwork(_options)); + socket->_state->connection = connection; // transfer ownership + socket->_state->server = true; + return socket; + } + + void SocketNetworkListener::stop() + { + std::lock_guard lock(_state->mutex); + if (_state->stopped) return; + _state->stopped = true; + if (_state->listener) nw_listener_cancel(_state->listener); + for (auto connection : _state->pending) + { + nw_connection_cancel(connection); + nw_release(connection); + } + _state->pending.clear(); + _state->signal.notify(); + } +} // namespace ix +#endif diff --git a/ixwebsocket/IXSocketNetwork.h b/ixwebsocket/IXSocketNetwork.h new file mode 100644 index 00000000..be335ebf --- /dev/null +++ b/ixwebsocket/IXSocketNetwork.h @@ -0,0 +1,62 @@ +/* + * IXSocketNetwork.h + * Native macOS TLS sockets backed by Network.framework. + */ +#pragma once + +#ifdef IXWEBSOCKET_USE_NETWORK_FRAMEWORK +#include "IXSocket.h" +#include "IXSocketTLSOptions.h" + +namespace ix +{ + class SocketNetworkListener; + + class SocketNetwork final : public Socket + { + public: + explicit SocketNetwork(const SocketTLSOptions& options); + ~SocketNetwork() override; + bool connect(const std::string& host, + int port, + std::string& error, + const CancellationRequest& cancelled) override; + bool accept(std::string& error) override; + bool accept(std::string& error, const CancellationRequest& cancelled); + void close() override; + std::ptrdiff_t send(char* buffer, size_t length) override; + std::ptrdiff_t recv(void* buffer, size_t length) override; + PollResultType isReadyToWrite(int timeoutMs) override; + + private: + struct State; + std::shared_ptr _state; + SocketTLSOptions _options; + bool start(std::string& error, const CancellationRequest& cancelled); + friend class SocketNetworkListener; + }; + + // A native listener supplies connections instead of accepting BSD descriptors. + class SocketNetworkListener + { + public: + SocketNetworkListener(); + ~SocketNetworkListener(); + bool listen(const std::string& host, + int& port, + int addressFamily, + size_t maxPending, + const SocketTLSOptions& options, + std::string& error); + int getFd() const; + std::string getError() const; + std::unique_ptr take(std::string& remoteIp, int& remotePort); + void stop(); + + private: + struct State; + std::shared_ptr _state; + SocketTLSOptions _options; + }; +} // namespace ix +#endif diff --git a/ixwebsocket/IXSocketServer.cpp b/ixwebsocket/IXSocketServer.cpp index 1373a084..3780f316 100644 --- a/ixwebsocket/IXSocketServer.cpp +++ b/ixwebsocket/IXSocketServer.cpp @@ -13,6 +13,9 @@ #include "IXSocket.h" #include "IXSocketConnect.h" #include "IXSocketFactory.h" +#ifdef IXWEBSOCKET_USE_NETWORK_FRAMEWORK +#include "IXSocketNetwork.h" +#endif #include #include #include @@ -93,6 +96,17 @@ namespace ix return std::make_pair(false, errMsg); } +#ifdef IXWEBSOCKET_USE_NETWORK_FRAMEWORK + if (_socketTLSOptions.tls) + { + _networkListener.reset(new SocketNetworkListener()); + std::string error; + bool success = _networkListener->listen( + _host, _port, _addressFamily, _maxConnections, _socketTLSOptions, error); + if (!success) _networkListener.reset(); + return std::make_pair(success, error); + } +#endif // Get a socket for accepting connections. if ((_serverFd = socket(_addressFamily, SOCK_STREAM, 0)) < 0) { @@ -253,12 +267,15 @@ namespace ix _stop = false; } +#ifdef IXWEBSOCKET_USE_NETWORK_FRAMEWORK + if (_networkListener) _networkListener->stop(); +#endif // Join all threads and make sure that all connections are terminated if (_gcThread.joinable()) { _stopGc = true; { - std::lock_guard lock{ _conditionVariableMutexGC }; + std::lock_guard lock {_conditionVariableMutexGC}; _canContinueGC = true; } _conditionVariableGC.notify_one(); @@ -316,6 +333,13 @@ namespace ix void SocketServer::run() { +#ifdef IXWEBSOCKET_USE_NETWORK_FRAMEWORK + if (_networkListener) + { + runNetwork(); + return; + } +#endif // Set the socket to non blocking mode, so that accept calls are not blocking SocketConnect::configure(_serverFd); @@ -361,7 +385,8 @@ namespace ix socklen_t addressLen = sizeof(client); memset(&client, 0, sizeof(client)); - if ((clientFd = static_cast(accept(_serverFd, (struct sockaddr*) &client, &addressLen))) < 0) + if ((clientFd = static_cast( + accept(_serverFd, (struct sockaddr*) &client, &addressLen))) < 0) { if (!Socket::isWaitNeeded()) { @@ -411,7 +436,8 @@ namespace ix { char remoteIp4[INET_ADDRSTRLEN]; auto* client4 = reinterpret_cast(&client); - if (ix::inet_ntop(AF_INET, &client4->sin_addr, remoteIp4, INET_ADDRSTRLEN) == nullptr) + if (ix::inet_ntop(AF_INET, &client4->sin_addr, remoteIp4, INET_ADDRSTRLEN) == + nullptr) { int err = Socket::getErrno(); std::stringstream ss; @@ -493,6 +519,53 @@ namespace ix } } +#ifdef IXWEBSOCKET_USE_NETWORK_FRAMEWORK + void SocketServer::runNetwork() + { + setThreadName("Srv:ac:" + std::to_string(_port)); + while (!_stop) + { + auto result = + Socket::poll(true, 100, _networkListener->getFd(), _acceptSelectInterrupt); + if (result != PollResultType::ReadyForRead || _stop) continue; + auto listenerError = _networkListener->getError(); + if (!listenerError.empty()) + { + logError("SocketServer::run() listener failed: " + listenerError); + return; + } + std::string remoteIp; + int remotePort = 0; + auto socket = _networkListener->take(remoteIp, remotePort); + if (!socket) continue; + if (getConnectedClientsCount() >= _maxConnections) + { + logError("SocketServer::run() reached max connections = " + + std::to_string(_maxConnections)); + continue; + } + std::string error; + if (!socket->init(error) || !socket->accept(error, [this] { return _stop.load(); })) + { + logError("SocketServer::run() tls accept failed for client " + remoteIp + ":" + + std::to_string(remotePort) + ": " + error); + continue; + } + auto connectionState = _connectionStateFactory(); + connectionState->setOnSetTerminatedCallback([this] { onSetTerminatedCallback(); }); + connectionState->setRemoteIp(remoteIp); + connectionState->setRemotePort(remotePort); + if (_stop) return; + std::unique_ptr accepted(std::move(socket)); + std::lock_guard lock(_connectionsThreadsMutex); + _connectionsThreads.push_back(std::make_pair( + connectionState, + std::thread( + &SocketServer::handleConnection, this, std::move(accepted), connectionState))); + } + } +#endif + size_t SocketServer::getConnectionsThreadsCount() { std::lock_guard lock(_connectionsThreadsMutex); @@ -524,8 +597,9 @@ namespace ix if (!_stopGc) { std::unique_lock lock(_conditionVariableMutexGC); - if(!_canContinueGC) { - _conditionVariableGC.wait(lock, [this]{ return _canContinueGC; }); + if (!_canContinueGC) + { + _conditionVariableGC.wait(lock, [this] { return _canContinueGC; }); } _canContinueGC = false; } @@ -542,13 +616,13 @@ namespace ix // a connection got terminated, we can run the connection thread GC, // so wake up the thread responsible for that { - std::lock_guard lock{ _conditionVariableMutexGC }; + std::lock_guard lock {_conditionVariableMutexGC}; _canContinueGC = true; } _conditionVariableGC.notify_one(); } - int SocketServer::getPort() + int SocketServer::getPort() { return _port; } diff --git a/ixwebsocket/IXSocketServer.h b/ixwebsocket/IXSocketServer.h index 3e3a57e0..0b6804d9 100644 --- a/ixwebsocket/IXSocketServer.h +++ b/ixwebsocket/IXSocketServer.h @@ -24,6 +24,9 @@ namespace ix { class Socket; +#ifdef IXWEBSOCKET_USE_NETWORK_FRAMEWORK + class SocketNetworkListener; +#endif enum class LogLevel { @@ -85,6 +88,7 @@ namespace ix int getBacklog(); std::size_t getMaxConnections(); int getAddressFamily(); + protected: // Logging void logError(const std::string& str); @@ -103,6 +107,10 @@ namespace ix // socket for accepting connections socket_t _serverFd; +#ifdef IXWEBSOCKET_USE_NETWORK_FRAMEWORK + std::unique_ptr _networkListener; + void runNetwork(); +#endif std::atomic _stop; @@ -148,6 +156,6 @@ namespace ix // as a connection std::condition_variable _conditionVariableGC; std::mutex _conditionVariableMutexGC; - bool _canContinueGC{ false }; + bool _canContinueGC {false}; }; } // namespace ix diff --git a/ixwebsocket/IXUserAgent.cpp b/ixwebsocket/IXUserAgent.cpp index 89dcb0f3..790a6d51 100644 --- a/ixwebsocket/IXUserAgent.cpp +++ b/ixwebsocket/IXUserAgent.cpp @@ -72,6 +72,8 @@ namespace ix ss << " ssl/mbedtls " << MBEDTLS_VERSION_STRING; #elif defined(IXWEBSOCKET_USE_OPEN_SSL) ss << " ssl/OpenSSL " << OPENSSL_VERSION_TEXT; +#elif defined(IXWEBSOCKET_USE_NETWORK_FRAMEWORK) + ss << " ssl/Network.framework"; #elif __APPLE__ ss << " ssl/SecureTransport"; #endif diff --git a/ixwebsocket/IXWebSocketVersion.h b/ixwebsocket/IXWebSocketVersion.h index 47e329ae..14bd8eec 100644 --- a/ixwebsocket/IXWebSocketVersion.h +++ b/ixwebsocket/IXWebSocketVersion.h @@ -6,4 +6,4 @@ #pragma once -#define IX_WEBSOCKET_VERSION "11.4.6" +#define IX_WEBSOCKET_VERSION "12.0.1" diff --git a/makefile.dev b/makefile.dev index 39a7d378..c0614ee8 100644 --- a/makefile.dev +++ b/makefile.dev @@ -61,6 +61,15 @@ ws_openssl_install: ws_mbedtls: mkdir -p build && (cd build ; cmake -DCMAKE_INSTALL_MESSAGE=LAZY -DCMAKE_BUILD_TYPE=Debug -DUSE_TLS=1 -DUSE_WS=1 -DUSE_MBED_TLS=1 $(MBEDTLS_CMAKE_PREFIX) .. ; ninja) +# Replace any cached TLS backend selection in the standard build directory. +.PHONY: ws_network_framework test_network_framework +ws_network_framework: + cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug -DUSE_TLS=ON -DUSE_NETWORK_FRAMEWORK=ON -DUSE_OPEN_SSL=OFF -DUSE_LIBRE_SSL=OFF -DUSE_MBED_TLS=OFF -DUSE_SECURE_TRANSPORT=OFF -DUSE_WS=ON -DUSE_TEST=ON + cmake --build build --parallel + +test_network_framework: ws_network_framework + ctest --test-dir build -R '^IXSocketNetworkTest$$' --output-on-failure + ws_no_ssl: mkdir -p build && (cd build ; cmake -DCMAKE_INSTALL_MESSAGE=LAZY -DCMAKE_BUILD_TYPE=Debug -DUSE_WS=1 .. ; ninja) diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 6bdf79bd..4f14382b 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -104,3 +104,23 @@ foreach(TEST_TARGET_NAME ${TEST_TARGET_NAMES}) WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}) endforeach() + +if (USE_NETWORK_FRAMEWORK) + # Only the test credential generator needs the openssl command-line tool. + find_program(NETWORK_TEST_OPENSSL openssl) + if (NOT NETWORK_TEST_OPENSSL) + message(FATAL_ERROR "Network.framework tests need the openssl CLI to generate test credentials") + endif() + set(NETWORK_TEST_CERTS "${CMAKE_CURRENT_BINARY_DIR}/network-certs") + add_custom_command(OUTPUT "${NETWORK_TEST_CERTS}/ready" + COMMAND bash "${CMAKE_CURRENT_SOURCE_DIR}/generate_network_certs.sh" "${NETWORK_TEST_OPENSSL}" "${NETWORK_TEST_CERTS}" + DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/generate_network_certs.sh" + VERBATIM) + add_custom_target(network_test_certs DEPENDS "${NETWORK_TEST_CERTS}/ready") + add_executable(IXSocketNetworkTest IXSocketNetworkTest.cpp) + add_dependencies(IXSocketNetworkTest network_test_certs) + target_compile_definitions(IXSocketNetworkTest PRIVATE IX_NETWORK_TEST_CERTS="${NETWORK_TEST_CERTS}") + target_link_libraries(IXSocketNetworkTest ixwebsocket_test ixwebsocket Catch2) + add_test(NAME IXSocketNetworkTest COMMAND IXSocketNetworkTest) + set_tests_properties(IXSocketNetworkTest PROPERTIES TIMEOUT 45) +endif() diff --git a/test/IXSocketNetworkTest.cpp b/test/IXSocketNetworkTest.cpp new file mode 100644 index 00000000..2e65772b --- /dev/null +++ b/test/IXSocketNetworkTest.cpp @@ -0,0 +1,265 @@ +#include "IXTest.h" +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace ix; +namespace +{ + std::string credential(const std::string& name) + { + return std::string(IX_NETWORK_TEST_CERTS) + "/" + name; + } + SocketTLSOptions serverOptions(const std::string& name = "server") + { + SocketTLSOptions options; + options.tls = true; + options.certFile = credential(name + ".pem"); + options.keyFile = credential(name + ".key"); + options.caFile = "NONE"; + return options; + } + SocketTLSOptions clientOptions() + { + SocketTLSOptions options; + options.caFile = credential("ca.pem"); + return options; + } + void checkRequest(SocketTLSOptions serverTLS, + SocketTLSOptions clientTLS, + bool expected, + const std::string& host = "127.0.0.1", + int family = AF_INET, + const std::string& peerHost = "") + { + HttpServer server(0, host, 5, 128, family); + server.setTLSOptions(serverTLS); + // Exercise several read/send buffers, including the final send before close. + const std::string body(256 * 1024, 'x'); + server.setOnConnectionCallback( + [&](HttpRequestPtr request, std::shared_ptr) + { + return std::make_shared( + 200, "OK", HttpErrorCode::Ok, WebSocketHttpHeaders(), request->body); + }); + server.setLogCallback([](LogLevel, const std::string&) { }); + auto listening = server.listen(); + INFO(listening.second); + REQUIRE(listening.first); + server.start(); + HttpClient client; + client.setTLSOptions(clientTLS); + std::string target = peerHost.empty() ? host : peerHost; + std::string url = "https://" + (family == AF_INET6 ? "[" + target + "]" : target) + ":" + + std::to_string(server.getPort()) + "/"; + auto args = client.createRequest(url); + args->connectTimeout = 3; + args->transferTimeout = 5; + args->compress = false; + auto response = client.post(url, body, args); + INFO(response->errorMsg); + CHECK((response->errorCode == HttpErrorCode::Ok) == expected); + if (expected) + { + CHECK(response->statusCode == 200); + CHECK(response->body.size() == body.size()); + CHECK(bool(response->body == body)); + } + server.stop(); + } +} // namespace + +TEST_CASE("Network.framework TLS", "[network_tls]") +{ + SECTION("custom CA and IPv4 SAN") + { + checkRequest(serverOptions(), clientOptions(), true); + } + SECTION("intermediate certificate chain") + { + checkRequest(serverOptions("chain"), clientOptions(), true); + } + SECTION("DNS hostname") + { + checkRequest(serverOptions(), clientOptions(), true, "127.0.0.1", AF_INET, "localhost"); + } + SECTION("custom CA and IPv6 SAN") + { + checkRequest(serverOptions(), clientOptions(), true, "::1", AF_INET6); + } + SECTION("wrong hostname") + { + checkRequest(serverOptions("wrong"), clientOptions(), false); + } + SECTION("hostname checking can be disabled") + { + auto client = clientOptions(); + client.disable_hostname_validation = true; + checkRequest(serverOptions("wrong"), client, true); + } + SECTION("expired certificate") + { + auto client = clientOptions(); + client.caFile = credential("expired-ca.pem"); + checkRequest(serverOptions("expired"), client, false, "127.0.0.1", AF_INET, "localhost"); + } + SECTION("SYSTEM rejects an untrusted private CA") + { + SocketTLSOptions client; + checkRequest(serverOptions(), client, false); + } + SECTION("NONE disables verification") + { + auto client = clientOptions(); + client.caFile = "NONE"; + checkRequest(serverOptions("expired"), client, true); + } + SECTION("in-memory CA") + { + auto client = clientOptions(); + std::ifstream file(credential("ca.pem")); + client.caFile = + "# CA bundle with comments\n" + std::string(std::istreambuf_iterator(file), {}); + checkRequest(serverOptions(), client, true); + } + SECTION("mutual TLS") + { + auto server = serverOptions(); + server.caFile = credential("ca.pem"); + auto client = clientOptions(); + client.certFile = credential("client.pem"); + client.keyFile = credential("client.key"); + checkRequest(server, client, true); + } + SECTION("missing client certificate") + { + auto server = serverOptions(); + server.caFile = credential("ca.pem"); + checkRequest(server, clientOptions(), false); + } + SECTION("untrusted client certificate") + { + auto server = serverOptions(); + server.caFile = credential("ca.pem"); + auto client = clientOptions(); + client.certFile = credential("untrusted.pem"); + client.keyFile = credential("untrusted.key"); + checkRequest(server, client, false); + } + SECTION("a server certificate cannot authenticate a client") + { + auto server = serverOptions(); + server.caFile = credential("ca.pem"); + auto client = clientOptions(); + client.certFile = credential("server.pem"); + client.keyFile = credential("server.key"); + checkRequest(server, client, false); + } + SECTION("invalid CA fails closed") + { + auto options = clientOptions(); + options.caFile = credential("client.key"); + SocketNetwork socket(options); + std::string error; + REQUIRE(socket.init(error)); + CHECK_FALSE(socket.connect("127.0.0.1", 443, error, [] { return false; })); + CHECK_FALSE(error.empty()); + } + SECTION("unsupported cipher strings fail explicitly") + { + auto options = serverOptions(); + options.ciphers = "ECDHE-RSA-AES128-GCM-SHA256"; + HttpServer server(0); + server.setTLSOptions(options); + auto result = server.listen(); + CHECK_FALSE(result.first); + CHECK(result.second.find("cipher") != std::string::npos); + } + SECTION("bad identity fails at listen") + { + auto options = serverOptions(); + options.keyFile = credential("client.key"); + HttpServer server(0); + server.setTLSOptions(options); + auto result = server.listen(); + CHECK_FALSE(result.first); + CHECK_FALSE(result.second.empty()); + } + SECTION("poll can be interrupted") + { + SocketNetwork socket(clientOptions()); + std::string error; + REQUIRE(socket.init(error)); + auto polling = std::async(std::launch::async, [&] { return socket.isReadyToRead(3000); }); + REQUIRE(socket.wakeUpFromPoll(SelectInterrupt::kCloseRequest)); + CHECK(polling.get() == PollResultType::CloseRequest); + } + SECTION("connection cancellation") + { + SocketNetwork socket(clientOptions()); + std::string error; + REQUIRE(socket.init(error)); + CHECK_FALSE(socket.connect("192.0.2.1", 443, error, [] { return true; })); + CHECK_FALSE(error.empty()); + } +} + +TEST_CASE("Network.framework WebSocket echo", "[network_tls]") +{ + WebSocketServer server(0, "127.0.0.1"); + server.setTLSOptions(serverOptions()); + server.setOnClientMessageCallback( + [](std::shared_ptr, WebSocket& socket, const WebSocketMessagePtr& message) + { + if (message->type == WebSocketMessageType::Message) socket.sendBinary(message->str); + }); + auto listening = server.listen(); + INFO(listening.second); + REQUIRE(listening.first); + server.start(); + WebSocket client; + client.setTLSOptions(clientOptions()); + client.setUrl("wss://127.0.0.1:" + std::to_string(server.getPort()) + "/"); + std::promise received; + std::atomic completed(false); + const std::string body(256 * 1024, 'z'); + client.disableAutomaticReconnection(); + client.setOnMessageCallback( + [&](const WebSocketMessagePtr& message) + { + if (message->type == WebSocketMessageType::Open) client.sendBinary(body); + if (message->type == WebSocketMessageType::Message && !completed.exchange(true)) + received.set_value(message->str); + if (message->type == WebSocketMessageType::Error && !completed.exchange(true)) + received.set_value(message->errorInfo.reason); + }); + auto result = received.get_future(); + client.start(); + auto status = result.wait_for(std::chrono::seconds(5)); + CHECK(status == std::future_status::ready); + if (status == std::future_status::ready) CHECK(bool(result.get() == body)); + client.stop(); + server.stop(); +} + +TEST_CASE("Network.framework server stop cancels a stalled handshake", "[network_tls]") +{ + HttpServer server(0, "127.0.0.1"); + server.setTLSOptions(serverOptions()); + server.setLogCallback([](LogLevel, const std::string&) { }); + REQUIRE(server.listen().first); + server.start(); + Socket peer; + std::string error; + REQUIRE(peer.init(error)); + REQUIRE(peer.connect("127.0.0.1", server.getPort(), error, [] { return false; })); + msleep(50); + auto start = std::chrono::steady_clock::now(); + server.stop(); + CHECK(std::chrono::steady_clock::now() - start < std::chrono::seconds(1)); +} diff --git a/test/IXSocketTest.cpp b/test/IXSocketTest.cpp index a69e1a91..2507965d 100644 --- a/test/IXSocketTest.cpp +++ b/test/IXSocketTest.cpp @@ -84,7 +84,12 @@ TEST_CASE("socket", "[socket]") std::string errMsg; bool tls = true; SocketTLSOptions tlsOptions; +#ifdef IXWEBSOCKET_USE_NETWORK_FRAMEWORK + // Exercise native system trust; the vendored CA bundle dates from 2014. + tlsOptions.caFile = "SYSTEM"; +#else tlsOptions.caFile = "cacert.pem"; +#endif std::shared_ptr socket = createSocket(tls, -1, errMsg, tlsOptions); std::string host("www.google.com"); int port = 443; diff --git a/test/generate_network_certs.sh b/test/generate_network_certs.sh new file mode 100644 index 00000000..13047f3f --- /dev/null +++ b/test/generate_network_certs.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# Test-only credentials, generated outside the source tree. No keychain changes. +set -euo pipefail +openssl_bin=$1 +cert_dir=$2 +mkdir -p "$cert_dir" +"$openssl_bin" req -x509 -newkey rsa:2048 -nodes -sha256 -days 3650 \ + -subj /CN=IXWebSocket-Test-CA -addext basicConstraints=critical,CA:TRUE -addext keyUsage=critical,keyCertSign,cRLSign -keyout "$cert_dir/ca.key" -out "$cert_dir/ca.pem" >/dev/null 2>&1 +for name in server wrong client; do + "$openssl_bin" req -new -newkey rsa:2048 -nodes -sha256 -subj "/CN=$name" \ + -keyout "$cert_dir/$name.key" -out "$cert_dir/$name.csr" >/dev/null 2>&1 + days=365 + if [[ "$name" == client ]]; then + printf 'basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature,keyEncipherment\nextendedKeyUsage=clientAuth\n' > "$cert_dir/extensions" + else + san='DNS:localhost,IP:127.0.0.1,IP:::1' + if [[ "$name" == wrong ]]; then san='DNS:wrong.invalid'; fi + printf 'basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\nsubjectAltName=%s\n' "$san" > "$cert_dir/extensions" + fi + "$openssl_bin" x509 -req -sha256 -days "$days" -in "$cert_dir/$name.csr" \ + -CA "$cert_dir/ca.pem" -CAkey "$cert_dir/ca.key" -CAcreateserial \ + -extfile "$cert_dir/extensions" -out "$cert_dir/$name.pem" >/dev/null 2>&1 +done +"$openssl_bin" req -x509 -newkey rsa:2048 -nodes -sha256 -days 3650 \ + -subj /CN=Untrusted-Client -keyout "$cert_dir/untrusted.key" -out "$cert_dir/untrusted.pem" >/dev/null 2>&1 +# A chain with an intermediate verifies that certFile sends the full chain. +"$openssl_bin" req -new -newkey rsa:2048 -nodes -sha256 -subj /CN=Intermediate \ + -keyout "$cert_dir/intermediate.key" -out "$cert_dir/intermediate.csr" >/dev/null 2>&1 +printf 'basicConstraints=critical,CA:TRUE,pathlen:0\nkeyUsage=critical,keyCertSign,cRLSign\n' > "$cert_dir/extensions" +"$openssl_bin" x509 -req -sha256 -days 365 -in "$cert_dir/intermediate.csr" \ + -CA "$cert_dir/ca.pem" -CAkey "$cert_dir/ca.key" -CAcreateserial \ + -extfile "$cert_dir/extensions" -out "$cert_dir/intermediate.pem" >/dev/null 2>&1 +printf 'basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\nsubjectAltName=DNS:localhost,IP:127.0.0.1\n' > "$cert_dir/extensions" +"$openssl_bin" x509 -req -sha256 -days 365 -in "$cert_dir/server.csr" \ + -CA "$cert_dir/intermediate.pem" -CAkey "$cert_dir/intermediate.key" -CAcreateserial \ + -extfile "$cert_dir/extensions" -out "$cert_dir/chain.pem" >/dev/null 2>&1 +cat "$cert_dir/intermediate.pem" >> "$cert_dir/chain.pem" +cp "$cert_dir/server.key" "$cert_dir/chain.key" +# Use the checked-in credential whose validity ended in 2021 for the expiry case. +fixture_dir="$(cd "$(dirname "$0")" && pwd)/.certs" +cp "$fixture_dir/trusted-server-crt.pem" "$cert_dir/expired.pem" +cp "$fixture_dir/trusted-server-key.pem" "$cert_dir/expired.key" +cp "$fixture_dir/trusted-ca-crt.pem" "$cert_dir/expired-ca.pem" +touch "$cert_dir/ready"