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}