ProtoCore v0.0.2
Deterministic, zero-heap network stack for embedded targets
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 PC_ENABLE_FORWARD
17
18#include <string.h>
19
20#ifdef ARDUINO
21#include "services/system/clock.h" // pc_millis()
22#endif
23
24namespace
25{
26struct iface
27{
28 pc_if_send_fn send;
29 void *ctx;
30 uint8_t id;
31 pc_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 pc_fwd_action action;
43 bool used;
44};
45
46struct acl_entry
47{
48 uint8_t pattern[PC_FWD_ACL_PATLEN];
49 uint8_t mask[PC_FWD_ACL_PATLEN];
50 uint16_t offset;
51 uint8_t src; // source interface, or PC_FWD_IF_ANY
52 uint8_t patlen; // 0 = match any content
53 pc_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[PC_FWD_ACL_PATLEN];
62 uint8_t mask[PC_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 PC_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_[PC_FWD_MAX_IFACES];
77 rule rules[PC_FWD_MAX_RULES];
78 acl_entry acl[PC_FWD_MAX_ACL];
79 route routes[PC_FWD_MAX_ROUTES];
80 pc_fwd_action acl_default = pc_fwd_action::PC_FWD_ALLOW; // frames matching no ACL entry (opt-in ACL)
81#if PC_FWD_INSPECT
82 pc_fwd_inspect_fn inspector = nullptr; // opt-in ingress inspection hook
83 void *inspect_ctx = nullptr;
84#endif
85 pc_forward_stats stats;
86#ifndef ARDUINO
87 uint32_t now_ms = 0; // host test clock (real builds use pc_millis())
88#endif
89};
90ForwardCtx s_fwd;
91
92#ifdef ARDUINO
93uint32_t fwd_now()
94{
95 return pc_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 < PC_FWD_MAX_IFACES; i++)
107 {
108 if (f.if_[i].used && f.if_[i].id == id)
109 {
110 return &f.if_[i];
111 }
112 }
113 return nullptr;
114}
115
116// Resolve the action for (src -> dst): a DENY wins; otherwise the first matching ALLOW
117// governs (its index is returned via @p allow_idx); otherwise default-deny (no route).
118enum class resolve_result : uint8_t
119{
120 R_NOROUTE,
121 R_DENY,
122 R_ALLOW,
123};
124resolve_result resolve(const ForwardCtx &f, uint8_t src, uint8_t dst, int *allow_idx)
125{
126 int allow = -1;
127 bool deny = false;
128 for (uint8_t i = 0; i < PC_FWD_MAX_RULES; i++)
129 {
130 if (!f.rules[i].used || f.rules[i].src != src || f.rules[i].dst != dst)
131 {
132 continue;
133 }
134 if (f.rules[i].action == pc_fwd_action::PC_FWD_DENY)
135 {
136 deny = true;
137 }
138 else if (allow < 0)
139 {
140 allow = (int)i;
141 }
142 }
143 if (deny)
144 {
145 return resolve_result::R_DENY;
146 }
147 if (allow >= 0)
148 {
149 *allow_idx = allow;
150 return resolve_result::R_ALLOW;
151 }
152 return resolve_result::R_NOROUTE;
153}
154
155// Fixed 1-second window rate cap; fail-closed (returns true = drop) once the cap is hit.
156// Shared by the src->dst rules and the policy routes (same window bookkeeping fields).
157bool rate_gate(uint32_t &window_start, uint16_t &count, uint16_t rate_cap)
158{
159 if (rate_cap == 0)
160 {
161 return false; // unlimited
162 }
163 uint32_t now = fwd_now();
164 if ((now - window_start) >= 1000)
165 {
166 window_start = now;
167 count = 0;
168 }
169 if (count >= rate_cap)
170 {
171 return true;
172 }
173 count++;
174 return false;
175}
176
177bool rate_exceeded(rule *r)
178{
179 return rate_gate(r->window_start, r->count, r->rate_cap);
180}
181
182// Does a stored byte pattern match this frame? (already-masked @p pattern under @p mask at
183// @p offset). @p patlen 0 matches any content; a frame too short for the pattern does not match.
184bool pat_match(uint16_t offset, const uint8_t *pattern, const uint8_t *mask, uint8_t patlen, const uint8_t *data,
185 uint16_t len)
186{
187 if (patlen == 0)
188 {
189 return true;
190 }
191 if ((uint32_t)offset + patlen > len)
192 {
193 return false;
194 }
195 for (uint8_t i = 0; i < patlen; i++)
196 {
197 if ((data[offset + i] & mask[i]) != pattern[i])
198 {
199 return false;
200 }
201 }
202 return true;
203}
204
205// Does an ACL entry match this frame? (interface + byte pattern under mask).
206bool acl_match(const acl_entry *a, uint8_t src, const uint8_t *data, uint16_t len)
207{
208 if (a->src != PC_FWD_IF_ANY && a->src != src)
209 {
210 return false;
211 }
212 return pat_match(a->offset, a->pattern, a->mask, a->patlen, data, len);
213}
214
215// Ingress ACL: the first matching entry's action decides; otherwise the default.
216bool acl_permits(const ForwardCtx &f, uint8_t src, const uint8_t *data, uint16_t len)
217{
218 for (uint8_t i = 0; i < PC_FWD_MAX_ACL; i++)
219 {
220 if (f.acl[i].used && acl_match(&f.acl[i], src, data, len))
221 {
222 return f.acl[i].action == pc_fwd_action::PC_FWD_ALLOW;
223 }
224 }
225 return f.acl_default == pc_fwd_action::PC_FWD_ALLOW;
226}
227} // namespace
228
229void pc_forward_reset(void)
230{
231 memset(s_fwd.if_, 0, sizeof(s_fwd.if_));
232 memset(s_fwd.rules, 0, sizeof(s_fwd.rules));
233 memset(s_fwd.acl, 0, sizeof(s_fwd.acl));
234 memset(s_fwd.routes, 0, sizeof(s_fwd.routes));
235 s_fwd.acl_default = pc_fwd_action::PC_FWD_ALLOW;
236#if PC_FWD_INSPECT
237 s_fwd.inspector = nullptr;
238 s_fwd.inspect_ctx = nullptr;
239#endif
240 memset(&s_fwd.stats, 0, sizeof(s_fwd.stats));
241}
242
243void pc_forward_acl_set_default(pc_fwd_action action)
244{
245 s_fwd.acl_default = action;
246}
247
248bool pc_forward_acl_add(uint8_t src_if, uint16_t offset, const uint8_t *pattern, const uint8_t *mask, uint8_t patlen,
249 pc_fwd_action action)
250{
251 if (patlen > PC_FWD_ACL_PATLEN || (patlen > 0 && (!pattern || !mask)))
252 {
253 return false;
254 }
255 for (uint8_t i = 0; i < PC_FWD_MAX_ACL; i++)
256 {
257 if (s_fwd.acl[i].used)
258 {
259 continue;
260 }
261 memset(s_fwd.acl[i].pattern, 0, sizeof(s_fwd.acl[i].pattern));
262 memset(s_fwd.acl[i].mask, 0, sizeof(s_fwd.acl[i].mask));
263 for (uint8_t k = 0; k < patlen; k++)
264 {
265 s_fwd.acl[i].pattern[k] = (uint8_t)(pattern[k] & mask[k]); // store already masked
266 s_fwd.acl[i].mask[k] = mask[k];
267 }
268 s_fwd.acl[i].offset = offset;
269 s_fwd.acl[i].src = src_if;
270 s_fwd.acl[i].patlen = patlen;
271 s_fwd.acl[i].action = action;
272 s_fwd.acl[i].used = true;
273 return true;
274 }
275 return false; // table full
276}
277
278bool pc_forward_route_add(uint8_t src_if, uint16_t offset, const uint8_t *pattern, const uint8_t *mask, uint8_t patlen,
279 uint8_t egress_if, uint16_t rate_cap_per_sec)
280{
281 if (patlen > PC_FWD_ACL_PATLEN || (patlen > 0 && (!pattern || !mask)))
282 {
283 return false;
284 }
285 for (uint8_t i = 0; i < PC_FWD_MAX_ROUTES; i++)
286 {
287 if (s_fwd.routes[i].used)
288 {
289 continue;
290 }
291 memset(s_fwd.routes[i].pattern, 0, sizeof(s_fwd.routes[i].pattern));
292 memset(s_fwd.routes[i].mask, 0, sizeof(s_fwd.routes[i].mask));
293 for (uint8_t k = 0; k < patlen; k++)
294 {
295 s_fwd.routes[i].pattern[k] = (uint8_t)(pattern[k] & mask[k]); // store already masked
296 s_fwd.routes[i].mask[k] = mask[k];
297 }
298 s_fwd.routes[i].window_start = 0;
299 s_fwd.routes[i].offset = offset;
300 s_fwd.routes[i].rate_cap = rate_cap_per_sec;
301 s_fwd.routes[i].count = 0;
302 s_fwd.routes[i].src = src_if;
303 s_fwd.routes[i].patlen = patlen;
304 s_fwd.routes[i].egress = egress_if;
305 s_fwd.routes[i].used = true;
306 return true;
307 }
308 return false; // table full
309}
310
311bool pc_forward_add_if(uint8_t if_id, pc_if_kind kind, pc_if_send_fn send, void *ctx)
312{
313 if (!send || find_if(s_fwd, if_id))
314 {
315 return false;
316 }
317 for (uint8_t i = 0; i < PC_FWD_MAX_IFACES; i++)
318 {
319 if (s_fwd.if_[i].used)
320 {
321 continue;
322 }
323 s_fwd.if_[i].send = send;
324 s_fwd.if_[i].ctx = ctx;
325 s_fwd.if_[i].id = if_id;
326 s_fwd.if_[i].kind = kind;
327 s_fwd.if_[i].used = true;
328 return true;
329 }
330 return false; // table full
331}
332
333bool pc_forward_add_rule(uint8_t src_if, uint8_t dst_if, pc_fwd_action action, uint16_t rate_cap_per_sec)
334{
335 for (uint8_t i = 0; i < PC_FWD_MAX_RULES; i++)
336 {
337 if (s_fwd.rules[i].used)
338 {
339 continue;
340 }
341 s_fwd.rules[i].window_start = 0;
342 s_fwd.rules[i].rate_cap = rate_cap_per_sec;
343 s_fwd.rules[i].count = 0;
344 s_fwd.rules[i].src = src_if;
345 s_fwd.rules[i].dst = dst_if;
346 s_fwd.rules[i].action = action;
347 s_fwd.rules[i].used = true;
348 return true;
349 }
350 return false; // table full
351}
352
353// First matching policy route decides the frame (send to its egress only, or drop) - the same
354// precedence and guarantees as a rule. *handled=false means no route matched; run normal fan-out.
355static uint8_t forward_policy_route(uint8_t src_if, const uint8_t *data, uint16_t len, bool *handled)
356{
357 *handled = true;
358 for (uint8_t i = 0; i < PC_FWD_MAX_ROUTES; i++)
359 {
360 route &rt = s_fwd.routes[i];
361 if (!rt.used || (rt.src != PC_FWD_IF_ANY && rt.src != src_if))
362 {
363 continue;
364 }
365 if (!pat_match(rt.offset, rt.pattern, rt.mask, rt.patlen, data, len))
366 {
367 continue;
368 }
369 s_fwd.stats.policy_routed++;
370 if (rt.egress == src_if) // never reflect to the source interface
371 {
372 return 0;
373 }
374 const iface *out = find_if(s_fwd, rt.egress);
375 if (!out) // egress not registered -> drop, fail-closed
376 {
377 s_fwd.stats.send_fail++;
378 return 0;
379 }
380 if (rate_gate(rt.window_start, rt.count, rt.rate_cap))
381 {
382 s_fwd.stats.rate_dropped++;
383 return 0;
384 }
385 if (out->send(out->id, data, len, out->ctx))
386 {
387 s_fwd.stats.forwarded++;
388 return 1;
389 }
390 s_fwd.stats.send_fail++;
391 return 0;
392 }
393 *handled = false;
394 return 0;
395}
396
397uint8_t pc_forward_ingress(uint8_t src_if, const uint8_t *data, uint16_t len)
398{
399 s_fwd.stats.frames_in++;
400 if (!acl_permits(s_fwd, src_if, data, len)) // ingress ACL runs before any forwarding rule
401 {
402 s_fwd.stats.acl_denied++;
403 return 0;
404 }
405#if PC_FWD_INSPECT
406 // Opt-in inspection hook: an app callback observes/filters the frame before routing.
407 if (s_fwd.inspector && s_fwd.inspector(src_if, data, len, s_fwd.inspect_ctx) == pc_fwd_verdict::PC_FWD_INSPECT_DROP)
408 {
409 s_fwd.stats.inspect_dropped++;
410 return 0;
411 }
412#endif
413 // Policy routes take precedence over the src->dst fan-out: the first matching route sends
414 // the frame only to its chosen egress and ends the decision (same guarantees as a rule).
415 bool routed = false;
416 uint8_t verdict = forward_policy_route(src_if, data, len, &routed);
417 if (routed)
418 {
419 return verdict;
420 }
421 uint8_t n = 0;
422 for (uint8_t i = 0; i < PC_FWD_MAX_IFACES; i++)
423 {
424 if (!s_fwd.if_[i].used || s_fwd.if_[i].id == src_if) // never reflect to the source interface
425 {
426 continue;
427 }
428 int idx = -1;
429 resolve_result r = resolve(s_fwd, src_if, s_fwd.if_[i].id, &idx);
430 if (r == resolve_result::R_NOROUTE)
431 {
432 continue; // default-deny, silent
433 }
434 if (r == resolve_result::R_DENY)
435 {
436 s_fwd.stats.blocked++;
437 continue;
438 }
439 if (rate_exceeded(&s_fwd.rules[idx]))
440 {
441 s_fwd.stats.rate_dropped++;
442 continue;
443 }
444 if (s_fwd.if_[i].send(s_fwd.if_[i].id, data, len, s_fwd.if_[i].ctx))
445 {
446 s_fwd.stats.forwarded++;
447 n++;
448 }
449 else
450 {
451 s_fwd.stats.send_fail++;
452 }
453 }
454 return n;
455}
456
457void pc_forward_get_stats(pc_forward_stats *out)
458{
459 if (out)
460 {
461 *out = s_fwd.stats;
462 }
463}
464
465#if PC_FWD_INSPECT
466void pc_forward_set_inspector(pc_fwd_inspect_fn fn, void *ctx)
467{
468 s_fwd.inspector = fn;
469 s_fwd.inspect_ctx = ctx;
470}
471#endif
472
473#if !defined(ARDUINO)
474void pc_forward_test_set_now(uint32_t ms)
475{
476 s_fwd.now_ms = ms;
477}
478#endif
479
480#endif // PC_ENABLE_FORWARD
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
Interface forwarding plane (PC_ENABLE_FORWARD) - the v5 bridge / router.
uint32_t mask(uint8_t width)
Mask of width low bits (width 32 handled without a 32-bit shift, which is UB).
Definition crc.h:61
#define PC_FWD_MAX_ACL
Max ingress access-control entries (byte-pattern permit/deny; static).
#define PC_FWD_MAX_ROUTES
Max policy routes (byte-pattern -> egress interface; static). Policy routes take precedence over the ...
#define PC_FWD_MAX_RULES
Max forwarding rules (src -> dst allow/deny + rate cap; static-allocated).
#define PC_FWD_MAX_IFACES
Max interfaces the forwarding plane tracks (static-allocated).
#define PC_FWD_ACL_PATLEN
Bytes an ACL entry can match (its pattern / mask length).