umsh_ulcp_runtime/admin_responder.rs
1//! The device's answer to Node Management Requests.
2//!
3//! This is the seam between the mesh and the ULCP session. The node's
4//! receive path taps arriving Node Management Request payloads,
5//! [`admit`] decides whether the sender is entitled to be heard at all,
6//! and [`responder_loop`] runs each admitted exchange: the
7//! [`DeviceEngine`] reads the envelope, the session — reached through the
8//! driver's event loop, since it belongs to that task — serves the frame
9//! inside, and the engine wraps the answer and retains it against a
10//! retransmission.
11//!
12//! Two things are deliberately not here. The session's property surface
13//! is unchanged, because an administrative exchange runs through the same
14//! dispatch as the local link (`Session::handle_admin_frame`). And no
15//! part of the exchange holds a borrow across an await: the request
16//! crosses to the driver as bytes and the response comes back as bytes.
17
18use core::cell::RefCell;
19use core::sync::atomic::{AtomicU32, Ordering};
20
21use embassy_sync::blocking_mutex::Mutex as BlockingMutex;
22use embassy_sync::blocking_mutex::raw::{CriticalSectionRawMutex, RawMutex};
23use embassy_sync::channel::Channel;
24use embassy_time::Instant;
25
26use umsh_core::{ChannelId, PayloadType, PublicKey};
27use umsh_hal::CounterStore;
28use umsh_mac::SendOptions;
29use umsh_node::{PacketFamily, ReceivedPacketRef};
30use umsh_node_mgmt::device::{DeviceEngine, Dispatch, Ingress};
31use umsh_node_mgmt::fragment::{continuable, produce};
32use umsh_ulcp_device::{MAX_DEV_ADMINS, MULTI_MAX};
33
34use crate::device_node::{DeviceNode, NodeMutex};
35use crate::driver::{ADMIN_REPLY, AdminFrame, DevDomainSnapshot, InEvent, InputChannel};
36use crate::log::debug_log;
37
38/// What one secure unicast frame spends before its application payload
39/// begins, itemized against the packet format:
40///
41/// ```text
42/// 1 FCF
43/// 3 destination node hint
44/// 3 source node hint
45/// 5 SECINFO: SCF and the frame counter
46/// 32 a source route of up to 15 router hints, with its option header
47/// 3 region code
48/// 1 trace route
49/// 1 end-of-options marker
50/// 8 MIC
51/// 1 payload type
52/// --
53/// 58
54/// ```
55///
56/// Taken as 75 to leave room for an option this list does not yet know
57/// about. The asymmetry is deliberate: over-reserving costs a slightly
58/// smaller fragment and one more exchange, while under-reserving costs a
59/// response that cannot be sent at all — which an administrator cannot
60/// tell from a lost packet.
61///
62/// A full 32-octet source key is not reserved for. A response goes to an
63/// administrator, which is by definition a registered peer, so the hint
64/// form always suffices.
65const FRAME_RESERVE: usize = 75;
66
67/// The largest Node Management payload this device produces.
68///
69/// Also the size of each retained response, since a retained entry holds
70/// a complete payload.
71pub const ADMIN_PAYLOAD_MAX: usize = umsh_radio_loraphy::MAX_PAYLOAD - FRAME_RESERVE;
72
73/// A payload that cannot hold an envelope plus a frame is not a budget,
74/// it is a bug in the reserve above.
75const _: () = assert!(
76 ADMIN_PAYLOAD_MAX > umsh_node_mgmt::envelope::OVERHEAD_MAX + 32,
77 "FRAME_RESERVE leaves no room for a Node Management exchange"
78);
79
80/// An administrator cannot ask what a device's budget is, so it assumes
81/// the smallest one a device is allowed to have. A device below that
82/// would silently drop requests it is required to answer.
83const _: () = assert!(
84 ADMIN_PAYLOAD_MAX >= umsh_node_mgmt::PAYLOAD_MAX,
85 "this radio's payload is smaller than an administrator assumes"
86);
87
88/// One admitted request on its way to the responder.
89struct Request {
90 from: [u8; 32],
91 /// The channel the request arrived on, when it came as a blind unicast.
92 /// The response follows it back rather than naming both parties in the
93 /// clear.
94 channel: Option<ChannelId>,
95 payload: heapless::Vec<u8, ADMIN_PAYLOAD_MAX>,
96}
97
98/// Admitted requests, from the node's receive callback to the responder
99/// task.
100///
101/// One slot. An administrator may not have more than one exchange
102/// outstanding, and the responder serves one at a time, so a second
103/// arrival while one is in flight is either a different administrator or
104/// a retransmission — and both are better served by being asked again
105/// than by being queued behind an exchange that may take several radio
106/// round trips.
107static REQUESTS: Channel<NodeMutex, Request, 1> = Channel::new();
108
109/// The mirrored `PROP_DEV_ADMINS`, and the device-domain generation it
110/// was taken at.
111///
112/// Mirrored rather than read from the session because authorization
113/// happens in the node's receive callback, which cannot borrow the
114/// session and cannot await. An empty list disables node management
115/// entirely, which is the post-reset default.
116static ADMINS: BlockingMutex<
117 CriticalSectionRawMutex,
118 RefCell<heapless::Vec<[u8; 32], MAX_DEV_ADMINS>>,
119> = BlockingMutex::new(RefCell::new(heapless::Vec::new()));
120
121/// The device-domain generation the mirror was taken at, as the cursor
122/// generation.
123static GENERATION: AtomicU32 = AtomicU32::new(0);
124
125/// Requests dropped before reaching the engine, by reason.
126static UNAUTHORIZED: AtomicU32 = AtomicU32::new(0);
127static NOT_UNICAST: AtomicU32 = AtomicU32::new(0);
128static OVERSIZE: AtomicU32 = AtomicU32::new(0);
129static BUSY: AtomicU32 = AtomicU32::new(0);
130static RESPONSES_DROPPED: AtomicU32 = AtomicU32::new(0);
131
132/// What the binding refused, for a device that has to explain itself
133/// without having answered anybody.
134#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
135pub struct AdminCounters {
136 /// Requests from a node that is not a listed administrator, or whose
137 /// source the MAC could not authenticate.
138 pub unauthorized: u32,
139 /// Requests that arrived by multicast or broadcast.
140 pub not_unicast: u32,
141 /// Requests larger than this device's payload ceiling.
142 pub oversize: u32,
143 /// Requests that arrived while another exchange was in flight.
144 pub busy: u32,
145 /// Response payloads, which a device never solicits.
146 pub responses_dropped: u32,
147}
148
149/// What the binding has refused since boot.
150pub fn counters() -> AdminCounters {
151 AdminCounters {
152 unauthorized: UNAUTHORIZED.load(Ordering::Relaxed),
153 not_unicast: NOT_UNICAST.load(Ordering::Relaxed),
154 oversize: OVERSIZE.load(Ordering::Relaxed),
155 busy: BUSY.load(Ordering::Relaxed),
156 responses_dropped: RESPONSES_DROPPED.load(Ordering::Relaxed),
157 }
158}
159
160/// Republish the administrator list from a device-domain snapshot. Called
161/// by the device-domain sync loop on every snapshot, so a key added over
162/// either binding takes effect without a reboot.
163pub fn publish_dev_domain(snapshot: &DevDomainSnapshot) {
164 ADMINS.lock(|cell| {
165 let mut admins = cell.borrow_mut();
166 admins.clear();
167 for key in snapshot.admins.iter() {
168 let _ = admins.push(*key);
169 }
170 });
171 GENERATION.store(snapshot.version, Ordering::Relaxed);
172}
173
174fn is_admin(key: &[u8; 32]) -> bool {
175 ADMINS.lock(|cell| cell.borrow().iter().any(|listed| listed == key))
176}
177
178/// Decide whether an arriving packet is a Node Management Request this
179/// device will act on, and queue it if so.
180///
181/// Registered as a receive handler at bring-up; returns `false` always,
182/// so the packet still reaches every other observer. Everything refused
183/// is refused silently — an unlisted sender learns nothing about whether
184/// the device is manageable, not even that it declined to say.
185pub fn admit(packet: &ReceivedPacketRef<'_>) {
186 match packet.payload_type() {
187 PayloadType::NodeManagementRequest => {}
188 // A device never solicits anything, so a response addressed to it
189 // is either misdirected or an attempt to confuse it.
190 PayloadType::NodeManagementResponse => {
191 RESPONSES_DROPPED.fetch_add(1, Ordering::Relaxed);
192 return;
193 }
194 _ => return,
195 }
196 if !matches!(
197 packet.packet_family(),
198 PacketFamily::Unicast | PacketFamily::BlindUnicast
199 ) {
200 NOT_UNICAST.fetch_add(1, Ordering::Relaxed);
201 return;
202 }
203 // Both conditions matter and neither implies the other: an
204 // unauthenticated source could claim any key, and an authenticated
205 // one that is not listed is simply not an administrator here.
206 let Some(from) = packet.from_key().filter(|_| packet.source_authenticated()) else {
207 UNAUTHORIZED.fetch_add(1, Ordering::Relaxed);
208 return;
209 };
210 if !is_admin(&from.0) {
211 UNAUTHORIZED.fetch_add(1, Ordering::Relaxed);
212 return;
213 }
214 let mut payload = heapless::Vec::new();
215 if payload.extend_from_slice(packet.payload()).is_err() {
216 // Larger than this device can answer within. Dropped rather than
217 // truncated: a truncated envelope would be answered
218 // STATUS_PARSE_ERROR, which is a worse explanation than silence.
219 OVERSIZE.fetch_add(1, Ordering::Relaxed);
220 return;
221 }
222 if REQUESTS
223 .try_send(Request {
224 from: from.0,
225 channel: packet.channel().map(|c| c.id()),
226 payload,
227 })
228 .is_err()
229 {
230 BUSY.fetch_add(1, Ordering::Relaxed);
231 }
232}
233
234/// Serve admitted Node Management exchanges forever.
235///
236/// `nonce` is drawn once per boot from the platform's cryptographic RNG.
237/// It is what stops a cursor issued before a reboot from being honored
238/// after one, when the device-domain generation has started over.
239pub async fn responder_loop<CS: CounterStore + 'static, M: RawMutex + 'static>(
240 node: DeviceNode<CS>,
241 input: &'static InputChannel<M>,
242 nonce: u16,
243) {
244 let mut engine: DeviceEngine<ADMIN_PAYLOAD_MAX> = DeviceEngine::new(nonce);
245 let mut out = [0u8; ADMIN_PAYLOAD_MAX];
246 let mut cut = [0u8; ADMIN_PAYLOAD_MAX];
247 loop {
248 let request = REQUESTS.receive().await;
249 let generation = GENERATION.load(Ordering::Relaxed) as u16;
250 let now_ms = Instant::now().as_millis();
251 let len = match engine.begin(
252 &request.from,
253 &request.payload,
254 generation,
255 now_ms,
256 &mut out,
257 ) {
258 Ingress::Drop(reason) => {
259 debug_log(format_args!("admin: dropped {reason:?}"));
260 continue;
261 }
262 Ingress::Respond { len } => Some(len),
263 Ingress::Dispatch(dispatch) => {
264 let reply = serve(input, &dispatch).await;
265 let produced = produce(&reply, &dispatch, &mut cut);
266 match engine.complete(produced, &mut out) {
267 Ok(len) => len,
268 Err(error) => {
269 // Nothing goes out. The administrator retransmits,
270 // and gets here again — which is the honest
271 // outcome for a reply this device cannot carry.
272 debug_log(format_args!("admin: reply REFUSED {error:?}"));
273 continue;
274 }
275 }
276 }
277 };
278 // A reset-class command is answered by no payload at all; its
279 // delivery was confirmed by the MAC acknowledgment of the request.
280 let Some(len) = len else { continue };
281 respond(&node, &request.from, request.channel, &out[..len]).await;
282 }
283}
284
285/// Hand one frame to the session and wait for its answer.
286///
287/// The session belongs to the driver's task, so the exchange crosses the
288/// event loop. It always answers — an empty reply is the answer for a
289/// reset — so this never has to time out.
290async fn serve<M: RawMutex + 'static>(
291 input: &'static InputChannel<M>,
292 dispatch: &Dispatch<'_>,
293) -> AdminFrame {
294 let mut frame = AdminFrame::new();
295 if frame.extend_from_slice(dispatch.frame).is_err() {
296 // The frame came out of a payload smaller than this buffer.
297 debug_assert!(
298 false,
299 "admin request frame exceeds the driver's frame buffer"
300 );
301 return AdminFrame::new();
302 }
303 // A read may be continued with a cursor, so let the session build the
304 // whole answer and cut it down here. Everything else — a write
305 // sequence above all — is measured against what actually fits,
306 // because there is no continuing it: the binding's rule is that an
307 // entry whose reply would not fit is not executed at all.
308 let reply_budget = match dispatch.command() {
309 Some(cmd) if continuable(cmd) => MULTI_MAX,
310 _ => dispatch.budget,
311 };
312 input
313 .send(InEvent::Admin {
314 frame,
315 reply_budget,
316 })
317 .await;
318 ADMIN_REPLY.receive().await
319}
320
321/// Send one response payload back to the administrator.
322///
323/// No acknowledgment is requested. The administrator's token retry is the
324/// reliability layer for this binding, and it covers a lost response and
325/// a lost request alike — an ack would only tell the device something it
326/// has no use for.
327async fn respond<CS: CounterStore + 'static>(
328 node: &DeviceNode<CS>,
329 to: &[u8; 32],
330 channel: Option<ChannelId>,
331 payload: &[u8],
332) {
333 let mut wire = heapless::Vec::<u8, { ADMIN_PAYLOAD_MAX + 1 }>::new();
334 if wire
335 .push(PayloadType::NodeManagementResponse as u8)
336 .is_err()
337 || wire.extend_from_slice(payload).is_err()
338 {
339 debug_assert!(false, "admin response exceeds ADMIN_PAYLOAD_MAX");
340 return;
341 }
342 if node
343 .send_response(&PublicKey(*to), channel, &wire, &SendOptions::default())
344 .await
345 .is_err()
346 {
347 debug_log(format_args!(
348 "admin: response to {:02x}{:02x}.. FAILED",
349 to[0], to[1]
350 ));
351 }
352}