ProtoCore v0.0.1
Deterministic, zero-heap network stack for embedded targets
Loading...
Searching...
No Matches
provisioning_service.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 provisioning_service.cpp
6 * @brief First-boot WiFi provisioning / captive portal (PC_ENABLE_PROVISIONING).
7 *
8 * The catch-all DNS responder uses the transport-layer UDP service (no add-on library);
9 * credentials persist to NVS via Preferences.
10 */
11
13#include "services/system/clock.h" // pcdelay
16#include <string.h>
17
18// ---------------------------------------------------------------------------
19// Form-field parser (always compiled; the only non-trivial logic, unit-tested).
20// ---------------------------------------------------------------------------
21
22#if PC_ENABLE_PROVISIONING && defined(ARDUINO)
26#include "protocore.h"
27#include <Arduino.h>
28#include <Preferences.h>
29#endif
30bool pc_prov_form_field(const char *body, const char *key, char *out, size_t cap)
31{
32 if (out && cap)
33 {
34 out[0] = '\0';
35 }
36 if (!body || !key || !out || cap == 0)
37 {
38 return false;
39 }
40
41 size_t klen = strnlen(key, cap); // a form field name longer than the value buffer cannot yield a value
42 const char *val = nullptr;
43 for (const char *p = body; *p; p++)
44 {
45 // Match the key only as a whole field name: at the start or after '&'.
46 if ((p == body || p[-1] == '&') && strncmp(p, key, klen) == 0 && p[klen] == '=')
47 {
48 val = p + klen + 1;
49 break;
50 }
51 }
52 if (!val)
53 {
54 return false;
55 }
56
57 size_t o = 0;
58 for (const char *q = val; *q && *q != '&'; q++)
59 {
60 char c = *q;
61 if (c == '+')
62 {
63 c = ' ';
64 }
65 else if (c == '%')
66 {
67 int h = pc_hex_val(q[1]);
68 int l = (h >= 0) ? pc_hex_val(q[2]) : -1;
69 if (h >= 0 && l >= 0)
70 {
71 c = (char)((h << 4) | l);
72 q += 2;
73 }
74 }
75 if (o + 1 < cap)
76 {
77 out[o++] = c;
78 }
79 else
80 {
81 break;
82 }
83 }
84 out[o] = '\0';
85 return true;
86}
87
88// ---------------------------------------------------------------------------
89// ESP32 captive portal (softAP + lwIP UDP DNS + form/save routes)
90// ---------------------------------------------------------------------------
91
92#if PC_ENABLE_PROVISIONING && defined(ARDUINO)
93
94// All provisioning-service state, owned by one instance (internal linkage): the server handle
95// and the softAP IP the captive-portal DNS answers with. Grouped so it is one named owner,
96// unreachable cross-TU.
97struct ProvCtx
98{
99 PC *server = nullptr;
100 uint8_t ap_ip[4] = {192, 168, 4, 1};
101};
102static ProvCtx s_prov;
103
104// The NVS namespace + credential keys (PC_PROV_NVS_NAMESPACE / _KEY_SSID / _KEY_PSK) live in
105// protocore_config.h under PC_ENABLE_PROVISIONING so a deployment can override them; used across
106// the read / clear / save paths (and, for ssid/psk, as the HTML form field names).
107
108// Catch-all DNS: answer every query with our softAP IP (captive-portal hijack).
109static void prov_dns_recv(const uint8_t *req, size_t qlen, const struct pc_udp_peer *peer, void *ctx)
110{
111 (void)ctx;
112 if (qlen < 12)
113 {
114 return; // smaller than a DNS header
115 }
116
117 // Walk the (single) question to find where it ends: labels until a 0 byte,
118 // then 2-byte QTYPE + 2-byte QCLASS.
119 size_t qend = 12;
120 while (qend < qlen && req[qend] != 0)
121 {
122 qend += (size_t)req[qend] + 1;
123 }
124 qend += 1 + 4;
125 if (qend > qlen)
126 {
127 return;
128 }
129
130 uint8_t resp[300];
131 if (qend + 16 > sizeof(resp))
132 {
133 return;
134 }
135 memcpy(resp, req, qend);
136 resp[2] = 0x81; // QR=1, opcode 0, RD copied
137 resp[3] = 0x80; // RA=1, RCODE 0
138 resp[6] = 0x00;
139 resp[7] = 0x01; // ANCOUNT = 1
140 resp[8] = resp[9] = resp[10] = resp[11] = 0x00;
141
142 size_t n = qend;
143 resp[n++] = 0xC0; // name: pointer to the question at offset 0x0C
144 resp[n++] = 0x0C;
145 resp[n++] = 0x00;
146 resp[n++] = 0x01; // TYPE A
147 resp[n++] = 0x00;
148 resp[n++] = 0x01; // CLASS IN
149 resp[n++] = 0x00;
150 resp[n++] = 0x00;
151 resp[n++] = 0x00;
152 resp[n++] = 0x3C; // TTL 60s
153 resp[n++] = 0x00;
154 resp[n++] = 0x04; // RDLENGTH 4
155 resp[n++] = s_prov.ap_ip[0];
156 resp[n++] = s_prov.ap_ip[1];
157 resp[n++] = s_prov.ap_ip[2];
158 resp[n++] = s_prov.ap_ip[3];
159
160 pc_udp_send(peer, resp, n);
161}
162
163bool pc_provisioning_load(char *ssid, size_t ssid_cap, char *psk, size_t psk_cap)
164{
165 if (ssid && ssid_cap)
166 {
167 ssid[0] = '\0';
168 }
169 if (psk && psk_cap)
170 {
171 psk[0] = '\0';
172 }
173 Preferences prefs;
174 if (!prefs.begin(PC_PROV_NVS_NAMESPACE, true))
175 {
176 return false;
177 }
178 String s = prefs.getString(PC_PROV_KEY_SSID, "");
179 String k = prefs.getString(PC_PROV_KEY_PSK, "");
180 prefs.end();
181 if (s.length() == 0)
182 {
183 return false;
184 }
185 strncpy(ssid, s.c_str(), ssid_cap - 1);
186 ssid[ssid_cap - 1] = '\0';
187 strncpy(psk, k.c_str(), psk_cap - 1);
188 psk[psk_cap - 1] = '\0';
189 return true;
190}
191
193{
194 Preferences prefs;
195 if (prefs.begin(PC_PROV_NVS_NAMESPACE, false))
196 {
197 prefs.clear();
198 prefs.end();
199 }
200}
201
202static void prov_form_handler(uint8_t slot_id, HttpReq *req)
203{
204 (void)req;
205 s_prov.server->send(slot_id, 200, PC_MIME_TEXT_HTML, PC_PROV_FORM);
206}
207
208static void prov_save_handler(uint8_t slot_id, HttpReq *req)
209{
210 char ssid[33];
211 char psk[64];
212 bool have_ssid =
213 pc_prov_form_field((const char *)req->body, PC_PROV_KEY_SSID, ssid, sizeof(ssid)) && ssid[0] != '\0';
214 pc_prov_form_field((const char *)req->body, PC_PROV_KEY_PSK, psk, sizeof(psk));
215 if (!have_ssid)
216 {
217 s_prov.server->send(slot_id, 400, PC_MIME_TEXT_PLAIN, "SSID required");
218 return;
219 }
220 Preferences prefs;
221 prefs.begin(PC_PROV_NVS_NAMESPACE, false);
222 prefs.putString(PC_PROV_KEY_SSID, ssid);
223 prefs.putString(PC_PROV_KEY_PSK, psk);
224 prefs.end();
225 s_prov.server->send(slot_id, 200, PC_MIME_TEXT_HTML, PC_PROV_SAVED_HTML);
226 pcdelay(500);
227 ESP.restart();
228}
229
230void pc_provisioning_begin(PC &server, const char *ap_ssid)
231{
232 s_prov.server = &server;
233 init_wifi_ap_physical(ap_ssid, nullptr); // AP mode is implied by which bring-up you call
234 uint32_t ip = pc_net_ap_ip(); // network byte order
235 s_prov.ap_ip[0] = (uint8_t)(ip & 0xFF);
236 s_prov.ap_ip[1] = (uint8_t)((ip >> 8) & 0xFF);
237 s_prov.ap_ip[2] = (uint8_t)((ip >> 16) & 0xFF);
238 s_prov.ap_ip[3] = (uint8_t)((ip >> 24) & 0xFF);
239
240 // Catch-all DNS on UDP/53 via the transport-layer UDP service (callback-driven).
241 pc_udp_listen(53, prov_dns_recv, nullptr);
242
243 server.on("/save", HttpMethod::HTTP_POST, prov_save_handler);
244 server.on("/*", HttpMethod::HTTP_GET, prov_form_handler); // any other path -> the form
245}
246
247#else // disabled / non-Arduino: stubs (form-field parser above stays available)
248
249bool pc_provisioning_load(char *ssid, size_t ssid_cap, char *psk, size_t psk_cap)
250{
251 if (ssid && ssid_cap)
252 {
253 ssid[0] = '\0';
254 }
255 if (psk && psk_cap)
256 {
257 psk[0] = '\0';
258 }
259 return false;
260}
261// server can't be a const ref: the Arduino build's pc_provisioning_begin() registers routes via
262// server.on(); this host stub just ignores it (hence the const-ref suggestion here is a false positive).
263void pc_provisioning_begin(PC &server, const char *ap_ssid) // NOSONAR
264{
265 (void)server;
266 (void)ap_ssid;
267}
269{
270}
271
272#endif // PC_ENABLE_PROVISIONING && ARDUINO
Single-port HTTP server with deterministic, zero-allocation execution.
Definition protocore.h:348
void on(const char *path, HttpMethod method, Handler callback)
Register a route handler.
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
Base-16 conversion between raw bytes and their ASCII digits.
int8_t pc_hex_val(char c)
Hex character c as 0..15, or -1 when c is not a hex digit of either case.
Definition hex.h:36
Shared HTTP Content-Type ("MIME") string constants (one source of truth).
bool init_wifi_ap_physical(const char *, const char *)
Bring up a softAP, enabling AP+STA coexistence so a station link can run alongside it.
Definition physical.cpp:53
uint32_t pc_net_ap_ip(void)
softAP IPv4 (network byte order), or 0 if the softAP is not up (and on host builds).
Definition physical.cpp:85
Layer 1 (Physical) - link bring-up and live egress-interface reporting.
Layer 7 (Application) - public HTTP routing API.
@ HTTP_POST
Non-idempotent create / action.
@ HTTP_GET
Safe, idempotent read.
void pc_provisioning_begin(PC &server, const char *ap_ssid)
Start the captive portal: softAP ap_ssid + catch-all DNS + form routes.
bool pc_provisioning_load(char *ssid, size_t ssid_cap, char *psk, size_t psk_cap)
Load stored WiFi credentials from NVS.
bool pc_prov_form_field(const char *body, const char *key, char *out, size_t cap)
Extract and URL-decode a field from an x-www-form-urlencoded body.
void pc_provisioning_clear()
Erase stored credentials (forces re-provisioning on next boot).
First-boot WiFi provisioning via a captive portal (PC_ENABLE_PROVISIONING).
Fully-parsed HTTP/1.1 request.
uint8_t body[BODY_BUF_SIZE+1]
Stored body bytes, always null-terminated.
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_send(const struct pc_udp_peer *peer, const uint8_t *data, size_t len)
Send a datagram back to the peer captured during the handler call.
Definition udp.cpp:336
Layer 4 (Transport) - connectionless UDP datagram service.
const char PC_PROV_FORM[]
Captive-portal form for WiFi credential entry (provisioning service).
const char PC_PROV_SAVED_HTML[]
Success page shown after credentials are saved and the device reboots.
Layer 7 (Application) - embedded web assets generated from web_assets/input/.