ProtoCore v0.0.2
Deterministic, zero-heap network stack for embedded targets
Loading...
Searching...
No Matches
quic_server.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 pc_quic_server.cpp
6 * @brief HTTP/3 server glue - implementation. See pc_quic_server.h.
7 */
8
10
11#if PC_ENABLE_HTTP3
12
15#include "shared_primitives/ring.h" // pc_atomic
16#include <string.h>
17
18#if defined(ARDUINO)
20#endif
21
22// The pool (QuicConn + H3Conn per slot) and the ingest ring are large, so on a PSRAM board they can
23// be moved to external RAM (like the HTTP/2 pool). Default is internal DRAM; a build that overflows
24// sets PC_QUIC_SERVER_IN_PSRAM=1 on a core built with CONFIG_SPIRAM_ALLOW_BSS_SEG_EXTERNAL_MEMORY.
25#ifndef PC_QUIC_SERVER_IN_PSRAM
26#define PC_QUIC_SERVER_IN_PSRAM 0
27#endif
28#ifndef PC_QUIC_SERVER_ACK_DRAM
29#define PC_QUIC_SERVER_ACK_DRAM 0 ///< consciously accept the pool in internal DRAM (roomy S3 / P4)
30#endif
31// The QuicConn + H3Conn pool plus the ingest ring are tens of KB; on a device that is a deliberate
32// footprint choice. Fail fast (like PC_ENABLE_SSH_ZLIB / PC_ENABLE_HTTP2) so it is not an
33// accidental DRAM overflow: move the pool to PSRAM, or acknowledge the internal-DRAM cost.
34#if defined(ARDUINO) && !PC_QUIC_SERVER_IN_PSRAM && !PC_QUIC_SERVER_ACK_DRAM
35#error \
36 "ProtoCore: PC_ENABLE_HTTP3 - the pc_quic_server QuicConn+H3Conn pool + ingest ring are tens of KB. Set PC_QUIC_SERVER_IN_PSRAM=1 on a PSRAM board (S3 / P4 / WROVER built with CONFIG_SPIRAM_ALLOW_BSS_SEG_EXTERNAL_MEMORY=y, tools/psram/README.md), OR set PC_QUIC_SERVER_ACK_DRAM=1 to accept the internal-DRAM cost (fits a small pool on a roomy chip)."
37#endif
38#if PC_QUIC_SERVER_IN_PSRAM && defined(ARDUINO)
39#include <esp_attr.h>
40#if defined(EXT_RAM_BSS_ATTR)
41#define PC_QUIC_POOL_ATTR EXT_RAM_BSS_ATTR
42#elif defined(EXT_RAM_ATTR)
43#define PC_QUIC_POOL_ATTR EXT_RAM_ATTR
44#else
45#define PC_QUIC_POOL_ATTR
46#endif
47#else
48#define PC_QUIC_POOL_ATTR
49#endif
50
51namespace
52{
53// One buffered inbound datagram (payload + the peer it arrived from).
54struct QuicIngest
55{
56 uint8_t data[PC_QUIC_MAX_DATAGRAM];
57 uint16_t len;
58 char ip[16];
59 uint16_t port;
60};
61
62// One pool slot: a QUIC connection, its HTTP/3 engine, and the peer to reply to.
63struct QuicSlot
64{
65 bool used;
66 uint32_t id; ///< stable handle for pc_quic_server_respond()
67 QuicConn qc;
68 H3Conn h3;
69 char peer_ip[16];
70 uint16_t peer_port;
71 uint32_t last_ms; ///< pc_millis() of the last datagram received (idle-reaping clock)
72};
73
74// HTTP/3 QUIC connection + ingest buffers, owned by one instance (internal linkage). Placed in
75// PSRAM (PC_QUIC_POOL_ATTR) when configured; kept separate from the DRAM control state below
76// so only the large buffers move off internal RAM. One named owner, unreachable cross-TU.
77struct QuicServerPoolCtx
78{
79 QuicSlot pool[PC_QUIC_MAX_CONNS];
80 QuicIngest ring[PC_QUIC_INGEST_RING];
81};
82PC_QUIC_POOL_ATTR QuicServerPoolCtx s_qpool;
83
84// All HTTP/3 QUIC server control state, owned by one instance (internal linkage): the ingest
85// ring cursors, the server config + request callback + app pointer, the bound port / running
86// flag / next connection id, and (host) the outbound sink. One named owner, unreachable cross-TU.
87struct QuicServerCtx
88{
89 pc_atomic<size_t> ring_head; ///< producer (udp / ingest) advances
90 pc_atomic<size_t> ring_tail; ///< consumer (poll) advances
91 QuicServerConfig cfg;
92 QuicServerRequestFn on_request = nullptr;
93 void *app = nullptr;
94 uint16_t port = 0;
95 bool running = false;
96 uint32_t next_id = 1;
97#if !defined(ARDUINO)
98 QuicServerOutFn out_sink = nullptr;
99 void *out_ctx = nullptr;
100#endif
101};
102QuicServerCtx s_quic;
103
104void copy_str(char *dst, size_t cap, const char *src)
105{
106 size_t n = 0;
107 if (src)
108 {
109 while (src[n] && n + 1 < cap)
110 {
111 dst[n] = src[n];
112 n++;
113 }
114 }
115 dst[n] = 0;
116}
117
118bool cid_eq(const uint8_t *a, uint8_t alen, const uint8_t *b, uint8_t blen)
119{
120 return alen == blen && memcmp(a, b, alen) == 0;
121}
122
123void server_send(const char *ip, uint16_t port, const uint8_t *data, size_t len)
124{
125#if defined(ARDUINO)
126 pc_udp_listener_sendto(s_quic.port, ip, port, data, len);
127#else
128 if (s_quic.out_sink)
129 {
130 s_quic.out_sink(s_quic.out_ctx, data, len, ip, port);
131 }
132#endif
133}
134
135// --- ingest ring (SPSC: one producer fills, pc_quic_server_poll consumes) ------------------------
136bool ring_push(const uint8_t *dg, size_t len, const char *ip, uint16_t port)
137{
138 if (len == 0 || len > PC_QUIC_MAX_DATAGRAM)
139 {
140 return false;
141 }
142 size_t head = s_quic.ring_head;
143 size_t next = (head + 1) % PC_QUIC_INGEST_RING;
144 if (next == (size_t)s_quic.ring_tail)
145 {
146 return false; // ring full: drop (QUIC recovers via retransmission)
147 }
148 QuicIngest *e = &s_qpool.ring[head];
149 memcpy(e->data, dg, len);
150 e->len = (uint16_t)len;
151 copy_str(e->ip, sizeof e->ip, ip);
152 e->port = port;
153 s_quic.ring_head = next; // publish after the record is fully written
154 return true;
155}
156
157bool ring_pop(QuicIngest *out)
158{
159 size_t tail = s_quic.ring_tail;
160 if (tail == (size_t)s_quic.ring_head)
161 {
162 return false;
163 }
164 *out = s_qpool.ring[tail];
165 s_quic.ring_tail = (tail + 1) % PC_QUIC_INGEST_RING;
166 return true;
167}
168
169// --- slot pool --------------------------------------------------------------------------------
170QuicSlot *slot_by_id(uint32_t id)
171{
172 for (uint8_t i = 0; i < PC_QUIC_MAX_CONNS; i++)
173 {
174 if (s_qpool.pool[i].used && s_qpool.pool[i].id == id)
175 {
176 return &s_qpool.pool[i];
177 }
178 }
179 return nullptr;
180}
181
182QuicSlot *alloc_slot()
183{
184 for (uint8_t i = 0; i < PC_QUIC_MAX_CONNS; i++)
185 {
186 if (!s_qpool.pool[i].used)
187 {
188 QuicSlot *s = &s_qpool.pool[i];
189 memset(s, 0, sizeof *s);
190 s->used = true;
191 s->id = s_quic.next_id++;
192 if (s_quic.next_id == 0) // GCOVR_EXCL_BR_LINE the check itself runs every alloc; only the
193 // true branch (wrap) is unreachable - it needs 2^32 allocations in
194 // one process lifetime (next_id is never reset except by begin()),
195 // which no host test can drive
196 {
197 s_quic.next_id = 1; // GCOVR_EXCL_LINE dead unless the wrap above is taken; never hand out id 0
198 }
199 return s;
200 }
201 }
202 return nullptr;
203}
204
205// The HTTP/3 engine surfaces a completed request here; forward it to the application by conn id.
206void pc_h3_on_request(void *app, H3Conn * /*h3*/, uint64_t stream_id, const char *method, const char *path,
207 const char *authority, const uint8_t *body, size_t body_len)
208{
209 QuicSlot *s = (QuicSlot *)app;
210 if (s_quic.on_request)
211 {
212 s_quic.on_request(s_quic.app, s->id, stream_id, method, path, authority, body, body_len);
213 }
214}
215
216// Open a connection for a client's first Initial packet.
217QuicSlot *open_conn(const QuicLongHeader *lh, const char *ip, uint16_t port)
218{
219 QuicSlot *s = alloc_slot();
220 if (!s)
221 {
222 return nullptr;
223 }
224
225 QuicTlsConfig tc;
226 memset(&tc, 0, sizeof tc);
227 tc.cert_der = s_quic.cfg.cert_der;
228 tc.cert_len = s_quic.cfg.cert_len;
229 memcpy(tc.ed25519_seed, s_quic.cfg.ed25519_seed, sizeof tc.ed25519_seed);
230 pc_quic_tp_defaults(&tc.params);
231 // A real HTTP/3 endpoint must advertise flow-control room, or every request stream (and the
232 // client's control / QPACK streams) is blocked - the RFC 9000 sec 18.2 defaults are all zero.
233 tc.params.initial_max_data = 1048576;
234 tc.params.initial_max_sd_bidi_remote = 262144; // client-initiated request streams
235 tc.params.initial_max_sd_uni = 262144; // client control / QPACK encoder+decoder streams
236 tc.params.initial_max_streams_bidi = PC_H3_MAX_STREAMS;
237 tc.params.initial_max_streams_uni = PC_H3_MAX_STREAMS;
238 tc.params.max_idle_timeout = PC_QUIC_IDLE_MS; // both ends reclaim the connection after this idle
239 s_quic.cfg.rng(tc.ephemeral_priv, sizeof tc.ephemeral_priv);
240 s_quic.cfg.rng(tc.random, sizeof tc.random);
241#if PC_ENABLE_PQC_KEX
242 s_quic.cfg.rng(tc.mlkem_m, sizeof tc.mlkem_m); // fresh ML-KEM Encaps randomness per handshake
243#endif
244
245 uint8_t our_scid[PC_QUIC_SCID_LEN];
246 s_quic.cfg.rng(our_scid, sizeof our_scid);
247
248 QuicConnCallbacks cb;
249 memset(&cb, 0, sizeof cb); // pc_h3_conn_init installs the real callbacks
250 pc_quic_conn_init(&s->qc, &tc, lh->dcid, lh->dcid_len, lh->scid, lh->scid_len, our_scid, PC_QUIC_SCID_LEN, &cb);
251 pc_h3_conn_init(&s->h3, &s->qc, pc_h3_on_request, s);
252
253 copy_str(s->peer_ip, sizeof s->peer_ip, ip);
254 s->peer_port = port;
255 return s; // last_ms is set by the poll that received this datagram
256}
257
258// Route a datagram to its connection by Destination Connection ID. Sets *is_initial when it is an
259// unmatched Initial (the caller opens a new connection) and copies the parsed long header out.
260QuicSlot *route(const uint8_t *dg, size_t len, bool *is_initial, QuicLongHeader *lh_out)
261{
262 *is_initial = false;
263 if (len < 1) // GCOVR_EXCL_BR_LINE the check itself runs on every route() call (poll's only caller);
264 // only the true branch is unreachable - route()'s sole call site is
265 // pc_quic_server_poll, fed by ring_pop() from a ring that ring_push() (both
266 // ingest paths) already refuses to fill with len==0, so len>=1 always holds here
267 {
268 return nullptr; // GCOVR_EXCL_LINE dead unless the len<1 branch above is taken
269 }
270 if (pc_quic_is_long_header(dg[0]))
271 {
272 if (!pc_quic_parse_long_header(dg, len, lh_out))
273 {
274 return nullptr;
275 }
276 for (uint8_t i = 0; i < PC_QUIC_MAX_CONNS; i++)
277 {
278 QuicSlot *s = &s_qpool.pool[i];
279 if (!s->used)
280 {
281 continue;
282 }
283 if (cid_eq(lh_out->dcid, lh_out->dcid_len, s->qc.scid, s->qc.scid_len) ||
284 cid_eq(lh_out->dcid, lh_out->dcid_len, s->qc.odcid, s->qc.odcid_len))
285 {
286 return s;
287 }
288 }
289 if (lh_out->version == QUIC_VERSION_1 && lh_out->type == QuicLongPacket::QUIC_LP_INITIAL)
290 {
291 *is_initial = true;
292 }
293 return nullptr;
294 }
295 // Short header (1-RTT): the DCID is our chosen SCID, whose length only we know.
296 if (len < (size_t)1 + PC_QUIC_SCID_LEN)
297 {
298 return nullptr;
299 }
300 for (uint8_t i = 0; i < PC_QUIC_MAX_CONNS; i++)
301 {
302 QuicSlot *s = &s_qpool.pool[i];
303 // scid_len is always PC_QUIC_SCID_LEN (only this server sets it, at conn init above), so its
304 // != arm below is a defensive guard no host input can reach.
305 // GCOVR_EXCL_START
306 if (s->used && s->qc.scid_len == PC_QUIC_SCID_LEN && memcmp(dg + 1, s->qc.scid, PC_QUIC_SCID_LEN) == 0)
307 {
308 return s;
309 }
310 // GCOVR_EXCL_STOP
311 }
312 return nullptr;
313}
314
315void flush_and_reap(uint32_t now_ms)
316{
317 uint8_t out[PC_QUIC_MAX_DATAGRAM];
318 for (uint8_t i = 0; i < PC_QUIC_MAX_CONNS; i++)
319 {
320 QuicSlot *s = &s_qpool.pool[i];
321 if (!s->used)
322 {
323 continue;
324 }
325 pc_quic_conn_on_timeout(&s->qc, now_ms); // retransmit a lost handshake flight (PTO)
326 size_t n;
327 while ((n = pc_quic_conn_send(&s->qc, out, sizeof out)) > 0)
328 {
329 server_send(s->peer_ip, s->peer_port, out, n);
330 }
331 // Reap a closed connection, or one idle past the timeout (wrap-safe delta) so a client that
332 // never closes cannot leak the fixed pool.
333 if (pc_quic_conn_is_closed(&s->qc) || (uint32_t)(now_ms - s->last_ms) >= PC_QUIC_IDLE_MS)
334 {
335 s->used = false;
336 }
337 }
338}
339
340#if defined(ARDUINO)
341void udp_ingest_cb(const uint8_t *data, size_t len, const pc_udp_peer *peer, void * /*ctx*/)
342{
343 char ip[16];
344 uint16_t port = 0;
345 if (!pc_udp_peer_addr(peer, ip, sizeof ip, &port))
346 {
347 return;
348 }
349 ring_push(data, len, ip, port);
350}
351#endif
352} // namespace
353
354bool pc_quic_server_begin(uint16_t port, const QuicServerConfig *cfg, QuicServerRequestFn on_request, void *app)
355{
356 if (!cfg || !cfg->rng)
357 {
358 return false;
359 }
360 s_quic.cfg = *cfg;
361 s_quic.on_request = on_request;
362 s_quic.app = app;
363 s_quic.port = port ? port : PC_HTTP3_PORT;
364 for (uint8_t i = 0; i < PC_QUIC_MAX_CONNS; i++)
365 {
366 s_qpool.pool[i].used = false;
367 }
368 s_quic.ring_head = 0;
369 s_quic.ring_tail = 0;
370 s_quic.next_id = 1;
371 s_quic.running = true;
372#if defined(ARDUINO)
373 return pc_udp_listen(s_quic.port, udp_ingest_cb, nullptr);
374#else
375 return true; // host: fed through pc_quic_server_ingest()
376#endif
377}
378
379void pc_quic_server_poll(uint32_t now_ms)
380{
381 if (!s_quic.running)
382 {
383 return;
384 }
385 QuicIngest ig;
386 while (ring_pop(&ig))
387 {
388 bool is_initial = false;
389 QuicLongHeader lh;
390 QuicSlot *s = route(ig.data, ig.len, &is_initial, &lh);
391 if (!s && is_initial)
392 {
393 s = open_conn(&lh, ig.ip, ig.port);
394 }
395 if (!s)
396 {
397 continue;
398 }
399 s->last_ms = now_ms; // liveness for idle reaping
400 pc_quic_conn_recv(&s->qc, ig.data, ig.len);
401 }
402 flush_and_reap(now_ms);
403}
404
405bool pc_quic_server_respond(uint32_t conn_id, uint64_t stream_id, int status, const char *content_type,
406 const uint8_t *body, size_t body_len)
407{
408 QuicSlot *s = slot_by_id(conn_id);
409 if (!s)
410 {
411 return false;
412 }
413 return pc_h3_conn_respond(&s->h3, stream_id, status, content_type, body, body_len);
414}
415
416uint8_t pc_quic_server_active_conns(void)
417{
418 uint8_t n = 0;
419 for (uint8_t i = 0; i < PC_QUIC_MAX_CONNS; i++)
420 {
421 if (s_qpool.pool[i].used)
422 {
423 n++;
424 }
425 }
426 return n;
427}
428
429void pc_quic_server_stop(void)
430{
431 s_quic.running = false;
432 for (uint8_t i = 0; i < PC_QUIC_MAX_CONNS; i++)
433 {
434 s_qpool.pool[i].used = false;
435 }
436 s_quic.ring_head = 0;
437 s_quic.ring_tail = 0;
438}
439
440#if !defined(ARDUINO)
441void pc_quic_server_set_out_sink_cb(QuicServerOutFn fn, void *ctx)
442{
443 s_quic.out_sink = fn;
444 s_quic.out_ctx = ctx;
445}
446
447bool pc_quic_server_ingest(const uint8_t *datagram, size_t len, const char *ip, uint16_t port)
448{
449 return ring_push(datagram, len, ip, port);
450}
451#endif
452
453#endif // PC_ENABLE_HTTP3
#define PC_H3_MAX_STREAMS
Definition 16mbpsram.h:33
#define PC_HTTP3_PORT
UDP port the HTTP/3 (QUIC) server binds by default (used by PC::pc_h3_cert).
Shared single-producer / single-consumer byte-ring primitive.
bool pc_udp_listener_sendto(uint16_t listen_port, const char *dst_ip, uint16_t dst_port, const uint8_t *data, size_t len)
Send a datagram from the listener bound on listen_port to an address.
Definition udp.cpp:407
bool pc_udp_listen(uint16_t port, pc_udp_handler handler, void *ctx)
Bind a UDP port and route incoming datagrams to handler.
Definition udp.cpp:226
bool pc_udp_peer_addr(const struct pc_udp_peer *peer, char *ip_out, size_t ip_cap, uint16_t *port_out)
Copy a received peer's source IPv4 address (dotted-quad) and port out.
Definition udp.cpp:393
Layer 4 (Transport) - connectionless UDP datagram service.