DeterministicESPAsyncWebServer v6.27.1
Zero-allocation, bounded-execution async HTTP server for ESP32
Loading...
Searching...
No Matches
forward.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 forward.cpp
6 * @brief Interface forwarding plane - implementation.
7 *
8 * Static interface + rule tables. A frame on one interface is resolved against the rules
9 * (a DENY wins, otherwise a matching ALLOW forwards, otherwise default-deny) for every
10 * other registered interface, rate-capped per rule, then handed to that interface's send
11 * callback. Zero heap, fail-closed.
12 */
13
15
16#if DETWS_ENABLE_FORWARD
17
18#include <string.h>
19
20#ifdef ARDUINO
21#include "services/clock.h" // detws_millis()
22#endif
23
24namespace
25{
26struct iface
27{
28 det_if_send_fn send;
29 void *ctx;
30 uint8_t id;
31 det_if_kind kind;
32 bool used;
33};
34
35struct rule
36{
37 uint32_t window_start; // ms of the current rate window
38 uint16_t rate_cap; // frames per second (0 = unlimited)
39 uint16_t count; // frames forwarded in the current window
40 uint8_t src;
41 uint8_t dst;
42 det_fwd_action action;
43 bool used;
44};
45
46struct acl_entry
47{
48 uint8_t pattern[DETWS_FWD_ACL_PATLEN];
49 uint8_t mask[DETWS_FWD_ACL_PATLEN];
50 uint16_t offset;
51 uint8_t src; // source interface, or DET_FWD_IF_ANY
52 uint8_t patlen; // 0 = match any content
53 det_fwd_action action;
54 bool used;
55};
56
57// A policy route: match a frame by byte pattern (as the ACL does) and bind it to one egress.
58struct route
59{
60 uint32_t window_start; // ms of the current rate window
61 uint8_t pattern[DETWS_FWD_ACL_PATLEN];
62 uint8_t mask[DETWS_FWD_ACL_PATLEN];
63 uint16_t offset;
64 uint16_t rate_cap; // frames per second to the egress (0 = unlimited)
65 uint16_t count; // frames routed in the current window
66 uint8_t src; // source interface, or DET_FWD_IF_ANY
67 uint8_t patlen; // 0 = match any content
68 uint8_t egress; // egress interface id
69 bool used;
70};
71
72// All forwarding-plane state, owned by one instance (internal linkage): interfaces,
73// rules, ACL, and stats grouped so it is one named owner, unreachable cross-TU.
74struct ForwardCtx
75{
76 iface if_[DETWS_FWD_MAX_IFACES];
77 rule rules[DETWS_FWD_MAX_RULES];
78 acl_entry acl[DETWS_FWD_MAX_ACL];
79 route routes[DETWS_FWD_MAX_ROUTES];
80 det_fwd_action acl_default = det_fwd_action::DET_FWD_ALLOW; // frames matching no ACL entry (opt-in ACL)
81#if DETWS_FWD_INSPECT
82 det_fwd_inspect_fn inspector = nullptr; // opt-in ingress inspection hook
83 void *inspect_ctx = nullptr;
84#endif
85 det_forward_stats stats;
86#ifndef ARDUINO
87 uint32_t now_ms = 0; // host test clock (real builds use detws_millis())
88#endif
89};
90ForwardCtx s_fwd;
91
92#ifdef ARDUINO
93uint32_t fwd_now()
94{
95 return detws_millis();
96}
97#else
98uint32_t fwd_now()
99{
100 return s_fwd.now_ms;
101}
102#endif
103
104const iface *find_if(const ForwardCtx &f, uint8_t id)
105{
106 for (uint8_t i = 0; i < DETWS_FWD_MAX_IFACES; i++)
107 if (f.if_[i].used && f.if_[i].id == id)
108 return &f.if_[i];
109 return nullptr;
110}
111
112// Resolve the action for (src -> dst): a DENY wins; otherwise the first matching ALLOW
113// governs (its index is returned via @p allow_idx); otherwise default-deny (no route).
114enum class resolve_result : uint8_t
115{
116 R_NOROUTE,
117 R_DENY,
118 R_ALLOW,
119};
120resolve_result resolve(const ForwardCtx &f, uint8_t src, uint8_t dst, int *allow_idx)
121{
122 int allow = -1;
123 bool deny = false;
124 for (uint8_t i = 0; i < DETWS_FWD_MAX_RULES; i++)
125 {
126 if (!f.rules[i].used || f.rules[i].src != src || f.rules[i].dst != dst)
127 continue;
128 if (f.rules[i].action == det_fwd_action::DET_FWD_DENY)
129 deny = true;
130 else if (allow < 0)
131 allow = (int)i;
132 }
133 if (deny)
134 return resolve_result::R_DENY;
135 if (allow >= 0)
136 {
137 *allow_idx = allow;
138 return resolve_result::R_ALLOW;
139 }
140 return resolve_result::R_NOROUTE;
141}
142
143// Fixed 1-second window rate cap; fail-closed (returns true = drop) once the cap is hit.
144// Shared by the src->dst rules and the policy routes (same window bookkeeping fields).
145bool rate_gate(uint32_t &window_start, uint16_t &count, uint16_t rate_cap)
146{
147 if (rate_cap == 0)
148 return false; // unlimited
149 uint32_t now = fwd_now();
150 if ((now - window_start) >= 1000)
151 {
152 window_start = now;
153 count = 0;
154 }
155 if (count >= rate_cap)
156 return true;
157 count++;
158 return false;
159}
160
161bool rate_exceeded(rule *r)
162{
163 return rate_gate(r->window_start, r->count, r->rate_cap);
164}
165
166// Does a stored byte pattern match this frame? (already-masked @p pattern under @p mask at
167// @p offset). @p patlen 0 matches any content; a frame too short for the pattern does not match.
168bool pat_match(uint16_t offset, const uint8_t *pattern, const uint8_t *mask, uint8_t patlen, const uint8_t *data,
169 uint16_t len)
170{
171 if (patlen == 0)
172 return true;
173 if ((uint32_t)offset + patlen > len)
174 return false;
175 for (uint8_t i = 0; i < patlen; i++)
176 if ((data[offset + i] & mask[i]) != pattern[i])
177 return false;
178 return true;
179}
180
181// Does an ACL entry match this frame? (interface + byte pattern under mask).
182bool acl_match(const acl_entry *a, uint8_t src, const uint8_t *data, uint16_t len)
183{
184 if (a->src != DET_FWD_IF_ANY && a->src != src)
185 return false;
186 return pat_match(a->offset, a->pattern, a->mask, a->patlen, data, len);
187}
188
189// Ingress ACL: the first matching entry's action decides; otherwise the default.
190bool acl_permits(const ForwardCtx &f, uint8_t src, const uint8_t *data, uint16_t len)
191{
192 for (uint8_t i = 0; i < DETWS_FWD_MAX_ACL; i++)
193 if (f.acl[i].used && acl_match(&f.acl[i], src, data, len))
194 return f.acl[i].action == det_fwd_action::DET_FWD_ALLOW;
195 return f.acl_default == det_fwd_action::DET_FWD_ALLOW;
196}
197} // namespace
198
199void det_forward_reset(void)
200{
201 memset(s_fwd.if_, 0, sizeof(s_fwd.if_));
202 memset(s_fwd.rules, 0, sizeof(s_fwd.rules));
203 memset(s_fwd.acl, 0, sizeof(s_fwd.acl));
204 memset(s_fwd.routes, 0, sizeof(s_fwd.routes));
205 s_fwd.acl_default = det_fwd_action::DET_FWD_ALLOW;
206#if DETWS_FWD_INSPECT
207 s_fwd.inspector = nullptr;
208 s_fwd.inspect_ctx = nullptr;
209#endif
210 memset(&s_fwd.stats, 0, sizeof(s_fwd.stats));
211}
212
213void det_forward_acl_set_default(det_fwd_action action)
214{
215 s_fwd.acl_default = action;
216}
217
218bool det_forward_acl_add(uint8_t src_if, uint16_t offset, const uint8_t *pattern, const uint8_t *mask, uint8_t patlen,
219 det_fwd_action action)
220{
221 if (patlen > DETWS_FWD_ACL_PATLEN || (patlen > 0 && (!pattern || !mask)))
222 return false;
223 for (uint8_t i = 0; i < DETWS_FWD_MAX_ACL; i++)
224 {
225 if (s_fwd.acl[i].used)
226 continue;
227 memset(s_fwd.acl[i].pattern, 0, sizeof(s_fwd.acl[i].pattern));
228 memset(s_fwd.acl[i].mask, 0, sizeof(s_fwd.acl[i].mask));
229 for (uint8_t k = 0; k < patlen; k++)
230 {
231 s_fwd.acl[i].pattern[k] = (uint8_t)(pattern[k] & mask[k]); // store already masked
232 s_fwd.acl[i].mask[k] = mask[k];
233 }
234 s_fwd.acl[i].offset = offset;
235 s_fwd.acl[i].src = src_if;
236 s_fwd.acl[i].patlen = patlen;
237 s_fwd.acl[i].action = action;
238 s_fwd.acl[i].used = true;
239 return true;
240 }
241 return false; // table full
242}
243
244bool det_forward_route_add(uint8_t src_if, uint16_t offset, const uint8_t *pattern, const uint8_t *mask, uint8_t patlen,
245 uint8_t egress_if, uint16_t rate_cap_per_sec)
246{
247 if (patlen > DETWS_FWD_ACL_PATLEN || (patlen > 0 && (!pattern || !mask)))
248 return false;
249 for (uint8_t i = 0; i < DETWS_FWD_MAX_ROUTES; i++)
250 {
251 if (s_fwd.routes[i].used)
252 continue;
253 memset(s_fwd.routes[i].pattern, 0, sizeof(s_fwd.routes[i].pattern));
254 memset(s_fwd.routes[i].mask, 0, sizeof(s_fwd.routes[i].mask));
255 for (uint8_t k = 0; k < patlen; k++)
256 {
257 s_fwd.routes[i].pattern[k] = (uint8_t)(pattern[k] & mask[k]); // store already masked
258 s_fwd.routes[i].mask[k] = mask[k];
259 }
260 s_fwd.routes[i].window_start = 0;
261 s_fwd.routes[i].offset = offset;
262 s_fwd.routes[i].rate_cap = rate_cap_per_sec;
263 s_fwd.routes[i].count = 0;
264 s_fwd.routes[i].src = src_if;
265 s_fwd.routes[i].patlen = patlen;
266 s_fwd.routes[i].egress = egress_if;
267 s_fwd.routes[i].used = true;
268 return true;
269 }
270 return false; // table full
271}
272
273bool det_forward_add_if(uint8_t if_id, det_if_kind kind, det_if_send_fn send, void *ctx)
274{
275 if (!send || find_if(s_fwd, if_id))
276 return false;
277 for (uint8_t i = 0; i < DETWS_FWD_MAX_IFACES; i++)
278 {
279 if (s_fwd.if_[i].used)
280 continue;
281 s_fwd.if_[i].send = send;
282 s_fwd.if_[i].ctx = ctx;
283 s_fwd.if_[i].id = if_id;
284 s_fwd.if_[i].kind = kind;
285 s_fwd.if_[i].used = true;
286 return true;
287 }
288 return false; // table full
289}
290
291bool det_forward_add_rule(uint8_t src_if, uint8_t dst_if, det_fwd_action action, uint16_t rate_cap_per_sec)
292{
293 for (uint8_t i = 0; i < DETWS_FWD_MAX_RULES; i++)
294 {
295 if (s_fwd.rules[i].used)
296 continue;
297 s_fwd.rules[i].window_start = 0;
298 s_fwd.rules[i].rate_cap = rate_cap_per_sec;
299 s_fwd.rules[i].count = 0;
300 s_fwd.rules[i].src = src_if;
301 s_fwd.rules[i].dst = dst_if;
302 s_fwd.rules[i].action = action;
303 s_fwd.rules[i].used = true;
304 return true;
305 }
306 return false; // table full
307}
308
309// First matching policy route decides the frame (send to its egress only, or drop) - the same
310// precedence and guarantees as a rule. *handled=false means no route matched; run normal fan-out.
311static uint8_t forward_policy_route(uint8_t src_if, const uint8_t *data, uint16_t len, bool *handled)
312{
313 *handled = true;
314 for (uint8_t i = 0; i < DETWS_FWD_MAX_ROUTES; i++)
315 {
316 route &rt = s_fwd.routes[i];
317 if (!rt.used || (rt.src != DET_FWD_IF_ANY && rt.src != src_if))
318 continue;
319 if (!pat_match(rt.offset, rt.pattern, rt.mask, rt.patlen, data, len))
320 continue;
321 s_fwd.stats.policy_routed++;
322 if (rt.egress == src_if) // never reflect to the source interface
323 return 0;
324 const iface *out = find_if(s_fwd, rt.egress);
325 if (!out) // egress not registered -> drop, fail-closed
326 {
327 s_fwd.stats.send_fail++;
328 return 0;
329 }
330 if (rate_gate(rt.window_start, rt.count, rt.rate_cap))
331 {
332 s_fwd.stats.rate_dropped++;
333 return 0;
334 }
335 if (out->send(out->id, data, len, out->ctx))
336 {
337 s_fwd.stats.forwarded++;
338 return 1;
339 }
340 s_fwd.stats.send_fail++;
341 return 0;
342 }
343 *handled = false;
344 return 0;
345}
346
347uint8_t det_forward_ingress(uint8_t src_if, const uint8_t *data, uint16_t len)
348{
349 s_fwd.stats.frames_in++;
350 if (!acl_permits(s_fwd, src_if, data, len)) // ingress ACL runs before any forwarding rule
351 {
352 s_fwd.stats.acl_denied++;
353 return 0;
354 }
355#if DETWS_FWD_INSPECT
356 // Opt-in inspection hook: an app callback observes/filters the frame before routing.
357 if (s_fwd.inspector &&
358 s_fwd.inspector(src_if, data, len, s_fwd.inspect_ctx) == det_fwd_verdict::DET_FWD_INSPECT_DROP)
359 {
360 s_fwd.stats.inspect_dropped++;
361 return 0;
362 }
363#endif
364 // Policy routes take precedence over the src->dst fan-out: the first matching route sends
365 // the frame only to its chosen egress and ends the decision (same guarantees as a rule).
366 bool routed = false;
367 uint8_t verdict = forward_policy_route(src_if, data, len, &routed);
368 if (routed)
369 return verdict;
370 uint8_t n = 0;
371 for (uint8_t i = 0; i < DETWS_FWD_MAX_IFACES; i++)
372 {
373 if (!s_fwd.if_[i].used || s_fwd.if_[i].id == src_if) // never reflect to the source interface
374 continue;
375 int idx = -1;
376 resolve_result r = resolve(s_fwd, src_if, s_fwd.if_[i].id, &idx);
377 if (r == resolve_result::R_NOROUTE)
378 continue; // default-deny, silent
379 if (r == resolve_result::R_DENY)
380 {
381 s_fwd.stats.blocked++;
382 continue;
383 }
384 if (rate_exceeded(&s_fwd.rules[idx]))
385 {
386 s_fwd.stats.rate_dropped++;
387 continue;
388 }
389 if (s_fwd.if_[i].send(s_fwd.if_[i].id, data, len, s_fwd.if_[i].ctx))
390 {
391 s_fwd.stats.forwarded++;
392 n++;
393 }
394 else
395 {
396 s_fwd.stats.send_fail++;
397 }
398 }
399 return n;
400}
401
402void det_forward_get_stats(det_forward_stats *out)
403{
404 if (out)
405 *out = s_fwd.stats;
406}
407
408#if DETWS_FWD_INSPECT
409void det_forward_set_inspector(det_fwd_inspect_fn fn, void *ctx)
410{
411 s_fwd.inspector = fn;
412 s_fwd.inspect_ctx = ctx;
413}
414#endif
415
416#if !defined(ARDUINO)
417void det_forward_test_set_now(uint32_t ms)
418{
419 s_fwd.now_ms = ms;
420}
421#endif
422
423#endif // DETWS_ENABLE_FORWARD
#define DETWS_FWD_MAX_ACL
Max ingress access-control entries (byte-pattern permit/deny; static).
#define DETWS_FWD_MAX_RULES
Max forwarding rules (src -> dst allow/deny + rate cap; static-allocated).
#define DETWS_FWD_MAX_ROUTES
Max policy routes (byte-pattern -> egress interface; static). Policy routes take precedence over the ...
#define DETWS_FWD_MAX_IFACES
Max interfaces the forwarding plane tracks (static-allocated).
#define DETWS_FWD_ACL_PATLEN
Bytes an ACL entry can match (its pattern / mask length).
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
Interface forwarding plane (DETWS_ENABLE_FORWARD) - the v5 bridge / router.