diff --git a/CMakeLists.txt b/CMakeLists.txt index 95eff8fbfb..31ec15e505 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -304,7 +304,7 @@ include(cmake/GameABIConfig.cmake) find_package(Threads REQUIRED) set(GAME_COMPILE_DEFS DUSK_BUILDING_GAME=1) set(GAME_LIBS aurora::core aurora::gx aurora::gd aurora::si aurora::vi aurora::pad aurora::mtx aurora::os aurora::dvd - aurora::card borealis::cli borealis::crash borealis::data borealis::disc borealis::discord borealis::file_select borealis::io borealis::log borealis::presentation borealis::sentry borealis::update freeverb cxxopts::cxxopts absl::flat_hash_map nlohmann_json::nlohmann_json TracyClient fmt::fmt + aurora::card borealis::cli borealis::crash borealis::data borealis::disc borealis::discord borealis::file_select borealis::io borealis::log borealis::net borealis::presentation borealis::sentry borealis::update borealis::ws freeverb cxxopts::cxxopts absl::flat_hash_map nlohmann_json::nlohmann_json TracyClient fmt::fmt Threads::Threads zstd::libzstd dusklight_game_headers) if (DUSK_HAS_FUNCHOOK) list(APPEND GAME_LIBS funchook-static) diff --git a/docs/modding.md b/docs/modding.md index d8cc8c9d75..7d7fa142a0 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -451,6 +451,82 @@ For large responses, set `downloadPath` to an absolute path in the calling mod's an empty `body` and the final path in `downloadPath`. Check `Response::ok()` before using the file. `Pending::progress()` reports download progress when the server provides a total size. +### WebSocketService ([`mods/svc/websocket.h`](../sdk/include/mods/svc/websocket.h)) + +WebSocket client connections with text or binary messages. Secure `wss://` URLs are supported everywhere. Insecure +`ws://` is limited to `localhost`, `127.0.0.1`, and `[::1]`. + +```cpp +#include "mods/svc/websocket.hpp" + +IMPORT_SERVICE(WebSocketService, svc_websocket); + +mods::ws::Connection connection; + +MOD_EXPORT ModResult mod_initialize(ModError*) { + connection = mods::ws::connect({.url = "wss://example.com/events"}); + return connection ? MOD_OK : connection.result(); +} + +MOD_EXPORT ModResult mod_update(ModError*) { + mods::ws::Event event; + while (mods::ws::poll(event)) { + if (event.type == WEBSOCKET_EVENT_MESSAGE) { + consume(event.data); + } else if (event.type == WEBSOCKET_EVENT_CLOSED) { + schedule_reconnect(event.error); + } + } + return MOD_OK; +} +``` + +Keep the `Connection` alive and drain `mods::ws::poll()` regularly, usually on every `mod_update` tick. A connection +emits an (optional) `OPEN` event, zero to many `MESSAGE` events, and exactly one `CLOSED` event. + +**Restrictions:** A mod may only open four connections at once. Messages have a 1 MiB limit by default, and may request +up to 16 MiB. Unread data is limited to 16 MiB and the outbound queue to 4 MiB. `send` returns `MOD_CONFLICT` when the +outbound queue is full. Dropping the `Connection` or deactivating the mod attempts to gracefully close with code 1001, +until the close deadline expires. + +### NetService ([`mods/svc/net.h`](../sdk/include/mods/svc/net.h)) + +Asynchronous raw TCP and UDP networking. Endpoints can be `tcp://host:port` or `udp://host:port`. TCP connections and +`resolve` accept hostnames. Listeners and UDP endpoints require IP literals. + +```cpp +#include "mods/svc/net.hpp" + +IMPORT_SERVICE(NetService, svc_net); + +mods::net::BindOutcome bound; +mods::net::Socket listener; +mods::net::Socket client; + +MOD_EXPORT ModResult mod_initialize(ModError*) { + listener = mods::net::listen("tcp://127.0.0.1:0", &bound); + client = mods::net::connect(bound.local); + return listener && client ? MOD_OK : MOD_ERROR; +} + +MOD_EXPORT ModResult mod_update(ModError*) { + mods::net::Event event; + while (mods::net::poll(event)) { + if (event.type == NET_EVENT_ACCEPTED) { + remember_client(mods::net::adopt(event.accepted)); + } else if (event.type == NET_EVENT_STREAM_DATA) { + consume(event.data); + } + } + return MOD_OK; +} +``` + +`send` and `send_to` copy the payload and return `MOD_CONFLICT` when the socket's outbound queue is full. `stats` +reports queued bytes, traffic, dropped inbound datagrams, and asynchronous UDP send failures. + +**Restrictions:** A mod may only have 32 streams, 4 listeners, 4 UDP sockets, and 8 DNS resolutions active at once. + ### HostService ([`mods/svc/host.h`](../sdk/include/mods/svc/host.h)) Mod metadata and runtime interaction with the loader. diff --git a/extern/borealis b/extern/borealis index 08de28e885..c3b8014495 160000 --- a/extern/borealis +++ b/extern/borealis @@ -1 +1 @@ -Subproject commit 08de28e885cd38a7c271a2d1ecd3f4f1c0620e06 +Subproject commit c3b80144952d4697c35ecac5c9dcaadcf1c598af diff --git a/files.cmake b/files.cmake index 3296b53064..bb5bebeaf1 100644 --- a/files.cmake +++ b/files.cmake @@ -1501,6 +1501,9 @@ set(DUSK_FILES src/dusk/mods/svc/hook.cpp src/dusk/mods/svc/host.cpp src/dusk/mods/svc/http.cpp + src/dusk/mods/svc/net.cpp + src/dusk/mods/svc/net.hpp + src/dusk/mods/svc/websocket.cpp src/dusk/mods/svc/item.cpp src/dusk/mods/svc/item.hpp src/dusk/mods/svc/log.cpp diff --git a/platforms/android/app/src/main/AndroidManifest.xml b/platforms/android/app/src/main/AndroidManifest.xml index b7270ee433..8dc7bf3236 100644 --- a/platforms/android/app/src/main/AndroidManifest.xml +++ b/platforms/android/app/src/main/AndroidManifest.xml @@ -19,6 +19,7 @@ android:appCategory="game" android:icon="@mipmap/icon" android:label="@string/app_name" + android:networkSecurityConfig="@xml/network_security_config" android:theme="@android:style/Theme.NoTitleBar" android:enableOnBackInvokedCallback="false"> diff --git a/platforms/android/app/src/main/res/xml/network_security_config.xml b/platforms/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000000..aa9543fd1f --- /dev/null +++ b/platforms/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,9 @@ + + + + + localhost + 127.0.0.1 + ::1 + + diff --git a/platforms/ios/Info.plist.in b/platforms/ios/Info.plist.in index 395b29b7d7..9a9025b9f7 100644 --- a/platforms/ios/Info.plist.in +++ b/platforms/ios/Info.plist.in @@ -85,5 +85,12 @@ LSSupportsGameMode + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + NSLocalNetworkUsageDescription + Dusklight allows network-enabled mods to connect to devices on your local network. diff --git a/platforms/macos/Info.plist.in b/platforms/macos/Info.plist.in index 7f29358033..3f564d88f7 100644 --- a/platforms/macos/Info.plist.in +++ b/platforms/macos/Info.plist.in @@ -32,5 +32,12 @@ public.app-category.adventure-games LSSupportsGameMode + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + NSLocalNetworkUsageDescription + Dusklight allows network-enabled mods to connect to devices on your local network. diff --git a/platforms/tvos/Info.plist.in b/platforms/tvos/Info.plist.in index 49ed85edd7..7f32e2d207 100644 --- a/platforms/tvos/Info.plist.in +++ b/platforms/tvos/Info.plist.in @@ -47,5 +47,12 @@ Automatic LSSupportsGameMode + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + NSLocalNetworkUsageDescription + Dusklight allows network-enabled mods to connect to devices on your local network. diff --git a/sdk/include/mods/svc/net.h b/sdk/include/mods/svc/net.h new file mode 100644 index 0000000000..2c5e9a531a --- /dev/null +++ b/sdk/include/mods/svc/net.h @@ -0,0 +1,145 @@ +#pragma once + +#include + +#ifdef __cplusplus +#include +#endif + +#define NET_SERVICE_ID "dev.twilitrealm.dusklight.net" +#define NET_SERVICE_MAJOR 1u +#define NET_SERVICE_MINOR 0u + +/** 0 is never a valid handle. */ +typedef uint64_t NetHandle; + +typedef enum NetError { + NET_ERROR_NONE = 0, + NET_ERROR_INVALID_ENDPOINT = 1, + NET_ERROR_RESOLVE = 2, + NET_ERROR_TIMEOUT = 3, + NET_ERROR_REFUSED = 4, + NET_ERROR_UNREACHABLE = 5, + NET_ERROR_RESET = 6, + NET_ERROR_ADDRESS_IN_USE = 7, + NET_ERROR_PERMISSION = 8, + NET_ERROR_TOO_LARGE = 9, + NET_ERROR_CANCELED = 10, + NET_ERROR_NETWORK = 11, +} NetError; + +#define NET_ENDPOINT_MAX 80 +/** NUL-terminated tcp://host:port or udp://host:port endpoint. */ +typedef struct NetEndpoint { + char text[NET_ENDPOINT_MAX]; +} NetEndpoint; + +typedef struct NetConnectDesc { + uint32_t struct_size; + /** TCP endpoint. Hostnames and IP literals are accepted. */ + const char* endpoint; + /** 0 defaults to 10 seconds. Resolution is included. */ + uint32_t connect_timeout_ms; + /** 0 defaults to 5 seconds for flush and peer EOF. */ + uint32_t close_timeout_ms; + /** 0 defaults to 1 MiB. Maximum of 8 MiB. */ + size_t max_send_queue_bytes; + bool no_delay; + /** Sampled when each event is polled. */ + void* user_data; +} NetConnectDesc; + +#define NET_CONNECT_DESC_INIT {sizeof(NetConnectDesc), NULL, 0u, 0u, 0u, true, NULL} + +typedef struct NetListenDesc { + uint32_t struct_size; + /** TCP endpoint with an IP literal. Port 0 requests an ephemeral port. */ + const char* bind; + uint32_t close_timeout_ms; + size_t max_send_queue_bytes; + bool no_delay; + void* user_data; +} NetListenDesc; + +#define NET_LISTEN_DESC_INIT {sizeof(NetListenDesc), NULL, 0u, 0u, true, NULL} + +typedef struct NetDatagramDesc { + uint32_t struct_size; + /** UDP endpoint with an IP literal. Port 0 requests an ephemeral port. */ + const char* bind; + size_t max_send_queue_bytes; + void* user_data; +} NetDatagramDesc; + +#define NET_DATAGRAM_DESC_INIT {sizeof(NetDatagramDesc), NULL, 0u, NULL} + +typedef enum NetEventType { + NET_EVENT_NONE = 0, + NET_EVENT_CONNECTED = 1, + NET_EVENT_ACCEPTED = 2, + NET_EVENT_STREAM_DATA = 3, + NET_EVENT_DATAGRAM = 4, + NET_EVENT_DROPPED = 5, + NET_EVENT_RESOLVED = 6, + NET_EVENT_CLOSED = 7, +} NetEventType; + +typedef struct NetEvent { + uint32_t struct_size; + NetEventType type; + /** Source handle. A CLOSED or RESOLVED handle is invalid after poll_event returns it. */ + NetHandle handle; + void* user_data; + NetHandle accepted; + /** Peer, datagram source, or resolved endpoint as applicable. */ + NetEndpoint endpoint; + /** Valid until this mod's next poll_event call or deactivation. */ + const void* data; + size_t size; + uint32_t dropped; + NetError error; + /** Never NULL. Valid until this mod's next poll_event call or deactivation. */ + const char* error_message; +} NetEvent; + +#define NET_EVENT_INIT \ + {sizeof(NetEvent), NET_EVENT_NONE, 0u, NULL, 0u, {{0}}, NULL, 0u, 0u, NET_ERROR_NONE, ""} + +typedef struct NetStats { + uint32_t struct_size; + size_t queued_send_bytes; + uint64_t inbound_dropped; + uint64_t send_failures; + uint64_t bytes_sent; + uint64_t bytes_received; +} NetStats; + +#define NET_STATS_INIT {sizeof(NetStats), 0u, 0u, 0u, 0u, 0u} + +typedef struct NetService { + ServiceHeader header; + + /** Starts an asynchronous TCP connection. */ + ModResult (*connect)(ModContext* ctx, const NetConnectDesc* desc, NetHandle* out_handle); + /** Opens a TCP listener and returns its local endpoint. */ + ModResult (*listen)(ModContext* ctx, const NetListenDesc* desc, NetHandle* out_handle, + NetEndpoint* out_local, NetError* out_error); + /** Opens a UDP socket and returns its local endpoint. */ + ModResult (*open_datagram)(ModContext* ctx, const NetDatagramDesc* desc, NetHandle* out_handle, + NetEndpoint* out_local, NetError* out_error); + /** Resolves a TCP or UDP endpoint asynchronously. */ + ModResult (*resolve)( + ModContext* ctx, const char* endpoint, void* user_data, NetHandle* out_handle); + /** Returns MOD_OK and NET_EVENT_NONE when the queue is empty. */ + ModResult (*poll_event)(ModContext* ctx, NetEvent* out_event); + /** Copies bytes to a connected stream's outbound queue. */ + ModResult (*send)(ModContext* ctx, NetHandle stream, const void* data, size_t size); + /** Copies one datagram for a literal UDP destination. The maximum size is 65,507 bytes. */ + ModResult (*send_to)( + ModContext* ctx, NetHandle socket, const char* endpoint, const void* data, size_t size); + ModResult (*set_user_data)(ModContext* ctx, NetHandle handle, void* user_data); + ModResult (*stats)(ModContext* ctx, NetHandle handle, NetStats* out_stats); + ModResult (*close)(ModContext* ctx, NetHandle handle); +} NetService; + +MOD_DECLARE_SERVICE(NetService, svc_net, NET_SERVICE_ID, NET_SERVICE_MAJOR, NET_SERVICE_MINOR); diff --git a/sdk/include/mods/svc/net.hpp b/sdk/include/mods/svc/net.hpp new file mode 100644 index 0000000000..44edf4001b --- /dev/null +++ b/sdk/include/mods/svc/net.hpp @@ -0,0 +1,189 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace mods::net { + +class Socket { +public: + Socket() = default; + Socket(NetHandle handle, ModResult result) : mHandle{handle}, mResult{result} {} + ~Socket() { reset(); } + + Socket(const Socket&) = delete; + Socket& operator=(const Socket&) = delete; + Socket(Socket&& other) noexcept { *this = std::move(other); } + Socket& operator=(Socket&& other) noexcept { + if (this != &other) { + reset(); + mHandle = std::exchange(other.mHandle, 0); + mResult = other.mResult; + } + return *this; + } + + explicit operator bool() const { return mResult == MOD_OK && mHandle != 0; } + + ModResult result() const { return mResult; } + NetHandle handle() const { return mHandle; } + + ModResult send(std::span bytes) const { + return svc_net != nullptr && mHandle != 0 ? + svc_net->send(mod_ctx, mHandle, bytes.data(), bytes.size()) : + MOD_UNAVAILABLE; + } + + ModResult send_to(std::string_view endpoint, std::span bytes) const { + if (svc_net == nullptr || mHandle == 0) { + return MOD_UNAVAILABLE; + } + const std::string endpointText{endpoint}; + return svc_net->send_to(mod_ctx, mHandle, endpointText.c_str(), bytes.data(), bytes.size()); + } + + std::optional stats() const { + if (svc_net == nullptr || mHandle == 0) { + return std::nullopt; + } + NetStats value = NET_STATS_INIT; + if (svc_net->stats(mod_ctx, mHandle, &value) != MOD_OK) { + return std::nullopt; + } + return value; + } + + ModResult set_user_data(void* userData) const { + return svc_net != nullptr && mHandle != 0 ? + svc_net->set_user_data(mod_ctx, mHandle, userData) : + MOD_UNAVAILABLE; + } + + ModResult close() { + if (mHandle == 0) { + return mResult == MOD_OK ? MOD_OK : MOD_UNAVAILABLE; + } + mResult = svc_net != nullptr ? svc_net->close(mod_ctx, mHandle) : MOD_UNAVAILABLE; + mHandle = 0; + return mResult; + } + + void detach() { mHandle = 0; } + +private: + void reset() { (void)close(); } + + NetHandle mHandle = 0; + ModResult mResult = MOD_UNAVAILABLE; +}; + +struct BindOutcome { + std::string local; + NetError error = NET_ERROR_NONE; +}; + +inline Socket connect(std::string_view endpoint, NetConnectDesc options = NET_CONNECT_DESC_INIT) { + if (svc_net == nullptr) { + return {0, MOD_UNAVAILABLE}; + } + const std::string endpointText{endpoint}; + options.struct_size = sizeof(options); + options.endpoint = endpointText.c_str(); + NetHandle handle = 0; + const ModResult result = svc_net->connect(mod_ctx, &options, &handle); + return {handle, result}; +} + +inline Socket listen(std::string_view bind, BindOutcome* out = nullptr, + NetListenDesc options = NET_LISTEN_DESC_INIT) { + if (svc_net == nullptr) { + return {0, MOD_UNAVAILABLE}; + } + const std::string bindText{bind}; + options.struct_size = sizeof(options); + options.bind = bindText.c_str(); + NetHandle handle = 0; + NetEndpoint local{}; + NetError error = NET_ERROR_NONE; + const ModResult result = svc_net->listen(mod_ctx, &options, &handle, &local, &error); + if (out != nullptr) { + *out = {.local = local.text, .error = error}; + } + return {handle, result}; +} + +inline Socket open_datagram(std::string_view bind, BindOutcome* out = nullptr, + NetDatagramDesc options = NET_DATAGRAM_DESC_INIT) { + if (svc_net == nullptr) { + return {0, MOD_UNAVAILABLE}; + } + const std::string bindText{bind}; + options.struct_size = sizeof(options); + options.bind = bindText.c_str(); + NetHandle handle = 0; + NetEndpoint local{}; + NetError error = NET_ERROR_NONE; + const ModResult result = svc_net->open_datagram(mod_ctx, &options, &handle, &local, &error); + if (out != nullptr) { + *out = {.local = local.text, .error = error}; + } + return {handle, result}; +} + +inline Socket resolve(std::string_view endpoint, void* userData = nullptr) { + if (svc_net == nullptr) { + return {0, MOD_UNAVAILABLE}; + } + const std::string endpointText{endpoint}; + NetHandle handle = 0; + const ModResult result = svc_net->resolve(mod_ctx, endpointText.c_str(), userData, &handle); + return {handle, result}; +} + +inline Socket adopt(NetHandle accepted) { + return {accepted, accepted != 0 ? MOD_OK : MOD_INVALID_ARGUMENT}; +} + +struct Event { + NetEventType type = NET_EVENT_NONE; + NetHandle handle = 0; + void* userData = nullptr; + NetHandle accepted = 0; + std::string_view endpoint; + std::span data; + uint32_t dropped = 0; + NetError error = NET_ERROR_NONE; + std::string_view message; +}; + +inline bool poll(Event& out) { + out = {}; + if (svc_net == nullptr) { + return false; + } + NetEvent raw = NET_EVENT_INIT; + if (svc_net->poll_event(mod_ctx, &raw) != MOD_OK || raw.type == NET_EVENT_NONE) { + return false; + } + out.type = raw.type; + out.handle = raw.handle; + out.userData = raw.user_data; + out.accepted = raw.accepted; + out.endpoint = raw.endpoint.text; + if (raw.data != nullptr && raw.size != 0) { + out.data = {static_cast(raw.data), raw.size}; + } + out.dropped = raw.dropped; + out.error = raw.error; + out.message = raw.error_message != nullptr ? raw.error_message : ""; + return true; +} + +} // namespace mods::net diff --git a/sdk/include/mods/svc/websocket.h b/sdk/include/mods/svc/websocket.h new file mode 100644 index 0000000000..bfc12d8fca --- /dev/null +++ b/sdk/include/mods/svc/websocket.h @@ -0,0 +1,107 @@ +#pragma once + +#include +#include + +#ifdef __cplusplus +#include +#endif + +#define WEBSOCKET_SERVICE_ID "dev.twilitrealm.dusklight.websocket" +#define WEBSOCKET_SERVICE_MAJOR 1u +#define WEBSOCKET_SERVICE_MINOR 0u + +/** Generational connection handle. Zero is never valid. */ +typedef uint64_t WebSocketHandle; + +/** Connection outcome. Callers must tolerate values added by later service minors. */ +typedef enum WebSocketError { + WEBSOCKET_ERROR_NONE = 0, + WEBSOCKET_ERROR_INVALID_URL = 1, + WEBSOCKET_ERROR_UNSUPPORTED_SCHEME = 2, + WEBSOCKET_ERROR_TIMEOUT = 3, + WEBSOCKET_ERROR_TOO_LARGE = 4, + WEBSOCKET_ERROR_CANCELED = 5, + WEBSOCKET_ERROR_NETWORK = 6, + WEBSOCKET_ERROR_PROTOCOL = 7, + WEBSOCKET_ERROR_HANDSHAKE = 8, +} WebSocketError; + +typedef enum WebSocketMessageKind { + WEBSOCKET_MESSAGE_TEXT = 0, + WEBSOCKET_MESSAGE_BINARY = 1, +} WebSocketMessageKind; + +typedef struct WebSocketConnectDesc { + uint32_t struct_size; + /** wss:// URL, or ws:// for localhost, 127.0.0.1, or [::1]. */ + const char* url; + /** Request headers. WebSocket handshake headers and User-Agent are reserved. */ + const HttpHeader* headers; + uint32_t header_count; + const char* const* protocols; + uint32_t protocol_count; + uint32_t connect_timeout_ms; + uint32_t close_timeout_ms; + uint32_t keepalive_interval_ms; + /** 0 defaults to 1 MiB. Maximum of 16 MiB. */ + size_t max_message_bytes; + /** Passed in every event. */ + void* user_data; +} WebSocketConnectDesc; + +#define WEBSOCKET_CONNECT_DESC_INIT \ + {sizeof(WebSocketConnectDesc), NULL, NULL, 0u, NULL, 0u, 0u, 0u, 0u, 0u, NULL} + +typedef enum WebSocketEventType { + WEBSOCKET_EVENT_NONE = 0, + WEBSOCKET_EVENT_OPEN = 1, + WEBSOCKET_EVENT_MESSAGE = 2, + WEBSOCKET_EVENT_CLOSED = 3, +} WebSocketEventType; + +typedef struct WebSocketEvent { + uint32_t struct_size; + WebSocketEventType type; + WebSocketHandle ws; + void* user_data; + + const char* protocol; + /** Handshake response headers for OPEN or a handshake-rejected CLOSED event. */ + const HttpHeader* headers; + uint32_t header_count; + + WebSocketMessageKind message_kind; + /** Valid until this mod's next poll_event call or deactivation. */ + const void* data; + size_t size; + + WebSocketError error; + /** Never NULL. Valid until this mod's next poll_event call or deactivation. */ + const char* error_message; + int32_t handshake_status; + uint16_t close_code; + const char* close_reason; +} WebSocketEvent; + +#define WEBSOCKET_EVENT_INIT \ + {sizeof(WebSocketEvent), WEBSOCKET_EVENT_NONE, 0u, NULL, "", NULL, 0u, WEBSOCKET_MESSAGE_TEXT, \ + NULL, 0u, WEBSOCKET_ERROR_NONE, "", 0, 0u, ""} + +typedef struct WebSocketService { + ServiceHeader header; + + /** Starts a connection. */ + ModResult (*connect)( + ModContext* ctx, const WebSocketConnectDesc* desc, WebSocketHandle* out_handle); + /** Returns MOD_OK and WEBSOCKET_EVENT_NONE when the queue is empty. */ + ModResult (*poll_event)(ModContext* ctx, WebSocketEvent* out_event); + /** Copies a message into the outbound queue. */ + ModResult (*send)(ModContext* ctx, WebSocketHandle ws, WebSocketMessageKind kind, + const void* data, size_t size); + /** Code 0 defaults to 1000; accepted: 1000, 1001, and 3000-4999. */ + ModResult (*close)(ModContext* ctx, WebSocketHandle ws, uint16_t code, const char* reason); +} WebSocketService; + +MOD_DECLARE_SERVICE(WebSocketService, svc_websocket, WEBSOCKET_SERVICE_ID, WEBSOCKET_SERVICE_MAJOR, + WEBSOCKET_SERVICE_MINOR); diff --git a/sdk/include/mods/svc/websocket.hpp b/sdk/include/mods/svc/websocket.hpp new file mode 100644 index 0000000000..57d8b37005 --- /dev/null +++ b/sdk/include/mods/svc/websocket.hpp @@ -0,0 +1,172 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mods::ws { + +struct Options { + std::string url; + std::vector headers; + std::vector protocols; + uint32_t connectTimeoutMs = 0; + uint32_t closeTimeoutMs = 0; + uint32_t keepaliveIntervalMs = 0; + size_t maxMessageBytes = 0; + void* userData = nullptr; +}; + +class Connection { +public: + Connection() = default; + Connection(WebSocketHandle handle, ModResult result) : mHandle{handle}, mResult{result} {} + ~Connection() { reset(); } + + Connection(const Connection&) = delete; + Connection& operator=(const Connection&) = delete; + Connection(Connection&& other) noexcept { *this = std::move(other); } + Connection& operator=(Connection&& other) noexcept { + if (this != &other) { + reset(); + mHandle = std::exchange(other.mHandle, 0); + mResult = other.mResult; + } + return *this; + } + + explicit operator bool() const { return mResult == MOD_OK && mHandle != 0; } + + ModResult result() const { return mResult; } + WebSocketHandle handle() const { return mHandle; } + + ModResult send(WebSocketMessageKind kind, std::span bytes) const { + return svc_websocket != nullptr && mHandle != 0 ? + svc_websocket->send(mod_ctx, mHandle, kind, bytes.data(), bytes.size()) : + MOD_UNAVAILABLE; + } + + ModResult send_text(std::string_view text) const { + return svc_websocket != nullptr && mHandle != 0 ? + svc_websocket->send( + mod_ctx, mHandle, WEBSOCKET_MESSAGE_TEXT, text.data(), text.size()) : + MOD_UNAVAILABLE; + } + + ModResult send_binary(std::span bytes) const { + return send(WEBSOCKET_MESSAGE_BINARY, bytes); + } + + ModResult close(uint16_t code = 1000, std::string_view reason = {}) { + if (svc_websocket == nullptr || mHandle == 0) { + return MOD_UNAVAILABLE; + } + const std::string reasonText{reason}; + mResult = svc_websocket->close(mod_ctx, mHandle, code, reasonText.c_str()); + if (mResult == MOD_OK) { + mHandle = 0; + } + return mResult; + } + + void detach() { mHandle = 0; } + +private: + void reset() { + if (mHandle != 0) { + (void)close(1001, "Connection owner released"); + mHandle = 0; + } + } + + WebSocketHandle mHandle = 0; + ModResult mResult = MOD_UNAVAILABLE; +}; + +inline Connection connect(const Options& options) { + if (svc_websocket == nullptr || options.headers.size() > std::numeric_limits::max() || + options.protocols.size() > std::numeric_limits::max()) + { + return {0, svc_websocket == nullptr ? MOD_UNAVAILABLE : MOD_INVALID_ARGUMENT}; + } + + std::vector headers; + headers.reserve(options.headers.size()); + for (const auto& header : options.headers) { + headers.push_back({.name = header.name.c_str(), .value = header.value.c_str()}); + } + std::vector protocols; + protocols.reserve(options.protocols.size()); + for (const auto& protocol : options.protocols) { + protocols.push_back(protocol.c_str()); + } + + WebSocketConnectDesc desc = WEBSOCKET_CONNECT_DESC_INIT; + desc.url = options.url.c_str(); + desc.headers = headers.empty() ? nullptr : headers.data(); + desc.header_count = static_cast(headers.size()); + desc.protocols = protocols.empty() ? nullptr : protocols.data(); + desc.protocol_count = static_cast(protocols.size()); + desc.connect_timeout_ms = options.connectTimeoutMs; + desc.close_timeout_ms = options.closeTimeoutMs; + desc.keepalive_interval_ms = options.keepaliveIntervalMs; + desc.max_message_bytes = options.maxMessageBytes; + desc.user_data = options.userData; + + WebSocketHandle handle = 0; + const ModResult result = svc_websocket->connect(mod_ctx, &desc, &handle); + return {handle, result}; +} + +struct Event { + WebSocketEventType type = WEBSOCKET_EVENT_NONE; + WebSocketHandle handle = 0; + void* userData = nullptr; + std::string_view protocol; + std::span headers; + WebSocketMessageKind messageKind = WEBSOCKET_MESSAGE_TEXT; + std::span data; + WebSocketError error = WEBSOCKET_ERROR_NONE; + std::string_view message; + int handshakeStatus = 0; + uint16_t closeCode = 0; + std::string_view closeReason; +}; + +inline bool poll(Event& out) { + out = {}; + if (svc_websocket == nullptr) { + return false; + } + WebSocketEvent raw = WEBSOCKET_EVENT_INIT; + if (svc_websocket->poll_event(mod_ctx, &raw) != MOD_OK || raw.type == WEBSOCKET_EVENT_NONE) { + return false; + } + out.type = raw.type; + out.handle = raw.ws; + out.userData = raw.user_data; + out.protocol = raw.protocol != nullptr ? raw.protocol : ""; + if (raw.headers != nullptr && raw.header_count != 0) { + out.headers = {raw.headers, raw.header_count}; + } + out.messageKind = raw.message_kind; + if (raw.data != nullptr && raw.size != 0) { + out.data = {static_cast(raw.data), raw.size}; + } + out.error = raw.error; + out.message = raw.error_message != nullptr ? raw.error_message : ""; + out.handshakeStatus = raw.handshake_status; + out.closeCode = raw.close_code; + out.closeReason = raw.close_reason != nullptr ? raw.close_reason : ""; + return true; +} + +} // namespace mods::ws diff --git a/src/dusk/livesplit.cpp b/src/dusk/livesplit.cpp index d583ef75fa..2bf01dada1 100644 --- a/src/dusk/livesplit.cpp +++ b/src/dusk/livesplit.cpp @@ -1,107 +1,80 @@ -#if _WIN32 -#include -#include -using socket_t = SOCKET; -static void closeSocket(socket_t s) { - LINGER li{1, 0}; - setsockopt(s, SOL_SOCKET, SO_LINGER, reinterpret_cast(&li), sizeof(li)); - closesocket(s); -} -static int socketError(socket_t s) { - int err = 0; - int len = sizeof(err); - getsockopt(s, SOL_SOCKET, SO_ERROR, reinterpret_cast(&err), &len); - return err; -} -static constexpr int kSendFlags = 0; -#else -#include -#include -#include -#include -#include -#include -#include -using socket_t = int; -static void closeSocket(socket_t s) { - struct linger li{1, 0}; - setsockopt(s, SOL_SOCKET, SO_LINGER, &li, sizeof(li)); - close(s); -} -static int socketError(socket_t s) { - int err = 0; - socklen_t len = sizeof(err); - getsockopt(s, SOL_SOCKET, SO_ERROR, &err, &len); - return err; -} -#ifndef INVALID_SOCKET -#define INVALID_SOCKET -1 -#endif - -#if defined(__APPLE__) -static constexpr int kSendFlags = 0; -#else -static constexpr int kSendFlags = MSG_NOSIGNAL; -#endif -#endif - -#include #include "dusk/livesplit.h" + +#include "borealis/net.hpp" + #include "f_op/f_op_overlap_mng.h" +#include +#include +#include +#include + namespace dusk::speedrun { +namespace { -static bool running = false; -static bool startPending = false; -static uint64_t frameCount = 0; -static socket_t sock = INVALID_SOCKET; -static bool wasLoading = false; -static bool connected = false; -static bool connectPending = false; -static bool disconnectPending = false; -static uint32_t idleProbeCounter = 0; -static uint32_t reconnectCounter = 0; -static char storedHost[64] = "127.0.0.1"; -static int storedPort = 16834; +bool running = false; +bool startPending = false; +uint64_t frameCount = 0; +bool wasLoading = false; +bool connected = false; +bool connectPending = false; +bool disconnectPending = false; +uint32_t reconnectCounter = 0; +std::string storedEndpoint = "tcp://127.0.0.1:16834"; +std::unique_ptr netContext; +borealis::net::SocketId socketId = 0; -static void sendCmd(const char* cmd) { - if (sock == INVALID_SOCKET) { +void send_cmd(const char* command) { + if (!netContext || !connected || socketId == 0) { return; } - char msg[64]; - const int len = snprintf(msg, sizeof(msg), "%s\r\n", cmd); - if (len <= 0 || len >= static_cast(sizeof(msg))) { + char message[64]; + const int length = snprintf(message, sizeof(message), "%s\r\n", command); + if (length <= 0 || length >= static_cast(sizeof(message))) { return; } - if (send(sock, msg, len, kSendFlags) >= 0) { - if (!connected) { - connected = connectPending = true; - } - return; - } - -#if _WIN32 - const int err = WSAGetLastError(); - if (err == WSAEWOULDBLOCK || err == WSAENOTCONN) { - return; - } -#else - if (errno == EAGAIN || errno == EWOULDBLOCK || errno == ENOTCONN) { - return; - } -#endif - - if (connected) { - disconnectPending = true; - } - closeSocket(sock); - sock = INVALID_SOCKET; - connected = connectPending = false; - reconnectCounter = 0; + const auto chars = std::span{message, static_cast(length)}; + netContext->send(socketId, std::as_bytes(chars)); } +void reconnect() { + netContext.reset(); + netContext = std::make_unique(); + connected = false; + connectPending = false; + socketId = netContext->connect(storedEndpoint); +} + +void poll_network() { + if (!netContext) { + return; + } + + borealis::net::Event event; + while (netContext->poll(event)) { + if (event.id != socketId) { + continue; + } + if (event.kind == borealis::net::Event::Kind::Connected) { + connected = true; + connectPending = true; + send_cmd("initgametime"); + } else if (event.kind == borealis::net::Event::Kind::Closed) { + if (connected) { + disconnectPending = true; + } + connected = false; + connectPending = false; + socketId = 0; + reconnectCounter = 0; + } + } +} + +} // namespace + uint64_t getFrameCount() { return frameCount; } @@ -111,10 +84,9 @@ void onGameFrame() { return; } - bool loading = fopOvlpM_IsDoingReq() != 0; - + const bool loading = fopOvlpM_IsDoingReq() != 0; if (loading != wasLoading) { - sendCmd(loading ? "pausegametime" : "unpausegametime"); + send_cmd(loading ? "pausegametime" : "unpausegametime"); wasLoading = loading; } @@ -141,172 +113,74 @@ void reset() { startPending = false; frameCount = 0; wasLoading = false; - sendCmd("reset"); -} - -static void reconnect() { - if (sock != INVALID_SOCKET) { - closeSocket(sock); - sock = INVALID_SOCKET; - } - connected = connectPending = false; - - sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); - if (sock == INVALID_SOCKET) { - return; - } - -#if _WIN32 - u_long nb = 1; - if (ioctlsocket(sock, FIONBIO, &nb) != 0) { - closeSocket(sock); - sock = INVALID_SOCKET; - return; - } -#else - const int fl = fcntl(sock, F_GETFL, 0); - if (fl < 0 || fcntl(sock, F_SETFL, fl | O_NONBLOCK) < 0) { - closeSocket(sock); - sock = INVALID_SOCKET; - return; - } -#endif - -#if defined(__APPLE__) - { - int opt = 1; - setsockopt(sock, SOL_SOCKET, SO_NOSIGPIPE, &opt, sizeof(opt)); - } -#endif - - sockaddr_in addr{}; - addr.sin_family = AF_INET; - addr.sin_port = htons(static_cast(storedPort)); - if (inet_pton(AF_INET, storedHost, &addr.sin_addr) != 1) { - closeSocket(sock); - sock = INVALID_SOCKET; - return; - } - - const int cr = connect(sock, reinterpret_cast(&addr), sizeof(addr)); -#if _WIN32 - const bool connectPending_ = cr < 0 && WSAGetLastError() == WSAEWOULDBLOCK; -#else - const bool connectPending_ = cr < 0 && errno == EINPROGRESS; -#endif - if (cr != 0 && !connectPending_) { - closeSocket(sock); - sock = INVALID_SOCKET; - } + send_cmd("reset"); } void connectLiveSplit(const char* host, int port) { -#if _WIN32 - WSADATA wd{}; - WSAStartup(MAKEWORD(2, 2), &wd); -#endif - snprintf(storedHost, sizeof(storedHost), "%s", host); - storedPort = port; + std::string endpointHost = host; + if (endpointHost.find(':') != std::string::npos && + !(endpointHost.starts_with('[') && endpointHost.ends_with(']'))) + { + endpointHost = '[' + endpointHost + ']'; + } + storedEndpoint = "tcp://" + endpointHost + ':' + std::to_string(port); reconnect(); } void disconnectLiveSplit() { - if (sock != INVALID_SOCKET) { - closeSocket(sock); - sock = INVALID_SOCKET; - } - connected = connectPending = disconnectPending = false; + netContext.reset(); + socketId = 0; + connected = false; + connectPending = false; + disconnectPending = false; } bool consumeConnectedEvent() { - bool v = connectPending; + const bool value = connectPending; connectPending = false; - return v; + return value; } + bool consumeDisconnectedEvent() { - bool v = disconnectPending; + const bool value = disconnectPending; disconnectPending = false; - return v; + return value; } void updateLiveSplit() { - if (sock == INVALID_SOCKET) { + poll_network(); + if (socketId == 0) { if ((reconnectCounter++ % 30) == 0) { reconnect(); } return; } - if (!connected) { - fd_set writefds, errorfds; - FD_ZERO(&writefds); - FD_ZERO(&errorfds); - FD_SET(sock, &writefds); - FD_SET(sock, &errorfds); - timeval tv{0, 0}; -#if _WIN32 - const int r = select(0, nullptr, &writefds, &errorfds, &tv); -#else - const int r = select(sock + 1, nullptr, &writefds, &errorfds, &tv); -#endif - if (r < 0 || FD_ISSET(sock, &errorfds) || socketError(sock) != 0) { - closeSocket(sock); - sock = INVALID_SOCKET; - reconnectCounter = 0; - return; - } - if (!FD_ISSET(sock, &writefds)) { - return; - } - sendCmd("initgametime"); return; } if (startPending) { startPending = false; - sendCmd("initgametime"); - sendCmd("reset"); - sendCmd("starttimer"); + send_cmd("initgametime"); + send_cmd("reset"); + send_cmd("starttimer"); } if (!running) { - if ((idleProbeCounter++ % 60) == 0) { - char buf; - const int r = recv(sock, &buf, 1, 0); - if (r == 0 -#if _WIN32 - || (r < 0 && WSAGetLastError() != WSAEWOULDBLOCK) -#else - || (r < 0 && errno != EAGAIN && errno != EWOULDBLOCK) -#endif - ) - { - if (connected) { - disconnectPending = true; - } - closeSocket(sock); - sock = INVALID_SOCKET; - connected = connectPending = false; - reconnectCounter = 0; - } - } return; } const uint64_t totalMs = frameCount * 1000 / 30; const uint64_t totalSec = totalMs / 1000; - char cmd[32]; - snprintf(cmd, sizeof(cmd), "setgametime %u:%02u:%02u.%03u", + char command[32]; + snprintf(command, sizeof(command), "setgametime %u:%02u:%02u.%03u", static_cast(totalSec / 3600), static_cast((totalSec / 60) % 60), static_cast(totalSec % 60), static_cast(totalMs % 1000)); - sendCmd(cmd); + send_cmd(command); } void shutdown() { disconnectLiveSplit(); -#if _WIN32 - WSACleanup(); -#endif } } // namespace dusk::speedrun diff --git a/src/dusk/mods/loader/loader.cpp b/src/dusk/mods/loader/loader.cpp index 39211c9579..bacae852bb 100644 --- a/src/dusk/mods/loader/loader.cpp +++ b/src/dusk/mods/loader/loader.cpp @@ -1027,7 +1027,12 @@ void ModLoader::deactivate_mod(LoadedMod& mod) { log::write(mod.metadata.id, LOG_LEVEL_ERROR, "{} failed: {}", shutdownName, lifecycle_error_message(shutdownName, result, error)); } + } catch (const std::exception& exception) { + log::write( + mod.metadata.id, LOG_LEVEL_ERROR, "{} threw: {}", shutdownName, exception.what()); } catch (...) { + log::write( + mod.metadata.id, LOG_LEVEL_ERROR, "{} threw an unknown exception", shutdownName); } } mod.initialized = false; diff --git a/src/dusk/mods/svc/actor.cpp b/src/dusk/mods/svc/actor.cpp index 237181e827..e3cc9411d7 100644 --- a/src/dusk/mods/svc/actor.cpp +++ b/src/dusk/mods/svc/actor.cpp @@ -2,8 +2,8 @@ #include "dusk/mods/svc/actor.hpp" #include "config.hpp" +#include "internal.hpp" #include "registry.hpp" -#include "slot_map.hpp" #include "aurora/lib/logging.hpp" #include "dusk/mod_loader.hpp" diff --git a/src/dusk/mods/svc/camera.cpp b/src/dusk/mods/svc/camera.cpp index 60fbf42499..b93ea00e62 100644 --- a/src/dusk/mods/svc/camera.cpp +++ b/src/dusk/mods/svc/camera.cpp @@ -1,5 +1,5 @@ +#include "internal.hpp" #include "registry.hpp" -#include "slot_map.hpp" #include "dusk/camera_operators.hpp" #include "dusk/mods/loader/loader.hpp" diff --git a/src/dusk/mods/svc/config.cpp b/src/dusk/mods/svc/config.cpp index b1bbf63705..cf8c249b3c 100644 --- a/src/dusk/mods/svc/config.cpp +++ b/src/dusk/mods/svc/config.cpp @@ -1,7 +1,7 @@ #include "config.hpp" +#include "internal.hpp" #include "registry.hpp" -#include "slot_map.hpp" #include #include "dusk/config.hpp" diff --git a/src/dusk/mods/svc/file.cpp b/src/dusk/mods/svc/file.cpp index f1ede94a3c..8bbd4ced39 100644 --- a/src/dusk/mods/svc/file.cpp +++ b/src/dusk/mods/svc/file.cpp @@ -1,6 +1,6 @@ #include "registry.hpp" -#include "slot_map.hpp" +#include "internal.hpp" #include #include diff --git a/src/dusk/mods/svc/game_mode.cpp b/src/dusk/mods/svc/game_mode.cpp index cfa655baf8..e7b8c06915 100644 --- a/src/dusk/mods/svc/game_mode.cpp +++ b/src/dusk/mods/svc/game_mode.cpp @@ -2,8 +2,8 @@ #include "dusk/game_mode.hpp" #include "config.hpp" +#include "internal.hpp" #include "registry.hpp" -#include "slot_map.hpp" #include "aurora/lib/logging.hpp" #include "dusk/mod_loader.hpp" diff --git a/src/dusk/mods/svc/gfx.cpp b/src/dusk/mods/svc/gfx.cpp index 098894007a..9ec8f2415a 100644 --- a/src/dusk/mods/svc/gfx.cpp +++ b/src/dusk/mods/svc/gfx.cpp @@ -1,5 +1,5 @@ +#include "internal.hpp" #include "registry.hpp" -#include "slot_map.hpp" #include "window.hpp" #include diff --git a/src/dusk/mods/svc/host.cpp b/src/dusk/mods/svc/host.cpp index a8e975e22f..114a1bc2ee 100644 --- a/src/dusk/mods/svc/host.cpp +++ b/src/dusk/mods/svc/host.cpp @@ -1,5 +1,5 @@ +#include "internal.hpp" #include "registry.hpp" -#include "slot_map.hpp" #include "dusk/main.h" #include "dusk/mods/loader/loader.hpp" diff --git a/src/dusk/mods/svc/http.cpp b/src/dusk/mods/svc/http.cpp index f533aa5832..2277e66e01 100644 --- a/src/dusk/mods/svc/http.cpp +++ b/src/dusk/mods/svc/http.cpp @@ -1,21 +1,20 @@ #include "registry.hpp" -#include "slot_map.hpp" +#include "internal.hpp" +#include "net.hpp" -#include "dusk/app_info.hpp" #include "dusk/main.h" #include "dusk/mods/loader/loader.hpp" #include "mods/svc/http.h" #include #include -#include +#include #include #include #include #include -#include #include #include #include @@ -38,6 +37,15 @@ constexpr size_t MaxRequestBodyBytes = 16 * 1024 * 1024; constexpr size_t DefaultResponseBodyBytes = 1024 * 1024; constexpr size_t MaxResponseBodyBytes = 64 * 1024 * 1024; constexpr std::chrono::milliseconds DefaultTimeout{10000}; +constexpr std::string_view ReservedHeaders[]{ + "User-Agent", + "Host", + "Content-Length", + "Connection", + "Accept-Encoding", + "Range", + "If-Range", +}; struct PendingRequest { HttpCompleteFn callback = nullptr; @@ -52,53 +60,9 @@ static_assert(std::is_nothrow_move_constructible_v); SlotMap s_requests; -bool ascii_iequals(std::string_view left, std::string_view right) { - return left.size() == right.size() && std::ranges::equal(left, right, [](char a, char b) { - return std::tolower(static_cast(a)) == - std::tolower(static_cast(b)); - }); -} - -bool is_reserved_header(std::string_view name) { - constexpr std::string_view reserved[]{ - "User-Agent", - "Host", - "Content-Length", - "Connection", - "Accept-Encoding", - "Range", - "If-Range", - }; - return std::ranges::any_of( - reserved, [&](std::string_view value) { return ascii_iequals(name, value); }); -} - -bool valid_header_name(std::string_view name) { - constexpr std::string_view separators{"()<>@,;:\\\"/[]?={} \t"}; - return !name.empty() && std::ranges::all_of(name, [&](unsigned char value) { - return value > 32 && value < 127 && - separators.find(static_cast(value)) == std::string_view::npos; - }); -} - bool valid_url(std::string_view url) { - constexpr std::string_view scheme{"https://"}; - if (!url.starts_with(scheme) || url.size() <= scheme.size() || url.size() > MaxUrlBytes) { - return false; - } - if (std::ranges::any_of(url, [](unsigned char value) { return value <= 32 || value == 127; })) { - return false; - } - const auto authorityEnd = url.find_first_of("/?#", scheme.size()); - const auto authority = url.substr(scheme.size(), authorityEnd - scheme.size()); - return !authority.empty(); -} - -bool declares_http_import(const LoadedMod& mod) { - return std::ranges::any_of( - mod.manifestInfo.imports, [](const ModManifestInfo::Import& serviceImport) { - return serviceImport.id == HTTP_SERVICE_ID; - }); + const auto parsed = url.size() <= MaxUrlBytes ? borealis::url::parse(url) : std::nullopt; + return parsed && parsed->scheme == "https"; } std::filesystem::path normalized_absolute(const std::filesystem::path& path, std::error_code& ec) { @@ -311,14 +275,8 @@ void http_frame_begin() { .download_path = downloadSucceeded ? publishedPath.c_str() : nullptr, }; - try { - callback(owner->context.get(), handle, &snapshot, userData); - } catch (const std::exception& exception) { - fail_mod(*owner, MOD_ERROR, - std::string{"exception in HTTP completion callback: "} + exception.what()); - } catch (...) { - fail_mod(*owner, MOD_ERROR, "unknown exception in HTTP completion callback"); - } + guarded_callback(*owner, "HTTP completion callback", + [&] { callback(owner->context.get(), handle, &snapshot, userData); }); s_requests.erase(handle); } } @@ -343,17 +301,6 @@ bool staging_path_in_use(const LoadedMod& mod, const std::filesystem::path& path return inUse; } -std::string user_agent_version(std::string_view version) { - std::string result{version}; - for (char& ch : result) { - const auto value = static_cast(ch); - if (value <= 32 || value >= 127) { - ch = '_'; - } - } - return result; -} - ModResult start_request(LoadedMod& mod, const HttpRequestDesc& desc, HttpCompleteFn callback, void* userData, HttpRequestHandle& outHandle) { const std::string_view url{desc.url}; @@ -376,9 +323,7 @@ ModResult start_request(LoadedMod& mod, const HttpRequestDesc& desc, HttpComplet } const std::string_view name{header.name}; const std::string_view value{header.value}; - const bool invalidValue = std::ranges::any_of( - value, [](unsigned char ch) { return (ch < 32 && ch != '\t') || ch == 127; }); - if (!valid_header_name(name) || invalidValue || is_reserved_header(name) || + if (!valid_header(name, value, ReservedHeaders, true) || name.size() > MaxHeaderBytes - headerBytes) { return MOD_INVALID_ARGUMENT; @@ -450,8 +395,7 @@ ModResult start_request(LoadedMod& mod, const HttpRequestDesc& desc, HttpComplet } request.headers.push_back({ .name = "User-Agent", - .value = fmt::format("{}/{} {}/{}", AppName, BOREALIS_APP_VERSION, mod.metadata.id, - user_agent_version(mod.metadata.version)), + .value = user_agent(mod), }); auto task = borealis::http::start(std::move(request)); @@ -487,14 +431,10 @@ ModResult http_request(ModContext* context, const HttpRequestDesc* desc, HttpCom { return MOD_INVALID_ARGUMENT; } - if (!declares_http_import(*mod)) { + if (!declares_import(*mod, HTTP_SERVICE_ID)) { return MOD_UNSUPPORTED; } - try { - return start_request(*mod, *desc, callback, userData, *outHandle); - } catch (...) { - return MOD_ERROR; - } + return start_request(*mod, *desc, callback, userData, *outHandle); } ModResult http_progress(ModContext* context, HttpRequestHandle handle, HttpProgress* outProgress) { @@ -550,9 +490,9 @@ bool http_available() { constexpr HttpService s_httpService{ .header = SERVICE_HEADER(HttpService, HTTP_SERVICE_MAJOR, HTTP_SERVICE_MINOR), - .request = http_request, - .progress = http_progress, - .cancel = http_cancel, + .request = SERVICE_FUNCTION(http_request), + .progress = SERVICE_FUNCTION(http_progress), + .cancel = SERVICE_FUNCTION(http_cancel), }; } // namespace diff --git a/src/dusk/mods/svc/slot_map.hpp b/src/dusk/mods/svc/internal.hpp similarity index 67% rename from src/dusk/mods/svc/slot_map.hpp rename to src/dusk/mods/svc/internal.hpp index a97a4b80a5..496b2a58a5 100644 --- a/src/dusk/mods/svc/slot_map.hpp +++ b/src/dusk/mods/svc/internal.hpp @@ -1,5 +1,10 @@ #pragma once +#include "../loader/loader.hpp" +#include "../log_buffer.hpp" + +#include + #include #include #include @@ -106,9 +111,7 @@ public: return erase(handle); } - size_t erase_all(const LoadedMod& owner) { - return take_all(owner).size(); - } + size_t erase_all(const LoadedMod& owner) { return take_all(owner).size(); } template void for_each(Fn&& fn) const { @@ -123,9 +126,7 @@ public: } // Returns the index of the handle within the slot map. - static constexpr uint32_t index_of(Handle handle) { - return handle_index(handle); - } + static constexpr uint32_t index_of(Handle handle) { return handle_index(handle); } private: struct Slot { @@ -189,5 +190,69 @@ private: std::vector m_freeSlots; }; +template +class PerMod { +public: + template + T& get_or_create(LoadedMod& mod, Args&&... args) { + auto& state = m_states[&mod]; + if (!state) { + state = std::make_unique(std::forward(args)...); + } + return *state; + } + + T* find(LoadedMod& mod) { + const auto found = m_states.find(&mod); + return found != m_states.end() ? found->second.get() : nullptr; + } + + const T* find(const LoadedMod& mod) const { + const auto found = m_states.find(&mod); + return found != m_states.end() ? found->second.get() : nullptr; + } + + void erase(LoadedMod& mod) { m_states.erase(&mod); } + bool contains(const LoadedMod& mod) const { return m_states.contains(&mod); } + void clear() { m_states.clear(); } + +private: + std::unordered_map> m_states; +}; + +template +ModResult guarded(ModContext* context, std::string_view operation, Fn&& fn) noexcept { + try { + return fn(); + } catch (const std::exception& exception) { + log::emit(log::Source::Mod, mod_id_from_context(context), LOG_LEVEL_ERROR, + fmt::format("{}: {}", operation, exception.what())); + } catch (...) { + log::emit(log::Source::Mod, mod_id_from_context(context), LOG_LEVEL_ERROR, + fmt::format("{}: unknown exception", operation)); + } + return MOD_ERROR; +} + +// Wraps a mod callback, attempting to catch any escaping exceptions. +// Immediately logs and fails the mod. +template +void guarded_callback(LoadedMod& mod, std::string_view operation, Fn&& fn) noexcept { + try { + fn(); + } catch (const std::exception& exception) { + fail_mod(mod, MOD_ERROR, fmt::format("exception in {}: {}", operation, exception.what())); + } catch (...) { + fail_mod(mod, MOD_ERROR, fmt::format("unknown exception in {}", operation)); + } +} + } // namespace svc } // namespace dusk::mods + +// Wraps service functions, catching C++ exceptions to avoid unwinding into mod code. +#define SERVICE_FUNCTION(function) \ + [](ModContext* context, auto... arguments) -> ModResult { \ + return ::dusk::mods::svc::guarded( \ + context, #function, [&] { return function(context, arguments...); }); \ + } diff --git a/src/dusk/mods/svc/net.cpp b/src/dusk/mods/svc/net.cpp new file mode 100644 index 0000000000..4b929524cf --- /dev/null +++ b/src/dusk/mods/svc/net.cpp @@ -0,0 +1,574 @@ +#include "registry.hpp" + +#include "internal.hpp" +#include "net.hpp" + +#include "dusk/logging.h" +#include "dusk/mods/loader/loader.hpp" +#include "mods/svc/net.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dusk::mods::svc { +namespace { + +constexpr size_t MaxListenersPerMod = 4; +constexpr size_t MaxStreamsPerMod = 32; +constexpr size_t MaxDatagramsPerMod = 4; +constexpr size_t MaxResolversPerMod = 8; +constexpr size_t DefaultSendQueueBytes = 1024 * 1024; +constexpr size_t MaxSendQueueBytes = 8 * 1024 * 1024; +constexpr std::chrono::milliseconds DefaultConnectTimeout{10000}; +constexpr std::chrono::milliseconds DefaultCloseTimeout{5000}; +constexpr std::chrono::milliseconds InboundStallTimeout{30000}; + +enum class HandleKind { + Listener, + Stream, + Datagram, + Resolver, +}; + +struct HandleState { + borealis::net::SocketId socket = 0; + HandleKind kind = HandleKind::Stream; + // Stream sends remain unavailable until the mod observes CONNECTED. + bool openPublished = false; +}; + +struct ModState { + ModState() + : context{borealis::net::ContextOptions{ + .maxQueuedBytes = 16 * 1024 * 1024, + .maxQueuedEvents = 4096, + .maxStreams = MaxStreamsPerMod, + .maxListeners = MaxListenersPerMod, + .maxDatagramSockets = MaxDatagramsPerMod, + .maxResolvers = MaxResolversPerMod, + }} {} + + borealis::net::Context context; + std::unordered_map handles; + borealis::net::Event currentEvent; +}; + +SlotMap s_handles; +PerMod s_modStates; + +NetError map_error(borealis::net::Error error) { + switch (error) { + case borealis::net::Error::None: + return NET_ERROR_NONE; + case borealis::net::Error::InvalidEndpoint: + return NET_ERROR_INVALID_ENDPOINT; + case borealis::net::Error::Resolve: + return NET_ERROR_RESOLVE; + case borealis::net::Error::Timeout: + return NET_ERROR_TIMEOUT; + case borealis::net::Error::Refused: + return NET_ERROR_REFUSED; + case borealis::net::Error::Unreachable: + return NET_ERROR_UNREACHABLE; + case borealis::net::Error::Reset: + return NET_ERROR_RESET; + case borealis::net::Error::AddressInUse: + return NET_ERROR_ADDRESS_IN_USE; + case borealis::net::Error::Permission: + return NET_ERROR_PERMISSION; + case borealis::net::Error::TooLarge: + return NET_ERROR_TOO_LARGE; + case borealis::net::Error::Canceled: + return NET_ERROR_CANCELED; + case borealis::net::Error::Network: + default: + return NET_ERROR_NETWORK; + } +} + +ModResult map_send_result(borealis::net::SendResult result) { + switch (result) { + case borealis::net::SendResult::Ok: + return MOD_OK; + case borealis::net::SendResult::NotOpen: + return MOD_UNAVAILABLE; + case borealis::net::SendResult::QueueFull: + return MOD_CONFLICT; + case borealis::net::SendResult::TooLarge: + case borealis::net::SendResult::InvalidEndpoint: + return MOD_INVALID_ARGUMENT; + } + return MOD_ERROR; +} + +NetEventType map_event(borealis::net::Event::Kind kind) { + switch (kind) { + case borealis::net::Event::Kind::Connected: + return NET_EVENT_CONNECTED; + case borealis::net::Event::Kind::Accepted: + return NET_EVENT_ACCEPTED; + case borealis::net::Event::Kind::StreamData: + return NET_EVENT_STREAM_DATA; + case borealis::net::Event::Kind::Datagram: + return NET_EVENT_DATAGRAM; + case borealis::net::Event::Kind::Dropped: + return NET_EVENT_DROPPED; + case borealis::net::Event::Kind::Resolved: + return NET_EVENT_RESOLVED; + case borealis::net::Event::Kind::Closed: + return NET_EVENT_CLOSED; + default: + return NET_EVENT_NONE; + } +} + +bool valid_endpoint(std::string_view text, std::string_view scheme, bool requireLiteral) { + if (text.empty() || text.size() >= NET_ENDPOINT_MAX) { + return false; + } + const auto endpoint = borealis::net::parse_endpoint(text); + return endpoint && endpoint->scheme == scheme && (!requireLiteral || endpoint->literal); +} + +bool copy_endpoint(NetEndpoint& output, std::string_view endpoint) { + if (endpoint.size() >= sizeof(output.text)) { + output.text[0] = '\0'; + return false; + } + std::memcpy(output.text, endpoint.data(), endpoint.size()); + output.text[endpoint.size()] = '\0'; + return true; +} + +size_t bounded_send_queue(size_t requested) { + return requested != 0 ? requested : DefaultSendQueueBytes; +} + +std::chrono::milliseconds timeout_or_default(uint32_t value, std::chrono::milliseconds fallback) { + return value != 0 ? std::chrono::milliseconds{value} : fallback; +} + +size_t count_handles(const LoadedMod& mod, HandleKind kind) { + size_t count = 0; + s_handles.for_each([&](NetHandle, const auto& entry) { + if (entry.owner == &mod && entry.value.kind == kind) { + ++count; + } + }); + return count; +} + +ModState& state_for(LoadedMod& mod) { + return s_modStates.get_or_create(mod); +} + +ModState* find_state(LoadedMod& mod) { + return s_modStates.find(mod); +} + +NetHandle add_handle(LoadedMod& mod, ModState& state, borealis::net::SocketId socket, + HandleKind kind, bool openPublished) { + const NetHandle handle = s_handles.emplace(mod, HandleState{ + .socket = socket, + .kind = kind, + .openPublished = openPublished, + }); + state.handles.emplace(socket, handle); + return handle; +} + +ModResult net_connect(ModContext* context, const NetConnectDesc* desc, NetHandle* outHandle) { + if (outHandle != nullptr) { + *outHandle = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || desc == nullptr || desc->struct_size < sizeof(NetConnectDesc) || + desc->endpoint == nullptr || outHandle == nullptr) + { + return MOD_INVALID_ARGUMENT; + } + if (!declares_import(*mod, NET_SERVICE_ID)) { + return MOD_UNSUPPORTED; + } + const std::string_view endpoint{desc->endpoint}; + if (!valid_endpoint(endpoint, "tcp", false) || desc->max_send_queue_bytes > MaxSendQueueBytes) { + return MOD_INVALID_ARGUMENT; + } + if (!borealis::net::available()) { + return MOD_UNAVAILABLE; + } + if (count_handles(*mod, HandleKind::Stream) >= MaxStreamsPerMod) { + return MOD_CONFLICT; + } + auto& state = state_for(*mod); + const size_t maxSendQueueBytes = bounded_send_queue(desc->max_send_queue_bytes); + const borealis::net::SocketId socket = state.context.connect(endpoint, + borealis::net::StreamOptions{ + .connectTimeout = timeout_or_default(desc->connect_timeout_ms, DefaultConnectTimeout), + .closeTimeout = timeout_or_default(desc->close_timeout_ms, DefaultCloseTimeout), + .inboundStallTimeout = InboundStallTimeout, + .maxSendQueueBytes = maxSendQueueBytes, + .readChunkBytes = 64 * 1024, + .noDelay = desc->no_delay, + }, + desc->user_data); + if (socket == 0) { + return MOD_CONFLICT; + } + *outHandle = add_handle(*mod, state, socket, HandleKind::Stream, false); + return MOD_OK; +} + +ModResult net_listen(ModContext* context, const NetListenDesc* desc, NetHandle* outHandle, + NetEndpoint* outLocal, NetError* outError) { + if (outHandle != nullptr) { + *outHandle = 0; + } + if (outLocal != nullptr) { + *outLocal = {}; + } + if (outError != nullptr) { + *outError = NET_ERROR_NONE; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || desc == nullptr || desc->struct_size < sizeof(NetListenDesc) || + desc->bind == nullptr || outHandle == nullptr || outLocal == nullptr) + { + return MOD_INVALID_ARGUMENT; + } + if (!declares_import(*mod, NET_SERVICE_ID)) { + return MOD_UNSUPPORTED; + } + const std::string_view bind{desc->bind}; + if (!valid_endpoint(bind, "tcp", true) || desc->max_send_queue_bytes > MaxSendQueueBytes) { + return MOD_INVALID_ARGUMENT; + } + if (!borealis::net::available()) { + return MOD_UNAVAILABLE; + } + if (count_handles(*mod, HandleKind::Listener) >= MaxListenersPerMod) { + return MOD_CONFLICT; + } + auto& state = state_for(*mod); + const size_t maxSendQueueBytes = bounded_send_queue(desc->max_send_queue_bytes); + const auto result = state.context.listen(bind, + borealis::net::ListenOptions{ + .accepted = + borealis::net::StreamOptions{ + .connectTimeout = DefaultConnectTimeout, + .closeTimeout = timeout_or_default(desc->close_timeout_ms, DefaultCloseTimeout), + .inboundStallTimeout = InboundStallTimeout, + .maxSendQueueBytes = maxSendQueueBytes, + .readChunkBytes = 64 * 1024, + .noDelay = desc->no_delay, + }, + .backlog = 64, + .reuseAddress = true, + }, + desc->user_data); + if (result.id == 0) { + if (outError != nullptr) { + *outError = map_error(result.error); + } + DuskLog.error("[{}] could not listen on '{}': {}", mod->metadata.id, bind, result.message); + return MOD_ERROR; + } + if (!copy_endpoint(*outLocal, result.localEndpoint)) { + state.context.close(result.id); + return MOD_ERROR; + } + *outHandle = add_handle(*mod, state, result.id, HandleKind::Listener, true); + return MOD_OK; +} + +ModResult net_open_datagram(ModContext* context, const NetDatagramDesc* desc, NetHandle* outHandle, + NetEndpoint* outLocal, NetError* outError) { + if (outHandle != nullptr) { + *outHandle = 0; + } + if (outLocal != nullptr) { + *outLocal = {}; + } + if (outError != nullptr) { + *outError = NET_ERROR_NONE; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || desc == nullptr || desc->struct_size < sizeof(NetDatagramDesc) || + desc->bind == nullptr || outHandle == nullptr || outLocal == nullptr) + { + return MOD_INVALID_ARGUMENT; + } + if (!declares_import(*mod, NET_SERVICE_ID)) { + return MOD_UNSUPPORTED; + } + const std::string_view bind{desc->bind}; + if (!valid_endpoint(bind, "udp", true) || desc->max_send_queue_bytes > MaxSendQueueBytes) { + return MOD_INVALID_ARGUMENT; + } + if (!borealis::net::available()) { + return MOD_UNAVAILABLE; + } + if (count_handles(*mod, HandleKind::Datagram) >= MaxDatagramsPerMod) { + return MOD_CONFLICT; + } + auto& state = state_for(*mod); + const size_t maxSendQueueBytes = bounded_send_queue(desc->max_send_queue_bytes); + const auto result = state.context.open_datagram(bind, + borealis::net::DatagramOptions{ + .maxSendQueueBytes = maxSendQueueBytes, + .recvBufferBytes = 1024 * 1024, + .sendBufferBytes = 1024 * 1024, + }, + desc->user_data); + if (result.id == 0) { + if (outError != nullptr) { + *outError = map_error(result.error); + } + DuskLog.error("[{}] could not bind datagram socket on '{}': {}", mod->metadata.id, bind, + result.message); + return MOD_ERROR; + } + if (!copy_endpoint(*outLocal, result.localEndpoint)) { + state.context.close(result.id); + return MOD_ERROR; + } + *outHandle = add_handle(*mod, state, result.id, HandleKind::Datagram, true); + return MOD_OK; +} + +ModResult net_resolve( + ModContext* context, const char* endpoint, void* userData, NetHandle* outHandle) { + if (outHandle != nullptr) { + *outHandle = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || endpoint == nullptr || outHandle == nullptr) { + return MOD_INVALID_ARGUMENT; + } + if (!declares_import(*mod, NET_SERVICE_ID)) { + return MOD_UNSUPPORTED; + } + const std::string_view endpointText{endpoint}; + if (endpointText.empty() || endpointText.size() >= NET_ENDPOINT_MAX || + !borealis::net::parse_endpoint(endpointText)) + { + return MOD_INVALID_ARGUMENT; + } + if (!borealis::net::available()) { + return MOD_UNAVAILABLE; + } + if (count_handles(*mod, HandleKind::Resolver) >= MaxResolversPerMod) { + return MOD_CONFLICT; + } + auto& state = state_for(*mod); + const auto socket = state.context.resolve(endpointText, userData); + if (socket == 0) { + return MOD_CONFLICT; + } + *outHandle = add_handle(*mod, state, socket, HandleKind::Resolver, false); + return MOD_OK; +} + +ModResult net_poll_event(ModContext* context, NetEvent* outEvent) { + const uint32_t structSize = outEvent != nullptr ? outEvent->struct_size : 0; + auto* mod = mod_from_context(context); + if (mod == nullptr || outEvent == nullptr || structSize < sizeof(NetEvent)) { + return MOD_INVALID_ARGUMENT; + } + NetEvent output{.struct_size = structSize, .error_message = ""}; + auto* state = find_state(*mod); + if (state == nullptr) { + std::memcpy(outEvent, &output, sizeof(output)); + return MOD_OK; + } + + while (state->context.poll(state->currentEvent)) { + const auto handleFound = state->handles.find(state->currentEvent.id); + if (handleFound == state->handles.end()) { + continue; + } + const NetHandle handle = handleFound->second; + auto* handleState = s_handles.find_owned(handle, *mod); + if (handleState == nullptr) { + continue; + } + output.type = map_event(state->currentEvent.kind); + output.handle = handle; + output.user_data = state->currentEvent.userData; + output.data = state->currentEvent.data.data(); + output.size = state->currentEvent.data.size(); + output.dropped = state->currentEvent.dropped; + output.error = map_error(state->currentEvent.error); + output.error_message = state->currentEvent.message.c_str(); + if (!copy_endpoint(output.endpoint, state->currentEvent.endpoint)) { + output.endpoint = {}; + } + + if (state->currentEvent.kind == borealis::net::Event::Kind::Connected) { + handleState->value.openPublished = true; + } else if (state->currentEvent.kind == borealis::net::Event::Kind::Accepted) { + const NetHandle accepted = + add_handle(*mod, *state, state->currentEvent.accepted, HandleKind::Stream, true); + output.accepted = accepted; + } else if (state->currentEvent.kind == borealis::net::Event::Kind::Closed || + state->currentEvent.kind == borealis::net::Event::Kind::Resolved) + { + state->handles.erase(state->currentEvent.id); + s_handles.erase(handle); + } + + std::memcpy(outEvent, &output, sizeof(output)); + return MOD_OK; + } + + std::memcpy(outEvent, &output, sizeof(output)); + return MOD_OK; +} + +ModResult net_send(ModContext* context, NetHandle handle, const void* data, size_t size) { + auto* mod = mod_from_context(context); + if (mod == nullptr || (size != 0 && data == nullptr)) { + return MOD_INVALID_ARGUMENT; + } + auto* handleState = s_handles.find_owned(handle, *mod); + auto* state = find_state(*mod); + if (handleState == nullptr || state == nullptr || + handleState->value.kind != HandleKind::Stream || !handleState->value.openPublished) + { + return MOD_UNAVAILABLE; + } + const auto* bytes = static_cast(data); + return map_send_result(state->context.send(handleState->value.socket, {bytes, size})); +} + +ModResult net_send_to( + ModContext* context, NetHandle handle, const char* endpoint, const void* data, size_t size) { + auto* mod = mod_from_context(context); + if (mod == nullptr || endpoint == nullptr || (size != 0 && data == nullptr)) { + return MOD_INVALID_ARGUMENT; + } + auto* handleState = s_handles.find_owned(handle, *mod); + auto* state = find_state(*mod); + if (handleState == nullptr || state == nullptr || + handleState->value.kind != HandleKind::Datagram) + { + return MOD_UNAVAILABLE; + } + const auto* bytes = static_cast(data); + return map_send_result( + state->context.send_to(handleState->value.socket, endpoint, {bytes, size})); +} + +ModResult net_set_user_data(ModContext* context, NetHandle handle, void* userData) { + auto* mod = mod_from_context(context); + if (mod == nullptr) { + return MOD_INVALID_ARGUMENT; + } + const auto* handleState = s_handles.find_owned(handle, *mod); + auto* state = find_state(*mod); + if (handleState == nullptr || state == nullptr) { + return MOD_UNAVAILABLE; + } + state->context.set_user_data(handleState->value.socket, userData); + return MOD_OK; +} + +ModResult net_stats(ModContext* context, NetHandle handle, NetStats* outStats) { + const uint32_t structSize = outStats != nullptr ? outStats->struct_size : 0; + auto* mod = mod_from_context(context); + if (mod == nullptr || outStats == nullptr || structSize < sizeof(NetStats)) { + return MOD_INVALID_ARGUMENT; + } + NetStats output{.struct_size = structSize}; + const auto* handleState = s_handles.find_owned(handle, *mod); + auto* state = find_state(*mod); + if (handleState == nullptr || state == nullptr) { + std::memcpy(outStats, &output, sizeof(output)); + return MOD_UNAVAILABLE; + } + const auto stats = state->context.stats(handleState->value.socket); + if (!stats) { + std::memcpy(outStats, &output, sizeof(output)); + return MOD_UNAVAILABLE; + } + output.queued_send_bytes = stats->queuedSendBytes; + output.inbound_dropped = stats->inboundDropped; + output.send_failures = stats->sendFailures; + output.bytes_sent = stats->bytesSent; + output.bytes_received = stats->bytesReceived; + std::memcpy(outStats, &output, sizeof(output)); + return MOD_OK; +} + +ModResult net_close(ModContext* context, NetHandle handle) { + auto* mod = mod_from_context(context); + if (mod == nullptr) { + return MOD_INVALID_ARGUMENT; + } + auto* handleState = s_handles.find_owned(handle, *mod); + auto* state = find_state(*mod); + if (handleState == nullptr || state == nullptr) { + return MOD_UNAVAILABLE; + } + state->context.close(handleState->value.socket); + return MOD_OK; +} + +void net_mod_deactivating(LoadedMod& mod) { + (void)s_handles.take_all(mod); + s_modStates.erase(mod); +} + +void net_mod_detached(LoadedMod& mod) { + assert(find_state(mod) == nullptr); + bool found = false; + s_handles.for_each([&](NetHandle, const auto& entry) { found = found || entry.owner == &mod; }); + assert(!found); +} + +void net_shutdown() { + s_modStates.clear(); + s_handles = {}; +} + +bool net_available() { + return borealis::net::available(); +} + +constexpr NetService s_netService{ + .header = SERVICE_HEADER(NetService, NET_SERVICE_MAJOR, NET_SERVICE_MINOR), + .connect = SERVICE_FUNCTION(net_connect), + .listen = SERVICE_FUNCTION(net_listen), + .open_datagram = SERVICE_FUNCTION(net_open_datagram), + .resolve = SERVICE_FUNCTION(net_resolve), + .poll_event = SERVICE_FUNCTION(net_poll_event), + .send = SERVICE_FUNCTION(net_send), + .send_to = SERVICE_FUNCTION(net_send_to), + .set_user_data = SERVICE_FUNCTION(net_set_user_data), + .stats = SERVICE_FUNCTION(net_stats), + .close = SERVICE_FUNCTION(net_close), +}; + +} // namespace + +constinit const ServiceModule g_netModule{ + .id = NET_SERVICE_ID, + .majorVersion = NET_SERVICE_MAJOR, + .minorVersion = NET_SERVICE_MINOR, + .service = &s_netService, + .available = net_available, + .modDeactivating = net_mod_deactivating, + .modDetached = net_mod_detached, + .shutdown = net_shutdown, +}; + +} // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/net.hpp b/src/dusk/mods/svc/net.hpp new file mode 100644 index 0000000000..34ca17362f --- /dev/null +++ b/src/dusk/mods/svc/net.hpp @@ -0,0 +1,79 @@ +#pragma once + +#include "dusk/app_info.hpp" +#include "dusk/mods/loader/loader.hpp" +#include "mods/svc/http.h" +#include "mods/svc/net.h" +#include "mods/svc/websocket.h" + +#include +#include + +#include +#include +#include +#include +#include + +namespace dusk::mods::svc { + +inline bool ascii_iequals(std::string_view left, std::string_view right) { + if (left.size() != right.size()) { + return false; + } + const auto ascii_lower = [](char value) { + return value >= 'A' && value <= 'Z' ? static_cast(value - 'A' + 'a') : value; + }; + return std::ranges::equal( + left, right, [&](char a, char b) { return ascii_lower(a) == ascii_lower(b); }); +} + +inline bool valid_header_name(std::string_view name) { + constexpr std::string_view Separators{"()<>@,;:\\\"/[]?={} \t"}; + return !name.empty() && std::ranges::all_of(name, [&](unsigned char value) { + return value > 32 && value < 127 && + Separators.find(static_cast(value)) == std::string_view::npos; + }); +} + +inline bool valid_header_value(std::string_view value, bool allowHorizontalTab) { + return std::ranges::all_of(value, [=](unsigned char character) { + return character >= 32 || (allowHorizontalTab && character == '\t'); + }) && std::ranges::none_of(value, [](unsigned char character) { return character == 127; }); +} + +inline bool is_reserved_header(std::string_view name, std::span reserved) { + return std::ranges::any_of( + reserved, [&](std::string_view value) { return ascii_iequals(name, value); }); +} + +inline bool valid_header(std::string_view name, std::string_view value, + std::span reserved, bool allowHorizontalTab = false) { + return valid_header_name(name) && valid_header_value(value, allowHorizontalTab) && + !is_reserved_header(name, reserved); +} + +inline bool declares_import(const LoadedMod& mod, std::string_view serviceId) { + return std::ranges::any_of( + mod.manifestInfo.imports, [&](const ModManifestInfo::Import& serviceImport) { + return serviceImport.id == serviceId; + }); +} + +inline bool is_network_service(std::string_view serviceId) { + return serviceId == HTTP_SERVICE_ID || serviceId == NET_SERVICE_ID || + serviceId == WEBSOCKET_SERVICE_ID; +} + +inline std::string user_agent(const LoadedMod& mod) { + std::string version{mod.metadata.version}; + for (char& character : version) { + const auto value = static_cast(character); + if (value <= 32 || value >= 127) { + character = '_'; + } + } + return fmt::format("{}/{} {}/{}", AppName, BOREALIS_APP_VERSION, mod.metadata.id, version); +} + +} // namespace dusk::mods::svc::network diff --git a/src/dusk/mods/svc/overlay.cpp b/src/dusk/mods/svc/overlay.cpp index f4ad2ff6a6..d32cc8069c 100644 --- a/src/dusk/mods/svc/overlay.cpp +++ b/src/dusk/mods/svc/overlay.cpp @@ -1,5 +1,5 @@ +#include "internal.hpp" #include "registry.hpp" -#include "slot_map.hpp" #include #include "JSystem/JKernel/JKRArchive.h" diff --git a/src/dusk/mods/svc/registry.cpp b/src/dusk/mods/svc/registry.cpp index a26e2971dc..33ceede924 100644 --- a/src/dusk/mods/svc/registry.cpp +++ b/src/dusk/mods/svc/registry.cpp @@ -215,6 +215,8 @@ void ModLoader::init_services() { &svc::g_resourceModule, &svc::g_fileModule, &svc::g_httpModule, + &svc::g_netModule, + &svc::g_websocketModule, &svc::g_hookModule, &svc::g_overlayModule, &svc::g_textureModule, diff --git a/src/dusk/mods/svc/registry.hpp b/src/dusk/mods/svc/registry.hpp index 6f501c3d99..470208fad1 100644 --- a/src/dusk/mods/svc/registry.hpp +++ b/src/dusk/mods/svc/registry.hpp @@ -72,6 +72,8 @@ extern const ServiceModule g_logModule; extern const ServiceModule g_resourceModule; extern const ServiceModule g_fileModule; extern const ServiceModule g_httpModule; +extern const ServiceModule g_netModule; +extern const ServiceModule g_websocketModule; extern const ServiceModule g_hookModule; extern const ServiceModule g_overlayModule; extern const ServiceModule g_textureModule; diff --git a/src/dusk/mods/svc/ui.cpp b/src/dusk/mods/svc/ui.cpp index 10f864064f..7516b893bf 100644 --- a/src/dusk/mods/svc/ui.cpp +++ b/src/dusk/mods/svc/ui.cpp @@ -1,8 +1,8 @@ #include "ui.hpp" #include "config.hpp" +#include "internal.hpp" #include "registry.hpp" -#include "slot_map.hpp" #include "ui_v1.hpp" #include @@ -45,8 +45,7 @@ constexpr size_t kUiControlSelectedSize = offsetof(UiControlDesc, is_selected) + sizeof(UiPredicateFn); constexpr size_t kUiControlStringSetModeSize = offsetof(UiControlDesc, string_set_mode) + sizeof(UiStringSetMode); -constexpr size_t kUiControlFilePickerSize = - offsetof(UiControlDesc, directory_mode) + sizeof(bool); +constexpr size_t kUiControlFilePickerSize = offsetof(UiControlDesc, directory_mode) + sizeof(bool); constexpr size_t kUiListItemV21Size = offsetof(UiListItem, label) + sizeof(const char*); constexpr size_t kUiListDescV21Size = offsetof(UiListDesc, user_data) + sizeof(void*); @@ -650,8 +649,7 @@ ModResult ui_pane_add_control( spec.kind = ui::ModControlSpec::Kind::FilePicker; spec.directoryMode = desc.directory_mode; for (size_t i = 0; i < desc.file_filter_count; ++i) { - spec.fileFilters.push_back( - {desc.file_filters[i].name, desc.file_filters[i].pattern}); + spec.fileFilters.push_back({desc.file_filters[i].name, desc.file_filters[i].pattern}); } break; case UI_CONTROL_SELECT: diff --git a/src/dusk/mods/svc/websocket.cpp b/src/dusk/mods/svc/websocket.cpp new file mode 100644 index 0000000000..a53c388b30 --- /dev/null +++ b/src/dusk/mods/svc/websocket.cpp @@ -0,0 +1,393 @@ +#include "registry.hpp" + +#include "internal.hpp" +#include "net.hpp" + +#include "dusk/mods/loader/loader.hpp" +#include "mods/svc/websocket.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dusk::mods::svc { +namespace { + +constexpr size_t MaxConnectionsPerMod = 4; +constexpr size_t MaxUrlBytes = 8 * 1024; +constexpr size_t MaxHeaders = 64; +constexpr size_t MaxHeaderBytes = 16 * 1024; +constexpr size_t MaxProtocols = 8; +constexpr size_t DefaultMessageBytes = 1024 * 1024; +constexpr size_t MaxMessageBytes = 16 * 1024 * 1024; +constexpr std::chrono::milliseconds DefaultConnectTimeout{10000}; +constexpr std::chrono::milliseconds DefaultCloseTimeout{5000}; +constexpr std::string_view ReservedHeaders[]{ + "User-Agent", + "Host", + "Connection", + "Upgrade", + "Sec-WebSocket-Key", + "Sec-WebSocket-Version", + "Sec-WebSocket-Extensions", + "Sec-WebSocket-Protocol", + "Content-Length", +}; + +struct ConnectionState { + borealis::ws::Connection connection; + void* userData = nullptr; + bool openPublished = false; +}; + +struct ModPollState { + borealis::ws::Event currentEvent; + std::vector headers; + size_t next = 0; +}; + +SlotMap s_connections; +PerMod s_pollStates; + +bool valid_url(std::string_view url, bool& allowPlaintext) { + allowPlaintext = false; + if (url.size() > MaxUrlBytes) { + return false; + } + const auto parsed = borealis::url::parse(url); + if (!parsed || (parsed->scheme != "wss" && parsed->scheme != "ws")) { + return false; + } + allowPlaintext = parsed->scheme == "ws"; + if (!allowPlaintext) { + return true; + } + return parsed->host == "localhost" || parsed->host == "127.0.0.1" || parsed->host == "::1"; +} + +WebSocketError map_error(borealis::ws::Error error) { + switch (error) { + case borealis::ws::Error::None: + return WEBSOCKET_ERROR_NONE; + case borealis::ws::Error::InvalidUrl: + return WEBSOCKET_ERROR_INVALID_URL; + case borealis::ws::Error::UnsupportedScheme: + return WEBSOCKET_ERROR_UNSUPPORTED_SCHEME; + case borealis::ws::Error::Timeout: + return WEBSOCKET_ERROR_TIMEOUT; + case borealis::ws::Error::TooLarge: + return WEBSOCKET_ERROR_TOO_LARGE; + case borealis::ws::Error::Canceled: + return WEBSOCKET_ERROR_CANCELED; + case borealis::ws::Error::Protocol: + return WEBSOCKET_ERROR_PROTOCOL; + case borealis::ws::Error::Handshake: + return WEBSOCKET_ERROR_HANDSHAKE; + case borealis::ws::Error::NoBackend: + case borealis::ws::Error::Network: + default: + return WEBSOCKET_ERROR_NETWORK; + } +} + +ModResult map_send_result(borealis::ws::SendResult result) { + switch (result) { + case borealis::ws::SendResult::Ok: + return MOD_OK; + case borealis::ws::SendResult::NotOpen: + return MOD_UNAVAILABLE; + case borealis::ws::SendResult::QueueFull: + return MOD_CONFLICT; + case borealis::ws::SendResult::TooLarge: + case borealis::ws::SendResult::InvalidText: + return MOD_INVALID_ARGUMENT; + } + return MOD_ERROR; +} + +ModResult map_close_result(borealis::ws::CloseResult result) { + switch (result) { + case borealis::ws::CloseResult::Ok: + return MOD_OK; + case borealis::ws::CloseResult::NotOpen: + return MOD_UNAVAILABLE; + case borealis::ws::CloseResult::InvalidCode: + case borealis::ws::CloseResult::ReasonTooLarge: + case borealis::ws::CloseResult::InvalidText: + return MOD_INVALID_ARGUMENT; + } + return MOD_ERROR; +} + +size_t connection_count(const LoadedMod& mod) { + size_t count = 0; + s_connections.for_each([&](WebSocketHandle, const auto& entry) { + if (entry.owner == &mod) { + ++count; + } + }); + return count; +} + +ModResult websocket_connect( + ModContext* context, const WebSocketConnectDesc* desc, WebSocketHandle* outHandle) { + if (outHandle != nullptr) { + *outHandle = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || desc == nullptr || desc->struct_size < sizeof(WebSocketConnectDesc) || + desc->url == nullptr || outHandle == nullptr || + (desc->header_count != 0 && desc->headers == nullptr) || + (desc->protocol_count != 0 && desc->protocols == nullptr)) + { + return MOD_INVALID_ARGUMENT; + } + if (!declares_import(*mod, WEBSOCKET_SERVICE_ID)) { + return MOD_UNSUPPORTED; + } + bool allowPlaintext = false; + if (!valid_url(desc->url, allowPlaintext) || desc->header_count > MaxHeaders || + desc->protocol_count > MaxProtocols || desc->max_message_bytes > MaxMessageBytes) + { + return MOD_INVALID_ARGUMENT; + } + + size_t headerBytes = 0; + for (uint32_t index = 0; index < desc->header_count; ++index) { + const HttpHeader& header = desc->headers[index]; + if (header.name == nullptr || header.value == nullptr) { + return MOD_INVALID_ARGUMENT; + } + const std::string_view name{header.name}; + const std::string_view value{header.value}; + if (!valid_header(name, value, ReservedHeaders) || + name.size() > MaxHeaderBytes - std::min(headerBytes, MaxHeaderBytes)) + { + return MOD_INVALID_ARGUMENT; + } + headerBytes += name.size(); + if (value.size() > MaxHeaderBytes - std::min(headerBytes, MaxHeaderBytes)) { + return MOD_INVALID_ARGUMENT; + } + headerBytes += value.size(); + } + for (uint32_t index = 0; index < desc->protocol_count; ++index) { + if (desc->protocols[index] == nullptr || !valid_header_name(desc->protocols[index])) { + return MOD_INVALID_ARGUMENT; + } + } + if (!borealis::ws::available()) { + return MOD_UNAVAILABLE; + } + if (connection_count(*mod) >= MaxConnectionsPerMod) { + return MOD_CONFLICT; + } + + const size_t maxMessageBytes = + desc->max_message_bytes != 0 ? desc->max_message_bytes : DefaultMessageBytes; + borealis::ws::Options options{ + .url = desc->url, + .connectTimeout = desc->connect_timeout_ms != 0 ? + std::chrono::milliseconds{desc->connect_timeout_ms} : + DefaultConnectTimeout, + .closeTimeout = desc->close_timeout_ms != 0 ? + std::chrono::milliseconds{desc->close_timeout_ms} : + DefaultCloseTimeout, + .keepaliveInterval = std::chrono::milliseconds{desc->keepalive_interval_ms}, + .maxMessageBytes = maxMessageBytes, + .maxQueuedBytes = 16 * 1024 * 1024, + .maxSendQueueBytes = 4 * 1024 * 1024, + .allowPlaintext = allowPlaintext, + }; + options.headers.reserve(desc->header_count + 1); + for (uint32_t index = 0; index < desc->header_count; ++index) { + options.headers.push_back({desc->headers[index].name, desc->headers[index].value}); + } + options.headers.push_back({ + .name = "User-Agent", + .value = user_agent(*mod), + }); + options.protocols.reserve(desc->protocol_count); + for (uint32_t index = 0; index < desc->protocol_count; ++index) { + options.protocols.emplace_back(desc->protocols[index]); + } + auto connection = borealis::ws::connect(std::move(options)); + if (!connection) { + return MOD_UNAVAILABLE; + } + *outHandle = s_connections.emplace(*mod, ConnectionState{ + .connection = std::move(connection), + .userData = desc->user_data, + }); + return MOD_OK; +} + +ModResult websocket_poll_event(ModContext* context, WebSocketEvent* outEvent) { + const uint32_t structSize = outEvent != nullptr ? outEvent->struct_size : 0; + auto* mod = mod_from_context(context); + if (mod == nullptr || outEvent == nullptr || structSize < sizeof(WebSocketEvent)) { + return MOD_INVALID_ARGUMENT; + } + WebSocketEvent output{ + .struct_size = structSize, + .protocol = "", + .error_message = "", + .close_reason = "", + }; + + std::vector handles; + s_connections.for_each([&](WebSocketHandle handle, const auto& entry) { + if (entry.owner == mod) { + handles.push_back(handle); + } + }); + auto& pollState = s_pollStates.get_or_create(*mod); + if (handles.empty()) { + pollState.next = 0; + std::memcpy(outEvent, &output, sizeof(output)); + return MOD_OK; + } + + const size_t start = pollState.next % handles.size(); + WebSocketHandle selected = 0; + ConnectionState* selectedState = nullptr; + for (size_t offset = 0; offset < handles.size(); ++offset) { + const size_t index = (start + offset) % handles.size(); + auto* entry = s_connections.find_owned(handles[index], *mod); + if (entry != nullptr && entry->value.connection.poll(pollState.currentEvent)) { + selected = handles[index]; + selectedState = &entry->value; + pollState.next = index + 1; + break; + } + } + if (selectedState == nullptr) { + std::memcpy(outEvent, &output, sizeof(output)); + return MOD_OK; + } + + output.ws = selected; + output.user_data = selectedState->userData; + const auto& event = pollState.currentEvent; + pollState.headers.clear(); + pollState.headers.reserve(event.headers.size()); + for (const auto& header : event.headers) { + pollState.headers.push_back({header.name.c_str(), header.value.c_str()}); + } + output.headers = pollState.headers.data(); + output.header_count = static_cast(pollState.headers.size()); + if (event.kind == borealis::ws::Event::Kind::Open) { + output.type = WEBSOCKET_EVENT_OPEN; + output.protocol = event.protocol.c_str(); + selectedState->openPublished = true; + } else if (event.kind == borealis::ws::Event::Kind::Message) { + output.type = WEBSOCKET_EVENT_MESSAGE; + output.message_kind = event.messageKind == borealis::ws::MessageKind::Text ? + WEBSOCKET_MESSAGE_TEXT : + WEBSOCKET_MESSAGE_BINARY; + output.data = event.data.data(); + output.size = event.data.size(); + } else { + output.type = WEBSOCKET_EVENT_CLOSED; + output.error = map_error(event.error); + output.error_message = event.message.c_str(); + output.handshake_status = event.status; + output.close_code = event.code; + output.close_reason = event.reason.c_str(); + } + + std::memcpy(outEvent, &output, sizeof(output)); + if (event.kind == borealis::ws::Event::Kind::Closed) { + s_connections.erase(selected); + } + return MOD_OK; +} + +ModResult websocket_send(ModContext* context, WebSocketHandle handle, WebSocketMessageKind kind, + const void* data, size_t size) { + auto* mod = mod_from_context(context); + if (mod == nullptr || (size != 0 && data == nullptr) || + (kind != WEBSOCKET_MESSAGE_TEXT && kind != WEBSOCKET_MESSAGE_BINARY)) + { + return MOD_INVALID_ARGUMENT; + } + auto* entry = s_connections.find_owned(handle, *mod); + if (entry == nullptr || !entry->value.openPublished) { + return MOD_UNAVAILABLE; + } + const std::string_view bytes = data != nullptr ? + std::string_view{static_cast(data), size} : + std::string_view{}; + return map_send_result(entry->value.connection.send(kind == WEBSOCKET_MESSAGE_TEXT ? + borealis::ws::MessageKind::Text : + borealis::ws::MessageKind::Binary, + bytes)); +} + +ModResult websocket_close( + ModContext* context, WebSocketHandle handle, uint16_t code, const char* reason) { + auto* mod = mod_from_context(context); + const std::string_view reasonText = + reason != nullptr ? std::string_view{reason} : std::string_view{}; + if (mod == nullptr) { + return MOD_INVALID_ARGUMENT; + } + auto* entry = s_connections.find_owned(handle, *mod); + if (entry == nullptr) { + return MOD_UNAVAILABLE; + } + return map_close_result(entry->value.connection.close(code != 0 ? code : 1000, reasonText)); +} + +void websocket_mod_deactivating(LoadedMod& mod) { + (void)s_connections.take_all(mod); + s_pollStates.erase(mod); +} + +void websocket_mod_detached(LoadedMod& mod) { + bool found = false; + s_connections.for_each( + [&](WebSocketHandle, const auto& entry) { found = found || entry.owner == &mod; }); + assert(!found); + assert(!s_pollStates.contains(mod)); +} + +void websocket_shutdown() { + s_connections = {}; + s_pollStates.clear(); +} + +bool websocket_available() { + return borealis::ws::available(); +} + +constexpr WebSocketService s_websocketService{ + .header = SERVICE_HEADER(WebSocketService, WEBSOCKET_SERVICE_MAJOR, WEBSOCKET_SERVICE_MINOR), + .connect = SERVICE_FUNCTION(websocket_connect), + .poll_event = SERVICE_FUNCTION(websocket_poll_event), + .send = SERVICE_FUNCTION(websocket_send), + .close = SERVICE_FUNCTION(websocket_close), +}; + +} // namespace + +constinit const ServiceModule g_websocketModule{ + .id = WEBSOCKET_SERVICE_ID, + .majorVersion = WEBSOCKET_SERVICE_MAJOR, + .minorVersion = WEBSOCKET_SERVICE_MINOR, + .service = &s_websocketService, + .available = websocket_available, + .modDeactivating = websocket_mod_deactivating, + .modDetached = websocket_mod_detached, + .shutdown = websocket_shutdown, +}; + +} // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/window.cpp b/src/dusk/mods/svc/window.cpp index 7c9e938faf..5e61c43033 100644 --- a/src/dusk/mods/svc/window.cpp +++ b/src/dusk/mods/svc/window.cpp @@ -1,7 +1,7 @@ #include "window.hpp" +#include "internal.hpp" #include "registry.hpp" -#include "slot_map.hpp" #include #include "dusk/mods/loader/loader.hpp" diff --git a/src/dusk/ui/mods_window.cpp b/src/dusk/ui/mods_window.cpp index 757fa42b62..e23cb39d1d 100644 --- a/src/dusk/ui/mods_window.cpp +++ b/src/dusk/ui/mods_window.cpp @@ -1,11 +1,11 @@ #include "mods_window.hpp" #include "dusk/mod_loader.hpp" +#include "dusk/mods/svc/net.hpp" #include "dusk/mods/svc/ui.hpp" #include "fmt/format.h" #include "logs_window.hpp" #include "mod_texture_provider.hpp" -#include "mods/svc/http.h" #include "pane.hpp" #include "Z2AudioLib/Z2SeMgr.h" @@ -44,7 +44,7 @@ ModStatus mod_status(const mods::LoadedMod& mod) { bool mod_uses_network(const mods::LoadedMod& mod) { return std::ranges::any_of( mod.manifestInfo.imports, [](const mods::ModManifestInfo::Import& serviceImport) { - return serviceImport.id == HTTP_SERVICE_ID; + return mods::svc::is_network_service(serviceImport.id); }); }