ProtoCore v0.0.2
Deterministic, zero-heap network stack for embedded targets
Loading...
Searching...
No Matches
http_client.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 http_client.cpp
6 * @brief Outbound HTTP(S) client: URL parse + request build + response parse,
7 * and the raw-lwIP / mbedTLS transport (ESP32 only).
8 */
9
11#include "services/system/clock.h" // pcdelay
12#include "shared_primitives/strbuf.h" // pc_sb frame builder
13
14#if PC_ENABLE_HTTP_CLIENT
15
18#include <stdio.h>
19#include <string.h>
20
21// ---------------------------------------------------------------------------
22// Pure helpers (host-testable)
23// ---------------------------------------------------------------------------
24
25#if defined(ARDUINO)
26#include "network_drivers/transport/client.h" // shared outbound TCP client (L4)
27#include <Arduino.h> // millis()
28#endif
29#if defined(ARDUINO) && PC_ENABLE_HTTP_CLIENT_TLS
31#include <mbedtls/ssl.h> // MBEDTLS_ERR_SSL_WANT_READ for the BIO recv callback
32#endif
33bool http_client_parse_url(const char *url, bool *is_https, char *host, size_t host_cap, uint16_t *port, char *path,
34 size_t path_cap)
35{
36 if (!url || !is_https || !host || !port || !path)
37 {
38 return false;
39 }
40
41 const char *p = url;
42 if (strncmp(p, "https://", 8) == 0)
43 {
44 *is_https = true;
45 *port = 443;
46 p += 8;
47 }
48 else if (strncmp(p, "http://", 7) == 0) // NOSONAR: HTTP client must accept http:// URLs
49 {
50 *is_https = false;
51 *port = 80;
52 p += 7;
53 }
54 else
55 {
56 return false;
57 }
58
59 // host (up to ':' or '/')
60 const char *h = p;
61 while (*p && *p != ':' && *p != '/')
62 {
63 p++;
64 }
65 size_t hlen = (size_t)(p - h);
66 if (hlen == 0 || hlen >= host_cap)
67 {
68 return false;
69 }
70 memcpy(host, h, hlen);
71 host[hlen] = '\0';
72
73 // optional :port
74 if (*p == ':')
75 {
76 p++;
77 if (*p < '0' || *p > '9')
78 {
79 return false;
80 }
81 uint32_t pn = 0;
82 while (*p >= '0' && *p <= '9')
83 {
84 pn = pn * 10 + (uint32_t)(*p++ - '0');
85 if (pn > 65535)
86 {
87 return false;
88 }
89 }
90 *port = (uint16_t)pn;
91 }
92
93 // path ("/" if absent)
94 if (*p == '\0')
95 {
96 if (path_cap < 2)
97 {
98 return false;
99 }
100 path[0] = '/';
101 path[1] = '\0';
102 }
103 else
104 {
105 size_t plen = strnlen(p, path_cap + 1);
106 if (plen >= path_cap)
107 {
108 return false;
109 }
110 memcpy(path, p, plen + 1);
111 }
112 return true;
113}
114
115size_t http_client_build_request(const char *method, const char *host, uint16_t port, const char *path,
116 const char *content_type, const uint8_t *body, size_t body_len, char *out, size_t cap)
117{
118 if (!method || !host || !path || !out || cap == 0)
119 {
120 return 0;
121 }
122
123 // Host header carries the port only when it is non-default.
124 char hosthdr[80];
125 if (port == 80 || port == 443)
126 {
127 pc_sb sb_hosthdr = {hosthdr, sizeof(hosthdr), 0, true};
128 pc_sb_put(&sb_hosthdr, host);
129 if (pc_sb_finish(&sb_hosthdr) == 0)
130 {
131 hosthdr[0] = '\0';
132 }
133 }
134 else
135 {
136 pc_sb sb_hosthdr2 = {hosthdr, sizeof(hosthdr), 0, true};
137 pc_sb_put(&sb_hosthdr2, host);
138 pc_sb_put(&sb_hosthdr2, ":");
139 pc_sb_u32(&sb_hosthdr2, (uint32_t)((unsigned)port));
140 if (pc_sb_finish(&sb_hosthdr2) == 0)
141 {
142 hosthdr[0] = '\0';
143 }
144 }
145
146 int n;
147 if (body && body_len)
148 {
149 pc_sb sb_out = {out, cap, 0, true};
150 pc_sb_put(&sb_out, method);
151 pc_sb_put(&sb_out, " ");
152 pc_sb_put(&sb_out, path);
153 pc_sb_put(&sb_out, " HTTP/1.1\r\nHost: ");
154 pc_sb_put(&sb_out, hosthdr);
155 pc_sb_put(&sb_out, "\r\nUser-Agent: PC\r\nContent-Type: ");
156 pc_sb_put(&sb_out, content_type ? content_type : PC_MIME_OCTET_STREAM);
157 pc_sb_put(&sb_out, "\r\nContent-Length: ");
158 pc_sb_u32(&sb_out, (uint32_t)((unsigned)body_len));
159 pc_sb_put(&sb_out, "\r\nConnection: close\r\n\r\n");
160 n = (int)pc_sb_finish(&sb_out);
161 // The builder's flag is the overflow signal. pc_sb_finish reports 0 for a frame that did
162 // not fit, so a length comparison cannot detect it: headers too long for cap left n == 0,
163 // this guard passed, and the body was copied over an empty buffer and returned as a valid
164 // request. The body still has to fit in what remains.
165 if (!sb_out.ok || (size_t)n + body_len > cap)
166 {
167 return 0;
168 }
169 memcpy(out + n, body, body_len);
170 return (size_t)n + body_len;
171 }
172
173 pc_sb sb_out2 = {out, cap, 0, true};
174 pc_sb_put(&sb_out2, method);
175 pc_sb_put(&sb_out2, " ");
176 pc_sb_put(&sb_out2, path);
177 pc_sb_put(&sb_out2, " HTTP/1.1\r\nHost: ");
178 pc_sb_put(&sb_out2, hosthdr);
179 pc_sb_put(&sb_out2, "\r\nUser-Agent: PC\r\nConnection: close\r\n\r\n");
180 n = (int)pc_sb_finish(&sb_out2);
181 // n < 0 is unreachable here, for the same reason as the body-request snprintf above.
182 if (!sb_out2.ok) // GCOVR_EXCL_BR_LINE
183 {
184 return 0;
185 }
186 return (size_t)n;
187}
188
189// Case-insensitive search for header @p name in the header block [buf, end);
190// returns a pointer to the value (past "name:" and spaces) or nullptr.
191static const char *find_header(const uint8_t *buf, const uint8_t *end, const char *name)
192{
193 size_t nlen = strnlen(name, (size_t)(end - buf) + 1);
194 const uint8_t *p = buf;
195 while (p + nlen + 1 < end)
196 {
197 // at the start of a header line?
198 if (strncasecmp((const char *)p, name, nlen) == 0 && p[nlen] == ':')
199 {
200 const uint8_t *v = p + nlen + 1;
201 while (v < end && (*v == ' ' || *v == '\t'))
202 {
203 v++;
204 }
205 return (const char *)v;
206 }
207 // advance to the next line
208 while (p < end && *p != '\n')
209 {
210 p++;
211 }
212 if (p < end)
213 {
214 p++;
215 }
216 }
217 return nullptr;
218}
219
220int http_client_parse_response(uint8_t *buf, size_t len, size_t *body_off, size_t *body_len)
221{
222 if (!buf || len < 12 || memcmp(buf, "HTTP/1.", 7) != 0)
223 {
224 return (int)HttpClientError::HTTP_CLIENT_ERR_RESPONSE;
225 }
226
227 // Status code: first space, then 3 digits.
228 const uint8_t *sp = (const uint8_t *)memchr(buf, ' ', len);
229 if (!sp || sp + 4 > buf + len)
230 {
231 return (int)HttpClientError::HTTP_CLIENT_ERR_RESPONSE;
232 }
233 int status = (sp[1] - '0') * 100 + (sp[2] - '0') * 10 + (sp[3] - '0');
234 if (status < 100 || status > 599)
235 {
236 return (int)HttpClientError::HTTP_CLIENT_ERR_RESPONSE;
237 }
238
239 // Header terminator "\r\n\r\n".
240 uint8_t *hdr_end = nullptr;
241 for (size_t i = 0; i + 3 < len; i++)
242 {
243 if (buf[i] == '\r' && buf[i + 1] == '\n' && buf[i + 2] == '\r' && buf[i + 3] == '\n')
244 {
245 hdr_end = buf + i;
246 break;
247 }
248 }
249 if (!hdr_end)
250 {
251 return (int)HttpClientError::HTTP_CLIENT_ERR_RESPONSE;
252 }
253
254 size_t off = (size_t)(hdr_end + 4 - buf);
255 size_t avail = len - off;
256
257 const char *te = find_header(buf, hdr_end, "Transfer-Encoding");
258 if (te && strncasecmp(te, "chunked", 7) == 0)
259 {
260 // Decode chunked transfer-encoding in place into the body region.
261 size_t in = off, out = off;
262 while (in < len)
263 {
264 // chunk size line (hex) up to CRLF
265 size_t csz = 0;
266 bool any = false;
267 while (in < len && buf[in] != '\r')
268 {
269 uint8_t c = buf[in++];
270 int d;
271 if (c >= '0' && c <= '9')
272 {
273 d = c - '0';
274 }
275 else if (c >= 'a' && c <= 'f')
276 {
277 d = c - 'a' + 10;
278 }
279 else if (c >= 'A' && c <= 'F')
280 {
281 d = c - 'A' + 10;
282 }
283 else
284 {
285 break; // stop at ';' (chunk ext) or junk
286 }
287 csz = csz * 16 + (size_t)d;
288 any = true;
289 }
290 // skip to end of the size line
291 while (in < len && buf[in] != '\n')
292 {
293 in++;
294 }
295 if (in < len)
296 {
297 in++; // past '\n'
298 }
299 if (!any)
300 {
301 break;
302 }
303 if (csz == 0)
304 {
305 break; // last chunk
306 }
307 // Wrap-safe: csz is an unbounded attacker-controlled hex size; on a 32-bit target
308 // in + csz could overflow below len and skip this clamp, leaving a huge memmove.
309 // Compare against the bytes remaining instead (in <= len here).
310 if (csz > len - in)
311 {
312 csz = len - in; // truncated; take what we have
313 }
314 memmove(buf + out, buf + in, csz);
315 out += csz;
316 in += csz;
317 // skip trailing CRLF after chunk data
318 while (in < len && (buf[in] == '\r' || buf[in] == '\n'))
319 {
320 in++;
321 }
322 }
323 *body_off = off;
324 *body_len = out - off;
325 return status;
326 }
327
328 const char *cl = find_header(buf, hdr_end, "Content-Length");
329 if (cl)
330 {
331 long n = pc_strtol(cl, nullptr);
332 if (n < 0)
333 {
334 n = 0;
335 }
336 size_t want = (size_t)n;
337 *body_off = off;
338 *body_len = (want < avail) ? want : avail; // bounded by what we received
339 return status;
340 }
341
342 // No framing header: connection-close delimited (rest of the buffer).
343 *body_off = off;
344 *body_len = avail;
345 return status;
346}
347
348// ---------------------------------------------------------------------------
349// Transport (ESP32 only): raw-lwIP TCP client + DNS, optional client mbedTLS.
350// ---------------------------------------------------------------------------
351#if defined(ARDUINO)
352
353// Optional stage tracing: build with -DPC_HTTP_CLIENT_DEBUG to print where a
354// request stalls (DNS / connect / send / receive). Goes to the console UART.
355#ifdef PC_HTTP_CLIENT_DEBUG
356#define CL_DBG(...) printf(__VA_ARGS__)
357#else
358#define CL_DBG(...) ((void)0)
359#endif
360
361// All HTTP-client state, owned by one instance (internal linkage): a single in-flight request
362// (one loop task). rx holds the *response to parse*: the raw wire bytes for http, or (for
363// https) the plaintext decrypted by the TLS engine; the TCP connection lives in the shared
364// client pool (pc_client). One named owner, unreachable from any other translation unit.
365struct HttpClientCtx
366{
367 uint8_t rx[PC_HTTP_CLIENT_BUF_SIZE];
368 int cid = -1; // active outbound connection id (pc_client pool)
369};
370static HttpClientCtx s_http;
371
372#if PC_ENABLE_HTTP_CLIENT_TLS
373// mbedTLS BIO over the shared client transport: send wire bytes through the pool,
374// recv by draining its wire ring (which carries ciphertext for https).
375static int cl_tls_send(void *ctx, const unsigned char *buf, size_t len)
376{
377 (void)ctx;
378 size_t cap = len > 0xFFFF ? 0xFFFF : len;
379 return pc_client_send(s_http.cid, buf, cap) ? (int)cap : -1;
380}
381static int cl_tls_recv(void *ctx, unsigned char *buf, size_t len)
382{
383 (void)ctx;
384 size_t n = pc_client_read(s_http.cid, buf, len);
385 if (n == 0)
386 {
387 return pc_client_is_closed(s_http.cid) ? 0 : MBEDTLS_ERR_SSL_WANT_READ;
388 }
389 return (int)n;
390}
391#endif // PC_ENABLE_HTTP_CLIENT_TLS
392
393// Core request: build, connect, send, receive, parse.
394static int http_request(const char *method, const char *url, const char *content_type, const uint8_t *body,
395 size_t body_len, HttpClientResult *out)
396{
397 if (out)
398 {
399 out->status = 0;
400 out->body = nullptr;
401 out->body_len = 0;
402 }
403
404 bool is_https;
405 char host[80], path[160];
406 uint16_t port;
407 if (!http_client_parse_url(url, &is_https, host, sizeof(host), &port, path, sizeof(path)))
408 {
409 return (int)HttpClientError::HTTP_CLIENT_ERR_URL;
410 }
411 CL_DBG("[hc] url host=%s port=%u https=%d path=%s\n", host, (unsigned)port, (int)is_https, path);
412#if !PC_ENABLE_HTTP_CLIENT_TLS
413 if (is_https)
414 {
415 return (int)HttpClientError::HTTP_CLIENT_ERR_TLS;
416 }
417#endif
418
419 // Build the request line + headers (+ optional body).
420 char req[768];
421 size_t reqlen = http_client_build_request(method, host, port, path, content_type, body, body_len, req, sizeof(req));
422 if (reqlen == 0)
423 {
424 return (int)HttpClientError::HTTP_CLIENT_ERR_URL;
425 }
426
427 uint32_t deadline = millis() + PC_HTTP_CLIENT_TIMEOUT_MS;
428
429 // Open the connection (DNS + connect) via the shared client transport.
430 s_http.cid = pc_client_open(host, port, PC_HTTP_CLIENT_TIMEOUT_MS);
431 CL_DBG("[hc] pc_client_open cid=%d\n", s_http.cid);
432 if (s_http.cid < 0)
433 {
434 return (s_http.cid == -2) ? (int)HttpClientError::HTTP_CLIENT_ERR_DNS
435 : (int)HttpClientError::HTTP_CLIENT_ERR_CONNECT;
436 }
437
438 // The response to parse: raw wire bytes (http) or decrypted plaintext (https),
439 // both land in s_http.rx.
440 size_t pc_resp_len = 0;
441
442 if (is_https)
443 {
444#if PC_ENABLE_HTTP_CLIENT_TLS
445 int rc = pc_tls_client_run(host, (const uint8_t *)req, reqlen, s_http.rx, sizeof(s_http.rx), &pc_resp_len,
446 cl_tls_send, cl_tls_recv, deadline);
447 CL_DBG("[hc] tls rc=%d pt_len=%u\n", rc, (unsigned)pc_resp_len);
448 if (rc < 0)
449 {
450 pc_client_close(s_http.cid);
451 s_http.cid = -1;
452 return (int)HttpClientError::HTTP_CLIENT_ERR_TLS;
453 }
454#else
455 pc_client_close(s_http.cid);
456 s_http.cid = -1;
457 return (int)HttpClientError::HTTP_CLIENT_ERR_TLS;
458#endif
459 }
460 else
461 {
462 // Plaintext: send the request, then drain wire bytes into s_http.rx until the
463 // peer closes (and the ring is empty), the buffer fills, or we time out.
464 if (!pc_client_send(s_http.cid, req, reqlen))
465 {
466 pc_client_close(s_http.cid);
467 s_http.cid = -1;
468 return (int)HttpClientError::HTTP_CLIENT_ERR_SEND;
469 }
470 while ((int32_t)(deadline - millis()) > 0)
471 {
472 size_t n = pc_client_read(s_http.cid, s_http.rx + pc_resp_len, sizeof(s_http.rx) - pc_resp_len);
473 pc_resp_len += n;
474 if (pc_resp_len >= sizeof(s_http.rx))
475 {
476 break;
477 }
478 if (pc_client_is_closed(s_http.cid) && pc_client_available(s_http.cid) == 0)
479 {
480 break;
481 }
482 if (n == 0)
483 {
484 pcdelay(5);
485 }
486 }
487 }
488
489 CL_DBG("[hc] done pc_resp_len=%u\n", (unsigned)pc_resp_len);
490 pc_client_close(s_http.cid);
491 s_http.cid = -1;
492
493 if (pc_resp_len == 0)
494 {
495 return (int)HttpClientError::HTTP_CLIENT_ERR_TIMEOUT;
496 }
497
498 size_t body_off = 0, blen = 0;
499 int status = http_client_parse_response(s_http.rx, pc_resp_len, &body_off, &blen);
500 if (status < 0)
501 {
502 return (int)HttpClientError::HTTP_CLIENT_ERR_RESPONSE;
503 }
504 if (out)
505 {
506 out->status = status;
507 out->body = s_http.rx + body_off;
508 out->body_len = blen;
509 }
510 return status;
511}
512
513int http_get(const char *url, HttpClientResult *out)
514{
515 return http_request("GET", url, nullptr, nullptr, 0, out);
516}
517
518int http_post(const char *url, const char *content_type, const uint8_t *body, size_t body_len, HttpClientResult *out)
519{
520 return http_request("POST", url, content_type, body, body_len, out);
521}
522
523void http_client_set_ca(const uint8_t *ca, size_t ca_len)
524{
525#if PC_ENABLE_HTTP_CLIENT_TLS
526 pc_tls_client_set_ca(ca, ca_len);
527#else
528 (void)ca;
529 (void)ca_len;
530#endif
531}
532void http_client_set_pin(const uint8_t sha256[32])
533{
534#if PC_ENABLE_HTTP_CLIENT_TLS
535 pc_tls_client_set_pin(sha256);
536#else
537 (void)sha256;
538#endif
539}
540void http_client_clear_verify()
541{
542#if PC_ENABLE_HTTP_CLIENT_TLS
543 pc_tls_client_clear_verify();
544#endif
545}
546
547#else // host build: transport is a stub
548
549int http_get(const char *, HttpClientResult *)
550{
551 return (int)HttpClientError::HTTP_CLIENT_ERR_CONNECT;
552}
553int http_post(const char *, const char *, const uint8_t *, size_t, HttpClientResult *)
554{
555 return (int)HttpClientError::HTTP_CLIENT_ERR_CONNECT;
556}
557void http_client_set_ca(const uint8_t *, size_t)
558{
559}
560void http_client_set_pin(const uint8_t *)
561{
562}
563void http_client_clear_verify()
564{
565}
566
567#endif // ARDUINO
568
569#endif // PC_ENABLE_HTTP_CLIENT
bool pc_client_send(int, const void *, size_t)
Queue len wire bytes for transmission (marshaled tcp_write + output).
Definition client.cpp:366
size_t pc_client_read(int, uint8_t *, size_t)
Drain up to cap buffered wire bytes into buf; returns the count.
Definition client.cpp:374
bool pc_client_is_closed(int)
True once the peer closed (FIN) or the connection errored.
Definition client.cpp:362
int pc_client_open(const char *, uint16_t, uint32_t)
Resolve host (dotted-quad fast path, else DNS) and connect to host : port, blocking up to timeout_ms.
Definition client.cpp:354
size_t pc_client_available(int)
Wire bytes currently buffered and ready to read.
Definition client.cpp:370
void pc_client_close(int)
Tear down the connection (marshaled) and return the slot to the pool.
Definition client.cpp:378
Layer 4 outbound TCP client transport - the client-side peer of the (server) transport in tcp....
Pluggable monotonic clock for all library timing.
void pcdelay(uint32_t ms)
Block for at least ms milliseconds - the library's single delay primitive.
Definition clock.h:89
Zero-heap outbound HTTP(S) client over raw lwIP (PC_ENABLE_HTTP_CLIENT).
Shared HTTP Content-Type ("MIME") string constants (one source of truth).
Tiny no-stdlib base-10 number parsers (strtol/strtoul/strtof replacements).
long pc_strtol(const char *s, const char **end)
Parse a base-10 long; sets end past the digits (or to s if none).
Definition numparse.h:32
#define PC_HTTP_CLIENT_TIMEOUT_MS
Outbound HTTP client connect/response timeout in milliseconds.
#define PC_HTTP_CLIENT_BUF_SIZE
Receive buffer (and max response size) for the outbound HTTP client, bytes.
Bounded no-heap string builder that fails closed on overflow (one shared copy).
size_t pc_sb_finish(pc_sb *b)
NUL-terminate and return the built length, or 0 if the build overflowed.
Definition strbuf.h:648
void pc_sb_put(pc_sb *b, const char *s)
Append NUL-terminated s; leaves the buffer untouched and clears ok if it would not fit.
Definition strbuf.h:60
void pc_sb_u32(pc_sb *b, uint32_t v)
Append v as decimal (no leading zeros; "0" for zero).
Definition strbuf.h:303
Bump-append target; ok latches false once an append would overflow cap.
Definition strbuf.h:30
bool ok
Definition strbuf.h:34
Deterministic TLS engine: mbedTLS over a static memory pool (PC_ENABLE_TLS).