DeterministicESPAsyncWebServer v6.27.1
Zero-allocation, bounded-execution async HTTP server for ESP32
Loading...
Searching...
No Matches
ws_client.cpp
Go to the documentation of this file.
1// Copyright (C) 2026 Douglas Quigg (dstroy0) <dquigg123@gmail.com>
2// SPDX-License-Identifier: AGPL-3.0-or-later
3
4/**
5 * @file ws_client.cpp
6 * @brief WebSocket (RFC 6455) client codec (host-testable) + the raw-lwIP /
7 * mbedTLS persistent transport (ESP32 only).
8 */
9
11
12#if DETWS_ENABLE_WS_CLIENT
13
16#include <stdio.h>
17#include <string.h>
18
19// ---------------------------------------------------------------------------
20// Pure codec (host-testable)
21// ---------------------------------------------------------------------------
22
23static const char WS_MAGIC[] = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
24
25void ws_client_accept_for_key(const char *key_b64, char *out, size_t out_cap)
26{
27 if (!out || out_cap == 0)
28 return;
29 out[0] = '\0';
30 if (!key_b64)
31 return;
32 char concat[64];
33 size_t klen = strnlen(key_b64, sizeof(concat));
34 size_t mlen = sizeof(WS_MAGIC) - 1;
35 if (klen + mlen >= sizeof(concat))
36 return;
37 memcpy(concat, key_b64, klen);
38 memcpy(concat + klen, WS_MAGIC, mlen);
39 uint8_t digest[SHA1_DIGEST_LEN];
40 sha1((const uint8_t *)concat, klen + mlen, digest);
41 if (out_cap < 29) // 28 base64 chars + NUL
42 return;
43 base64_encode(digest, SHA1_DIGEST_LEN, out);
44}
45
46size_t ws_client_build_handshake(uint8_t *out, size_t cap, const char *host, const char *path, const char *key_b64,
47 const char *subprotocol)
48{
49 if (!out || !host || !path || !key_b64)
50 return 0;
51 // A Sec-WebSocket-Protocol offer is emitted only when a subprotocol is requested (e.g. "wamp.2.json" for
52 // WAMP-over-WebSocket); the server echoes the one it selected. Null/empty omits the header entirely.
53 int n;
54 if (subprotocol && subprotocol[0])
55 n = snprintf((char *)out, cap,
56 "GET %s HTTP/1.1\r\n"
57 "Host: %s\r\n"
58 "Upgrade: websocket\r\n"
59 "Connection: Upgrade\r\n"
60 "Sec-WebSocket-Key: %s\r\n"
61 "Sec-WebSocket-Protocol: %s\r\n"
62 "Sec-WebSocket-Version: 13\r\n\r\n",
63 path, host, key_b64, subprotocol);
64 else
65 n = snprintf((char *)out, cap,
66 "GET %s HTTP/1.1\r\n"
67 "Host: %s\r\n"
68 "Upgrade: websocket\r\n"
69 "Connection: Upgrade\r\n"
70 "Sec-WebSocket-Key: %s\r\n"
71 "Sec-WebSocket-Version: 13\r\n\r\n",
72 path, host, key_b64);
73 if (n < 0 || (size_t)n >= cap)
74 return 0;
75 return (size_t)n;
76}
77
78// Case-insensitive header-value lookup within [buf, buf+len); returns a pointer
79// to the value (past "name:" and OWS) and its length via *vlen, or nullptr.
80static const char *find_header(const uint8_t *buf, size_t len, const char *name, size_t *vlen)
81{
82 size_t nlen = strnlen(name, len + 1);
83 const uint8_t *p = buf;
84 const uint8_t *end = buf + len;
85 while (p + nlen + 1 < end)
86 {
87 if (strncasecmp((const char *)p, name, nlen) == 0 && p[nlen] == ':')
88 {
89 const uint8_t *v = p + nlen + 1;
90 while (v < end && (*v == ' ' || *v == '\t'))
91 v++;
92 const uint8_t *e = v;
93 while (e < end && *e != '\r' && *e != '\n')
94 e++;
95 *vlen = (size_t)(e - v);
96 return (const char *)v;
97 }
98 while (p < end && *p != '\n')
99 p++;
100 if (p < end)
101 p++;
102 }
103 return nullptr;
104}
105
106bool ws_client_check_response(const uint8_t *buf, size_t len, const char *expected_accept)
107{
108 if (!buf || len < 12 || !expected_accept)
109 return false;
110 // Status line must be an HTTP 101.
111 const uint8_t *eol = (const uint8_t *)memchr(buf, '\n', len);
112 if (!eol)
113 return false;
114 bool ok101 = false;
115 for (const uint8_t *q = buf; q + 3 < eol; q++)
116 if (q[0] == '1' && q[1] == '0' && q[2] == '1')
117 {
118 ok101 = true;
119 break;
120 }
121 if (!ok101)
122 return false;
123 size_t vlen = 0;
124 const char *acc = find_header(buf, len, "Sec-WebSocket-Accept", &vlen);
125 if (!acc)
126 return false;
127 return vlen == strnlen(expected_accept, vlen + 1) && memcmp(acc, expected_accept, vlen) == 0;
128}
129
130size_t ws_client_build_frame(uint8_t *out, size_t cap, WsClientOpcode opcode, const uint8_t *payload, size_t len,
131 const uint8_t mask[4])
132{
133 if (!out || !mask)
134 return 0;
135 size_t hdr = 2 + 4; // byte0 + len-byte + 4-byte mask (short form)
136 if (len >= 126 && len < 65536)
137 hdr += 2;
138 else if (len >= 65536)
139 hdr += 8;
140 if (hdr + len > cap)
141 return 0;
142
143 size_t i = 0;
144 out[i++] = (uint8_t)(0x80 | static_cast<uint8_t>(opcode)); // FIN + opcode (all opcodes <= 0x0A)
145 if (len < 126)
146 out[i++] = (uint8_t)(0x80 | len);
147 else if (len < 65536)
148 {
149 out[i++] = 0x80 | 126;
150 out[i++] = (uint8_t)(len >> 8);
151 out[i++] = (uint8_t)(len & 0xFF);
152 }
153 else
154 {
155 out[i++] = 0x80 | 127;
156 for (int s = 56; s >= 0; s -= 8)
157 out[i++] = (uint8_t)((uint64_t)len >> s);
158 }
159 memcpy(out + i, mask, 4);
160 i += 4;
161 for (size_t j = 0; j < len; j++)
162 out[i + j] = (uint8_t)(payload[j] ^ mask[j & 3]);
163 return i + len;
164}
165
166bool ws_client_parse_frame(const uint8_t *buf, size_t avail, uint8_t *opcode, bool *fin, size_t *payload_off,
167 size_t *payload_len, size_t *consumed)
168{
169 if (!buf || avail < 2)
170 return false;
171 uint8_t b0 = buf[0];
172 uint8_t b1 = buf[1];
173 bool masked = (b1 & 0x80) != 0;
174 uint64_t len = b1 & 0x7F;
175 size_t off = 2;
176 if (len == 126)
177 {
178 if (avail < off + 2)
179 return false;
180 len = ((uint64_t)buf[off] << 8) | buf[off + 1];
181 off += 2;
182 }
183 else if (len == 127)
184 {
185 if (avail < off + 8)
186 return false;
187 uint64_t v = 0;
188 for (int s = 0; s < 8; s++)
189 v = (v << 8) | buf[off + s];
190 if (v > 0xFFFFFFFFu) // absurd frame length on a constrained device
191 return false;
192 len = v;
193 off += 8;
194 }
195 if (masked)
196 off += 4; // server frames should not be masked, but stay aligned if they are
197 if (avail < off + (size_t)len)
198 return false;
199 *opcode = (uint8_t)(b0 & 0x0F);
200 *fin = (b0 & 0x80) != 0;
201 *payload_off = off;
202 *payload_len = (size_t)len;
203 *consumed = off + (size_t)len;
204 return true;
205}
206
207// ---------------------------------------------------------------------------
208// Transport (ESP32 only): persistent raw-lwIP client + RFC 6455 framing,
209// with wss:// over a persistent client TLS session (det_tls csess).
210// ---------------------------------------------------------------------------
211#if defined(ARDUINO)
212
213#include "network_drivers/transport/client.h" // shared outbound TCP client (L4)
214#include <Arduino.h>
215#include <esp_system.h> // esp_fill_random (per-frame masking key)
216
217#if DETWS_ENABLE_WS_CLIENT_TLS
219#include <mbedtls/ssl.h>
220#endif
221
222#ifdef DETWS_WS_CLIENT_DEBUG
223#define WSC_DBG(...) printf(__VA_ARGS__)
224#else
225#define WSC_DBG(...) ((void)0)
226#endif
227
228// All WebSocket-client connection state, owned by one instance (internal linkage): one server
229// at a time, all static / no heap. Grouped so it is one named owner, unreachable cross-TU.
230struct WsClientCtx
231{
232 WsClientMessageCb cb;
233 int cid = -1; // outbound connection id (det_client pool)
234 volatile bool closed; // peer closed / error (set when the pump sees it)
235 bool ws_up;
236 bool use_tls;
237
238 // Inbound plaintext ring, fed by a pump in the loop: from det_client_read for
239 // plain ws, from the TLS session (det_tls_csess_read) for wss.
240 uint8_t rx[DETWS_WS_CLIENT_BUF_SIZE];
241 volatile size_t rx_head;
242 volatile size_t rx_tail;
243 uint8_t pkt[DETWS_WS_CLIENT_BUF_SIZE]; // a frame copied out to parse
244 uint8_t tx[DETWS_WS_CLIENT_BUF_SIZE]; // outgoing frame scratch
245
246 // Fragmented-message reassembly (continuation frames -> one delivered message).
247 uint8_t msg[DETWS_WS_CLIENT_BUF_SIZE];
248 size_t msg_len;
249 uint8_t msg_op;
250};
251static WsClientCtx s_wsc;
252
253static inline size_t ring_avail()
254{
255 return (s_wsc.rx_head + sizeof(s_wsc.rx) - s_wsc.rx_tail) % sizeof(s_wsc.rx);
256}
257static inline uint8_t ring_peek(size_t i)
258{
259 return s_wsc.rx[(s_wsc.rx_tail + i) % sizeof(s_wsc.rx)];
260}
261static inline void ring_advance(size_t n)
262{
263 s_wsc.rx_tail = (s_wsc.rx_tail + n) % sizeof(s_wsc.rx);
264}
265static void ring_copy(uint8_t *dst, size_t n)
266{
267 for (size_t i = 0; i < n; i++)
268 dst[i] = s_wsc.rx[(s_wsc.rx_tail + i) % sizeof(s_wsc.rx)];
269}
270
271// --- transport over the shared outbound client (det_client) ---
272
273static bool ws_tx_plain(const uint8_t *data, size_t len)
274{
275 return det_client_send(s_wsc.cid, data, len);
276}
277
278// Drain plaintext wire bytes from the client into the s_wsc.rx ring (plain ws).
279static void ws_pump_plain()
280{
281 uint8_t tmp[256];
282 for (;;)
283 {
284 size_t freey = (sizeof(s_wsc.rx) - 1) - ring_avail();
285 if (freey == 0)
286 break;
287 size_t want = freey < sizeof(tmp) ? freey : sizeof(tmp);
288 size_t n = det_client_read(s_wsc.cid, tmp, want);
289 if (n == 0)
290 {
291 if (det_client_is_closed(s_wsc.cid))
292 s_wsc.closed = true;
293 break;
294 }
295 for (size_t i = 0; i < n; i++)
296 {
297 s_wsc.rx[s_wsc.rx_head] = tmp[i];
298 s_wsc.rx_head = (s_wsc.rx_head + 1) % sizeof(s_wsc.rx);
299 }
300 }
301}
302
303#if DETWS_ENABLE_WS_CLIENT_TLS
304// TLS BIO over the shared client: write ciphertext through the pool, read
305// ciphertext by draining the client's wire ring.
306static int ws_tls_send(void *ctx, const unsigned char *buf, size_t len)
307{
308 (void)ctx;
309 size_t cap = len > 0xFFFF ? 0xFFFF : len;
310 return det_client_send(s_wsc.cid, buf, cap) ? (int)cap : MBEDTLS_ERR_SSL_WANT_WRITE;
311}
312static int ws_tls_recv(void *ctx, unsigned char *buf, size_t len)
313{
314 (void)ctx;
315 size_t n = det_client_read(s_wsc.cid, buf, len);
316 if (n == 0)
317 return det_client_is_closed(s_wsc.cid) ? 0 : MBEDTLS_ERR_SSL_WANT_READ;
318 return (int)n;
319}
320static void ws_pump_tls()
321{
322 uint8_t tmp[256];
323 for (;;)
324 {
325 size_t freey = (sizeof(s_wsc.rx) - 1) - ring_avail();
326 if (freey == 0)
327 break;
328 size_t want = freey < sizeof(tmp) ? freey : sizeof(tmp);
329 int n = det_tls_csess_read(tmp, want);
330 if (n <= 0)
331 {
332 if (n < 0)
333 s_wsc.closed = true;
334 break;
335 }
336 for (int i = 0; i < n; i++)
337 {
338 s_wsc.rx[s_wsc.rx_head] = tmp[i];
339 s_wsc.rx_head = (s_wsc.rx_head + 1) % sizeof(s_wsc.rx);
340 }
341 }
342}
343#endif // DETWS_ENABLE_WS_CLIENT_TLS
344
345// Send already-framed bytes (plaintext or TLS-encrypted per the mode).
346static bool ws_tx(const uint8_t *data, size_t len)
347{
348#if DETWS_ENABLE_WS_CLIENT_TLS
349 if (s_wsc.use_tls)
350 return det_tls_csess_write(data, len) == (int)len;
351#endif
352 return ws_tx_plain(data, len);
353}
354
355// Frame and send a message with a fresh random masking key (RFC 6455 client rule).
356static bool ws_send_frame(WsClientOpcode opcode, const uint8_t *payload, size_t len)
357{
358 if (!s_wsc.ws_up)
359 return false;
360 uint8_t mask[4];
361 esp_fill_random(mask, 4);
362 size_t n = ws_client_build_frame(s_wsc.tx, sizeof(s_wsc.tx), opcode, payload, len, mask);
363 return n && ws_tx(s_wsc.tx, n);
364}
365
366static void ws_close_tcp()
367{
368#if DETWS_ENABLE_WS_CLIENT_TLS
369 if (s_wsc.use_tls)
370 det_tls_csess_end();
371#endif
372 if (s_wsc.cid >= 0)
373 det_client_close(s_wsc.cid);
374 s_wsc.cid = -1;
375 s_wsc.ws_up = false;
376}
377
378static void deliver(uint8_t op, const uint8_t *payload, size_t len)
379{
380 if (s_wsc.cb && (op == (uint8_t)WsClientOpcode::WSC_OP_TEXT || op == (uint8_t)WsClientOpcode::WSC_OP_BINARY))
381 s_wsc.cb(op, payload, len);
382}
383
384// Dispatch one parsed frame (handles fragmentation, ping/pong, close).
385static void handle_frame(uint8_t op, bool fin, const uint8_t *payload, size_t len)
386{
387 switch ((WsClientOpcode)op)
388 {
389 case WsClientOpcode::WSC_OP_TEXT:
390 case WsClientOpcode::WSC_OP_BINARY:
391 if (fin)
392 {
393 deliver(op, payload, len); // common case: unfragmented
394 }
395 else
396 {
397 s_wsc.msg_op = op; // first fragment
398 s_wsc.msg_len = len < sizeof(s_wsc.msg) ? len : sizeof(s_wsc.msg);
399 memcpy(s_wsc.msg, payload, s_wsc.msg_len);
400 }
401 break;
402 case WsClientOpcode::WSC_OP_CONT:
403 if (s_wsc.msg_len + len <= sizeof(s_wsc.msg))
404 {
405 memcpy(s_wsc.msg + s_wsc.msg_len, payload, len);
406 s_wsc.msg_len += len;
407 }
408 if (fin)
409 {
410 deliver(s_wsc.msg_op, s_wsc.msg, s_wsc.msg_len);
411 s_wsc.msg_len = 0;
412 }
413 break;
414 case WsClientOpcode::WSC_OP_PING:
415 ws_send_frame(WsClientOpcode::WSC_OP_PONG, payload, len); // echo the application data
416 break;
417 case WsClientOpcode::WSC_OP_CLOSE:
418 ws_send_frame(WsClientOpcode::WSC_OP_CLOSE, nullptr, 0);
419 s_wsc.closed = true;
420 break;
421 case WsClientOpcode::WSC_OP_PONG:
422 default:
423 break;
424 }
425}
426
427static void process_rx()
428{
429#if DETWS_ENABLE_WS_CLIENT_TLS
430 if (s_wsc.use_tls)
431 ws_pump_tls();
432 else
433#endif
434 ws_pump_plain();
435 for (;;)
436 {
437 size_t avail = ring_avail();
438 if (avail < 2)
439 return;
440 // Peek the header bytes (a frame header is at most 14 bytes); parse_frame
441 // reads only the header and uses the real ring count to test completeness.
442 uint8_t hdr[14];
443 size_t hn = avail < sizeof(hdr) ? avail : sizeof(hdr);
444 for (size_t i = 0; i < hn; i++)
445 hdr[i] = ring_peek(i);
446 uint8_t op;
447 bool fin;
448 size_t off;
449 size_t plen;
450 size_t consumed;
451 if (!ws_client_parse_frame(hdr, avail, &op, &fin, &off, &plen, &consumed))
452 return; // header incomplete or full frame not yet arrived
453 if (consumed > sizeof(s_wsc.pkt))
454 {
455 ring_advance(consumed); // oversized frame: drop it
456 continue;
457 }
458 ring_copy(s_wsc.pkt, consumed);
459 ring_advance(consumed);
460 handle_frame(op, fin, s_wsc.pkt + off, plen);
461 }
462}
463
464void ws_client_on_message(WsClientMessageCb cb)
465{
466 s_wsc.cb = cb;
467}
468
469bool ws_client_connect(const char *host, uint16_t port, bool use_tls, const char *path)
470{
471 if (!host || !path)
472 return false;
473#if !DETWS_ENABLE_WS_CLIENT_TLS
474 if (use_tls)
475 return false;
476#endif
477 s_wsc.rx_head = s_wsc.rx_tail = 0;
478 s_wsc.closed = s_wsc.ws_up = false;
479 s_wsc.msg_len = 0;
480 s_wsc.use_tls = use_tls;
481
482 uint32_t deadline = millis() + 8000;
483
484 // Open the TCP connection (DNS + connect) via the shared client transport.
485 s_wsc.cid = det_client_open(host, port, 8000);
486 if (s_wsc.cid < 0)
487 {
488 WSC_DBG("[wsc] det_client_open failed (%d)\n", s_wsc.cid);
489 return false;
490 }
491
492#if DETWS_ENABLE_WS_CLIENT_TLS
493 if (s_wsc.use_tls)
494 {
495 if (!det_tls_csess_begin(host, ws_tls_send, ws_tls_recv))
496 {
497 WSC_DBG("[wsc] csess_begin failed\n");
498 ws_close_tcp();
499 return false;
500 }
501 int h;
502 while ((h = det_tls_csess_handshake()) == 0 && !s_wsc.closed && (int32_t)(deadline - millis()) > 0)
503 delay(5);
504 if (h != 1)
505 {
506 WSC_DBG("[wsc] TLS handshake h=%d closed=%d\n", h, (int)s_wsc.closed);
507 ws_close_tcp();
508 return false;
509 }
510 WSC_DBG("[wsc] TLS handshake ok\n");
511 }
512#endif
513
514 // Generate a random 16-byte key, base64 it, send the opening handshake.
515 uint8_t keyraw[16];
516 esp_fill_random(keyraw, sizeof(keyraw));
517 char key_b64[25];
518 base64_encode(keyraw, sizeof(keyraw), key_b64);
519 char expect[32];
520 ws_client_accept_for_key(key_b64, expect, sizeof(expect));
521
522 size_t n = ws_client_build_handshake(s_wsc.tx, sizeof(s_wsc.tx), host, path, key_b64, nullptr);
523 if (n == 0 || !ws_tx(s_wsc.tx, n))
524 {
525 ws_close_tcp();
526 return false;
527 }
528
529 // Read the response header (up to "\r\n\r\n") out of the rx ring.
530 uint8_t hs[512];
531 size_t hl = 0;
532 bool done = false;
533 while (!done && !s_wsc.closed && (int32_t)(deadline - millis()) > 0)
534 {
535#if DETWS_ENABLE_WS_CLIENT_TLS
536 if (s_wsc.use_tls)
537 ws_pump_tls();
538 else
539#endif
540 ws_pump_plain();
541 while (ring_avail() > 0 && hl < sizeof(hs))
542 {
543 hs[hl++] = ring_peek(0);
544 ring_advance(1);
545 if (hl >= 4 && hs[hl - 4] == '\r' && hs[hl - 3] == '\n' && hs[hl - 2] == '\r' && hs[hl - 1] == '\n')
546 {
547 done = true;
548 break;
549 }
550 }
551 if (!done)
552 delay(5);
553 }
554 if (!done || !ws_client_check_response(hs, hl, expect))
555 {
556 WSC_DBG("[wsc] handshake fail done=%d hl=%u resp:\n%.*s\n", (int)done, (unsigned)hl, (int)hl, (const char *)hs);
557 ws_close_tcp();
558 return false;
559 }
560 s_wsc.ws_up = true;
561 return true;
562}
563
564bool ws_client_send_text(const char *text)
565{
566 return ws_send_frame(WsClientOpcode::WSC_OP_TEXT, (const uint8_t *)text,
567 text ? strnlen(text, DETWS_WS_CLIENT_BUF_SIZE) : 0);
568}
569bool ws_client_send_binary(const uint8_t *data, size_t len)
570{
571 return ws_send_frame(WsClientOpcode::WSC_OP_BINARY, data, len);
572}
573
574bool ws_client_loop()
575{
576 if (!s_wsc.ws_up)
577 return false;
578 process_rx();
579 if (s_wsc.closed)
580 {
581 ws_close_tcp();
582 return false;
583 }
584 return true;
585}
586
587bool ws_client_connected()
588{
589 return s_wsc.ws_up;
590}
591
592void ws_client_close()
593{
594 if (s_wsc.ws_up)
595 ws_send_frame(WsClientOpcode::WSC_OP_CLOSE, nullptr, 0);
596 ws_close_tcp();
597}
598
599#else // host build: transport is a stub
600
601void ws_client_on_message(WsClientMessageCb)
602{
603}
604bool ws_client_connect(const char *, uint16_t, bool, const char *)
605{
606 return false;
607}
608bool ws_client_send_text(const char *)
609{
610 return false;
611}
612bool ws_client_send_binary(const uint8_t *, size_t)
613{
614 return false;
615}
616bool ws_client_loop()
617{
618 return false;
619}
620bool ws_client_connected()
621{
622 return false;
623}
624void ws_client_close()
625{
626}
627
628#endif // ARDUINO
629
630#endif // DETWS_ENABLE_WS_CLIENT
#define DETWS_WS_CLIENT_BUF_SIZE
WebSocket client send/receive buffer size in bytes (bounds one frame).
void base64_encode(const uint8_t *src, size_t src_len, char *dst)
Encode src_len bytes of src as Base64.
Definition base64.cpp:26
Base64 encoder/decoder.
bool det_client_is_closed(int)
True once the peer closed (FIN) or the connection errored.
Definition client.cpp:318
int det_client_open(const char *, uint16_t, uint32_t)
Resolve host (dotted-quad fast path, else DNS) and connect to host : port, blocking up to timeout_ms.
Definition client.cpp:310
size_t det_client_read(int, uint8_t *, size_t)
Drain up to cap buffered wire bytes into buf; returns the count.
Definition client.cpp:330
void det_client_close(int)
Tear down the connection (marshaled) and return the slot to the pool.
Definition client.cpp:334
bool det_client_send(int, const void *, size_t)
Queue len wire bytes for transmission (marshaled tcp_write + output).
Definition client.cpp:322
Layer 4 outbound TCP client transport - the client-side peer of the (server) transport in tcp....
void sha1(const uint8_t *data, size_t len, uint8_t digest[SHA1_DIGEST_LEN])
Compute a SHA-1 digest over an arbitrary byte buffer.
Definition sha1.cpp:24
Software SHA-1 implementation - no platform dependencies.
#define SHA1_DIGEST_LEN
SHA-1 digest length in bytes.
Definition sha1.h:22
Deterministic TLS engine: mbedTLS over a static memory pool (DETWS_ENABLE_TLS).
bool ws_send_frame(WsConn *ws, WsOpcode opcode, const uint8_t *payload, uint16_t len)
Send a WebSocket frame to the client.
Zero-heap outbound WebSocket client, RFC 6455 (DETWS_ENABLE_WS_CLIENT).