DeterministicESPAsyncWebServer v6.27.1
Zero-allocation, bounded-execution async HTTP server for ESP32
Loading...
Searching...
No Matches
dwserver.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 dwserver.cpp
6 * @brief Layer 7 (Application) - HTTP routing and request handler implementation.
7 *
8 * **Dispatch pipeline (called from DetWebServer::handle())**
9 * ```
10 * handle()
11 * └─ server_tick() ← drain FreeRTOS event queue
12 * └─ for each slot:
13 * ParseState::PARSE_COMPLETE → match_and_execute()
14 * ParseState::PARSE_ERROR → send(400)
15 * ParseState::PARSE_ENTITY_TOO_LARGE → send(413)
16 * ParseState::PARSE_URI_TOO_LONG → send(414)
17 * ```
18 *
19 * **Route table**
20 * Routes are stored in a fixed-size array of `Route` structs. Both exact
21 * and wildcard (suffix `*`) routes are supported; exact routes always take
22 * priority because the loop checks them in insertion order and returns on
23 * the first match.
24 *
25 * **PCB lifecycle / teardown ownership**
26 * All TCP I/O and teardown go through the transport-layer connection API
27 * (det_conn_send / det_conn_flush / det_conn_begin_close / det_conn_close /
28 * det_conn_abort_slot), so this layer never calls lwIP or touches the raw
29 * `tcp_pcb` directly. The transport owns the teardown order for every close:
30 * it detaches the pcb from its lwIP callbacks and sets the slot `ConnState::CONN_FREE`
31 * (pcb nulled) BEFORE the FIN/RST, on the captured pcb pointer. This means any
32 * lwIP error callback that fires mid-teardown sees the slot as already free and
33 * takes no action - preventing a double-free. L7 passes only the slot index:
34 * det_conn_close(slot) for a graceful local close, det_conn_abort_slot(slot)
35 * for a hard RST, det_conn_begin_close(slot) for the drain-then-close dwell.
36 */
37
38#include "dwserver.h"
39#include "server/dwserver_internal.h" // shared helpers exposed to the src/server/*.cpp handlers
40#include "network_drivers/presentation/presentation.h" // http_proto_set_poll (install the instance-bound HTTP poll)
47#if DETWS_ENABLE_HTTP2
49#endif
50#if DETWS_ENABLE_HTTP3
52#endif
53#if DETWS_ENABLE_WEBSOCKET
56#elif DETWS_ENABLE_AUTH
58#endif
59#if DETWS_ENABLE_AUTH
61#include "services/clock.h" // detws_millis() for the stateless Digest nonce timestamp
62#if DETWS_ENABLE_AUTH_LOCKOUT
64#endif
65#ifdef ARDUINO
66#include <esp_system.h> // esp_random() for the Digest nonce CSPRNG
67#endif
68#endif
69#if DETWS_ENABLE_CSRF
70#include "services/csrf/csrf.h"
71#ifdef ARDUINO
72#include <esp_system.h> // esp_random() for the CSRF HMAC secret
73#endif
74#endif
75#if DETWS_ENABLE_WEBDAV
77#include <time.h> // RFC 1123 Last-Modified formatting
78#endif
79#if DETWS_ENABLE_METRICS || DETWS_ENABLE_STATS
80#include "network_drivers/application/web_assets.h" // DETWS_METRICS_PROM / DETWS_STATS_JSON (generated)
81#endif
82#if DETWS_HTTP_EMIT_DATE
83#if DETWS_ENABLE_TIME_SOURCE
84#include "services/time_source/time_source.h" // detws_time_http_date() - any NTP/GPS/RTC/... source
85#else
86#include "services/ntp_service/ntp_service.h" // detws_ntp_http_date() - direct NTP (or the host test seam)
87#endif
88#endif
89#include <stdarg.h>
90#include <stdio.h>
91#include <string.h>
92
93// The outbound-transfer continuation types (FileSend/ChunkSend/SendCtx) live in
94// server/dwserver_internal.h so the split file_serving / chunked handler TUs share the same
95// per-slot state. This is the single owning definition of that state (external linkage, but the
96// sole definition - the one named owner).
98
99/**
100 * @brief Convert an HTTP status code to its standard reason phrase.
101 *
102 * Covers the 18 codes that arise in typical REST micro-server usage.
103 * Unknown codes produce "Unknown" so callers never receive a null pointer.
104 *
105 * @param code HTTP status integer.
106 * @return Pointer to a string-literal reason phrase; never null.
107 */
108// Response bytes go out via the transport-layer connection I/O API
109// (det_conn_send / det_conn_flush / det_conn_close, see tcp.h) so this
110// application layer never calls lwIP directly.
111
112const char *status_text(int code)
113{
114 switch (code)
115 {
116 case 200:
117 return "OK";
118 case 201:
119 return "Created";
120 case 204:
121 return "No Content";
122 case 206:
123 return "Partial Content";
124#if DETWS_ENABLE_WEBDAV
125 case 207:
126 return "Multi-Status";
127#endif
128 case 301:
129 return "Moved Permanently";
130 case 302:
131 return "Found";
132 case 303:
133 return "See Other";
134 case 304:
135 return "Not Modified";
136 case 307:
137 return "Temporary Redirect";
138 case 308:
139 return "Permanent Redirect";
140 case 400:
141 return "Bad Request";
142 case 401:
143 return "Unauthorized";
144 case 403:
145 return "Forbidden";
146 case 404:
147 return "Not Found";
148 case 405:
149 return "Method Not Allowed";
150 case 408:
151 return "Request Timeout";
152 case 409:
153 return "Conflict";
154#if DETWS_ENABLE_WEBDAV
155 case 412:
156 return "Precondition Failed";
157 case 423:
158 return "Locked";
159 case 502:
160 return "Bad Gateway";
161#endif
162 case 413:
163 return "Payload Too Large";
164 case 414:
165 return "URI Too Long";
166 case 416:
167 return "Range Not Satisfiable";
168 case 429:
169 return "Too Many Requests";
170 case 500:
171 return "Internal Server Error";
172 case 501:
173 return "Not Implemented";
174 case 503:
175 return "Service Unavailable";
176 default:
177 return "Unknown";
178 }
179}
180
181/**
182 * @brief Map a method string (from the parsed request line) to an HttpMethod enum.
183 *
184 * Returns HttpVersion::HTTP_UNKNOWN for any method the server does not implement, so the
185 * dispatcher can answer 501 Not Implemented (RFC 7231 §6.5.2) instead of
186 * silently treating it as GET.
187 *
188 * @param m Null-terminated method string, e.g. "POST".
189 * @return Matching HttpMethod enum value, or HttpVersion::HTTP_UNKNOWN.
190 */
191static HttpMethod parse_method(const char *m)
192{
193 if (strcmp(m, "GET") == 0)
195 if (strcmp(m, "POST") == 0)
197 if (strcmp(m, "PUT") == 0)
199 if (strcmp(m, "DELETE") == 0)
201 if (strcmp(m, "PATCH") == 0)
203 if (strcmp(m, "HEAD") == 0)
205 if (strcmp(m, "OPTIONS") == 0)
208}
209
210/**
211 * @brief Canonical method token for an HttpMethod (for the Allow header).
212 */
213static const char *method_name(HttpMethod m)
214{
215 switch (m)
216 {
218 return "GET";
220 return "POST";
222 return "PUT";
224 return "DELETE";
226 return "PATCH";
228 return "HEAD";
230 return "OPTIONS";
231 default:
232 return "";
233 }
234}
235
237 : _route_count(0), _not_found_handler(nullptr), _cors_enabled(false), _log_cb(nullptr), _listener_count(0),
238 _middleware_count(0), _rl_max(0), _rl_window_ms(0), _rl_window_start(0), _rl_count(0)
239{
240 for (int i = 0; i < MAX_ROUTES; i++)
241 _routes[i] = {};
242 for (int i = 0; i < MAX_MIDDLEWARE; i++)
243 _middleware[i] = nullptr;
244 _cors_header_buf[0] = '\0';
245 _cache_control_buf[0] = '\0';
246 for (int i = 0; i < MAX_CONNS; i++)
247 _extra_hdr[i][0] = '\0';
248#if DETWS_ENABLE_AUTH
249 regen_digest_secret();
250#endif
251#if DETWS_ENABLE_STATS
252 _stat_requests = 0;
253 _stat_2xx = 0;
254 _stat_4xx = 0;
255 _stat_5xx = 0;
256#endif
257}
258
260{
261 _log_cb = cb;
262}
263
264// Record a completed response: bump stats counters and fire the access-log hook.
265// The request's method/path are still intact in http_pool[slot_id] (http_reset
266// has not run yet at the call sites).
267void DetWebServer::note_response(uint8_t slot_id, int code, int body_len)
268{
269#if DETWS_ENABLE_STATS
270 _stat_requests++;
271 if (code >= 500)
272 _stat_5xx++;
273 else if (code >= 400)
274 _stat_4xx++;
275 else if (code >= 200 && code < 300)
276 _stat_2xx++;
277#endif
278 if (_log_cb)
279 {
280 const HttpReq *r = &http_pool[slot_id];
281 _log_cb(r->method, r->path, code, body_len);
282 }
283}
284
285#if DETWS_ENABLE_KEEPALIVE || DETWS_ENABLE_WEBSOCKET
286// Case-insensitive search for @p token as a comma/space-delimited element of a
287// Connection header value (e.g. "keep-alive" in "Keep-Alive, Upgrade"). Shared by
288// keep-alive evaluation and the WebSocket Upgrade-token check.
289static bool conn_has_token(const char *hdr, const char *token)
290{
291 if (!hdr)
292 return false;
293 size_t tlen = strnlen(token, 32);
294 const char *p = hdr;
295 while (*p)
296 {
297 while (*p == ' ' || *p == ',' || *p == '\t')
298 p++;
299 const char *start = p;
300 while (*p && *p != ',')
301 p++;
302 size_t len = (size_t)(p - start);
303 while (len && (start[len - 1] == ' ' || start[len - 1] == '\t'))
304 len--;
305 if (len == tlen && strncasecmp(start, token, tlen) == 0)
306 return true;
307 if (*p == ',')
308 p++;
309 }
310 return false;
311}
312#endif // DETWS_ENABLE_KEEPALIVE || DETWS_ENABLE_WEBSOCKET
313
314#if DETWS_ENABLE_KEEPALIVE
315bool DetWebServer::keepalive_eval(uint8_t slot_id)
316{
317 HttpReq *req = &http_pool[slot_id];
318 // Only a cleanly-parsed request has a known message boundary; errors close.
320 return false;
321
322 const char *c = http_get_header(req, "Connection");
323 bool keep;
324 if (req->version == HttpVersion::HTTP_11)
325 keep = !conn_has_token(c, "close"); // 1.1 default: persistent
326 else
327 keep = conn_has_token(c, "keep-alive"); // 1.0/unknown default: close
328 if (!keep)
329 return false;
330
331 // Fairness bound: serve at most DETWS_KEEPALIVE_MAX_REQUESTS, then close.
332 if (++http_req_count[slot_id] >= DETWS_KEEPALIVE_MAX_REQUESTS)
333 return false;
334 return true;
335}
336#endif // DETWS_ENABLE_KEEPALIVE
337
338// Finish a response: flush, then either begin the graceful ConnState::CONN_CLOSING dwell
339// (close path) or leave the slot active for reuse (keep-alive). The HTTP parser
340// is reset either way, returning a kept-alive slot to ParseState::PARSE_METHOD ready for the
341// next request. The slot stays ConnState::CONN_ACTIVE through the write for BOTH paths
342// (callbacks live - the model keep-alive has always used); the close path then
343// dwells in ConnState::CONN_CLOSING from here, so the slot is reclaimed only once the peer
344// ACKs the response (or the CLOSING timeout fires), not before it is delivered.
345// Every response path now addresses the connection by slot alone - the transport
346// resolves the pcb internally, the same way the RX read path does (no pcb is
347// threaded through the app layer, so the send target can never disagree).
348void DetWebServer::resp_end(uint8_t slot_id, int code, int body_len, bool keep, bool pre_flushed)
349{
350 if (!pre_flushed)
351 det_conn_flush(slot_id); // a pre_flushed caller already did tcp_output in its final send
352 if (!keep)
353 det_conn_begin_close(slot_id); // ACTIVE -> ConnState::CONN_CLOSING; finalizes on ACK
354 note_response(slot_id, code, body_len);
355 http_reset(slot_id);
356}
357
358// Resolve the Connection response header (and report keep-alive intent) in one
359// place so every response path agrees. Keep-alive compiled out always closes.
360const char *DetWebServer::resp_conn_hdr(uint8_t slot_id, bool *keep_out)
361{
362 bool keep = false;
363#if DETWS_ENABLE_KEEPALIVE
364 keep = keepalive_eval(slot_id);
365#else
366 (void)slot_id;
367#endif
368 if (keep_out)
369 *keep_out = keep;
370 return keep ? "Connection: keep-alive\r\n" : "Connection: close\r\n";
371}
372
373// Append the shared response trailer (CORS block + custom headers + Connection +
374// the terminating blank line) to a header buffer already holding the status line
375// and per-response headers. One owner for the trailer every dynamic response ends
376// with. Returns the new total length.
377int DetWebServer::append_resp_trailer(char *buf, size_t cap, int hlen, uint8_t slot_id, const char *cl)
378{
379 if (hlen < 0)
380 return 0;
381 if ((size_t)hlen >= cap)
382 return (int)cap - 1; // status line already filled the buffer (truncated); clamp in-bounds
383#if DETWS_HTTP_EMIT_DATE
384 // RFC 7231 7.1.1.2: emit Date only when a real wall-clock time exists; a clock-less device (no
385 // synced/valid time source yet) omits it. The time comes from the multi-source registry (any
386 // enabled NTP / GPS / RTC / ... by priority) when DETWS_ENABLE_TIME_SOURCE is set, else straight
387 // from NTP.
388 char date_hdr[48] = "";
389 char imf[40];
390#if DETWS_ENABLE_TIME_SOURCE
391 if (detws_time_http_date(imf, sizeof(imf)) > 0)
392#else
393 if (detws_ntp_http_date(imf, sizeof(imf)) > 0)
394#endif
395 snprintf(date_hdr, sizeof(date_hdr), "Date: %s\r\n", imf);
396#else
397 const char *date_hdr = "";
398#endif
399 int n = snprintf(buf + hlen, cap - (size_t)hlen, "%s%s%s%s\r\n", date_hdr, _cors_enabled ? _cors_header_buf : "",
400 _extra_hdr[slot_id], cl);
401 if (n < 0)
402 return hlen;
403 // snprintf returns the would-be length; if the trailer truncated, clamp to the
404 // bytes actually written so the returned length never exceeds the buffer (else a
405 // caller would send/copy past the end - a stack over-read on large extra headers).
406 if ((size_t)n >= cap - (size_t)hlen)
407 return (int)cap - 1;
408 return hlen + n;
409}
410
411int32_t DetWebServer::listen(uint16_t port, ConnProto proto)
412{
413 if (_listener_count >= MAX_LISTENERS)
415 _listen_ports[_listener_count] = port;
416 _listen_protos[_listener_count] = proto;
417 _listen_tls[_listener_count] = false;
418 _listener_count++;
419 // Return the listener id (its index), not DetWebServerResult::DETWS_OK: begin() binds listener_pool[i] from
420 // _listen_ports[i] and the accept path stamps that same index onto the slot, so this id is what
421 // det_relay_publish() / ssh_forward_begin() must match against. (Errors are negative.)
422 return (int32_t)(_listener_count - 1);
423}
424
425// Server instance bindings, owned by one instance (internal linkage): the instance whose
426// pipeline the worker task pumps, the HTTP/3-running flag, and the instance the HTTP on_poll
427// forwarder dispatches into. The library serves from a single DetWebServer (the slot pools are
428// global singletons), which is exactly what these instance pointers model. One named owner,
429// unreachable from any other translation unit. Set in begin().
431{
433#if DETWS_ENABLE_HTTP3
434 bool h3_running = false;
435#endif
437};
438static InstanceCtx s_inst;
439#ifdef ARDUINO
440// The worker task's per-tick entry (registered with detws_workers_start below); ESP32-only, so it is
441// compiled only where it is used - on host the pipeline runs inline via handle().
442static void detws_pump_trampoline(int worker_id)
443{
444 if (s_inst.worker_server)
445 s_inst.worker_server->service_once(worker_id);
446}
447#endif
448
449#if DETWS_ENABLE_HTTP3
450// The quic_server request seam has no DetWebServer type; this trampoline forwards a completed
451// HTTP/3 request into the instance's shared route dispatcher (app == the DetWebServer *).
452static void detws_h3_request_trampoline(void *app, uint32_t conn_id, uint64_t stream_id, const char *method,
453 const char *path, const char *authority, const uint8_t *body, size_t body_len)
454{
455 if (app)
456 ((DetWebServer *)app)->dispatch_h3_request(conn_id, stream_id, method, path, authority, body, body_len);
457}
458
459// Randomness for the QUIC ephemeral X25519 key, the ServerHello random, and our connection IDs: the
460// hardware TRNG on device; a deterministic PRNG on host (test builds carry no security context and
461// have no esp_random).
462static void detws_h3_rng(uint8_t *out, size_t len)
463{
464#ifdef ARDUINO
465 size_t i = 0;
466 while (i < len)
467 {
468 uint32_t r = esp_random();
469 size_t n = (len - i) < 4 ? (len - i) : 4;
470 memcpy(out + i, &r, n);
471 i += n;
472 }
473#else
474 static uint32_t s = 0x9e3779b9u;
475 for (size_t i = 0; i < len; i++)
476 {
477 s = s * 1664525u + 1013904223u;
478 out[i] = (uint8_t)(s >> 24);
479 }
480#endif
481}
482#endif // DETWS_ENABLE_HTTP3
483
484// HTTP's poll is instance-bound (it dispatches into this server's routes), so it cannot be a plain
485// global on_poll like the singleton protocols. begin() records the serving instance here and installs
486// this forwarder as the HTTP ProtoHandler's on_poll, so the worker loop pumps HTTP through the one
487// uniform seam. The library serves from a single DetWebServer (the slot pools are global singletons),
488// which is exactly what this one instance pointer models.
489static void detws_http_on_poll(uint8_t slot)
490{
491 if (s_inst.http_instance)
492 s_inst.http_instance->http_poll_slot(slot);
493}
494
496{
497 if (_listener_count == 0
499 && !_h3_enabled // an HTTP/3-only server binds UDP, not a TCP listener
500#endif
501 )
504#if DETWS_ENABLE_AUTH
505 regen_digest_secret(); // fresh server keying secret per begin()
506#endif
507#if DETWS_ENABLE_CSRF
508 {
509 // Seed the CSRF HMAC secret from the hardware RNG (a fixed dev secret on
510 // native/test builds, which have no esp_random).
511 uint8_t sec[32];
512#ifdef ARDUINO
513 for (int i = 0; i < 8; i++)
514 {
515 uint32_t r = esp_random();
516 memcpy(sec + i * 4, &r, 4);
517 }
518#else
519 for (int i = 0; i < 32; i++)
520 sec[i] = (uint8_t)(0xA5 ^ i);
521#endif
522 csrf_set_secret(sec, sizeof(sec));
523 }
524#endif
525 for (uint8_t i = 0; i < MAX_CONNS; i++)
526 http_reset(i);
527#if DETWS_ENABLE_WEBSOCKET
528 ws_init();
529#endif
530#if DETWS_ENABLE_SSE
531 sse_init();
532#endif
533 for (uint8_t i = 0; i < _listener_count; i++)
534 {
535 if (listener_add(i, _listen_ports[i], _listen_protos[i], _listen_tls[i]) < 0)
537 }
538#if DETWS_ENABLE_HTTP3
539 // Bind the HTTP/3 QUIC server (UDP on device; on host it is fed via quic_server_ingest). Requests
540 // dispatch through this instance's routes via the trampoline; quic_server_poll() runs in service_once.
541 if (_h3_enabled)
542 {
543 QuicServerConfig h3cfg;
544 memset(&h3cfg, 0, sizeof(h3cfg));
545 h3cfg.cert_der = _h3_cert;
546 h3cfg.cert_len = _h3_cert_len;
547 memcpy(h3cfg.ed25519_seed, _h3_seed, sizeof(h3cfg.ed25519_seed));
548 h3cfg.rng = detws_h3_rng;
549 s_inst.h3_running = quic_server_begin(_h3_port, &h3cfg, detws_h3_request_trampoline, this);
550 }
551#endif
552#ifdef ARDUINO
553 // Routes/listeners are now fixed; start the worker task(s) that drive the
554 // pipeline off the user's loop(). On host the pipeline runs inline via handle().
555 s_inst.worker_server = this;
556 detws_workers_start(detws_pump_trampoline);
557#endif
558 return (int32_t)DetWebServerResult::DETWS_OK;
559}
560
561int32_t DetWebServer::begin(uint16_t port, const WebServerConfig *cfg)
562{
563 int32_t rc = listen(port);
564 if (rc < 0)
565 return rc;
566 return begin(cfg);
567}
568
569#if DETWS_ENABLE_HTTP3
570bool DetWebServer::h3_cert(const uint8_t *cert_der, size_t cert_len, const uint8_t ed25519_seed[32], uint16_t port)
571{
572 if (!cert_der || cert_len == 0 || !ed25519_seed)
573 return false;
574 _h3_cert = cert_der;
575 _h3_cert_len = cert_len;
576 memcpy(_h3_seed, ed25519_seed, sizeof(_h3_seed));
577 _h3_port = port;
578 _h3_enabled = true;
579 return true;
580}
581
582// Response sink for the HTTP/3 dispatch slot: route (code, content_type, body) onto the QUIC stream
583// the request arrived on (ids stashed on the slot by dispatch_h3_request). Installed as conn->resp_sink
584// so send()/send_empty() stay protocol-agnostic.
585static bool h3_resp_sink(uint8_t slot, int code, const char *content_type, const char *body, size_t len)
586{
587 TcpConn *c = &conn_pool[slot];
588 return quic_server_respond(c->h3_conn_id, c->h3_stream, code, content_type, (const uint8_t *)body, len);
589}
590
591void DetWebServer::dispatch_h3_request(uint32_t conn_id, uint64_t stream_id, const char *method, const char *path,
592 const char *authority, const uint8_t *body, size_t body_len)
593{
594 const uint8_t slot = DETWS_H3_DISPATCH_SLOT;
595 HttpReq *r = &http_pool[slot];
596 http_reset(slot);
597
598 // Map the semantic request fields into the shared HttpReq (as h2_server does per stream).
599 size_t mn = strnlen(method, sizeof(r->method));
600 if (mn >= sizeof(r->method))
601 mn = sizeof(r->method) - 1;
602 memcpy(r->method, method, mn);
603 r->method[mn] = 0;
604
605 const char *q = strchr(path, '?');
606 size_t plen = q ? (size_t)(q - path) : strnlen(path, sizeof(r->path));
607 if (plen >= sizeof(r->path))
608 plen = sizeof(r->path) - 1;
609 memcpy(r->path, path, plen);
610 r->path[plen] = 0;
611 r->path_idx = strnlen(r->path, sizeof(r->path));
612 if (q)
613 {
614 size_t ql = strnlen(q + 1, sizeof(r->query));
615 if (ql >= sizeof(r->query))
616 ql = sizeof(r->query) - 1;
617 memcpy(r->query, q + 1, ql);
618 r->query[ql] = 0;
619 r->query_idx = strnlen(r->query, sizeof(r->query));
620 }
621
622 // :authority maps to Host, the way the h2 bridge does.
623 if (authority && authority[0] && r->header_count < MAX_HEADERS)
624 {
625 Header *h = &r->headers[r->header_count++];
626 memcpy(h->key, "host", 5);
627 size_t vl = strnlen(authority, sizeof(h->val));
628 if (vl >= sizeof(h->val))
629 vl = sizeof(h->val) - 1;
630 memcpy(h->val, authority, vl);
631 h->val[vl] = 0;
632 }
633
634 if (body && body_len)
635 {
636 size_t n = body_len > BODY_BUF_SIZE ? BODY_BUF_SIZE : body_len;
637 memcpy(r->body, body, n);
638 r->body_len = n;
639 r->body[r->body_len] = 0;
640 r->body_bytes_read = body_len;
641 r->content_length = body_len;
642 }
644
645 // Mark the reserved slot as HTTP/3 and install the response sink so send() / send_empty() route the
646 // response back onto this stream (no TCP pcb here - the sink owns the QUIC framing).
647 TcpConn *c = &conn_pool[slot];
648 c->h3 = 1;
649 c->h3_conn_id = conn_id;
650 c->h3_stream = stream_id;
651 c->resp_sink = h3_resp_sink;
654 c->pcb = nullptr;
655
656 match_and_execute(slot); // -> handler -> send() -> resp_sink -> quic_server_respond()
657
658 // Release the dispatch slot for the next request (a no-response handler simply leaves the stream open).
659 c->h3 = 0;
660 c->resp_sink = nullptr;
662 http_reset(slot);
663}
664#endif // DETWS_ENABLE_HTTP3
665
666#if DETWS_ENABLE_TLS
667bool DetWebServer::tls_cert(const uint8_t *cert, size_t cert_len, const uint8_t *key, size_t key_len)
668{
669 return det_tls_global_init(cert, cert_len, key, key_len);
670}
671
672int32_t DetWebServer::listen_tls(uint16_t port)
673{
674 if (_listener_count >= MAX_LISTENERS)
676 _listen_ports[_listener_count] = port;
677 _listen_protos[_listener_count] = ConnProto::PROTO_HTTP;
678 _listen_tls[_listener_count] = true;
679 _listener_count++;
680 return (int32_t)DetWebServerResult::DETWS_OK;
681}
682
683int32_t DetWebServer::begin_tls(uint16_t port, const uint8_t *cert, size_t cert_len, const uint8_t *key, size_t key_len,
684 const WebServerConfig *cfg)
685{
686 if (!tls_cert(cert, cert_len, key, key_len))
688 int32_t rc = listen_tls(port);
689 if (rc < 0)
690 return rc;
691 return begin(cfg);
692}
693
694#if DETWS_ENABLE_MTLS
695bool DetWebServer::tls_require_client_cert(const uint8_t *ca, size_t ca_len)
696{
697 return det_tls_set_client_ca(ca, ca_len);
698}
699
700int DetWebServer::tls_client_subject(uint8_t slot_id, char *out, size_t out_len)
701{
702 return det_tls_peer_subject(slot_id, out, out_len);
703}
704#endif // DETWS_ENABLE_MTLS
705#endif // DETWS_ENABLE_TLS
706
708{
709 if (_listener_count == 0)
711 stop();
712 return begin(cfg);
713}
714
716{
717#ifdef ARDUINO
718 // Stop the worker task(s) before tearing down the slots they service.
720#endif
723 for (uint8_t i = 0; i < MAX_CONNS; i++)
724 http_reset(i);
725#if DETWS_ENABLE_WEBSOCKET
726 ws_init();
727#endif
728#if DETWS_ENABLE_SSE
729 sse_init();
730#endif
731}
732
733/**
734 * @brief Register a route in the route table.
735 *
736 * Paths are stored null-terminated and truncated to MAX_PATH_LEN. The
737 * trailing character of the stored path is inspected to detect wildcard
738 * routes: any path ending in `*` is treated as a prefix match.
739 *
740 * Registrations beyond MAX_ROUTES are silently ignored - callers should
741 * verify return values if overflow is a concern.
742 *
743 * @param path URL path to match, e.g. "/api/*".
744 * @param method HTTP method that triggers this route.
745 * @param callback Handler invoked with (slot_id, request).
746 */
747void fill_route_base(Route *r, const char *path)
748{
749 strncpy(r->path, path, MAX_PATH_LEN - 1);
750 r->path[MAX_PATH_LEN - 1] = '\0';
751 r->is_active = true;
752 size_t len = strnlen(r->path, MAX_PATH_LEN);
753 r->is_wildcard = (len > 0 && r->path[len - 1] == '*');
754 r->is_param = (strstr(r->path, "/:") != nullptr);
755 r->is_regex = false;
757}
758
759void DetWebServer::on(const char *path, HttpMethod method, Handler callback)
760{
761 if (_route_count >= MAX_ROUTES)
762 return;
763 Route *r = &_routes[_route_count++];
764 fill_route_base(r, path);
766 r->method = method;
767 r->callback = callback;
768}
769
770void DetWebServer::on(const char *path, HttpMethod method, Handler callback, DetIface iface)
771{
772 if (_route_count >= MAX_ROUTES)
773 return;
774 Route *r = &_routes[_route_count++];
775 fill_route_base(r, path);
777 r->method = method;
778 r->callback = callback;
779 r->iface_filter = iface;
780}
781
782void DetWebServer::set_ap_ip(uint32_t ap_ip)
783{
784 detws_ap_ip = ap_ip;
785}
786
787void DetWebServer::on_regex(const char *pattern, HttpMethod method, Handler callback)
788{
789 if (_route_count >= MAX_ROUTES)
790 return;
791 Route *r = &_routes[_route_count++];
792 fill_route_base(r, pattern);
794 r->method = method;
795 r->callback = callback;
796 r->is_regex = true;
797}
798
799#if DETWS_ENABLE_AUTH
800void DetWebServer::on(const char *path, HttpMethod method, Handler callback, const char *realm, const char *user,
801 const char *pass, bool digest)
802{
803 if (_route_count >= MAX_ROUTES)
804 return;
805 Route *r = &_routes[_route_count++];
806 fill_route_base(r, path);
808 r->method = method;
809 r->callback = callback;
810 r->auth_required = true;
811 r->auth_digest = digest;
812 strncpy(r->auth_realm, realm, MAX_AUTH_LEN - 1);
813 r->auth_realm[MAX_AUTH_LEN - 1] = '\0';
814 strncpy(r->auth_user, user, MAX_AUTH_LEN - 1);
815 r->auth_user[MAX_AUTH_LEN - 1] = '\0';
816 strncpy(r->auth_pass, pass, MAX_AUTH_LEN - 1);
817 r->auth_pass[MAX_AUTH_LEN - 1] = '\0';
818}
819#endif // DETWS_ENABLE_AUTH
820
821#if DETWS_ENABLE_WEBSOCKET
822void DetWebServer::on_ws(const char *path, WsConnectHandler on_connect, WsMessageHandler on_message,
823 WsCloseHandler on_close)
824{
825 if (_route_count >= MAX_ROUTES)
826 return;
827 Route *r = &_routes[_route_count++];
828 fill_route_base(r, path);
829 r->type = RouteType::ROUTE_WS;
830 r->ws_connect = on_connect;
831 r->ws_message = on_message;
832 r->ws_close = on_close;
833}
834#endif // DETWS_ENABLE_WEBSOCKET
835
836#if DETWS_ENABLE_SSE
837void DetWebServer::on_sse(const char *path, SseConnectHandler on_connect)
838{
839 if (_route_count >= MAX_ROUTES)
840 return;
841 Route *r = &_routes[_route_count++];
842 fill_route_base(r, path);
843 r->type = RouteType::ROUTE_SSE;
844 r->sse_connect = on_connect;
845}
846#endif // DETWS_ENABLE_SSE
847
849{
850 _not_found_handler = callback;
851}
852
853/*
854 * Enable CORS and pre-build the Access-Control response header block.
855 *
856 * The header string is constructed once here rather than at response time to
857 * avoid repeated snprintf calls on the hot path. It is stored in
858 * `_cors_header_buf[]` and injected verbatim into every response when
859 * `_cors_enabled` is true.
860 *
861 * Passing an empty or null origin disables CORS without clearing the buffer -
862 * only the `_cors_enabled` flag matters at dispatch time.
863 *
864 * @param origin Value for the Access-Control-Allow-Origin header, e.g. "*".
865 */
866void DetWebServer::set_cors(const char *origin)
867{
868 if (!origin || origin[0] == '\0')
869 {
870 _cors_enabled = false;
871 _cors_header_buf[0] = '\0';
872 return;
873 }
874 snprintf(_cors_header_buf, CORS_HDR_BUF_SIZE,
875 "Access-Control-Allow-Origin: %s\r\n"
876 "Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS\r\n"
877 "Access-Control-Allow-Headers: Content-Type\r\n",
878 origin);
879 _cors_enabled = true;
880}
881
882void DetWebServer::set_cache_control(const char *value)
883{
884 if (!value || value[0] == '\0')
885 {
886 _cache_control_buf[0] = '\0';
887 return;
888 }
889 snprintf(_cache_control_buf, CACHE_CONTROL_BUF_SIZE, "Cache-Control: %s\r\n", value);
890}
891
892/**
893 * @brief Test whether a route path matches an incoming request path.
894 *
895 * Exact routes use strcmp (full-string equality). Wildcard routes use
896 * strncmp against the prefix up to (but not including) the trailing `*`.
897 *
898 * @param route Registered route path, potentially ending in `*`.
899 * @param is_wildcard True when the route was registered with a trailing `*`.
900 * @param req_path Incoming request path from the parsed HTTP request line.
901 * @return True if the route matches the request path.
902 */
903bool DetWebServer::path_matches(const char *route, bool is_wildcard, const char *req_path)
904{
905 if (!is_wildcard)
906 return strcmp(route, req_path) == 0;
907
908 // Prefix match: compare everything up to (but not including) the '*'
909 size_t prefix_len = strnlen(route, MAX_PATH_LEN) - 1;
910 return strncmp(route, req_path, prefix_len) == 0;
911}
912
913/**
914 * @brief Segment-by-segment match for routes containing `:name` parameters.
915 *
916 * Walks @p route and @p path one `/`-delimited segment at a time. Literal
917 * segments must match exactly; a `:name` segment captures the corresponding
918 * path segment into @p req->path_params. Both must contain the same number of
919 * segments. No wildcard support (`:name` and trailing `*` are not combined).
920 *
921 * @return True on a full match (params captured); false otherwise.
922 */
923// Record one `:name` path parameter (key from the route segment, value from the path segment), each
924// truncated to its buffer. No-op once the param table is full. Extracted to keep the matcher loop flat.
925static void capture_path_param(HttpReq *req, const char *key, size_t klen, const char *val, size_t vlen)
926{
928 return;
929 QueryParam *qp = &req->path_params[req->path_param_count++];
930 if (klen > QUERY_KEY_LEN - 1)
931 klen = QUERY_KEY_LEN - 1;
932 memcpy(qp->key, key, klen);
933 qp->key[klen] = '\0';
934 if (vlen > QUERY_VAL_LEN - 1)
935 vlen = QUERY_VAL_LEN - 1;
936 memcpy(qp->val, val, vlen);
937 qp->val[vlen] = '\0';
938}
939
940static bool match_path_params(const char *route, const char *path, HttpReq *req)
941{
942 req->path_param_count = 0;
943 const char *r = route;
944 const char *p = path;
945
946 while (*r == '/' && *p == '/')
947 {
948 r++;
949 p++;
950 const char *rseg = r;
951 while (*r && *r != '/')
952 r++;
953 size_t rlen = (size_t)(r - rseg);
954 const char *pseg = p;
955 while (*p && *p != '/')
956 p++;
957 size_t plen = (size_t)(p - pseg);
958
959 if (rlen > 0 && rseg[0] == ':')
960 {
961 if (plen == 0)
962 return false; // a `:name` segment must capture a non-empty value
963 capture_path_param(req, rseg + 1, rlen - 1, pseg, plen);
964 }
965 else if (rlen != plen || strncmp(rseg, pseg, rlen) != 0)
966 {
967 return false; // literal segment mismatch
968 }
969 }
970
971 // Both strings must be fully consumed (identical segment counts).
972 return (*r == '\0' && *p == '\0');
973}
974
975/**
976 * @brief Main application tick - tick the session layer then dispatch completed requests.
977 *
978 * Call this repeatedly from loop(). Each call:
979 * 1. Calls server_tick() which runs timeout sweeps + drains the event queue.
980 * 2. Walks all slots; any in ParseState::PARSE_COMPLETE is dispatched via match_and_execute().
981 * 3. Any slot left in ParseState::PARSE_COMPLETE after dispatch (i.e., callback did not
982 * send a response) is reset so it doesn't block the slot.
983 * 4. Any slot in ParseState::PARSE_ERROR receives an automatic 400 response.
984 * 5. Any slot in ParseState::PARSE_ENTITY_TOO_LARGE receives an automatic 413 response.
985 * 6. Any slot in ParseState::PARSE_URI_TOO_LONG receives an automatic 414 response.
986 */
987#if DETWS_ENABLE_WEBSOCKET
988void DetWebServer::ws_dispatch_message(WsConn *ws)
989{
990 for (uint8_t r = 0; r < _route_count; r++)
991 if (_routes[r].type == RouteType::ROUTE_WS && _routes[r].ws_message)
992 {
993 _routes[r].ws_message(ws->ws_id);
994 break;
995 }
996}
997
998void DetWebServer::ws_dispatch_close(WsConn *ws)
999{
1000 for (uint8_t r = 0; r < _route_count; r++)
1001 if (_routes[r].type == RouteType::ROUTE_WS && _routes[r].ws_close)
1002 {
1003 _routes[r].ws_close(ws->ws_id);
1004 break;
1005 }
1006}
1007#endif // DETWS_ENABLE_WEBSOCKET
1008
1010{
1011#ifdef ARDUINO
1012 // The worker task drives the pipeline on its own core; loop() is freed.
1014 return;
1015#endif
1016 service_once();
1017}
1018
1020{
1021 // Wire HTTP's instance-bound poll to the server currently being serviced, so the dispatch loop
1022 // pumps HTTP through the uniform ProtoHandler::on_poll seam (see http_poll_slot). Done here (not
1023 // just begin()) so test paths that drive service_once() directly also install it, and so it always
1024 // targets the running instance. Two pointer stores; negligible at poll cadence.
1025 s_inst.http_instance = this;
1026 http_proto_set_poll(detws_http_on_poll);
1027
1028 server_tick(worker_id);
1029
1030#if DETWS_ENABLE_HTTP3
1031 // Drive the QUIC/HTTP-3 server: ingest queued datagrams, run the engines (which dispatch requests
1032 // through this instance's routes), flush replies. One worker owns it, so requests stay single-threaded.
1033 if (worker_id == 0 && s_inst.h3_running)
1034 quic_server_poll(detws_millis());
1035#endif
1036
1037 for (uint8_t i = 0; i < MAX_CONNS; i++)
1038 {
1039 // This worker services only the slots it owns (all of them at N=1).
1040 if (conn_pool[i].owner != worker_id)
1041 continue;
1042
1043 // Ack-on-consume: reopen the TCP receive window by whatever any consumer
1044 // (HTTP/WS/TLS/service) drained from this slot's ring on the previous pass.
1045 // Transport owns the window math; we just nudge it once per slot per loop.
1047
1048 // Every protocol - HTTP included - is pumped through the one uniform ProtoHandler::on_poll
1049 // seam (no per-protocol branch here). HTTP's poll is instance-bound (it dispatches into this
1050 // server's routes), installed at begin() via http_proto_set_poll() -> http_poll_slot(); the
1051 // singleton pollers (SSH etc.) gate on ConnState::CONN_ACTIVE inside their own on_poll.
1052 const ProtoHandler *ph = proto_get(conn_pool[i].proto);
1053 if (ph && ph->on_poll)
1054 ph->on_poll(i);
1055 }
1056
1057 // Run any callbacks app code deferred to this worker (race-free push path).
1058 detws_worker_run_deferred(worker_id);
1059}
1060
1061// HTTP's instance-bound poll pump. Installed as the HTTP ProtoHandler's on_poll at begin() (via
1062// http_proto_set_poll) so the worker dispatch loop pumps HTTP through the same uniform seam as every
1063// other protocol - no HTTP special case in the loop. Runs the file/chunk send pumps, the WebSocket +
1064// SSE drains, the keep-alive re-parse, and dispatches a completed request into this server's routes.
1065#if DETWS_ENABLE_EDGE_CACHE
1066// Edge-cache async-fetch pump seam (see detws_http_set_edge_poll / services/edge_cache/edge_cache_proxy):
1067// a cache miss suspends the client request and drives the non-blocking origin fetch from this slot's poll.
1068static bool (*s_edge_poll)(uint8_t slot) = nullptr;
1069void detws_http_set_edge_poll(bool (*fn)(uint8_t slot))
1070{
1071 s_edge_poll = fn;
1072}
1073#endif
1074
1076{
1077#if DETWS_ENABLE_EDGE_CACHE
1078 // An edge-cache origin fetch in flight for this slot owns it: pump the fetch and skip the rest of the
1079 // HTTP pipeline until it completes (and hands off to send_chunked for the cached response).
1080 if (s_edge_poll && s_edge_poll(i))
1081 return;
1082#endif
1083#if DETWS_ENABLE_FILE_SERVING
1084 // A file response in flight owns the slot: page out the next window and
1085 // skip the rest of the pipeline until the whole body has been sent.
1086 if (s_send.file[i].active)
1087 {
1088 file_send_pump(i);
1089 return;
1090 }
1091#endif
1092 // Likewise a chunked response in flight: pull + frame the next window.
1093 if (s_send.chunk[i].active)
1094 {
1095 chunk_send_pump(i);
1096 return;
1097 }
1098
1099#if DETWS_ENABLE_WEBSOCKET
1100 // WebSocket slot - drain ring buffer and dispatch ready frames
1101 WsConn *ws = ws_find(i);
1102 if (ws)
1103 {
1104#if DETWS_ENABLE_TLS
1105 if (conn_pool[i].tls)
1106 {
1107 // wss://: the rx ring holds ciphertext, so decrypt records here and
1108 // feed the frame parser, dispatching each completed frame as it
1109 // finishes (one TLS record may carry several WS frames).
1110 uint8_t tbuf[256];
1111 int n;
1112 while ((n = det_tls_read(i, tbuf, sizeof(tbuf))) > 0)
1113 {
1114 for (int k = 0; k < n; k++)
1115 {
1116 ws_feed_byte(ws, tbuf[k]);
1118 {
1119 ws_dispatch_message(ws);
1120 ws_reset_frame(ws);
1121 }
1123 break;
1124 }
1126 break;
1127 }
1129 {
1130 ws_dispatch_close(ws);
1131 ws_free(i);
1132 det_conn_abort_slot(i); // transport owns TLS-free + detach + reset + RST
1133 http_reset(i);
1134 }
1135 return;
1136 }
1137#endif // DETWS_ENABLE_TLS
1138
1139 ws_parse(ws);
1140
1142 {
1143 ws_dispatch_message(ws);
1144 ws_reset_frame(ws);
1145 }
1147 {
1148 ws_dispatch_close(ws);
1149 ws_free(i);
1150 // RFC 6455 5.5.1: close the underlying TCP connection after the close
1151 // handshake. begin_close moves the slot out of ConnState::CONN_ACTIVE so the
1152 // post-close bytes are NOT re-parsed as a new HTTP request (the
1153 // close-frame the WS layer queued still flushes during the dwell).
1155 http_reset(i);
1156 }
1157 return; // slot is owned by WS; skip HTTP dispatch
1158 }
1159#endif // DETWS_ENABLE_WEBSOCKET
1160
1161#if DETWS_ENABLE_SSE
1162 // SSE slot - connection stays open, nothing to parse from client
1163 if (sse_find(i))
1164 return;
1165#endif // DETWS_ENABLE_SSE
1166
1167#if DETWS_ENABLE_KEEPALIVE
1168 // Keep-alive: a slot recycled after a response may already hold the next
1169 // (pipelined) request in its ring buffer with no new EvtType::EVT_DATA to trigger a
1170 // parse. Drain it here each tick so it gets dispatched. TLS slots are
1171 // skipped - their ring holds ciphertext, decrypted in the session layer.
1172 if (conn_pool[i].state == ConnState::CONN_ACTIVE && http_pool[i].parse_state != ParseState::PARSE_COMPLETE
1174 && !conn_pool[i].tls
1175#endif
1176 )
1177 http_parse(i);
1178#endif
1179
1180 // HTTP slot
1181 if (http_pool[i].parse_state == ParseState::PARSE_COMPLETE)
1182 {
1183 match_and_execute(i);
1184 if (http_pool[i].parse_state == ParseState::PARSE_COMPLETE)
1185 http_reset(i);
1186 }
1187 else if (http_pool[i].parse_state == ParseState::PARSE_ERROR)
1188 {
1189 send(i, 400, DET_MIME_TEXT_PLAIN, "Bad Request");
1190 }
1191 else if (http_pool[i].parse_state == ParseState::PARSE_ENTITY_TOO_LARGE)
1192 {
1193 send(i, 413, DET_MIME_TEXT_PLAIN, "Payload Too Large");
1194 }
1195 else if (http_pool[i].parse_state == ParseState::PARSE_URI_TOO_LONG)
1196 {
1197 send(i, 414, DET_MIME_TEXT_PLAIN, "URI Too Long");
1198 }
1199}
1200
1201bool DetWebServer::defer(uint8_t slot, detws_deferred_fn fn, void *arg)
1202{
1203 if (slot >= MAX_CONNS)
1204 return false;
1205 // Route to the worker that owns the slot so the callback runs single-threaded
1206 // alongside that slot's own processing.
1207 return detws_defer(conn_pool[slot].owner, fn, arg);
1208}
1209
1210// ---------------------------------------------------------------------------
1211// Diagnostic endpoint
1212// ---------------------------------------------------------------------------
1213
1214#if DETWS_ENABLE_DIAG
1215void DetWebServer::diag(uint8_t slot_id)
1216{
1217 send(slot_id, 200, DET_MIME_JSON, DETWS_DIAG_JSON);
1218}
1219#endif
1220
1221// ---------------------------------------------------------------------------
1222// Route dispatch
1223// ---------------------------------------------------------------------------
1224
1225// True when the request on this slot used the HEAD method, whose response must
1226// carry the same headers as GET but no message body (RFC 7231 §4.3.2). External
1227// linkage (declared in server/dwserver_internal.h): the split handler TUs call it.
1228bool req_is_head(uint8_t slot_id)
1229{
1230 return strcmp(http_pool[slot_id].method, "HEAD") == 0;
1231}
1232
1233// Append a method token to a comma-separated Allow list, de-duplicating.
1234static void allow_append(char *buf, size_t cap, const char *m)
1235{
1236 if (!m[0] || strstr(buf, m))
1237 return;
1238 size_t len = strnlen(buf, cap);
1239 if (len == 0)
1240 snprintf(buf, cap, "%s", m);
1241 else
1242 snprintf(buf + len, cap - len, ", %s", m);
1243}
1244
1245// Send a terminal text/plain error response that closes the connection: the
1246// status reason (e.g. "405 Method Not Allowed"), one optional pre-formatted extra
1247// header (CRLF-terminated, e.g. "Allow: GET\r\n"), then Content-Type/Length and
1248// "Connection: close". Begins the ConnState::CONN_CLOSING dwell so the bytes drain before
1249// teardown; HEAD omits the body. One owner for the error-and-close path.
1250static void send_error_close(uint8_t slot_id, const char *status, const char *extra_hdr, const char *body)
1251{
1252 TcpConn *conn = &conn_pool[slot_id];
1253 if (conn->state != ConnState::CONN_ACTIVE || conn->pcb == nullptr)
1254 {
1255 http_reset(slot_id);
1256 return;
1257 }
1258
1259 int blen = (int)strnlen(body, 0xFFFF);
1260 char header[RESP_HDR_BUF_SIZE];
1261 int hlen = snprintf(header, sizeof(header),
1262 "HTTP/1.1 %s\r\n"
1263 "%s"
1264 "Content-Type: %s\r\n"
1265 "Content-Length: %d\r\n"
1266 "Connection: close\r\n\r\n",
1267 status, extra_hdr ? extra_hdr : "", DET_MIME_TEXT_PLAIN, blen);
1268
1269 // The last write carries the flush (det_conn_send_flush = write+tcp_output in one marshal), so
1270 // an error-and-close costs one round-trip fewer - and 4xx/5xx closes are the hot path under a
1271 // flood (rate-limit / auth-lockout 429s).
1272 if (blen > 0 && !req_is_head(slot_id))
1273 {
1274 det_conn_send(slot_id, header, (u16_t)hlen);
1275 det_conn_send_flush(slot_id, body, (u16_t)blen);
1276 }
1277 else
1278 {
1279 det_conn_send_flush(slot_id, header, (u16_t)hlen);
1280 }
1281 det_conn_begin_close(slot_id); // dwell in ConnState::CONN_CLOSING until the response drains
1282 http_reset(slot_id);
1283}
1284
1285// Send 405 Method Not Allowed with the required Allow header (RFC 7231 §6.5.5).
1286static void send_method_not_allowed(uint8_t slot_id, const char *allow)
1287{
1288 char extra[80];
1289 snprintf(extra, sizeof(extra), "Allow: %s\r\n", allow);
1290 send_error_close(slot_id, "405 Method Not Allowed", extra, "Method Not Allowed");
1291}
1292
1293#if DETWS_ENABLE_AUTH_LOCKOUT
1294// The peer's family-tagged source address for the connection in slot_id (unspecified on native /
1295// no pcb). Used as the auth-lockout bucket key - the full IPv4 or IPv6 address, so a v6 peer is
1296// never flattened onto a shared v4 bucket nor folded into a collideable hash.
1297static DetIp lockout_client_ip(uint8_t slot_id)
1298{
1299 DetIp ip;
1301 det_conn_remote_addr(slot_id, &ip);
1302 return ip;
1303}
1304
1305// 429 Too Many Requests with Retry-After (auth lockout active). Closes the
1306// connection - mirrors send_method_not_allowed's PCB lifecycle.
1307static void send_too_many_requests(uint8_t slot_id, uint32_t retry_after_s)
1308{
1309 char extra[40];
1310 snprintf(extra, sizeof(extra), "Retry-After: %lu\r\n", (unsigned long)retry_after_s);
1311 send_error_close(slot_id, "429 Too Many Requests", extra, "Too Many Requests");
1312}
1313#endif // DETWS_ENABLE_AUTH_LOCKOUT
1314
1315bool DetWebServer::route_admits(const Route *r, uint8_t slot_id, HttpReq *req) const
1316{
1317 if (!r->is_active)
1318 return false;
1319 bool matched = r->is_regex ? regex_match(r->path, req->path)
1320 : r->is_param ? match_path_params(r->path, req->path, req)
1321 : path_matches(r->path, r->is_wildcard, req->path);
1322 if (!matched)
1323 return false;
1324 // Per-route interface gate: a route bound to STA/AP is invisible on the
1325 // other interface (falls through to other routes / 404).
1326 if (r->iface_filter != DetIface::DETIFACE_ANY && r->iface_filter != det_conn_iface(slot_id))
1327 return false;
1328 return true;
1329}
1330
1331#if DETWS_ENABLE_CSRF
1332bool DetWebServer::csrf_gate(uint8_t slot_id, HttpReq *req, HttpMethod method)
1333{
1334 // Built-in token endpoint: GET /csrf issues a signed token (also set as the
1335 // csrf cookie) for clients to echo in X-CSRF-Token on state-changing requests.
1336 if (method == HttpMethod::HTTP_GET && strcmp(req->path, "/csrf") == 0)
1337 {
1338 char tok[CSRF_TOKEN_BUF];
1339 if (csrf_issue(tok, sizeof(tok)) > 0)
1340 {
1341 set_cookie(slot_id, "csrf", tok, "Path=/; SameSite=Strict");
1342 char body[CSRF_TOKEN_BUF + 16];
1343 snprintf(body, sizeof(body), "{\"token\":\"%s\"}", tok);
1344 send(slot_id, 200, DET_MIME_JSON, body);
1345 }
1346 else
1347 {
1348 send(slot_id, 500, DET_MIME_TEXT_PLAIN, "CSRF unavailable");
1349 }
1350 return true;
1351 }
1352
1353 // Enforce CSRF on every state-changing method: require a valid signed
1354 // X-CSRF-Token header (GET / HEAD / OPTIONS are exempt - not state-changing).
1355 if (method == HttpMethod::HTTP_POST || method == HttpMethod::HTTP_PUT || method == HttpMethod::HTTP_PATCH ||
1356 method == HttpMethod::HTTP_DELETE)
1357 {
1358 const char *tok = http_get_header(req, "X-CSRF-Token");
1359 if (!tok || !csrf_verify(tok))
1360 {
1361 send(slot_id, 403, DET_MIME_TEXT_PLAIN, "CSRF token missing or invalid");
1362 return true;
1363 }
1364 }
1365 return false;
1366}
1367#endif // DETWS_ENABLE_CSRF
1368
1369#if DETWS_ENABLE_WEBSOCKET
1370void DetWebServer::handle_ws_route(uint8_t slot_id, HttpReq *req, HttpMethod method, const Route *r)
1371{
1372 const char *upgrade_hdr = http_get_header(req, "Upgrade");
1373 // RFC 6455 4.2.1: a valid handshake needs Upgrade: websocket AND a Connection
1374 // header that includes the "Upgrade" token.
1375 bool is_ws_upgrade = (method == HttpMethod::HTTP_GET) && upgrade_hdr &&
1376 (strcasecmp(upgrade_hdr, "websocket") == 0) &&
1377 conn_has_token(http_get_header(req, "Connection"), "upgrade");
1378 if (!is_ws_upgrade)
1379 {
1380 send(slot_id, 400, DET_MIME_TEXT_PLAIN, "WebSocket upgrade required");
1381 return;
1382 }
1383 // RFC 6455 §4.2.1: only version 13 is supported; otherwise 426.
1384 const char *ws_ver = http_get_header(req, "Sec-WebSocket-Version");
1385 if (!ws_ver || strcmp(ws_ver, "13") != 0)
1386 {
1387 ws_send_version_required(slot_id);
1388 return;
1389 }
1390 // A failed upgrade here means a malformed/oversized Sec-WebSocket-Key (a
1391 // client error, RFC 6455 4.2.1), so answer 400 rather than 503.
1392 if (!ws_do_upgrade(slot_id, req, r->ws_connect))
1393 send(slot_id, 400, DET_MIME_TEXT_PLAIN, "Bad WebSocket handshake");
1394}
1395#endif // DETWS_ENABLE_WEBSOCKET
1396
1397#if DETWS_ENABLE_AUTH
1398bool DetWebServer::authorize_request(uint8_t slot_id, HttpReq *req, const Route *r)
1399{
1400#if DETWS_ENABLE_AUTH_LOCKOUT
1401 DetIp cip = lockout_client_ip(slot_id);
1402 uint32_t now = (uint32_t)millis();
1403 uint32_t remain = auth_lockout_remaining_ms(&cip, now);
1404 if (remain > 0)
1405 {
1406 // Address is locked out: 429 + Retry-After, no credential check.
1407 send_too_many_requests(slot_id, (remain + 999) / 1000);
1408 return false;
1409 }
1410#endif
1411 bool stale = false;
1412 bool ok = r->auth_digest ? check_digest_auth(slot_id, req, r, &stale) : check_basic_auth(slot_id, req, r);
1413#if DETWS_ENABLE_AUTH_LOCKOUT
1414 // A stale-nonce retry carries valid credentials, so it is not a failed
1415 // attempt: don't count it toward the lockout (nor reset the counter).
1416 if (ok)
1417 auth_lockout_succeed(&cip);
1418 else if (!stale)
1419 auth_lockout_fail(&cip, now);
1420#endif
1421 if (!ok)
1422 {
1423 send_unauth(slot_id, r, stale);
1424 return false;
1425 }
1426 return true;
1427}
1428#endif // DETWS_ENABLE_AUTH
1429
1430bool DetWebServer::dispatch_matched_route(uint8_t slot_id, HttpReq *req, HttpMethod method, Route *r,
1431 bool *path_matched, char *allow_buf, size_t allow_cap)
1432{
1433#if DETWS_ENABLE_WEBSOCKET
1434 if (r->type == RouteType::ROUTE_WS)
1435 {
1436 handle_ws_route(slot_id, req, method, r);
1437 return true;
1438 }
1439#endif // DETWS_ENABLE_WEBSOCKET
1440
1441#if DETWS_ENABLE_SSE
1442 if (r->type == RouteType::ROUTE_SSE)
1443 {
1444 if (!sse_do_upgrade(slot_id, req, r->sse_connect))
1445 send(slot_id, 503, DET_MIME_TEXT_PLAIN, "Service Unavailable");
1446 return true;
1447 }
1448#endif // DETWS_ENABLE_SSE
1449
1450#if DETWS_ENABLE_FILE_SERVING
1451 if (r->type == RouteType::ROUTE_STATIC)
1452 {
1453 // Static mounts answer GET (and HEAD via GET); other methods → 405.
1454 if (method != HttpMethod::HTTP_GET && method != HttpMethod::HTTP_HEAD)
1455 {
1456 *path_matched = true;
1457 allow_append(allow_buf, allow_cap, "GET");
1458 allow_append(allow_buf, allow_cap, "HEAD");
1459 return false;
1460 }
1461 serve_static_request(slot_id, req, r);
1462 return true;
1463 }
1464#endif // DETWS_ENABLE_FILE_SERVING
1465
1466 // RouteType::ROUTE_HTTP - a HEAD request is served by the GET handler with the
1467 // response body suppressed (RFC 7231 §4.3.2).
1468 bool method_ok = (r->method == method) || (method == HttpMethod::HTTP_HEAD && r->method == HttpMethod::HTTP_GET);
1469 if (!method_ok)
1470 {
1471 // Path matches but method differs - record it for a 405 + Allow.
1472 *path_matched = true;
1473 allow_append(allow_buf, allow_cap, method_name(r->method));
1474 // A GET route also answers HEAD, so advertise it in Allow.
1475 if (r->method == HttpMethod::HTTP_GET)
1476 allow_append(allow_buf, allow_cap, "HEAD");
1477 return false;
1478 }
1479#if DETWS_ENABLE_AUTH
1480 if (r->auth_required && !authorize_request(slot_id, req, r))
1481 return true; // 401/429 already sent
1482#endif // DETWS_ENABLE_AUTH
1483 r->callback(slot_id, req);
1484 return true;
1485}
1486
1487void DetWebServer::match_and_execute(uint8_t slot_id)
1488{
1489 HttpReq *req = &http_pool[slot_id];
1490 HttpMethod method = parse_method(req->method);
1491
1492 // Start each request with no carried-over custom response headers or
1493 // captured path parameters.
1494 _extra_hdr[slot_id][0] = '\0';
1495 req->path_param_count = 0;
1496
1497 // Built-in rate limiter first (cheapest rejection under flood), then the
1498 // user middleware chain. Either may short-circuit with a response.
1499 if (rate_limit_check(slot_id))
1500 return;
1501 if (run_middleware(slot_id, req))
1502 return;
1503
1504#if DETWS_ENABLE_WEBDAV
1505 // A WebDAV mount owns its whole subtree and every method on it (including
1506 // PROPFIND/MKCOL/etc., which parse_method() does not recognize), so intercept
1507 // before the unknown-method 501 and the normal route loop.
1508 if (try_serve_dav(slot_id, req))
1509 return;
1510#endif
1511
1512 // CORS preflight
1513 if (method == HttpMethod::HTTP_OPTIONS && _cors_enabled)
1514 {
1515 send_empty(slot_id, 204);
1516 return;
1517 }
1518
1519#if DETWS_ENABLE_CSRF
1520 if (csrf_gate(slot_id, req, method))
1521 return;
1522#endif
1523
1524 // RFC 7230 §3.3.1: reject Transfer-Encoding
1525 if (http_get_header(req, "Transfer-Encoding") != nullptr)
1526 {
1527 send(slot_id, 501, DET_MIME_TEXT_PLAIN, "Not Implemented");
1528 return;
1529 }
1530
1531 // RFC 7231 §6.5.2: a method the server does not implement → 501.
1532 if (method == HttpMethod::HTTP_METHOD_UNKNOWN)
1533 {
1534 send(slot_id, 501, DET_MIME_TEXT_PLAIN, "Not Implemented");
1535 return;
1536 }
1537
1538 // For RFC 7231 §6.5.5: if a path matches but no method does, answer 405
1539 // with an Allow header listing the methods registered for that path.
1540 bool path_matched = false;
1541 char allow_buf[64];
1542 allow_buf[0] = '\0';
1543
1544 for (uint8_t i = 0; i < _route_count; i++)
1545 {
1546 Route *r = &_routes[i];
1547 if (!route_admits(r, slot_id, req))
1548 continue;
1549 if (dispatch_matched_route(slot_id, req, method, r, &path_matched, allow_buf, sizeof(allow_buf)))
1550 return;
1551 }
1552
1553 // Path existed but the method was not allowed (RFC 7231 §6.5.5).
1554 if (path_matched)
1555 {
1556 send_method_not_allowed(slot_id, allow_buf);
1557 return;
1558 }
1559
1560 if (_not_found_handler)
1561 _not_found_handler(slot_id, req);
1562 else
1563 send(slot_id, 404, DET_MIME_TEXT_PLAIN, "Not Found");
1564}
1565
1566/*
1567 * Build and transmit an HTTP response with a body.
1568 *
1569 * Uses a 512-byte stack buffer for headers. CORS headers are appended when
1570 * `_cors_enabled`. The slot is freed (state → ConnState::CONN_FREE, pcb → nullptr)
1571 * *before* the tcp_write + tcp_close sequence to ensure any error callback
1572 * that lwIP fires during the write sees the slot as already released.
1573 *
1574 * If the slot's connection is not active (e.g., already timed-out or the
1575 * PCB is null) the slot is reset and the function returns without writing.
1576 *
1577 * @param slot_id Connection slot index.
1578 * @param code HTTP status code, e.g. 200.
1579 * @param content_type MIME type string, e.g. "application/json".
1580 * @param payload Null-terminated body string to send.
1581 */
1582void DetWebServer::send(uint8_t slot_id, int code, const char *content_type, const char *payload)
1583{
1584 // Null-terminated convenience wrapper over the explicit-length send.
1585 send(slot_id, code, content_type, (const uint8_t *)payload, payload ? strnlen(payload, 0xFFFF) : 0);
1586}
1587
1588void DetWebServer::send(uint8_t slot_id, int code, const char *content_type, const uint8_t *body, size_t body_len)
1589{
1590 if (slot_id >= CONN_POOL_SLOTS)
1591 return; // guard the public entry: never index conn_pool out of range
1592 const char *payload = (const char *)body;
1593 TcpConn *conn = &conn_pool[slot_id];
1594#if DETWS_ENABLE_HTTP2 || DETWS_ENABLE_HTTP3
1595 // A self-framing protocol (HTTP/2, HTTP/3) installed its own response sink at negotiation /
1596 // dispatch time; route through it and let it own its framing + connection lifecycle. This runs
1597 // before the HTTP/1.1 pcb check because that check is a TCP-transport concern (the HTTP/3 slot
1598 // has no pcb by design, and an h2 connection manages its own).
1599 if (conn->resp_sink)
1600 {
1601 conn->resp_sink(slot_id, code, content_type, payload, body_len);
1602 return;
1603 }
1604#endif
1605 if (conn->state != ConnState::CONN_ACTIVE || conn->pcb == nullptr)
1606 {
1607 http_reset(slot_id);
1608 return;
1609 }
1610
1611 int payload_len = (int)(body_len > 0xFFFF ? 0xFFFF : body_len);
1612
1613 bool keep;
1614 const char *cl = resp_conn_hdr(slot_id, &keep);
1615
1616 char header[RESP_HDR_BUF_SIZE];
1617 int hlen = snprintf(header, sizeof(header),
1618 "HTTP/1.1 %d %s\r\n"
1619 "Content-Type: %s\r\n"
1620 "Content-Length: %d\r\n",
1621 code, status_text(code), content_type, payload_len);
1622 hlen = append_resp_trailer(header, sizeof(header), hlen, slot_id, cl);
1623
1624 // The slot stays ConnState::CONN_ACTIVE through the write for both paths; resp_end then
1625 // begins the ConnState::CONN_CLOSING dwell on the close path (finalized once ACKed).
1626
1627 bool head = req_is_head(slot_id);
1628
1629 // HEAD responses carry the headers (incl. Content-Length) but no body. For a
1630 // body that fits the header scratch, coalesce headers+body into a single send
1631 // so the response costs one tcpip_thread round-trip instead of two. The final
1632 // write also carries the flush (det_conn_send_flush), so resp_end skips it -
1633 // a keep-alive small response is now one marshal (write+output) instead of two.
1634 if (!head && payload_len > 0 && (size_t)hlen + (size_t)payload_len <= sizeof(header))
1635 {
1636 memcpy(header + hlen, payload, (size_t)payload_len);
1637 det_conn_send_flush(slot_id, header, (u16_t)(hlen + payload_len));
1638 }
1639 else if (!head && payload_len > 0)
1640 {
1641 det_conn_send(slot_id, header, (u16_t)hlen);
1642 det_conn_send_flush(slot_id, payload, (u16_t)payload_len);
1643 }
1644 else
1645 {
1646 det_conn_send_flush(slot_id, header, (u16_t)hlen);
1647 }
1648
1649 resp_end(slot_id, code, payload_len, keep, /*pre_flushed=*/true);
1650}
1651
1652/*
1653 * Build and transmit an HTTP response with no body.
1654 *
1655 * Used for CORS preflight (204) and any response where only status headers
1656 * are needed. Behaves identically to send() regarding slot lifecycle and
1657 * PCB ownership transfer - the slot is freed before the lwIP write call.
1658 *
1659 * @param slot_id Connection slot index.
1660 * @param code HTTP status code, e.g. 204.
1661 */
1662void DetWebServer::send_empty(uint8_t slot_id, int code)
1663{
1664 if (slot_id >= CONN_POOL_SLOTS)
1665 return;
1666 TcpConn *conn = &conn_pool[slot_id];
1667#if DETWS_ENABLE_HTTP2 || DETWS_ENABLE_HTTP3
1668 if (conn->resp_sink)
1669 {
1670 conn->resp_sink(slot_id, code, "text/plain", "", 0);
1671 return;
1672 }
1673#endif
1674 if (conn->state != ConnState::CONN_ACTIVE || conn->pcb == nullptr)
1675 {
1676 http_reset(slot_id);
1677 return;
1678 }
1679
1680 bool keep;
1681 const char *cl = resp_conn_hdr(slot_id, &keep);
1682
1683 char header[RESP_HDR_BUF_SIZE];
1684 int hlen = snprintf(header, sizeof(header),
1685 "HTTP/1.1 %d %s\r\n"
1686 "Content-Length: 0\r\n",
1687 code, status_text(code));
1688 hlen = append_resp_trailer(header, sizeof(header), hlen, slot_id, cl);
1689
1690 det_conn_send_flush(slot_id, header, (u16_t)hlen);
1691
1692 resp_end(slot_id, code, 0, keep, /*pre_flushed=*/true);
1693}
1694
1695void DetWebServer::redirect(uint8_t slot_id, int code, const char *location)
1696{
1697 if (slot_id >= MAX_CONNS)
1698 return;
1699 TcpConn *conn = &conn_pool[slot_id];
1700 if (conn->state != ConnState::CONN_ACTIVE || conn->pcb == nullptr)
1701 {
1702 http_reset(slot_id);
1703 return;
1704 }
1705
1706 // Only the redirect status codes are valid here; anything else → 302.
1707 switch (code)
1708 {
1709 case 301:
1710 case 302:
1711 case 303:
1712 case 307:
1713 case 308:
1714 break;
1715 default:
1716 code = 302;
1717 break;
1718 }
1719
1720 bool keep;
1721 const char *cl = resp_conn_hdr(slot_id, &keep);
1722
1723 char header[RESP_HDR_BUF_SIZE];
1724 int hlen = snprintf(header, sizeof(header),
1725 "HTTP/1.1 %d %s\r\n"
1726 "Location: %s\r\n"
1727 "Content-Length: 0\r\n",
1728 code, status_text(code), location);
1729 hlen = append_resp_trailer(header, sizeof(header), hlen, slot_id, cl);
1730
1731 det_conn_send_flush(slot_id, header, (u16_t)hlen);
1732
1733 resp_end(slot_id, code, 0, keep, /*pre_flushed=*/true);
1734}
#define CONN_POOL_SLOTS
#define DETWS_ENABLE_HTTP3
HTTP/3 (RFC 9114) over QUIC (RFC 9000) - implemented, host-tested end-to-end (HW verification pending...
DetIface
Network interface a connection arrived on (for per-route filtering).
@ DETIFACE_STA
Station interface (joined to an AP / your LAN).
@ DETIFACE_ANY
Unknown / no filter (matches any interface).
#define DETWS_ENABLE_TLS
TLS (HTTPS/WSS) via mbedTLS with a static memory pool (ESP32-only).
#define MAX_LISTENERS
Maximum number of simultaneously active listener ports.
ConnProto
Application protocol spoken on a listener port or connection slot.
@ PROTO_HTTP
HTTP/1.1 with optional WS and SSE upgrades.
#define CACHE_CONTROL_BUF_SIZE
Size of the optional Cache-Control header line stored in DetWebServer.
#define RESP_HDR_BUF_SIZE
Stack buffer for HTTP response header lines in send() / send_empty() / send_unauth() / serve_file().
#define QUERY_VAL_LEN
Maximum query-parameter value length.
#define DETWS_KEEPALIVE_MAX_REQUESTS
Maximum requests served on one keep-alive connection before it is closed.
#define BODY_BUF_SIZE
Maximum request body bytes stored in HttpReq::body.
#define MAX_MIDDLEWARE
Maximum globally-registered middleware functions.
#define MAX_HEADERS
Maximum HTTP headers stored per request.
#define MAX_PATH_LEN
Maximum URL path length (including leading /).
#define MAX_AUTH_LEN
Maximum username or password length for HTTP Basic Authentication.
#define CORS_HDR_BUF_SIZE
Size of the pre-built CORS header block stored in DetWebServer.
#define MAX_CONNS
Maximum simultaneous TCP connections (fixed static pool; ~3.95 KB of internal RAM per slot).
#define QUERY_KEY_LEN
Maximum query-parameter key length.
#define MAX_PATH_PARAMS
Maximum number of :name path parameters captured per route match.
#define MAX_ROUTES
Maximum simultaneously registered routes.
Per-peer brute-force lockout for HTTP auth (DETWS_ENABLE_AUTH_LOCKOUT).
Base64 encoder/decoder.
Single-port HTTP server with deterministic, zero-allocation execution.
Definition dwserver.h:348
void set_cookie(uint8_t slot_id, const char *name, const char *value, const char *attrs=nullptr)
Queue a Set-Cookie response header for the next send on this slot.
Definition response.cpp:289
void set_cache_control(const char *value)
Set the Cache-Control header emitted for static files.
Definition dwserver.cpp:882
void on_regex(const char *pattern, HttpMethod method, Handler callback)
Register a route whose path is a regular expression.
Definition dwserver.cpp:787
void on(const char *path, HttpMethod method, Handler callback)
Register a route handler.
Definition dwserver.cpp:759
void handle()
Drive the server - call every Arduino loop() iteration.
int32_t restart(const WebServerConfig *cfg=nullptr)
Hard-reset all connections and re-open all registered listeners.
Definition dwserver.cpp:707
int32_t listen(uint16_t port, ConnProto proto=ConnProto::PROTO_HTTP)
Register a port to listen on when begin() is called.
Definition dwserver.cpp:411
void send(uint8_t slot_id, int code, const char *content_type, const char *payload)
Send an HTTP response with a body and close the connection.
void on_not_found(Handler callback)
Register a fallback handler for unmatched requests.
Definition dwserver.cpp:848
void send_empty(uint8_t slot_id, int code)
Send a headers-only HTTP response and close the connection.
void http_poll_slot(uint8_t slot_id)
The instance-bound HTTP poll pump for one slot (the HTTP ProtoHandler's on_poll).
bool defer(uint8_t slot, detws_deferred_fn fn, void *arg)
Run fn(arg) on the worker that owns connection slot.
void on_request_log(RequestLogCb cb)
Install a per-request access-log callback (one hook, no buffering).
Definition dwserver.cpp:259
void service_once(int worker_id=0)
Run exactly one service iteration for worker worker_id (the body driven by that worker's task,...
void set_cors(const char *origin)
Enable CORS by pre-building the Access-Control headers.
Definition dwserver.cpp:866
void redirect(uint8_t slot_id, int code, const char *location)
Send an HTTP redirect (Location header, empty body) and close.
void set_ap_ip(uint32_t ap_ip)
Tell the server the softAP IPv4 address for STA/AP route filtering.
Definition dwserver.cpp:782
int32_t begin(const WebServerConfig *cfg=nullptr)
Initialize all connection slots and open all registered listeners.
Definition dwserver.cpp:495
DetWebServer()
Construct a DetWebServer with an empty routing table.
Definition dwserver.cpp:236
void stop()
Gracefully stop the server.
Definition dwserver.cpp:715
static void pool_init(const WebServerConfig *cfg=nullptr)
Initialize the connection pool and store the runtime config.
Definition tcp.cpp:608
static void stop()
Abort all active connections and reset the pool to ConnState::CONN_FREE.
Definition tcp.cpp:625
Pluggable monotonic clock for all library timing.
uint32_t detws_millis(void)
The library's monotonic time at 1000 Hz (milliseconds).
Definition clock.h:71
Stateless HMAC-signed CSRF token (DETWS_ENABLE_CSRF).
void fill_route_base(Route *r, const char *path)
Register a route in the route table.
Definition dwserver.cpp:747
const char * status_text(int code)
Convert an HTTP status code to its standard reason phrase.
Definition dwserver.cpp:112
SendCtx s_send
Definition dwserver.cpp:97
bool req_is_head(uint8_t slot_id)
True if the request in slot slot_id used the HEAD method (send headers, no body).
Layer 7 (Application) - public HTTP routing API.
@ DETWS_ERR_LISTEN_FAILED
A listener failed to open (bind/listen/lwIP error).
@ DETWS_ERR_LISTENER_FULL
listen(): listener pool (MAX_LISTENERS) is full.
@ DETWS_ERR_NO_LISTENERS
begin() called before any listen() / begin(port).
void(* Handler)(uint8_t slot_id, HttpReq *request)
Callback signature for HTTP request handlers.
Definition dwserver.h:106
void(* RequestLogCb)(const char *method, const char *path, int status, int body_len)
Per-request access-log callback (see DetWebServer::on_request_log()).
Definition dwserver.h:126
@ ROUTE_HTTP
Standard HTTP request/response.
HttpMethod
HTTP request methods supported by the router.
Definition dwserver.h:77
@ HTTP_METHOD_UNKNOWN
Unrecognized method token → 501 Not Implemented.
@ HTTP_DELETE
Idempotent delete.
@ HTTP_POST
Non-idempotent create / action.
@ HTTP_OPTIONS
Capability query / CORS preflight.
@ HTTP_PATCH
Partial update.
@ HTTP_GET
Safe, idempotent read.
@ HTTP_PUT
Idempotent replace.
@ HTTP_HEAD
Same as GET but no response body.
Library-private declarations shared between dwserver.cpp and the src/server/*.cpp request-handler tra...
bool regex_match(const char *pattern, const char *path)
Whole-path regex match (anchored both ends; bounded by RE_MAX_STEPS, fails closed)....
Definition regex.cpp:188
Bridge between the HTTP/2 engine (h2_conn) and the server's request pipeline.
Tiny no-stdlib hex encode / decode / nibble helpers (one shared copy).
const char * http_get_header(const HttpReq *req, const char *key)
Look up a header value by name (case-insensitive).
HttpReq http_pool[CONN_POOL_SLOTS]
Pool of parser contexts, one per connection-pool slot (incl. reserved dispatch slots).
@ PARSE_ERROR
Unrecoverable parse failure → 400.
@ PARSE_ENTITY_TOO_LARGE
Content-Length > BODY_BUF_SIZE → 413.
@ PARSE_COMPLETE
Full request parsed; ready for dispatch.
@ PARSE_URI_TOO_LONG
Path exceeds MAX_PATH_LEN → 414.
@ HTTP_11
HTTP/1.1 - persistent connection by default.
@ DET_IP_NONE
empty / unparsed
int32_t listener_add(uint8_t idx, uint16_t port, ConnProto proto, bool tls)
Create a listening socket on port and register it at idx.
Definition listener.cpp:466
void listener_stop_all()
Stop all active listeners.
Definition listener.cpp:545
Layer 4 (Listener) - per-port TCP listener abstraction.
Shared HTTP Content-Type ("MIME") string constants (one source of truth).
size_t detws_ntp_http_date(char *out, size_t out_cap)
Format the current time as an RFC 7231 IMF-fixdate (HTTP Date).
Optional SNTP wall-clock time sync (DETWS_ENABLE_NTP).
void http_reset(uint8_t slot_id)
Reset the HTTP parser for a connection slot.
void http_proto_set_poll(void(*fn)(uint8_t slot))
Install the HTTP per-slot poll pump (the routing core's instance-bound on_poll).
void http_parse(uint8_t slot_id)
Drain the transport ring buffer and advance the HTTP parser.
Layer 6 (Presentation) - wires the transport ring buffer to the HTTP parser.
Layer 5 (Session) - per-protocol connection handler dispatch table.
const ProtoHandler * proto_get(ConnProto proto)
Look up the handler for proto.
Definition session.cpp:44
HTTP/3 server glue - binds UDP to a pool of QUIC + HTTP/3 connections (RFC 9000/9114).
void server_tick(int worker_id)
Drive the session layer for one Arduino loop iteration.
Definition session.cpp:91
Software SHA-1 implementation - no platform dependencies.
void sse_init()
Initialize all SSE pool slots to inactive.
Definition sse.cpp:16
SseConn * sse_find(uint8_t slot_id)
Find the SseConn for a given TCP slot, or nullptr.
Definition sse.cpp:43
SHA-256 (FIPS 180-4) - streaming context and one-shot API.
bool active
a chunked response is in progress on this slot.
A v4 or v6 address in network (big-endian) byte order.
Definition ip.h:52
DetIpFamily family
address family tag
Definition ip.h:53
A single HTTP header field (key: value).
Definition http_parser.h:90
char val[MAX_VAL_LEN]
Field value, null-terminated.
Definition http_parser.h:92
char key[MAX_KEY_LEN]
Field name, null-terminated.
Definition http_parser.h:91
Fully-parsed HTTP/1.1 request.
Header headers[MAX_HEADERS]
Captured header fields.
char query[MAX_QUERY_LEN]
Raw query string (after ?).
char method[DETWS_METHOD_BUF_SIZE]
HTTP method, null-terminated (OPTIONS, or WebDAV methods when enabled).
uint8_t body[BODY_BUF_SIZE+1]
Stored body bytes, always null-terminated.
QueryParam path_params[MAX_PATH_PARAMS]
:name captures from the matched route.
uint8_t header_count
Valid entries in headers[].
ParseState parse_state
Current parser state.
size_t path_idx
Write cursor into path[].
size_t content_length
Value of Content-Length header (0 if absent).
uint8_t path_param_count
Valid entries in path_params[].
size_t body_len
Bytes stored in body[] (≤ BODY_BUF_SIZE).
HttpVersion version
Protocol version parsed from the request line.
char path[MAX_PATH_LEN]
URL path, null-terminated; no query string.
size_t body_bytes_read
Body bytes received (may exceed BODY_BUF_SIZE).
size_t query_idx
Write cursor into query[].
DetWebServer * worker_server
Definition dwserver.cpp:432
DetWebServer * http_instance
Definition dwserver.cpp:436
Per-protocol connection event/poll callbacks (Layer 5 dispatch vtable).
void(* on_poll)(uint8_t slot)
Called for an active slot each handle() loop (nullable).
A single parsed query-string parameter.
Definition http_parser.h:97
char val[QUERY_VAL_LEN]
Parameter value (empty string if absent).
Definition http_parser.h:99
char key[QUERY_KEY_LEN]
Parameter name, null-terminated.
Definition http_parser.h:98
Internal route entry stored in the routing table.
Definition dwserver.h:244
bool is_param
true when the path contains a :name segment.
Definition dwserver.h:275
RouteType type
HTTP, WS, or SSE.
Definition dwserver.h:246
bool is_regex
true when the path is a regex (see on_regex()).
Definition dwserver.h:276
Handler callback
HTTP handler (RouteType::ROUTE_HTTP only).
Definition dwserver.h:248
DetIface iface_filter
Interface gate; DetIface::DETIFACE_ANY (0) = match any interface.
Definition dwserver.h:277
HttpMethod method
HTTP method (RouteType::ROUTE_HTTP only).
Definition dwserver.h:247
bool is_active
false for unused table slots.
Definition dwserver.h:273
bool is_wildcard
true when path ends with *.
Definition dwserver.h:274
char path[MAX_PATH_LEN]
Null-terminated path pattern.
Definition dwserver.h:245
ChunkSend chunk[MAX_CONNS]
A single TCP connection context.
Definition tcp.h:72
DetIface iface
Interface this connection arrived on; set at accept time.
Definition tcp.h:90
struct tcp_pcb * pcb
lwIP PCB; null when slot is free.
Definition tcp.h:75
DetAtomic< ConnState > state
Lifecycle state; acquire/release for inter-task visibility.
Definition tcp.h:74
Runtime-tunable server parameters.
WebSocket connection state stored in ws_pool[].
Definition websocket.h:125
uint8_t ws_id
Index into ws_pool[] (set at init).
Definition websocket.h:126
WsParseState parse_state
Current frame parser state.
Definition websocket.h:130
bool det_conn_send(uint8_t slot, const void *data, u16_t len)
Send len bytes on connection slot (copies data; TLS-aware).
Definition tcp.cpp:362
void det_conn_flush(uint8_t slot)
Flush queued bytes / finish the send on slot (TLS-aware).
Definition tcp.cpp:413
void det_conn_begin_close(uint8_t slot_id)
Begin a graceful close that dwells in ConnState::CONN_CLOSING until the peer ACKs.
Definition tcp.cpp:572
uint32_t detws_ap_ip
softAP IPv4 address (network byte order) for STA/AP interface tagging.
Definition tcp.cpp:349
void det_conn_ack_consumed(uint8_t slot)
Reopen the TCP receive window by however much slot has drained.
Definition tcp.cpp:427
TcpConn conn_pool[CONN_POOL_SLOTS]
Static pool of connection contexts. Defined in tcp.cpp. Sized CONN_POOL_SLOTS: MAX_CONNS TCP slots pl...
Definition tcp.cpp:347
bool det_conn_send_flush(uint8_t slot, const void *data, u16_t len)
Send len bytes on slot and flush in a single tcpip_thread round-trip.
Definition tcp.cpp:378
bool det_conn_remote_addr(uint8_t slot, DetIp *out)
The connected peer's address as a family-tagged DetIp (IPv4 or IPv6).
Definition tcp.cpp:691
void det_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:496
@ CONN_ACTIVE
Live connection; PCB is valid.
@ CONN_FREE
Slot is available; no PCB is attached.
size_t detws_time_http_date(char *out, size_t out_cap)
The current best time (detws_time_now, any registered NTP / GPS / RTC / ... source) formatted as an R...
Multi-source time fallback matrix (DETWS_ENABLE_TIME_SOURCE).
Deterministic TLS engine: mbedTLS over a static memory pool (DETWS_ENABLE_TLS).
Layer 7 (Application) - embedded web assets generated from src/web/input/.
WebDAV server core (RFC 4918): method classification, header parsing, and the 207 Multi-Status XML bu...
void ws_free(uint8_t slot_id)
Free the WsConn associated with a TCP slot.
Definition websocket.cpp:77
void ws_feed_byte(WsConn *ws, uint8_t byte)
Feed one already-plaintext byte through the WS frame state machine.
void ws_close(WsConn *ws, WsCloseCode code)
Send a Close frame and mark the slot WsParseState::WS_CLOSED.
void ws_parse(WsConn *ws)
Drain the ring buffer for slot_id and feed bytes to the WS parser.
WsConn * ws_find(uint8_t slot_id)
Find the WsConn for a given TCP slot, or nullptr if none.
Definition websocket.cpp:67
void ws_reset_frame(WsConn *ws)
Reset the frame parser back to WsParseState::WS_HEADER1, ready for the next frame.
void ws_init()
Initialize all WebSocket pool slots to inactive.
Definition websocket.cpp:31
@ WS_FRAME_READY
Complete frame ready for dispatch.
@ WS_ERROR
Protocol error; close frame has been queued.
@ WS_CLOSED
Connection closed; slot may be recycled.
void detws_workers_stop(void)
Signal the worker task(s) to exit and wait briefly for them. No-op on host.
Definition worker.cpp:162
bool detws_workers_running(void)
True while worker task(s) are running (always false on host).
Definition worker.cpp:172
void detws_workers_start(detws_worker_pump_fn pump)
Spawn the worker task(s) and start them running pump. No-op on host.
Definition worker.cpp:112
bool detws_defer(int worker_id, detws_deferred_fn fn, void *arg)
Run fn(arg) on worker worker_id. Returns false if the queue is full.
Definition worker.cpp:130
void detws_worker_run_deferred(int worker_id)
Drain and run worker worker_id's deferred callbacks (called by the worker).
Definition worker.cpp:152
Layer 5 (Session) - server worker identity.
void(* detws_deferred_fn)(void *arg)
Deferred callback signature.
Definition worker.h:92