|
DeterministicESPAsyncWebServer v6.27.1
Zero-allocation, bounded-execution async HTTP server for ESP32
|
This document details the standards conformance of DeterministicESPAsyncWebServer's HTTP/1.1 (request parsing, response generation, keep-alive, Range), authentication (Basic / Digest / JWT), WebSocket (server and client), TLS / mTLS, CoAP, SNMP, Modbus TCP, OPC UA, syslog, the outbound HTTP and MQTT clients, and error-handling behavior. (SSH conformance lives in SSH.md; the TLS posture is in SECURITY.md.) HTTP/2 (RFC 9113 / HPACK) and the HTTP/3 stack in progress (QUIC RFC 9000, QUIC-TLS RFC 9001, HTTP/3 RFC 9114, QPACK RFC 9204) are tracked in STANDARDS.md.
The parser enforces these rules byte-by-byte during parsing:
| Field | Allowed characters | RFC reference | Violation response |
|---|---|---|---|
| Method | tchar (‘ALPHA DIGIT ! # $ % & ’ * + - . ^ _ ` | ~) \ilinebr </td> <td class="markdownTableBodyNone"> §3.1.1 \ilinebr </td> <td class="markdownTableBodyNone"> 400 \ilinebr </td> </tr> <tr class="markdownTableRowEven"> <td class="markdownTableBodyNone"> Path / Query \ilinebr </td> <td class="markdownTableBodyNone">VCHAR(x21–7E) \ilinebr </td> <td class="markdownTableBodyNone"> RFC 3986 §3.3 \ilinebr </td> <td class="markdownTableBodyNone"> 400 \ilinebr </td> </tr> <tr class="markdownTableRowOdd"> <td class="markdownTableBodyNone"> Header field-name \ilinebr </td> <td class="markdownTableBodyNone">tchar\ilinebr </td> <td class="markdownTableBodyNone"> §3.2 \ilinebr </td> <td class="markdownTableBodyNone"> 400 \ilinebr </td> </tr> <tr class="markdownTableRowEven"> <td class="markdownTableBodyNone"> Header field-value \ilinebr </td> <td class="markdownTableBodyNone">VCHAR, SP, HTAB, obs-text (x80–FF) \ilinebr </td> <td class="markdownTableBodyNone"> §3.2 \ilinebr </td> <td class="markdownTableBodyNone"> 400 \ilinebr </td> </tr> <tr class="markdownTableRowOdd"> <td class="markdownTableBodyNone"> Path length \ilinebr </td> <td class="markdownTableBodyNone"> ≤MAX_PATH_LEN − 1bytes \ilinebr </td> <td class="markdownTableBodyNone"> §3.1.1 \ilinebr </td> <td class="markdownTableBodyNone"> 414 \ilinebr </td> </tr> <tr class="markdownTableRowEven"> <td class="markdownTableBodyNone"> Body size \ilinebr </td> <td class="markdownTableBodyNone"> ≤ [BODY_BUF_SIZE](@ref BODY_BUF_SIZE) bytes (viaContent-Length) \ilinebr </td> <td class="markdownTableBodyNone"> §3.3.2 \ilinebr </td> <td class="markdownTableBodyNone"> 413 \ilinebr </td> </tr> <tr class="markdownTableRowOdd"> <td class="markdownTableBodyNone"> Content-Length \ilinebr </td> <td class="markdownTableBodyNone"> Must be1*DIGIT; conflicting duplicates rejected \ilinebr </td> <td class="markdownTableBodyNone"> §3.3.2 \ilinebr </td> <td class="markdownTableBodyNone"> 400 \ilinebr </td> </tr> <tr class="markdownTableRowEven"> <td class="markdownTableBodyNone"> Host header \ilinebr </td> <td class="markdownTableBodyNone"> Required for HTTP/1.1; more than one always rejected \ilinebr </td> <td class="markdownTableBodyNone"> §5.4 \ilinebr </td> <td class="markdownTableBodyNone"> 400 \ilinebr </td> </tr> <tr class="markdownTableRowOdd"> <td class="markdownTableBodyNone"> Transfer-Encoding \ilinebr </td> <td class="markdownTableBodyNone"> Not supported - rejected at dispatch \ilinebr </td> <td class="markdownTableBodyNone"> §3.3.1 \ilinebr </td> <td class="markdownTableBodyNone"> 501 \ilinebr </td> </tr> <tr class="markdownTableRowEven"> <td class="markdownTableBodyNone"> HTTP version \ilinebr </td> <td class="markdownTableBodyNone"> FNV-1a hash match; sets [HttpReq::version](@ref HttpReq::version) \ilinebr </td> <td class="markdownTableBodyNone"> §2.6 \ilinebr </td> <td class="markdownTableBodyNone"> [HTTP_UNKNOWN`](HTTP_UNKNOWN) |
Additional behaviors:
1); set to 0 to accept HTTP/1.1 requests without a Host header. The "more than one Host" and Content-Length rules are always active. Host detection is independent of the MAX_HEADERS storage cap.HTTP keep-alive (`DETWS_ENABLE_KEEPALIVE`, default on) answers a cleanly-parsed request with Connection: keep-alive and reuses the connection for the next request (persistent connections, RFC 7230 §6.3): HTTP/1.1 persists unless the client sends Connection: close, HTTP/1.0 closes unless it sends Connection: keep-alive, and any error or non-complete parse (400/413/414) always closes since the next request boundary is unknown. Each connection serves at most `DETWS_KEEPALIVE_MAX_REQUESTS` requests (a fairness bound) and idle ones are still reclaimed by the timeout sweep; pipelined requests already in the buffer are drained in order.
| Behavior | RFC reference | Notes |
|---|---|---|
Content-Length on fixed-size responses | 7230 §3.3.2 | `send()` / `send_empty()` / `redirect()` / `send_template()` |
| Chunked transfer-encoding (streamed body) | 7230 §4.1 | `send_chunked()`: Transfer-Encoding: chunked, <hexlen>\r\n<data>\r\n chunks, 0\r\n\r\n terminator |
HEAD suppresses body, keeps headers | 7231 §4.3.2 | applies to chunked too (the Transfer-Encoding header is sent, but no chunks) |
Note the deliberate asymmetry: an inbound request carrying Transfer-Encoding is rejected (501, §3.3.1 - the server does not de-chunk request bodies), whereas an outbound response may use chunked transfer via `send_chunked()`.
A route registered with credentials challenges unauthenticated requests with 401 Unauthorized + WWW-Authenticate and Connection: close (so a client gets exactly one guess per TCP connection - a built-in brute-force bound).
| Scheme | RFC | Challenge / verification |
|---|---|---|
| Basic | 7617 | WWW-Authenticate: Basic realm="…"; the base64 user:pass is decoded with an output-capacity guard before comparison. |
| Digest | 7616 | Digest with algorithm=SHA-256, qop="auth". HA1=SHA256(user:realm:pass), HA2=SHA256(method:uri), response=SHA256(HA1:nonce:nc:cnonce:qop:HA2). The server nonce is regenerated each begin() from the ESP32 hardware CSPRNG. |
Digest nonce-count (nc) replay tracking is not implemented (it needs per-client state that conflicts with the single shared server nonce on a 1-2 client device); the per-begin() nonce rotation bounds the replay window.
| Rule | Section | Behavior |
|---|---|---|
| Client→server frames must be masked | §5.1 | Unmasked frame → Close 1002, fail |
| Reserved opcodes rejected | §5.2 | Opcode ∉ {0,1,2,8,9,A} → Close 1002 |
| RSV2/RSV3 must be zero | §5.2 | RSV2/RSV3 set → Close 1002 |
| RSV1 reserved unless deflate negotiated | §5.2 | RSV1 set without permessage-deflate → 1002 |
| Control frames ≤ 125 bytes | §5.5 | Oversized control frame → Close 1002 |
| Control frames must not be fragmented | §5.5 | Control frame with FIN=0 → Close 1002 |
| Payload ≤ `WS_FRAME_SIZE` | §5.2 | Oversized / 64-bit length → Close 1009 |
| Handshake version negotiation | §4.2.1 | Missing/≠ 13 → 426 with supported version |
Fragmented data messages (continuation frames, §5.4) are reassembled into the per-connection buffer and delivered once the FIN frame arrives; control frames may be interleaved between fragments. The reassembled message must fit in WS_FRAME_SIZE (else Close 1009).
Optional inbound message compression (`DETWS_ENABLE_WS_DEFLATE`, default off). When the client offers permessage-deflate, the server accepts it with client_no_context_takeover; server_no_context_takeover, so every message decompresses independently. A compressed message (RSV1 set on its first frame) is INFLATEd before delivery via a bounded RFC 1951 decompressor whose tables come from the shared per-dispatch scratch arena; the 0x00 0x00 0xff 0xff marker (§7.2.2) is appended internally. Both the compressed input and the decompressed output must fit WS_FRAME_SIZE (else Close 1009); a malformed stream closes 1002. Outbound frames are sent uncompressed (§6 permits this).
Optional HTTPS (`DETWS_ENABLE_TLS`, default off) via mbedTLS on a static memory pool (no heap). TLS 1.2 (RFC 5246) is the negotiated minimum; TLS 1.3 (RFC 8446) is used when the client offers it. The verified cipher suite is ECDHE-ECDSA-AES256-GCM-SHA384 (forward-secret ECDHE, ECDSA authentication, AEAD AES-256-GCM). Server certificate/key are loaded via `begin_tls()` / `tls_cert()`. wss:// and TLS Server-Sent Events run over the same TLS record layer when TLS is enabled: the WebSocket upgrade and every subsequent frame/event are encrypted, transparent to handler code. Optional mutual TLS (`DETWS_ENABLE_MTLS`) requires and verifies a client certificate chaining to a configured CA (RFC 5246 §7.4.6 / RFC 8446 §4.4.2) and exposes the verified peer subject DN to handlers.
Optional session resumption (`DETWS_ENABLE_TLS_RESUMPTION`) via session tickets (RFC 5077): the TLS 1.2 server issues an encrypted ticket and accepts it on reconnect, so a returning client completes an abbreviated handshake (no certificate or ECDHE key exchange) - much less CPU and latency on a constrained device. It is stateless: the session is sealed into the client's ticket with a server-held AES-256-GCM key that rotates on the `DETWS_TLS_TICKET_LIFETIME_S` schedule, so no per-session cache grows in the arena. Full properties and caveats: SECURITY.md §6.
Optional SNMP agent (`DETWS_ENABLE_SNMP`, default off) on UDP port 161 (via the transport-layer UDP service), with a zero-heap ASN.1 BER codec and a fixed MIB table. Conformance:
SNMPv1 (version 0) and SNMPv2c (version 1) community-based messages: SEQUENCE { version, community, PDU }. A request whose community matches neither the read-only nor the read-write community is silently discarded.DETWS_ENABLE_SNMP_V3): the v3 message format (msgGlobalData + msgSecurityParameters + scopedPDU), engine discovery (Report usmStatsUnknownEngineIDs), the timeliness window (engineBoots / engineTime), authentication usmHMAC192SHA256 (HMAC-SHA-256, RFC 7860) and privacy usmAesCfb128 (AES-128-CFB, RFC 3826), with key localization per RFC 3414 §2.6. The authenticated, decrypted inner PDU is dispatched through the same MIB core as v1/v2c.INTEGER, OCTET STRING, NULL, OBJECT IDENTIFIER (first two arcs packed as 40·a+b, sub-identifiers in base-128), SEQUENCE, and the SMI application types Counter32 / Gauge32 / TimeTicks / IpAddress / Opaque. Integers use the minimal two's-complement form; unsigned application types add a leading 0x00 when the high bit is set.GetRequest, GetNextRequest, GetBulkRequest (v2c), and SetRequest, each answered with a GetResponse echoing the request id. v2c retrieval reports per-varbind exceptions (noSuchObject / endOfMibView); v1 reports error-status / error-index (noSuchName). Set is authorized by the read-write community and uses error-status (noAccess / notWritable / wrongType) in both versions. GetBulk honors non-repeaters / max-repetitions, clamped so the response never exceeds SNMP_MAX_VARBINDS; an over-large response degrades to tooBig.DETWS_ENABLE_SNMP_TRAP): outbound SNMPv2-Trap and InformRequest PDUs, each opening with sysUpTime.0 (TimeTicks) and snmpTrapOID.0 (the trap OID) followed by caller varbinds. SNMPv2c uses the community envelope; SNMPv3 traps are USM authPriv, built from the agent's engine ID and localized keys (the same message construction as v3 responses).system group (1.3.6.1.2.1.1): sysDescr, sysObjectID, sysUpTime (TimeTicks), sysContact, sysName, sysLocation, sysServices: plus any application objects registered under a private enterprise subtree. GetNext / GetBulk walk objects in lexicographic OID order.The decode/dispatch/encode core (`snmp_agent_process()`) is transport-independent and host-tested; only the UDP socket is ESP32-specific. SNMP security properties (cleartext community strings, no v1/v2c authentication): SECURITY.md.
Optional (`DETWS_ENABLE_RANGE`, requires file serving, default off). Served files advertise Accept-Ranges: bytes. A single-range Range: bytes=A-B, bytes=A-, or bytes=-N request is answered 206 Partial Content with Content-Range: bytes A-B/size, seeking the file and streaming only the requested bytes (resumable downloads, media seeking). An unsatisfiable range yields 416 Range Not Satisfiable with Content-Range: bytes */size. A multi-range (comma-separated) request degrades to a full 200 response, which RFC 7233 §3.1 explicitly permits, as does a malformed or absent Range.
Optional (`DETWS_ENABLE_WEBDAV`, requires file serving, default off). `dav()` mounts a filesystem subtree that answers the WebDAV methods, extending HTTP so a client can manage files:
DAV: 1, 2 and the supported Allow set.0 or 1) returns 207 Multi-Status with a <D:response> per resource carrying resourcetype (collection or empty), getcontentlength, getcontenttype, and an RFC 1123 getlastmodified. A Depth-1 listing is bounded by DETWS_WEBDAV_MAX_ENTRIES; the document is built into a DETWS_WEBDAV_BUF_SIZE buffer. The builder + XML escaping are host-tested.201 Created / 204 No Content); DELETE removes a file or recursively a collection (204); MKCOL creates a collection; COPY (files and collections, recursive, honoring Depth: 0/infinity) and MOVE honor Destination and Overwrite (F -> 412 Precondition Failed).opaquelocktoken is issued (so clients that require a lock can write) but not enforced.Out of scope: PROPPATCH (properties are read-only -> 405), shared/real locking, and Depth: infinity (treated as 1). PUT buffers the body (bounded by BODY_BUF_SIZE); large uploads need the streaming-body sink. A destination outside the mount yields 502 Bad Gateway. Pair a writable share with per-route auth, HTTPS, and the accept throttles.
Optional (`DETWS_ENABLE_MODBUS`, default off) Modbus TCP server on port 502. The MBAP header (Transaction Id, Protocol Id = 0, Length, Unit Id) is validated and echoed; the PDU is dispatched against a fixed BSS data model of coils, discrete inputs, holding registers, and input registers. Supported function codes: 0x01 Read Coils, 0x02 Read Discrete Inputs, 0x03 Read Holding Registers, 0x04 Read Input Registers, 0x05 Write Single Coil, 0x06 Write Single Register, 0x0F Write Multiple Coils, 0x10 Write Multiple Registers. An unsupported function returns exception 0x01 (Illegal Function), an out-of-range address 0x02 (Illegal Data Address), and a bad quantity/value 0x03 (Illegal Data Value). The codec (`modbus_process_adu()`) is transport-independent and host-tested; the TCP framing (one ADU per MBAP length, pipelined out of the rx ring) is dispatched through the PROTO_MODBUS connection handler. The application reads and writes the model with the accessor functions and is notified of client writes via `modbus_on_write()`. Modbus has no authentication or encryption - run it only on a trusted control network.
Optional (`DETWS_ENABLE_COAP`, default off) zero-heap Constrained Application Protocol endpoint on UDP/5683. Message layer: a Confirmable (CON) request is answered with a piggybacked ACK, a Non-confirmable (NON) request with a NON response, and a malformed or empty CON with a Reset (RST). The parser handles the 4-byte header + token (≤ 8 bytes), delta-encoded options (Uri-Path, Uri-Query, Content-Format), and the 0xFF payload marker, then dispatches GET/POST/PUT/DELETE against a fixed resource table by reconstructed Uri-Path. The codec (`coap_server_process()`) is transport-independent and host-tested; only the UDP socket is ESP32-specific.
Resource discovery (RFC 6690): a GET to /.well-known/core returns the registered resources in CoRE Link Format (application/link-format, Content-Format 40), e.g. </hello>,</time> - paged with Block2 when large; a non-GET to it is 4.05 Method Not Allowed.
**Resource observation (RFC 7641, optional `DETWS_ENABLE_COAP_OBSERVE`):** a GET carrying the Observe option (value 0) registers the client as an observer; the registration response and every subsequent notification carry the Observe option with a monotonically increasing sequence. `coap_notify()` re-renders the resource and pushes a NON notification to each observer from the server's port (so the client matches it by token + endpoint). An observer is removed by a GET with Observe (1), a Reset, or a failed send.
**Block-wise transfer (RFC 7959, optional `DETWS_ENABLE_COAP_BLOCK`):** the Block2 (descriptive) and Block1 (control) options carry transfers larger than one datagram. For a response, a representation bigger than the server's maximum block size, or any request bearing a Block2 option, is served one block at a time: the server re-renders the (idempotent) resource and slices out the requested block number, clamping an over-large client block size to its own maximum and setting the More bit until the last block. For a request, a chunked POST/PUT payload is reassembled into a single buffer - each non-final block is acknowledged 2.31 Continue, and the final block dispatches the handler with the whole payload. One transfer is reassembled at a time; an out-of-order block yields 4.08 Request Entity Incomplete and an oversized one 4.13 Request Entity Too Large. Size1/Size2 are not emitted.
Optional (`DETWS_ENABLE_OPCUA`, default off) zero-heap OPC UA Binary server on TCP/4840 (listen(4840, PROTO_OPCUA)). Transport is UA-TCP / UA-SecureConversation (Part 6): every message is MessageType + chunk + MessageSize, and a secure message carries SecureChannelId + SymmetricSecurityHeader(TokenId) + SequenceHeader(SequenceNumber, RequestId) before the body (the SecureChannelId field is mandatory - omitting it is rejected by compliant clients). Supported flow:
HEL/ACK handshake (buffer-size negotiation), OPN OpenSecureChannel (the server assigns a SecureChannelId + security token), and GetEndpoints (advertises one endpoint).CreateSession (assigns a SessionId + AuthenticationToken, returns ServerEndpoints) and ActivateSession.Read (Part 4 §5.10) and Write decode/encode scalar Variant/DataValue (Boolean, Int32, UInt32, Float, Double, String); Browse (Part 4 §5.8.2) returns ReferenceDescriptions. The application supplies the address space via registered resolvers (`opcua_set_read_handler` / `opcua_set_write_handler` / `opcua_set_browse_handler`); an unknown NodeId yields BadNodeIdUnknown.CloseSession, CLO CloseSecureChannel (closes the socket), and a ServiceFault (BadServiceUnsupported) for any unsupported service so a client never stalls.The built-in-type codec, framing, and all service request/response builders are transport-independent and host-tested; only `opcua_rx()` (the PROTO_OPCUA data handler) is ESP32-specific. Interoperability is verified against a third-party client (Python asyncua): connect (handshake → secure channel → session → activate), browse the Objects folder, and read/write live values. An optional matching client (`DETWS_ENABLE_OPCUA_CLIENT`, services/opcua_client) builds the requests and parses the responses, transport-agnostic.
Security properties: SecurityPolicy is None - messages are neither signed nor encrypted and the user identity is Anonymous, so (like cleartext SNMP v1/v2c) the OPC UA endpoint must run only on a trusted network or behind a separate transport-security layer. Sign / SignAndEncrypt policies are not implemented.
Optional (`DETWS_ENABLE_SYSLOG`, default off) syslog client over UDP. Each line is <PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID STRUCTURED-DATA MSG, where PRI = facility*8 + severity and TIMESTAMP / PROCID / MSGID / STRUCTURED-DATA are the NILVALUE - (the device has no wall clock or PID). Fire-and-forget; the formatter (`syslog_format()`) is host-tested and the datagram is sent via the transport-layer UDP service.
Optional (`DETWS_ENABLE_JWT`, default off). Verifies a compact-serialization JWS (RFC 7515) JSON Web Token (RFC 7519): base64url(header).base64url(payload).base64url(signature) with alg=HS256 (HMAC-SHA-256, RFC 7518). The signature is recomputed over header.payload and compared in constant time (`jwt_bearer_valid()`); integer claims such as exp are readable via `jwt_claim_int()` for the handler to enforce. The full Authorization header is captured (a bearer token exceeds the normal header-value cap). Shared-secret caveat: SECURITY.md.
Optional (`DETWS_ENABLE_MQTT`, default off) persistent publish/subscribe client. Conformance to the OASIS MQTT 3.1.1 specification:
mqtts:// runs over a persistent client-side TLS session (`DETWS_ENABLE_MQTT_TLS`) with the same optional CA / pin verification as the HTTP client. QoS 2 inbound flow uses method A (deliver on PUBLISH, de-dup by id until PUBREL). The packet codec is transport-independent and host-tested (env:native_mqtt).Optional (`DETWS_ENABLE_WS_CLIENT`, default off) outbound client - the device as a WebSocket client to a remote endpoint:
Sec-WebSocket-Key and Sec-WebSocket-Version: 13, and verifies the 101 response's Sec-WebSocket-Accept = base64(SHA-1(key + GUID)).wss:// runs over the shared persistent client TLS session (`DETWS_ENABLE_WS_CLIENT_TLS`) with the same CA / pin verification. The handshake/frame codec is host-tested (env:native_ws_client), including the RFC 6455 §4.2.2 accept example.Optional (`DETWS_ENABLE_HTTP_CLIENT`, default off). Builds RFC 7230 request messages (`http_get()` / `http_post()`) and parses responses framed by Content-Length or Transfer-Encoding: chunked (decoded in place) or by connection close. https:// runs over client-side mbedTLS (`DETWS_ENABLE_HTTP_CLIENT_TLS`); encrypt-only by default, with optional server authentication via a CA trust anchor (`http_client_set_ca()`) or a SHA-256 certificate pin (`http_client_set_pin()`). See SECURITY.md.
`handle()` sends these before dispatching to any route handler:
| Parser state | Response | Trigger |
|---|---|---|
| `PARSE_ERROR` | 400 Bad Request | Any RFC 7230 character violation or malformed CRLF |
| `PARSE_ENTITY_TOO_LARGE` | 413 Payload Too Large | Content-Length > BODY_BUF_SIZE |
| `PARSE_URI_TOO_LONG` | 414 URI Too Long | Path exceeds MAX_PATH_LEN − 1 bytes |
handle() also sends these during dispatch:
| Condition | Response | RFC reference |
|---|---|---|
Transfer-Encoding header present | 501 Not Implemented | 7230 §3.3.1 |
| Unrecognized request method | 501 Not Implemented | 7231 §6.5.2 |
| Path matches a route but the method does not | 405 Method Not Allowed (with Allow header) | 7231 §6.5.5 |
No matching route, no on_not_found handler | 404 Not Found | 7231 §6.5.4 |
| WebSocket upgrade on a non-WS route | 400 Bad Request | 6455 §4.2.1 |
Unsupported Sec-WebSocket-Version | 426 Upgrade Required | 6455 §4.2.1 |
| WebSocket or SSE pool full | 503 Service Unavailable | - |
A HEAD request is served by the matching GET route with the body suppressed (RFC 7231 §4.3.2); GET routes advertise HEAD in the Allow header.