ProtoCore v0.0.2
Deterministic, zero-heap network stack for embedded targets
Loading...
Searching...
No Matches
websocket_sse.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 websocket_sse.cpp
6 * @brief WebSocket (RFC 6455) and Server-Sent Events upgrade + public API for PC.
7 *
8 * Split out of protocore.cpp (single-purpose server files). The WS handshake (Sec-WebSocket-Accept
9 * over SHA-1+base64, optional permessage-deflate), the SSE 200 upgrade, and the send/broadcast
10 * public API for both. The frame codecs live in the presentation layer (websocket/, sse/); this
11 * is the PC glue. The upgrade entry points are called by the route dispatcher in
12 * protocore.cpp (declared in server/protocore_internal.h). Behavior is identical to the pre-split code.
13 */
14
15#include "network_drivers/transport/tcp.h" // conn_pool, pc_conn_*, TcpConn/ConnState
16#include "protocore.h"
18#include "shared_primitives/strbuf.h" // pc_sb frame builder
19#if PC_ENABLE_WEBSOCKET
20#include "crypto/hash/sha1.h" // pc_sha1, PC_SHA1_DIGEST_LEN
21#include "network_drivers/presentation/codec/base64/base64.h" // pc_base64_decode/encode
22#include "network_drivers/presentation/http/websocket/websocket.h" // ws_pool, WsConn, ws_alloc/send_frame/close
23#endif
24#if PC_ENABLE_SSE
25#include "network_drivers/presentation/http/sse/sse.h" // pc_sse_pool, SseConn, pc_sse_alloc/write
26#endif
27#include <stdio.h>
28#include <string.h>
29
30#if PC_ENABLE_WEBSOCKET
31// Magic GUID concatenated to the client key for the WS accept hash (RFC 6455 4.2.2).
32static const char WS_MAGIC[] = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
33#endif
34
35// ---------------------------------------------------------------------------
36// WebSocket handshake helpers
37// ---------------------------------------------------------------------------
38
39#if PC_ENABLE_WEBSOCKET
40/**
41 * @brief Compute the Sec-WebSocket-Accept value for the HTTP 101 response.
42 *
43 * Concatenates the client key with the RFC 6455 magic GUID, SHA-1 hashes
44 * the result, and base64-encodes the 20-byte digest into @p out.
45 * @p out must be at least 29 bytes (28 base64 chars + null terminator).
46 */
47static const size_t WS_MAX_KEY_LEN = 64;
48
49static bool ws_accept_key(const char *client_key, char *out)
50{
51 size_t key_len = strnlen(client_key, WS_MAX_KEY_LEN + 1);
52 // GCOVR_EXCL_START unreachable at the default header sizing: client_key is a Header::val, which
53 // is MAX_VAL_LEN (48) bytes, so key_len cannot exceed WS_MAX_KEY_LEN (64). A build that raises
54 // MAX_VAL_LEN past 64 does reach it, and the bound is what lets the concat buffer below be sized
55 // WS_MAX_KEY_LEN + sizeof(WS_MAGIC) - so the guard stays either way.
56 if (key_len > WS_MAX_KEY_LEN)
57 {
58 out[0] = '\0';
59 return false;
60 }
61 // GCOVR_EXCL_STOP
62 // RFC 6455 4.2.1: the Sec-WebSocket-Key must base64-decode to exactly 16 bytes.
63 uint8_t raw[24];
64 if (pc_base64_decode(client_key, raw, sizeof(raw)) != 16)
65 {
66 out[0] = '\0';
67 return false;
68 }
69 size_t magic_len = sizeof(WS_MAGIC) - 1;
70 char concat[WS_MAX_KEY_LEN + sizeof(WS_MAGIC)];
71 memcpy(concat, client_key, key_len);
72 memcpy(concat + key_len, WS_MAGIC, magic_len);
73
74 uint8_t digest[PC_SHA1_DIGEST_LEN];
75 pc_sha1((const uint8_t *)concat, key_len + magic_len, digest);
77 return true;
78}
79
80/**
81 * @brief Send the HTTP 101 Switching Protocols handshake and upgrade the slot.
82 *
83 * Does NOT close the TCP connection -- that is intentional. The slot moves
84 * from HTTP parse ownership to WS frame parse ownership.
85 */
86/**
87 * @brief Send a 426 Upgrade Required for an unsupported Sec-WebSocket-Version.
88 *
89 * RFC 6455 §4.2.1: if the version is not 13 the server MUST respond with a
90 * 426 and include a Sec-WebSocket-Version header listing the versions it
91 * supports. Closes the connection afterward.
92 */
93void ws_send_version_required(uint8_t slot_id)
94{
95 if (!pc_conn_active(slot_id))
96 {
97 http_reset(slot_id);
98 return;
99 }
100
101 static const char resp[] = "HTTP/1.1 426 Upgrade Required\r\n"
102 "Sec-WebSocket-Version: 13\r\n"
103 "Content-Length: 0\r\n"
104 "Connection: close\r\n\r\n";
105
106 pc_conn_send(slot_id, resp, (u16_t)(sizeof(resp) - 1));
107 pc_conn_flush(slot_id);
108 pc_conn_begin_close(slot_id); // dwell in ConnState::CONN_CLOSING until the response drains
109
110 http_reset(slot_id);
111}
112
113bool ws_do_upgrade(uint8_t slot_id, HttpReq *req, WsConnectHandler on_connect)
114{
115 const char *client_key = http_get_header(req, "Sec-WebSocket-Key");
116 if (!client_key)
117 {
118 return false;
119 }
120
121 char accept[32];
122 if (!ws_accept_key(client_key, accept))
123 {
124 return false;
125 }
126
127 if (!pc_conn_active(slot_id))
128 {
129 return false;
130 }
131
132 char hdr[WS_HDR_BUF_SIZE];
133 int hlen;
134#if PC_ENABLE_WS_DEFLATE
135 // Negotiate permessage-deflate (RFC 7692) if the client offered it. We force
136 // no_context_takeover in both directions so each message decompresses
137 // independently (the INFLATE window is the message buffer, not a kept window).
138 const char *ws_ext = http_get_header(req, "Sec-WebSocket-Extensions");
139 bool pmd = ws_ext && strstr(ws_ext, "permessage-deflate");
140 pc_sb sb_hdr = {hdr, sizeof(hdr), 0, true};
141 pc_sb_put(
142 &sb_hdr,
143 "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ");
144 pc_sb_put(&sb_hdr, accept);
145 pc_sb_put(&sb_hdr, "\r\n");
146 pc_sb_put(&sb_hdr, pmd ? "Sec-WebSocket-Extensions: permessage-deflate; client_no_context_takeover; "
147 "server_no_context_takeover\r\n"
148 : "");
149 pc_sb_put(&sb_hdr, "\r\n");
150 hlen = (int)pc_sb_finish(&sb_hdr);
151#else
152 pc_sb sb_hdr2 = {hdr, sizeof(hdr), 0, true};
153 pc_sb_put(
154 &sb_hdr2,
155 "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ");
156 pc_sb_put(&sb_hdr2, accept);
157 pc_sb_put(&sb_hdr2, "\r\n\r\n");
158 hlen = (int)pc_sb_finish(&sb_hdr2);
159#endif
160
161 pc_conn_send(slot_id, hdr, (u16_t)hlen);
162 pc_conn_flush(slot_id);
163
164 // Reset HTTP parser but keep the TCP slot -- WS owns it now
165 http_reset(slot_id);
166
167 WsConn *ws = ws_alloc(slot_id);
168 if (!ws)
169 {
170 // No WS slot available -- abort the connection (transport owns the teardown)
171 pc_conn_abort_slot(slot_id);
172 return false;
173 }
174
175#if PC_ENABLE_WS_DEFLATE
176 ws->pmd = pmd;
177#endif
178 if (on_connect)
179 {
180 on_connect(ws->ws_id);
181 }
182
183 return true;
184}
185#endif // PC_ENABLE_WEBSOCKET
186
187// ---------------------------------------------------------------------------
188// SSE upgrade helper
189// ---------------------------------------------------------------------------
190
191#if PC_ENABLE_SSE
192/**
193 * @brief Send the HTTP 200 + SSE headers and promote the slot to SSE mode.
194 */
195bool pc_sse_do_upgrade(uint8_t slot_id, HttpReq *req, SseConnectHandler on_connect)
196{
197 if (!pc_conn_active(slot_id))
198 {
199 return false;
200 }
201
202 static const char SSE_HDR[] = "HTTP/1.1 200 OK\r\n"
203 "Content-Type: text/event-stream\r\n"
204 "Cache-Control: no-cache\r\n"
205 "Connection: keep-alive\r\n\r\n";
206
207 pc_conn_send(slot_id, SSE_HDR, (u16_t)(sizeof(SSE_HDR) - 1));
208 pc_conn_flush(slot_id);
209
210 // Copy the path BEFORE resetting the parser: http_reset() zeroes the whole
211 // HttpReq (including req->path), so a pointer into it would dangle. The saved
212 // path is what pc_sse_broadcast() matches against.
213 char path[MAX_PATH_LEN];
214 strncpy(path, req->path, sizeof(path) - 1);
215 path[sizeof(path) - 1] = '\0';
216 http_reset(slot_id);
217
218 SseConn *sse = pc_sse_alloc(slot_id, path);
219 if (!sse)
220 {
221 pc_conn_abort_slot(slot_id); // transport owns detach + reset + RST
222 return false;
223 }
224
225 if (on_connect)
226 {
227 on_connect(sse->pc_sse_id);
228 }
229
230 return true;
231}
232#endif // PC_ENABLE_SSE
233
234// ---------------------------------------------------------------------------
235// WebSocket public API
236// ---------------------------------------------------------------------------
237
238#if PC_ENABLE_WEBSOCKET
239void PC::ws_send_text(uint8_t ws_id, const char *text)
240{
241 if (ws_id >= MAX_WS_CONNS || !ws_pool[ws_id].active)
242 {
243 return;
244 }
245 WsConn *ws = &ws_pool[ws_id];
247 {
248 return;
249 }
250 uint16_t len = (uint16_t)strnlen(text, 0xFFFF);
251 if (ws_send_frame(ws, WsOpcode::WS_OP_TEXT, (const uint8_t *)text, len))
252 {
253 // GCOVR_EXCL_BR_START the false half cannot fire: ws_send_frame returns true only after it
254 // has itself checked pc_conn_active(), and nothing between the two can tear the slot down
255 // on a single-threaded run. It is a re-check for the marshalled (ARDUINO) send path.
256 if (pc_conn_active(ws->slot_id))
257 {
259 }
260 // GCOVR_EXCL_BR_STOP
261 }
262}
263
264void PC::ws_send_binary(uint8_t ws_id, const uint8_t *data, uint16_t len)
265{
266 if (ws_id >= MAX_WS_CONNS || !ws_pool[ws_id].active)
267 {
268 return;
269 }
270 WsConn *ws = &ws_pool[ws_id];
272 {
273 return;
274 }
275 if (ws_send_frame(ws, WsOpcode::WS_OP_BINARY, data, len))
276 {
277 // GCOVR_EXCL_BR_START as in ws_send_text: ws_send_frame already required an active
278 // connection, so the false half of this re-check is unreachable from a host test.
279 if (pc_conn_active(ws->slot_id))
280 {
282 }
283 // GCOVR_EXCL_BR_STOP
284 }
285}
286
287void PC::ws_disconnect(uint8_t ws_id)
288{
289 if (ws_id >= MAX_WS_CONNS || !ws_pool[ws_id].active)
290 {
291 return;
292 }
293 WsConn *ws = &ws_pool[ws_id];
295 if (pc_conn_active(ws->slot_id))
296 {
298 }
299 // handle() detects WsParseState::WS_CLOSED next tick and fires ws_close callback
300}
301#endif // PC_ENABLE_WEBSOCKET
302
303// ---------------------------------------------------------------------------
304// Server-Sent Events public API
305// ---------------------------------------------------------------------------
306
307#if PC_ENABLE_SSE
308void PC::pc_sse_send(uint8_t pc_sse_id, const char *data, const char *event, const char *id)
309{
310 if (pc_sse_id >= MAX_SSE_CONNS || !pc_sse_pool[pc_sse_id].active)
311 {
312 return;
313 }
314 SseConn *sse = &pc_sse_pool[pc_sse_id];
315 if (pc_sse_write(sse, data, event, id))
316 {
317 // GCOVR_EXCL_BR_START the false half cannot fire: pc_sse_write returns true only after it
318 // has itself checked pc_conn_active(), so the slot is still live here.
319 if (pc_conn_active(sse->slot_id))
320 {
322 }
323 // GCOVR_EXCL_BR_STOP
324 }
325}
326
327void PC::pc_sse_broadcast(const char *path, const char *data, const char *event, const char *id)
328{
329 for (int i = 0; i < MAX_SSE_CONNS; i++)
330 {
331 if (!pc_sse_pool[i].active)
332 {
333 continue;
334 }
335 if (strcmp(pc_sse_pool[i].path, path) != 0)
336 {
337 continue;
338 }
339 SseConn *sse = &pc_sse_pool[i];
340 if (pc_sse_write(sse, data, event, id))
341 {
342 // GCOVR_EXCL_BR_START as in pc_sse_send: pc_sse_write already required an active
343 // connection, so the false half of this re-check is unreachable from a host test.
344 if (pc_conn_active(sse->slot_id))
345 {
347 }
348 // GCOVR_EXCL_BR_STOP
349 }
350 }
351}
352#endif // PC_ENABLE_SSE
size_t pc_base64_decode(const char *src, uint8_t *dst, size_t dst_cap)
Decode a null-terminated Base64 string.
Definition base64.cpp:217
void pc_base64_encode(const uint8_t *src, size_t src_len, char *dst)
Encode src_len bytes of src as Base64.
Definition base64.cpp:29
Base64 encoder/decoder.
#define MAX_SSE_CONNS
Definition c2_defaults.h:75
#define MAX_WS_CONNS
Definition c2_defaults.h:72
const char * http_get_header(const HttpReq *req, const char *key)
Look up a header value by name (case-insensitive).
void http_reset(uint8_t slot_id)
Reset the HTTP parser for a connection slot.
Layer 7 (Application) - public HTTP routing API.
#define WS_HDR_BUF_SIZE
Stack buffer for the HTTP 101 Switching Protocols response sent during the WebSocket handshake.
#define MAX_PATH_LEN
Maximum URL path length (including leading /).
Library-private declarations shared between protocore.cpp and the src/server/*.cpp request-handler tr...
PC_CRYPTO_HOT void pc_sha1(const uint8_t *data, size_t len, uint8_t digest[PC_SHA1_DIGEST_LEN])
Compute a SHA-1 digest over an arbitrary byte buffer.
Definition sha1.cpp:26
SHA-1 (FIPS 180-4) - one-shot digest.
#define PC_SHA1_DIGEST_LEN
SHA-1 digest length in bytes.
Definition sha1.h:23
SseConn * pc_sse_alloc(uint8_t slot_id, const char *path)
Allocate an SseConn and bind it to a TCP slot.
Definition sse.cpp:25
bool pc_sse_write(SseConn *sse, const char *data, const char *event, const char *id)
Write one SSE event record to a client.
Definition sse.cpp:116
SseConn pc_sse_pool[MAX_SSE_CONNS]
Pool of SSE connection state, one per MAX_SSE_CONNS.
Definition sse.cpp:14
Layer 6 (Presentation) – Server-Sent Events connection pool.
Bounded no-heap string builder that fails closed on overflow (one shared copy).
size_t pc_sb_finish(pc_sb *b)
NUL-terminate and return the built length, or 0 if the build overflowed.
Definition strbuf.h:648
void pc_sb_put(pc_sb *b, const char *s)
Append NUL-terminated s; leaves the buffer untouched and clears ok if it would not fit.
Definition strbuf.h:60
Fully-parsed HTTP/1.1 request.
char path[MAX_PATH_LEN]
URL path, null-terminated; no query string.
SSE connection state stored in pc_sse_pool[].
Definition sse.h:46
uint8_t pc_sse_id
Index into pc_sse_pool[] (set at init).
Definition sse.h:47
uint8_t slot_id
Owning TCP slot in conn_pool[].
Definition sse.h:48
WebSocket connection state stored in ws_pool[].
Definition websocket.h:149
uint8_t ws_id
Index into ws_pool[] (set at init).
Definition websocket.h:150
uint8_t slot_id
Owning TCP slot in conn_pool[].
Definition websocket.h:151
WsParseState parse_state
Current frame parser state.
Definition websocket.h:154
Bump-append target; ok latches false once an append would overflow cap.
Definition strbuf.h:30
void pc_conn_abort_slot(uint8_t slot)
Hard-abort connection slot (RST) for a fatal condition. The transport owns the teardown order: free t...
Definition tcp.cpp:682
void pc_conn_flush(uint8_t slot)
Flush queued bytes / finish the send on slot (TLS-aware).
Definition tcp.cpp:561
bool pc_conn_send(uint8_t slot, const void *data, u16_t len)
Send len bytes on connection slot (copies data; TLS-aware).
Definition tcp.cpp:500
void pc_conn_begin_close(uint8_t slot_id)
Begin a graceful close that dwells in ConnState::CONN_CLOSING until the peer ACKs.
Definition tcp.cpp:778
Layer 4 (Transport) - TCP connection pool, ring buffers, and lwIP integration.
bool ws_send_frame(WsConn *ws, WsOpcode opcode, const uint8_t *payload, uint16_t len)
Send a WebSocket frame to the client.
void ws_close(WsConn *ws, WsCloseCode code)
Send a Close frame and mark the slot WsParseState::WS_CLOSED.
WsConn * ws_alloc(uint8_t slot_id)
Allocate a WsConn slot and bind it to a TCP slot.
Definition websocket.cpp:50
WsConn ws_pool[MAX_WS_CONNS]
Pool of WebSocket connection state, one per MAX_WS_CONNS.
Definition websocket.cpp:29
Layer 6 (Presentation) – WebSocket frame parser and connection pool.
@ WS_CLOSE_NORMAL
Normal closure.
@ WS_ERROR
Protocol error; close frame has been queued.
@ WS_CLOSED
Connection closed; slot may be recycled.
@ WS_OP_TEXT
UTF-8 text payload.
@ WS_OP_BINARY
Binary payload.