morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
ws_handshake.hpp
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4#include <poll.h>
5
6#include <array>
7#include <chrono>
8#include <core/Base64.hpp>
9#include <cstdint>
10#include <random>
11#include <stdexcept>
12#include <string>
13#include <string_view>
14#include <system_error>
15#include <vector>
16
17#include "sha1.hpp"
18#include "tcp_socket.hpp"
19
20namespace morph::net::detail {
21
22/// @brief The RFC 6455 §1.3 magic GUID appended to a client's key before hashing.
23inline constexpr std::string_view kWebSocketGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
24
25/// @brief Computes the `Sec-WebSocket-Accept` value for a given client key.
26/// @param clientKey The `Sec-WebSocket-Key` header value sent by the client.
27/// @return `base64(sha1(clientKey + kWebSocketGuid))`, per RFC 6455 §1.3.
28inline std::string computeAcceptKey(std::string_view clientKey) {
29 std::string concatenated{clientKey};
30 concatenated += kWebSocketGuid;
31 auto digest = sha1Digest(concatenated);
32 return ::core::base64::encode(digest.begin(), digest.end());
33}
34
35/// @brief Generates a random 16-byte `Sec-WebSocket-Key`, base64-encoded.
36/// @return A 24-character base64 string suitable for the `Sec-WebSocket-Key` header.
37inline std::string generateClientKey() {
38 static thread_local std::mt19937 gen{std::random_device{}()};
39 std::uniform_int_distribution<int> dist{0, 255};
40 std::array<std::uint8_t, 16> raw{};
41 for (auto& b : raw) {
42 b = static_cast<std::uint8_t>(dist(gen));
43 }
44 return ::core::base64::encode(raw.begin(), raw.end());
45}
46
47/// @brief The pieces of a `ws://` URL relevant to opening a socket + handshake.
48struct ParsedWsUrl {
49 /// @brief Hostname or numeric address to connect to.
50 std::string host;
51 /// @brief TCP port to connect to.
52 std::uint16_t port{0};
53 /// @brief HTTP request path (defaults to `/` when the URL has none).
54 std::string path;
55};
56
57/// @brief Parses a `ws://host:port[/path]` URL.
58/// @param url The URL to parse.
59/// @return The parsed host/port/path.
60/// @throws std::runtime_error if @p url is `wss://` (unsupported — see
61/// `docs/spec/core/backend.md`'s `morph::net` section), does not start
62/// with `ws://`, has no explicit port, or has an invalid port.
63inline ParsedWsUrl parseWsUrl(std::string_view url) {
64 constexpr std::string_view kWsScheme = "ws://";
65 constexpr std::string_view kWssScheme = "wss://";
66 if (url.substr(0, kWssScheme.size()) == kWssScheme) {
67 throw std::runtime_error(
68 "parseWsUrl: wss:// is not supported by morph::net (plaintext ws:// only; TLS is future work)");
69 }
70 if (url.substr(0, kWsScheme.size()) != kWsScheme) {
71 throw std::runtime_error("parseWsUrl: URL must start with ws://");
72 }
73 std::string_view rest = url.substr(kWsScheme.size());
74 auto slashPos = rest.find('/');
75 std::string_view hostPort = (slashPos == std::string_view::npos) ? rest : rest.substr(0, slashPos);
76 std::string path = (slashPos == std::string_view::npos) ? "/" : std::string(rest.substr(slashPos));
77 auto colonPos = hostPort.rfind(':');
78 if (colonPos == std::string_view::npos || colonPos == 0) {
79 throw std::runtime_error("parseWsUrl: URL must include an explicit host and port (e.g. ws://host:1234)");
80 }
81 std::string host{hostPort.substr(0, colonPos)};
82 std::string_view portStr = hostPort.substr(colonPos + 1);
83 if (portStr.empty()) {
84 throw std::runtime_error("parseWsUrl: invalid port in URL");
85 }
86 int portVal = 0;
87 for (char ch : portStr) {
88 if (ch < '0' || ch > '9') {
89 throw std::runtime_error("parseWsUrl: invalid port in URL");
90 }
91 portVal = (portVal * 10) + (ch - '0');
92 if (portVal > 65535) {
93 throw std::runtime_error("parseWsUrl: port out of range");
94 }
95 }
96 if (portVal == 0) {
97 throw std::runtime_error("parseWsUrl: invalid port in URL");
98 }
99 return ParsedWsUrl{std::move(host), static_cast<std::uint16_t>(portVal), std::move(path)};
100}
101
102/// @brief Builds the client's HTTP/1.1 Upgrade request.
103/// @param url The target host/port/path.
104/// @param key The `Sec-WebSocket-Key` to send (see `generateClientKey`).
105/// @return The full request, including the trailing blank-line terminator.
106inline std::string buildClientHandshakeRequest(const ParsedWsUrl& url, std::string_view key) {
107 std::string req;
108 req += "GET " + url.path + " HTTP/1.1\r\n";
109 // static_cast<unsigned>: see TcpSocket::connect — std::to_string has no
110 // uint16_t overload, and the promotion GCC would pick trips -Wsign-promo.
111 req += "Host: " + url.host + ":" + std::to_string(static_cast<unsigned>(url.port)) + "\r\n";
112 req += "Upgrade: websocket\r\n";
113 req += "Connection: Upgrade\r\n";
114 req += "Sec-WebSocket-Key: " + std::string(key) + "\r\n";
115 req += "Sec-WebSocket-Version: 13\r\n";
116 req += "\r\n";
117 return req;
118}
119
120/// @brief Builds the server's HTTP/1.1 101 Switching Protocols response.
121/// @param clientKey The `Sec-WebSocket-Key` value the client sent.
122/// @return The full response, including the trailing blank-line terminator.
123inline std::string buildServerHandshakeResponse(std::string_view clientKey) {
124 std::string resp;
125 resp += "HTTP/1.1 101 Switching Protocols\r\n";
126 resp += "Upgrade: websocket\r\n";
127 resp += "Connection: Upgrade\r\n";
128 resp += "Sec-WebSocket-Accept: " + computeAcceptKey(clientKey) + "\r\n";
129 resp += "\r\n";
130 return resp;
131}
132
133namespace ws_handshake_impl {
134
135inline std::string toLowerAscii(std::string_view s) {
136 std::string out{s};
137 for (char& c : out) {
138 if (c >= 'A' && c <= 'Z') {
139 c = static_cast<char>(c - 'A' + 'a');
140 }
141 }
142 return out;
143}
144
145inline std::vector<std::string_view> splitLines(std::string_view raw) {
146 std::vector<std::string_view> lines;
147 std::size_t start = 0;
148 while (start < raw.size()) {
149 auto pos = raw.find("\r\n", start);
150 if (pos == std::string_view::npos) {
151 lines.push_back(raw.substr(start));
152 break;
153 }
154 lines.push_back(raw.substr(start, pos - start));
155 start = pos + 2;
156 }
157 return lines;
158}
159
160inline std::string_view trimLeadingSpace(std::string_view s) {
161 std::size_t i = 0;
162 while (i < s.size() && s[i] == ' ') {
163 ++i;
164 }
165 return s.substr(i);
166}
167
168} // namespace ws_handshake_impl
169
170/// @brief The pieces of a client handshake request relevant to completing it.
171struct ClientHandshakeRequest {
172 /// @brief The `Sec-WebSocket-Key` header value.
173 std::string key;
174 /// @brief The request-line path (e.g. `/`).
175 std::string path;
176};
177
178/// @brief Parses a client's HTTP/1.1 Upgrade request (header block only, no
179/// trailing blank line).
180/// @param headerBlock The request's header lines, joined by `\r\n`, with no
181/// trailing `\r\n\r\n` (see `readHttpHeaderBlock`).
182/// @return The extracted key and path.
183/// @throws std::runtime_error if the request line is not a `GET`, or the
184/// `Sec-WebSocket-Key`/`Upgrade` headers are missing.
185inline ClientHandshakeRequest parseClientHandshakeRequest(std::string_view headerBlock) {
186 auto lines = ws_handshake_impl::splitLines(headerBlock);
187 if (lines.empty()) {
188 throw std::runtime_error("parseClientHandshakeRequest: empty request");
189 }
190 std::string_view requestLine = lines.front();
191 if (requestLine.substr(0, 4) != "GET ") {
192 throw std::runtime_error("parseClientHandshakeRequest: expected a GET request line");
193 }
194 std::size_t const pathStart = 4;
195 std::size_t const pathEnd = requestLine.find(' ', pathStart);
196 if (pathEnd == std::string_view::npos) {
197 throw std::runtime_error("parseClientHandshakeRequest: malformed request line");
198 }
199 std::string path{requestLine.substr(pathStart, pathEnd - pathStart)};
200
201 std::string key;
202 bool hasUpgrade = false;
203 for (std::size_t i = 1; i < lines.size(); ++i) {
204 std::string_view line = lines[i];
205 auto colon = line.find(':');
206 if (colon == std::string_view::npos) {
207 continue;
208 }
209 std::string name = ws_handshake_impl::toLowerAscii(line.substr(0, colon));
210 std::string_view value = ws_handshake_impl::trimLeadingSpace(line.substr(colon + 1));
211 if (name == "sec-websocket-key") {
212 key = std::string(value);
213 } else if (name == "upgrade") {
214 hasUpgrade = true;
215 }
216 }
217 if (key.empty()) {
218 throw std::runtime_error("parseClientHandshakeRequest: missing Sec-WebSocket-Key header");
219 }
220 if (!hasUpgrade) {
221 throw std::runtime_error("parseClientHandshakeRequest: missing Upgrade header");
222 }
223 return ClientHandshakeRequest{std::move(key), std::move(path)};
224}
225
226/// @brief Verifies the server's HTTP/1.1 101 response against the key the
227/// client sent.
228/// @param headerBlock The response's header lines, joined by `\r\n`, with no
229/// trailing `\r\n\r\n` (see `readHttpHeaderBlock`).
230/// @param clientKey The `Sec-WebSocket-Key` this client sent in its request.
231/// @throws std::runtime_error if the status line is not `101`, or
232/// `Sec-WebSocket-Accept` does not match `computeAcceptKey(clientKey)`.
233inline void verifyServerHandshakeResponse(std::string_view headerBlock, std::string_view clientKey) {
234 auto lines = ws_handshake_impl::splitLines(headerBlock);
235 if (lines.empty()) {
236 throw std::runtime_error("verifyServerHandshakeResponse: empty response");
237 }
238 std::string_view statusLine = lines.front();
239 if (statusLine.find(" 101 ") == std::string_view::npos) {
240 throw std::runtime_error("verifyServerHandshakeResponse: server did not return 101 Switching Protocols: " +
241 std::string(statusLine));
242 }
243 std::string accept;
244 for (std::size_t i = 1; i < lines.size(); ++i) {
245 std::string_view line = lines[i];
246 auto colon = line.find(':');
247 if (colon == std::string_view::npos) {
248 continue;
249 }
250 std::string name = ws_handshake_impl::toLowerAscii(line.substr(0, colon));
251 if (name == "sec-websocket-accept") {
252 accept = std::string(ws_handshake_impl::trimLeadingSpace(line.substr(colon + 1)));
253 }
254 }
255 std::string expected = computeAcceptKey(clientKey);
256 if (accept != expected) {
257 throw std::runtime_error("verifyServerHandshakeResponse: Sec-WebSocket-Accept mismatch");
258 }
259}
260
261/// @brief The result of reading an HTTP header block up through `\r\n\r\n`.
262struct HandshakeReadResult {
263 /// @brief Header lines, joined by `\r\n`, **not** including the final `\r\n\r\n`.
264 std::string header;
265 /// @brief Any bytes read past the header terminator (belong to the first
266 /// WebSocket frame(s), if the peer pipelined them with the handshake).
267 std::string leftover;
268};
269
270/// Blocks until @p socket is readable or @p deadline passes, on the same
271/// single-budget wait `TcpSocket::connect()` uses (`pollUntil`). Split out of
272/// `readHttpHeaderBlock` so that function's own branching stays readable.
273/// @param socket Socket to wait on.
274/// @param deadline Point after which the handshake counts as timed out.
275/// @throws std::runtime_error once @p deadline passes, or on a `poll()` error
276/// other than `EINTR`.
277inline void waitReadableUntil(const TcpSocket& socket, std::chrono::steady_clock::time_point deadline) {
278 auto const result = pollUntil(socket.nativeHandle(), POLLIN, deadline);
279 if (result.outcome == PollOutcome::kTimedOut) {
280 throw std::runtime_error("readHttpHeaderBlock: handshake timed out");
281 }
282 if (result.outcome == PollOutcome::kFailed) {
283 throw std::runtime_error("readHttpHeaderBlock: poll failed: " + std::system_category().message(result.error));
284 }
285 // kReady: readable (or hung up) -- the caller's recvSome will not block.
286}
287
288/// @brief Reads bytes from @p socket until the `\r\n\r\n` header terminator.
289/// @param socket Connected socket to read from.
290/// @param timeout Bound on the *whole* read, from the first byte to the
291/// terminator; zero disables it (block indefinitely). Enforced
292/// by polling for readiness with the remaining budget before
293/// each `recvSome` -- deliberately not `SO_RCVTIMEO`, which
294/// restarts on every `recv` and so only bounds each individual
295/// read: a peer trickling one byte per interval could then
296/// stretch the whole handshake to roughly @p timeout times the
297/// 64 KiB header cap below. A single deadline across the loop
298/// keeps @p timeout an honest bound on the total.
299/// @return The header text and any leftover bytes read past the terminator.
300/// @throws std::runtime_error if the peer closes before completing the header,
301/// if the header exceeds a ~64 KiB safety cap (the check runs before
302/// each `recvSome`, so the true bound is 64 KiB rounded up to the next
303/// read chunk -- tests/net/test_handshake_over_socket.cpp says the
304/// same), or if @p timeout elapses before the terminator arrives.
305inline HandshakeReadResult readHttpHeaderBlock(TcpSocket& socket,
306 std::chrono::milliseconds timeout = std::chrono::milliseconds{0}) {
307 std::string buf;
308 char chunk[4096];
309 bool const bounded = timeout.count() > 0;
310 auto const deadline = std::chrono::steady_clock::now() + timeout;
311 for (;;) {
312 auto pos = buf.find("\r\n\r\n");
313 if (pos != std::string::npos) {
314 HandshakeReadResult result;
315 result.header = buf.substr(0, pos);
316 result.leftover = buf.substr(pos + 4);
317 return result;
318 }
319 if (buf.size() > 64u * 1024u) {
320 throw std::runtime_error("readHttpHeaderBlock: header exceeds maximum size (64 KiB)");
321 }
322 if (bounded) {
323 waitReadableUntil(socket, deadline);
324 }
325 std::size_t got = socket.recvSome(chunk, sizeof(chunk));
326 if (got == 0) {
327 throw std::runtime_error("readHttpHeaderBlock: peer closed connection during handshake");
328 }
329 buf.append(chunk, got);
330 }
331}
332
333/// @brief Performs the client side of the WebSocket handshake over @p socket.
334/// @param socket Already-connected socket.
335/// @param url Parsed target URL (host/port/path) to send in the request.
336/// @return Leftover bytes read past the response header — feed these into a
337/// `WsFrameReader` before reading further from the socket.
338/// @throws std::runtime_error if the server's response is not a valid 101
339/// Switching Protocols with a matching `Sec-WebSocket-Accept`.
340inline std::string performClientHandshake(TcpSocket& socket, const ParsedWsUrl& url) {
341 std::string key = generateClientKey();
342 std::string request = buildClientHandshakeRequest(url, key);
343 socket.sendAll(request.data(), request.size());
344 HandshakeReadResult const result = readHttpHeaderBlock(socket);
345 verifyServerHandshakeResponse(result.header, key);
346 return result.leftover;
347}
348
349/// @brief Performs the server side of the WebSocket handshake over @p socket.
350/// @param socket Freshly accepted socket.
351/// @param handshakeTimeout Bound on the whole handshake read; see
352/// `readHttpHeaderBlock`. Zero disables it.
353/// @return Leftover bytes read past the request header — feed these into a
354/// `WsFrameReader` before reading further from the socket.
355/// @throws std::runtime_error if the client's request is malformed or missing
356/// the required headers, or if @p handshakeTimeout elapses first.
357inline std::string performServerHandshake(TcpSocket& socket,
358 std::chrono::milliseconds handshakeTimeout = std::chrono::milliseconds{0}) {
359 HandshakeReadResult const result = readHttpHeaderBlock(socket, handshakeTimeout);
360 ClientHandshakeRequest req = parseClientHandshakeRequest(result.header);
361 std::string response = buildServerHandshakeResponse(req.key);
362 socket.sendAll(response.data(), response.size());
363 return result.leftover;
364}
365
366} // namespace morph::net::detail
Command host(std::string const &name, AnyHandle const &args={})
host: a command the application registered with the client.
Definition author.hpp:2870
void request(Store< ViewState, Msg > &store, bridge::BridgeHandler< Model, Sharing > &handler, async::CallbackScope const &scope, Action action, ToMsg toMsg, ToFailMsg toFailMsg)
Executes action and sends its result, or its failure, to store as one message.
Definition store.hpp:202