diff --git a/README.md b/README.md index ac908e3..7cae007 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,46 @@ Use `html_escaped` for text nodes and attribute values. Use `url_encoded` for qu parameter values in `href` and `action` URLs. The HTTP server does not escape response bodies automatically. +# WebSocket (v1 spike) + +Upgrade is handled inside `http::server` (not as response middleware). Register a path +with `server.ws(...).ws(handler)`; a matching `GET` with `Upgrade: websocket` returns +`101` and runs a text-frame session (ping/pong/close + optional text replies). + +Clients use `websocket::connect` for the same text session (masked outbound frames, +automatic ping/pong, `send` / `recv` / `read_loop` / `close`). No `wss://` — terminate +TLS in front of the server if needed. + +v1 framing policy (fail closed with a close frame, no silent drops): + +- Client-to-server frames must be masked (`1002` if not). +- Complete text frames only — `FIN=0`, `continuation`, and `binary` → `1003`. +- Text payloads must be valid UTF-8 (`1007`); no xson dependency. +- Control frames must be FIN and ≤ 125 bytes; RSV bits must be 0 (`1002`). +- Payloads larger than 1 MiB → `1009`. + +```cpp +import net; +import std; + +// Server +auto server = http::server{}; +server.ws("/events").ws([](std::string_view msg) { + return net::websocket::text_reply{std::string{msg}}; // echo +}); +server.listen("127.0.0.1", "8080"); + +// Client +auto ws = net::websocket::connect("127.0.0.1", "8080", "/events"); +ws.send("hello"); +if(auto reply = ws.recv()) +{ + // … +} +// or: ws.read_loop([](std::string_view msg) { … }); +ws.close(); +``` + # Syslog Stream Example ## Basic Usage diff --git a/net/net-http_server.c++m b/net/net-http_server.c++m index dbe7a70..ad4a995 100644 --- a/net/net-http_server.c++m +++ b/net/net-http_server.c++m @@ -6,6 +6,7 @@ import :http_headers; import :endpointstream; import :utils; import :uuid; +import :websocket; import std; export namespace http { @@ -21,8 +22,10 @@ constexpr auto method_put = "PUT"sv; constexpr auto method_patch = "PATCH"sv; constexpr auto method_delete = "DELETE"sv; constexpr auto method_options = "OPTIONS"sv; +constexpr auto method_ws = "WS"sv; // HTTP status constants +const auto status_switching_protocols = "101 Switching Protocols"s; const auto status_ok = "200 OK"s; const auto status_created = "201 Created"s; const auto status_no_content = "204 No Content"s; @@ -82,6 +85,7 @@ class controller public: using callback = std::function<::http::response_with_headers(request_view, body_view, headers&)>; + using websocket_callback = net::websocket::text_handler; void text(const content& c) { @@ -153,10 +157,28 @@ public: return {std::move(s), std::move(c), m_content_type, std::move(custom_h)}; } + // WebSocket text handler: payload in → optional text frame out (nullopt = no reply). + controller& ws(websocket_callback cb) + { + m_ws_callback = std::move(cb); + return *this; + } + + [[nodiscard]] bool has_websocket() const noexcept + { + return static_cast(m_ws_callback); + } + + [[nodiscard]] const websocket_callback& websocket_handler() const noexcept + { + return m_ws_callback; + } + private: content_type m_content_type = "*/*"; callback m_callback = [](request_view, body_view, headers&) -> ::http::response_with_headers { return make_response(status_ok, "Not Found"s); }; + websocket_callback m_ws_callback; }; class server @@ -199,6 +221,12 @@ public: return m_router[std::string{path}][method_delete]; } + // Register a WebSocket endpoint (upgrade from GET). Chain `.ws(handler)`. + controller& ws(std::string_view path) + { + return m_router[std::string{path}][method_ws]; + } + void listen(std::string_view service_or_port = "http") { listen("0.0.0.0"sv, service_or_port, nullptr); @@ -453,6 +481,68 @@ private: break; } + // WebSocket upgrade takes over the connection (not a normal HTTP response). + if(method == method_get and net::websocket::is_websocket_upgrade(hs)) + { + const auto path_only = std::string{uri.substr(0, uri.find('?'))}; + auto matched_path = std::optional{}; + for(const auto& [path, methods_map] : m_router) + { + if(not std::regex_match(path_only, std::regex{path})) + continue; + if(methods_map.contains(method_ws) and methods_map.at(method_ws).has_websocket()) + { + matched_path = path; + break; + } + } + + if(not matched_path) + { + stream << "HTTP/1.1 " << status_not_found << net::crlf + << "Date: " << date() << net::crlf + << "Server: " << host() << net::crlf + << "Content-Type: " << m_content_type << net::crlf + << "Content-Length: 0" << net::crlf + << "Connection: close" << net::crlf + << net::crlf << net::flush; + break; + } + + if(not hs.contains("sec-websocket-key") or hs["sec-websocket-key"].empty()) + { + stream << "HTTP/1.1 " << status_bad_request << net::crlf + << "Date: " << date() << net::crlf + << "Server: " << host() << net::crlf + << "Content-Type: " << m_content_type << net::crlf + << "Content-Length: 0" << net::crlf + << "Connection: close" << net::crlf + << net::crlf << net::flush; + break; + } + + const auto accept = net::websocket::sec_websocket_accept(hs["sec-websocket-key"]); + net::slog << net::notice("HTTP_WEBSOCKET_UPGRADE") << "upgrading connection" + << std::pair{"ip"sv, endpoint} + << std::pair{"port", port} + << std::pair{"uri", std::string{uri}} + << std::pair{"request_id", request_id} + << net::flush; + + stream << "HTTP/1.1 " << status_switching_protocols << net::crlf + << "Date: " << date() << net::crlf + << "Server: " << host() << net::crlf + << "Upgrade: websocket" << net::crlf + << "Connection: Upgrade" << net::crlf + << "Sec-WebSocket-Accept: " << accept << net::crlf + << net::crlf << net::flush; + + // Copy handler before session (flat_map proxies are not stable refs). + const auto ws_handler = m_router.at(*matched_path).at(method_ws).websocket_handler(); + net::websocket::run_text_session(stream, ws_handler); + break; + } + auto close_connection = false; if(hs.contains("connection")) { diff --git a/net/net-websocket.c++m b/net/net-websocket.c++m new file mode 100644 index 0000000..91ea694 --- /dev/null +++ b/net/net-websocket.c++m @@ -0,0 +1,456 @@ +export module net:websocket; +export import :websocket_frame; +import :connector; +import :endpointstream; +import :http_base64; +import :http_headers; +import :utils; +import std; + +export namespace net::websocket { + +using namespace std::string_literals; +using namespace std::string_view_literals; + +// RFC 6455 §1.3 magic GUID +inline constexpr auto accept_guid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"sv; + +namespace detail { + +// Compact SHA-1 (FIPS 180-1) for the short WebSocket accept material only. +inline std::array sha1_digest(std::string_view message) +{ + auto left_rotate = [](std::uint32_t value, std::uint32_t bits) noexcept + { + return (value << bits) | (value >> (32u - bits)); + }; + + auto h0 = 0x67452301u; + auto h1 = 0xEFCDAB89u; + auto h2 = 0x98BADCFEu; + auto h3 = 0x10325476u; + auto h4 = 0xC3D2E1F0u; + + auto bytes = std::vector(message.begin(), message.end()); + const auto bit_len = static_cast(bytes.size()) * 8ull; + bytes.push_back(0x80u); + while((bytes.size() % 64u) != 56u) + bytes.push_back(0u); + for(int shift = 56; shift >= 0; shift -= 8) + bytes.push_back(static_cast((bit_len >> shift) & 0xFFu)); + + for(std::size_t offset = 0; offset < bytes.size(); offset += 64) + { + std::array w{}; + for(std::size_t i = 0; i < 16; ++i) + { + w[i] = (static_cast(bytes[offset + i * 4]) << 24) + | (static_cast(bytes[offset + i * 4 + 1]) << 16) + | (static_cast(bytes[offset + i * 4 + 2]) << 8) + | static_cast(bytes[offset + i * 4 + 3]); + } + for(std::size_t i = 16; i < 80; ++i) + w[i] = left_rotate(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16], 1); + + auto a = h0; + auto b = h1; + auto c = h2; + auto d = h3; + auto e = h4; + + for(std::size_t i = 0; i < 80; ++i) + { + std::uint32_t f = 0; + std::uint32_t k = 0; + if(i < 20) + { + f = (b & c) | ((~b) & d); + k = 0x5A827999u; + } + else if(i < 40) + { + f = b ^ c ^ d; + k = 0x6ED9EBA1u; + } + else if(i < 60) + { + f = (b & c) | (b & d) | (c & d); + k = 0x8F1BBCDCu; + } + else + { + f = b ^ c ^ d; + k = 0xCA62C1D6u; + } + + const auto temp = left_rotate(a, 5) + f + e + k + w[i]; + e = d; + d = c; + c = left_rotate(b, 30); + b = a; + a = temp; + } + + h0 += a; + h1 += b; + h2 += c; + h3 += d; + h4 += e; + } + + auto digest = std::array{}; + const auto words = std::array{h0, h1, h2, h3, h4}; + for(std::size_t i = 0; i < words.size(); ++i) + { + digest[i * 4] = static_cast((words[i] >> 24) & 0xFFu); + digest[i * 4 + 1] = static_cast((words[i] >> 16) & 0xFFu); + digest[i * 4 + 2] = static_cast((words[i] >> 8) & 0xFFu); + digest[i * 4 + 3] = static_cast(words[i] & 0xFFu); + } + return digest; +} + +inline std::uint32_t random_u32() +{ + thread_local std::mt19937 gen{std::random_device{}()}; + return std::uniform_int_distribution{}(gen); +} + +inline std::string random_websocket_key() +{ + std::array bytes{}; + for(auto& b : bytes) + b = static_cast(random_u32() & 0xFFu); + return ::http::base64::encode(std::string_view{bytes.data(), bytes.size()}); +} + +inline frame mask_frame(frame f) +{ + f.masked = true; + f.masking_key = random_u32(); + return f; +} + +inline bool write_masked(std::ostream& os, frame f) +{ + return write_frame(os, mask_frame(std::move(f))); +} + +inline std::string normalize_path(std::string_view path) +{ + if(path.empty()) + return "/"s; + if(path.front() == '/') + return std::string{path}; + return "/"s + std::string{path}; +} + +} // namespace detail + +inline std::string sec_websocket_accept(std::string_view client_key) +{ + auto material = std::string{client_key}; + material.append(accept_guid); + const auto digest = detail::sha1_digest(material); + return ::http::base64::encode( + std::string_view{reinterpret_cast(digest.data()), digest.size()}); +} + +inline bool header_token_contains(std::string value, std::string_view token) +{ + utils::ascii_to_lower(value); + auto needle = std::string{token}; + utils::ascii_to_lower(needle); + return value.contains(needle); +} + +inline bool is_websocket_upgrade(const ::http::headers& hs) +{ + if(not hs.contains("upgrade") or not hs.contains("connection")) + return false; + return header_token_contains(hs["upgrade"], "websocket"sv) + and header_token_contains(hs["connection"], "upgrade"sv); +} + +// nullopt = no outbound text frame; engaged = send that payload. +using text_reply = std::optional; +using text_handler = std::function; +using text_sink = std::function; + +inline void close_session(std::iostream& stream, std::uint16_t code) +{ + write_frame(stream, make_close_frame(code)); +} + +// Drive a WebSocket session until close/error. Server frames are unmasked. +// v1 policy: complete text frames only (no fragmentation / binary); invalid +// UTF-8 → 1007; structural frame errors → 1002 / 1009. +inline void run_text_session(std::iostream& stream, const text_handler& on_text) +{ + while(stream) + { + auto incoming = frame{}; + switch(read_frame(stream, incoming)) + { + case frame_read::ok: + break; + case frame_read::io_error: + return; + case frame_read::message_too_big: + close_session(stream, close_message_too_big); + return; + case frame_read::protocol_error: + close_session(stream, close_protocol_error); + return; + } + + // RFC 6455 §5.1: every client-to-server frame MUST be masked. + if(not incoming.masked) + { + close_session(stream, close_protocol_error); + return; + } + + switch(incoming.op) + { + case opcode::ping: + if(not write_frame(stream, make_pong_frame(incoming.payload))) + return; + break; + + case opcode::pong: + break; + + case opcode::close: + write_frame(stream, make_close_frame(incoming.payload)); + return; + + case opcode::text: + { + // No reassembly: FIN=0 is unsupported (close 1003), not silently dropped. + if(not incoming.fin) + { + close_session(stream, close_unsupported_data); + return; + } + if(not is_valid_utf8(std::span{incoming.payload})) + { + close_session(stream, close_invalid_payload); + return; + } + if(not on_text) + break; + const auto text = payload_as_string(incoming); + if(auto reply = on_text(text)) + { + if(not write_frame(stream, make_text_frame(*reply))) + return; + } + break; + } + + case opcode::binary: + case opcode::continuation: + close_session(stream, close_unsupported_data); + return; + + default: + close_session(stream, close_protocol_error); + return; + } + } +} + +// Client connection: TCP + HTTP upgrade, then masked text send / unmasked recv. +// Same v1 policy as the server (text-only, no fragments, UTF-8, fail closed). +class connection +{ +public: + + connection(connection&&) noexcept = default; + connection& operator=(connection&&) noexcept = default; + + [[nodiscard]] explicit operator bool() const noexcept + { + return not m_closed and static_cast(m_stream); + } + + // Send one complete text message (client frames are masked). + [[nodiscard]] bool send(std::string_view text) + { + if(not *this) + return false; + return detail::write_masked(m_stream, make_text_frame(text)); + } + + // Next text message, or nullopt when the session ends (close / protocol / IO). + // Automatically answers ping with a masked pong. + [[nodiscard]] std::optional recv() + { + while(*this) + { + auto incoming = frame{}; + switch(read_frame(m_stream, incoming)) + { + case frame_read::ok: + break; + case frame_read::io_error: + m_closed = true; + return std::nullopt; + case frame_read::message_too_big: + fail_close(close_message_too_big); + return std::nullopt; + case frame_read::protocol_error: + fail_close(close_protocol_error); + return std::nullopt; + } + + // RFC 6455 §5.1: server-to-client frames MUST NOT be masked. + if(incoming.masked) + { + fail_close(close_protocol_error); + return std::nullopt; + } + + switch(incoming.op) + { + case opcode::ping: + if(not detail::write_masked(m_stream, make_pong_frame(incoming.payload))) + { + m_closed = true; + return std::nullopt; + } + break; + + case opcode::pong: + break; + + case opcode::close: + detail::write_masked(m_stream, make_close_frame(incoming.payload)); + m_closed = true; + return std::nullopt; + + case opcode::text: + if(not incoming.fin) + { + fail_close(close_unsupported_data); + return std::nullopt; + } + if(not is_valid_utf8(std::span{incoming.payload})) + { + fail_close(close_invalid_payload); + return std::nullopt; + } + return payload_as_string(incoming); + + case opcode::binary: + case opcode::continuation: + fail_close(close_unsupported_data); + return std::nullopt; + + default: + fail_close(close_protocol_error); + return std::nullopt; + } + } + return std::nullopt; + } + + // Call on_text for each text message until the session ends. + void read_loop(const text_sink& on_text) + { + while(auto msg = recv()) + { + if(on_text) + on_text(*msg); + } + } + + // Initiate close; best-effort wait for peer close frame. + void close(std::uint16_t code = close_normal) + { + if(m_closed) + return; + detail::write_masked(m_stream, make_close_frame(code)); + while(m_stream and not m_closed) + { + auto incoming = frame{}; + if(read_frame(m_stream, incoming) != frame_read::ok) + break; + if(incoming.op == opcode::close) + break; + if(incoming.op == opcode::ping) + detail::write_masked(m_stream, make_pong_frame(incoming.payload)); + } + m_closed = true; + m_stream.close(); + } + +private: + + friend connection connect( + std::string_view host, + std::string_view port, + std::string_view path, + const std::chrono::milliseconds& timeout); // defined below + + explicit connection(net::endpointstream stream) + : m_stream{std::move(stream)} + { + } + + void fail_close(std::uint16_t code) + { + if(not m_closed and m_stream) + detail::write_masked(m_stream, make_close_frame(code)); + m_closed = true; + } + + net::endpointstream m_stream; + bool m_closed = false; +}; + +// TCP connect + HTTP Upgrade handshake. Throws std::runtime_error on a failed +// upgrade (TCP failure throws via net::connect). +inline connection connect( + std::string_view host, + std::string_view port, + std::string_view path = "/"sv, + const std::chrono::milliseconds& timeout = net::default_connect_timeout) +{ + auto stream = net::connect(host, port, timeout); + const auto key = detail::random_websocket_key(); + const auto expected_accept = sec_websocket_accept(key); + const auto request_path = detail::normalize_path(path); + auto host_header = std::string{host}; + host_header.push_back(':'); + host_header.append(port); + + stream << "GET " << request_path << " HTTP/1.1" << net::crlf + << "Host: " << host_header << net::crlf + << "Upgrade: websocket" << net::crlf + << "Connection: Upgrade" << net::crlf + << "Sec-WebSocket-Key: " << key << net::crlf + << "Sec-WebSocket-Version: 13" << net::crlf + << net::crlf << net::flush; + + auto version = ""s; + auto status_code = ""s; + auto reason = ""s; + stream >> version >> status_code; + std::getline(stream, reason); + if(status_code != "101"s) + throw std::runtime_error{"websocket upgrade failed: expected 101, got " + status_code}; + + auto hs = ::http::headers{}; + stream >> hs >> net::crlf; + if(not is_websocket_upgrade(hs)) + throw std::runtime_error{"websocket upgrade failed: missing Upgrade headers"}; + if(not hs.contains("sec-websocket-accept") + or hs["sec-websocket-accept"] != expected_accept) + throw std::runtime_error{"websocket upgrade failed: Sec-WebSocket-Accept mismatch"}; + + return connection{std::move(stream)}; +} + +} // namespace net::websocket diff --git a/net/net-websocket.test.c++ b/net/net-websocket.test.c++ new file mode 100644 index 0000000..860968b --- /dev/null +++ b/net/net-websocket.test.c++ @@ -0,0 +1,557 @@ +module net; +import tester; +import std; + +using namespace net; +using namespace net::websocket; +using namespace std::string_literals; +using namespace std::string_view_literals; + +namespace { + +using tester::assertions::check_eq; +using tester::assertions::check_true; +using tester::assertions::require_true; + +inline bool network_tests_enabled() +{ + if(const auto* v = std::getenv("NET_DISABLE_NETWORK_TESTS")) + return std::string_view{v} != "1"; + return true; +} + +inline frame make_masked_text(std::string_view text, std::uint32_t key = 0x01020304u) +{ + auto f = make_text_frame(text); + f.masked = true; + f.masking_key = key; + return f; +} + +inline frame make_masked_close(std::uint32_t key = 0x01020304u) +{ + auto f = make_close_frame(); + f.masked = true; + f.masking_key = key; + return f; +} + +inline frame make_masked_ping(std::string_view payload, std::uint32_t key = 0x01020304u) +{ + auto f = frame{}; + f.op = opcode::ping; + f.masked = true; + f.masking_key = key; + f.payload.resize(payload.size()); + if(not payload.empty()) + std::memcpy(f.payload.data(), payload.data(), payload.size()); + return f; +} + +inline frame make_masked_pong(std::uint32_t key = 0x01020304u) +{ + auto f = frame{}; + f.op = opcode::pong; + f.masked = true; + f.masking_key = key; + return f; +} + +// In-memory duplex stream: reads consume a prepared input buffer, writes are +// captured separately so run_text_session responses can be inspected without a +// socket. Keeping the two directions apart avoids the reader looping over freshly +// written response bytes (which a single shared buffer would do). +class duplex_buf : public std::streambuf +{ +public: + + explicit duplex_buf(std::string input) : m_in{std::move(input)} + { + auto* base = m_in.data(); + setg(base, base, base + m_in.size()); + } + + const std::string& output() const noexcept { return m_out; } + +protected: + + int_type overflow(int_type ch) override + { + if(ch != traits_type::eof()) + m_out.push_back(static_cast(ch)); + return ch; + } + + std::streamsize xsputn(const char* s, std::streamsize n) override + { + m_out.append(s, static_cast(n)); + return n; + } + +private: + + std::string m_in; + std::string m_out; +}; + +class duplex_stream : public std::iostream +{ +public: + + explicit duplex_stream(std::string input) : std::iostream{nullptr}, m_buf{std::move(input)} + { + rdbuf(&m_buf); + } + + const std::string& output() const noexcept { return m_buf.output(); } + +private: + + duplex_buf m_buf; +}; + +// Encode a frame to bytes for use as duplex_stream input. +inline std::string frame_bytes(const frame& f) +{ + auto oss = std::ostringstream{}; + write_frame(oss, f); + return oss.str(); +} + +} // namespace + +auto register_websocket_tests() +{ + tester::bdd::scenario("sec_websocket_accept matches RFC 6455 example, [net]") = [] { + // RFC 6455 §1.3 + const auto accept = sec_websocket_accept("dGhlIHNhbXBsZSBub25jZQ=="sv); + check_eq(accept, "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="s); + }; + + tester::bdd::scenario("websocket frame round-trip with client mask, [net]") = [] { + auto oss = std::ostringstream{}; + require_true(write_frame(oss, make_masked_text("hello"sv))); + + auto iss = std::istringstream{oss.str()}; + auto decoded = frame{}; + require_true(read_frame(iss, decoded) == frame_read::ok); + check_eq(decoded.op, opcode::text); + check_true(decoded.fin); + check_eq(payload_as_string(decoded), "hello"s); + }; + + tester::bdd::scenario("websocket frame supports 16-bit extended length, [net]") = [] { + const auto payload = std::string(200, 'x'); + auto oss = std::ostringstream{}; + require_true(write_frame(oss, make_masked_text(payload))); + + auto iss = std::istringstream{oss.str()}; + auto decoded = frame{}; + require_true(read_frame(iss, decoded) == frame_read::ok); + check_eq(payload_as_string(decoded), payload); + }; + + tester::bdd::scenario("websocket unmasked server frame round-trip, [net]") = [] { + auto oss = std::ostringstream{}; + require_true(write_frame(oss, make_text_frame("server-say"sv))); + + auto iss = std::istringstream{oss.str()}; + auto decoded = frame{}; + require_true(read_frame(iss, decoded) == frame_read::ok); + check_true(not decoded.masked); + check_eq(decoded.op, opcode::text); + check_eq(payload_as_string(decoded), "server-say"s); + }; + + tester::bdd::scenario("websocket frame supports 64-bit extended length, [net]") = [] { + // > 0xFFFF forces the 8-byte length encoding, still under the 1 MiB cap. + const auto payload = std::string(70000, 'y'); + auto oss = std::ostringstream{}; + require_true(write_frame(oss, make_masked_text(payload))); + + auto iss = std::istringstream{oss.str()}; + auto decoded = frame{}; + require_true(read_frame(iss, decoded) == frame_read::ok); + check_eq(decoded.payload.size(), payload.size()); + check_eq(payload_as_string(decoded), payload); + }; + + tester::bdd::scenario("websocket read_frame rejects oversized payload, [net]") = [] { + // Hand-craft a header that claims > 1 MiB via the 64-bit length field. + auto header = std::string{}; + header.push_back(static_cast(0x81)); // FIN + text + header.push_back(static_cast(0x7F)); // 127 => 64-bit length, unmasked + const auto len = std::uint64_t{(1u << 20) + 1}; + for(int shift = 56; shift >= 0; shift -= 8) + header.push_back(static_cast((len >> shift) & 0xFFu)); + + auto iss = std::istringstream{header}; + auto decoded = frame{}; + check_eq(read_frame(iss, decoded), frame_read::message_too_big); + }; + + tester::bdd::scenario("websocket read_frame fails on truncated payload, [net]") = [] { + // Announce 5 bytes but only provide 2. + auto oss = std::ostringstream{}; + require_true(write_frame(oss, make_masked_text("hello"sv))); + auto truncated = oss.str().substr(0, oss.str().size() - 3); + + auto iss = std::istringstream{truncated}; + auto decoded = frame{}; + check_true(read_frame(iss, decoded) != frame_read::ok); + }; + + tester::bdd::scenario("run_text_session echoes text via handler, [net]") = [] { + auto stream = duplex_stream{frame_bytes(make_masked_text("hello"sv))}; + run_text_session(stream, [](std::string_view msg) { + return text_reply{std::string{msg} + "!"}; + }); + + auto out = std::istringstream{stream.output()}; + auto reply = frame{}; + require_true(read_frame(out, reply) == frame_read::ok); + check_true(not reply.masked); + check_eq(reply.op, opcode::text); + check_eq(payload_as_string(reply), "hello!"s); + }; + + tester::bdd::scenario("run_text_session suppresses reply when handler returns nullopt, [net]") = [] { + auto stream = duplex_stream{frame_bytes(make_masked_text("quiet"sv))}; + run_text_session(stream, [](std::string_view) { + return text_reply{}; + }); + check_true(stream.output().empty()); + }; + + tester::bdd::scenario("run_text_session answers ping with pong, [net]") = [] { + auto stream = duplex_stream{frame_bytes(make_masked_ping("hb"sv))}; + run_text_session(stream, nullptr); + + auto out = std::istringstream{stream.output()}; + auto reply = frame{}; + require_true(read_frame(out, reply) == frame_read::ok); + check_eq(reply.op, opcode::pong); + check_eq(payload_as_string(reply), "hb"s); + }; + + tester::bdd::scenario("run_text_session ignores pong frames, [net]") = [] { + auto stream = duplex_stream{frame_bytes(make_masked_pong())}; + run_text_session(stream, nullptr); + check_true(stream.output().empty()); + }; + + tester::bdd::scenario("run_text_session replies to close and ends, [net]") = [] { + // A close frame followed by a text frame: the session must stop after the + // close and never echo the trailing text. + auto input = frame_bytes(make_masked_close()); + input += frame_bytes(make_masked_text("after-close"sv)); + + auto stream = duplex_stream{input}; + run_text_session(stream, [](std::string_view msg) { + return text_reply{std::string{msg}}; + }); + + auto out = std::istringstream{stream.output()}; + auto reply = frame{}; + require_true(read_frame(out, reply) == frame_read::ok); + check_eq(reply.op, opcode::close); + + auto extra = frame{}; + check_true(read_frame(out, extra) != frame_read::ok); // nothing echoed after close + }; + + tester::bdd::scenario("run_text_session rejects unmasked client frame with 1002, [net]") = [] { + // RFC 6455 §5.1: client frames must be masked. An unmasked text frame must + // be rejected with a protocol-error close and the handler must not run. + auto handler_ran = std::make_shared(false); + auto stream = duplex_stream{frame_bytes(make_text_frame("nomask"sv))}; + run_text_session(stream, [handler_ran](std::string_view msg) { + *handler_ran = true; + return text_reply{std::string{msg}}; + }); + + auto out = std::istringstream{stream.output()}; + auto reply = frame{}; + require_true(read_frame(out, reply) == frame_read::ok); + check_eq(reply.op, opcode::close); + check_eq(close_code(reply), close_protocol_error); + check_true(not *handler_ran); + + auto extra = frame{}; + check_true(read_frame(out, extra) != frame_read::ok); // session ended after the close + }; + + tester::bdd::scenario("is_valid_utf8 accepts text and rejects illegal bytes, [net]") = [] { + check_true(is_valid_utf8("hello"sv)); + check_true(is_valid_utf8("äö€"sv)); // multi-byte OK + check_true(is_valid_utf8(""sv)); + + const auto overlong = std::array{std::byte{0xC0}, std::byte{0x80}}; // U+0000 overlong + check_true(not is_valid_utf8(std::span{overlong})); + + const auto bad = std::array{std::byte{0xFFu}}; + check_true(not is_valid_utf8(std::span{bad})); + + const auto truncated = std::array{std::byte{0xE2}, std::byte{0x82}}; // incomplete € + check_true(not is_valid_utf8(std::span{truncated})); + }; + + tester::bdd::scenario("read_frame rejects RSV bits and oversized control, [net]") = [] { + // FIN + text with RSV1 set. + auto rsv = std::string{}; + rsv.push_back(static_cast(0xC1)); // FIN|RSV1|text + rsv.push_back(static_cast(0x80)); // masked, len 0 + rsv.append("\x01\x02\x03\x04", 4); + auto iss = std::istringstream{rsv}; + auto decoded = frame{}; + check_eq(read_frame(iss, decoded), frame_read::protocol_error); + + // Control payload must be ≤ 125: claim 126 via 16-bit length on ping. + auto ctrl = std::string{}; + ctrl.push_back(static_cast(0x89)); // FIN + ping + ctrl.push_back(static_cast(0xFE)); // masked + 126 + ctrl.push_back(static_cast(0x00)); + ctrl.push_back(static_cast(0x7E)); // 126 bytes + auto iss2 = std::istringstream{ctrl}; + check_eq(read_frame(iss2, decoded), frame_read::protocol_error); + }; + + tester::bdd::scenario("run_text_session rejects fragmented text with 1003, [net]") = [] { + auto frag = make_masked_text("part"sv); + frag.fin = false; + auto handler_ran = std::make_shared(false); + auto stream = duplex_stream{frame_bytes(frag)}; + run_text_session(stream, [handler_ran](std::string_view) { + *handler_ran = true; + return text_reply{}; + }); + + auto out = std::istringstream{stream.output()}; + auto reply = frame{}; + require_true(read_frame(out, reply) == frame_read::ok); + check_eq(reply.op, opcode::close); + check_eq(close_code(reply), close_unsupported_data); + check_true(not *handler_ran); + }; + + tester::bdd::scenario("run_text_session rejects continuation and binary with 1003, [net]") = [] { + auto cont = frame{}; + cont.op = opcode::continuation; + cont.fin = true; + cont.masked = true; + cont.masking_key = 0x01020304u; + + auto stream = duplex_stream{frame_bytes(cont)}; + run_text_session(stream, nullptr); + auto out = std::istringstream{stream.output()}; + auto reply = frame{}; + require_true(read_frame(out, reply) == frame_read::ok); + check_eq(close_code(reply), close_unsupported_data); + + auto bin = frame{}; + bin.op = opcode::binary; + bin.masked = true; + bin.masking_key = 0x01020304u; + bin.payload = {std::byte{0x01}}; + auto stream2 = duplex_stream{frame_bytes(bin)}; + run_text_session(stream2, nullptr); + auto out2 = std::istringstream{stream2.output()}; + require_true(read_frame(out2, reply) == frame_read::ok); + check_eq(close_code(reply), close_unsupported_data); + }; + + tester::bdd::scenario("run_text_session rejects invalid UTF-8 with 1007, [net]") = [] { + auto bad = make_masked_text("x"sv); + bad.payload = {std::byte{0xFFu}}; + auto handler_ran = std::make_shared(false); + auto stream = duplex_stream{frame_bytes(bad)}; + run_text_session(stream, [handler_ran](std::string_view) { + *handler_ran = true; + return text_reply{}; + }); + + auto out = std::istringstream{stream.output()}; + auto reply = frame{}; + require_true(read_frame(out, reply) == frame_read::ok); + check_eq(close_code(reply), close_invalid_payload); + check_true(not *handler_ran); + }; + + tester::bdd::scenario("run_text_session rejects oversized frame with 1009, [net]") = [] { + // Header only: length > 1 MiB; session must close 1009 without running handler. + auto header = std::string{}; + header.push_back(static_cast(0x81)); // FIN + text + header.push_back(static_cast(0xFF)); // masked + 127 + const auto len = std::uint64_t{(1u << 20) + 1}; + for(int shift = 56; shift >= 0; shift -= 8) + header.push_back(static_cast((len >> shift) & 0xFFu)); + header.append("\x01\x02\x03\x04", 4); // masking key; no payload bytes + + auto handler_ran = std::make_shared(false); + auto stream = duplex_stream{std::move(header)}; + run_text_session(stream, [handler_ran](std::string_view) { + *handler_ran = true; + return text_reply{}; + }); + + auto out = std::istringstream{stream.output()}; + auto reply = frame{}; + require_true(read_frame(out, reply) == frame_read::ok); + check_eq(close_code(reply), close_message_too_big); + check_true(not *handler_ran); + }; + + tester::bdd::scenario("http server websocket echo upgrade, [net]") = [] { + if(not network_tests_enabled()) + return; + + auto server = std::make_shared(); + server->ws("/echo").ws([](std::string_view msg) { + return text_reply{std::string{msg}}; + }); + + std::promise bound; + auto bound_future = bound.get_future(); + std::thread server_thread{[server, &bound] { + try + { + server->listen("127.0.0.1"sv, "18090"sv, [&bound] { bound.set_value(); }); + } + catch(...) + { + try { bound.set_value(); } catch(...) {} + } + }}; + + using namespace std::chrono_literals; + require_true(bound_future.wait_for(3s) == std::future_status::ready); + std::this_thread::sleep_for(50ms); + + auto client = net::connect("127.0.0.1", "18090"); + client << "GET /echo HTTP/1.1" << net::crlf + << "Host: 127.0.0.1:18090" << net::crlf + << "Upgrade: websocket" << net::crlf + << "Connection: Upgrade" << net::crlf + << "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" << net::crlf + << "Sec-WebSocket-Version: 13" << net::crlf + << net::crlf << net::flush; + + auto version = ""s; + auto status_code = ""s; + auto reason = ""s; + client >> version >> status_code; + std::getline(client, reason); + check_eq(status_code, "101"s); + + auto hs = http::headers{}; + client >> hs >> net::crlf; + check_true(hs.contains("sec-websocket-accept")); + check_eq(hs["sec-websocket-accept"], "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="s); + + require_true(write_frame(client, make_masked_text("ping-me"sv))); + auto reply = frame{}; + require_true(read_frame(client, reply) == frame_read::ok); + check_eq(reply.op, opcode::text); + check_eq(payload_as_string(reply), "ping-me"s); + + require_true(write_frame(client, make_masked_close())); + auto close_reply = frame{}; + require_true(read_frame(client, close_reply) == frame_read::ok); + check_eq(close_reply.op, opcode::close); + + server->stop(); + if(server_thread.joinable()) + server_thread.join(); + }; + + tester::bdd::scenario("websocket::connect send/recv/close echo, [net]") = [] { + if(not network_tests_enabled()) + return; + + auto server = std::make_shared(); + server->ws("/events").ws([](std::string_view msg) { + return text_reply{std::string{msg}}; + }); + + std::promise bound; + auto bound_future = bound.get_future(); + std::thread server_thread{[server, &bound] { + try + { + server->listen("127.0.0.1"sv, "18091"sv, [&bound] { bound.set_value(); }); + } + catch(...) + { + try { bound.set_value(); } catch(...) {} + } + }}; + + using namespace std::chrono_literals; + require_true(bound_future.wait_for(3s) == std::future_status::ready); + std::this_thread::sleep_for(50ms); + + auto ws = websocket::connect("127.0.0.1"sv, "18091"sv, "/events"sv); + require_true(static_cast(ws)); + require_true(ws.send("hello"sv)); + auto reply = ws.recv(); + require_true(reply.has_value()); + check_eq(*reply, "hello"s); + ws.close(); + check_true(not ws); + + server->stop(); + if(server_thread.joinable()) + server_thread.join(); + }; + + tester::bdd::scenario("websocket::connect read_loop collects text, [net]") = [] { + if(not network_tests_enabled()) + return; + + auto server = std::make_shared(); + auto count = std::make_shared(0); + server->ws("/events").ws([count](std::string_view msg) { + ++(*count); + return text_reply{std::string{msg} + "-" + std::to_string(*count)}; + }); + + std::promise bound; + auto bound_future = bound.get_future(); + std::thread server_thread{[server, &bound] { + try + { + server->listen("127.0.0.1"sv, "18092"sv, [&bound] { bound.set_value(); }); + } + catch(...) + { + try { bound.set_value(); } catch(...) {} + } + }}; + + using namespace std::chrono_literals; + require_true(bound_future.wait_for(3s) == std::future_status::ready); + std::this_thread::sleep_for(50ms); + + auto ws = websocket::connect("127.0.0.1"sv, "18092"sv, "/events"sv); + require_true(ws.send("a"sv)); + require_true(ws.send("b"sv)); + + auto seen = std::vector{}; + ws.read_loop([&](std::string_view msg) { + seen.emplace_back(msg); + if(seen.size() == 2u) + ws.close(); + }); + require_true(seen.size() == 2u); + check_eq(seen[0], "a-1"s); + check_eq(seen[1], "b-2"s); + + server->stop(); + if(server_thread.joinable()) + server_thread.join(); + }; + + return true; +} + +const auto _ = register_websocket_tests(); diff --git a/net/net-websocket_frame.c++m b/net/net-websocket_frame.c++m new file mode 100644 index 0000000..f211f15 --- /dev/null +++ b/net/net-websocket_frame.c++m @@ -0,0 +1,336 @@ +export module net:websocket_frame; +import std; + +// RFC 6455 WebSocket framing (byte-oriented; no bitfield endian surprises). + +export namespace net::websocket { + +using namespace std::string_literals; +using namespace std::string_view_literals; + +enum class opcode : std::uint8_t +{ + continuation = 0x0, + text = 0x1, + binary = 0x2, + close = 0x8, + ping = 0x9, + pong = 0xA, +}; + +// RFC 6455 §7.4.1 close status codes (subset used by this library). +inline constexpr std::uint16_t close_normal = 1000; +inline constexpr std::uint16_t close_protocol_error = 1002; +inline constexpr std::uint16_t close_unsupported_data = 1003; // e.g. fragments / binary +inline constexpr std::uint16_t close_invalid_payload = 1007; // invalid UTF-8 text +inline constexpr std::uint16_t close_message_too_big = 1009; + +inline constexpr std::uint64_t max_frame_payload = 1u << 20; // 1 MiB + +enum class frame_read : std::uint8_t +{ + ok, + io_error, // truncated header/payload or stream fail + message_too_big, // declared length > max_frame_payload + protocol_error, // RSV, reserved opcode, or control-frame rules +}; + +struct frame +{ + bool fin = true; + opcode op = opcode::text; + bool masked = false; + std::uint32_t masking_key = 0; + std::vector payload; +}; + +inline bool is_control_opcode(opcode op) noexcept +{ + const auto v = static_cast(op); + return v >= 0x8u and v <= 0xAu; +} + +inline bool is_known_opcode(opcode op) noexcept +{ + switch(op) + { + case opcode::continuation: + case opcode::text: + case opcode::binary: + case opcode::close: + case opcode::ping: + case opcode::pong: + return true; + } + return false; +} + +inline std::uint16_t close_code(const frame& f) noexcept +{ + if(f.payload.size() < 2) + return 0; + return static_cast( + (std::to_integer(f.payload[0]) << 8) + | std::to_integer(f.payload[1])); +} + +// Strict UTF-8 (RFC 3629): reject overlongs, surrogates, and > U+10FFFF. +// No dependency on xson — WebSocket text validation stays in net. +inline bool is_valid_utf8(std::span bytes) noexcept +{ + std::size_t i = 0; + while(i < bytes.size()) + { + const auto b0 = std::to_integer(bytes[i]); + if(b0 <= 0x7Fu) + { + ++i; + continue; + } + + unsigned need = 0; + unsigned long cp = 0; + if((b0 & 0xE0u) == 0xC0u) + { + need = 1; + cp = b0 & 0x1Fu; + if(cp < 0x02u) // overlong 2-byte + return false; + } + else if((b0 & 0xF0u) == 0xE0u) + { + need = 2; + cp = b0 & 0x0Fu; + } + else if((b0 & 0xF8u) == 0xF0u) + { + need = 3; + cp = b0 & 0x07u; + if(cp > 0x04u) // would exceed U+10FFFF + return false; + } + else + return false; + + if(i + need >= bytes.size()) + return false; + + for(unsigned n = 1; n <= need; ++n) + { + const auto bx = std::to_integer(bytes[i + n]); + if((bx & 0xC0u) != 0x80u) + return false; + cp = (cp << 6) | (bx & 0x3Fu); + } + + if(need == 2) + { + if(cp < 0x800u) // overlong 3-byte + return false; + if(cp >= 0xD800u and cp <= 0xDFFFu) // UTF-16 surrogates + return false; + } + else if(need == 3) + { + if(cp < 0x10000u) // overlong 4-byte + return false; + if(cp > 0x10FFFFu) + return false; + } + + i += need + 1; + } + return true; +} + +inline bool is_valid_utf8(std::string_view text) noexcept +{ + return is_valid_utf8(std::span{ + reinterpret_cast(text.data()), + text.size()}); +} + +inline void apply_mask(std::span data, std::uint32_t key) noexcept +{ + const auto key_bytes = std::array{ + static_cast((key >> 24) & 0xFFu), + static_cast((key >> 16) & 0xFFu), + static_cast((key >> 8) & 0xFFu), + static_cast(key & 0xFFu)}; + for(std::size_t i = 0; i < data.size(); ++i) + data[i] ^= key_bytes[i % 4]; +} + +inline frame make_text_frame(std::string_view text) +{ + auto f = frame{}; + f.op = opcode::text; + f.payload.resize(text.size()); + std::memcpy(f.payload.data(), text.data(), text.size()); + return f; +} + +inline frame make_close_frame(std::span payload = {}) +{ + auto f = frame{}; + f.op = opcode::close; + f.payload.assign(payload.begin(), payload.end()); + return f; +} + +// Close frame carrying a 2-byte big-endian status code (RFC 6455 §5.5.1). +inline frame make_close_frame(std::uint16_t status_code) +{ + auto f = frame{}; + f.op = opcode::close; + f.payload.resize(2); + f.payload[0] = static_cast((status_code >> 8) & 0xFFu); + f.payload[1] = static_cast(status_code & 0xFFu); + return f; +} + +inline frame make_pong_frame(std::span payload) +{ + auto f = frame{}; + f.op = opcode::pong; + f.payload.assign(payload.begin(), payload.end()); + return f; +} + +inline std::string payload_as_string(const frame& f) +{ + return std::string{ + reinterpret_cast(f.payload.data()), + f.payload.size()}; +} + +inline bool write_frame(std::ostream& os, const frame& f) +{ + const auto len = f.payload.size(); + auto b0 = static_cast(static_cast(f.op) & 0x0Fu); + if(f.fin) + b0 |= 0x80u; + + auto b1 = static_cast(f.masked ? 0x80u : 0u); + if(len <= 125) + b1 |= static_cast(len); + else if(len <= 0xFFFFu) + b1 |= 126u; + else + b1 |= 127u; + + os.put(static_cast(b0)); + os.put(static_cast(b1)); + + if(len > 125 and len <= 0xFFFFu) + { + os.put(static_cast((len >> 8) & 0xFFu)); + os.put(static_cast(len & 0xFFu)); + } + else if(len > 0xFFFFu) + { + for(int shift = 56; shift >= 0; shift -= 8) + os.put(static_cast((len >> shift) & 0xFFu)); + } + + auto payload = f.payload; + if(f.masked) + { + apply_mask(payload, f.masking_key); + os.put(static_cast((f.masking_key >> 24) & 0xFFu)); + os.put(static_cast((f.masking_key >> 16) & 0xFFu)); + os.put(static_cast((f.masking_key >> 8) & 0xFFu)); + os.put(static_cast(f.masking_key & 0xFFu)); + } + + os.write(reinterpret_cast(payload.data()), static_cast(payload.size())); + os.flush(); + return static_cast(os); +} + +// Parse one frame. Structural RFC checks only (RSV, opcodes, control size/FIN, +// max length). Fragmentation / UTF-8 policy is applied by the session layer. +inline frame_read read_frame(std::istream& is, frame& f) +{ + f = frame{}; + const auto c0 = is.get(); + const auto c1 = is.get(); + if(not is) + return frame_read::io_error; + + const auto b0 = static_cast(c0); + const auto b1 = static_cast(c1); + f.fin = (b0 & 0x80u) != 0; + f.op = static_cast(b0 & 0x0Fu); + f.masked = (b1 & 0x80u) != 0; + auto len = static_cast(b1 & 0x7Fu); + + // §5.2: RSV1–3 must be 0 unless an extension was negotiated (we negotiate none). + if((b0 & 0x70u) != 0) + return frame_read::protocol_error; + + if(not is_known_opcode(f.op)) + return frame_read::protocol_error; + + if(len == 126) + { + const auto hi = is.get(); + const auto lo = is.get(); + if(not is) + return frame_read::io_error; + len = (static_cast(static_cast(hi)) << 8) + | static_cast(lo); + } + else if(len == 127) + { + len = 0; + for(int i = 0; i < 8; ++i) + { + const auto b = is.get(); + if(not is) + return frame_read::io_error; + len = (len << 8) | static_cast(b); + } + } + + // §5.5: control frames must be FIN and ≤ 125 bytes. + if(is_control_opcode(f.op)) + { + if(not f.fin or len > 125u) + return frame_read::protocol_error; + } + + if(len > max_frame_payload) + return frame_read::message_too_big; + + if(f.masked) + { + std::array key_bytes{}; + for(auto& b : key_bytes) + { + const auto c = is.get(); + if(not is) + return frame_read::io_error; + b = static_cast(c); + } + f.masking_key = (static_cast(key_bytes[0]) << 24) + | (static_cast(key_bytes[1]) << 16) + | (static_cast(key_bytes[2]) << 8) + | static_cast(key_bytes[3]); + } + + f.payload.resize(static_cast(len)); + if(len > 0) + { + is.read(reinterpret_cast(f.payload.data()), static_cast(len)); + if(not is or static_cast(is.gcount()) != len) + return frame_read::io_error; + } + + if(f.masked) + apply_mask(f.payload, f.masking_key); + + return frame_read::ok; +} + +} // namespace net::websocket diff --git a/net/net.c++m b/net/net.c++m index 5062722..ac0ee1c 100644 --- a/net/net.c++m +++ b/net/net.c++m @@ -20,3 +20,5 @@ export import :structured_log_stream; export import :uri; export import :utils; export import :uuid; +export import :websocket; +export import :websocket_frame;