diff options
Diffstat (limited to 'indra/llcorehttp')
45 files changed, 4324 insertions, 323 deletions
diff --git a/indra/llcorehttp/CMakeLists.txt b/indra/llcorehttp/CMakeLists.txt index ab3a86b37e..058c3feac5 100644 --- a/indra/llcorehttp/CMakeLists.txt +++ b/indra/llcorehttp/CMakeLists.txt @@ -13,6 +13,7 @@ include(LLAddBuildTest) endif () include(LLCommon) include(bugsplat) +include(websocketpp) set(llcorehttp_SOURCE_FILES bufferarray.cpp @@ -24,6 +25,8 @@ set(llcorehttp_SOURCE_FILES httprequest.cpp httpresponse.cpp httpstats.cpp + llwebsocketmgr.cpp + lljsonrpcws.cpp _httplibcurl.cpp _httpopcancel.cpp _httpoperation.cpp @@ -52,6 +55,8 @@ set(llcorehttp_HEADER_FILES httprequest.h httpresponse.h httpstats.h + llwebsocketmgr.h + lljsonrpcws.h _httpinternal.h _httplibcurl.h _httpopcancel.h @@ -82,10 +87,12 @@ list(APPEND llcorehttp_SOURCE_FILES ${llcorehttp_HEADER_FILES}) add_library (llcorehttp ${llcorehttp_SOURCE_FILES}) target_link_libraries( llcorehttp + llmath llcommon ll::libcurl ll::openssl ll::nghttp2 + ll::websocketpp ) target_include_directories( llcorehttp INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}) # llmessage depends on llcorehttp, yet llcorehttp also depends on llmessage (at least for includes). @@ -95,6 +102,10 @@ target_include_directories( llcorehttp PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../ll include(LibraryInstall) +if (USE_PRECOMPILED_HEADERS) + target_precompile_headers(llcorehttp REUSE_FROM llprecompiled) +endif () + # tests set(LLCOREHTTP_TESTS ON CACHE BOOL "Build and run llcorehttp integration tests specifically") @@ -111,6 +122,8 @@ if (LL_TESTS AND LLCOREHTTP_TESTS) tests/test_httpheaders.hpp tests/test_bufferarray.hpp tests/test_bufferstream.hpp + tests/test_jsonrpcws.hpp + tests/test_websocketmgr.hpp ) list(APPEND llcorehttp_TEST_SOURCE_FILES ${llcorehttp_TEST_HEADER_FILES}) @@ -134,7 +147,11 @@ if (LL_TESTS AND LLCOREHTTP_TESTS) ${PYTHON_EXECUTABLE} "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_llcorehttp_peer.py" ) - + + LL_ADD_INTEGRATION_TEST(wsserver "" "${test_libs}") + + LL_ADD_INTEGRATION_TEST(jsonrpcws "" "${test_libs}") + # # Example Programs # @@ -154,6 +171,7 @@ if (LL_TESTS AND LLCOREHTTP_TESTS) set_target_properties(http_texture_load PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${EXE_STAGING_DIR}" + FOLDER "Tests" ) if (WINDOWS) @@ -166,6 +184,13 @@ if (LL_TESTS AND LLCOREHTTP_TESTS) ) endif (WINDOWS) + if(DARWIN) + set_target_properties(http_texture_load + PROPERTIES + OSX_ARCHITECTURES ${LL_MACOS_TEST_ARCHITECTURE} + ) + endif() + target_link_libraries(http_texture_load ${example_libs}) endif (LL_TESTS AND LLCOREHTTP_TESTS) diff --git a/indra/llcorehttp/_httplibcurl.h b/indra/llcorehttp/_httplibcurl.h index 3631965837..58affcb796 100644 --- a/indra/llcorehttp/_httplibcurl.h +++ b/indra/llcorehttp/_httplibcurl.h @@ -61,8 +61,8 @@ public: virtual ~HttpLibcurl(); private: - HttpLibcurl(const HttpLibcurl &); // Not defined - void operator=(const HttpLibcurl &); // Not defined + HttpLibcurl(const HttpLibcurl&) = delete; + void operator=(const HttpLibcurl&) = delete; public: typedef std::shared_ptr<HttpOpRequest> opReqPtr_t; @@ -179,8 +179,8 @@ protected: ~HandleCache(); private: - HandleCache(const HandleCache &); // Not defined - void operator=(const HandleCache &); // Not defined + HandleCache(const HandleCache&) = delete; + void operator=(const HandleCache&) = delete; public: /// Allocate a curl handle for caller. May be freed using diff --git a/indra/llcorehttp/_httpoperation.h b/indra/llcorehttp/_httpoperation.h index ff7efe60e9..8412043aa5 100644 --- a/indra/llcorehttp/_httpoperation.h +++ b/indra/llcorehttp/_httpoperation.h @@ -68,8 +68,7 @@ class HttpService; /// via queue-like interfaces that are thread compatible /// and those interfaces establish the access rules. -class HttpOperation : private boost::noncopyable, - public std::enable_shared_from_this<HttpOperation> +class HttpOperation : public std::enable_shared_from_this<HttpOperation> { public: typedef std::shared_ptr<HttpOperation> ptr_t; @@ -82,6 +81,9 @@ public: /// Threading: called by any thread. virtual ~HttpOperation(); // Use release() + // Non-copyable + HttpOperation(const HttpOperation&) = delete; + HttpOperation& operator=(const HttpOperation&) = delete; public: /// Register a reply queue and a handler for completion notifications. @@ -220,12 +222,10 @@ class HttpOpStop : public HttpOperation { public: HttpOpStop(); - virtual ~HttpOpStop(); -private: - HttpOpStop(const HttpOpStop &); // Not defined - void operator=(const HttpOpStop &); // Not defined + HttpOpStop(const HttpOpStop &) = delete; + HttpOpStop& operator=(const HttpOpStop&) = delete; public: virtual void stageFromRequest(HttpService *); @@ -242,12 +242,10 @@ class HttpOpNull : public HttpOperation { public: HttpOpNull(); - virtual ~HttpOpNull(); -private: - HttpOpNull(const HttpOpNull &); // Not defined - void operator=(const HttpOpNull &); // Not defined + HttpOpNull(const HttpOpNull&) = delete; + HttpOpNull& operator=(const HttpOpNull&) = delete; public: virtual void stageFromRequest(HttpService *); @@ -264,12 +262,10 @@ public: // 0 does a hard spin in the operation // 1 does a soft spin continuously requeuing itself HttpOpSpin(int mode); - virtual ~HttpOpSpin(); -private: - HttpOpSpin(const HttpOpSpin &); // Not defined - void operator=(const HttpOpSpin &); // Not defined + HttpOpSpin(const HttpOpSpin&) = delete; + HttpOpSpin& operator=(const HttpOpSpin&) = delete; public: virtual void stageFromRequest(HttpService *); diff --git a/indra/llcorehttp/_httpoprequest.cpp b/indra/llcorehttp/_httpoprequest.cpp index 6186e7a308..bbf39c2afa 100644 --- a/indra/llcorehttp/_httpoprequest.cpp +++ b/indra/llcorehttp/_httpoprequest.cpp @@ -153,7 +153,7 @@ HttpOpRequest::HttpOpRequest() mPolicyRetryLimit(HTTP_RETRY_COUNT_DEFAULT), mPolicyMinRetryBackoff(HttpTime(HTTP_RETRY_BACKOFF_MIN_DEFAULT)), mPolicyMaxRetryBackoff(HttpTime(HTTP_RETRY_BACKOFF_MAX_DEFAULT)), - mCallbackSSLVerify(NULL) + mCallbackSSLVerify(nullptr) { // *NOTE: As members are added, retry initialization/cleanup // may need to be extended in @see prepareRequest(). @@ -272,7 +272,7 @@ void HttpOpRequest::visitNotifier(HttpRequest * request) response->setContentType(mReplyConType); response->setRetries(mPolicyRetries, mPolicy503Retries); - HttpResponse::TransferStats::ptr_t stats = HttpResponse::TransferStats::ptr_t(new HttpResponse::TransferStats); + HttpResponse::TransferStats::ptr_t stats = std::make_shared<HttpResponse::TransferStats>(); curl_easy_getinfo(mCurlHandle, CURLINFO_SIZE_DOWNLOAD, &stats->mSizeDownload); curl_easy_getinfo(mCurlHandle, CURLINFO_TOTAL_TIME, &stats->mTotalTime); @@ -513,7 +513,9 @@ HttpStatus HttpOpRequest::prepareRequest(HttpService * service) check_curl_easy_setopt(mCurlHandle, CURLOPT_NOPROGRESS, 1); check_curl_easy_setopt(mCurlHandle, CURLOPT_URL, mReqURL.c_str()); check_curl_easy_setopt(mCurlHandle, CURLOPT_PRIVATE, getHandle()); +#if LIBCURL_VERSION_MAJOR < 8 check_curl_easy_setopt(mCurlHandle, CURLOPT_ENCODING, ""); +#endif check_curl_easy_setopt(mCurlHandle, CURLOPT_AUTOREFERER, 1); check_curl_easy_setopt(mCurlHandle, CURLOPT_MAXREDIRS, HTTP_REDIRECTS_DEFAULT); @@ -538,6 +540,7 @@ HttpStatus HttpOpRequest::prepareRequest(HttpService * service) long sslHostV(0L); long dnsCacheTimeout(-1L); long nobody(0L); + curl_off_t lastModified(0L); if (mReqOptions) { @@ -546,6 +549,7 @@ HttpStatus HttpOpRequest::prepareRequest(HttpService * service) sslHostV = mReqOptions->getSSLVerifyHost() ? 2L : 0L; dnsCacheTimeout = mReqOptions->getDNSCacheTimeout(); nobody = mReqOptions->getHeadersOnly() ? 1L : 0L; + lastModified = (curl_off_t)mReqOptions->getLastModified(); } check_curl_easy_setopt(mCurlHandle, CURLOPT_FOLLOWLOCATION, follow_redirect); @@ -554,6 +558,17 @@ HttpStatus HttpOpRequest::prepareRequest(HttpService * service) check_curl_easy_setopt(mCurlHandle, CURLOPT_NOBODY, nobody); + if (lastModified) + { + check_curl_easy_setopt(mCurlHandle, CURLOPT_TIMECONDITION, CURL_TIMECOND_IFMODSINCE); +#if (LIBCURL_VERSION_NUM >= 0x073B00) + // requires curl 7.59.0 + check_curl_easy_setopt(mCurlHandle, CURLOPT_TIMEVALUE_LARGE, lastModified); +#else + check_curl_easy_setopt(mCurlHandle, CURLOPT_TIMEVALUE, (long)lastModified); +#endif + } + // The Linksys WRT54G V5 router has an issue with frequent // DNS lookups from LAN machines. If they happen too often, // like for every HTTP request, the router gets annoyed after @@ -603,7 +618,9 @@ HttpStatus HttpOpRequest::prepareRequest(HttpService * service) case HOR_POST: { check_curl_easy_setopt(mCurlHandle, CURLOPT_POST, 1); +#if LIBCURL_VERSION_MAJOR < 8 check_curl_easy_setopt(mCurlHandle, CURLOPT_ENCODING, ""); +#endif long data_size(0); if (mReqBody) { @@ -951,7 +968,7 @@ size_t HttpOpRequest::headerCallback(void * data, size_t size, size_t nmemb, voi // Save headers in response if (! op->mReplyHeaders) { - op->mReplyHeaders = HttpHeaders::ptr_t(new HttpHeaders); + op->mReplyHeaders = std::make_shared<HttpHeaders>(); } op->mReplyHeaders->append(name, value ? value : ""); } diff --git a/indra/llcorehttp/_httpoprequest.h b/indra/llcorehttp/_httpoprequest.h index b029bc740c..717535555c 100644 --- a/indra/llcorehttp/_httpoprequest.h +++ b/indra/llcorehttp/_httpoprequest.h @@ -73,8 +73,8 @@ public: virtual ~HttpOpRequest(); // Use release() private: - HttpOpRequest(const HttpOpRequest &); // Not defined - void operator=(const HttpOpRequest &); // Not defined + HttpOpRequest(const HttpOpRequest&) = delete; + void operator=(const HttpOpRequest&) = delete; public: enum EMethod diff --git a/indra/llcorehttp/_httpopsetget.h b/indra/llcorehttp/_httpopsetget.h index 0b927a6b71..ff8204547b 100644 --- a/indra/llcorehttp/_httpopsetget.h +++ b/indra/llcorehttp/_httpopsetget.h @@ -60,8 +60,8 @@ public: virtual ~HttpOpSetGet(); // Use release() private: - HttpOpSetGet(const HttpOpSetGet &); // Not defined - void operator=(const HttpOpSetGet &); // Not defined + HttpOpSetGet(const HttpOpSetGet&) = delete; + void operator=(const HttpOpSetGet&) = delete; public: /// Threading: called by application thread diff --git a/indra/llcorehttp/_httppolicy.h b/indra/llcorehttp/_httppolicy.h index a074949f20..82f552176e 100644 --- a/indra/llcorehttp/_httppolicy.h +++ b/indra/llcorehttp/_httppolicy.h @@ -56,8 +56,8 @@ public: virtual ~HttpPolicy(); private: - HttpPolicy(const HttpPolicy &); // Not defined - void operator=(const HttpPolicy &); // Not defined + HttpPolicy(const HttpPolicy&) = delete; + void operator=(const HttpPolicy&) = delete; public: typedef std::shared_ptr<HttpOpRequest> opReqPtr_t; diff --git a/indra/llcorehttp/_httppolicyclass.h b/indra/llcorehttp/_httppolicyclass.h index 32bcad4f9c..5bf0fa1fa7 100644 --- a/indra/llcorehttp/_httppolicyclass.h +++ b/indra/llcorehttp/_httppolicyclass.h @@ -53,7 +53,7 @@ public: ~HttpPolicyClass(); HttpPolicyClass & operator=(const HttpPolicyClass &); - HttpPolicyClass(const HttpPolicyClass &); // Not defined + HttpPolicyClass(const HttpPolicyClass &); public: HttpStatus set(HttpRequest::EPolicyOption opt, long value); diff --git a/indra/llcorehttp/_httppolicyglobal.h b/indra/llcorehttp/_httppolicyglobal.h index d9114d167f..3840d66b22 100644 --- a/indra/llcorehttp/_httppolicyglobal.h +++ b/indra/llcorehttp/_httppolicyglobal.h @@ -55,7 +55,7 @@ public: HttpPolicyGlobal & operator=(const HttpPolicyGlobal &); private: - HttpPolicyGlobal(const HttpPolicyGlobal &); // Not defined + HttpPolicyGlobal(const HttpPolicyGlobal &) = delete; public: HttpStatus set(HttpRequest::EPolicyOption opt, long value); diff --git a/indra/llcorehttp/_httpreadyqueue.h b/indra/llcorehttp/_httpreadyqueue.h index 0bc0723511..04fcf25356 100644 --- a/indra/llcorehttp/_httpreadyqueue.h +++ b/indra/llcorehttp/_httpreadyqueue.h @@ -77,8 +77,8 @@ public: {} protected: - HttpReadyQueue(const HttpReadyQueue &); // Not defined - void operator=(const HttpReadyQueue &); // Not defined + HttpReadyQueue(const HttpReadyQueue&) = delete; + void operator=(const HttpReadyQueue&) = delete; public: diff --git a/indra/llcorehttp/_httpreplyqueue.h b/indra/llcorehttp/_httpreplyqueue.h index d8847fafb5..e1b5648691 100644 --- a/indra/llcorehttp/_httpreplyqueue.h +++ b/indra/llcorehttp/_httpreplyqueue.h @@ -30,8 +30,6 @@ #include "_refcounted.h" #include "_mutex.h" -#include "boost/noncopyable.hpp" - namespace LLCore { @@ -59,7 +57,7 @@ class HttpOperation; /// will be coded anyway so it shouldn't be too much of a /// burden. -class HttpReplyQueue : private boost::noncopyable +class HttpReplyQueue { public: @@ -69,6 +67,9 @@ public: HttpReplyQueue(); virtual ~HttpReplyQueue(); + HttpReplyQueue(const HttpReplyQueue&) = delete; + HttpReplyQueue& operator=(const HttpReplyQueue&) = delete; + public: typedef std::vector< opPtr_t > OpContainer; diff --git a/indra/llcorehttp/_httprequestqueue.h b/indra/llcorehttp/_httprequestqueue.h index 0823126f78..82537c9053 100644 --- a/indra/llcorehttp/_httprequestqueue.h +++ b/indra/llcorehttp/_httprequestqueue.h @@ -57,8 +57,8 @@ protected: virtual ~HttpRequestQueue(); // Use release() private: - HttpRequestQueue(const HttpRequestQueue &); // Not defined - void operator=(const HttpRequestQueue &); // Not defined + HttpRequestQueue(const HttpRequestQueue&) = delete; + void operator=(const HttpRequestQueue&) = delete; public: typedef std::shared_ptr<HttpOperation> opPtr_t; diff --git a/indra/llcorehttp/_httpservice.cpp b/indra/llcorehttp/_httpservice.cpp index 5880fb7e87..e14a533479 100644 --- a/indra/llcorehttp/_httpservice.cpp +++ b/indra/llcorehttp/_httpservice.cpp @@ -27,7 +27,6 @@ #include "_httpservice.h" #include <boost/bind.hpp> -#include <boost/function.hpp> #include "_httpoperation.h" #include "_httprequestqueue.h" @@ -71,7 +70,6 @@ volatile HttpService::EState HttpService::sState(NOT_INITIALIZED); HttpService::HttpService() : mRequestQueue(NULL), mExitRequested(0U), - mThread(NULL), mPolicy(NULL), mTransport(NULL), mLastPolicy(0) @@ -90,23 +88,30 @@ HttpService::~HttpService() { if (mRequestQueue->stopQueue()) { - // Give mRequestQueue a chance to finish - ms_sleep(10); + // Now wait a bit for the thread to exit + S32 counter = 0; + const S32 MAX_WAIT = 600; + while (counter < MAX_WAIT) + { + if (STOPPED == sState) + { + break; + } + // Sleep for a tenth of a second + ms_sleep(100); + std::this_thread::yield(); + counter++; + } } } - if (mThread) + if (mThread && RUNNING == sState) { - if (! mThread->timedJoin(250)) - { - // Failed to join, expect problems ahead so do a hard termination. - LL_WARNS(LOG_CORE) << "Destroying HttpService with running thread. Expect problems." << LL_NEWLINE - << "State: " << S32(sState) - << " Last policy: " << U32(mLastPolicy) - << LL_ENDL; + // Failed to shutdown, expect problems ahead so do a hard termination. + LL_WARNS(LOG_CORE) << "Destroying HttpService with running thread. Expect problems." << LL_NEWLINE << "State: " << S32(sState) + << " Last policy: " << U32(mLastPolicy) << LL_ENDL; - mThread->cancel(); - } + mThread->cancel(); } } @@ -122,11 +127,7 @@ HttpService::~HttpService() delete mPolicy; mPolicy = NULL; - if (mThread) - { - mThread->release(); - mThread = NULL; - } + mThread.reset(); } @@ -205,14 +206,15 @@ void HttpService::startThread() if (mThread) { - mThread->release(); + mThread.reset(); } // Push current policy definitions, enable policy & transport components mPolicy->start(); mTransport->start(mLastPolicy + 1); - mThread = new LLCoreInt::HttpThread(boost::bind(&HttpService::threadRun, this, _1)); + mThread = std::make_unique<LLCoreInt::HttpThread>(boost::bind(&HttpService::threadRun, this, _1)); + mThread->detach(); // Detach thread to let it clean its self up sState = RUNNING; } @@ -283,12 +285,9 @@ void HttpService::shutdown() // requested to stop. void HttpService::threadRun(LLCoreInt::HttpThread * thread) { + set_thread_name("HttpService"); LL_PROFILER_SET_THREAD_NAME("HttpService"); - boost::this_thread::disable_interruption di; - - LLThread::registerThreadID(); - ELoopSpeed loop(REQUEST_SLEEP); while (! mExitRequested) { @@ -315,7 +314,7 @@ void HttpService::threadRun(LLCoreInt::HttpThread * thread) { LOG_UNHANDLED_EXCEPTION(""); } - catch (std::bad_alloc&) + catch (const std::bad_alloc&) { LLMemory::logMemoryInfo(true); diff --git a/indra/llcorehttp/_httpservice.h b/indra/llcorehttp/_httpservice.h index 13eb034f0e..4540fb9383 100644 --- a/indra/llcorehttp/_httpservice.h +++ b/indra/llcorehttp/_httpservice.h @@ -86,8 +86,8 @@ protected: virtual ~HttpService(); private: - HttpService(const HttpService &); // Not defined - void operator=(const HttpService &); // Not defined + HttpService(const HttpService&) = delete; + void operator=(const HttpService&) = delete; public: enum EState @@ -218,7 +218,7 @@ protected: static volatile EState sState; HttpRequestQueue * mRequestQueue; // Refcounted LLAtomicU32 mExitRequested; - LLCoreInt::HttpThread * mThread; + std::unique_ptr<LLCoreInt::HttpThread> mThread; // === working-thread-only data === HttpPolicy * mPolicy; // Simple pointer, has ownership diff --git a/indra/llcorehttp/_mutex.h b/indra/llcorehttp/_mutex.h index 5e0164eb51..43782d3a37 100644 --- a/indra/llcorehttp/_mutex.h +++ b/indra/llcorehttp/_mutex.h @@ -27,9 +27,8 @@ #ifndef LLCOREINT_MUTEX_H_ #define LLCOREINT_MUTEX_H_ - -#include <boost/thread.hpp> - +#include <condition_variable> +#include <mutex> namespace LLCoreInt { @@ -37,17 +36,17 @@ namespace LLCoreInt // MUTEX TYPES // unique mutex type -typedef boost::mutex HttpMutex; +typedef std::mutex HttpMutex; // CONDITION VARIABLES // standard condition variable -typedef boost::condition_variable HttpConditionVariable; +typedef std::condition_variable HttpConditionVariable; // LOCKS AND FENCES // scoped unique lock -typedef boost::unique_lock<HttpMutex> HttpScopedLock; +typedef std::unique_lock<HttpMutex> HttpScopedLock; } diff --git a/indra/llcorehttp/_refcounted.h b/indra/llcorehttp/_refcounted.h index 7470965a7f..bc90f8274d 100644 --- a/indra/llcorehttp/_refcounted.h +++ b/indra/llcorehttp/_refcounted.h @@ -30,24 +30,20 @@ #include "linden_common.h" -#include "fix_macros.h" -#include <boost/thread.hpp> #include <boost/intrusive_ptr.hpp> - #include "llatomic.h" - namespace LLCoreInt { class RefCounted { -private: - RefCounted(); // Not defined - may not be default constructed - void operator=(const RefCounted &); // Not defined - public: + RefCounted() = delete; // Not defined - may not be default constructed + RefCounted(const RefCounted&) = delete; // Not defined - may not be copy constructed + void operator=(const RefCounted&) = delete; // Not defined + explicit RefCounted(bool const implicit) : mRefCount(implicit) {} diff --git a/indra/llcorehttp/_thread.h b/indra/llcorehttp/_thread.h index 6c0e39cf92..66b9689c5a 100644 --- a/indra/llcorehttp/_thread.h +++ b/indra/llcorehttp/_thread.h @@ -29,92 +29,75 @@ #include "linden_common.h" -#include <boost/thread.hpp> -#include <boost/function.hpp> -#include <boost/date_time/posix_time/posix_time_types.hpp> +#include <functional> +#include <thread> -#include "apr.h" // thread-related functions #include "_refcounted.h" namespace LLCoreInt { -class HttpThread : public RefCounted +class HttpThread { -private: - HttpThread(); // Not defined - void operator=(const HttpThread &); // Not defined - - void at_exit() - { - // the thread function has exited so we need to release our reference - // to ourself so that we will be automagically cleaned up. - release(); - } +public: + HttpThread() = delete; // Not defined + HttpThread(const HttpThread&) = delete; // Not defined + void operator=(const HttpThread &) = delete; // Not defined +private: void run() - { // THREAD CONTEXT - - // Take out additional reference for the at_exit handler - addRef(); - boost::this_thread::at_thread_exit(boost::bind(&HttpThread::at_exit, this)); - - // run the thread function - mThreadFunc(this); - - } // THREAD CONTEXT - -protected: - virtual ~HttpThread() - { - delete mThread; - } + { // THREAD CONTEXT + // run the thread function + mThreadFunc(this); + } // THREAD CONTEXT public: /// Constructs a thread object for concurrent execution but does /// not start running. Caller receives on refcount on the thread /// instance. If the thread is started, another will be taken /// out for the exit handler. - explicit HttpThread(boost::function<void (HttpThread *)> threadFunc) - : RefCounted(true), // implicit reference - mThreadFunc(threadFunc) - { - // this creates a boost thread that will call HttpThread::run on this instance - // and pass it the threadfunc callable... - boost::function<void()> f = boost::bind(&HttpThread::run, this); + explicit HttpThread(std::function<void (HttpThread *)> threadFunc) + : mThreadFunc(threadFunc) + { + // this creates a std thread that will call HttpThread::run on this instance + // and pass it the threadfunc callable... + std::function<void()> f = std::bind(&HttpThread::run, this); - mThread = new boost::thread(f); - } + mThread = std::make_unique<std::thread>(f); + mNativeHandle = mThread->native_handle(); + } - inline void join() - { - mThread->join(); - } + ~HttpThread() = default; - inline bool timedJoin(S32 millis) - { - return mThread->timed_join(boost::posix_time::milliseconds(millis)); - } + inline void join() + { + mThread->join(); + } inline bool joinable() const - { - return mThread->joinable(); - } + { + return mThread->joinable(); + } + + inline void detach() + { + mThread->detach(); + } // A very hostile method to force a thread to quit inline void cancel() - { - boost::thread::native_handle_type thread(mThread->native_handle()); -#if LL_WINDOWS - TerminateThread(thread, 0); + { +#if LL_WINDOWS + TerminateThread(mNativeHandle, 0); #else - pthread_cancel(thread); + pthread_cancel(mNativeHandle); #endif - } + } private: - boost::function<void(HttpThread *)> mThreadFunc; - boost::thread * mThread; + std::function<void(HttpThread *)> mThreadFunc; + std::unique_ptr<std::thread> mThread; + std::thread::native_handle_type mNativeHandle; }; // end class HttpThread } // end namespace LLCoreInt diff --git a/indra/llcorehttp/bufferarray.cpp b/indra/llcorehttp/bufferarray.cpp index 6b33661d8f..46c03f991d 100644 --- a/indra/llcorehttp/bufferarray.cpp +++ b/indra/llcorehttp/bufferarray.cpp @@ -57,12 +57,12 @@ public: void operator delete(void *); void operator delete(void *, size_t len); + Block(const Block&) = delete; + Block& operator=(const Block&) = delete; + protected: Block(size_t len); - Block(const Block &); // Not defined - void operator=(const Block &); // Not defined - // Allocate the block with the additional space for the // buffered data at the end of the object. void * operator new(size_t len, size_t addl_len); diff --git a/indra/llcorehttp/bufferarray.h b/indra/llcorehttp/bufferarray.h index 5105dbc4f7..9abe1778ed 100644 --- a/indra/llcorehttp/bufferarray.h +++ b/indra/llcorehttp/bufferarray.h @@ -79,8 +79,8 @@ protected: virtual ~BufferArray(); // Use release() private: - BufferArray(const BufferArray &); // Not defined - void operator=(const BufferArray &); // Not defined + BufferArray(const BufferArray&) = delete; + void operator=(const BufferArray&) = delete; public: // Internal magic number, may be used by unit tests. diff --git a/indra/llcorehttp/bufferstream.h b/indra/llcorehttp/bufferstream.h index 93891810aa..ba84821df3 100644 --- a/indra/llcorehttp/bufferstream.h +++ b/indra/llcorehttp/bufferstream.h @@ -91,8 +91,8 @@ public: virtual ~BufferArrayStreamBuf(); private: - BufferArrayStreamBuf(const BufferArrayStreamBuf &); // Not defined - void operator=(const BufferArrayStreamBuf &); // Not defined + BufferArrayStreamBuf(const BufferArrayStreamBuf&) = delete; + void operator=(const BufferArrayStreamBuf&) = delete; public: // Input interfaces from std::streambuf diff --git a/indra/llcorehttp/examples/http_texture_load.cpp b/indra/llcorehttp/examples/http_texture_load.cpp index 72e0c29a24..986e675d00 100644 --- a/indra/llcorehttp/examples/http_texture_load.cpp +++ b/indra/llcorehttp/examples/http_texture_load.cpp @@ -52,8 +52,6 @@ void init_curl(); void term_curl(); -void ssl_thread_id_callback(CRYPTO_THREADID*); -void ssl_locking_callback(int mode, int type, const char * file, int line); void usage(std::ostream & out); // Default command line settings @@ -606,63 +604,15 @@ void WorkingSet::loadAssetUuids(FILE * in) } -int ssl_mutex_count(0); -LLCoreInt::HttpMutex ** ssl_mutex_list = NULL; - void init_curl() { curl_global_init(CURL_GLOBAL_ALL); - - ssl_mutex_count = CRYPTO_num_locks(); - if (ssl_mutex_count > 0) - { - ssl_mutex_list = new LLCoreInt::HttpMutex * [ssl_mutex_count]; - - for (int i(0); i < ssl_mutex_count; ++i) - { - ssl_mutex_list[i] = new LLCoreInt::HttpMutex; - } - - CRYPTO_set_locking_callback(ssl_locking_callback); - CRYPTO_THREADID_set_callback(ssl_thread_id_callback); - } } void term_curl() { - CRYPTO_set_locking_callback(NULL); - for (int i(0); i < ssl_mutex_count; ++i) - { - delete ssl_mutex_list[i]; - } - delete [] ssl_mutex_list; -} - - -void ssl_thread_id_callback(CRYPTO_THREADID* pthreadid) -{ -#if defined(WIN32) - CRYPTO_THREADID_set_pointer(pthreadid, GetCurrentThread()); -#else - CRYPTO_THREADID_set_pointer(pthreadid, pthread_self()); -#endif -} - - -void ssl_locking_callback(int mode, int type, const char * /* file */, int /* line */) -{ - if (type >= 0 && type < ssl_mutex_count) - { - if (mode & CRYPTO_LOCK) - { - ssl_mutex_list[type]->lock(); - } - else - { - ssl_mutex_list[type]->unlock(); - } - } + curl_global_cleanup(); } diff --git a/indra/llcorehttp/httpcommon.cpp b/indra/llcorehttp/httpcommon.cpp index 315ff15ebb..1037855017 100644 --- a/indra/llcorehttp/httpcommon.cpp +++ b/indra/llcorehttp/httpcommon.cpp @@ -36,10 +36,6 @@ namespace LLCore { -HttpStatus::type_enum_t EXT_CURL_EASY; -HttpStatus::type_enum_t EXT_CURL_MULTI; -HttpStatus::type_enum_t LLCORE; - HttpStatus::operator U32() const { // Effectively, concatenate mType (high) with mStatus (low). @@ -289,8 +285,10 @@ CURL *getCurlTemplateHandle() check_curl_code(result, CURLOPT_NOSIGNAL); result = curl_easy_setopt(curlpTemplateHandle, CURLOPT_NOPROGRESS, 1); check_curl_code(result, CURLOPT_NOPROGRESS); +#if LIBCURL_VERSION_MAJOR < 8 result = curl_easy_setopt(curlpTemplateHandle, CURLOPT_ENCODING, ""); check_curl_code(result, CURLOPT_ENCODING); +#endif result = curl_easy_setopt(curlpTemplateHandle, CURLOPT_AUTOREFERER, 1); check_curl_code(result, CURLOPT_AUTOREFERER); result = curl_easy_setopt(curlpTemplateHandle, CURLOPT_FOLLOWLOCATION, 1); diff --git a/indra/llcorehttp/httpcommon.h b/indra/llcorehttp/httpcommon.h index 511a17e000..001fd08f93 100644 --- a/indra/llcorehttp/httpcommon.h +++ b/indra/llcorehttp/httpcommon.h @@ -191,7 +191,6 @@ #include "llsd.h" #include <string> #include <curl/curl.h> -#include "boost/noncopyable.hpp" namespace LLCore { @@ -297,25 +296,25 @@ struct HttpStatus HttpStatus() { - mDetails = std::shared_ptr<Details>(new Details(LLCORE, HE_SUCCESS)); + mDetails = std::make_shared<Details>(LLCORE, HE_SUCCESS); } HttpStatus(type_enum_t type, short status) { - mDetails = std::shared_ptr<Details>(new Details(type, status)); + mDetails = std::make_shared<Details>(type, status); } HttpStatus(int http_status) { - mDetails = std::shared_ptr<Details>(new Details(http_status, - (http_status >= 200 && http_status <= 299) ? HE_SUCCESS : HE_REPLY_ERROR)); + mDetails = std::make_shared<Details>(http_status, + (http_status >= 200 && http_status <= 299) ? HE_SUCCESS : HE_REPLY_ERROR); llassert(http_status >= 100 && http_status <= 999); } HttpStatus(int http_status, const std::string &message) { - mDetails = std::shared_ptr<Details>(new Details(http_status, - (http_status >= 200 && http_status <= 299) ? HE_SUCCESS : HE_REPLY_ERROR)); + mDetails = std::make_shared<Details>(http_status, + (http_status >= 200 && http_status <= 299) ? HE_SUCCESS : HE_REPLY_ERROR); llassert(http_status >= 100 && http_status <= 999); mDetails->mMessage = message; } @@ -337,13 +336,13 @@ struct HttpStatus HttpStatus & clone(const HttpStatus &rhs) { - mDetails = std::shared_ptr<Details>(new Details(*rhs.mDetails)); + mDetails = std::make_shared<Details>(*rhs.mDetails); return *this; } - static const type_enum_t EXT_CURL_EASY = 0; ///< mStatus is an error from a curl_easy_*() call - static const type_enum_t EXT_CURL_MULTI = 1; ///< mStatus is an error from a curl_multi_*() call - static const type_enum_t LLCORE = 2; ///< mStatus is an HE_* error code + static constexpr type_enum_t EXT_CURL_EASY = 0; ///< mStatus is an error from a curl_easy_*() call + static constexpr type_enum_t EXT_CURL_MULTI = 1; ///< mStatus is an error from a curl_multi_*() call + static constexpr type_enum_t LLCORE = 2; ///< mStatus is an HE_* error code ///< 100-999 directly represent HTTP status codes /// Test for successful status in the code regardless /// of error source (internal, libcurl). diff --git a/indra/llcorehttp/httpheaders.h b/indra/llcorehttp/httpheaders.h index a5ca7749b0..6a03cf4083 100644 --- a/indra/llcorehttp/httpheaders.h +++ b/indra/llcorehttp/httpheaders.h @@ -74,7 +74,7 @@ namespace LLCore /// constructor is given a refcount. /// -class HttpHeaders: private boost::noncopyable +class HttpHeaders { public: typedef std::pair<std::string, std::string> header_t; @@ -94,11 +94,11 @@ public: HttpHeaders(); virtual ~HttpHeaders(); // Use release() - //typedef LLCoreInt::IntrusivePtr<HttpHeaders> ptr_t; -protected: + // Non-copyable + HttpHeaders(const HttpHeaders&) = delete; + HttpHeaders& operator=(const HttpHeaders&) = delete; - HttpHeaders(const HttpHeaders &); // Not defined - void operator=(const HttpHeaders &); // Not defined + //typedef LLCoreInt::IntrusivePtr<HttpHeaders> ptr_t; public: // Empty the list of headers. diff --git a/indra/llcorehttp/httpoptions.cpp b/indra/llcorehttp/httpoptions.cpp index d85f6039b1..5abd28e211 100644 --- a/indra/llcorehttp/httpoptions.cpp +++ b/indra/llcorehttp/httpoptions.cpp @@ -47,6 +47,7 @@ HttpOptions::HttpOptions() : mVerifyPeer(sDefaultVerifyPeer), mVerifyHost(false), mDNSCacheTimeout(-1L), + mLastModified(0), mNoBody(false) {} @@ -129,6 +130,11 @@ void HttpOptions::setHeadersOnly(bool nobody) } } +void HttpOptions::setLastModified(time_t lastModified) +{ + mLastModified = lastModified; +} + void HttpOptions::setDefaultSSLVerifyPeer(bool verify) { sDefaultVerifyPeer = verify; diff --git a/indra/llcorehttp/httpoptions.h b/indra/llcorehttp/httpoptions.h index 56a28013cb..d50c5470ae 100644 --- a/indra/llcorehttp/httpoptions.h +++ b/indra/llcorehttp/httpoptions.h @@ -55,7 +55,7 @@ namespace LLCore /// Allocation: Refcounted, heap only. Caller of the constructor /// is given a refcount. /// -class HttpOptions : private boost::noncopyable +class HttpOptions { public: HttpOptions(); @@ -64,10 +64,9 @@ public: virtual ~HttpOptions(); // Use release() -protected: - - HttpOptions(const HttpOptions &); // Not defined - void operator=(const HttpOptions &); // Not defined + // Non-copyable + HttpOptions(const HttpOptions&) = delete; + HttpOptions& operator=(const HttpOptions&) = delete; public: @@ -178,6 +177,13 @@ public: return mNoBody; } + // Default: 0 + void setLastModified(time_t lastModified); + time_t getLastModified() const + { + return mLastModified; + } + /// Sets default behavior for verifying that the name in the /// security certificate matches the name of the host contacted. /// Defaults false if not set, but should be set according to @@ -199,6 +205,7 @@ protected: bool mVerifyHost; int mDNSCacheTimeout; bool mNoBody; + time_t mLastModified; static bool sDefaultVerifyPeer; }; // end class HttpOptions diff --git a/indra/llcorehttp/httprequest.cpp b/indra/llcorehttp/httprequest.cpp index 2aaf71f8a4..d8fcd6a03f 100644 --- a/indra/llcorehttp/httprequest.cpp +++ b/indra/llcorehttp/httprequest.cpp @@ -60,7 +60,7 @@ HttpRequest::HttpRequest() mRequestQueue = HttpRequestQueue::instanceOf(); mRequestQueue->addRef(); - mReplyQueue.reset( new HttpReplyQueue() ); + mReplyQueue = std::make_shared<HttpReplyQueue>(); HTTPStats::instance().recordHTTPRequest(); } @@ -129,7 +129,7 @@ HttpHandle HttpRequest::setPolicyOption(EPolicyOption opt, policy_t pclass, { HttpStatus status; - HttpOpSetGet::ptr_t op(new HttpOpSetGet()); + HttpOpSetGet::ptr_t op = std::make_shared<HttpOpSetGet>(); if (! (status = op->setupSet(opt, pclass, value))) { mLastReqStatus = status; @@ -152,7 +152,7 @@ HttpHandle HttpRequest::setPolicyOption(EPolicyOption opt, policy_t pclass, { HttpStatus status; - HttpOpSetGet::ptr_t op (new HttpOpSetGet()); + HttpOpSetGet::ptr_t op = std::make_shared<HttpOpSetGet>(); if (! (status = op->setupSet(opt, pclass, value))) { mLastReqStatus = status; @@ -190,7 +190,7 @@ HttpHandle HttpRequest::requestGet(policy_t policy_id, LL_PROFILE_ZONE_SCOPED_CATEGORY_NETWORK; HttpStatus status; - HttpOpRequest::ptr_t op(new HttpOpRequest()); + HttpOpRequest::ptr_t op = std::make_shared<HttpOpRequest>(); if (! (status = op->setupGet(policy_id, url, options, headers))) { mLastReqStatus = status; @@ -219,7 +219,7 @@ HttpHandle HttpRequest::requestGetByteRange(policy_t policy_id, LL_PROFILE_ZONE_SCOPED_CATEGORY_NETWORK; HttpStatus status; - HttpOpRequest::ptr_t op(new HttpOpRequest()); + HttpOpRequest::ptr_t op = std::make_shared<HttpOpRequest>(); if (! (status = op->setupGetByteRange(policy_id, url, offset, len, options, headers))) { mLastReqStatus = status; @@ -246,7 +246,7 @@ HttpHandle HttpRequest::requestPost(policy_t policy_id, { HttpStatus status; - HttpOpRequest::ptr_t op(new HttpOpRequest()); + HttpOpRequest::ptr_t op = std::make_shared<HttpOpRequest>(); if (! (status = op->setupPost(policy_id, url, body, options, headers))) { mLastReqStatus = status; @@ -273,7 +273,7 @@ HttpHandle HttpRequest::requestPut(policy_t policy_id, { HttpStatus status; - HttpOpRequest::ptr_t op (new HttpOpRequest()); + HttpOpRequest::ptr_t op = std::make_shared<HttpOpRequest>(); if (! (status = op->setupPut(policy_id, url, body, options, headers))) { mLastReqStatus = status; @@ -298,7 +298,7 @@ HttpHandle HttpRequest::requestDelete(policy_t policy_id, { HttpStatus status; - HttpOpRequest::ptr_t op(new HttpOpRequest()); + HttpOpRequest::ptr_t op = std::make_shared<HttpOpRequest>(); if (!(status = op->setupDelete(policy_id, url, options, headers))) { mLastReqStatus = status; @@ -324,7 +324,7 @@ HttpHandle HttpRequest::requestPatch(policy_t policy_id, { HttpStatus status; - HttpOpRequest::ptr_t op (new HttpOpRequest()); + HttpOpRequest::ptr_t op = std::make_shared<HttpOpRequest>(); if (!(status = op->setupPatch(policy_id, url, body, options, headers))) { mLastReqStatus = status; @@ -349,7 +349,7 @@ HttpHandle HttpRequest::requestCopy(policy_t policy_id, { HttpStatus status; - HttpOpRequest::ptr_t op(new HttpOpRequest()); + HttpOpRequest::ptr_t op = std::make_shared<HttpOpRequest>(); if (!(status = op->setupCopy(policy_id, url, options, headers))) { mLastReqStatus = status; @@ -375,7 +375,7 @@ HttpHandle HttpRequest::requestMove(policy_t policy_id, { HttpStatus status; - HttpOpRequest::ptr_t op (new HttpOpRequest()); + HttpOpRequest::ptr_t op = std::make_shared<HttpOpRequest>(); if (!(status = op->setupMove(policy_id, url, options, headers))) { mLastReqStatus = status; @@ -397,7 +397,7 @@ HttpHandle HttpRequest::requestNoOp(HttpHandler::ptr_t user_handler) { HttpStatus status; - HttpOperation::ptr_t op (new HttpOpNull()); + HttpOperation::ptr_t op = std::make_shared<HttpOpNull>(); op->setReplyPath(mReplyQueue, user_handler); if (! (status = mRequestQueue->addOp(op))) // transfers refcount { @@ -463,7 +463,7 @@ HttpHandle HttpRequest::requestCancel(HttpHandle request, HttpHandler::ptr_t use { HttpStatus status; - HttpOperation::ptr_t op(new HttpOpCancel(request)); + HttpOperation::ptr_t op = std::make_shared<HttpOpCancel>(request); op->setReplyPath(mReplyQueue, user_handler); if (! (status = mRequestQueue->addOp(op))) // transfers refcount { @@ -528,7 +528,7 @@ HttpHandle HttpRequest::requestStopThread(HttpHandler::ptr_t user_handler) HttpStatus status; HttpHandle handle(LLCORE_HTTP_HANDLE_INVALID); - HttpOperation::ptr_t op(new HttpOpStop()); + HttpOperation::ptr_t op = std::make_shared<HttpOpStop>(); op->setReplyPath(mReplyQueue, user_handler); if (! (status = mRequestQueue->addOp(op))) // transfers refcount { @@ -548,7 +548,7 @@ HttpHandle HttpRequest::requestSpin(int mode) HttpStatus status; HttpHandle handle(LLCORE_HTTP_HANDLE_INVALID); - HttpOperation::ptr_t op(new HttpOpSpin(mode)); + HttpOperation::ptr_t op = std::make_shared<HttpOpSpin>(mode); op->setReplyPath(mReplyQueue, HttpHandler::ptr_t()); if (! (status = mRequestQueue->addOp(op))) // transfers refcount { diff --git a/indra/llcorehttp/httprequest.h b/indra/llcorehttp/httprequest.h index e6e051410e..f7516f127a 100644 --- a/indra/llcorehttp/httprequest.h +++ b/indra/llcorehttp/httprequest.h @@ -104,9 +104,9 @@ public: /// Represents a default, catch-all policy class that guarantees /// eventual service for any HTTP request. - static const policy_t DEFAULT_POLICY_ID = 0; - static const policy_t INVALID_POLICY_ID = 0xFFFFFFFFU; - static const policy_t GLOBAL_POLICY_ID = 0xFFFFFFFEU; + static constexpr policy_t DEFAULT_POLICY_ID = 0; + static constexpr policy_t INVALID_POLICY_ID = 0xFFFFFFFFU; + static constexpr policy_t GLOBAL_POLICY_ID = 0xFFFFFFFEU; /// Create a new policy class into which requests can be made. /// @@ -237,7 +237,7 @@ public: /// Prototype for policy based callbacks. The callback methods will be executed /// on the worker thread so no modifications should be made to the HttpHandler object. - typedef boost::function<HttpStatus(const std::string &, const HttpHandler::ptr_t &, void *)> policyCallback_t; + typedef std::function<HttpStatus(const std::string &, const HttpHandler::ptr_t &, void *)> policyCallback_t; /// Set a policy option for a global or class parameter at /// startup time (prior to thread start). diff --git a/indra/llcorehttp/httpresponse.h b/indra/llcorehttp/httpresponse.h index 99c8f1d2f9..ed2d76c10f 100644 --- a/indra/llcorehttp/httpresponse.h +++ b/indra/llcorehttp/httpresponse.h @@ -65,8 +65,8 @@ public: protected: virtual ~HttpResponse(); // Use release() - HttpResponse(const HttpResponse &); // Not defined - void operator=(const HttpResponse &); // Not defined + HttpResponse(const HttpResponse&) = delete; + void operator=(const HttpResponse&) = delete; public: /// Statistics for the HTTP diff --git a/indra/llcorehttp/httpstats.cpp b/indra/llcorehttp/httpstats.cpp index dc2ac35c71..f64752e91e 100644 --- a/indra/llcorehttp/httpstats.cpp +++ b/indra/llcorehttp/httpstats.cpp @@ -41,6 +41,7 @@ HTTPStats::~HTTPStats() void HTTPStats::resetStats() { + std::lock_guard<std::mutex> lock(mStatsMutex); mResutCodes.clear(); mDataDown.reset(); mDataUp.reset(); @@ -50,6 +51,7 @@ void HTTPStats::resetStats() void HTTPStats::recordResultCode(S32 code) { + std::lock_guard<std::mutex> lock(mStatsMutex); std::map<S32, S32>::iterator it; it = mResutCodes.find(code); @@ -86,6 +88,7 @@ namespace void HTTPStats::dumpStats() { + std::lock_guard<std::mutex> lock(mStatsMutex); std::stringstream out; out << "HTTP DATA SUMMARY" << std::endl; diff --git a/indra/llcorehttp/httpstats.h b/indra/llcorehttp/httpstats.h index 5c0f26d34e..08a537cda7 100644 --- a/indra/llcorehttp/httpstats.h +++ b/indra/llcorehttp/httpstats.h @@ -47,17 +47,23 @@ namespace LLCore void recordDataDown(size_t bytes) { + std::lock_guard<std::mutex> lock(mStatsMutex); mDataDown.push((F32)bytes); } void recordDataUp(size_t bytes) { + std::lock_guard<std::mutex> lock(mStatsMutex); mDataUp.push((F32)bytes); } - void recordHTTPRequest() { ++mRequests; } + void recordHTTPRequest() + { + std::lock_guard<std::mutex> lock(mStatsMutex); + ++mRequests; + } - void recordResultCode(S32 code); + void recordResultCode(S32 code); // http thread void dumpStats(); private: @@ -67,6 +73,8 @@ namespace LLCore S32 mRequests; std::map<S32, S32> mResutCodes; + + std::mutex mStatsMutex; }; diff --git a/indra/llcorehttp/llhttpconstants.cpp b/indra/llcorehttp/llhttpconstants.cpp index 40d6c7506c..7a671543d9 100755..100644 --- a/indra/llcorehttp/llhttpconstants.cpp +++ b/indra/llcorehttp/llhttpconstants.cpp @@ -100,6 +100,7 @@ const std::string HTTP_IN_HEADER_LOCATION("location"); const std::string HTTP_IN_HEADER_RETRY_AFTER("retry-after"); const std::string HTTP_IN_HEADER_SET_COOKIE("set-cookie"); const std::string HTTP_IN_HEADER_USER_AGENT("user-agent"); +const std::string HTTP_IN_HEADER_X_CONTENT_TYPE_OPTIONS("x-content-type-options"); const std::string HTTP_IN_HEADER_X_FORWARDED_FOR("x-forwarded-for"); const std::string HTTP_CONTENT_LLSD_XML("application/llsd+xml"); @@ -122,6 +123,7 @@ const std::string HTTP_CONTENT_IMAGE_BMP("image/bmp"); const std::string HTTP_NO_CACHE("no-cache"); const std::string HTTP_NO_CACHE_CONTROL("no-cache, max-age=0"); +const std::string HTTP_NOSNIFF("nosniff"); const std::string HTTP_VERB_INVALID("(invalid)"); const std::string HTTP_VERB_HEAD("HEAD"); diff --git a/indra/llcorehttp/llhttpconstants.h b/indra/llcorehttp/llhttpconstants.h index 583f9fbcb7..d05d0f1af1 100755..100644 --- a/indra/llcorehttp/llhttpconstants.h +++ b/indra/llcorehttp/llhttpconstants.h @@ -100,6 +100,8 @@ extern const std::string HTTP_VERB_POST; extern const std::string HTTP_VERB_DELETE; extern const std::string HTTP_VERB_MOVE; extern const std::string HTTP_VERB_OPTIONS; +extern const std::string HTTP_VERB_PATCH; +extern const std::string HTTP_VERB_COPY; enum EHTTPMethod { @@ -190,6 +192,7 @@ extern const std::string HTTP_IN_HEADER_LOCATION; extern const std::string HTTP_IN_HEADER_RETRY_AFTER; extern const std::string HTTP_IN_HEADER_SET_COOKIE; extern const std::string HTTP_IN_HEADER_USER_AGENT; +extern const std::string HTTP_IN_HEADER_X_CONTENT_TYPE_OPTIONS; extern const std::string HTTP_IN_HEADER_X_FORWARDED_FOR; //// HTTP Content Types //// @@ -215,5 +218,6 @@ extern const std::string HTTP_CONTENT_IMAGE_BMP; //// HTTP Cache Settings //// extern const std::string HTTP_NO_CACHE; extern const std::string HTTP_NO_CACHE_CONTROL; +extern const std::string HTTP_NOSNIFF; #endif diff --git a/indra/llcorehttp/lljsonrpcws.cpp b/indra/llcorehttp/lljsonrpcws.cpp new file mode 100644 index 0000000000..f04738d805 --- /dev/null +++ b/indra/llcorehttp/lljsonrpcws.cpp @@ -0,0 +1,902 @@ +/** + * @file lljsonrpcws.cpp + * @brief JSON-RPC 2.0 WebSocket server and connection implementation + * + * $LicenseInfo:firstyear=2025&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2025, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + * Linden Research, Inc., 945 Battery Street, San Francisco, CA 94111 USA + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "lljsonrpcws.h" +#include "llerror.h" +#include "llsdjson.h" +#include "lldate.h" +#include "llcoros.h" +#include "lltimer.h" +#include "workqueue.h" + +#include <boost/json.hpp> + +//======================================================================== +// LLJSONRPCConnection Implementation +//======================================================================== + +void LLJSONRPCConnection::onOpen() +{ + LL_INFOS("JSONRPC") << "JSON-RPC connection opened" << LL_ENDL; +} + +void LLJSONRPCConnection::onClose() +{ + // Move the pending-request map out under the lock so we can invoke the + // callbacks without holding it (callbacks may themselves call into this + // connection). + std::unordered_map<std::string, ResponseCallback> pending; + { + LLMutexLock lock(&mMutex); + pending.swap(mPendingRequests); + // Deadlines correspond to entries in mPendingRequests; drop them. + std::priority_queue<PendingDeadline> empty; + mPendingDeadlines.swap(empty); + } + + LL_INFOS("JSONRPC") << "JSON-RPC connection closed, clearing " + << pending.size() << " pending requests" << LL_ENDL; + + for (auto& [id, callback] : pending) + { + if (callback) + { + LLSD error; + error["code"] = RPCError::CONNECTION_CLOSED; + error["message"] = "Connection closed"; + callback(LLSD(), error); + } + } +} + +void LLJSONRPCConnection::onMessage(const std::string& message) +{ + LL_PROFILE_ZONE_SCOPED_CATEGORY_WEBSOCKET; + LL_DEBUGS("JSONRPC") << "Received JSON-RPC message: " << message << LL_ENDL; + + try + { + // Parse JSON message + boost::system::error_code ec; + boost::json::value json_value = boost::json::parse(message, ec); + + if (ec.failed()) + { + LL_WARNS("JSONRPC") << "Failed to parse JSON: " << ec.message() << LL_ENDL; + sendError(LLSD(), ParseError(ec.message())); + return; + } + + // Convert to LLSD + LLSD message_obj = LlsdFromJson(json_value); + + // Handle batch vs single message + if (message_obj.isArray()) + { + // JSON-RPC 2.0 batch requests are intentionally not supported. + // No known client (including the sl-vscode-plugin) sends batches, + // and a spec-compliant implementation would require accumulating + // responses across sync + async handlers before shipping a single + // array frame. If a real use case appears, implement per + // JSON-RPC 2.0 §6. + sendError(LLSD(), InvalidRequest("Batch requests are not supported")); + return; + } + + // Single message + processMessage(message_obj); + } + catch (const std::exception& e) + { + LL_WARNS("JSONRPC") << "Exception processing JSON-RPC message: " << e.what() << LL_ENDL; + sendError(LLSD(), InternalError()); + } +} + +void LLJSONRPCConnection::processMessage(const LLSD& message_obj) +{ + LL_PROFILE_ZONE_SCOPED_CATEGORY_WEBSOCKET; + try + { + // Determine if this is a request, notification, or response + if (message_obj.has("method")) + { + // This is a request or notification + if (validateMessage(message_obj, true)) + { + processRequest(message_obj); + } + } + else if (message_obj.has("result") || message_obj.has("error")) + { + // This is a response + if (validateMessage(message_obj, false)) + { + processResponse(message_obj); + } + } + else + { + LL_WARNS("JSONRPC") << "Message must contain 'method' or 'result'/'error'" << LL_ENDL; + } + } + catch (const RPCError& e) + { + LLSD id = message_obj.has("id") ? message_obj["id"] : LLSD(); + sendError(id, e); + } +} + +void LLJSONRPCConnection::processRequest(const LLSD& request) +{ + LL_PROFILE_ZONE_SCOPED_CATEGORY_WEBSOCKET; + std::string method = request["method"].asString(); + LLSD params = request.has("params") ? request["params"] : LLSD(); + LLSD id = request.has("id") ? request["id"] : LLSD(); + bool is_notification = !request.has("id"); + + LL_DEBUGS("JSONRPC") << "Processing " << (is_notification ? "notification" : "request") + << " for method: " << method << LL_ENDL; + + // Nothing for a peer that has not proven itself, and nothing said of + // which methods there are. + if (!mAuthenticated) + { + LL_DEBUGS("JSONRPC") << "Refused " << method << " from a peer not authenticated" << LL_ENDL; + if (!is_notification) + { + sendError(id, UnauthorizedError()); + } + return; + } + + // Resolve the handler under the mutex, then invoke it unlocked. + MethodHandler handler; + bool is_async = false; + { + LLMutexLock lock(&mMutex); + auto async_it = mAsyncMethodHandlers.find(method); + if (async_it != mAsyncMethodHandlers.end()) + { + handler = async_it->second; + is_async = true; + } + else + { + auto sync_it = mMethodHandlers.find(method); + if (sync_it != mMethodHandlers.end()) + { + handler = sync_it->second; + } + } + } + + if (is_async) + { + // Async handler — launched as a coroutine, response sent by the lambda. + if (is_notification) + { + LL_WARNS("JSONRPC") << "Method " << method + << " called as notification; rejecting request" << LL_ENDL; + throw InvalidRequest("Method " + method + " cannot be called as a notification"); + } + ptr_t conn = std::static_pointer_cast<LLJSONRPCConnection>(getSelfPtr()); + if (!conn) + { + LL_WARNS("JSONRPC") << "Connection expired before method " << method + << " could be launched; failing request" << LL_ENDL; + throw InternalError("Connection expired before method " + method + + " could be launched"); + } + bool posted = LL::WorkQueue::postToMainLoop( + [handler, method, id, params, conn]() + { + LLCoros::instance().launch( + "JSONRPC::" + method, + [handler, method, id, params, conn]() + { + try + { + LLSD result = handler(method, id, params); + if (conn->isConnected()) + { + conn->sendResponse(id, result); + } + else + { + LL_WARNS("JSONRPC") << "Connection closed before async method " + << method << " could send response" << LL_ENDL; + } + } + catch (const RPCError& e) + { + if (conn->isConnected()) + { + conn->sendError(id, e); + } + else + { + LL_WARNS("JSONRPC") << "Connection closed before async method " + << method << " could send error" << LL_ENDL; + } + } + catch (const std::exception& e) + { + if (conn->isConnected()) + { + LL_WARNS("JSONRPC") << "Exception in async method " << method + << ": " << e.what() << LL_ENDL; + conn->sendError(id, InternalError()); + } + else + { + LL_WARNS("JSONRPC") << "Connection closed before async method " + << method << " could send error" << LL_ENDL; + } + } + }); + }); + if (!posted) + { + LL_WARNS("JSONRPC") << "Main loop unavailable; failing async method " << method << LL_ENDL; + throw InternalError("Viewer is shutting down; cannot service " + method); + } + return; + } + + if (!handler) + { + if (!is_notification) + { + sendError(id, MethodNotFound(method)); + } + return; + } + + try + { + LLSD result = handler(method, id, params); + + if (!is_notification) + { + sendResponse(id, result); + } + } + catch (const RPCError& e) + { + if (!is_notification) + { + sendError(id, e); + } + else + { + LL_WARNS("JSONRPC") << "Error in notification handler for " << method + << ": " << e.what() << LL_ENDL; + } + } + catch (const std::exception& e) + { + if (!is_notification) + { + LL_WARNS("JSONRPC") << "Exception in handler for " << method + << ": " << e.what() << LL_ENDL; + sendError(id, InternalError()); + } + else + { + LL_WARNS("JSONRPC") << "Exception in notification handler for " << method + << ": " << e.what() << LL_ENDL; + } + } +} + +void LLJSONRPCConnection::processResponse(const LLSD& response) +{ + LL_PROFILE_ZONE_SCOPED_CATEGORY_WEBSOCKET; + if (!response.has("id")) + { + LL_WARNS("JSONRPC") << "Response missing id field" << LL_ENDL; + return; + } + + std::string id = response["id"].asString(); + ResponseCallback callback; + { + LLMutexLock lock(&mMutex); + auto it = mPendingRequests.find(id); + if (it == mPendingRequests.end()) + { + LL_WARNS("JSONRPC") << "Received response for unknown request id: " << id << LL_ENDL; + return; + } + callback = std::move(it->second); + mPendingRequests.erase(it); + } + + if (callback) + { + LLSD result = response.has("result") ? response["result"] : LLSD(); + LLSD error = response.has("error") ? response["error"] : LLSD(); + + callback(result, error); + } +} + +bool LLJSONRPCConnection::validateMessage(const LLSD& message, bool is_request) +{ + // Check JSON-RPC version + if (!message.has("jsonrpc") || message["jsonrpc"].asString() != "2.0") + { + LL_WARNS("JSONRPC") << "Missing or invalid jsonrpc version" << LL_ENDL; + return false; + } + + if (is_request) + { + // Request/notification validation + if (!message.has("method")) + { + LL_WARNS("JSONRPC") << "Missing method field" << LL_ENDL; + return false; + } + + if (!message["method"].isString()) + { + LL_WARNS("JSONRPC") << "Method must be a string" << LL_ENDL; + return false; + } + + // Params are optional but must be array or object if present + if (message.has("params")) + { + if (!message["params"].isArray() && !message["params"].isMap()) + { + LL_WARNS("JSONRPC") << "Params must be array or object" << LL_ENDL; + return false; + } + } + } + else + { + // Response validation + if (!message.has("id")) + { + LL_WARNS("JSONRPC") << "Response missing id field" << LL_ENDL; + return false; + } + + // Must have either result or error, but not both + bool has_result = message.has("result"); + bool has_error = message.has("error"); + + if (!has_result && !has_error) + { + LL_WARNS("JSONRPC") << "Response must have result or error" << LL_ENDL; + return false; + } + + if (has_result && has_error) + { + LL_WARNS("JSONRPC") << "Response cannot have both result and error" << LL_ENDL; + return false; + } + + // Error must be an object with code and message + if (has_error) + { + LLSD error = message["error"]; + if (!error.isMap()) + { + LL_WARNS("JSONRPC") << "Error must be an object" << LL_ENDL; + return false; + } + if (!error.has("code") || !error.has("message")) + { + LL_WARNS("JSONRPC") << "Error must have code and message" << LL_ENDL; + return false; + } + } + } + return true; +} + +void LLJSONRPCConnection::sweepTimeouts() +{ + LL_PROFILE_ZONE_SCOPED_CATEGORY_WEBSOCKET; + // Pop expired deadlines and collect their callbacks. Tombstones (entries + // whose request already completed) are silently discarded. + std::vector<std::pair<std::string, ResponseCallback>> expired; + const F64 now = LLTimer::getTotalSeconds(); + { + LLMutexLock lock(&mMutex); + while (!mPendingDeadlines.empty() && mPendingDeadlines.top().mDeadline <= now) + { + std::string id = mPendingDeadlines.top().mId; + mPendingDeadlines.pop(); + auto it = mPendingRequests.find(id); + if (it != mPendingRequests.end()) + { + expired.emplace_back(std::move(id), std::move(it->second)); + mPendingRequests.erase(it); + } + } + } + + for (auto& [id, callback] : expired) + { + LL_WARNS("JSONRPC") << "Request " << id << " timed out" << LL_ENDL; + if (callback) + { + LLSD error; + error["code"] = RPCError::REQUEST_TIMEOUT; + error["message"] = "Request timed out"; + callback(LLSD(), error); + } + } +} + +void LLJSONRPCConnection::testInjectPendingRequest(const std::string& id, F64 deadline, ResponseCallback callback) +{ + LLMutexLock lock(&mMutex); + mPendingRequests[id] = std::move(callback); + mPendingDeadlines.push({ deadline, id }); +} + +void LLJSONRPCConnection::testSweepTimeouts() +{ + sweepTimeouts(); +} + +size_t LLJSONRPCConnection::testPendingRequestCount() const +{ + LLMutexLock lock(&mMutex); + return mPendingRequests.size(); +} + +LLSD LLJSONRPCConnection::generateId() +{ + // Server-wide atomic counter for efficient unique ID generation. + // Start above zero to avoid conflicts with any manual test IDs. + static constexpr U64 REQUEST_ID_START = 1000; + static std::atomic<U64> sRequestIdCounter{REQUEST_ID_START}; + + // Generate server-unique sequential ID + U64 id = sRequestIdCounter.fetch_add(1); + return LLSD(llformat("rpc_%llu", id)); +} + +void LLJSONRPCConnection::registerMethod(const std::string& method, MethodHandler handler) +{ + { + LLMutexLock lock(&mMutex); + mMethodHandlers[method] = std::move(handler); + } + LL_DEBUGS("JSONRPC") << "Registered method: " << method << LL_ENDL; +} + +void LLJSONRPCConnection::registerAsyncMethod(const std::string& method, MethodHandler handler) +{ + { + LLMutexLock lock(&mMutex); + mAsyncMethodHandlers[method] = std::move(handler); + } + LL_DEBUGS("JSONRPC") << "Registered async method: " << method << LL_ENDL; +} + +void LLJSONRPCConnection::unregisterMethod(const std::string& method) +{ + { + LLMutexLock lock(&mMutex); + mMethodHandlers.erase(method); + mAsyncMethodHandlers.erase(method); + } + LL_DEBUGS("JSONRPC") << "Unregistered method: " << method << LL_ENDL; +} + +std::set<std::string> LLJSONRPCConnection::getMethods() const +{ + LLMutexLock lock(&mMutex); + std::set<std::string> methods; + + for (const auto& [method, handler] : mMethodHandlers) + { + methods.insert(method); + } + + for (const auto& [method, handler] : mAsyncMethodHandlers) + { + methods.insert(method); + } + + return methods; +} + +LLSD LLJSONRPCConnection::makeEnvelope(const LLSD& id, + const std::string& method, + const LLSD& params, + const LLSD& result, + const LLSD& error) +{ + LLSD env; + env["jsonrpc"] = "2.0"; + // Notifications (requests without an id) are the only case that omits id. + if (!(id.isUndefined() && !method.empty())) + { + env["id"] = id; + } + if (!method.empty()) + { + env["method"] = method; + } + if (params.isDefined()) + { + env["params"] = params; + } + if (result.isDefined()) + { + env["result"] = result; + } + if (error.isDefined()) + { + env["error"] = error; + } + return env; +} + +LLSD LLJSONRPCConnection::call(const std::string& method, const LLSD& params, ResponseCallback callback, F64 timeout) +{ + LL_PROFILE_ZONE_SCOPED_CATEGORY_WEBSOCKET; + LLSD id = generateId(); + LLSD request = makeEnvelope(id, method, params, LLSD(), LLSD()); + const std::string id_str = id.asString(); + + // Store callback if provided. Fire-and-forget calls (no callback) are + // not tracked for timeouts since there is nobody to deliver the error to. + if (callback) + { + LLMutexLock lock(&mMutex); + mPendingRequests[id_str] = std::move(callback); + mPendingDeadlines.push({ LLTimer::getTotalSeconds() + timeout, id_str }); + } + + // Send the request + if (!sendMessage(LlsdToJson(request))) + { + // Remove from pending if send failed + { + LLMutexLock lock(&mMutex); + mPendingRequests.erase(id_str); + } + LL_WARNS("JSONRPC") << "Failed to send request" << LL_ENDL; + return LLSD(); + } + + LL_DEBUGS("JSONRPC") << "Sent request: " << method << " with id: " << id.asString() << LL_ENDL; + return id; +} + +bool LLJSONRPCConnection::notify(const std::string& method, const LLSD& params) +{ + LL_PROFILE_ZONE_SCOPED_CATEGORY_WEBSOCKET; + LLSD notification = makeEnvelope(LLSD(), method, params, LLSD(), LLSD()); + + if (!sendMessage(LlsdToJson(notification))) + { + LL_WARNS("JSONRPC") << "Failed to send notification" << LL_ENDL; + return false; + } + + LL_DEBUGS("JSONRPC") << "Sent notification: " << method << LL_ENDL; + return true; +} + +bool LLJSONRPCConnection::sendResponse(const LLSD& id, const LLSD& result) +{ + LL_PROFILE_ZONE_SCOPED_CATEGORY_WEBSOCKET; + LLSD response = makeEnvelope(id, std::string(), LLSD(), result, LLSD()); + + if (!sendMessage(LlsdToJson(response))) + { + LL_WARNS("JSONRPC") << "Failed to send response for id: " << id.asString() << LL_ENDL; + return false; + } + LL_DEBUGS("JSONRPC") << "Sent response for id: " << id.asString() << LL_ENDL; + return true; +} + +bool LLJSONRPCConnection::sendError(const LLSD& id, const RPCError& error) +{ + LL_PROFILE_ZONE_SCOPED_CATEGORY_WEBSOCKET; + LLSD error_obj; + error_obj["code"] = error.getCode(); + error_obj["message"] = error.what(); + + if (!error.getData().isUndefined()) + { + error_obj["data"] = error.getData(); + } + + // Responses always include id; an undefined id serializes as null (used for parse errors). + LLSD response = makeEnvelope(id, std::string(), LLSD(), LLSD(), error_obj); + + if (!sendMessage(LlsdToJson(response))) + { + LL_WARNS("JSONRPC") << "Failed to send error response" << LL_ENDL; + return false; + } + LL_DEBUGS("JSONRPC") << "Sent error response: " << error.what() << LL_ENDL; + return true; +} + +bool LLJSONRPCConnection::sendBatch(const LLSD& batch, ResponseCallback callback) +{ + if (!batch.isArray() || batch.size() == 0) + { + LL_WARNS("JSONRPC") << "Batch must be non-empty array" << LL_ENDL; + return false; + } + + // For batch requests with callbacks, we need to track multiple responses + // This is complex as we need to correlate all responses before calling callback + // For now, we'll send the batch but won't support batch response callbacks + if (callback) + { + LL_WARNS("JSONRPC") << "Batch response callbacks not yet implemented" << LL_ENDL; + } + + if (!sendMessage(LlsdToJson(batch))) + { + LL_WARNS("JSONRPC") << "Failed to send batch" << LL_ENDL; + return false; + } + + LL_DEBUGS("JSONRPC") << "Sent batch with " << batch.size() << " messages" << LL_ENDL; + return true; +} + +//======================================================================== +// LLJSONRPCServer Implementation +//======================================================================== + +LLJSONRPCServer::LLJSONRPCServer(const std::string& name, U16 port, bool local_only) + : LLWebsocketMgr::WSServer(name, port, local_only), mServerName(name) +{ + LL_INFOS("JSONRPC") << "Created JSON-RPC server: " << name + << " on port " << port << LL_ENDL; + + // Register standard JSON-RPC methods + registerGlobalMethod("system.getStats", [this](const std::string& method, const LLSD& id, const LLSD& params) -> LLSD { + LL_DEBUGS("JSONRPC") << "System method " << method << " called" << LL_ENDL; + return getServerStats(); + }); + +} + +LLWebsocketMgr::WSConnection::ptr_t LLJSONRPCServer::connectionFactory(LLWebsocketMgr::WSServer::ptr_t server, + LLWebsocketMgr::connection_h handle) +{ + auto connection = std::make_shared<LLJSONRPCConnection>(server, handle); + setupConnectionMethods(connection); + return connection; +} + +void LLJSONRPCServer::onConnectionOpened(const LLWebsocketMgr::WSConnection::ptr_t& connection) +{ + LL_INFOS("JSONRPC") << "JSON-RPC client connected, total connections: " + << getConnectionCount() << LL_ENDL; +} + +void LLJSONRPCServer::onConnectionClosed(const LLWebsocketMgr::WSConnection::ptr_t& connection) +{ + LL_INFOS("JSONRPC") << "JSON-RPC client disconnected, total connections: " + << getConnectionCount() << LL_ENDL; +} + +bool LLJSONRPCServer::update() +{ + // Main thread, once per frame; throttled to TIMEOUT_SWEEP_INTERVAL. + if (mSweepTimer.getElapsedTimeF32() < LLJSONRPCConnection::TIMEOUT_SWEEP_INTERVAL) + { + return true; + } + mSweepTimer.reset(); + + for (const auto& conn : getConnections()) + { + if (auto rpc = std::dynamic_pointer_cast<LLJSONRPCConnection>(conn)) + { + rpc->sweepTimeouts(); + } + } + return true; +} + +void LLJSONRPCServer::setupConnectionMethods(LLJSONRPCConnection::ptr_t connection) +{ + LLMutexLock lock(&mGlobalMethodsMutex); + + // Register all global methods on the new connection + for (const auto& [method, handler] : mGlobalMethods) + { + connection->registerMethod(method, handler); + } + + std::weak_ptr<LLJSONRPCConnection> weak_connection = connection; + connection->registerMethod( + "system.listMethods", + [weak_connection]( + const std::string&, + const LLSD&, + const LLSD&) -> LLSD + { + LLSD methods(LLSD::emptyArray()); + auto connection = weak_connection.lock(); + if (!connection) + { + return methods; + } + + for (const std::string& method : connection->getMethods()) + { + methods.append(method); + } + + return methods; + }); + + connection->registerMethod( + "system.ping", + [this, weak_connection]( + const std::string& method, + const LLSD& id, + const LLSD& params) -> LLSD + { + LL_DEBUGS("JSONRPC") << "System method " << method + << " called" << LL_ENDL; + return handlePing(weak_connection.lock(), params); + }); + + connection->registerMethod( + "system.getVersion", + [this, weak_connection]( + const std::string& method, + const LLSD& id, + const LLSD& params) -> LLSD + { + LL_DEBUGS("JSONRPC") << "System method " << method + << " called" << LL_ENDL; + return handleGetVersion(weak_connection.lock(), params); + }); + + connection->registerMethod( + "system.status", + [this, weak_connection]( + const std::string& method, + const LLSD& id, + const LLSD& params) -> LLSD + { + LL_DEBUGS("JSONRPC") << "System method " << method + << " called" << LL_ENDL; + return handleStatus(weak_connection.lock(), params); + }); +} + +LLSD LLJSONRPCServer::handlePing( + const LLJSONRPCConnection::ptr_t& connection, + const LLSD& params) const +{ + return LLSD("pong"); +} + +LLSD LLJSONRPCServer::handleStatus( + const LLJSONRPCConnection::ptr_t& connection, + const LLSD& params) const +{ + LLSD result; + result["status"] = "OK"; + return result; +} + +void LLJSONRPCServer::registerGlobalMethod(const std::string& method, MethodHandler handler) +{ + { + LLMutexLock lock(&mGlobalMethodsMutex); + mGlobalMethods[method] = handler; + } + + // Apply to all existing connections - we need to iterate through connections + // Since mConnections is private, we need to use broadcastMessage or find another approach + // For now, we'll only apply to new connections + + LL_INFOS("JSONRPC") << "Registered global method: " << method << LL_ENDL; +} + +void LLJSONRPCServer::unregisterGlobalMethod(const std::string& method) +{ + { + LLMutexLock lock(&mGlobalMethodsMutex); + mGlobalMethods.erase(method); + } + + // For existing connections, we would need access to them + // This is a limitation of the current design - methods added after connection + // establishment won't be retroactively applied + + LL_INFOS("JSONRPC") << "Unregistered global method: " << method << LL_ENDL; +} + +LLSD LLJSONRPCServer::getMethodList() const +{ + LLMutexLock lock(&mGlobalMethodsMutex); + + LLSD methods = LLSD::emptyArray(); + for (const auto& [method, handler] : mGlobalMethods) + { + methods.append(method); + } + + return methods; +} + +void LLJSONRPCServer::broadcastNotification(const std::string& method, const LLSD& params) +{ + LL_PROFILE_ZONE_SCOPED_CATEGORY_WEBSOCKET; + // Use custom broadcast logic since we need to call notify() on each JSON-RPC connection + // We can't use the base broadcastMessage() because we need structured JSON-RPC messages + + // Create the notification message + LLSD notification = LLJSONRPCConnection::makeEnvelope(LLSD(), method, params, LLSD(), LLSD()); + + // Use the base class broadcast functionality + broadcastMessage(boost::json::serialize(LlsdToJson(notification))); + + // Cache the count: getConnectionCount() walks a locked container in the base. + size_t count = getConnectionCount(); + mTotalNotificationsSent += count; + LL_DEBUGS("JSONRPC") << "Broadcast notification: " << method + << " to " << count << " clients" << LL_ENDL; +} + +LLSD LLJSONRPCServer::getServerStats() const +{ + LLSD stats; + stats["server_name"] = mServerName; + stats["connection_count"] = static_cast<S32>(getConnectionCount()); + stats["is_running"] = isRunning(); + + { + LLMutexLock lock(&mGlobalMethodsMutex); + stats["global_method_count"] = static_cast<S32>(mGlobalMethods.size()); + } + + stats["total_requests_handled"] = static_cast<LLSD::Integer>(mTotalRequestsHandled.load()); + stats["total_notifications_sent"] = static_cast<LLSD::Integer>(mTotalNotificationsSent.load()); + stats["uptime"] = LLDate::now().asString(); + + return stats; +} diff --git a/indra/llcorehttp/lljsonrpcws.h b/indra/llcorehttp/lljsonrpcws.h new file mode 100644 index 0000000000..98c858b022 --- /dev/null +++ b/indra/llcorehttp/lljsonrpcws.h @@ -0,0 +1,547 @@ +/** + * @file lljsonrpcws.h + * @brief JSON-RPC 2.0 WebSocket server and connection implementation + * + * $LicenseInfo:firstyear=2025&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2025, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + * Linden Research, Inc., 945 Battery Street, San Francisco, CA 94111 USA + * $/LicenseInfo$ + */ + +#pragma once + +#include "llwebsocketmgr.h" +#include "llsd.h" +#include "lluuid.h" +#include "lltimer.h" + +#include <functional> +#include <unordered_map> +#include <memory> +#include <queue> + +/** + * @class LLJSONRPCConnection + * @brief JSON-RPC 2.0 WebSocket connection implementation + * + * This class implements the JSON-RPC 2.0 protocol over WebSocket connections. + * It handles request/response patterns, notifications, method registration, + * and error handling according to the JSON-RPC 2.0 specification. + * + * ## JSON-RPC 2.0 Protocol Features + * + * - **Requests**: Method calls that expect a response + * - **Notifications**: Method calls that do not expect a response + * - **Batch Operations**: Multiple requests/notifications in a single message + * - **Error Handling**: Standardized error codes and messages + * - **ID Correlation**: Request/response correlation using unique identifiers + * + * ## Method Handler Registration + * + * Methods use an enhanced handler signature that provides method name and request ID context: + * + * @code + * connection->registerMethod("echo", [](const std::string& method, const LLSD& id, const LLSD& params) -> LLSD { + * LL_INFOS("JSONRPC") << "Method " << method << " called with ID " << id.asString() << LL_ENDL; + * return params; // Echo back the parameters + * }); + * + * connection->registerMethod("add", [](const std::string& method, const LLSD& id, const LLSD& params) -> LLSD { + * if (params.isArray() && params.size() >= 2) { + * LL_INFOS("JSONRPC") << "Adding numbers via " << method << LL_ENDL; + * return params[0].asReal() + params[1].asReal(); + * } + * throw LLJSONRPCConnection::InvalidParams("Expected array with 2 numbers"); + * }); + * @endcode + * + * The enhanced signature enables: + * - Method context awareness for shared handlers + * - Request correlation and distributed tracing + * - Distinction between notifications (id undefined) and requests + * - Enhanced logging and error reporting with context + * + * ## Making RPC Calls + * + * @code + * // Asynchronous request with callback + * LLSD params; + * params.append(5); + * params.append(3); + * connection->call("add", params, [](const LLSD& result, const LLSD& error) { + * if (error.isUndefined()) { + * LL_INFOS() << "Result: " << result.asReal() << LL_ENDL; + * } else { + * LL_WARNS() << "Error: " << error["message"].asString() << LL_ENDL; + * } + * }); + * + * // Fire-and-forget notification + * connection->notify("log", LLSD("Server started")); + * @endcode + */ +class LLJSONRPCConnection : public LLWebsocketMgr::WSConnection +{ +public: + using ptr_t = std::shared_ptr<LLJSONRPCConnection>; + + /// Method handler function signature + /// @param method The method name that was called + /// @param id The request ID (undefined for notifications) + /// @param params The parameters passed to the method + /// @return The result to return to the caller + /// @throw RPCError-derived exceptions for error responses + using MethodHandler = std::function<LLSD(const std::string& method, const LLSD& id, const LLSD& params)>; + + /// Response callback function signature + /// @param result The result from a successful call (undefined if error occurred) + /// @param error The error object if call failed (undefined if successful) + using ResponseCallback = std::function<void(const LLSD& result, const LLSD& error)>; + + /** + * @brief JSON-RPC error base class + */ + class RPCError : public std::runtime_error + { + public: + // JSON-RPC 2.0 Standard Error Codes + static constexpr S32 PARSE_ERROR = -32700; ///< Invalid JSON was received by the server + static constexpr S32 INVALID_REQUEST = -32600; ///< The JSON sent is not a valid Request object + static constexpr S32 METHOD_NOT_FOUND = -32601; ///< The method does not exist / is not available + static constexpr S32 INVALID_PARAMS = -32602; ///< Invalid method parameter(s) + static constexpr S32 INTERNAL_ERROR = -32603; ///< Internal JSON-RPC error + + // Server Error Range (-32000 to -32099) + static constexpr S32 SERVER_ERROR_MIN = -32099; ///< Server error range minimum + static constexpr S32 SERVER_ERROR_MAX = -32000; ///< Server error range maximum + + // Common server-specific errors + static constexpr S32 CONNECTION_CLOSED = -32000; ///< Connection closed unexpectedly + static constexpr S32 REQUEST_TIMEOUT = -32001; ///< Request timed out + static constexpr S32 UNAUTHORIZED = -32002; ///< Authentication required + static constexpr S32 FORBIDDEN = -32003; ///< Access denied + static constexpr S32 RATE_LIMITED = -32004; ///< Too many requests + static constexpr S32 SERVICE_UNAVAILABLE = -32005; ///< Service temporarily unavailable + static constexpr S32 MESSAGE_TOO_LARGE = -32006; ///< Message exceeds maximum size + static constexpr S32 INVALID_SESSION = -32007; ///< Session expired or invalid + + RPCError(S32 code, const std::string& message, const LLSD& data = LLSD()) + : std::runtime_error(message), mCode(code), mData(data) {} + + S32 getCode() const { return mCode; } + const LLSD& getData() const { return mData; } + + protected: + S32 mCode; + LLSD mData; + }; + + /// Standard JSON-RPC error classes using named constants + class ParseError : public RPCError { + public: + ParseError(const std::string& details = "") + : RPCError(PARSE_ERROR, "Parse error" + (details.empty() ? "" : ": " + details)) {} + }; + + class InvalidRequest : public RPCError { + public: + InvalidRequest(const std::string& details = "") + : RPCError(INVALID_REQUEST, "Invalid Request" + (details.empty() ? "" : ": " + details)) {} + }; + + class MethodNotFound : public RPCError { + public: + MethodNotFound(const std::string& method = "") + : RPCError(METHOD_NOT_FOUND, "Method not found" + (method.empty() ? "" : ": " + method)) {} + }; + + class InvalidParams : public RPCError { + public: + InvalidParams(const std::string& details = "") + : RPCError(INVALID_PARAMS, "Invalid params" + (details.empty() ? "" : ": " + details)) {} + }; + + class InternalError : public RPCError { + public: + InternalError(const std::string& details = "") + : RPCError(INTERNAL_ERROR, "Internal error" + (details.empty() ? "" : ": " + details)) {} + }; + + /// Server-specific errors (in the -32000 to -32099 range) + class ConnectionClosedError : public RPCError { + public: + ConnectionClosedError(const std::string& details = "Connection closed") + : RPCError(CONNECTION_CLOSED, details) {} + }; + + class RequestTimeoutError : public RPCError { + public: + RequestTimeoutError(const std::string& details = "Request timed out") + : RPCError(REQUEST_TIMEOUT, details) {} + }; + + class UnauthorizedError : public RPCError { + public: + UnauthorizedError(const std::string& details = "Authentication required") + : RPCError(UNAUTHORIZED, details) {} + }; + + class ForbiddenError : public RPCError { + public: + ForbiddenError(const std::string& details = "Access denied") + : RPCError(FORBIDDEN, details) {} + }; + + class RateLimitedError : public RPCError { + public: + RateLimitedError(const std::string& details = "Too many requests") + : RPCError(RATE_LIMITED, details) {} + }; + + class ServiceUnavailableError : public RPCError { + public: + ServiceUnavailableError(const std::string& details = "Service temporarily unavailable") + : RPCError(SERVICE_UNAVAILABLE, details) {} + }; + + class InvalidSessionError : public RPCError { + public: + InvalidSessionError(const std::string& details = "Session expired or invalid") + : RPCError(INVALID_SESSION, details) {} + }; + + + LLJSONRPCConnection(const LLWebsocketMgr::WSServer::ptr_t server, + const LLWebsocketMgr::connection_h& handle) + : LLWebsocketMgr::WSConnection(server, handle) {} + + ~LLJSONRPCConnection() override = default; + + // WebSocket connection lifecycle + void onOpen() override; + void onClose() override; + void onMessage(const std::string& message) override; + + /** + * @brief Register a method handler + * @param method The method name to register + * @param handler The function to call when this method is invoked + * + * @warning Sync handlers execute on the WebSocket I/O thread. They must + * only touch state that is either internal to this connection + * (protected by the connection's mutex) or otherwise thread-safe. + * Do NOT read or write viewer main-thread-only state (e.g., + * gAgent, gObjectList, LLSelectMgr, LLFloaterReg, gSavedSettings, + * LLInventoryModel, or any LLViewerObject) from a sync handler; + * register with registerAsyncMethod() instead, which dispatches + * to the main thread inside a coroutine. + */ + void registerMethod(const std::string& method, MethodHandler handler); + + /** + * @brief Register an async method handler, executed in a coroutine + * + * Unlike registerMethod(), the handler runs inside an LLCoros coroutine + * and may use llcoro::suspendUntilEventOn* to wait for async results. + * The handler returns its result normally; the framework sends the + * JSON-RPC response automatically when the coroutine returns. + * + * @param method The method name to register + * @param handler The coroutine-safe function to call + */ + void registerAsyncMethod(const std::string& method, MethodHandler handler); + + /** + * @brief Unregister a method handler + * @param method The method name to unregister + */ + void unregisterMethod(const std::string& method); + + /** + * @brief Get the names of all registered methods + * @return Ordered set containing sync and async method names + */ + virtual std::set<std::string> getMethods() const; + + /** + * @brief Make an asynchronous JSON-RPC call + * @param method The method name to call + * @param params The parameters to pass + * @param callback Callback for the response (optional) + * @param timeout Seconds before the callback is told the call timed out + * @return The request ID for correlation, undefined where it could not + * be sent (the callback is then never called) + */ + LLSD call(const std::string& method, const LLSD& params = LLSD(), + ResponseCallback callback = nullptr, F64 timeout = REQUEST_TIMEOUT_SECONDS); + + /** + * @brief Whether the peer's requests are dispatched + * + * A connection starts out unauthenticated and refuses every request as + * unauthorized, whether or not the method exists, until it is told the + * peer has proven itself -- e.g. by answering a challenge. Answers to + * its own calls go through either way: that is how the peer proves + * itself. + */ + bool isAuthenticated() const { return mAuthenticated.load(); } + + /** + * @brief Send a JSON-RPC notification (no response expected) + * @param method The method name + * @param params The parameters to pass + */ + bool notify(const std::string& method, const LLSD& params = LLSD()); + + /** + * @brief Send a successful response to a request + * @param id The request ID from the original request + * @param result The result to return + */ + bool sendResponse(const LLSD& id, const LLSD& result); + + /** + * @brief Send an error response to a request + * @param id The request ID from the original request (can be null) + * @param error The RPCError to send + */ + bool sendError(const LLSD& id, const RPCError& error); + + /** + * @brief Send a batch of requests/notifications + * @param batch Array of request/notification objects + * @param callback Callback for batch response (optional) + */ + bool sendBatch(const LLSD& batch, ResponseCallback callback = nullptr); + +protected: + /** + * @brief Process a single JSON-RPC message + * @param message_obj The parsed JSON message + */ + void processMessage(const LLSD& message_obj); + + /** + * @brief Process a JSON-RPC request + * @param request The request object + */ + void processRequest(const LLSD& request); + + /** + * @brief Process a JSON-RPC response + * @param response The response object + */ + void processResponse(const LLSD& response); + + /** + * @brief Validate a JSON-RPC message structure + * @param message The message to validate + * @param is_request True if validating a request, false for response + */ + bool validateMessage(const LLSD& message, bool is_request = true); + + /** + * @brief Generate the next unique request ID + * @return A server-unique request ID + * + * Generates a server-wide unique identifier using an atomic counter. + * This ensures request IDs are unique across all connections within the + * server instance, providing efficient ID generation with guaranteed uniqueness. + * + * IDs follow the format "rpc_{counter}" where counter is a monotonically + * increasing 64-bit value starting from 1. This approach provides: + * - Guaranteed uniqueness within server scope + * - High performance (atomic increment operation) + * - Predictable, sequential ordering for debugging + * - Thread-safe generation across multiple connections + */ + LLSD generateId(); + + void setAuthenticated(bool authenticated) { mAuthenticated = authenticated; } + +public: + /** + * @brief Build a JSON-RPC 2.0 envelope. + * + * Stamps "jsonrpc" = "2.0" and includes only the fields that are set: + * - @a method is included when non-empty. + * - @a params, @a result, @a error are included when defined. + * - @a id is included unless it is undefined and @a method is non-empty + * (i.e. notifications omit id; responses keep id, serializing an + * undefined id as JSON null per the JSON-RPC spec). + */ + static LLSD makeEnvelope(const LLSD& id, + const std::string& method, + const LLSD& params, + const LLSD& result, + const LLSD& error); + +private: + // Guards the three maps below. Handlers/callbacks are copied out from + // under the lock and then invoked without it held, to avoid re-entrancy + // and to keep the critical section short. + mutable LLMutex mMutex; + std::unordered_map<std::string, MethodHandler> mMethodHandlers; + std::unordered_map<std::string, MethodHandler> mAsyncMethodHandlers; + std::unordered_map<std::string, ResponseCallback> mPendingRequests; + + // Per-request timeout tracking. mPendingDeadlines is a min-heap of + // (deadline, request_id) ordered by deadline; entries whose request has + // already been answered become tombstones (skipped when they reach the + // top). The owning server calls sweepTimeouts() once per frame (see + // LLJSONRPCServer::update()) to fire any expired entries. + struct PendingDeadline + { + F64 mDeadline; // absolute time in seconds (LLTimer::getTotalSeconds) + std::string mId; + // std::priority_queue is a max-heap; invert to get min-heap by deadline. + bool operator<(const PendingDeadline& rhs) const { return mDeadline > rhs.mDeadline; } + }; + std::priority_queue<PendingDeadline> mPendingDeadlines; + + std::atomic<bool> mAuthenticated{ false }; + + static constexpr F64 REQUEST_TIMEOUT_SECONDS = 120.0; + +public: + /// Sweep interval used by LLJSONRPCServer::update() to throttle sweepTimeouts(). + static constexpr F32 TIMEOUT_SWEEP_INTERVAL = 1.0f; + + /// Invoked once per frame by the owning server (see LLJSONRPCServer::update()); + /// fires the timeout callback for any request whose deadline has passed. + /// Main-thread only. + void sweepTimeouts(); + + void testInjectPendingRequest(const std::string& id, F64 deadline, ResponseCallback callback); + void testSweepTimeouts(); + size_t testPendingRequestCount() const; +}; + +/** + * @class LLJSONRPCServer + * @brief JSON-RPC 2.0 WebSocket server implementation + * + * This server extends the basic WebSocket server to provide JSON-RPC 2.0 + * protocol support. It manages JSON-RPC connections and provides server-wide + * method registration and broadcasting capabilities. + * + * ## Server-Wide Method Registration + * + * Methods can be registered at the server level and will be available + * on all connections: + * + * @code + * auto server = std::make_shared<LLJSONRPCServer>("rpc_server", 8080); + * + * server->registerGlobalMethod("getServerInfo", [](const std::string& method, const LLSD& id, const LLSD& params) -> LLSD { + * LL_INFOS("JSONRPC") << "Server info requested via " << method << LL_ENDL; + * LLSD info; + * info["name"] = "My RPC Server"; + * info["version"] = "1.0.0"; + * info["uptime"] = LLDate::now().secondsSinceEpoch(); + * return info; + * }); + * + * server->registerGlobalMethod("listMethods", [server](const std::string& method, const LLSD& id, const LLSD& params) -> LLSD { + * return server->getMethodList(); + * }); + * @endcode + * + * ## Broadcasting and Multi-client Operations + * + * @code + * // Broadcast notification to all connected clients + * server->broadcastNotification("serverAlert", LLSD("Server will restart in 5 minutes")); + * @endcode + */ +class LLJSONRPCServer : public LLWebsocketMgr::WSServer +{ +public: + using ptr_t = std::shared_ptr<LLJSONRPCServer>; + using MethodHandler = LLJSONRPCConnection::MethodHandler; + using ResponseCallback = LLJSONRPCConnection::ResponseCallback; + + LLJSONRPCServer(const std::string& name, U16 port, bool local_only = true); + ~LLJSONRPCServer() override = default; + + // Server lifecycle callbacks + void onConnectionOpened(const LLWebsocketMgr::WSConnection::ptr_t& connection) override; + void onConnectionClosed(const LLWebsocketMgr::WSConnection::ptr_t& connection) override; + + /** + * @brief Register a global method available on all connections + * @param method The method name to register + * @param handler The function to call when this method is invoked + */ + void registerGlobalMethod(const std::string& method, MethodHandler handler); + + /** + * @brief Unregister a global method + * @param method The method name to unregister + */ + void unregisterGlobalMethod(const std::string& method); + + /** + * @brief Get list of registered global methods + * @return Array of method names + */ + LLSD getMethodList() const; + + /** + * @brief Broadcast a notification to all connected clients + * @param method The method name + * @param params The parameters to pass + */ + void broadcastNotification(const std::string& method, const LLSD& params = LLSD()); + + /** + * @brief Get server statistics + * @return Statistics object with connection count, method count, etc. + */ + LLSD getServerStats() const; + + bool update() override; + +protected: + LLWebsocketMgr::WSConnection::ptr_t connectionFactory(LLWebsocketMgr::WSServer::ptr_t server, + LLWebsocketMgr::connection_h handle) override; + + virtual LLSD handlePing(const LLJSONRPCConnection::ptr_t& connection, + const LLSD& params) const; + + virtual LLSD handleGetVersion(const LLJSONRPCConnection::ptr_t& connection, + const LLSD& params) const = 0; + virtual LLSD handleStatus(const LLJSONRPCConnection::ptr_t& connection, + const LLSD& params) const; + + /** + * @brief Apply global method handlers to a new connection + * @param connection The connection to configure + */ + virtual void setupConnectionMethods(LLJSONRPCConnection::ptr_t connection); + +private: + std::unordered_map<std::string, MethodHandler> mGlobalMethods; + mutable LLMutex mGlobalMethodsMutex; + + std::string mServerName; // Store server name for stats + std::atomic<U64> mTotalRequestsHandled{0}; + std::atomic<U64> mTotalNotificationsSent{0}; + LLTimer mSweepTimer; +}; diff --git a/indra/llcorehttp/llwebsocketmgr.cpp b/indra/llcorehttp/llwebsocketmgr.cpp new file mode 100644 index 0000000000..ef07295eed --- /dev/null +++ b/indra/llcorehttp/llwebsocketmgr.cpp @@ -0,0 +1,776 @@ +/** + * @file llwebsocketmgr.cpp + * @brief WebSocket manager singleton implementation + * + * $LicenseInfo:firstyear=2025&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2025, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + * Linden Research, Inc., 945 Battery Street, San Francisco, CA 94111 USA + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "llwebsocketmgr.h" +#include "llerror.h" +#include "llsdserialize.h" +#include "llhost.h" +#include "llsdjson.h" +#include "lltimer.h" + +#include <websocketpp/config/boost_config.hpp> +#include <websocketpp/config/asio_no_tls.hpp> +#include <websocketpp/client.hpp> +#include <websocketpp/server.hpp> + +#include <thread> +#include <atomic> +#include <chrono> + +//------------------------------------------------------------------------ +namespace +{ + using Server_t = websocketpp::server<websocketpp::config::asio>; + using Client_t = websocketpp::client<websocketpp::config::asio>; + using Connection_t = websocketpp::connection<websocketpp::config::asio>; + + // How long stop() waits for the I/O thread to exit before logging a + // warning. It still joins after this -- this is diagnostic, not a timeout. + constexpr F32 SERVER_STOP_WARN_SECONDS = 5.0f; +} + +//------------------------------------------------------------------------ + +//------------------------------------------------------------------------ +// LLWebsocketMgr Implementation +// + +void LLWebsocketMgr::initSingleton() +{ } + +void LLWebsocketMgr::cleanupSingleton() +{ + stopAllServers(); +} + +void LLWebsocketMgr::update() +{ + LL_PROFILE_ZONE_SCOPED_CATEGORY_WEBSOCKET; + std::vector<WSServer::ptr_t> stops; + + for (auto &[name, server] : mServers) + { + if (server && server->isRunning()) + { + if (!server->update()) + { + stops.push_back(server); + } + } + } + + for (const auto& server : stops) + { + if (server) + { + LL_DEBUGS("WebSocket") << "Stopping server: " << server->mServerName << LL_ENDL; + removeServer(server->mServerName); + } + } +} + + +LLWebsocketMgr::WSServer::ptr_t LLWebsocketMgr::findServerByName(const std::string &name) const +{ + auto it = mServers.find(std::string(name)); + if (it != mServers.end()) + { + return it->second; + } + return nullptr; +} + +bool LLWebsocketMgr::addServer(const LLWebsocketMgr::WSServer::ptr_t& server) +{ + if (!server) + { + LL_WARNS("WebSocket") << "Attempted to add a null server" << LL_ENDL; + return false; + } + + auto it = mServers.find(server->mServerName); + if (it != mServers.end()) + { + LL_WARNS("WebSocket") << "Server with name " << server->mServerName << " already exists" << LL_ENDL; + return false; + } + mServers[server->mServerName] = server; + LL_INFOS("WebSocket") << "Added WebSocket server: " << server->mServerName << LL_ENDL; + return true; +} + +bool LLWebsocketMgr::removeServer(const std::string& name) +{ + auto it = mServers.find(name); + if (it == mServers.end()) + { + LL_WARNS("WebSocket") << "No server found with name " << name << " to remove" << LL_ENDL; + return false; + } + if (it->second && it->second->isRunning()) + it->second->stop(); + mServers.erase(it); + LL_INFOS("WebSocket") << "Removed WebSocket server: " << name << LL_ENDL; + return true; +} + + +bool LLWebsocketMgr::startServer(const std::string &name) const +{ + LLWebsocketMgr::WSServer::ptr_t server = findServerByName(name); + if (!server) + { + LL_WARNS("WebSocket") << "No server found with name " << name << " to start" << LL_ENDL; + return false; + } + if (server->isRunning()) + { + LL_WARNS("WebSocket") << "Server " << name << " is already running" << LL_ENDL; + return false; + } + return server->start(); +} + +void LLWebsocketMgr::stopServer(const std::string& name) const +{ + LLWebsocketMgr::WSServer::ptr_t server = findServerByName(name); + if (!server) + { + LL_WARNS("WebSocket") << "No server found with name " << name << " to stop" << LL_ENDL; + return; + } + if (!server->isRunning()) + { + LL_WARNS("WebSocket") << "Server " << name << " is not running" << LL_ENDL; + return; + } + server->stop(); +} + +void LLWebsocketMgr::stopAllServers() +{ + for (auto &[name, server] : mServers) + { + if (server && server->isRunning()) + { + LL_INFOS("WebSocket") << "Stopping server: " << name << LL_ENDL; + server->stop(); + } + } + + mServers.clear(); +} + +//------------------------------------------------------------------------ +struct Server_impl +{ + Server_impl(LLWebsocketMgr::WSServer *owner, U16 port, bool local_only) : + mOwner(owner), + mPort(port), + mLocalOnly(local_only) + { + + mServer.set_validate_handler([this](websocketpp::connection_hdl hdl) { return this->onValidate(hdl); }); + mServer.set_open_handler([this](websocketpp::connection_hdl hdl) { this->onOpen(hdl); }); + mServer.set_close_handler([this](websocketpp::connection_hdl hdl) { this->onClose(hdl); }); + mServer.set_message_handler([this](websocketpp::connection_hdl hdl, Server_t::message_ptr msg) { this->onMessage(hdl, msg); }); + } + + ~Server_impl() = default; + + /** + * @brief Initialize the websocketpp server and configure listening + * + * Performs the initial setup of the websocketpp server by calling init_asio() + * to initialize the ASIO networking layer, then configures the server to listen + * on the specified port. The binding behavior depends on the mLocalOnly flag: + * - If mLocalOnly is true: binds to "127.0.0.1" (localhost only) + * - If mLocalOnly is false: binds to all available network interfaces + */ + void init() + { + mServer.init_asio(); + try { + if (mLocalOnly) + { + std::stringstream port_str; + port_str << mPort; + mServer.listen("127.0.0.1", port_str.str()); + } + else + { + mServer.listen(mPort); + } + } + catch (const websocketpp::exception& e) + { + LL_WARNS("WebSocket") << "WebSocket server listen exception: " << e.what() << LL_ENDL; + } + catch (const std::exception& e) + { + LL_WARNS("WebSocket") << "WebSocket server listen std::exception: " << e.what() << LL_ENDL; + } + catch (...) + { + LL_WARNS("WebSocket") << "WebSocket server listen unknown exception" << LL_ENDL; + } + } + + /** + * @brief Start the websocket server and begin accepting connections + * @return true if server started successfully, false on error + */ + bool start() + { + //if (!mServer.stopped()) + //{ + // LL_WARNS("WebSocket") << "WebSocket server is already running" << LL_ENDL; + // return false; + //} + + try + { + LL_INFOS("WebSocket") << "Starting WebSocket server on port " << mPort + << (mLocalOnly ? " (localhost only)" : " (all interfaces)") << LL_ENDL; + mServer.start_accept(); + + // Run controlled event loop with periodic stop flag checking + while (!mOwner->mShouldStop && !mServer.stopped()) + { + LL_PROFILE_ZONE_NAMED_CATEGORY_WEBSOCKET("ws server run_for"); + // Process events for up to 100ms, then check the stop flag + std::chrono::milliseconds timeout(100); + std::size_t handlers_run = mServer.get_io_service().run_for(timeout); + + // If no handlers were run and the server isn't stopped, + // reset the io_service for the next iteration + if (handlers_run == 0 && !mServer.stopped() && !mOwner->mShouldStop) + { + mServer.get_io_service().restart(); + } + } + + LL_INFOS("WebSocket") << "WebSocket server event loop exited cleanly" << LL_ENDL; + return true; + } + catch (const websocketpp::exception& e) + { + LL_WARNS("WebSocket") << "WebSocket server exception: " << e.what() << LL_ENDL; + return false; + } + catch (const std::exception& e) + { + LL_WARNS("WebSocket") << "WebSocket server std::exception: " << e.what() << LL_ENDL; + return false; + } + catch (...) + { + LL_WARNS("WebSocket") << "WebSocket server unknown exception" << LL_ENDL; + return false; + } + } + + void stop() + { + if (mServer.stopped()) + { + return; + } + try + { + mServer.stop_listening(); + mServer.stop(); + } + catch (const std::exception&) + { + LL_WARNS("WebSocket") << "Error stopping WebSocket server" << LL_ENDL; + } + } + + /** + * @brief Decide whether an upgrade request is let in + * @param hdl WebSocket connection handle from websocketpp + * @return false to answer the request 403 and never open it + * + * Called by websocketpp once the request has been read, before it is + * answered: the owner says which origins it takes. + */ + bool onValidate(websocketpp::connection_hdl hdl) + { + LL_PROFILE_ZONE_SCOPED_CATEGORY_WEBSOCKET; + websocketpp::lib::error_code ec; + Server_t::connection_ptr con = mServer.get_con_from_hdl(hdl, ec); + if (ec || !con) + { + return false; + } + const std::string& origin = con->get_origin(); + if (mOwner->acceptOrigin(origin)) + { + return true; + } + // Once for each origin: a page can ask as often as it likes. + LL_WARNS_ONCE("WebSocket") << mOwner->mServerName << " refused a connection from origin " << origin << LL_ENDL; + con->set_status(websocketpp::http::status_code::forbidden); + return false; + } + + /** + * @brief Handle new connection establishment event + * @param hdl WebSocket connection handle from websocketpp + * + * Called automatically by the websocketpp library when a new client connection + * is successfully established. This method serves as a bridge between the + * low-level websocketpp callback and the high-level WSServer interface. + */ + void onOpen(websocketpp::connection_hdl hdl) const + { + LL_PROFILE_ZONE_SCOPED_CATEGORY_WEBSOCKET; + LL_ERRS_IF(!mOwner, "WebSocket") << "mOwner should never be null. If it is, something is very wrong!" << LL_ENDL; + + mOwner->handleOpenConnection(hdl); + } + + /** + * @brief Handle connection closure event + * @param hdl WebSocket connection handle from websocketpp + */ + void onClose(websocketpp::connection_hdl hdl) const + { + LL_PROFILE_ZONE_SCOPED_CATEGORY_WEBSOCKET; + LL_ERRS_IF(!mOwner, "WebSocket") << "mOwner should never be null" << LL_ENDL; + mOwner->handleCloseConnection(hdl); + } + + /** + * @brief Handle incoming message from client + * @param hdl WebSocket connection handle identifying the sender + * @param msg Shared pointer to the message object containing payload and metadata + * + * Called automatically by the websocketpp library when a complete message is + * received from a client. This method validates the connection exists, extracts + * the message payload, and forwards it to the appropriate connection handler. + * + * Currently handles text messages only. + */ + void onMessage(websocketpp::connection_hdl hdl, Server_t::message_ptr msg) const + { + LL_PROFILE_ZONE_SCOPED_CATEGORY_WEBSOCKET; + LL_ERRS_IF(!mOwner, "WebSocket") << "mOwner should never be null" << LL_ENDL; + LLWebsocketMgr::WSConnection::ptr_t connection = mOwner->getConnection(hdl); + if (!connection) + { + LL_WARNS("WebSocket") << "Received message for unknown connection" << LL_ENDL; + return; + } + + // TODO: check the FIN bit and handle fragmented messages if needed + // TODO: check terminal and close codes and handle connection closure if needed + mOwner->handleMessage(hdl, msg->get_payload()); + } + + //------------------------------------------- + Server_t mServer; ///< The underlying websocketpp server instance + LLWebsocketMgr::WSServer* mOwner{ nullptr }; ///< Back-reference to the owning WSServer instance (guaranteed non-null) + U16 mPort{ 0 }; ///< TCP port number the server listens on + bool mLocalOnly{ true }; ///< Whether to bind to localhost only (true) or all interfaces (false) +}; + +//------------------------------------------------------------------------ +LLWebsocketMgr::WSServer::WSServer(std::string_view name, U16 port, bool local_only): + mServerName(name), + mImpl(std::make_unique<Server_impl>(this, port, local_only)) +{ + mImpl->init(); + + // Initialize the server with the given name and host + LL_INFOS("WebSocket") << "Creating WebSocket server: " << name << + " listening " << (mImpl->mLocalOnly ? "locally" : "ON ALL INTERFACES") << + " on port " << mImpl->mPort << LL_ENDL; +} + +LLWebsocketMgr::WSServer::~WSServer() +{ + // Ensure the server is stopped before destruction + stop(); +} + +LLWebsocketMgr::WSConnection::ptr_t LLWebsocketMgr::WSServer::connectionFactory(LLWebsocketMgr::WSServer::ptr_t server, + LLWebsocketMgr::connection_h handle) +{ + return std::make_shared<LLWebsocketMgr::WSConnection>(server, handle); +} + +bool LLWebsocketMgr::WSServer::start() +{ + LL_ERRS_IF(!mImpl, "WebSocket") << "WebSocket server " << mServerName << " implementation is null !" << LL_ENDL; + + LLMutexLock lock(&mThreadMutex); + + // Check if already running + if (isRunning()) + { + LL_WARNS("WebSocket") << "Server " << mServerName << " is already running" << LL_ENDL; + return false; + } + + // Reset the stop flag + mShouldStop = false; + mRunning = true; + + // Start the server thread + mServerThread = std::thread([this]() { + LL_PROFILER_SET_THREAD_NAME(mServerName.c_str()); + LL_INFOS("WebSocket") << "WebSocket server thread starting for: " << mServerName << LL_ENDL; + + // Run the controlled server loop that checks the stop flag + // Server_impl accesses mShouldStop through the mOwner pointer + bool success = mImpl->start(); + + if (!success) + { + LL_WARNS("WebSocket") << "WebSocket server thread failed to start for: " << mServerName << LL_ENDL; + } + + LL_INFOS("WebSocket") << "WebSocket server thread exiting for: " << mServerName << LL_ENDL; + mRunning = false; + }); + + onStarted(); + LL_INFOS("WebSocket") << "Started WebSocket server thread: " << mServerName << LL_ENDL; + return true; +} + +void LLWebsocketMgr::WSServer::stop() +{ + LL_ERRS_IF(!mImpl, "WebSocket") << "WebSocket server " << mServerName << " implementation is null !" << LL_ENDL; + + { + LLMutexLock lock(&mThreadMutex); + + // Check if already stopped + if (!isRunning()) + { + return; + } + + LL_INFOS("WebSocket") << "Stopping WebSocket server: " << mServerName << LL_ENDL; + + mShouldStop = true; + + // Send close frames to all connected clients before stopping the ASIO loop + closeAllConnections(1001, "Server shutting down"); + + // Stop the websocket server (this will cause the controlled run loop to exit) + mImpl->stop(); + } // Release the lock here + + if (mServerThread.joinable()) + { + // The I/O thread should exit promptly once mImpl->stop() returns. If + // it doesn't, something on that thread is blocked -- most likely a + // regression reintroducing a cross-thread wait. Warn instead of + // hanging silently; still join (detaching would use-after-free mImpl). + LLTimer wait_timer; + while (mRunning && wait_timer.getElapsedTimeF32() < SERVER_STOP_WARN_SECONDS) + { + ms_sleep(10); + } + if (mRunning) + { + LL_WARNS("WebSocket") << mServerName << ": I/O thread did not exit within " + << SERVER_STOP_WARN_SECONDS << "s; joining anyway. This indicates a " + << "thread-boundary regression." << LL_ENDL; + } + + mServerThread.join(); + LL_INFOS("WebSocket") << "WebSocket server thread joined for: " << mServerName << LL_ENDL; + } + onStopped(); +} + +bool LLWebsocketMgr::WSServer::isRunning() const +{ + // mRunning is set true just before the I/O thread launches and false as + // the last thing that thread does before exiting -- lock-free, and safe + // to call while start()/stop() hold mThreadMutex on this thread (unlike + // a mutex-based check, which would self-deadlock there). + return mRunning && !mShouldStop; +} + +void LLWebsocketMgr::WSServer::broadcastMessage(const std::string& message) +{ + LL_PROFILE_ZONE_SCOPED_CATEGORY_WEBSOCKET; + LL_ERRS_IF(!mImpl, "WebSocket") << "WebSocket server " << mServerName << " implementation is null !" << LL_ENDL; + LLMutexLock lock(&mConnectionMutex); + for (const auto& [handle, conn] : mConnections) + { + sendMessageTo(handle, message); + } +} + +bool LLWebsocketMgr::WSServer::sendMessageTo(const connection_h& handle, const std::string& message) +{ + LL_PROFILE_ZONE_SCOPED_CATEGORY_WEBSOCKET; + LL_ERRS_IF(!mImpl, "WebSocket") << "WebSocket server " << mServerName << " implementation is null !" << LL_ENDL; + websocketpp::lib::error_code ec; + mImpl->mServer.send(handle, message, websocketpp::frame::opcode::text, ec); + if (ec) + { + LL_WARNS("WebSocket") << mServerName << " failed to send message: " << ec.message() << LL_ENDL; + return false; + } + return true; +} + +bool LLWebsocketMgr::WSServer::closeConnection(const connection_h& handle, U16 code, const std::string& reason) +{ + LL_ERRS_IF(!mImpl, "WebSocket") << "WebSocket server " << mServerName << " implementation is null !" << LL_ENDL; + + try + { + websocketpp::lib::error_code ec; + mImpl->mServer.close(handle, code, reason, ec); + if (ec) + { + LL_WARNS("WebSocket") << mServerName << " failed to close connection: " << ec.message() << LL_ENDL; + return false; + } + + LL_INFOS("WebSocket") << mServerName << " initiated close for connection with code " + << code << " and reason: " << reason << LL_ENDL; + return true; + } + catch (const websocketpp::exception& e) + { + LL_WARNS("WebSocket") << mServerName << " exception closing connection: " << e.what() << LL_ENDL; + return false; + } + catch (const std::exception& e) + { + LL_WARNS("WebSocket") << mServerName << " std::exception closing connection: " << e.what() << LL_ENDL; + return false; + } + catch (...) + { + LL_WARNS("WebSocket") << mServerName << " unknown exception closing connection" << LL_ENDL; + return false; + } +} + +LLWebsocketMgr::WSConnection::ptr_t LLWebsocketMgr::WSServer::getConnection(const connection_h& handle) +{ + LLMutexLock lock(&mConnectionMutex); + auto it = mConnections.find(handle); + if (it != mConnections.end()) + { + return it->second; + } + return nullptr; +} + +std::vector<LLWebsocketMgr::WSConnection::ptr_t> LLWebsocketMgr::WSServer::getConnections() const +{ + LLMutexLock lock(&mConnectionMutex); + std::vector<WSConnection::ptr_t> result; + result.reserve(mConnections.size()); + for (const auto& [handle, conn] : mConnections) + { + result.push_back(conn); + } + return result; +} + +LLWebsocketMgr::connection_state_t LLWebsocketMgr::WSServer::getConnectionState(const connection_h& handle) const +{ + websocketpp::lib::error_code ec; + auto con = mImpl->mServer.get_con_from_hdl(handle, ec); + if (ec) + { + LL_WARNS("WebSocket") << mServerName << " failed to get connection state: " << ec.message() << LL_ENDL; + websocketpp::session::state::value state = websocketpp::session::state::closed; + return connection_closed; + } + return static_cast<connection_state_t>(con->get_state()); +} + + +void LLWebsocketMgr::WSServer::handleOpenConnection(const connection_h& handle) +{ + LL_PROFILE_ZONE_SCOPED_CATEGORY_WEBSOCKET; + WSConnection::ptr_t connection; + size_t size(0); + { + LLMutexLock lock(&mConnectionMutex); + auto it = mConnections.find(handle); + if (it == mConnections.end()) + { + connection = connectionFactory(shared_from_this(), handle); + if (!connection) + { + LL_WARNS("WebSocket") << "Failed to create connection for websocket server " << mServerName << LL_ENDL; + return; + } + mConnections[handle] = connection; + } + else + { + connection = it->second; + } + + if (!connection) + { + LL_WARNS("WebSocket") << mServerName << " failed to create connection object" << LL_ENDL; + return; + } + // Removed redundant assignment to mConnections[handle] + size = mConnections.size(); + } + + onConnectionOpened(connection); // TODO: consider letting the server reject the connection here + connection->onOpen(); + LL_INFOS("WebSocket") << mServerName << " opened new connection, total connections: " << size << LL_ENDL; +} + +void LLWebsocketMgr::WSServer::handleCloseConnection(const connection_h& handle) +{ + LL_PROFILE_ZONE_SCOPED_CATEGORY_WEBSOCKET; + size_t size(0); + WSConnection::ptr_t connection; + { + LLMutexLock lock(&mConnectionMutex); + auto it = mConnections.find(handle); + if (it != mConnections.end()) + { + connection = it->second; + mConnections.erase(it); + } + size = mConnections.size(); + } + if (connection) + { + connection->onClose(); + onConnectionClosed(connection); + LL_INFOS("WebSocket") << mServerName << " closed connection, total connections: " << size << LL_ENDL; + } + else + { + LL_WARNS("WebSocket") << mServerName << " attempted to close unknown connection" << LL_ENDL; + } +} + +void LLWebsocketMgr::WSServer::handleMessage(const connection_h& handle, const std::string& message) +{ + LL_PROFILE_ZONE_SCOPED_CATEGORY_WEBSOCKET; + WSConnection::ptr_t connection = getConnection(handle); + if (connection) + { + connection->onMessage(message); + } + else + { + LL_WARNS("WebSocket") << mServerName << " received message for unknown connection" << LL_ENDL; + } +} + +//------------------------------------------------------------------------ +bool LLWebsocketMgr::WSConnection::sendMessage(const std::string& message) const +{ + if (mOwningServer.expired()) + { + LL_WARNS("WebSocket") << "Attempted to send message on connection with null server reference" << LL_ENDL; + return false; + } + return mOwningServer.lock()->sendMessageTo(mConnectionHandle, message); +} + +bool LLWebsocketMgr::WSConnection::sendMessage(const boost::json::value& json) const +{ + std::string message = boost::json::serialize(json); + return sendMessage(message); +} + +bool LLWebsocketMgr::WSConnection::sendMessage(const LLSD& data) const +{ + return sendMessage(LlsdToJson(data)); +} + +void LLWebsocketMgr::WSConnection::closeConnection(U16 code, const std::string& reason) +{ + if (mOwningServer.expired()) + { + LL_WARNS("WebSocket") << "Attempted to close connection with null server reference" << LL_ENDL; + return; + } + + LL_INFOS("WebSocket") << "WSConnection closing connection with code " << code + << " and reason: " << (reason.empty() ? "(no reason)" : reason) << LL_ENDL; + + if (!mOwningServer.lock()->closeConnection(mConnectionHandle, code, reason)) + { + LL_WARNS("WebSocket") << "Failed to close connection through server" << LL_ENDL; + } +} + +bool LLWebsocketMgr::WSConnection::isConnected() const +{ + if (mOwningServer.expired()) + { + return false; + } + + LLWebsocketMgr::WSServer::ptr_t server = mOwningServer.lock(); + if (!server) + { + return false; + } + return server->getConnectionState(mConnectionHandle) == connection_open; +} + +LLWebsocketMgr::WSConnection::ptr_t LLWebsocketMgr::WSConnection::getSelfPtr() +{ + auto server = mOwningServer.lock(); + if (!server) return nullptr; + return server->getConnection(mConnectionHandle); +} + +void LLWebsocketMgr::WSServer::closeAllConnections(U16 code, const std::string& reason) +{ + std::vector<connection_h> handles; + { + LLMutexLock lock(&mConnectionMutex); + for (const auto& [handle, conn] : mConnections) + { + handles.push_back(handle); + } + } + for (const auto& handle : handles) + { + closeConnection(handle, code, reason); + } +} diff --git a/indra/llcorehttp/llwebsocketmgr.h b/indra/llcorehttp/llwebsocketmgr.h new file mode 100644 index 0000000000..19ceb5feed --- /dev/null +++ b/indra/llcorehttp/llwebsocketmgr.h @@ -0,0 +1,328 @@ +/** + * @file llwebsocketmgr.h + * @brief WebSocket manager singleton for managing WebSocket servers and connections + * + * $LicenseInfo:firstyear=2025&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2025, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + * Linden Research, Inc., 945 Battery Street, San Francisco, CA 94111 USA + * $/LicenseInfo$ + */ + +#pragma once + +#include "llsingleton.h" +#include "llsd.h" +#include "lluuid.h" +#include "llhost.h" +#include "llmutex.h" + +#include <memory> +#include <map> +#include <vector> +#include <functional> +#include <thread> +#include <atomic> + +#include <boost/json.hpp> + +#include <websocketpp/common/connection_hdl.hpp> + +struct Server_impl; + +/** + * @class LLWebsocketMgr + * @brief Singleton manager for WebSocket connections and servers + * + * This class provides a high-level interface for managing WebSocket connections + * and servers using websocketpp library. It handles both client and server + * connections, provides thread-safe operations, and integrates with the + * existing Linden Lab infrastructure. + */ +class LLWebsocketMgr: public LLSingleton<LLWebsocketMgr> +{ + LLSINGLETON(LLWebsocketMgr) = default; + virtual ~LLWebsocketMgr() = default; + LOG_CLASS(LLWebsocketMgr); + +public: + using connection_h = websocketpp::connection_hdl; + class WSServer; + + enum connection_state_t + { // must map to websocketpp::session::state + connection_connecting = 0, + connection_open = 1, + connection_closing = 2, + connection_closed = 3 + }; + + class WSConnection + { + friend class LLWebsocketMgr; + + public: + using ptr_t = std::shared_ptr<WSConnection>; + + /** + * @brief Constructor for WSConnection + * @param server Shared pointer to the parent WSServer + * @param handle WebSocket connection handle from websocketpp + */ + WSConnection(const std::shared_ptr<WSServer> &server, const connection_h& handle): + mConnectionHandle(handle), + mOwningServer(server) + {} + + virtual ~WSConnection() = default; + + /** + * Override this method in derived classes to handle connection establishment. + * This is called after the WebSocket handshake is complete and the connection + * is ready to send/receive messages. + */ + virtual void onOpen() {} + + /** + * Override this method in derived classes to handle connection closure. + * This is called when the connection has been terminated, either normally + * or due to an error condition. + */ + virtual void onClose() {} + + /** + * @brief Called when a message is received + * @param message The received message as a string + * + * Override this method in derived classes to handle incoming messages. + * Currently only text messages are supported. + */ + virtual void onMessage(const std::string& message) {} + + /** + * @brief Send a message to the connected client + * @param message The message string to send + * @return true if the message was queued successfully, false on error + * + * Sends a text message to the remote endpoint. The message is queued + * asynchronously and may not be sent immediately. + */ + virtual bool sendMessage(const std::string& message) const; + bool sendMessage(const boost::json::value& json) const; + bool sendMessage(const LLSD& data) const; + + /** + * @brief Close the WebSocket connection gracefully + * @param code Optional close code (default: normal closure) + * @param reason Optional reason string (default: empty) + * + * Initiates a graceful WebSocket close handshake. The connection will + * send a close frame with the specified code and reason, then wait for + * the remote endpoint to respond with its own close frame before + * actually closing the underlying TCP connection. + * + * Common close codes: + * - 1000: Normal closure (default) + * - 1001: Going away (server shutting down, page navigating away) + * - 1002: Protocol error + * - 1003: Unsupported data type + * - 1008: Policy violation + * - 1009: Message too big + * + * @note After calling this method, no further messages should be sent + * @note The onClose() callback will be invoked when the close handshake completes + */ + void closeConnection(U16 code = 1000, const std::string& reason = std::string()); + + bool isConnected() const; + + protected: + /// Returns a shared_ptr to this connection, retrieved from the owning server. + /// Valid only while the connection is open and registered with the server. + ptr_t getSelfPtr(); + + connection_h mConnectionHandle; + std::weak_ptr<WSServer> mOwningServer; // Back-reference to the server this connection belongs to + }; + + /** + * @class WSServer + * @brief Base class for WebSocket servers with customizable connection handling + * + * WSServer provides a high-level abstraction over websocketpp servers, handling + * threading, connection management, and event dispatching. Derive from this class + * to create custom WebSocket servers with application-specific logic. + * + * ## Basic Usage + * + * @code + * class MyServer : public LLWebsocketMgr::WSServer + * { + * public: + * MyServer(const std::string& name, U16 port) + * : WSServer(name, port, false) // Listen on all interfaces + * {} + * + * void onConnectionOpened(const WSConnection::ptr_t& connection) override + * { + * LL_INFOS("MyServer") << "New client connected" << LL_ENDL; + * // Send welcome message + * connection->sendMessage("Welcome to the server!"); + * } + * + * void onConnectionClosed(const WSConnection::ptr_t& connection) override + * { + * LL_INFOS("MyServer") << "Client disconnected" << LL_ENDL; + * } + * + * protected: + * // Use custom connection class + * WSConnection::ptr_t connectionFactory(WSServer::ptr_t server, connection_h handle) override + * { + * return std::make_shared<MyConnection>(server, handle); + * } + * }; + * @endcode + * + * ## Connection Management + * + * The server automatically manages connection lifetimes and provides several ways + * to interact with connections: + * + * - `broadcastMessage()` - Send message to all connected clients + * - `sendMessageTo()` - Send message to specific connection + * - `closeConnection()` - Close specific connection with code/reason + * - `getConnection()` - Get connection object by handle + * + * ## Thread Safety + * + * All public methods are thread-safe and can be called from any thread. The server + * runs its own background thread for handling WebSocket events, while connection + * callbacks are also executed on this background thread. + */ + class WSServer: public std::enable_shared_from_this<WSServer> + { + friend struct Server_impl; + friend class WSConnection; + friend class LLWebsocketMgr; + + public: + using ptr_t = std::shared_ptr<WSServer>; + + WSServer(std::string_view name, U16 port, bool local_only = true); + virtual ~WSServer(); + + virtual void onStarted() {} + virtual void onStopped() {} + + virtual void onConnectionOpened(const WSConnection::ptr_t& connection) { } + virtual void onConnectionClosed(const WSConnection::ptr_t& connection) { } + + bool isRunning() const; + size_t getConnectionCount() const + { + LLMutexLock lock(&mConnectionMutex); + return mConnections.size(); + } + + void broadcastMessage(const std::string& message); + virtual bool update() { return true; } + + /** + * @brief Whether an upgrade request from this origin is let in + * @param origin The request's Origin header, empty where it had none + * + * A browser sends the page's origin with every WebSocket it opens, + * and nothing stops a page -- or media on a prim, in the viewer's + * own browser -- from opening one to 127.0.0.1. A program, such as + * an editor's extension, sends none. By default only those are let + * in; a request refused is answered 403 and never opens. Called on + * the server's thread. + */ + virtual bool acceptOrigin(const std::string& origin) const { return origin.empty(); } + + connection_state_t getConnectionState(const connection_h& handle) const; + + protected: + /// Snapshot of live connections. Safe to iterate after the lock is + /// released -- these are real shared_ptrs, not weak references. + std::vector<WSConnection::ptr_t> getConnections() const; + + virtual WSConnection::ptr_t connectionFactory(WSServer::ptr_t server, connection_h handle); + + bool start(); + void stop(); + + bool sendMessageTo(const connection_h& handle, const std::string& message); + + /** + * @brief Close a specific connection gracefully + * @param handle The connection handle to close + * @param code Close code (default: normal closure) + * @param reason Close reason string (default: empty) + * @return true if close was initiated successfully, false on error + * + * Internal method used by WSConnection to close individual connections. + * This method is thread-safe and can be called from any thread. + */ + bool closeConnection(const connection_h& handle, U16 code = 1000, const std::string& reason = std::string()); + void closeAllConnections(U16 code = 1001, const std::string& reason = "Server shutting down"); + + private: + using connection_map_t = std::map<connection_h, WSConnection::ptr_t, std::owner_less<connection_h> >; + + WSConnection::ptr_t getConnection(const connection_h& handle); + + void handleOpenConnection(const connection_h& handle); + void handleCloseConnection(const connection_h& handle); + void handleMessage(const connection_h& handle, const std::string& message); + + std::string mServerName; + std::unique_ptr<Server_impl> mImpl; + connection_map_t mConnections; + mutable LLMutex mConnectionMutex; + + // Threading support + std::thread mServerThread; ///< Thread running the ASIO event loop + std::atomic<bool> mShouldStop{ false }; ///< Thread-safe stop flag + std::atomic<bool> mRunning{ false }; ///< True from just before the I/O thread launches until it exits + mutable LLMutex mThreadMutex; ///< Mutex for thread synchronization + }; + + // Server and Connection Management + WSServer::ptr_t findServerByName(const std::string &name) const; + + bool addServer(const WSServer::ptr_t& server); + bool removeServer(const std::string &name); + + bool startServer(const std::string &name) const; + void stopServer(const std::string &name) const; + + void update(); + +protected: + void initSingleton() override; + void cleanupSingleton() override; + +private: + using server_map_t = std::map<std::string, WSServer::ptr_t>; + + void stopAllServers(); + + server_map_t mServers; +}; diff --git a/indra/llcorehttp/tests/jsonrpcws_test.cpp b/indra/llcorehttp/tests/jsonrpcws_test.cpp new file mode 100644 index 0000000000..c07cb6f4fd --- /dev/null +++ b/indra/llcorehttp/tests/jsonrpcws_test.cpp @@ -0,0 +1,553 @@ +/** + * @file jsonrpcws_test.cpp + * @brief Security-focused unit tests for LLJSONRPCConnection / LLJSONRPCServer + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + * Linden Research, Inc., 945 Battery Street, San Francisco, CA 94111 USA + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "lljsonrpcws.h" +#include "llsdjson.h" + +#include "../test/lltut.h" + +#include <boost/json.hpp> +#include <vector> + +namespace +{ + // Exposes the protected test-only members LLJSONRPCConnection provides + // for exactly this purpose, on a connection with no real transport. + class TestJSONRPCConnection : public LLJSONRPCConnection + { + public: + TestJSONRPCConnection() + : LLJSONRPCConnection(LLWebsocketMgr::WSServer::ptr_t(), LLWebsocketMgr::connection_h()) + { + } + + using LLJSONRPCConnection::processMessage; + using LLJSONRPCConnection::validateMessage; + using LLJSONRPCConnection::testInjectPendingRequest; + using LLJSONRPCConnection::testPendingRequestCount; + using LLJSONRPCConnection::testSweepTimeouts; + using LLJSONRPCConnection::setAuthenticated; + }; + + // Minimal concrete server, used only to register the built-in + // system.* methods on a connection via setupConnectionMethods() -- + // LLJSONRPCServer itself is abstract (handleGetVersion is pure virtual). + // Never started, so port 0 is fine. + class TestJSONRPCServer : public LLJSONRPCServer + { + public: + TestJSONRPCServer() : LLJSONRPCServer("test_jsonrpcws_server", 0, true) {} + + using LLJSONRPCServer::setupConnectionMethods; + + LLSD handleGetVersion(const LLJSONRPCConnection::ptr_t&, const LLSD&) const override + { + return LLSD(); + } + }; + + // Captures what would be sent to the peer, since there is no real + // transport to observe it on. + class CapturingJSONRPCConnection : public TestJSONRPCConnection + { + public: + mutable std::vector<std::string> mSent; + + bool sendMessage(const std::string& message) const override + { + mSent.push_back(message); + return true; + } + }; +} + +namespace tut +{ + struct jsonrpcws_data + { + }; + + typedef test_group<jsonrpcws_data> jsonrpcws_test; + typedef jsonrpcws_test::object jsonrpcws_object; + jsonrpcws_test jsonrpcws_testcase("LLJSONRPCConnection"); + + template<> template<> + void jsonrpcws_object::test<1>() + { + set_test_name("an unauthenticated connection dispatches neither requests nor notifications"); + + TestJSONRPCConnection conn; + conn.setAuthenticated(false); + + int calls = 0; + conn.registerMethod("object.list", + [&](const std::string&, const LLSD&, const LLSD&) -> LLSD + { + ++calls; + return LLSD(); + }); + + const LLSD request = LLJSONRPCConnection::makeEnvelope(LLSD("req_1"), "object.list", LLSD::emptyMap(), LLSD(), LLSD()); + conn.processMessage(request); + + const LLSD notification = LLJSONRPCConnection::makeEnvelope(LLSD(), "object.list", LLSD::emptyMap(), LLSD(), LLSD()); + conn.processMessage(notification); + + ensure_equals("neither a request nor a notification reaches the handler", calls, 0); + } + + template<> template<> + void jsonrpcws_object::test<2>() + { + set_test_name("once authenticated, requests and notifications dispatch normally"); + + TestJSONRPCConnection conn; + conn.setAuthenticated(true); + ensure("authenticated after the peer proves itself", conn.isAuthenticated()); + + int calls = 0; + conn.registerMethod("object.list", + [&](const std::string&, const LLSD&, const LLSD&) -> LLSD + { + ++calls; + return LLSD(); + }); + + const LLSD request = LLJSONRPCConnection::makeEnvelope(LLSD("req_1"), "object.list", LLSD::emptyMap(), LLSD(), LLSD()); + conn.processMessage(request); + ensure_equals("the request reaches the handler", calls, 1); + + const LLSD notification = LLJSONRPCConnection::makeEnvelope(LLSD(), "object.list", LLSD::emptyMap(), LLSD(), LLSD()); + conn.processMessage(notification); + ensure_equals("the notification also reaches the handler", calls, 2); + } + + template<> template<> + void jsonrpcws_object::test<3>() + { + set_test_name("an unauthenticated connection still receives the answers to its own calls"); + + TestJSONRPCConnection conn; + ensure("unauthenticated by default", !conn.isAuthenticated()); + + LLSD answer; + conn.testInjectPendingRequest( + "rpc_1", + LLTimer::getTotalSeconds() + 60.0, + [&](const LLSD& result, const LLSD& error) + { + ensure("no error", error.isUndefined()); + answer = result; + }); + + LLSD result; + result["challenge_response"] = "secret"; + conn.processMessage(LLJSONRPCConnection::makeEnvelope(LLSD("rpc_1"), std::string(), LLSD(), result, LLSD())); + ensure_equals("the answer reached the caller", answer["challenge_response"].asString(), std::string("secret")); + ensure("and it is answered", conn.testPendingRequestCount() == 0); + } + + template<> template<> + void jsonrpcws_object::test<4>() + { + set_test_name("an unauthenticated request is answered -32002; a notification gets nothing back"); + + CapturingJSONRPCConnection conn; + ensure("unauthenticated by default", !conn.isAuthenticated()); + + const LLSD request = LLJSONRPCConnection::makeEnvelope(LLSD("req_1"), "object.list", LLSD::emptyMap(), LLSD(), LLSD()); + conn.processMessage(request); + ensure_equals("exactly one message sent for the request", conn.mSent.size(), (size_t)1); + + boost::system::error_code ec; + const LLSD sent = LlsdFromJson(boost::json::parse(conn.mSent[0], ec)); + ensure("parsed without error", !ec); + ensure_equals("unauthorized error code", sent["error"]["code"].asInteger(), LLJSONRPCConnection::RPCError::UNAUTHORIZED); + + const LLSD notification = LLJSONRPCConnection::makeEnvelope(LLSD(), "object.list", LLSD::emptyMap(), LLSD(), LLSD()); + conn.processMessage(notification); + ensure_equals("nothing sent for a notification", conn.mSent.size(), (size_t)1); + } + + template<> template<> + void jsonrpcws_object::test<5>() + { + set_test_name("malformed JSON is answered with a parse error, and the connection survives"); + + CapturingJSONRPCConnection conn; + conn.setAuthenticated(true); + + conn.onMessage("{not valid json"); + ensure_equals("a parse error is sent", conn.mSent.size(), (size_t)1); + + boost::system::error_code ec; + const LLSD sent = LlsdFromJson(boost::json::parse(conn.mSent[0], ec)); + ensure("parsed without error", !ec); + ensure_equals("parse error code", sent["error"]["code"].asInteger(), LLJSONRPCConnection::RPCError::PARSE_ERROR); + + // The connection itself is unaffected: valid follow-up traffic still works. + int calls = 0; + conn.registerMethod("object.list", + [&](const std::string&, const LLSD&, const LLSD&) -> LLSD + { + ++calls; + return LLSD(); + }); + conn.onMessage(boost::json::serialize(LlsdToJson(LLJSONRPCConnection::makeEnvelope(LLSD("req_1"), "object.list", LLSD::emptyMap(), LLSD(), LLSD())))); + ensure_equals("a later valid request still dispatches", calls, 1); + } + + template<> template<> + void jsonrpcws_object::test<6>() + { + set_test_name("a batch request is rejected outright; batch support is intentionally disabled"); + + CapturingJSONRPCConnection conn; + conn.setAuthenticated(true); + + const LLSD one = LLJSONRPCConnection::makeEnvelope(LLSD("req_1"), "object.list", LLSD::emptyMap(), LLSD(), LLSD()); + LLSD batch = LLSD::emptyArray(); + batch.append(one); + batch.append(one); + + conn.onMessage(boost::json::serialize(LlsdToJson(batch))); + ensure_equals("a single error is sent for the whole batch", conn.mSent.size(), (size_t)1); + + boost::system::error_code ec; + const LLSD sent = LlsdFromJson(boost::json::parse(conn.mSent[0], ec)); + ensure("parsed without error", !ec); + ensure_equals("invalid request error code", sent["error"]["code"].asInteger(), LLJSONRPCConnection::RPCError::INVALID_REQUEST); + } + + template<> template<> + void jsonrpcws_object::test<7>() + { + set_test_name("a missing or wrong jsonrpc version is rejected by validateMessage"); + + TestJSONRPCConnection conn; + + LLSD missing_version; + missing_version["method"] = "object.list"; + ensure("missing version fails validation", !conn.validateMessage(missing_version, true)); + + LLSD wrong_version = missing_version; + wrong_version["jsonrpc"] = "1.0"; + ensure("wrong version fails validation", !conn.validateMessage(wrong_version, true)); + + LLSD correct_version = missing_version; + correct_version["jsonrpc"] = "2.0"; + ensure("correct version passes validation", conn.validateMessage(correct_version, true)); + } + + template<> template<> + void jsonrpcws_object::test<8>() + { + set_test_name("params of the wrong type are rejected; the handler is never invoked"); + + TestJSONRPCConnection conn; + conn.setAuthenticated(true); + + LLSD bad_params; + bad_params["jsonrpc"] = "2.0"; + bad_params["method"] = "object.list"; + bad_params["params"] = "not-an-array-or-object"; + ensure("string params fail validation", !conn.validateMessage(bad_params, true)); + + int calls = 0; + conn.registerMethod("object.list", + [&](const std::string&, const LLSD&, const LLSD&) -> LLSD + { + ++calls; + return LLSD(); + }); + + conn.processMessage(bad_params); + ensure_equals("the handler is never invoked", calls, 0); + + LLSD good_params = bad_params; + good_params["params"] = LLSD::emptyMap(); + conn.processMessage(good_params); + ensure_equals("valid params do dispatch", calls, 1); + } + + template<> template<> + void jsonrpcws_object::test<9>() + { + set_test_name("a response for an unknown id is silently ignored"); + + TestJSONRPCConnection conn; + + bool callback_called = false; + conn.testInjectPendingRequest( + "req_1", + LLTimer::getTotalSeconds() + 60.0, + [&](const LLSD&, const LLSD&) + { + callback_called = true; + }); + + LLSD result; + result["value"] = "ok"; + conn.processMessage(LLJSONRPCConnection::makeEnvelope(LLSD("unknown_id"), std::string(), LLSD(), result, LLSD())); + + ensure("no callback fired for an unrelated id", !callback_called); + ensure_equals("the real pending request is still tracked", conn.testPendingRequestCount(), (size_t)1); + + conn.processMessage(LLJSONRPCConnection::makeEnvelope(LLSD("req_1"), std::string(), LLSD(), result, LLSD())); + ensure("the callback fires for its own id", callback_called); + ensure_equals("and it is removed once answered", conn.testPendingRequestCount(), (size_t)0); + } + + template<> template<> + void jsonrpcws_object::test<10>() + { + set_test_name("a response with both result and error, or with neither, is rejected as invalid"); + + TestJSONRPCConnection conn; + + LLSD both; + both["jsonrpc"] = "2.0"; + both["id"] = "req_1"; + both["result"] = "ok"; + both["error"]["code"] = -1; + both["error"]["message"] = "bad"; + ensure("result and error together fail validation", !conn.validateMessage(both, false)); + + LLSD neither; + neither["jsonrpc"] = "2.0"; + neither["id"] = "req_1"; + ensure("neither result nor error fails validation", !conn.validateMessage(neither, false)); + + LLSD result_only; + result_only["jsonrpc"] = "2.0"; + result_only["id"] = "req_1"; + result_only["result"] = "ok"; + ensure("result alone passes validation", conn.validateMessage(result_only, false)); + + bool callback_called = false; + conn.testInjectPendingRequest( + "req_1", + LLTimer::getTotalSeconds() + 60.0, + [&](const LLSD&, const LLSD&) + { + callback_called = true; + }); + + conn.processMessage(both); + ensure("an invalid response never reaches the callback", !callback_called); + ensure_equals("the pending request is still tracked", conn.testPendingRequestCount(), (size_t)1); + } + + template<> template<> + void jsonrpcws_object::test<11>() + { + set_test_name("an unauthenticated peer is told unauthorized, never that the method doesn't exist"); + + CapturingJSONRPCConnection conn; + + const LLSD request = LLJSONRPCConnection::makeEnvelope(LLSD("req_1"), "no.such.method", LLSD::emptyMap(), LLSD(), LLSD()); + conn.processMessage(request); + ensure_equals("one message sent", conn.mSent.size(), (size_t)1); + + boost::system::error_code ec; + LLSD sent = LlsdFromJson(boost::json::parse(conn.mSent[0], ec)); + ensure("parsed without error", !ec); + ensure_equals("unauthorized, not method-not-found", sent["error"]["code"].asInteger(), LLJSONRPCConnection::RPCError::UNAUTHORIZED); + + conn.setAuthenticated(true); + conn.processMessage(request); + ensure_equals("a second message sent once authenticated", conn.mSent.size(), (size_t)2); + + sent = LlsdFromJson(boost::json::parse(conn.mSent[1], ec)); + ensure("parsed without error", !ec); + ensure_equals("now it is method-not-found", sent["error"]["code"].asInteger(), LLJSONRPCConnection::RPCError::METHOD_NOT_FOUND); + } + + template<> template<> + void jsonrpcws_object::test<12>() + { + set_test_name("unregisterMethod removes both sync and async handlers"); + + TestJSONRPCConnection conn; + + conn.registerMethod("sync.method", + [](const std::string&, const LLSD&, const LLSD&) -> LLSD + { + return LLSD(); + }); + conn.registerAsyncMethod("async.method", + [](const std::string&, const LLSD&, const LLSD&) -> LLSD + { + return LLSD(); + }); + + ensure_equals("sync method registered", conn.getMethods().count("sync.method"), (size_t)1); + ensure_equals("async method registered", conn.getMethods().count("async.method"), (size_t)1); + + conn.unregisterMethod("sync.method"); + conn.unregisterMethod("async.method"); + + ensure_equals("sync method gone", conn.getMethods().count("sync.method"), (size_t)0); + ensure_equals("async method gone", conn.getMethods().count("async.method"), (size_t)0); + } + + template<> template<> + void jsonrpcws_object::test<13>() + { + set_test_name("a handler throwing a plain std::exception is converted to a generic internal error"); + + CapturingJSONRPCConnection conn; + conn.setAuthenticated(true); + + conn.registerMethod("boom", + [](const std::string&, const LLSD&, const LLSD&) -> LLSD + { + throw std::runtime_error("boom detail"); + }); + + conn.processMessage(LLJSONRPCConnection::makeEnvelope(LLSD("req_1"), "boom", LLSD::emptyMap(), LLSD(), LLSD())); + ensure_equals("one message sent", conn.mSent.size(), (size_t)1); + + boost::system::error_code ec; + const LLSD sent = LlsdFromJson(boost::json::parse(conn.mSent[0], ec)); + ensure("parsed without error", !ec); + ensure_equals("internal error code", sent["error"]["code"].asInteger(), LLJSONRPCConnection::RPCError::INTERNAL_ERROR); + + // The exception's own message must never reach the client -- it can + // carry paths, connection strings, or other internal detail. + ensure("the exception's own message is not forwarded to the client", + sent["error"]["message"].asString().find("boom detail") == std::string::npos); + } + + template<> template<> + void jsonrpcws_object::test<14>() + { + set_test_name("call() with no way to send returns undefined and leaves nothing pending"); + + TestJSONRPCConnection conn; + + bool callback_called = false; + const LLSD id = conn.call("object.list", LLSD::emptyMap(), + [&](const LLSD&, const LLSD&) + { + callback_called = true; + }); + + ensure("call() reports it could not send", id.isUndefined()); + ensure_equals("nothing left pending", conn.testPendingRequestCount(), (size_t)0); + ensure("the callback is never invoked for an unsent call", !callback_called); + } + + template<> template<> + void jsonrpcws_object::test<15>() + { + set_test_name("the built-in system.* methods are gated by authentication like any other method"); + + TestJSONRPCServer server; + auto conn = std::make_shared<CapturingJSONRPCConnection>(); + server.setupConnectionMethods(conn); + + ensure_equals("system.listMethods is registered", conn->getMethods().count("system.listMethods"), (size_t)1); + ensure_equals("system.getStats is registered", conn->getMethods().count("system.getStats"), (size_t)1); + + conn->processMessage(LLJSONRPCConnection::makeEnvelope(LLSD("req_1"), "system.listMethods", LLSD::emptyMap(), LLSD(), LLSD())); + ensure_equals("one message sent", conn->mSent.size(), (size_t)1); + + boost::system::error_code ec; + const LLSD sent = LlsdFromJson(boost::json::parse(conn->mSent[0], ec)); + ensure("parsed without error", !ec); + ensure_equals("unauthenticated, even a built-in method is refused", sent["error"]["code"].asInteger(), LLJSONRPCConnection::RPCError::UNAUTHORIZED); + } + + template<> template<> + void jsonrpcws_object::test<16>() + { + set_test_name("makeEnvelope includes id and method by the documented rules"); + + // Notification: id undefined, method non-empty -> id omitted. + const LLSD notification = LLJSONRPCConnection::makeEnvelope(LLSD(), "object.list", LLSD::emptyMap(), LLSD(), LLSD()); + ensure("notification omits id", !notification.has("id")); + ensure_equals("notification keeps method", notification["method"].asString(), std::string("object.list")); + + // Request: id defined, method non-empty -> both kept. + const LLSD request = LLJSONRPCConnection::makeEnvelope(LLSD("req_1"), "object.list", LLSD::emptyMap(), LLSD(), LLSD()); + ensure("request keeps id", request.has("id")); + ensure_equals("request keeps method", request["method"].asString(), std::string("object.list")); + + // Success response: id defined, method empty -> id kept, method omitted. + const LLSD response = LLJSONRPCConnection::makeEnvelope(LLSD("req_1"), std::string(), LLSD(), LLSD("ok"), LLSD()); + ensure("response keeps id", response.has("id")); + ensure("response omits method", !response.has("method")); + ensure_equals("response keeps result", response["result"].asString(), std::string("ok")); + + // Error response with an undefined id (e.g. a parse error): method + // is empty, so id is kept even though undefined -- serializes as null. + LLSD error_obj; + error_obj["code"] = -32700; + error_obj["message"] = "Parse error"; + const LLSD error_response = LLJSONRPCConnection::makeEnvelope(LLSD(), std::string(), LLSD(), LLSD(), error_obj); + ensure("error response keeps id even when undefined", error_response.has("id")); + ensure("that id is null", error_response["id"].isUndefined()); + ensure("error response omits method", !error_response.has("method")); + } + + template<> template<> + void jsonrpcws_object::test<17>() + { + set_test_name("onClose fails every pending request with CONNECTION_CLOSED, exactly once"); + + TestJSONRPCConnection conn; + + int callback_count_a = 0; + int callback_count_b = 0; + conn.testInjectPendingRequest( + "req_a", + LLTimer::getTotalSeconds() + 60.0, + [&](const LLSD& result, const LLSD& error) + { + ++callback_count_a; + ensure("no result on connection close", result.isUndefined()); + ensure_equals("connection closed error code", error["code"].asInteger(), LLJSONRPCConnection::RPCError::CONNECTION_CLOSED); + }); + conn.testInjectPendingRequest( + "req_b", + LLTimer::getTotalSeconds() + 60.0, + [&](const LLSD&, const LLSD& error) + { + ++callback_count_b; + ensure_equals("connection closed error code", error["code"].asInteger(), LLJSONRPCConnection::RPCError::CONNECTION_CLOSED); + }); + ensure_equals("two requests pending", conn.testPendingRequestCount(), (size_t)2); + + conn.onClose(); + + ensure_equals("first callback fires exactly once", callback_count_a, 1); + ensure_equals("second callback fires exactly once", callback_count_b, 1); + ensure_equals("nothing left pending", conn.testPendingRequestCount(), (size_t)0); + } +} diff --git a/indra/llcorehttp/tests/llcorehttp_test.cpp b/indra/llcorehttp/tests/llcorehttp_test.cpp index c0cc2c8030..cc3c241a96 100755..100644 --- a/indra/llcorehttp/tests/llcorehttp_test.cpp +++ b/indra/llcorehttp/tests/llcorehttp_test.cpp @@ -41,21 +41,16 @@ #include "test_httpstatus.hpp" #include "test_refcounted.hpp" #include "test_httpoperation.hpp" -// As of 2019-06-28, test_httprequest.hpp consistently crashes on Mac Release -// builds for reasons not yet diagnosed. -#if ! (LL_DARWIN && LL_RELEASE) #include "test_httprequest.hpp" -#endif #include "test_httpheaders.hpp" #include "test_httprequestqueue.hpp" +#include "test_jsonrpcws.hpp" +#include "test_websocketmgr.hpp" #include "_httpservice.h" #include "llproxy.h" #include "llcleanup.h" -void ssl_thread_id_callback(CRYPTO_THREADID*); -void ssl_locking_callback(int mode, int type, const char * file, int line); - #if 0 // lltut provides main and runner namespace tut @@ -80,27 +75,10 @@ int main() #endif // 0 -int ssl_mutex_count(0); -LLCoreInt::HttpMutex ** ssl_mutex_list = NULL; - void init_curl() { curl_global_init(CURL_GLOBAL_ALL); - ssl_mutex_count = CRYPTO_num_locks(); - if (ssl_mutex_count > 0) - { - ssl_mutex_list = new LLCoreInt::HttpMutex * [ssl_mutex_count]; - - for (int i(0); i < ssl_mutex_count; ++i) - { - ssl_mutex_list[i] = new LLCoreInt::HttpMutex; - } - - CRYPTO_set_locking_callback(ssl_locking_callback); - CRYPTO_THREADID_set_callback(ssl_thread_id_callback); - } - LLProxy::getInstance(); } @@ -108,39 +86,6 @@ void init_curl() void term_curl() { SUBSYSTEM_CLEANUP(LLProxy); - - CRYPTO_set_locking_callback(NULL); - for (int i(0); i < ssl_mutex_count; ++i) - { - delete ssl_mutex_list[i]; - } - delete [] ssl_mutex_list; -} - - -void ssl_thread_id_callback(CRYPTO_THREADID* pthreadid) -{ -#if defined(WIN32) - CRYPTO_THREADID_set_pointer(pthreadid, GetCurrentThread()); -#else - CRYPTO_THREADID_set_pointer(pthreadid, pthread_self()); -#endif -} - - -void ssl_locking_callback(int mode, int type, const char * /* file */, int /* line */) -{ - if (type >= 0 && type < ssl_mutex_count) - { - if (mode & CRYPTO_LOCK) - { - ssl_mutex_list[type]->lock(); - } - else - { - ssl_mutex_list[type]->unlock(); - } - } } diff --git a/indra/llcorehttp/tests/test_httprequest.hpp b/indra/llcorehttp/tests/test_httprequest.hpp index aed906bb8f..68f8c4f71f 100644 --- a/indra/llcorehttp/tests/test_httprequest.hpp +++ b/indra/llcorehttp/tests/test_httprequest.hpp @@ -454,6 +454,10 @@ void HttpRequestTestObjectType::test<4>() template <> template <> void HttpRequestTestObjectType::test<5>() { +#ifndef LL_WINDOWS + skip("Skip due to issues with testing pthread cancellation"); +#endif + ScopedCurlInit ready; set_test_name("HttpRequest Spin (soft) + NoOp + hard termination"); @@ -517,6 +521,9 @@ void HttpRequestTestObjectType::test<5>() template <> template <> void HttpRequestTestObjectType::test<6>() { +#ifndef LL_WINDOWS + skip("Skip due to issues with testing pthread cancellation"); +#endif ScopedCurlInit ready; set_test_name("HttpRequest Spin + NoOp + hard termination"); @@ -2737,13 +2744,6 @@ void HttpRequestTestObjectType::test<22>() set_test_name("BUG-2295"); -#if LL_WINDOWS && ADDRESS_SIZE == 64 - // teamcity win64 builds freeze on this test, if you figure out the cause, please fix it - if (getenv("TEAMCITY_PROJECT_NAME")) - { - skip("BUG-2295 - partial load on W64 causes freeze"); - } -#endif // Handler can be stack-allocated *if* there are no dangling // references to it after completion of this method. // Create before memory record as the string copy will bump numbers. @@ -2779,7 +2779,7 @@ void HttpRequestTestObjectType::test<22>() for (int i(0); i < test_count; ++i) { char buffer[128]; - sprintf(buffer, "/bug2295/%d/", i); + snprintf(buffer, sizeof(buffer), "/bug2295/%d/", i); HttpHandle handle = req->requestGetByteRange(HttpRequest::DEFAULT_POLICY_ID, url_base + buffer, 0, @@ -2810,7 +2810,7 @@ void HttpRequestTestObjectType::test<22>() for (int i(0); i < test2_count; ++i) { char buffer[128]; - sprintf(buffer, "/bug2295/00000012/%d/", i); + snprintf(buffer, sizeof(buffer), "/bug2295/00000012/%d/", i); HttpHandle handle = req->requestGetByteRange(HttpRequest::DEFAULT_POLICY_ID, url_base + buffer, 0, @@ -2841,7 +2841,7 @@ void HttpRequestTestObjectType::test<22>() for (int i(0); i < test3_count; ++i) { char buffer[128]; - sprintf(buffer, "/bug2295/inv_cont_range/%d/", i); + snprintf(buffer, sizeof(buffer), "/bug2295/inv_cont_range/%d/", i); HttpHandle handle = req->requestGetByteRange(HttpRequest::DEFAULT_POLICY_ID, url_base + buffer, 0, @@ -2917,14 +2917,6 @@ void HttpRequestTestObjectType::test<23>() set_test_name("HttpRequest GET 503s with 'Retry-After'"); -#if LL_WINDOWS && ADDRESS_SIZE == 64 - // teamcity win64 builds freeze on this test, if you figure out the cause, please fix it - if (getenv("TEAMCITY_PROJECT_NAME")) - { - skip("llcorehttp 503-with-retry test hangs on Windows 64"); - } -#endif - // This tests mainly that the code doesn't fall over if // various well- and mis-formed Retry-After headers are // sent along with the response. Direct inspection of diff --git a/indra/llcorehttp/tests/test_jsonrpcws.hpp b/indra/llcorehttp/tests/test_jsonrpcws.hpp new file mode 100644 index 0000000000..a40d0117b8 --- /dev/null +++ b/indra/llcorehttp/tests/test_jsonrpcws.hpp @@ -0,0 +1,203 @@ +/** + * @file test_jsonrpcws.hpp + * @brief unit tests for LLJSONRPCConnection helpers and dispatch behavior + */ + +#ifndef TEST_LLCORE_JSONRPCWS_H_ +#define TEST_LLCORE_JSONRPCWS_H_ + +#include "lljsonrpcws.h" +#include "lltimer.h" + +namespace +{ +class TestJSONRPCConnection : public LLJSONRPCConnection +{ +public: + TestJSONRPCConnection() + : LLJSONRPCConnection(LLWebsocketMgr::WSServer::ptr_t(), LLWebsocketMgr::connection_h()) + { + } + + using LLJSONRPCConnection::processMessage; + using LLJSONRPCConnection::validateMessage; + using LLJSONRPCConnection::testInjectPendingRequest; + using LLJSONRPCConnection::testPendingRequestCount; + using LLJSONRPCConnection::testSweepTimeouts; + using LLJSONRPCConnection::setAuthenticated; +}; +} + +namespace tut +{ + struct JSONRPCWSTestData + { + }; + + typedef test_group<JSONRPCWSTestData> JSONRPCWSTestGroupType; + typedef JSONRPCWSTestGroupType::object JSONRPCWSTestObjectType; + JSONRPCWSTestGroupType JSONRPCWSTestGroup("LLJSONRPCConnection Tests"); + + template<> template<> + void JSONRPCWSTestObjectType::test<1>() + { + set_test_name("makeEnvelope notification omits id"); + + LLSD params; + params["value"] = 42; + LLSD env = LLJSONRPCConnection::makeEnvelope(LLSD(), "runtime.debug", params, LLSD(), LLSD()); + + ensure("jsonrpc field should exist", env.has("jsonrpc")); + ensure_equals("jsonrpc version", env["jsonrpc"].asString(), "2.0"); + ensure("notification should omit id", !env.has("id")); + ensure_equals("method", env["method"].asString(), "runtime.debug"); + ensure_equals("param round trip", env["params"]["value"].asInteger(), 42); + } + + template<> template<> + void JSONRPCWSTestObjectType::test<2>() + { + set_test_name("makeEnvelope response keeps id slot"); + + LLSD env = LLJSONRPCConnection::makeEnvelope(LLSD(), std::string(), LLSD(), LLSD("ok"), LLSD()); + + ensure("response should include id", env.has("id")); + ensure("response should not include method", !env.has("method")); + ensure_equals("result", env["result"].asString(), "ok"); + } + + template<> template<> + void JSONRPCWSTestObjectType::test<3>() + { + set_test_name("validateMessage accepts valid request and rejects invalid params"); + + TestJSONRPCConnection conn; + + LLSD valid; + valid["jsonrpc"] = "2.0"; + valid["method"] = "session.ping"; + LLSD params = LLSD::emptyMap(); + params["timestamp"] = 123; + valid["params"] = params; + ensure("valid request should pass", conn.validateMessage(valid, true)); + + LLSD invalid = valid; + invalid["params"] = "not-an-array-or-object"; + ensure("invalid params type should fail", !conn.validateMessage(invalid, true)); + } + + template<> template<> + void JSONRPCWSTestObjectType::test<4>() + { + set_test_name("processMessage dispatches notification handler"); + + TestJSONRPCConnection conn; + conn.setAuthenticated(true); + + bool called = false; + LLSD seen_id; + LLSD seen_params; + conn.registerMethod("runtime.debug", + [&](const std::string& method, const LLSD& id, const LLSD& params) -> LLSD + { + called = true; + ensure_equals("method propagated", method, "runtime.debug"); + seen_id = id; + seen_params = params; + return LLSD(); + }); + + LLSD params; + params["message"] = "hello"; + LLSD notification = LLJSONRPCConnection::makeEnvelope(LLSD(), "runtime.debug", params, LLSD(), LLSD()); + conn.processMessage(notification); + + ensure("handler should be called", called); + ensure("notification id should be undefined", seen_id.isUndefined()); + ensure_equals("payload should be forwarded", seen_params["message"].asString(), "hello"); + } + + template<> template<> + void JSONRPCWSTestObjectType::test<5>() + { + set_test_name("sweepTimeouts expires overdue callbacks"); + + TestJSONRPCConnection conn; + conn.setAuthenticated(true); + + bool callback_called = false; + conn.testInjectPendingRequest( + "req_1", + LLTimer::getTotalSeconds() - 1.0, + [&](const LLSD& result, const LLSD& error) + { + callback_called = true; + ensure("timed out result should be undefined", result.isUndefined()); + ensure_equals( + "timeout code", + error["code"].asInteger(), + LLJSONRPCConnection::RPCError::REQUEST_TIMEOUT); + }); + + ensure_equals("pending request should be tracked", conn.testPendingRequestCount(), (size_t)1); + conn.testSweepTimeouts(); + ensure("timeout callback should be called", callback_called); + ensure_equals("pending request should be removed", conn.testPendingRequestCount(), (size_t)0); + } + + template<> template<> + void JSONRPCWSTestObjectType::test<6>() + { + set_test_name("a connection not yet authenticated dispatches nothing, and everything once it is"); + + TestJSONRPCConnection conn; + ensure("unauthenticated unless told otherwise", !conn.isAuthenticated()); + + int calls = 0; + conn.registerMethod("object.list", + [&](const std::string&, const LLSD&, const LLSD&) -> LLSD + { + ++calls; + return LLSD(); + }); + + conn.setAuthenticated(false); + ensure("not authenticated", !conn.isAuthenticated()); + const LLSD request = LLJSONRPCConnection::makeEnvelope(LLSD("req_1"), "object.list", LLSD::emptyMap(), LLSD(), LLSD()); + conn.processMessage(request); + const LLSD notification = LLJSONRPCConnection::makeEnvelope(LLSD(), "object.list", LLSD::emptyMap(), LLSD(), LLSD()); + conn.processMessage(notification); + ensure_equals("neither a request nor a notification reaches the handler", calls, 0); + + conn.setAuthenticated(true); + conn.processMessage(request); + ensure_equals("dispatched once authenticated", calls, 1); + } + + template<> template<> + void JSONRPCWSTestObjectType::test<7>() + { + set_test_name("a connection not yet authenticated still takes the answers to its own calls"); + + TestJSONRPCConnection conn; + conn.setAuthenticated(false); + + LLSD answer; + conn.testInjectPendingRequest( + "rpc_1", + LLTimer::getTotalSeconds() + 60.0, + [&](const LLSD& result, const LLSD& error) + { + ensure("no error", error.isUndefined()); + answer = result; + }); + + LLSD result; + result["challenge_response"] = "secret"; + conn.processMessage(LLJSONRPCConnection::makeEnvelope(LLSD("rpc_1"), std::string(), LLSD(), result, LLSD())); + ensure_equals("the answer reached the caller", answer["challenge_response"].asString(), std::string("secret")); + ensure("and it is answered", conn.testPendingRequestCount() == 0); + } +} + +#endif
\ No newline at end of file diff --git a/indra/llcorehttp/tests/test_refcounted.hpp b/indra/llcorehttp/tests/test_refcounted.hpp index c0c8e78413..eb23a25545 100644 --- a/indra/llcorehttp/tests/test_refcounted.hpp +++ b/indra/llcorehttp/tests/test_refcounted.hpp @@ -28,8 +28,6 @@ #include "_refcounted.h" -// disable all of this because it's hanging win64 builds? -#if ! (LL_WINDOWS && ADDRESS_SIZE == 64) using namespace LLCoreInt; namespace tut @@ -122,5 +120,4 @@ namespace tut ensure(rc->getRefCount() == RefCounted::NOT_REF_COUNTED); } } -#endif // disabling on Win64 #endif // TEST_LLCOREINT_REF_COUNTED_H_ diff --git a/indra/llcorehttp/tests/test_websocketmgr.hpp b/indra/llcorehttp/tests/test_websocketmgr.hpp new file mode 100644 index 0000000000..38b3d79cbf --- /dev/null +++ b/indra/llcorehttp/tests/test_websocketmgr.hpp @@ -0,0 +1,157 @@ +/** + * @file test_websocketmgr.hpp + * @brief A WebSocket server lets programs in and turns pages away. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + * Linden Research, Inc., 945 Battery Street, San Francisco, CA 94111 USA + * $/LicenseInfo$ + */ + +#ifndef TEST_LLCORE_WEBSOCKETMGR_H_ +#define TEST_LLCORE_WEBSOCKETMGR_H_ + +#include "llwebsocketmgr.h" + +#include <boost/asio/io_context.hpp> +#include <boost/asio/ip/tcp.hpp> +#include <boost/asio/read_until.hpp> +#include <boost/asio/streambuf.hpp> +#include <boost/asio/write.hpp> + +#include <chrono> +#include <istream> +#include <random> + +namespace +{ + // A port nothing on this machine is listening on, as far as can be + // told; zero where none turned up. + U16 unusedPort() + { + std::random_device random; + for (int tries = 0; tries < 20; ++tries) + { + const U16 port = static_cast<U16>(40000 + random() % 20000); + boost::asio::io_context io; + boost::asio::ip::tcp::acceptor acceptor(io); + boost::system::error_code ec; + acceptor.open(boost::asio::ip::tcp::v4(), ec); + if (!ec) + { + acceptor.bind(boost::asio::ip::tcp::endpoint(boost::asio::ip::make_address("127.0.0.1"), port), ec); + } + if (!ec) + { + return port; + } + } + return 0; + } + + // The HTTP status a WebSocket upgrade request from this origin -- none, + // where empty -- is answered with: 101 where it would open, -1 where + // nothing answered at all. The request is what a browser or a program + // sends, written out, so that what is tested is what the server says. + int upgradeFrom(U16 port, const std::string& origin) + { + boost::asio::io_context io; + boost::asio::ip::tcp::socket socket(io); + boost::system::error_code ec; + socket.connect(boost::asio::ip::tcp::endpoint(boost::asio::ip::make_address("127.0.0.1"), port), ec); + if (ec) + { + return -1; + } + + std::string request = "GET / HTTP/1.1\r\n" + "Host: 127.0.0.1:" + std::to_string(port) + "\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" + "Sec-WebSocket-Version: 13\r\n"; + if (!origin.empty()) + { + request += "Origin: " + origin + "\r\n"; + } + request += "\r\n"; + boost::asio::write(socket, boost::asio::buffer(request), ec); + if (ec) + { + return -1; + } + + // The status line, or nothing within the time. + boost::asio::streambuf response; + bool answered = false; + boost::asio::async_read_until(socket, response, "\r\n", + [&](const boost::system::error_code& error, std::size_t) { answered = !error; }); + io.run_for(std::chrono::seconds(10)); + socket.close(ec); + if (!answered) + { + return -1; + } + std::istream line(&response); + std::string version; + int status = -1; + line >> version >> status; + return status; + } +} + +namespace tut +{ + struct WebsocketMgrTestData + { + }; + + typedef test_group<WebsocketMgrTestData> WebsocketMgrTestGroupType; + typedef WebsocketMgrTestGroupType::object WebsocketMgrTestObjectType; + WebsocketMgrTestGroupType WebsocketMgrTestGroup("LLWebsocketMgr Tests"); + + template<> template<> + void WebsocketMgrTestObjectType::test<1>() + { + set_test_name("a program, which sends no origin, is let in; a page, which always does, is refused"); + + const U16 port = unusedPort(); + if (!port) + { + skip("no port free to listen on"); + } + const std::string name = "origin_test"; + LLWebsocketMgr& manager = LLWebsocketMgr::instance(); + ensure("added", manager.addServer(std::make_shared<LLWebsocketMgr::WSServer>(name, port, true))); + ensure("started", manager.startServer(name)); + + const int program = upgradeFrom(port, std::string()); + const int page = upgradeFrom(port, "https://example.com"); + const int opaque = upgradeFrom(port, "null"); + const int local = upgradeFrom(port, "http://127.0.0.1:8080"); + manager.removeServer(name); + + ensure_equals("no origin: in", program, 101); + ensure_equals("a web page: refused", page, 403); + ensure_equals("a page with an opaque origin, a sandboxed frame or a file: refused", opaque, 403); + ensure_equals("a page served from this machine: refused", local, 403); + } +} + +#endif diff --git a/indra/llcorehttp/tests/testwsclient.h b/indra/llcorehttp/tests/testwsclient.h new file mode 100644 index 0000000000..587934f2b6 --- /dev/null +++ b/indra/llcorehttp/tests/testwsclient.h @@ -0,0 +1,170 @@ +/** + * @file testwsclient.h + * @brief Minimal WebSocket client for driving a server under test + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + * Linden Research, Inc., 945 Battery Street, San Francisco, CA 94111 USA + * $/LicenseInfo$ + */ + +#ifndef LL_TESTWSCLIENT_H +#define LL_TESTWSCLIENT_H + +// MSVC does not report __cplusplus >= 201103L without /Zc:__cplusplus, so +// websocketpp's own C++11 detection would otherwise fall back to +// boost::random_device, which nothing here links. +#define _WEBSOCKETPP_CPP11_RANDOM_DEVICE_ + +#include <websocketpp/config/asio_no_tls_client.hpp> +#include <websocketpp/client.hpp> + +#include <chrono> +#include <condition_variable> +#include <mutex> +#include <string> +#include <thread> +#include <vector> + +// A minimal WebSocket client for driving a server under test: connects, +// optionally with an Origin header, and can send and collect messages. +class TestWSClient +{ +public: + using client_t = websocketpp::client<websocketpp::config::asio_client>; + + TestWSClient() + { + mClient.clear_access_channels(websocketpp::log::alevel::all); + mClient.clear_error_channels(websocketpp::log::elevel::all); + mClient.init_asio(); + + mClient.set_open_handler([this](websocketpp::connection_hdl hdl) + { + std::lock_guard<std::mutex> lock(mMutex); + mHandle = hdl; + mOpened = true; + mCondVar.notify_all(); + }); + mClient.set_fail_handler([this](websocketpp::connection_hdl) + { + std::lock_guard<std::mutex> lock(mMutex); + mFailed = true; + mCondVar.notify_all(); + }); + mClient.set_close_handler([this](websocketpp::connection_hdl) + { + std::lock_guard<std::mutex> lock(mMutex); + mClosed = true; + mCondVar.notify_all(); + }); + mClient.set_message_handler([this](websocketpp::connection_hdl, client_t::message_ptr msg) + { + std::lock_guard<std::mutex> lock(mMutex); + mReceived.push_back(msg->get_payload()); + mCondVar.notify_all(); + }); + } + + ~TestWSClient() + { + close(); + mClient.stop(); + if (mThread.joinable()) + { + mThread.join(); + } + } + + // Initiates a close and waits for the ack, so teardown looks like a + // normal closure instead of a dropped socket. Safe to call more than + // once, and safe if the connection never opened. + void close(std::chrono::seconds timeout = std::chrono::seconds(2)) + { + std::unique_lock<std::mutex> lock(mMutex); + if (!mOpened || mClosed) + { + return; + } + websocketpp::lib::error_code ec; + mClient.close(mHandle, websocketpp::close::status::normal, "test done", ec); + mCondVar.wait_for(lock, timeout, [this]() { return mClosed; }); + } + + // Connects to 127.0.0.1:port, optionally with an Origin header, and + // waits up to timeout for the handshake to open or fail. + // force_origin_header sends "Origin:" with an empty value instead of + // omitting the header entirely, for origin.empty(). + bool connect(U16 port, const std::string& origin, bool force_origin_header = false, + std::chrono::seconds timeout = std::chrono::seconds(5)) + { + websocketpp::lib::error_code ec; + client_t::connection_ptr con = mClient.get_connection( + "ws://127.0.0.1:" + std::to_string(port) + "/", ec); + if (ec) + { + return false; + } + if (!origin.empty() || force_origin_header) + { + con->append_header("Origin", origin); + } + mClient.connect(con); + mThread = std::thread([this]() { mClient.run(); }); + + std::unique_lock<std::mutex> lock(mMutex); + return mCondVar.wait_for(lock, timeout, [this]() { return mOpened || mFailed; }) && mOpened; + } + + bool send(const std::string& message) + { + websocketpp::lib::error_code ec; + mClient.send(mHandle, message, websocketpp::frame::opcode::text, ec); + return !ec; + } + + // Waits up to timeout for at least one message to arrive, then + // removes and returns it; empty where none arrived in time. + std::string waitForMessage(std::chrono::seconds timeout = std::chrono::seconds(5)) + { + std::unique_lock<std::mutex> lock(mMutex); + if (!mCondVar.wait_for(lock, timeout, [this]() { return !mReceived.empty(); })) + { + return std::string(); + } + std::string message = mReceived.front(); + mReceived.erase(mReceived.begin()); + return message; + } + + bool isOpen() const { return mOpened && !mClosed; } + +private: + client_t mClient; + websocketpp::connection_hdl mHandle; + std::thread mThread; + std::mutex mMutex; + std::condition_variable mCondVar; + bool mOpened{ false }; + bool mFailed{ false }; + bool mClosed{ false }; + std::vector<std::string> mReceived; +}; + +#endif // LL_TESTWSCLIENT_H diff --git a/indra/llcorehttp/tests/wsserver_test.cpp b/indra/llcorehttp/tests/wsserver_test.cpp new file mode 100644 index 0000000000..62fa66a9cd --- /dev/null +++ b/indra/llcorehttp/tests/wsserver_test.cpp @@ -0,0 +1,438 @@ +/** + * @file wsserver_test.cpp + * @brief Security-focused unit tests for LLWebsocketMgr::WSServer + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + * Linden Research, Inc., 945 Battery Street, San Francisco, CA 94111 USA + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "llwebsocketmgr.h" + +#include "testwsclient.h" +#include "../test/lltut.h" + +#include <boost/asio/io_context.hpp> +#include <boost/asio/ip/tcp.hpp> +#include <boost/asio/ip/udp.hpp> +#include <boost/asio/read_until.hpp> +#include <boost/asio/streambuf.hpp> +#include <boost/asio/write.hpp> + +#include <atomic> +#include <chrono> +#include <istream> +#include <random> +#include <thread> + +namespace +{ + // A port nothing on this machine is listening on, as far as can be + // told; zero where none turned up. + U16 unusedPort() + { + std::random_device random; + for (int tries = 0; tries < 20; ++tries) + { + const U16 port = static_cast<U16>(40000 + random() % 20000); + boost::asio::io_context io; + boost::asio::ip::tcp::acceptor acceptor(io); + boost::system::error_code ec; + acceptor.open(boost::asio::ip::tcp::v4(), ec); + if (!ec) + { + acceptor.bind(boost::asio::ip::tcp::endpoint(boost::asio::ip::make_address("127.0.0.1"), port), ec); + } + if (!ec) + { + return port; + } + } + return 0; + } + + // The HTTP status a WebSocket upgrade request from this origin -- none, + // where empty -- is answered with: 101 where it would open, -1 where + // nothing answered at all. + // force_origin_header sends "Origin:" with an empty value instead of + // omitting the header entirely, for origin.empty(). + int upgradeFrom(U16 port, const std::string& origin, bool force_origin_header = false) + { + boost::asio::io_context io; + boost::asio::ip::tcp::socket socket(io); + boost::system::error_code ec; + socket.connect(boost::asio::ip::tcp::endpoint(boost::asio::ip::make_address("127.0.0.1"), port), ec); + if (ec) + { + return -1; + } + + std::string request = "GET / HTTP/1.1\r\n" + "Host: 127.0.0.1:" + std::to_string(port) + "\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" + "Sec-WebSocket-Version: 13\r\n"; + if (!origin.empty() || force_origin_header) + { + request += "Origin: " + origin + "\r\n"; + } + request += "\r\n"; + boost::asio::write(socket, boost::asio::buffer(request), ec); + if (ec) + { + return -1; + } + + boost::asio::streambuf response; + bool answered = false; + boost::asio::async_read_until(socket, response, "\r\n", + [&](const boost::system::error_code& error, std::size_t) { answered = !error; }); + io.run_for(std::chrono::seconds(10)); + socket.close(ec); + if (!answered) + { + return -1; + } + std::istream line(&response); + std::string version; + int status = -1; + line >> version >> status; + return status; + } + + // Polls a predicate until it is true or the deadline passes. A client + // seeing its own handshake complete says nothing about whether the + // server's own thread has finished its side of onOpen bookkeeping yet. + template <typename Predicate> + bool waitUntil(Predicate predicate, std::chrono::milliseconds timeout = std::chrono::milliseconds(2000)) + { + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (!predicate()) + { + if (std::chrono::steady_clock::now() >= deadline) + { + return false; + } + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + return true; + } + + // The machine's own non-loopback IPv4 address, as seen when routing + // toward the internet (no packet is actually sent); empty where none + // could be found, such as a loopback-only sandbox. + std::string localNonLoopbackAddress() + { + boost::asio::io_context io; + boost::asio::ip::udp::socket socket(io); + boost::system::error_code ec; + socket.connect(boost::asio::ip::udp::endpoint(boost::asio::ip::make_address("8.8.8.8"), 53), ec); + if (ec) + { + return std::string(); + } + return socket.local_endpoint(ec).address().to_string(); + } + + // Whether a TCP connection to host:port succeeds within a short time; + // refused or silently dropped are both treated as "no". + bool canConnect(const std::string& host, U16 port) + { + boost::asio::io_context io; + boost::asio::ip::tcp::socket socket(io); + boost::system::error_code result = boost::asio::error::would_block; + socket.async_connect( + boost::asio::ip::tcp::endpoint(boost::asio::ip::make_address(host), port), + [&](const boost::system::error_code& ec) { result = ec; }); + io.run_for(std::chrono::seconds(3)); + boost::system::error_code close_ec; + socket.close(close_ec); + return !result; + } + + // Records what a server actually did, so tests can assert on lifecycle + // callbacks the base class leaves as no-ops. + class TestWSServer : public LLWebsocketMgr::WSServer + { + public: + using LLWebsocketMgr::WSServer::WSServer; + + std::atomic<int> mOpenedCount{ 0 }; + std::atomic<int> mClosedCount{ 0 }; + + // Empty where the base policy (reject any Origin) should apply; + // otherwise the one origin this test server lets in. + std::string mAllowedOrigin; + + void onConnectionOpened(const LLWebsocketMgr::WSConnection::ptr_t&) override + { + ++mOpenedCount; + } + + void onConnectionClosed(const LLWebsocketMgr::WSConnection::ptr_t&) override + { + ++mClosedCount; + } + + bool acceptOrigin(const std::string& origin) const override + { + if (!mAllowedOrigin.empty() && origin == mAllowedOrigin) + { + return true; + } + return WSServer::acceptOrigin(origin); + } + }; +} + +namespace tut +{ + struct wsserver_data + { + U16 mPort; + std::string mName; + + wsserver_data() : + mPort(unusedPort()), + mName("wsserver_test") + { + } + + ~wsserver_data() + { + LLWebsocketMgr::instance().removeServer(mName); + } + }; + + typedef test_group<wsserver_data> wsserver_test; + typedef wsserver_test::object wsserver_object; + wsserver_test wsserver_testcase("LLWebsocketMgr::WSServer"); + + template<> template<> + void wsserver_object::test<1>() + { + set_test_name("a program, which sends no Origin header, is let in"); + + if (!mPort) + { + skip("no port free to listen on"); + } + + LLWebsocketMgr& manager = LLWebsocketMgr::instance(); + auto server = std::make_shared<LLWebsocketMgr::WSServer>(mName, mPort, true); + ensure("added", manager.addServer(server)); + ensure("started", manager.startServer(mName)); + + ensure_equals("no origin: connection opens", upgradeFrom(mPort, std::string()), 101); + ensure("server clears the abruptly closed raw connection", waitUntil([&]() { return server->getConnectionCount() == 0; })); + + TestWSClient client; + ensure("no origin: client connects over the network", client.connect(mPort, std::string())); + ensure("server sees the open connection", waitUntil([&]() { return server->getConnectionCount() == 1; })); + + client.close(); + manager.removeServer(mName); + } + + template<> template<> + void wsserver_object::test<2>() + { + set_test_name("a page, which sends an Origin header, is refused"); + + if (!mPort) + { + skip("no port free to listen on"); + } + + LLWebsocketMgr& manager = LLWebsocketMgr::instance(); + auto server = std::make_shared<LLWebsocketMgr::WSServer>(mName, mPort, true); + ensure("added", manager.addServer(server)); + ensure("started", manager.startServer(mName)); + + ensure_equals("a web page: refused", upgradeFrom(mPort, "https://example.com"), 403); + + TestWSClient client; + ensure("a web page: client never opens over the network", !client.connect(mPort, "https://example.com")); + ensure_equals("server never sees a connection", server->getConnectionCount(), (size_t)0); + + manager.removeServer(mName); + } + + template<> template<> + void wsserver_object::test<3>() + { + set_test_name("a page with an opaque origin (a sandboxed frame or a file) is refused"); + + if (!mPort) + { + skip("no port free to listen on"); + } + + LLWebsocketMgr& manager = LLWebsocketMgr::instance(); + auto server = std::make_shared<LLWebsocketMgr::WSServer>(mName, mPort, true); + ensure("added", manager.addServer(server)); + ensure("started", manager.startServer(mName)); + + ensure_equals("an opaque origin: refused", upgradeFrom(mPort, "null"), 403); + + TestWSClient client; + ensure("an opaque origin: client never opens over the network", !client.connect(mPort, "null")); + ensure_equals("server never sees a connection", server->getConnectionCount(), (size_t)0); + + manager.removeServer(mName); + } + + template<> template<> + void wsserver_object::test<4>() + { + set_test_name("a page served from this machine is refused"); + + if (!mPort) + { + skip("no port free to listen on"); + } + + LLWebsocketMgr& manager = LLWebsocketMgr::instance(); + auto server = std::make_shared<LLWebsocketMgr::WSServer>(mName, mPort, true); + ensure("added", manager.addServer(server)); + ensure("started", manager.startServer(mName)); + + ensure_equals("a page on this machine: refused", upgradeFrom(mPort, "http://127.0.0.1:8080"), 403); + + TestWSClient client; + ensure("a page on this machine: client never opens over the network", !client.connect(mPort, "http://127.0.0.1:8080")); + ensure_equals("server never sees a connection", server->getConnectionCount(), (size_t)0); + + manager.removeServer(mName); + } + + template<> template<> + void wsserver_object::test<5>() + { + set_test_name("an Origin header present but empty is treated the same as none"); + + if (!mPort) + { + skip("no port free to listen on"); + } + + LLWebsocketMgr& manager = LLWebsocketMgr::instance(); + auto server = std::make_shared<LLWebsocketMgr::WSServer>(mName, mPort, true); + ensure("added", manager.addServer(server)); + ensure("started", manager.startServer(mName)); + + ensure_equals("empty origin: connection opens", upgradeFrom(mPort, std::string(), true), 101); + ensure("server clears the abruptly closed raw connection", waitUntil([&]() { return server->getConnectionCount() == 0; })); + + TestWSClient client; + ensure("empty origin: client connects over the network", client.connect(mPort, std::string(), true)); + ensure("server sees the open connection", waitUntil([&]() { return server->getConnectionCount() == 1; })); + + client.close(); + manager.removeServer(mName); + } + + template<> template<> + void wsserver_object::test<7>() + { + set_test_name("local_only binds to loopback only; the machine's own LAN address is unreachable"); + + if (!mPort) + { + skip("no port free to listen on"); + } + + const std::string lan_address = localNonLoopbackAddress(); + if (lan_address.empty()) + { + skip("no non-loopback network interface to test against"); + } + + LLWebsocketMgr& manager = LLWebsocketMgr::instance(); + auto server = std::make_shared<LLWebsocketMgr::WSServer>(mName, mPort, true); + ensure("added", manager.addServer(server)); + ensure("started", manager.startServer(mName)); + + ensure("loopback: reachable", canConnect("127.0.0.1", mPort)); + ensure("LAN address: unreachable", !canConnect(lan_address, mPort)); + + manager.removeServer(mName); + } + + template<> template<> + void wsserver_object::test<8>() + { + set_test_name("a refused connection never reaches onConnectionOpened, and an accepted one does"); + + if (!mPort) + { + skip("no port free to listen on"); + } + + LLWebsocketMgr& manager = LLWebsocketMgr::instance(); + auto server = std::make_shared<TestWSServer>(mName, mPort, true); + ensure("added", manager.addServer(server)); + ensure("started", manager.startServer(mName)); + + ensure_equals("a web page: refused", upgradeFrom(mPort, "https://example.com"), 403); + ensure_equals("refused: onConnectionOpened never fires", server->mOpenedCount.load(), 0); + + TestWSClient client; + ensure("no origin: client connects over the network", client.connect(mPort, std::string())); + ensure("accepted: onConnectionOpened fires once", waitUntil([&]() { return server->mOpenedCount.load() == 1; })); + + client.close(); + manager.removeServer(mName); + } + + template<> template<> + void wsserver_object::test<9>() + { + set_test_name("multiple simultaneous connections are each tracked independently"); + + if (!mPort) + { + skip("no port free to listen on"); + } + + LLWebsocketMgr& manager = LLWebsocketMgr::instance(); + auto server = std::make_shared<TestWSServer>(mName, mPort, true); + ensure("added", manager.addServer(server)); + ensure("started", manager.startServer(mName)); + + TestWSClient client_a; + TestWSClient client_b; + TestWSClient client_c; + ensure("first client connects", client_a.connect(mPort, std::string())); + ensure("second client connects", client_b.connect(mPort, std::string())); + ensure("third client connects", client_c.connect(mPort, std::string())); + + ensure("server counts all three connections", waitUntil([&]() { return server->getConnectionCount() == 3; })); + ensure("onConnectionOpened fires once per connection", waitUntil([&]() { return server->mOpenedCount.load() == 3; })); + + client_a.close(); + client_b.close(); + client_c.close(); + manager.removeServer(mName); + } +} |
