diff --git a/CBL_C.xcodeproj/project.pbxproj b/CBL_C.xcodeproj/project.pbxproj index 93a0a5c7..bbbd394e 100644 --- a/CBL_C.xcodeproj/project.pbxproj +++ b/CBL_C.xcodeproj/project.pbxproj @@ -33,6 +33,12 @@ 1BDEBC5B2FEB5E690063786B /* ReplicatorPropEncTest_Cpp.cc in Sources */ = {isa = PBXBuildFile; fileRef = 1BDEBC592FEB5E690063786B /* ReplicatorPropEncTest_Cpp.cc */; }; 1BDEBC5C2FEB5E690063786B /* ReplicatorPropEncTest_Cpp.cc in Sources */ = {isa = PBXBuildFile; fileRef = 1BDEBC592FEB5E690063786B /* ReplicatorPropEncTest_Cpp.cc */; }; 1BDEBC5D2FEB5E690063786B /* ReplicatorPropEncTest_Cpp.cc in Sources */ = {isa = PBXBuildFile; fileRef = 1BDEBC592FEB5E690063786B /* ReplicatorPropEncTest_Cpp.cc */; }; + 1BF09102301140600093C32B /* URLEndpointListener.hh in Headers */ = {isa = PBXBuildFile; fileRef = 1BF09101301140600093C32B /* URLEndpointListener.hh */; settings = {ATTRIBUTES = (Public, ); }; }; + 1BF09103301140600093C32B /* URLEndpointListener.hh in Headers */ = {isa = PBXBuildFile; fileRef = 1BF09101301140600093C32B /* URLEndpointListener.hh */; settings = {ATTRIBUTES = (Public, ); }; }; + 1BF0911830127EB00093C32B /* URLEndpointListenerTest_Cpp.cc in Sources */ = {isa = PBXBuildFile; fileRef = 1BF0911730127EB00093C32B /* URLEndpointListenerTest_Cpp.cc */; }; + 1BF0911930127EB00093C32B /* URLEndpointListenerTest_Cpp.cc in Sources */ = {isa = PBXBuildFile; fileRef = 1BF0911730127EB00093C32B /* URLEndpointListenerTest_Cpp.cc */; }; + 1BF0911A30127EB00093C32B /* URLEndpointListenerTest_Cpp.cc in Sources */ = {isa = PBXBuildFile; fileRef = 1BF0911730127EB00093C32B /* URLEndpointListenerTest_Cpp.cc */; }; + 1BF0911B30127EB00093C32B /* URLEndpointListenerTest_Cpp.cc in Sources */ = {isa = PBXBuildFile; fileRef = 1BF0911730127EB00093C32B /* URLEndpointListenerTest_Cpp.cc */; }; 1BF219BA2D7F87E2009534EC /* CBLTLSIdentity.h in Headers */ = {isa = PBXBuildFile; fileRef = 1BF219B92D7F87E2009534EC /* CBLTLSIdentity.h */; settings = {ATTRIBUTES = (Public, ); }; }; 1BF219BB2D7F87E2009534EC /* CBLTLSIdentity.h in Headers */ = {isa = PBXBuildFile; fileRef = 1BF219B92D7F87E2009534EC /* CBLTLSIdentity.h */; settings = {ATTRIBUTES = (Public, ); }; }; 1BF219C02D7F930F009534EC /* CBLTLSIdentity_CAPI.cc in Sources */ = {isa = PBXBuildFile; fileRef = 1BF219BF2D7F930F009534EC /* CBLTLSIdentity_CAPI.cc */; }; @@ -580,6 +586,9 @@ 1BC5D9652D6E4DA60080153E /* CBLURLEndpointListener_CAPI.cc */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = CBLURLEndpointListener_CAPI.cc; sourceTree = ""; }; 1BDEBBF12FBB98A10063786B /* EncryptableTest_Cpp.cc */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = EncryptableTest_Cpp.cc; sourceTree = ""; }; 1BDEBC592FEB5E690063786B /* ReplicatorPropEncTest_Cpp.cc */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = ReplicatorPropEncTest_Cpp.cc; sourceTree = ""; }; + 1BF09101301140600093C32B /* URLEndpointListener.hh */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = URLEndpointListener.hh; sourceTree = ""; }; + 1BF0911630127EB00093C32B /* URLEndpointListenerTest_Cpp.hh */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = URLEndpointListenerTest_Cpp.hh; sourceTree = ""; }; + 1BF0911730127EB00093C32B /* URLEndpointListenerTest_Cpp.cc */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = URLEndpointListenerTest_Cpp.cc; sourceTree = ""; }; 1BF219B92D7F87E2009534EC /* CBLTLSIdentity.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CBLTLSIdentity.h; sourceTree = ""; }; 1BF219BF2D7F930F009534EC /* CBLTLSIdentity_CAPI.cc */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = CBLTLSIdentity_CAPI.cc; sourceTree = ""; }; 1BF21A3C2D84E419009534EC /* TLSIdentityTest.cc */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = TLSIdentityTest.cc; sourceTree = ""; }; @@ -1075,6 +1084,7 @@ 400AB0522C2E66B500DB6223 /* QueryIndex.hh */, 27C9B5F121F7D74A0040BC45 /* Replicator.hh */, 1B8BE8C3300570A500162AC5 /* TLSIdentity.hh */, + 1BF09101301140600093C32B /* URLEndpointListener.hh */, 400AB0542C2E7AC300DB6223 /* VectorIndex.hh */, ); path = "cbl++"; @@ -1118,6 +1128,8 @@ 1B7694AA2DA07261006EEEAB /* TLSIdentityTest.hh */, 1B21A7D82D711B29000CA0D5 /* URLEndpointListenerTest.cc */, 1B49DBCF2DAF106F000F382E /* URLEndpointListenerTest.hh */, + 1BF0911730127EB00093C32B /* URLEndpointListenerTest_Cpp.cc */, + 1BF0911630127EB00093C32B /* URLEndpointListenerTest_Cpp.hh */, 40FE2E0F2C12AF52005E99E9 /* VectorSearchTest.hh */, 406E46D22BACAEFF0088198C /* VectorSearchTest.cc */, 400AB0412C2E669500DB6223 /* VectorSearchTest_Cpp.cc */, @@ -1292,6 +1304,7 @@ isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; files = ( + 1BF09102301140600093C32B /* URLEndpointListener.hh in Headers */, 27984E292249A240000FE777 /* CouchbaseLite.h in Headers */, 27984E302249A240000FE777 /* CBL_Compat.h in Headers */, 400983512D0769630029F26E /* CBLLogSinks.h in Headers */, @@ -1349,6 +1362,7 @@ isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; files = ( + 1BF09103301140600093C32B /* URLEndpointListener.hh in Headers */, 40B32F832E54034500D48F37 /* CouchbaseLite.h in Headers */, 40B32F842E54034500D48F37 /* CBL_Compat.h in Headers */, 40B32F852E54034500D48F37 /* CBLLogSinks.h in Headers */, @@ -2201,6 +2215,7 @@ 2736A635242E5A74002B9D65 /* ReplicatorEETest.cc in Sources */, 1BDEBBF32FBB98A10063786B /* EncryptableTest_Cpp.cc in Sources */, 93C70D3526D01B5A0093E927 /* BlobTest.cc in Sources */, + 1BF0911830127EB00093C32B /* URLEndpointListenerTest_Cpp.cc in Sources */, FC0907E828AD763600201B07 /* DocumentTest_Cpp.cc in Sources */, FC09077C28A5F3EF00201B07 /* CollectionTest_Cpp.cc in Sources */, 1B8BE8DA30057FB600162AC5 /* TLSIdentityTest_Cpp.cc in Sources */, @@ -2267,6 +2282,7 @@ 275B359E234D064600FE9CF0 /* QueryTest.cc in Sources */, 277CC99522BC23DE00B245CB /* ReplicatorTest.cc in Sources */, 400AB0512C2E669F00DB6223 /* VectorSearchTest_Cpp.cc in Sources */, + 1BF0911A30127EB00093C32B /* URLEndpointListenerTest_Cpp.cc in Sources */, 2736A634242E5A74002B9D65 /* ReplicatorEETest.cc in Sources */, FC09078A28A5F40200201B07 /* CollectionTest_Cpp.cc in Sources */, 27D30F9F23A2D30500392107 /* PerfTest.cc in Sources */, @@ -2314,6 +2330,7 @@ 40B32EF92E53EAF600D48F37 /* QueryTest.cc in Sources */, 40B32EFA2E53EAF600D48F37 /* ReplicatorTest.cc in Sources */, 40B32EFB2E53EAF600D48F37 /* VectorSearchTest_Cpp.cc in Sources */, + 1BF0911B30127EB00093C32B /* URLEndpointListenerTest_Cpp.cc in Sources */, 40B32EFC2E53EAF600D48F37 /* ReplicatorEETest.cc in Sources */, 40B32EFD2E53EAF600D48F37 /* CollectionTest_Cpp.cc in Sources */, 40B32EFE2E53EAF600D48F37 /* PerfTest.cc in Sources */, @@ -2360,6 +2377,7 @@ FC645E1C29085C89007D5536 /* ReplicatorPropEncTest.cc in Sources */, FC645E1529085C63007D5536 /* DocumentTest.cc in Sources */, FCE497D32907963900EF2354 /* CBLCatchTests.mm in Sources */, + 1BF0911930127EB00093C32B /* URLEndpointListenerTest_Cpp.cc in Sources */, AED22E7F291BE1B0005F065D /* QueryTest_Cpp.cc in Sources */, 1BF21A3E2D84E419009534EC /* TLSIdentityTest.cc in Sources */, FC645E1A29085C84007D5536 /* ReplicatorCollectionTest_Cpp.cc in Sources */, diff --git a/include/cbl++/Base.hh b/include/cbl++/Base.hh index 89cdd657..92137a69 100644 --- a/include/cbl++/Base.hh +++ b/include/cbl++/Base.hh @@ -18,19 +18,17 @@ #pragma once #include "cbl/CBLBase.h" +#include "cbl/CBLLog.h" #include "cbl/CBLQueryTypes.h" #include "fleece/slice.hh" #include +#include #include #include #include #include #include -#if DEBUG -#include "cbl/CBLLog.h" -#endif - CBL_ASSUME_NONNULL_BEGIN @@ -133,6 +131,29 @@ namespace cbl { throw cbl::Error{error.domain, error.code, message.asString()}; } } + + /** Invokes fn (typically a user-supplied C++ callback), catching and logging any exception + it throws instead of letting it escape, and returning `false` instead. For use by + trampolines that bridge a C++ callable into a plain C-API callback invoked from a + context that isn't C++-exception-safe (e.g. a network/TLS layer mid-handshake, possibly + backed by plain C underneath) -- an escaping exception there wouldn't propagate to any + caller, it would just call std::terminate() and kill the whole process. + @param className The class the trampoline belongs to, used only for the log message. + @param what A short description of the operation, used only for the log message. */ + template + inline bool invokeSafely(CBLLogDomain logDomain, const char* className, const char* what, Fn&& fn) noexcept { + try { + return fn(); + } catch (const cbl::Error& error) { + CBL_Log(logDomain, kCBLLogError, "%s::%s threw error %d/%d: %s", + className, what, error.domain, error.code, error.what()); + } catch (const std::exception& error) { + CBL_Log(logDomain, kCBLLogError, "%s::%s threw %s", className, what, error.what()); + } catch (...) { + CBL_Log(logDomain, kCBLLogError, "%s::%s threw an unknown exception", className, what); + } + return false; + } } // For use by the cbl++ headers only: generates the public boilerplate members (ctors, diff --git a/include/cbl++/CouchbaseLite.hh b/include/cbl++/CouchbaseLite.hh index 0a65dd5c..86289721 100644 --- a/include/cbl++/CouchbaseLite.hh +++ b/include/cbl++/CouchbaseLite.hh @@ -101,4 +101,5 @@ #include "QueryIndex.hh" #include "Replicator.hh" #include "TLSIdentity.hh" +#include "URLEndpointListener.hh" #include "VectorIndex.hh" diff --git a/include/cbl++/Replicator.hh b/include/cbl++/Replicator.hh index e1b1839a..ea3fa89b 100644 --- a/include/cbl++/Replicator.hh +++ b/include/cbl++/Replicator.hh @@ -18,6 +18,7 @@ #pragma once #include "cbl++/Document.hh" +#include "cbl++/TLSIdentity.hh" #include "cbl/CBLReplicator.h" #include "cbl/CBLDefaults.h" #include @@ -89,6 +90,16 @@ namespace cbl { return Authenticator(CBLAuth_CreateSession(slice(sessionId), cname)); } +#ifdef COUCHBASE_ENTERPRISE + /** Creates a certificate authenticator using a TLS client identity. (Enterprise Edition only.) */ + static Authenticator certificateAuthenticator(const TLSIdentity& identity) { + if ( !identity ) { + throw Error{kCBLDomain, kCBLErrorInvalidParameter, "identity must not be empty"}; + } + return Authenticator(CBLAuth_CreateCertificate(identity.ref())); + } +#endif + protected: friend class ReplicatorConfiguration; diff --git a/include/cbl++/TLSIdentity.hh b/include/cbl++/TLSIdentity.hh index 8a4eee70..2ad72941 100644 --- a/include/cbl++/TLSIdentity.hh +++ b/include/cbl++/TLSIdentity.hh @@ -102,12 +102,12 @@ namespace cbl { try { customFree(externalKey); } catch (const cbl::Error& error) { - CBL_Log(kCBLLogDomainNetwork, kCBLLogError, "ExternalKeyHolder::customFree threw error %d/%d: %s", + CBL_Log(kCBLLogDomainListener, kCBLLogError, "ExternalKeyHolder::customFree threw error %d/%d: %s", error.domain, error.code, error.what()); } catch (const std::exception& error) { - CBL_Log(kCBLLogDomainNetwork, kCBLLogError, "ExternalKeyHolder::customFree threw %s", error.what()); + CBL_Log(kCBLLogDomainListener, kCBLLogError, "ExternalKeyHolder::customFree threw %s", error.what()); } catch (...) { - CBL_Log(kCBLLogDomainNetwork, kCBLLogError, "ExternalKeyHolder::customFree threw an unknown exception"); + CBL_Log(kCBLLogDomainListener, kCBLLogError, "ExternalKeyHolder::customFree threw an unknown exception"); } } @@ -145,21 +145,21 @@ namespace cbl { CBLExternalKeyCallbacks cCallbacks = {}; cCallbacks.publicKeyData = [](void* rawContext, void* output, size_t outputMaxLen, size_t* outputLen) -> bool { - return invokeSafely("publicKeyData", [&] { + return internal::invokeSafely(kCBLLogDomainListener, "ExternalKeyHolder", "publicKeyData", [&] { auto& holder = ((ExternalKeyContext*)rawContext)->holder; return copyResult(holder.publicKeyData(holder.externalKey), output, outputMaxLen, outputLen); }); }; cCallbacks.decrypt = [](void* rawContext, FLSlice input, void* output, size_t outputMaxLen, size_t* outputLen) -> bool { - return invokeSafely("decrypt", [&] { + return internal::invokeSafely(kCBLLogDomainListener, "ExternalKeyHolder", "decrypt", [&] { auto& holder = ((ExternalKeyContext*)rawContext)->holder; return copyResult(holder.decrypt(holder.externalKey, input), output, outputMaxLen, outputLen); }); }; cCallbacks.sign = [](void* rawContext, CBLSignatureDigestAlgorithm digestAlgorithm, FLSlice inputData, void* outSignature) -> bool { - return invokeSafely("sign", [&] { + return internal::invokeSafely(kCBLLogDomainListener, "ExternalKeyHolder", "sign", [&] { auto* context = (ExternalKeyContext*)rawContext; auto& holder = context->holder; auto result = holder.sign(holder.externalKey, digestAlgorithm, inputData); @@ -239,25 +239,6 @@ namespace cbl { if ( result->size > 0 ) memcpy(output, result->buf, result->size); return true; } - - // Invokes fn, catching and logging any exception it throws. Required because the C API - // invokes these trampolines from a noexcept path (litecore's - // ExternalKeyPair::_decrypt/_sign), where an escaping exception would call - // std::terminate() instead of just failing the TLS operation. - template - static bool invokeSafely(const char* what, Fn&& fn) noexcept { - try { - return fn(); - } catch (const cbl::Error& error) { - CBL_Log(kCBLLogDomainNetwork, kCBLLogError, "ExternalKeyHolder::%s threw error %d/%d: %s", - what, error.domain, error.code, error.what()); - } catch (const std::exception& error) { - CBL_Log(kCBLLogDomainNetwork, kCBLLogError, "ExternalKeyHolder::%s threw %s", what, error.what()); - } catch (...) { - CBL_Log(kCBLLogDomainNetwork, kCBLLogError, "ExternalKeyHolder::%s threw an unknown exception", what); - } - return false; - } }; /** An X.509 certificate, or the first link of a certificate chain. @@ -321,6 +302,7 @@ namespace cbl { private: friend class TLSIdentity; + friend class ListenerAuthenticator; struct adopt_t {}; inline static constexpr adopt_t adopt{}; @@ -476,6 +458,8 @@ namespace cbl { CBL_REFCOUNTED_BOILERPLATE(TLSIdentity, RefCounted, CBLTLSIdentity) private: + friend class URLEndpointListener; + struct adopt_t {}; inline static constexpr adopt_t adopt{}; diff --git a/include/cbl++/URLEndpointListener.hh b/include/cbl++/URLEndpointListener.hh new file mode 100644 index 00000000..54e2ccb2 --- /dev/null +++ b/include/cbl++/URLEndpointListener.hh @@ -0,0 +1,320 @@ +// +// URLEndpointListener.hh +// +// Copyright (c) 2026 Couchbase, Inc All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#pragma once + +#ifdef COUCHBASE_ENTERPRISE + +#include "cbl++/Base.hh" +#include "cbl++/Collection.hh" +#include "cbl++/TLSIdentity.hh" +#include "cbl/CBLURLEndpointListener.h" +#include "fleece/Fleece.hh" +#include +#include +#include +#include +#include +#include + +CBL_ASSUME_NONNULL_BEGIN + +namespace cbl { + + /** An authenticator used by \ref URLEndpointListener to verify client credentials. + \note ENTERPRISE EDITION ONLY */ + class ListenerAuthenticator { + public: + /** Callback for verifying client credentials via HTTP Basic Authentication. */ + using PasswordAuthCallback = std::function; + + /** Callback for verifying a client's certificate when TLS client-certificate authentication is used. */ + using CertAuthCallback = std::function; + + /** Creates an empty (null) authenticator. */ + ListenerAuthenticator() = default; + + /** Creates a password authenticator that verifies client credentials via HTTP Basic Authentication. + @param callback The callback used to verify a client's username/password. + @throws cbl::Error If callback is falsy (empty). */ + static ListenerAuthenticator passwordAuthenticator(PasswordAuthCallback callback) { + if ( !callback ) { + throw Error{kCBLDomain, kCBLErrorInvalidParameter, "callback must not be empty"}; + } + return ListenerAuthenticator(std::move(callback)); + } + + /** Creates a certificate authenticator that verifies a client's certificate using the given callback. + @param callback The callback used to verify a client's certificate. + @throws cbl::Error If callback is falsy (empty). */ + static ListenerAuthenticator certAuthenticator(CertAuthCallback callback) { + if ( !callback ) { + throw Error{kCBLDomain, kCBLErrorInvalidParameter, "callback must not be empty"}; + } + return ListenerAuthenticator(std::move(callback)); + } + + /** Creates a certificate authenticator that trusts any client certificate signed by the given + root certificate chain. + @param rootCerts The root certificate chain to trust. + @throws cbl::Error If rootCerts is falsy (empty). */ + static ListenerAuthenticator certAuthenticator(const Cert& rootCerts) { + if ( !rootCerts ) { + throw Error{kCBLDomain, kCBLErrorInvalidParameter, "rootCerts must not be empty"}; + } + return ListenerAuthenticator(CBLListenerAuth_CreateCertificateWithRootCerts(rootCerts.ref())); + } + + /** Returns a pointer to the underlying C CBLListenerAuthenticator object, or NULL if this + is an empty (null) authenticator. */ + CBLListenerAuthenticator* _cbl_nullable ref() const {return _ref.get();} + + protected: + friend class URLEndpointListenerConfiguration; + + private: + // The C API only accepts a plain function pointer, so PasswordAuthCallback/CertAuthCallback + // (arbitrary capturing C++ callables) are boxed here, and a captureless static trampoline + // (_callPasswordAuth/_callCertAuth) is handed to the C API instead, along with a pointer to + // this box as its `context`. _callback keeps that box alive for as long as any copy of this + // authenticator exists (see URLEndpointListener::_authenticator for why that matters). + using Callback = std::variant; + + explicit ListenerAuthenticator(PasswordAuthCallback callback) + :_callback(std::make_shared(std::move(callback))) + { + _ref = std::shared_ptr( + CBLListenerAuth_CreatePassword(&_callPasswordAuth, _callback.get()), CBLListenerAuth_Free); + } + + explicit ListenerAuthenticator(CertAuthCallback callback) + :_callback(std::make_shared(std::move(callback))) + { + _ref = std::shared_ptr( + CBLListenerAuth_CreateCertificate(&_callCertAuth, _callback.get()), CBLListenerAuth_Free); + } + + // For the root-certs case, which needs no callback/context at all. + explicit ListenerAuthenticator(CBLListenerAuthenticator* _cbl_nullable auth) + :_ref(auth, CBLListenerAuth_Free) + { } + + // These trampolines are invoked from the network/TLS layer during a handshake -- a + // context that isn't C++-exception-safe, since an escaping exception could call + // std::terminate() instead of just failing this one connection attempt (the same reason + // KeyPair's sign/decrypt/publicKeyData trampolines in TLSIdentity.hh use the same + // internal::invokeSafely helper) -- so any exception the user's callback throws is caught, + // logged, and turned into a plain authentication failure (false) instead. + static bool _callPasswordAuth(void* context, FLString username, FLString password) noexcept { + return internal::invokeSafely(kCBLLogDomainListener, "ListenerAuthenticator", "passwordAuthCallback", [&] { + return std::get(*(Callback*)context)(slice(username), slice(password)); + }); + } + + static bool _callCertAuth(void* context, CBLCert* cert) noexcept { + return internal::invokeSafely(kCBLLogDomainListener, "ListenerAuthenticator", "certAuthCallback", [&] { + return std::get(*(Callback*)context)(Cert(cert)); + }); + } + + std::shared_ptr _ref; + std::shared_ptr _callback; + }; + + /** The configuration of a \ref URLEndpointListener. + \note ENTERPRISE EDITION ONLY */ + class URLEndpointListenerConfiguration { + public: + /** Creates a configuration for the given collections. + @param collections The collections to make available for replication. */ + URLEndpointListenerConfiguration(std::vector collections) + :_collections(std::move(collections)) + { } + + //-- Accessors: + + /** Returns the configured collections. */ + std::vector collections() const {return _collections;} + + //-- Network: + + /** The port that the listener will listen on. The default value, zero, means the listener will + automatically select an available port when started. */ + uint16_t port = 0; + + /** The network interface, as an IP address or a network interface name such as "en0", that the + listener will listen on. The default value, an empty string, means the listener will listen + on all network interfaces. */ + std::string networkInterface; + + //-- TLS: + + /** Disables TLS communication. The default value, false, means TLS is enabled by default. */ + bool disableTLS = false; + + /** The TLS identity to use for TLS communication, if \ref disableTLS is false. If left unset, + the listener generates and uses its own anonymous self-signed identity. */ + TLSIdentity tlsIdentity; + + //-- Authentication: + + /** The authenticator used by the listener to authenticate clients, if any. */ + ListenerAuthenticator authenticator; + + //-- Replication behavior: + + /** Allows delta sync when replicating with the listener. The default value is false. */ + bool enableDeltaSync = false; + + /** Allows only pull replication, so clients may only pull changes from the listener. + The default value is false. */ + bool readOnly = false; + + private: + friend class URLEndpointListener; + + // Builds the C config with everything except .collections/.collectionCount, which the + // caller (URLEndpointListener's constructor) must fill in itself: those point at a + // CBLCollection* array whose backing storage this method has no place to keep alive + // beyond its own return. + CBLURLEndpointListenerConfiguration toCConfigWithoutCollections() const { + CBLURLEndpointListenerConfiguration c{}; + c.port = port; + if ( !networkInterface.empty() ) c.networkInterface = slice(networkInterface); + c.disableTLS = disableTLS; + c.tlsIdentity = tlsIdentity.ref(); + c.authenticator = authenticator.ref(); + c.enableDeltaSync = enableDeltaSync; + c.readOnly = readOnly; + return c; + } + + std::vector _collections; + }; + + /** A listener that serves the collections of local databases over the network, to enable + peer-to-peer sync with incoming replicator connections. + \note ENTERPRISE EDITION ONLY */ + class URLEndpointListener : protected RefCounted { + public: + /** The connection status of a listener: its total and active connection counts. */ + using ConnectionStatus = CBLConnectionStatus; + + /** Creates a URL endpoint listener with the given configuration. + @param config The listener's configuration. + @throws cbl::Error If the listener cannot be created. */ + explicit URLEndpointListener(const URLEndpointListenerConfiguration& config) + :_authenticator(config.authenticator) + { + auto collections = config.collections(); + std::vector cCollections; + cCollections.reserve(collections.size()); + for ( auto& collection : collections ) cCollections.push_back(collection.ref()); + + CBLURLEndpointListenerConfiguration c_config = config.toCConfigWithoutCollections(); + c_config.collections = cCollections.data(); + c_config.collectionCount = cCollections.size(); + + CBLError error{}; + _ref = (CBLRefCounted*)CBLURLEndpointListener_Create(&c_config, &error); + internal::check(_ref != nullptr, error); + } + + /** The listening port of the listener. If the listener is not started, the port will be zero. */ + uint16_t port() const {return CBLURLEndpointListener_Port(ref());} + + /** The TLS identity used by the listener for TLS communication, or a falsy TLSIdentity if the + listener is not started, or if TLS is disabled. + @note The returned identity remains valid only until the listener is stopped or released. + Assign it to a variable of your own if you need it to outlive that. */ + TLSIdentity tlsIdentity() const {return TLSIdentity(CBLURLEndpointListener_TLSIdentity(ref()));} + + /** The possible URLs of the listener, or a falsy MutableArray if the listener is not started. */ + fleece::MutableArray urls() const { + FLMutableArray flUrls = CBLURLEndpointListener_Urls(ref()); + fleece::MutableArray result(flUrls); + FLMutableArray_Release(flUrls); + return result; + } + + /** Returns the current connection status of the listener. */ + ConnectionStatus status() const {return CBLURLEndpointListener_Status(ref());} + + /** Starts the listener. + @throws cbl::Error If the listener cannot be started. */ + void start() { + CBLError error{}; + internal::check(CBLURLEndpointListener_Start(ref(), &error), error); + } + + /** Stops the listener. */ + void stop() {CBLURLEndpointListener_Stop(ref());} + + private: + // Keeps the authenticator's callback context alive for as long as this URLEndpointListener + // itself lives, decoupled from the ListenerConfiguration used to construct it (which the + // caller may reasonably discard right after construction, as with ReplicatorConfiguration). + // This matters because the underlying C listener does NOT extend that lifetime itself: it + // heap-allocates its own shallow copy of the CBLListenerAuthenticator struct (sharing the + // same context pointer, see CBLURLEndpointListener_Internal.hh), so if nothing else kept the + // original context alive, it would dangle the moment the config's ListenerAuthenticator was + // destroyed -- unlike tlsIdentity/collections, which the listener retains via CBL_Retain. + ListenerAuthenticator _authenticator; + + CBL_REFCOUNTED_WITHOUT_COPY_MOVE_BOILERPLATE(URLEndpointListener, RefCounted, CBLURLEndpointListener) + + public: + /** Copy constructor. Both `*this` and `other` refer to the same underlying + \ref CBLURLEndpointListener handle (its refcount is incremented) and share the + authenticator's callback context. */ + URLEndpointListener(const URLEndpointListener& other) noexcept + :RefCounted(other) + ,_authenticator(other._authenticator) + { } + + /** Move constructor. Takes over `other`'s \ref CBLURLEndpointListener handle and + authenticator context, leaving `other` empty. */ + URLEndpointListener(URLEndpointListener&& other) noexcept + :RefCounted(static_cast(other)) + ,_authenticator(std::move(other._authenticator)) + { } + + /** Copy assignment. Releases the currently-referenced handle (if any), then makes + `*this` refer to the same \ref CBLURLEndpointListener as `other` (refcount + incremented) and share its authenticator context. */ + URLEndpointListener& operator=(const URLEndpointListener& other) noexcept { + RefCounted::operator=(other); + _authenticator = other._authenticator; + return *this; + } + + /** Move assignment. Releases the currently-referenced handle (if any), then takes + over `other`'s \ref CBLURLEndpointListener handle and authenticator context; + `other` is left empty. */ + URLEndpointListener& operator=(URLEndpointListener&& other) noexcept { + RefCounted::operator=(static_cast(other)); + _authenticator = std::move(other._authenticator); + return *this; + } + }; + +} + +CBL_ASSUME_NONNULL_END + +#endif //#ifdef COUCHBASE_ENTERPRISE diff --git a/test/TLSIdentityTest_Cpp.cc b/test/TLSIdentityTest_Cpp.cc index 9063f818..f19750b2 100644 --- a/test/TLSIdentityTest_Cpp.cc +++ b/test/TLSIdentityTest_Cpp.cc @@ -18,8 +18,8 @@ #include "CBLTest_Cpp.hh" #include "TLSIdentityTest.hh" -#include "URLEndpointListenerTest.hh" // for the shared readFile() asset helper, and the - // listener/replicator fixture used below +#include "URLEndpointListenerTest_Cpp.hh" // for the shared readFile() asset helper, and the + // C++ listener/replicator fixture used below #include "fleece/Mutable.hh" #include #include @@ -33,6 +33,7 @@ #ifdef COUCHBASE_ENTERPRISE +using namespace std; using namespace std::chrono; using namespace fleece; using namespace cbl; @@ -44,7 +45,7 @@ TEST_CASE_METHOD(TLSIdentityTest_Cpp, "C++ Self-Signed Cert Identity", "[TLSIden // Load a known RSA private key (instead of the private, test-only // CBLKeyPair_GenerateRSAKeyPair API, which has no C++ wrapper) via the public // KeyPair::createWithPrivateKeyData factory. - string pem = URLEndpointListenerTest::readFile("private_key_of_self_signed_cert.pem"); + string pem = URLEndpointListenerTest_Cpp::readFile("private_key_of_self_signed_cert.pem"); KeyPair keypair = KeyPair::createWithPrivateKeyData(slice{pem.c_str(), pem.size() + 1}); MutableDict attributes = MutableDict::newDict(); @@ -134,13 +135,11 @@ TEST_CASE_METHOD(TLSIdentityTest_Cpp, "C++ External Keys", "[TLSIdentity]") { } // Ported from TLSIdentityTest.cc's "Self-Signed Identity with PrivateKey Callback": builds the -// identity via the C++ API, then bridges into the still-C CBLURLEndpointListener/replicator via -// KeyPair::ref()/TLSIdentity::ref() -- there's no C++ URLEndpointListener wrapper yet. This is -// the one deferred test worth porting now (see the comment further down), since it's the only -// one that actually drives ExternalKeyHolder's decrypt/sign callbacks through a real TLS -// handshake; the others mostly re-exercise listener/replicator plumbing that's unrelated to -// this header. -TEST_CASE_METHOD(URLEndpointListenerTest, "C++ Self-Signed Identity with PrivateKey Callback", "[TLSIdentity]") { +// identity via the C++ API and drives it through a real TLS handshake using the C++ +// URLEndpointListener wrapper -- this is the one test that actually exercises +// ExternalKeyHolder's decrypt/sign callbacks through a live connection; the others mostly +// re-exercise listener/replicator plumbing unrelated to this header (see further down). +TEST_CASE_METHOD(URLEndpointListenerTest_Cpp, "C++ Self-Signed Identity with PrivateKey Callback", "[TLSIdentity]") { constexpr size_t keySizeInBits = 2048; // Creates a KeyPair wrapping an Apple Keychain-backed external key (callbacks defined in @@ -179,47 +178,34 @@ TEST_CASE_METHOD(URLEndpointListenerTest, "C++ Self-Signed Identity with Private duration_cast(TLSIdentityTest::OneYear).count()); CHECK(identity); - // Initializes a listener with a config using the identity, bridged via TLSIdentity::ref() - // since CBLURLEndpointListenerConfiguration is still a plain C API. - createNumberedDocsWithPrefix(cy[0], 20, "doc2"); - createNumberedDocsWithPrefix(cy[1], 20, "doc2"); - CBLURLEndpointListenerConfiguration listenerConfig { - cy.data(), - 2, - 0, // port - {}, // networkInterface - false // disableTLS - }; - listenerConfig.tlsIdentity = identity.ref(); - listenerConfig.authenticator = CBLListenerAuth_CreatePassword([](void* ctx, FLString usr, FLString psw) { - return usr == TLSIdentityTest::kUser && psw == TLSIdentityTest::kPassword; - }, nullptr); - REQUIRE(listenerConfig.authenticator); - - CBLError outError{}; - CBLURLEndpointListener* listener = CBLURLEndpointListener_Create(&listenerConfig, &outError); - CHECK(outError.code == 0); - CHECK(listener); - - // Starts the listener. - outError.code = 0; - bool started = CBLURLEndpointListener_Start(listener, &outError); - CHECK(outError.code == 0); - CHECK(started); - - // Starts a single shot replicator connecting to the listener. - std::vector colConfigs; - configOneShotReplicator(listener, colConfigs); - config.authenticator = CBLAuth_CreatePassword(TLSIdentityTest::kUser, TLSIdentityTest::kPassword); - REQUIRE(config.authenticator); - - replicate(); - - // Stops the listener. - CBLURLEndpointListener_Stop(listener); - - CBLURLEndpointListener_Release(listener); - CBLListenerAuth_Free(listenerConfig.authenticator); + // Initializes a listener with a config using the identity. Scoped in a block so the + // listener and its config -- both of which retain their own copy of `identity` (the C++ + // config struct stores a TLSIdentity by value, unlike the C API's borrowed raw pointer + // field, and the listener itself retains its own internal copy too) -- release those + // copies before the counterFree check below. + { + createNumberedDocsWithPrefix(cy[0], 20, "doc2"); + createNumberedDocsWithPrefix(cy[1], 20, "doc2"); + URLEndpointListenerConfiguration listenerConfig({cy[0], cy[1]}); + listenerConfig.disableTLS = false; + listenerConfig.tlsIdentity = identity; + listenerConfig.authenticator = ListenerAuthenticator::passwordAuthenticator( + [](std::string_view usr, std::string_view psw) { + return slice(usr) == TLSIdentityTest::kUser && slice(psw) == TLSIdentityTest::kPassword; + }); + + URLEndpointListener listener(listenerConfig); + listener.start(); + + // Starts a single shot replicator connecting to the listener. + configOneShotReplicator(listenerConfig, listener); + config.authenticator = Authenticator::basicAuthenticator((std::string_view)TLSIdentityTest::kUser, + (std::string_view)TLSIdentityTest::kPassword); + + replicate(); + + listener.stop(); + } // Release the identity and keypair now (rather than waiting for them to go out of scope) // so the counters below reflect the callbacks having actually fired. @@ -264,7 +250,7 @@ TEST_CASE_METHOD(TLSIdentityTest_Cpp, "C++ Identity With Label", "[TLSIdentity]" #endif //#if !defined(__linux__) && !defined(__ANDROID__) TEST_CASE_METHOD(TLSIdentityTest_Cpp, "C++ Get CertChain", "[TLSIdentity]") { - string pemChain = URLEndpointListenerTest::readFile("cert_chain.pem"); + string pemChain = URLEndpointListenerTest_Cpp::readFile("cert_chain.pem"); Cert cert = Cert::createWithData(slice{pemChain}); CHECK(cert); @@ -289,30 +275,130 @@ TEST_CASE_METHOD(TLSIdentityTest_Cpp, "C++ Get CertChain", "[TLSIdentity]") { CHECK(!iter); } -// The following tests from TLSIdentityTest.cc exercise a TLSIdentity together with a -// CBLURLEndpointListener / replicator. They mostly re-exercise listener/replicator plumbing -// rather than anything specific to this header, so they're left for whenever -// include/cbl++/URLEndpointListener.hh gets a C++ wrapper (unlike "Self-Signed Identity with -// PrivateKey Callback" above, which was worth porting now via KeyPair::ref()/TLSIdentity::ref() -// since it's the only one that drives ExternalKeyHolder's decrypt/sign callbacks through a real -// TLS handshake). Revisit once that wrapper exists: -// - "Use Identity Created with Label" -// - "Self-Signed Identity with Private KeyData" -// - "Identity from KeyPair and Certs" -#if 0 - -TEST_CASE_METHOD(URLEndpointListenerTest, "C++ Use Identity Created with Label", "[TLSIdentity]") { - // TODO: port using cbl::TLSIdentity + cbl::URLEndpointListener once the latter exists. -} +#if !defined(__linux__) && !defined(__ANDROID__) + +TEST_CASE_METHOD(URLEndpointListenerTest_Cpp, "C++ Use Identity Created with Label", "[TLSIdentity]") { + TLSIdentity identity; + + SECTION("First Pass - Create Identity with Label") { + // Clean the identity from the system. + (void)TLSIdentity::deleteIdentityWithLabel(TLSIdentityTest::Label); + + MutableDict attributes = MutableDict::newDict(); + attributes[kCBLCertAttrKeyCommonName] = TLSIdentityTest::CN; + identity = TLSIdentity::createIdentity(kCBLKeyUsagesServerAuth, attributes, + duration_cast(TLSIdentityTest::OneYear).count(), + TLSIdentityTest::Label); + } -TEST_CASE_METHOD(URLEndpointListenerTest, "C++ Self-Signed Identity with Private KeyData", "[TLSIdentity]") { - // TODO: port using cbl::TLSIdentity + cbl::URLEndpointListener once the latter exists. + SECTION("Second Pass - Retrieve the Identity by the Label") { + identity = TLSIdentity::identityWithLabel(TLSIdentityTest::Label); + identityLabelsToDelete.emplace_back(TLSIdentityTest::Label); + } + + CHECK(identity); + + // Initializes a listener with a config with the TLS identity. + createNumberedDocsWithPrefix(cy[0], 20, "doc2"); + createNumberedDocsWithPrefix(cy[1], 20, "doc2"); + URLEndpointListenerConfiguration listenerConfig({cy[0], cy[1]}); + listenerConfig.disableTLS = false; + listenerConfig.tlsIdentity = identity; + listenerConfig.authenticator = ListenerAuthenticator::certAuthenticator( + [](const Cert& cert) { + return cert.subjectName() == "CN=URLEndpointListener_Client"; + }); + + URLEndpointListener listener(listenerConfig); + listener.start(); + + // Starts a single shot replicator to the listener. + configOneShotReplicator(listenerConfig, listener); + TLSIdentity clientIdentity = createTLSIdentity(false, false); + REQUIRE(clientIdentity); + config.authenticator = Authenticator::certificateAuthenticator(clientIdentity); + + replicate(); + + // Checks that the replicator stopped without an error. + listener.stop(); } -TEST_CASE_METHOD(URLEndpointListenerTest, "C++ Identity from KeyPair and Certs", "[TLSIdentity]") { - // TODO: port using cbl::TLSIdentity + cbl::URLEndpointListener once the latter exists. +// T0011-3 TestCreateAndUseSelfSignedIdentityWithPrivateKeyData +TEST_CASE_METHOD(URLEndpointListenerTest_Cpp, "C++ Self-Signed Identity with Private KeyData", "[TLSIdentity]") { + // Gets a pre-created RSA private key data in PEM format with a password from file. + string pemString = URLEndpointListenerTest_Cpp::readFile("private_key_pass.pem"); + KeyPair privateKey = KeyPair::createWithPrivateKeyData(slice{pemString.c_str(), pemString.size() + 1}, "pass"); + CHECK(privateKey); + + // Create a self-signed identity with the KeyPair and CN = "CBL-Server". + MutableDict attributes = MutableDict::newDict(); + attributes[kCBLCertAttrKeyCommonName] = TLSIdentityTest::CN; + TLSIdentity identity = TLSIdentity::createIdentity( + kCBLKeyUsagesServerAuth, privateKey, attributes, + duration_cast(TLSIdentityTest::OneYear).count()); + CHECK(identity); + + // Initializes a listener with a config with the identity. + createNumberedDocsWithPrefix(cy[0], 20, "doc2"); + createNumberedDocsWithPrefix(cy[1], 20, "doc2"); + URLEndpointListenerConfiguration listenerConfig({cy[0], cy[1]}); + listenerConfig.disableTLS = false; + listenerConfig.tlsIdentity = identity; + listenerConfig.authenticator = ListenerAuthenticator::passwordAuthenticator( + [](std::string_view usr, std::string_view psw) { + return slice(usr) == TLSIdentityTest::kUser && slice(psw) == TLSIdentityTest::kPassword; + }); + + URLEndpointListener listener(listenerConfig); + listener.start(); + + // Starts a single shot replicator to the listener. + configOneShotReplicator(listenerConfig, listener); + config.authenticator = Authenticator::basicAuthenticator((std::string_view)TLSIdentityTest::kUser, + (std::string_view)TLSIdentityTest::kPassword); + + replicate(); + + // Checks that the replicator stopped without an error. + listener.stop(); } -#endif // #if 0 +#endif // #if !defined(__linux__) && !defined(__ANDROID__) + +// T0011-5 TestCreateAndUseIdentityFromKeyPairAndCerts +TEST_CASE_METHOD(URLEndpointListenerTest_Cpp, "C++ Identity from KeyPair and Certs", "[TLSIdentity]") { + // Create a KeyPair object from a private key loaded from a PEM file. + string pem = URLEndpointListenerTest_Cpp::readFile("private_key_of_self_signed_cert.pem"); + KeyPair privateKey = KeyPair::createWithPrivateKeyData(slice{pem.c_str(), pem.size() + 1}); + CHECK(privateKey); + + // Creates a Cert object from a PEM file. + pem = URLEndpointListenerTest_Cpp::readFile("self_signed_cert.pem"); + Cert cert = Cert::createWithData(slice{pem.c_str(), pem.size() + 1}); + CHECK(cert); + + // Create an identity from the KeyPair and Cert object. + TLSIdentity identity = TLSIdentity::identityWithKeyPairAndCerts(privateKey, cert); + CHECK(identity); + + // Initializes a listener with a config with the identity. + createNumberedDocsWithPrefix(cy[0], 20, "doc2"); + createNumberedDocsWithPrefix(cy[1], 20, "doc2"); + URLEndpointListenerConfiguration listenerConfig({cy[0], cy[1]}); + listenerConfig.disableTLS = false; + listenerConfig.tlsIdentity = identity; + + URLEndpointListener listener(listenerConfig); + listener.start(); + + // Starts a single shot replicator to the listener. + configOneShotReplicator(listenerConfig, listener); + + replicate(); + + // Checks that the replicator stopped without an error. + listener.stop(); +} #endif // #ifdef COUCHBASE_ENTERPRISE diff --git a/test/URLEndpointListenerTest_Cpp.cc b/test/URLEndpointListenerTest_Cpp.cc new file mode 100644 index 00000000..516ea96f --- /dev/null +++ b/test/URLEndpointListenerTest_Cpp.cc @@ -0,0 +1,798 @@ +// +// URLEndpointListenerTest_Cpp.cc +// +// Copyright © 2026 Couchbase. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#include "URLEndpointListenerTest_Cpp.hh" +#include "CBLPrivate.h" // for CBLURLEndpointListener_AnonymousLabel: test-only, no C++ wrapper + +#ifdef COUCHBASE_ENTERPRISE + +using namespace std; +using namespace std::chrono; +using namespace fleece; +using namespace cbl; + +TEST_CASE_METHOD(URLEndpointListenerTest_Cpp, "C++ Listener Basics", "[URLListener]") { + createNumberedDocsWithPrefix(cy[0], 20, "doc2"); + createNumberedDocsWithPrefix(cy[1], 20, "doc2"); + + SECTION("0 Collections") { + // Cannot create a listener with 0 collections. + URLEndpointListenerConfiguration cfg({}); + cfg.disableTLS = true; + ExpectingExceptions x; + CBLError error{}; + try { + URLEndpointListener listener(cfg); + FAIL("Expected an exception"); + } catch ( const cbl::Error& e ) { + error = asCBLError(e); + } + CheckError(error, kCBLErrorInvalidParameter); + } + + URLEndpointListenerConfiguration listenerConfig({cy[0], cy[1]}); + listenerConfig.disableTLS = true; + + SECTION("Comparing the Configuration from the Listener") { + URLEndpointListener listener(listenerConfig); + + // Compares listenerConfig (what we configured) against the listener's own + // internally-stored copy of it, fetched via the raw C accessor (see + // checkConfiguration()'s comment for why there's no C++ wrapper for that). + checkConfiguration(listenerConfig, CBLURLEndpointListener_Config(listener.ref())); + + listener.stop(); + } + + SECTION("Port from the Listener") { + URLEndpointListener listener(listenerConfig); + // Before start, the listener's port is 0. + CHECK(0 == listener.port()); + listener.start(); + // Having started, it returns the port selected by the server. + CHECK(listener.port() > 0); + listener.stop(); + } + + SECTION("URLs from Listener") { + URLEndpointListener listener(listenerConfig); + + MutableArray urls = listener.urls(); + CHECK(!urls); + + listener.start(); + urls = listener.urls(); + CHECK(urls); + alloc_slice json = urls.toJSON(); + CHECK(json.size > 0); + CHECK(json.containsBytes("\"ws://")); + + listener.stop(); + } +} + +TEST_CASE_METHOD(URLEndpointListenerTest_Cpp, "C++ Listener with OneShot Replication", "[URLListener]") { + URLEndpointListenerConfiguration listenerConfig({cy[0], cy[1]}); + listenerConfig.disableTLS = true; + + createNumberedDocsWithPrefix(cx[0], 10, "doc"); + createNumberedDocsWithPrefix(cx[1], 10, "doc"); + createNumberedDocsWithPrefix(cy[0], 20, "doc2"); + createNumberedDocsWithPrefix(cy[1], 20, "doc2"); + + URLEndpointListener listener(listenerConfig); + listener.start(); + + config = ReplicatorConfiguration({CollectionConfiguration(cx[0]), CollectionConfiguration(cx[1])}, + clientEndpoint(listenerConfig, listener)); + + SECTION("PUSH") { + config.replicatorType = kCBLReplicatorTypePush; + expectedDocumentCount = 20; + replicate(); + } + + SECTION("PULL") { + config.replicatorType = kCBLReplicatorTypePull; + expectedDocumentCount = 40; + replicate(); + } + + SECTION("PUSH-PULL") { + config.replicatorType = kCBLReplicatorTypePushAndPull; + expectedDocumentCount = 60; + replicate(); + } + + listener.stop(); +} + +TEST_CASE_METHOD(URLEndpointListenerTest_Cpp, "C++ Listener with Basic Authentication", "[URLListener]") { + static constexpr slice kUser{"pupshaw"}; + static constexpr slice kPassword{"frank"}; + + URLEndpointListenerConfiguration listenerConfig({cy[0], cy[1]}); + listenerConfig.disableTLS = true; + + SECTION("Successful Login") { + listenerConfig.authenticator = ListenerAuthenticator::passwordAuthenticator( + [myUser=kUser, myPassword=kPassword](std::string_view usr, std::string_view psw) { + return slice(usr) == myUser && slice(psw) == myPassword; + }); + expectedDocumentCount = 20; + } + + SECTION("Wrong User") { + listenerConfig.authenticator = ListenerAuthenticator::passwordAuthenticator( + [](std::string_view usr, std::string_view psw) { + return slice(usr) == "InvalidUser"_sl && slice(psw) == kPassword; + }); + expectedError.code = 401; + } + + SECTION("Wrong Password") { + listenerConfig.authenticator = ListenerAuthenticator::passwordAuthenticator( + [](std::string_view usr, std::string_view psw) { + return slice(usr) == kUser && slice(psw) == "InvalidPassword"_sl; + }); + expectedError.code = 401; + } + + createNumberedDocsWithPrefix(cx[0], 10, "doc"); + createNumberedDocsWithPrefix(cx[1], 10, "doc"); + createNumberedDocsWithPrefix(cy[0], 20, "doc2"); + createNumberedDocsWithPrefix(cy[1], 20, "doc2"); + + URLEndpointListener listener(listenerConfig); + listener.start(); + + config = ReplicatorConfiguration({CollectionConfiguration(cx[0]), CollectionConfiguration(cx[1])}, + clientEndpoint(listenerConfig, listener)); + config.authenticator = Authenticator::basicAuthenticator((std::string_view)kUser, (std::string_view)kPassword); + config.replicatorType = kCBLReplicatorTypePush; + replicate(); + + listener.stop(); +} + +TEST_CASE_METHOD(URLEndpointListenerTest_Cpp, "C++ Listener with Cert Authentication", "[URLListener]") { + constexpr bool withExternalKey = false; + + URLEndpointListenerConfiguration listenerConfig({cy[0], cy[1]}); + listenerConfig.disableTLS = false; + + SECTION("Self-signed Cert") { + listenerConfig.tlsIdentity = createTLSIdentity(true, withExternalKey); + } + + SECTION("Self-signed Anonymous Cert") { + // Leave listenerConfig.tlsIdentity falsy: the listener mints an anonymous identity. + } + + listenerConfig.authenticator = ListenerAuthenticator::certAuthenticator( + [](const Cert& cert) { + return cert.subjectName() == "CN=URLEndpointListener_Client"; + }); + config.acceptOnlySelfSignedServerCertificate = true; + expectedDocumentCount = 20; + + createNumberedDocsWithPrefix(cx[0], 10, "doc"); + createNumberedDocsWithPrefix(cx[1], 10, "doc"); + createNumberedDocsWithPrefix(cy[0], 20, "doc2"); + createNumberedDocsWithPrefix(cy[1], 20, "doc2"); + + URLEndpointListener listener(listenerConfig); + listener.start(); + + config = ReplicatorConfiguration({CollectionConfiguration(cx[0]), CollectionConfiguration(cx[1])}, + clientEndpoint(listenerConfig, listener)); + config.acceptOnlySelfSignedServerCertificate = true; + + TLSIdentity clientIdentity = createTLSIdentity(false, withExternalKey); + REQUIRE(clientIdentity); + config.authenticator = Authenticator::certificateAuthenticator(clientIdentity); + config.replicatorType = kCBLReplicatorTypePush; + replicate(); + + listener.stop(); + + if ( !listenerConfig.tlsIdentity ) { +#if !defined(__linux__) && !defined(__ANDROID__) + alloc_slice anonymousLabel = CBLURLEndpointListener_AnonymousLabel(listener.ref()); + CHECK(anonymousLabel); + identityLabelsToDelete.emplace_back(anonymousLabel); +#endif + } +} + +TEST_CASE_METHOD(URLEndpointListenerTest_Cpp, "C++ Get Peer TLS Certificate", "[URLListener]") { + constexpr bool withExternalKey = false; + + URLEndpointListenerConfiguration listenerConfig({cy[0], cy[1]}); + listenerConfig.disableTLS = false; + listenerConfig.tlsIdentity = createTLSIdentity(true, withExternalKey); + listenerConfig.authenticator = ListenerAuthenticator::certAuthenticator( + [](const Cert& cert) { + return cert.subjectName() == "CN=URLEndpointListener_Client"; + }); + config.acceptOnlySelfSignedServerCertificate = true; + expectedDocumentCount = 20; + + createNumberedDocsWithPrefix(cx[0], 10, "doc"); + createNumberedDocsWithPrefix(cx[1], 10, "doc"); + createNumberedDocsWithPrefix(cy[0], 20, "doc2"); + createNumberedDocsWithPrefix(cy[1], 20, "doc2"); + + URLEndpointListener listener(listenerConfig); + listener.start(); + + config = ReplicatorConfiguration({CollectionConfiguration(cx[0]), CollectionConfiguration(cx[1])}, + clientEndpoint(listenerConfig, listener)); + config.acceptOnlySelfSignedServerCertificate = true; + + TLSIdentity clientIdentity = createTLSIdentity(false, withExternalKey); + REQUIRE(clientIdentity); + config.authenticator = Authenticator::certificateAuthenticator(clientIdentity); + config.replicatorType = kCBLReplicatorTypePush; + + // CBLReplicator_ServerCertificate is a raw C accessor with no C++ wrapper; bridged directly + // here since it's only needed for this one test-only comparison. + statusWatcher = [&](const CBLReplicatorStatus& status) { + if ( status.activity > kCBLReplicatorConnecting ) { + CBLCert* cert = CBLReplicator_ServerCertificate(repl.ref()); + CHECK(cert); + alloc_slice certData = CBLCert_Data(cert, true); + CBLCert_Release(cert); + alloc_slice listenerData = listenerConfig.tlsIdentity.certificates().data(true); + CHECK(certData == listenerData); + statusWatcher = nullptr; + } + }; + replicate(); + + listener.stop(); +} + +#ifdef __APPLE__ +TEST_CASE_METHOD(URLEndpointListenerTest_Cpp, "C++ Listener with Cert Authentication with External KeyPair", "[URLListener]") { + URLEndpointListenerConfiguration listenerConfig({cy[0], cy[1]}); + listenerConfig.disableTLS = false; + + TLSIdentity clientIdentity; + + SECTION("Server External KeyPair") { + listenerConfig.tlsIdentity = createTLSIdentity(true, true); + clientIdentity = createTLSIdentity(false, false); + } + + SECTION("Client External KeyPair") { + listenerConfig.tlsIdentity = createTLSIdentity(true, false); + clientIdentity = createTLSIdentity(false, true); + } + + SECTION("Server & Client External KeyPairs") { + listenerConfig.tlsIdentity = createTLSIdentity(true, true); + clientIdentity = createTLSIdentity(false, true); + } + + REQUIRE(listenerConfig.tlsIdentity); + REQUIRE(clientIdentity); + + listenerConfig.authenticator = ListenerAuthenticator::certAuthenticator( + [](const Cert& cert) { + return cert.subjectName() == "CN=URLEndpointListener_Client"; + }); + config.acceptOnlySelfSignedServerCertificate = true; + expectedDocumentCount = 20; + + createNumberedDocsWithPrefix(cx[0], 10, "doc"); + createNumberedDocsWithPrefix(cx[1], 10, "doc"); + createNumberedDocsWithPrefix(cy[0], 20, "doc2"); + createNumberedDocsWithPrefix(cy[1], 20, "doc2"); + + URLEndpointListener listener(listenerConfig); + listener.start(); + + config = ReplicatorConfiguration({CollectionConfiguration(cx[0]), CollectionConfiguration(cx[1])}, + clientEndpoint(listenerConfig, listener)); + config.acceptOnlySelfSignedServerCertificate = true; + config.authenticator = Authenticator::certificateAuthenticator(clientIdentity); + config.replicatorType = kCBLReplicatorTypePush; + replicate(); + + listener.stop(); +} +#endif // #ifdef __APPLE__ + +// T0010-1 TestPort +TEST_CASE_METHOD(URLEndpointListenerTest_Cpp, "C++ Listener Port", "[URLListener]") { + URLEndpointListenerConfiguration listenerConfig({cy[0], cy[1]}); + listenerConfig.port = 12345; + listenerConfig.disableTLS = true; + + URLEndpointListener listener(listenerConfig); + listener.start(); + CHECK(listener.port() == 12345); + + listener.stop(); + CHECK(listener.port() == 0); +} + +// T0010-2 TestEmptyPort +TEST_CASE_METHOD(URLEndpointListenerTest_Cpp, "C++ Empty Port", "[URLListener]") { + URLEndpointListenerConfiguration listenerConfig({cy[0], cy[1]}); + listenerConfig.disableTLS = true; + + URLEndpointListener listener(listenerConfig); + listener.start(); + CHECK(listener.port() > 0); + + listener.stop(); + CHECK(listener.port() == 0); +} + +// T0010-3 TestBusyPort +TEST_CASE_METHOD(URLEndpointListenerTest_Cpp, "C++ Busy Port", "[URLListener]") { + URLEndpointListenerConfiguration listenerConfig({cy[0], cy[1]}); + listenerConfig.disableTLS = true; + + URLEndpointListener listener(listenerConfig); + listener.start(); + auto port = listener.port(); + REQUIRE(port > 0); + + URLEndpointListenerConfiguration listener2Config({cy[0], cy[1]}); + listener2Config.port = port; + listener2Config.disableTLS = true; + URLEndpointListener listener2(listener2Config); + + { + ExpectingExceptions x; + // Checks that an error (EADDRINUSE or equivalent) is thrown starting the second listener. + CHECK(!succeeds([&] { listener2.start(); })); + } + + listener.stop(); + listener2.stop(); +} + +// T0010-4 TestURLs +TEST_CASE_METHOD(URLEndpointListenerTest_Cpp, "C++ Listener URLs", "[URLListener]") { + URLEndpointListenerConfiguration listenerConfig({cy[0], cy[1]}); + listenerConfig.disableTLS = true; + + URLEndpointListener listener(listenerConfig); + listener.start(); + auto port = listener.port(); + REQUIRE(port > 0); + + MutableArray urls = listener.urls(); + CHECK(urls.count() > 0); + + std::stringstream ss; + ss << ":" << port << "/"; + string portSuffix = ss.str(); + for ( Array::iterator iter(urls); iter; ++iter ) { + auto url = iter.value().asString().asString(); + // Checks that each URL contains the specified port (may not hold on every platform). + CHECK(url.find(portSuffix) != string::npos); + } + + listener.stop(); + + // Checks that the listener's urls are now empty. + MutableArray urls2 = listener.urls(); + CHECK(urls2.count() == 0); +} + +// T0010-5 TestConnectionStatus +TEST_CASE_METHOD(URLEndpointListenerTest_Cpp, "C++ Listener Connection Status", "[URLListener]") { + URLEndpointListenerConfiguration listenerConfig({cy[0]}); // one collection + listenerConfig.disableTLS = true; + + URLEndpointListener listener(listenerConfig); + listener.start(); + + CBLConnectionStatus status = listener.status(); + CHECK(status.connectionCount == 0); + CHECK(status.activeConnectionCount == 0); + + createNumberedDocsWithPrefix(cy[0], 1, "doc2"); + + CBLConnectionStatus statusDuringPull{}; + auto colConfig = CollectionConfiguration(cx[0]); + colConfig.pullFilter = [&](Document doc, CBLDocumentFlags flags) -> bool { + statusDuringPull = listener.status(); + return true; + }; + config = ReplicatorConfiguration({colConfig}, clientEndpoint(listenerConfig, listener)); + config.replicatorType = kCBLReplicatorTypePull; + replicate(); + + CHECK(statusDuringPull.connectionCount == 1); + CHECK(statusDuringPull.activeConnectionCount == 1); + + listener.stop(); +} + +// T0010-6 TestListenerWithDefaultAnonymousIdentity +TEST_CASE_METHOD(URLEndpointListenerTest_Cpp, "C++ Anonymous Identity", "[URLListener]") { + createNumberedDocsWithPrefix(cy[0], 20, "doc2"); + createNumberedDocsWithPrefix(cy[1], 20, "doc2"); + + URLEndpointListenerConfiguration listenerConfig({cy[0], cy[1]}); + listenerConfig.disableTLS = false; + + // Anonymous identity means both of: + REQUIRE(!listenerConfig.tlsIdentity); + REQUIRE(!listenerConfig.disableTLS); + + URLEndpointListener listener(listenerConfig); + listener.start(); + + CHECK(listener.tlsIdentity()); + +#if !defined(__linux__) && !defined(__ANDROID__) + alloc_slice anonymousLabel = CBLURLEndpointListener_AnonymousLabel(listener.ref()); + CHECK(anonymousLabel); + identityLabelsToDelete.emplace_back(anonymousLabel); +#endif + + configOneShotReplicator(listenerConfig, listener); + config.acceptOnlySelfSignedServerCertificate = true; + replicate(); + + listener.stop(); +} + +// T0010-7 TestListenerWithSpecifiedIdentity: covered by other test cases above. + +// T0010-8 TestPasswordAuthenticator +TEST_CASE_METHOD(URLEndpointListenerTest_Cpp, "C++ Password Authenticator", "[URLListener]") { + createNumberedDocsWithPrefix(cy[0], 20, "doc2"); + createNumberedDocsWithPrefix(cy[1], 20, "doc2"); + + URLEndpointListenerConfiguration listenerConfig({cy[0], cy[1]}); + listenerConfig.disableTLS = true; + listenerConfig.authenticator = ListenerAuthenticator::passwordAuthenticator( + [](std::string_view usr, std::string_view psw) { + return slice(usr) == TLSIdentityTest::kUser && slice(psw) == TLSIdentityTest::kPassword; + }); + + URLEndpointListener listener(listenerConfig); + listener.start(); + + configOneShotReplicator(listenerConfig, listener); + + SECTION("Without Client Auth") { + // No password authenticator on the client: expect an HTTP auth error. + expectedError.code = 401; + expectedDocumentCount = -1; + } + + SECTION("Incorrect Password") { + config.authenticator = Authenticator::basicAuthenticator((std::string_view)TLSIdentityTest::kUser, "wrong-password"); + expectedError.code = 401; + expectedDocumentCount = -1; + } + + SECTION("Good Password") { + config.authenticator = Authenticator::basicAuthenticator((std::string_view)TLSIdentityTest::kUser, + (std::string_view)TLSIdentityTest::kPassword); + } + + replicate(); + + listener.stop(); +} + +// T0010-9 TestClientCertCallbackAuthenticator +TEST_CASE_METHOD(URLEndpointListenerTest_Cpp, "C++ Client Cert Callback Authenticator", "[URLListener]") { + createNumberedDocsWithPrefix(cy[0], 20, "doc2"); + createNumberedDocsWithPrefix(cy[1], 20, "doc2"); + + URLEndpointListenerConfiguration listenerConfig({cy[0], cy[1]}); + listenerConfig.disableTLS = false; + listenerConfig.tlsIdentity = createTLSIdentity(true, false); + + int section = 0; + listenerConfig.authenticator = ListenerAuthenticator::certAuthenticator( + [§ion](const Cert&) { return section != 2; }); + + URLEndpointListener listener(listenerConfig); + listener.start(); + + configOneShotReplicator(listenerConfig, listener); + config.acceptOnlySelfSignedServerCertificate = true; + + TLSIdentity clientIdentity; + + SECTION("Without Client Cert Authenticator") { + section = 1; + expectedError.code = kCBLNetErrTLSHandshakeFailed; + expectedDocumentCount = -1; + } + + SECTION("Listener Auth Callback Returns false") { + section = 2; + expectedError.code = kCBLNetErrTLSClientCertRejected; + expectedDocumentCount = -1; + } + + SECTION("Listener Auth Callback Returns true") { + section = 3; + } + + if ( section != 1 ) { + clientIdentity = createTLSIdentity(false, false); + REQUIRE(clientIdentity); + config.authenticator = Authenticator::certificateAuthenticator(clientIdentity); + } + + replicate(); + + listener.stop(); +} + +// T0010-10 TestClientCertAuthenticatorWithRootCert +TEST_CASE_METHOD(URLEndpointListenerTest_Cpp, "C++ Client Cert Authenticator with RootCert", "[URLListener]") { + createNumberedDocsWithPrefix(cy[0], 20, "doc2"); + createNumberedDocsWithPrefix(cy[1], 20, "doc2"); + + URLEndpointListenerConfiguration listenerConfig({cy[0], cy[1]}); + listenerConfig.disableTLS = false; + listenerConfig.tlsIdentity = createTLSIdentity(true, false); + + string pemRootChain = readFile("inter1_root.pem"); + Cert rootCerts = Cert::createWithData(slice{pemRootChain}); + REQUIRE(rootCerts); + listenerConfig.authenticator = ListenerAuthenticator::certAuthenticator(rootCerts); + + URLEndpointListener listener(listenerConfig); + listener.start(); + + configOneShotReplicator(listenerConfig, listener); + + SECTION("Not Signed By the rootCerts") { + // A self-signed cert not descended from the root chain above should be rejected. + string pemCert = readFile("self_signed_cert.pem"); + Cert clientCert = Cert::createWithData(slice{pemCert}); + REQUIRE(clientCert); + + string pemKey = readFile("private_key_of_self_signed_cert.pem"); + KeyPair clientKey = KeyPair::createWithPrivateKeyData(slice(pemKey)); + + TLSIdentity clientIdentity = TLSIdentity::identityWithKeyPairAndCerts(clientKey, clientCert); + REQUIRE(clientIdentity); + config.authenticator = Authenticator::certificateAuthenticator(clientIdentity); + + expectedError.code = kCBLNetErrTLSClientCertRejected; + expectedDocumentCount = -1; + } + + SECTION("Signed Leaf Cert") { + string pemCert = readFile("leaf.pem"); + Cert clientCert = Cert::createWithData(slice{pemCert}); + REQUIRE(clientCert); + + string pemKey = readFile("leaf.key"); + KeyPair clientKey = KeyPair::createWithPrivateKeyData(slice(pemKey)); + + TLSIdentity clientIdentity = TLSIdentity::identityWithKeyPairAndCerts(clientKey, clientCert); + REQUIRE(clientIdentity); + config.authenticator = Authenticator::certificateAuthenticator(clientIdentity); + } + + replicate(); + + listener.stop(); +} + +// T0010-11 TestClientCertAuthenticatorWithDisabledTLS +TEST_CASE_METHOD(URLEndpointListenerTest_Cpp, "C++ Client Cert Auth with Disabled TLS", "[URLListener]") { + createNumberedDocsWithPrefix(cy[0], 20, "doc2"); + createNumberedDocsWithPrefix(cy[1], 20, "doc2"); + + URLEndpointListenerConfiguration listenerConfig({cy[0], cy[1]}); + listenerConfig.disableTLS = true; + listenerConfig.authenticator = ListenerAuthenticator::certAuthenticator( + [](const Cert&) { return true; }); + + ExpectingExceptions x; + CBLError error{}; + try { + URLEndpointListener listener(listenerConfig); + FAIL("Expected an exception"); + } catch ( const cbl::Error& e ) { + error = asCBLError(e); + } + CheckError(error, kCBLErrorInvalidParameter); +} + +// T0010-12 TestInvalidNetworkInterface +TEST_CASE_METHOD(URLEndpointListenerTest_Cpp, "C++ Invalid Network Interface", "[URLListener]") { + createNumberedDocsWithPrefix(cy[0], 20, "doc2"); + createNumberedDocsWithPrefix(cy[1], 20, "doc2"); + + URLEndpointListenerConfiguration listenerConfig({cy[0], cy[1]}); + listenerConfig.disableTLS = true; + + SECTION("Incorrect Interface 1") { + listenerConfig.networkInterface = "1.1.1.256"; + } + + SECTION("Incorrect Interface 2") { + listenerConfig.networkInterface = "foo"; + } + + URLEndpointListener listener(listenerConfig); + + ExpectingExceptions x; + CBLError error{}; + try { + listener.start(); + FAIL("Expected an exception"); + } catch ( const cbl::Error& e ) { + error = asCBLError(e); + } + CHECK(error.code == 2); +} + +// T0010-13 TestReplicatorServerCertificate: no assertions in the C test either (stub), not ported. + +// T0010-14 TestAcceptOnlySelfSignedCertificate +TEST_CASE_METHOD(URLEndpointListenerTest_Cpp, "C++ Accept Only Self-Signed Certificate", "[URLListener]") { + createNumberedDocsWithPrefix(cy[0], 20, "doc2"); + createNumberedDocsWithPrefix(cy[1], 20, "doc2"); + + URLEndpointListenerConfiguration listenerConfig({cy[0], cy[1]}); + listenerConfig.disableTLS = false; + { + string pem = readFile("leaf_inter1_root.pem"); + Cert cert = Cert::createWithData(slice{pem}); + REQUIRE(cert); + pem = readFile("leaf.key"); + KeyPair privateKey = KeyPair::createWithPrivateKeyData(slice(pem)); + listenerConfig.tlsIdentity = TLSIdentity::identityWithKeyPairAndCerts(privateKey, cert); + REQUIRE(listenerConfig.tlsIdentity); + } + + URLEndpointListener listener(listenerConfig); + listener.start(); + + configOneShotReplicator(listenerConfig, listener); + + SECTION("Self-Signed Only") { + config.acceptOnlySelfSignedServerCertificate = true; + expectedDocumentCount = -1; + expectedError.code = kCBLNetErrTLSCertNameMismatch; + } + + SECTION("Not Self-Signed Only") { + config.acceptOnlySelfSignedServerCertificate = false; + expectedError.code = kCBLNetErrTLSCertUnknownRoot; + expectedDocumentCount = -1; + } + + replicate(); + + listener.stop(); +} + +// T0010-15 TestReadOnly +TEST_CASE_METHOD(URLEndpointListenerTest_Cpp, "C++ Listener Read Only", "[URLListener]") { + createNumberedDocsWithPrefix(cy[0], 20, "doc2"); + createNumberedDocsWithPrefix(cy[1], 20, "doc2"); + + URLEndpointListenerConfiguration listenerConfig({cy[0], cy[1]}); + listenerConfig.disableTLS = true; + listenerConfig.readOnly = true; + + URLEndpointListener listener(listenerConfig); + listener.start(); + + configOneShotReplicator(listenerConfig, listener); + + SECTION("Push Replicator") { + config.replicatorType = kCBLReplicatorTypePush; + expectedError.code = 403; // webSocketDomain + expectedDocumentCount = -1; + } + + SECTION("Push-and-Pull Replicator") { + config.replicatorType = kCBLReplicatorTypePushAndPull; + expectedError.code = 403; // webSocketDomain + expectedDocumentCount = -1; + } + + replicate(); + + listener.stop(); +} + +// T0010-16 TestListenerWithMultipleCollections: covered by default set-up (cy has 2+ collections). + +// T0010-17 TestCloseDatabaseStopsListener +TEST_CASE_METHOD(URLEndpointListenerTest_Cpp, "C++ Close Database Stops Listener", "[URLListener]") { + createNumberedDocsWithPrefix(cy[0], 20, "doc2"); + createNumberedDocsWithPrefix(cy[1], 20, "doc2"); + + URLEndpointListenerConfiguration listenerConfig({cy[0], cy[1]}); + listenerConfig.port = 54321; + listenerConfig.disableTLS = true; + + URLEndpointListener listener(listenerConfig); + listener.start(); + CHECK(listener.port() == 54321); + + db2.close(); + db2 = nullptr; + + CHECK(listener.port() == 0); +} + +// T0010-18 TestListenerTLSIdentity +TEST_CASE_METHOD(URLEndpointListenerTest_Cpp, "C++ Listener TLS Identity", "[URLListener]") { + URLEndpointListenerConfiguration listenerConfig({cy[0]}); + + bool useAnonymousIdentity = false; + + SECTION("Disable TLS") { + listenerConfig.disableTLS = true; + } + + SECTION("With TLSIdentity") { + listenerConfig.tlsIdentity = createTLSIdentity(true, false); + } + + SECTION("With Anonymous TLSIdentity") { + useAnonymousIdentity = true; + } + + URLEndpointListener listener(listenerConfig); + CHECK(!listener.tlsIdentity()); + + listener.start(); + + if ( listenerConfig.disableTLS ) + CHECK(!listener.tlsIdentity()); + else + CHECK(listener.tlsIdentity()); + +#if !defined(__linux__) && !defined(__ANDROID__) + if ( useAnonymousIdentity ) { + alloc_slice anonymousLabel = CBLURLEndpointListener_AnonymousLabel(listener.ref()); + CHECK(anonymousLabel); + identityLabelsToDelete.emplace_back(anonymousLabel); + } +#endif + + listener.stop(); + CHECK(!listener.tlsIdentity()); +} + +TEST_CASE_METHOD(URLEndpointListenerTest_Cpp, "C++ Start and Stop Listener", "[URLListener]") { + URLEndpointListenerConfiguration listenerConfig({defaultCollection}); + listenerConfig.disableTLS = true; + + URLEndpointListener listener(listenerConfig); + listener.start(); + listener.stop(); +} + +#endif //#ifdef COUCHBASE_ENTERPRISE diff --git a/test/URLEndpointListenerTest_Cpp.hh b/test/URLEndpointListenerTest_Cpp.hh new file mode 100644 index 00000000..83a7d9d1 --- /dev/null +++ b/test/URLEndpointListenerTest_Cpp.hh @@ -0,0 +1,264 @@ +// +// URLEndpointListenerTest_Cpp.hh +// +// Copyright © 2026 Couchbase. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#pragma once + +#include "CBLTest_Cpp.hh" +#include "TLSIdentityTest.hh" +#include "fleece/Fleece.hh" +#include "fleece/Mutable.hh" +#include +#include +#include +#include +#include +#include +#include +#include + +#include "cbl++/CouchbaseLite.hh" + +#ifdef COUCHBASE_ENTERPRISE + +class URLEndpointListenerTest_Cpp : public CBLTest_Cpp { +public: + using clock = std::chrono::high_resolution_clock; + using time = clock::time_point; + using seconds = std::chrono::duration>; + + enum class IdleAction { + kStopReplicator, ///< Stop Replicator + kContinueMonitor, ///< Continue checking status + kFinishMonitor ///< Finish checking status + }; + + URLEndpointListenerTest_Cpp() + :db2(openDatabaseNamed("otherdb", true)) // empty + ,config({std::vector(), cbl::Endpoint::databaseEndpoint(db2)}) // placeholder; reassigned per test + { + cx.push_back(db.createCollection("colA", "scopeA")); + cx.push_back(db.createCollection("colB", "scopeA")); + cx.push_back(db.createCollection("colC", "scopeA")); + + cy.push_back(db2.createCollection("colA", "scopeA")); + cy.push_back(db2.createCollection("colB", "scopeA")); + cy.push_back(db2.createCollection("colC", "scopeA")); + } + + ~URLEndpointListenerTest_Cpp() { + if (db2) { + db2.close(); + db2 = nullptr; + } + + std::set labelSet; + for (const auto& label : identityLabelsToDelete) { + auto res = labelSet.insert(label); + // Make sure that a test does not generate identical labels. + CHECK(res.second); +#if !defined(__linux__) && !defined(__ANDROID__) + CHECK(cbl::TLSIdentity::deleteIdentityWithLabel((std::string_view)fleece::slice(label))); +#else + assert(false); +#endif + } + } + + // Builds a URL endpoint pointing at `listener`, deriving the scheme from `listenerConfig` + // and the database name from its first collection (there's no C++ equivalent of the C API's + // CBLURLEndpointListener_Config, so the config the test itself built is used instead). + cbl::Endpoint clientEndpoint(const cbl::URLEndpointListenerConfiguration& listenerConfig, + const cbl::URLEndpointListener& listener) { + auto collections = listenerConfig.collections(); + REQUIRE(!collections.empty()); + std::stringstream ss; + ss << (listenerConfig.disableTLS ? "ws" : "wss"); + ss << "://localhost:" << listener.port() << "/" << collections[0].database().name(); + return cbl::Endpoint::urlEndpoint(ss.str()); + } + + // Verifies fromListener (from CBLURLEndpointListener_Config) matches source. + // c.f. URLEndpointListener::URLEndpointListener(), + // URLEndpointListenerConfiguration::toCConfigWithoutCollections() (private -- fields + // compared here directly instead) + static void checkConfiguration(const cbl::URLEndpointListenerConfiguration& source, + const CBLURLEndpointListenerConfiguration* fromListener) + { + REQUIRE(fromListener); + CHECK(fromListener->port == source.port); + CHECK(fromListener->disableTLS == source.disableTLS); + CHECK(fromListener->enableDeltaSync == source.enableDeltaSync); + CHECK(fromListener->readOnly == source.readOnly); + CHECK(fleece::slice(fromListener->networkInterface) == fleece::slice(source.networkInterface)); + CHECK(fromListener->tlsIdentity == source.tlsIdentity.ref()); + // Unlike tlsIdentity/collections (which the listener retains via CBL_Retain, so the same + // pointer identity holds), the listener's constructor heap-allocates its own copy of the + // CBLListenerAuthenticator struct itself (CBLURLEndpointListener_Internal.hh, in the EE + // sibling repo: `_conf.authenticator = new CBLListenerAuthenticator(*_conf.authenticator)`). + // So fromListener->authenticator is never pointer-equal to what was configured -- only + // presence/absence is checkable here, not identity. + CHECK((fromListener->authenticator != nullptr) == (source.authenticator.ref() != nullptr)); + + auto collections = source.collections(); + REQUIRE(fromListener->collectionCount == collections.size()); + for ( size_t i = 0; i < collections.size(); i++ ) { + CHECK(fromListener->collections[i] == collections[i].ref()); + } + } + + // Creates a self-signed TLS identity for the given role. The C fixture mints a fresh RSA + // keypair per call via CBLKeyPair_GenerateRSAKeyPair, but that's a private, test-only API + // with no C++ wrapper (see TLSIdentityTest_Cpp.cc's "C++ Self-Signed Cert Identity"), so the + // non-external-key path here loads a fixed pre-generated key asset instead. Server and client + // identities end up sharing key material, but each still gets its own self-signed cert with a + // distinct CN, which is all these tests actually verify. + cbl::TLSIdentity createTLSIdentity(bool isServer, bool withExternalKey) { + cbl::KeyPair keypair; + if ( !withExternalKey ) { + std::string pem = readFile("private_key_of_self_signed_cert.pem"); + keypair = cbl::KeyPair::createWithPrivateKeyData(fleece::slice{pem.c_str(), pem.size() + 1}); + } else { +#ifdef __APPLE__ + auto* externalKey = TLSIdentityTest::ExternalKey::generateRSA(2048); + REQUIRE(externalKey); + cbl::KeyPair::ExternalKeyHolder holder( + externalKey, + [](void* ctx) -> std::optional { + return static_cast(ctx)->publicKeyData(); + }, + [](void* ctx, fleece::slice input) -> std::optional { + return static_cast(ctx)->decrypt(input); + }, + [](void* ctx, cbl::KeyPair::SignatureDigestAlgorithm digestAlgorithm, fleece::slice input) -> std::optional { + return static_cast(ctx)->sign(digestAlgorithm, input); + }, + [](void* ctx) { delete (TLSIdentityTest::ExternalKey*)ctx; }); + keypair = cbl::KeyPair::createWithExternalKey(2048, std::move(holder)); +#else + return cbl::TLSIdentity(); +#endif + } + + fleece::MutableDict attributes = fleece::MutableDict::newDict(); + attributes[kCBLCertAttrKeyCommonName] = isServer ? "URLEndpointListener" : "URLEndpointListener_Client"; + CBLKeyUsages usages = isServer ? kCBLKeyUsagesServerAuth : kCBLKeyUsagesClientAuth; + return cbl::TLSIdentity::createIdentity(usages, keypair, attributes, + std::chrono::duration_cast(TLSIdentityTest::OneYear).count()); + } + + static std::string readFile(const char* filename) { + std::string path = GetAssetFilePath(filename); + std::ifstream file(path, std::ios::binary); // Use binary to preserve newlines + return std::string{std::istreambuf_iterator(file), std::istreambuf_iterator()}; + } + + // Configures `config` as a one-shot, self-signed-only push replicator from cx[0]/cx[1] to + // `listener`, matching URLEndpointListenerTest::configOneShotReplicator. + void configOneShotReplicator(const cbl::URLEndpointListenerConfiguration& listenerConfig, + const cbl::URLEndpointListener& listener) { + createNumberedDocsWithPrefix(cx[0], 10, "doc"); + createNumberedDocsWithPrefix(cx[1], 10, "doc"); + expectedDocumentCount = 20; + config = cbl::ReplicatorConfiguration({cbl::CollectionConfiguration(cx[0]), cbl::CollectionConfiguration(cx[1])}, + clientEndpoint(listenerConfig, listener)); + config.acceptOnlySelfSignedServerCertificate = true; + config.replicatorType = kCBLReplicatorTypePush; + } + + void replicate(bool reset =false) { + CBLReplicatorStatus status; + if ( !repl ) { + repl = cbl::Replicator(config); + status = repl.status(); + CHECK(status.activity == kCBLReplicatorStopped); + CHECK(status.progress.complete == 0.0); + CHECK(status.progress.documentCount == 0); + CHECK(status.error.code == 0); + } + REQUIRE(repl); + + auto changeListener = repl.addChangeListener([&](cbl::Replicator r, const CBLReplicatorStatus& status) { + statusChanged(r, status); + }); + + repl.start(reset); + + time start = clock::now(); + std::cerr << "Waiting...\n"; + while ( std::chrono::duration_cast(clock::now() - start).count() < timeoutSeconds ) { + status = repl.status(); + if ( config.continuous && status.activity == kCBLReplicatorIdle ) { + if ( idleAction == IdleAction::kStopReplicator ) { + std::cerr << "Stop the continuous replicator...\n"; + repl.stop(); + } else if ( idleAction == IdleAction::kFinishMonitor ) { + break; + } + } else if ( status.activity == kCBLReplicatorStopped ) { + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + std::cerr << "Finished with activity=" << static_cast(status.activity) + << ", complete=" << status.progress.complete + << ", documentCount=" << status.progress.documentCount + << ", error=(" << status.error.domain << "/" << status.error.code << ")\n"; + + if ( config.continuous && idleAction == IdleAction::kFinishMonitor ) + CHECK(status.activity == kCBLReplicatorIdle); + else + CHECK(status.activity == kCBLReplicatorStopped); + + if ( expectedError.code > 0 ) { + CHECK(status.error.code == expectedError.code); + } else { + CHECK(status.error.code == 0); + CHECK(status.progress.complete == 1.0); + } + + if ( expectedDocumentCount >= 0 ) { + CHECK(status.progress.documentCount == expectedDocumentCount); + } + } + + void statusChanged(cbl::Replicator& r, const CBLReplicatorStatus& status) { + CHECK(r == repl); + std::cerr << "--- PROGRESS: status=" << static_cast(status.activity) + << ", fraction=" << status.progress.complete + << ", err=" << status.error.domain << "/" << status.error.code << "\n"; + if ( statusWatcher ) statusWatcher(status); + } + + cbl::Database db2; + std::vector cx; + std::vector cy; + + cbl::ReplicatorConfiguration config; + cbl::Replicator repl; + + double timeoutSeconds = 30.0; + IdleAction idleAction = IdleAction::kStopReplicator; + std::function statusWatcher; + + CBLError expectedError = {}; + int64_t expectedDocumentCount = -1; + + std::vector identityLabelsToDelete; +}; + +#endif //#ifdef COUCHBASE_ENTERPRISE diff --git a/test/cmake/test_source_files.cmake b/test/cmake/test_source_files.cmake index 74f13fa6..5bb07e31 100644 --- a/test/cmake/test_source_files.cmake +++ b/test/cmake/test_source_files.cmake @@ -39,6 +39,7 @@ function(set_test_source_files) ${T_DIR}/TLSIdentityTest.cc ${T_DIR}/TLSIdentityTest_Cpp.cc ${T_DIR}/URLEndpointListenerTest.cc + ${T_DIR}/URLEndpointListenerTest_Cpp.cc ${T_DIR}/VectorSearchTest.cc ${T_DIR}/VectorSearchTest_Cpp.cc ${T_DIR}/LazyVectorIndexTest.cc