ProtoCore v0.0.2
Deterministic, zero-heap network stack for embedded targets
Loading...
Searching...
No Matches
coaps_server.cpp
Go to the documentation of this file.
1// Copyright (C) 2026 Douglas Quigg (dstroy0) <dquigg123@gmail.com>
2// SPDX-License-Identifier: AGPL-3.0-or-later
3
4/**
5 * @file coaps_server.cpp
6 * @brief CoAP-over-DTLS server front-end - implementation. See coaps_server.h.
7 */
8
10
11#if PC_ENABLE_DTLS && PC_ENABLE_COAP
12
14#include "services/iot/coap/coaps.h" // pc_coaps_process()
15#include "services/system/clock.h" // pc_millis() - idle-reap clock (the DTLS PTO uses it internally too)
16#include "shared_primitives/ring.h" // pc_atomic (SPSC ingest-ring cursors)
17#include <string.h>
18
19#if defined(ARDUINO)
21#endif
22
23namespace
24{
25// Largest inbound datagram buffered: a ClientHello, a client Finished, or one CoAP application record
26// (CoAP messages fit a single datagram, RFC 7252 §4.6). The server's outbound flight is larger but is
27// sent straight out, never buffered here.
28#ifndef PC_COAPS_MAX_DATAGRAM
29#define PC_COAPS_MAX_DATAGRAM 1500
30#endif
31// Scratch for one poll's outbound datagram: a full server flight (ServerHello..Finished) or a sealed
32// CoAP response. The Certificate-dominated flight is the largest thing written here.
33#define PC_COAPS_OUT_CAP 2048
34// The HelloRetryRequest cookie binds the peer's IPv4 address (4) + port (2); the transport is IPv4.
35#define PC_COAPS_PEER_SER 6
36
37// One buffered inbound datagram (payload + the peer it arrived from).
38struct CoapsIngest
39{
40 uint8_t data[PC_COAPS_MAX_DATAGRAM];
41 uint16_t len;
42 char ip[16];
43 uint16_t port;
44};
45
46// One pool slot: a DTLS connection plus the per-connection key material its config points at (so the
47// config's pointers outlive the DtlsConn) and the peer to reply to.
48struct CoapsSlot
49{
50 bool used;
51 DtlsConn conn;
52 DtlsServerConfig cfg; ///< per-slot config; its ephemeral/random pointers reference the buffers below
53 uint8_t eph[32]; ///< fresh X25519 ephemeral private key for this handshake
54 uint8_t srand[32]; ///< fresh ServerHello random for this handshake
55 char peer_ip[16];
56 uint16_t peer_port;
57 uint32_t last_ms; ///< pc_millis() of the last inbound datagram (idle-reaping clock)
58};
59
60// The DtlsConn pool + the ingest ring, owned by one instance (internal linkage). Kept separate from
61// the DRAM control state below so the large buffers group together (and could move to PSRAM like the
62// HTTP/3 pool if a large pool were ever wanted). One named owner, unreachable cross-TU.
63struct CoapsServerPoolCtx
64{
65 CoapsSlot pool[PC_COAPS_MAX_CONNS];
66 CoapsIngest ring[PC_COAPS_INGEST_RING];
67};
68CoapsServerPoolCtx s_cpool;
69
70// All CoAPs-server control state, owned by one instance (internal linkage): the ingest-ring cursors,
71// the server identity + randomness source, the bound port / running flag, and (host) the outbound
72// sink. One named owner, unreachable cross-TU.
73struct CoapsServerCtx
74{
75 pc_atomic<size_t> ring_head; ///< producer (udp / ingest) advances
76 pc_atomic<size_t> ring_tail; ///< consumer (poll) advances
77 const uint8_t *cert_der = nullptr;
78 size_t cert_len = 0;
79 uint8_t ed25519_seed[32];
80 uint8_t cookie_key[32];
81 void (*rng)(uint8_t *out, size_t len) = nullptr;
82 uint16_t port = 0;
83 bool running = false;
84#if !defined(ARDUINO)
85 CoapsServerOutFn out_sink = nullptr;
86 void *out_ctx = nullptr;
87#endif
88};
89CoapsServerCtx s_coaps;
90
91void copy_str(char *dst, size_t cap, const char *src)
92{
93 size_t n = 0;
94 if (src)
95 {
96 while (src[n] && n + 1 < cap)
97 {
98 dst[n] = src[n];
99 n++;
100 }
101 }
102 dst[n] = 0;
103}
104
105// Serialize an IPv4 dotted-quad @p ip + @p port into the address the HelloRetryRequest cookie binds, so
106// a cookie minted for one peer is worthless to another (RFC 9147 §5.1). Returns false on a malformed
107// address (then no address is bound and the peer simply cannot take the HRR path).
108bool serialize_peer(const char *ip, uint16_t port, uint8_t out[PC_COAPS_PEER_SER])
109{
110 uint32_t oct = 0;
111 int octets = 0;
112 int ndig = 0;
113 int idx = 0;
114 for (const char *p = ip;; p++)
115 {
116 if (*p >= '0' && *p <= '9')
117 {
118 oct = oct * 10 + (uint32_t)(*p - '0');
119 ndig++;
120 if (oct > 255 || ndig > 3)
121 {
122 return false;
123 }
124 }
125 else if (*p == '.' || *p == 0)
126 {
127 if (ndig == 0)
128 {
129 return false;
130 }
131 out[idx++] = (uint8_t)oct;
132 octets++;
133 oct = 0;
134 ndig = 0;
135 if (*p == 0)
136 {
137 break;
138 }
139 }
140 else
141 {
142 return false;
143 }
144 }
145 if (octets != 4)
146 {
147 return false;
148 }
149 out[4] = (uint8_t)(port >> 8);
150 out[5] = (uint8_t)(port & 0xFF);
151 return true;
152}
153
154void server_send(const char *ip, uint16_t port, const uint8_t *data, size_t len)
155{
156#if defined(ARDUINO)
157 pc_udp_listener_sendto(s_coaps.port, ip, port, data, len);
158#else
159 if (s_coaps.out_sink)
160 {
161 s_coaps.out_sink(s_coaps.out_ctx, data, len, ip, port);
162 }
163#endif
164}
165
166// --- ingest ring (SPSC: one producer fills, pc_coaps_server_poll consumes) ------------------------
167bool ring_push(const uint8_t *dg, size_t len, const char *ip, uint16_t port)
168{
169 if (len == 0 || len > PC_COAPS_MAX_DATAGRAM)
170 {
171 return false;
172 }
173 size_t head = s_coaps.ring_head;
174 size_t next = (head + 1) % PC_COAPS_INGEST_RING;
175 if (next == (size_t)s_coaps.ring_tail)
176 {
177 return false; // ring full: drop (DTLS recovers via retransmission)
178 }
179 CoapsIngest *e = &s_cpool.ring[head];
180 memcpy(e->data, dg, len);
181 e->len = (uint16_t)len;
182 copy_str(e->ip, sizeof e->ip, ip);
183 e->port = port;
184 s_coaps.ring_head = next; // publish after the record is fully written
185 return true;
186}
187
188bool ring_pop(CoapsIngest *out)
189{
190 size_t tail = s_coaps.ring_tail;
191 if (tail == (size_t)s_coaps.ring_head)
192 {
193 return false;
194 }
195 *out = s_cpool.ring[tail];
196 s_coaps.ring_tail = (tail + 1) % PC_COAPS_INGEST_RING;
197 return true;
198}
199
200// --- slot pool (keyed by peer address, or by connection id once one is negotiated) -------------
201CoapsSlot *slot_by_peer(const char *ip, uint16_t port)
202{
203 for (uint8_t i = 0; i < PC_COAPS_MAX_CONNS; i++)
204 {
205 CoapsSlot *s = &s_cpool.pool[i];
206 if (s->used && s->peer_port == port && strcmp(s->peer_ip, ip) == 0)
207 {
208 return s;
209 }
210 }
211 return nullptr;
212}
213
214// Find the connection whose negotiated connection id (RFC 9146 / RFC 9147 §9) matches the id carried in
215// an inbound CID record, so a peer that has roamed to a new address is still routed to its connection.
216// @p cid points just past the record's first byte; @p avail is the bytes available there.
217CoapsSlot *slot_by_cid(const uint8_t *cid, size_t avail)
218{
219 uint8_t sc[PC_DTLS_CID_MAX];
220 for (uint8_t i = 0; i < PC_COAPS_MAX_CONNS; i++)
221 {
222 CoapsSlot *s = &s_cpool.pool[i];
223 if (!s->used)
224 {
225 continue;
226 }
227 size_t sl = pc_dtls_conn_local_cid(&s->conn, sc);
228 if (sl && sl <= avail && memcmp(cid, sc, sl) == 0)
229 {
230 return s;
231 }
232 }
233 return nullptr;
234}
235
236CoapsSlot *alloc_slot()
237{
238 for (uint8_t i = 0; i < PC_COAPS_MAX_CONNS; i++)
239 {
240 if (!s_cpool.pool[i].used)
241 {
242 CoapsSlot *s = &s_cpool.pool[i];
243 memset(s, 0, sizeof *s);
244 s->used = true;
245 return s;
246 }
247 }
248 return nullptr;
249}
250
251// Open a connection for a datagram from a peer with no existing slot.
252CoapsSlot *open_conn(const char *ip, uint16_t port)
253{
254 CoapsSlot *s = alloc_slot();
255 if (!s)
256 {
257 return nullptr;
258 }
259 s_coaps.rng(s->eph, sizeof s->eph); // fresh X25519 ephemeral private key
260 s_coaps.rng(s->srand, sizeof s->srand); // fresh ServerHello random
261 s->cfg.cert_der = s_coaps.cert_der;
262 s->cfg.cert_len = s_coaps.cert_len;
263 s->cfg.ed25519_seed = s_coaps.ed25519_seed;
264 s->cfg.ephemeral_priv = s->eph;
265 s->cfg.server_random = s->srand;
266 s->cfg.cookie_key = s_coaps.cookie_key;
267 uint8_t paddr[PC_COAPS_PEER_SER];
268 bool ok = serialize_peer(ip, port, paddr);
269 pc_dtls_conn_init(&s->conn, &s->cfg, ok ? paddr : nullptr, ok ? sizeof paddr : 0);
270 copy_str(s->peer_ip, sizeof s->peer_ip, ip);
271 s->peer_port = port;
272 return s;
273}
274
275#if defined(ARDUINO)
276void udp_ingest_cb(const uint8_t *data, size_t len, const pc_udp_peer *peer, void * /*ctx*/)
277{
278 char ip[16];
279 uint16_t port = 0;
280 if (!pc_udp_peer_addr(peer, ip, sizeof ip, &port))
281 {
282 return;
283 }
284 ring_push(data, len, ip, port);
285}
286#endif
287
288// Route one ingested datagram to its peer slot (opening one for a fresh peer's ClientHello) and drive
289// the DTLS handshake / CoAP exchange through the bridge.
290void coaps_route_datagram(const CoapsIngest *ig, uint32_t now, uint8_t *out, size_t out_cap)
291{
292 // A DTLSCiphertext with the C bit (0b001C....) carries a connection id: route by it so a peer that
293 // has roamed to a new address still reaches its connection (RFC 9146 / RFC 9147 §9). Otherwise route
294 // by source address; a fresh peer's (plaintext) ClientHello opens a slot.
295 // GCOVR_EXCL_BR_LINE below: ig->len >= 1 is always true here - ring_push (the ring's only producer)
296 // rejects len == 0, so no entry ring_pop hands back can have a zero length.
297 bool cid_rec = ig->len >= 1 && (ig->data[0] & 0xE0) == 0x20 && (ig->data[0] & 0x10); // GCOVR_EXCL_BR_LINE
298 CoapsSlot *s = cid_rec ? slot_by_cid(ig->data + 1, ig->len - 1) : nullptr;
299 if (!s)
300 {
301 s = slot_by_peer(ig->ip, ig->port);
302 }
303 if (!s && !cid_rec)
304 {
305 s = open_conn(ig->ip, ig->port);
306 }
307 if (!s)
308 {
309 return; // unknown connection id, or pool full: drop (the peer retransmits / DTLS PTO recovers)
310 }
311 // Address migration: a valid CID record from a new address updates where we send replies.
312 if (cid_rec && (s->peer_port != ig->port || strcmp(s->peer_ip, ig->ip) != 0))
313 {
314 copy_str(s->peer_ip, sizeof s->peer_ip, ig->ip);
315 s->peer_port = ig->port;
316 }
317 s->last_ms = now;
318 int n = pc_coaps_process(&s->conn, ig->data, ig->len, out, out_cap);
319 if (n > 0)
320 {
321 server_send(s->peer_ip, s->peer_port, out, (size_t)n);
322 }
323 else if (n < 0)
324 {
325 s->used = false; // fatal handshake error: free the slot
326 }
327}
328
329// Fire the retransmission timer for a slot's outstanding flight, then reap it if the handshake failed or
330// the connection has gone idle.
331void coaps_service_slot(CoapsSlot *s, uint32_t now, uint8_t *out, size_t out_cap)
332{
333 if (!s->used)
334 {
335 return;
336 }
337 if (pc_dtls_conn_timeout_ms(&s->conn) == 0) // 0 == due now (-1 == no timer, >0 == still pending)
338 {
339 int n = pc_dtls_conn_on_timeout(&s->conn, out, out_cap);
340 if (n > 0)
341 {
342 server_send(s->peer_ip, s->peer_port, out, (size_t)n);
343 }
344 // GCOVR_EXCL_BR_LINE below: n is never 0 here. pc_dtls_conn_on_timeout's two early "return 0" guards
345 // (not-awaiting-reply/failed/done, not-yet-due) recompute the exact same fields and clock this call's
346 // guard (timeout_ms(&s->conn) == 0) already evaluated true for, with no state change in between, so
347 // neither can fire. Past those it either abandons the handshake (-1, retransmit ceiling) or fails to
348 // build a record (-1), or returns flight_transmit's byte count, which is never 0: flight_arm (the only
349 // place that sets awaiting_reply) always runs right after at least one flight_add call (1 message for
350 // a HelloRetryRequest flight, 5 for the main flight), and nothing resets flight_count to 0 while
351 // awaiting_reply stays true - so flight_transmit's loop always contributes at least one record's worth
352 // of bytes when it succeeds.
353 else if (n < 0) // GCOVR_EXCL_BR_LINE
354 {
355 s->used = false; // retransmission ceiling hit: abandon the handshake
356 return;
357 }
358 }
359 if (now - s->last_ms >= PC_COAPS_IDLE_MS) // wrap-safe idle delta (uint32_t arithmetic)
360 {
361 s->used = false;
362 }
363}
364} // namespace
365
366bool pc_coaps_server_begin(uint16_t port, const CoapsServerConfig *cfg)
367{
368 if (!cfg || !cfg->rng || !cfg->cert_der || cfg->cert_len == 0)
369 {
370 return false;
371 }
372 s_coaps.cert_der = cfg->cert_der;
373 s_coaps.cert_len = cfg->cert_len;
374 memcpy(s_coaps.ed25519_seed, cfg->ed25519_seed, sizeof s_coaps.ed25519_seed);
375 memcpy(s_coaps.cookie_key, cfg->cookie_key, sizeof s_coaps.cookie_key);
376 s_coaps.rng = cfg->rng;
377 s_coaps.port = port ? port : PC_COAPS_PORT;
378 for (uint8_t i = 0; i < PC_COAPS_MAX_CONNS; i++)
379 {
380 s_cpool.pool[i].used = false;
381 }
382 s_coaps.ring_head = 0;
383 s_coaps.ring_tail = 0;
384 s_coaps.running = true;
385#if defined(ARDUINO)
386 return pc_udp_listen(s_coaps.port, udp_ingest_cb, nullptr);
387#else
388 return true; // host: fed through pc_coaps_server_ingest()
389#endif
390}
391
392void pc_coaps_server_poll()
393{
394 if (!s_coaps.running)
395 {
396 return;
397 }
398 uint32_t now = pc_millis();
399 uint8_t out[PC_COAPS_OUT_CAP];
400
401 // Drain queued datagrams: route each to its peer's slot (opening one for a new peer) and drive the
402 // handshake / CoAP exchange through the bridge.
403 CoapsIngest ig;
404 while (ring_pop(&ig))
405 {
406 coaps_route_datagram(&ig, now, out, sizeof out);
407 }
408
409 // Fire the retransmission timer for any outstanding flight, then reap closed / idle connections.
410 for (uint8_t i = 0; i < PC_COAPS_MAX_CONNS; i++)
411 {
412 coaps_service_slot(&s_cpool.pool[i], now, out, sizeof out);
413 }
414}
415
416uint8_t pc_coaps_server_active_conns()
417{
418 uint8_t n = 0;
419 for (uint8_t i = 0; i < PC_COAPS_MAX_CONNS; i++)
420 {
421 if (s_cpool.pool[i].used)
422 {
423 n++;
424 }
425 }
426 return n;
427}
428
429void pc_coaps_server_stop()
430{
431 s_coaps.running = false;
432 for (uint8_t i = 0; i < PC_COAPS_MAX_CONNS; i++)
433 {
434 s_cpool.pool[i].used = false;
435 }
436 s_coaps.ring_head = 0;
437 s_coaps.ring_tail = 0;
438}
439
440#if !defined(ARDUINO)
441void pc_coaps_server_set_out_sink_cb(CoapsServerOutFn fn, void *ctx)
442{
443 s_coaps.out_sink = fn;
444 s_coaps.out_ctx = ctx;
445}
446
447bool pc_coaps_server_ingest(const uint8_t *datagram, size_t len, const char *ip, uint16_t port)
448{
449 return ring_push(datagram, len, ip, port);
450}
451#endif
452
453#endif // PC_ENABLE_DTLS && PC_ENABLE_COAP
Pluggable monotonic clock for all library timing.
uint32_t pc_millis(void)
The library's monotonic time at 1000 Hz (milliseconds).
Definition clock.h:71
CoAP over DTLS (CoAPs, RFC 7252 §9) - the bridge between the DTLS 1.3 server and the CoAP request han...
CoAP-over-DTLS server front-end - binds a UDP port to a pool of DtlsConn + the CoAPs bridge.
Shared single-producer / single-consumer byte-ring primitive.
bool pc_udp_listener_sendto(uint16_t listen_port, const char *dst_ip, uint16_t dst_port, const uint8_t *data, size_t len)
Send a datagram from the listener bound on listen_port to an address.
Definition udp.cpp:407
bool pc_udp_listen(uint16_t port, pc_udp_handler handler, void *ctx)
Bind a UDP port and route incoming datagrams to handler.
Definition udp.cpp:226
bool pc_udp_peer_addr(const struct pc_udp_peer *peer, char *ip_out, size_t ip_cap, uint16_t *port_out)
Copy a received peer's source IPv4 address (dotted-quad) and port out.
Definition udp.cpp:393
Layer 4 (Transport) - connectionless UDP datagram service.