umshctl/command/
discover.rs

1//! `discover`: who else is out there.
2//!
3//! Two mechanisms wearing one command. Nodes advertise their identity on
4//! their own schedule, so a radio that simply listens learns who is
5//! nearby eventually; and a node may ask, with an Identity Request that
6//! everyone in earshot answers. This asks once and then listens, which
7//! is what makes the answer arrive in a minute rather than in an hour.
8//!
9//! The ask is a zero-hop broadcast with no flood budget: repeaters never
10//! carry it, so it reaches exactly the nodes that can hear this radio.
11//! Reaching further is what `--via` is for — it steers the question
12//! through the routers on the way to a node this tool already knows how
13//! to reach, and the answers come home along the trace the question
14//! accumulated.
15
16use std::cell::RefCell;
17use std::rc::Rc;
18use std::time::Duration;
19
20use anyhow::{Result, anyhow, bail};
21use tokio::time::Instant;
22
23use umsh::core::{PayloadType, PublicKey, RouterHint};
24use umsh::crypto::software::SoftwareIdentity;
25use umsh::hal::Radio;
26use umsh::mac::{CachedRoute, SendOptions};
27use umsh::node::mac_command::IdentityRequestBuilder;
28use umsh::node::{
29    MacCommand, NodeCapabilities, NodeIdentityPayload, NodeRole, ReceivedPacketRef, Transport,
30    mac_command,
31};
32use umsh_sync::AsyncRefCell;
33
34use super::values::{HintPrefixArg, KeyArg};
35use crate::App;
36use crate::mesh::{self, CtlMac, NodeStack};
37use crate::output::{field, note, subfield};
38use crate::routes::{self, RouteCache};
39
40/// How long a single pump waits before the deadline is checked again.
41const POLL: Duration = Duration::from_millis(250);
42
43/// Room for the request frame: an option block of a nonce and three
44/// filters, and the payload type byte in front of it.
45const REQUEST_FRAME: usize = 64;
46
47#[derive(Debug, clap::Args)]
48pub struct DiscoverArgs {
49    /// How long to listen, in seconds.
50    ///
51    /// A node holds its answer for a random delay of up to thirty
52    /// seconds so that everyone within earshot does not reply at once,
53    /// so anything shorter than that hears only the eager.
54    #[arg(long, short = 'W', default_value_t = 40, value_name = "SECS")]
55    pub timeout: u64,
56
57    /// Listen without asking: transmit nothing at all.
58    ///
59    /// What arrives is whatever nodes advertise on their own schedule,
60    /// which is a much quieter way to learn the same thing, given time.
61    #[arg(long)]
62    pub passive: bool,
63
64    /// Ask only nodes in this role to answer.
65    #[arg(long, value_name = "ROLE")]
66    pub role: Option<RoleFilter>,
67
68    /// Ask only nodes with every one of these capabilities.
69    #[arg(long, value_name = "CAP", value_delimiter = ',')]
70    pub caps: Vec<CapFilter>,
71
72    /// Ask only nodes whose hint starts with these bytes, as one to
73    /// three hex octets.
74    #[arg(long, value_name = "HEX")]
75    pub hint: Option<HintPrefixArg>,
76
77    /// Ask from this node's vantage rather than from here.
78    ///
79    /// The question is source-routed through the routers on the way to
80    /// it, so what answers is what can hear *that* node. Needs a
81    /// remembered source route — `routes` lists what there is.
82    #[arg(long, value_name = "KEY")]
83    pub via: Option<KeyArg>,
84}
85
86/// The roles a request can single out.
87///
88/// Deliberately not every [`NodeRole`]: `unspecified` is what a node
89/// says when it declines to say, and filtering for it would ask the
90/// least identifiable nodes to identify themselves.
91#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
92pub enum RoleFilter {
93    Repeater,
94    Chat,
95    Tracker,
96    Sensor,
97    Bridge,
98    ChatRoom,
99}
100
101impl RoleFilter {
102    fn role(self) -> NodeRole {
103        match self {
104            Self::Repeater => NodeRole::Repeater,
105            Self::Chat => NodeRole::Chat,
106            Self::Tracker => NodeRole::Tracker,
107            Self::Sensor => NodeRole::Sensor,
108            Self::Bridge => NodeRole::Bridge,
109            Self::ChatRoom => NodeRole::ChatRoom,
110        }
111    }
112}
113
114#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
115pub enum CapFilter {
116    Repeater,
117    Mobile,
118    TextMessages,
119    Telemetry,
120    ChatRoom,
121    Coap,
122}
123
124impl CapFilter {
125    fn bit(self) -> NodeCapabilities {
126        match self {
127            Self::Repeater => NodeCapabilities::REPEATER,
128            Self::Mobile => NodeCapabilities::MOBILE,
129            Self::TextMessages => NodeCapabilities::TEXT_MESSAGES,
130            Self::Telemetry => NodeCapabilities::TELEMETRY,
131            Self::ChatRoom => NodeCapabilities::CHAT_ROOM,
132            Self::Coap => NodeCapabilities::COAP,
133        }
134    }
135}
136
137/// `discover`: ask once, then listen.
138pub async fn discover(app: &mut App, args: DiscoverArgs) -> Result<()> {
139    mesh::borrowing_the_radio(app, args).await
140}
141
142impl mesh::RadioErrand for DiscoverArgs {
143    async fn run<R: Radio>(
144        self,
145        mac: &AsyncRefCell<CtlMac<R>>,
146        identity: SoftwareIdentity,
147    ) -> Result<()>
148    where
149        R::Error: core::fmt::Debug,
150    {
151        explore(mac, identity, &self).await
152    }
153}
154
155async fn explore<R: Radio>(
156    mac: &AsyncRefCell<CtlMac<R>>,
157    identity: SoftwareIdentity,
158    args: &DiscoverArgs,
159) -> Result<()>
160where
161    R::Error: core::fmt::Debug,
162{
163    let mut routes = RouteCache::load();
164    // Resolve the vantage before standing anything up: a route this tool
165    // cannot express is a mistake in the command line, not a failure
166    // halfway through a discovery.
167    let vantage = match &args.via {
168        Some(key) => Some(steer_through(&routes, &PublicKey(key.0))?),
169        None => None,
170    };
171
172    let (mut stack, local_key) = NodeStack::build(mac, identity).await?;
173    // A stranger answers with its whole key in the source address,
174    // having no reason to think this node has heard of it. Without this
175    // the MAC has nowhere to put that key and drops the answer.
176    stack.handle.set_auto_register_full_key_peers(true).await;
177
178    field("listening as", local_key.to_string());
179
180    let seen: Rc<RefCell<Vec<PublicKey>>> = Rc::new(RefCell::new(Vec::new()));
181    let started = Instant::now();
182
183    let nonce = if args.passive {
184        note("listening only; nothing is transmitted");
185        None
186    } else {
187        let nonce = ask(&mut stack, args, vantage.as_deref()).await?;
188        field("asked", describe_ask(args));
189        Some(nonce)
190    };
191
192    // An advertisement is addressed to everybody, so this only reads it —
193    // returning false leaves it for anyone else who is listening.
194    let sink = seen.clone();
195    let _subscription = stack.node.on_receive(move |packet| {
196        let Some(identity) = advertisement(packet) else {
197            return false;
198        };
199        let Some(key) = packet.from_key() else {
200            return false;
201        };
202        let mut seen = sink.borrow_mut();
203        if seen.contains(&key) {
204            return false;
205        }
206        seen.push(key);
207        report(&key, &identity, packet, nonce, started);
208        false
209    });
210
211    note(format!("listening for {} s", args.timeout));
212    let deadline = Instant::now() + Duration::from_secs(args.timeout);
213    loop {
214        if Instant::now() >= deadline {
215            break;
216        }
217        tokio::select! {
218            result = stack.pump_until(deadline.min(Instant::now() + POLL)) => result?,
219            // Ctrl-C ends the command rather than the process, so the
220            // shell gets its prompt back and the radio comes home.
221            _ = tokio::signal::ctrl_c() => {
222                println!();
223                break;
224            }
225        }
226    }
227
228    let count = seen.borrow().len();
229    match count {
230        0 if args.passive => note("nothing advertised itself"),
231        0 => note("nobody answered"),
232        1 => field("heard", "1 node"),
233        many => field("heard", format!("{many} nodes")),
234    }
235
236    // An answer that came back over a trace taught the MAC a way home.
237    routes.harvest(&stack.handle).await;
238    if let Err(error) = routes.store() {
239        crate::output::warn(format!("could not save learned routes: {error:#}"));
240    }
241    let _ = stack.handle.service_counter_persistence().await;
242    Ok(())
243}
244
245/// Broadcast one Identity Request, and return the nonce it carries so
246/// the answers can be told from unsolicited advertisements.
247async fn ask<R: Radio>(
248    stack: &mut NodeStack<'_, R>,
249    args: &DiscoverArgs,
250    vantage: Option<&[RouterHint]>,
251) -> Result<u32>
252where
253    R::Error: core::fmt::Debug,
254{
255    let mut bytes = [0u8; 4];
256    stack.handle.fill_random(&mut bytes).await;
257    let nonce = u32::from_be_bytes(bytes);
258    let frame = request_frame(args, nonce)?;
259
260    // Full source lets a stranger unicast an answer back.
261    let mut send = SendOptions::default().with_full_source();
262    if let Some(hops) = vantage {
263        send = send
264            .try_with_source_route(hops)
265            .map_err(|_| anyhow!("that route is longer than a request can carry"))?
266            // The trace the question accumulates is the answering
267            // strangers' only path home: a broadcast teaches the MAC no
268            // route, and the answer carries no flood budget.
269            .with_trace_route();
270    }
271    // Last, always: a source route back-fills a flood budget from its
272    // own length, and any flood budget at all makes the far end drop an
273    // unhinted solicitation.
274    let send = send.no_flood();
275
276    stack
277        .node
278        .send_all(&frame, &send)
279        .await
280        .map_err(|error| anyhow!("asking: {error:?}"))?;
281    Ok(nonce)
282}
283
284/// The Identity Request payload this ask sends, payload-type byte and
285/// all.
286fn request_frame(args: &DiscoverArgs, nonce: u32) -> Result<Vec<u8>> {
287    // Options are emitted in ascending key order: nonce, hint, role,
288    // capabilities. The builder does not sort them.
289    let mut builder = IdentityRequestBuilder::new()
290        .nonce(nonce)
291        .map_err(|error| anyhow!("building the request: {error:?}"))?;
292    if let Some(hint) = &args.hint {
293        builder = builder
294            .filter_hint_prefix(&hint.0)
295            .map_err(|error| anyhow!("building the hint filter: {error:?}"))?;
296    }
297    if let Some(role) = args.role {
298        builder = builder
299            .filter_role(role.role())
300            .map_err(|error| anyhow!("building the role filter: {error:?}"))?;
301    }
302    // A broadcast request must carry at least one filter option, so an
303    // ask that names nothing carries a zero-bit capability filter, which
304    // every node satisfies. A hint or a role has already narrowed it.
305    let caps = args
306        .caps
307        .iter()
308        .fold(NodeCapabilities::empty(), |bits, cap| bits | cap.bit());
309    if !args.caps.is_empty() || (args.hint.is_none() && args.role.is_none()) {
310        builder = builder
311            .filter_caps(caps)
312            .map_err(|error| anyhow!("building the capability filter: {error:?}"))?;
313    }
314
315    let options = builder.build();
316    let command = MacCommand::IdentityRequest { options: &options };
317    let mut frame = [0u8; REQUEST_FRAME];
318    frame[0] = PayloadType::MacCommand as u8;
319    let length = mac_command::encode(&command, &mut frame[1..])
320        .map_err(|error| anyhow!("encoding the request: {error:?}"))?
321        + 1;
322    Ok(frame[..length].to_vec())
323}
324
325/// The router hints that steer a question through `peer` to whatever is
326/// around it.
327fn steer_through(routes: &RouteCache, peer: &PublicKey) -> Result<Vec<RouterHint>> {
328    let Some(record) = routes.get(peer) else {
329        bail!("no remembered route to {peer}; reach it once — `ping {peer}` — and try again");
330    };
331    match &record.route {
332        CachedRoute::Source(hints) => Ok(hints.to_vec()),
333        // A direct neighbor is reached with no routers in between, so
334        // there is nothing to steer through: the question would go out
335        // exactly as it does without --via.
336        CachedRoute::Direct => bail!(
337            "{peer} is a direct neighbor, so asking from its vantage is asking from here; drop --via"
338        ),
339        CachedRoute::Flood { .. } => bail!(
340            "only a flood route to {peer} is remembered ({}); a vantage needs the routers named",
341            routes::describe(&record.route)
342        ),
343    }
344}
345
346/// Read a packet as a node identity advertisement, if that is what it is.
347fn advertisement(packet: &ReceivedPacketRef<'_>) -> Option<NodeIdentityPayload> {
348    if packet.payload_type() != PayloadType::NodeIdentity {
349        return None;
350    }
351    // The payload type rides in the MAC header, so the payload is the
352    // identity from its first byte.
353    NodeIdentityPayload::from_bytes(packet.payload()).ok()
354}
355
356/// Print one node, the first time it is heard.
357fn report(
358    key: &PublicKey,
359    identity: &NodeIdentityPayload,
360    packet: &ReceivedPacketRef<'_>,
361    asked: Option<u32>,
362    started: Instant,
363) {
364    println!("{key}");
365    if let Some(name) = &identity.name {
366        subfield("name", name);
367    }
368    subfield("role", role_name(identity.role));
369    if !identity.capabilities.is_empty() {
370        subfield("capabilities", capability_names(identity.capabilities));
371    }
372    if let Some(location) = &identity.location
373        && !location.is_unspecified()
374    {
375        let (lat, lon) = location.center();
376        subfield(
377            "location",
378            format!("{lat:.4}, {lon:.4} (±{} bytes)", location.precision()),
379        );
380    }
381    if let Some(regions) = &identity.supported_regions
382        && !regions.is_empty()
383    {
384        subfield("regions", regions.join(", "));
385    }
386    if let Some(rssi) = packet.rssi() {
387        subfield("signal", format!("{rssi} dBm"));
388    }
389    if let Some(hops) = packet.hop_count() {
390        subfield("hops", hops);
391    }
392    subfield(
393        "heard",
394        match (asked, identity.nonce) {
395            (Some(mine), Some(theirs)) if mine == theirs => "an answer to this ask".to_string(),
396            // Somebody else is discovering at the same time, and this
397            // radio overheard the reply. Worth saying: it means the node
398            // is there, but says nothing about whether it heard *us*.
399            (_, Some(_)) => "an answer to somebody else's ask".to_string(),
400            (_, None) => format!("advertised, {:.0?} in", started.elapsed()),
401        },
402    );
403    if !packet.source_authenticated() {
404        subfield("warning", "this identity is not authenticated");
405    }
406}
407
408fn role_name(role: NodeRole) -> String {
409    match role {
410        NodeRole::Unspecified => "unspecified".to_string(),
411        NodeRole::Repeater => "repeater".to_string(),
412        NodeRole::Chat => "chat".to_string(),
413        NodeRole::Tracker => "tracker".to_string(),
414        NodeRole::Sensor => "sensor".to_string(),
415        NodeRole::Bridge => "bridge".to_string(),
416        NodeRole::ChatRoom => "chat room".to_string(),
417        NodeRole::TemporarySession => "temporary session".to_string(),
418        NodeRole::Unknown(code) => format!("role {code}"),
419    }
420}
421
422fn capability_names(caps: NodeCapabilities) -> String {
423    let named = [
424        (NodeCapabilities::REPEATER, "repeater"),
425        (NodeCapabilities::MOBILE, "mobile"),
426        (NodeCapabilities::TEXT_MESSAGES, "text-messages"),
427        (NodeCapabilities::TELEMETRY, "telemetry"),
428        (NodeCapabilities::CHAT_ROOM, "chat-room"),
429        (NodeCapabilities::COAP, "coap"),
430    ];
431    let mut out: Vec<&str> = named
432        .iter()
433        .filter(|(bit, _)| caps.contains(*bit))
434        .map(|(_, name)| *name)
435        .collect();
436    let known = named
437        .iter()
438        .fold(NodeCapabilities::empty(), |bits, (bit, _)| bits | *bit);
439    let rest = caps.bits() & !known.bits();
440    let extra;
441    if rest != 0 {
442        extra = format!("0x{rest:02x}");
443        out.push(&extra);
444    }
445    out.join(", ")
446}
447
448/// How the ask reads back, so the report says what was actually asked.
449fn describe_ask(args: &DiscoverArgs) -> String {
450    let mut parts = Vec::new();
451    if let Some(hint) = &args.hint {
452        parts.push(format!(
453            "hint {}",
454            hint.0
455                .iter()
456                .map(|byte| format!("{byte:02x}"))
457                .collect::<String>()
458        ));
459    }
460    if let Some(role) = args.role {
461        parts.push(role_name(role.role()));
462    }
463    if !args.caps.is_empty() {
464        parts.push(capability_names(
465            args.caps
466                .iter()
467                .fold(NodeCapabilities::empty(), |bits, cap| bits | cap.bit()),
468        ));
469    }
470    let who = if parts.is_empty() {
471        "every node in earshot".to_string()
472    } else {
473        parts.join(", ")
474    };
475    match &args.via {
476        Some(key) => format!("{who}, from the vantage of {key}", key = PublicKey(key.0)),
477        None => who,
478    }
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484    use umsh::core::NodeHint;
485    use umsh::node::mac_command::IdentityRequestFilters;
486
487    /// The frame this command sends, read back the way a node that
488    /// receives it reads it.
489    fn filters_of(frame: &[u8]) -> (Option<u32>, Vec<u8>) {
490        assert_eq!(frame[0], PayloadType::MacCommand as u8, "payload type");
491        let MacCommand::IdentityRequest { options } =
492            mac_command::parse(&frame[1..]).expect("parses as a MAC command")
493        else {
494            panic!("not an identity request");
495        };
496        let filters = IdentityRequestFilters::new(options);
497        (filters.nonce().expect("a readable nonce"), options.to_vec())
498    }
499
500    fn bare() -> DiscoverArgs {
501        DiscoverArgs {
502            timeout: 40,
503            passive: false,
504            role: None,
505            caps: Vec::new(),
506            hint: None,
507            via: None,
508        }
509    }
510
511    #[test]
512    fn an_unfiltered_ask_still_carries_a_filter_everyone_satisfies() {
513        let frame = request_frame(&bare(), 0x0102_0304).unwrap();
514        let (nonce, options) = filters_of(&frame);
515        assert_eq!(nonce, Some(0x0102_0304));
516
517        // A broadcast request with no filter option at all is dropped by
518        // the far end, so an ask that names nobody asks for no
519        // capabilities in particular — which every node has.
520        let filters = IdentityRequestFilters::new(&options);
521        assert!(!filters.hint_filtered());
522        for role in [NodeRole::Repeater, NodeRole::Chat, NodeRole::Sensor] {
523            assert!(
524                filters
525                    .selects(
526                        role,
527                        NodeCapabilities::empty(),
528                        &NodeHint([0x11, 0x22, 0x33])
529                    )
530                    .unwrap(),
531                "{role:?} should be selected by an unfiltered ask"
532            );
533        }
534    }
535
536    #[test]
537    fn a_narrowed_ask_selects_only_what_it_named() {
538        let args = DiscoverArgs {
539            role: Some(RoleFilter::Repeater),
540            caps: vec![CapFilter::Telemetry],
541            hint: Some(HintPrefixArg(vec![0xa1])),
542            ..bare()
543        };
544        let frame = request_frame(&args, 7).unwrap();
545        let (nonce, options) = filters_of(&frame);
546        assert_eq!(nonce, Some(7));
547        let filters = IdentityRequestFilters::new(&options);
548        assert!(filters.hint_filtered());
549
550        let matching = NodeHint([0xa1, 0x22, 0x33]);
551        let elsewhere = NodeHint([0xb0, 0x22, 0x33]);
552        let telemetry = NodeCapabilities::TELEMETRY | NodeCapabilities::REPEATER;
553        assert!(
554            filters
555                .selects(NodeRole::Repeater, telemetry, &matching)
556                .unwrap()
557        );
558        // Every named filter has to hold: the wrong role, a missing
559        // capability, or a hint outside the prefix each rule the node out.
560        assert!(
561            !filters
562                .selects(NodeRole::Chat, telemetry, &matching)
563                .unwrap()
564        );
565        assert!(
566            !filters
567                .selects(NodeRole::Repeater, NodeCapabilities::REPEATER, &matching)
568                .unwrap()
569        );
570        assert!(
571            !filters
572                .selects(NodeRole::Repeater, telemetry, &elsewhere)
573                .unwrap()
574        );
575    }
576
577    #[test]
578    fn capabilities_read_as_names_and_keep_what_they_do_not_know() {
579        assert_eq!(
580            capability_names(NodeCapabilities::REPEATER | NodeCapabilities::TEXT_MESSAGES),
581            "repeater, text-messages"
582        );
583        assert_eq!(capability_names(NodeCapabilities::empty()), "");
584        // A bit this build has no name for is still worth reporting.
585        assert_eq!(
586            capability_names(NodeCapabilities::from_bits_retain(0x81)),
587            "repeater, 0x80"
588        );
589    }
590
591    #[test]
592    fn an_ask_says_who_it_is_for() {
593        assert_eq!(describe_ask(&bare()), "every node in earshot");
594
595        let narrowed = DiscoverArgs {
596            role: Some(RoleFilter::Repeater),
597            caps: vec![CapFilter::Telemetry],
598            hint: Some(HintPrefixArg(vec![0xa1, 0xb2])),
599            ..bare()
600        };
601        assert_eq!(describe_ask(&narrowed), "hint a1b2, repeater, telemetry");
602    }
603
604    #[test]
605    fn a_vantage_needs_the_routers_named() {
606        let mut routes = RouteCache::default();
607        let peer = PublicKey([0x11; 32]);
608        // Nothing remembered at all.
609        assert!(steer_through(&routes, &peer).is_err());
610
611        // A direct neighbor has no routers in between.
612        routes.record(&peer, CachedRoute::Direct);
613        assert!(steer_through(&routes, &peer).is_err());
614
615        // A flood route names no routers either.
616        routes.record(&peer, CachedRoute::flood(5, &[]).unwrap());
617        assert!(steer_through(&routes, &peer).is_err());
618
619        let hops = [RouterHint([0xa1, 0xb2]), RouterHint([0xc3, 0xd4])];
620        routes.record(&peer, CachedRoute::source(&hops).unwrap());
621        assert_eq!(steer_through(&routes, &peer).unwrap(), hops);
622    }
623}