umshctl/
routes.rs

1//! Routes remembered between invocations.
2//!
3//! The MAC learns a route from inbound traffic and keeps it in the peer
4//! registry, which lives exactly as long as the `Mac` does — and this
5//! tool builds a new one for every command. Without somewhere to put
6//! them, two `manage` calls a second apart each flood the mesh to
7//! discover the same path.
8//!
9//! So each session hands its learned routes to a file, and the next one
10//! puts them back before it says anything. A route is a hint, not a
11//! promise: a stale one costs a single failed exchange, after which the
12//! MAC's own ack-timeout retry rediscovers the path and overwrites it.
13//! That is why the file can be lost, truncated, or edited by hand
14//! without breaking anything.
15
16use std::collections::HashMap;
17use std::fmt::Write as _;
18use std::time::{Duration, SystemTime, UNIX_EPOCH};
19
20use anyhow::{Context as _, Result};
21
22use umsh::core::{PublicKey, RouterHint};
23use umsh::hal::Radio;
24use umsh::mac::CachedRoute;
25
26use crate::connection;
27
28/// How long a remembered route is worth trying.
29///
30/// A day, because the meshes this tool works on are mostly repeaters
31/// bolted to buildings, and a path good this morning is usually good
32/// tonight. The cost of being wrong is one exchange, so there is no case
33/// for expiring sooner and none for asking the user to tune it.
34pub const ROUTE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
35
36/// The file's first line, so a future format can be told apart from this
37/// one without guessing.
38const HEADER: &str = "# umshctl learned routes v1";
39
40/// One remembered route and when it was learned.
41#[derive(Clone, Debug, PartialEq, Eq)]
42pub struct RouteRecord {
43    pub route: CachedRoute,
44    pub learned_at: SystemTime,
45}
46
47impl RouteRecord {
48    /// How long ago this route was learned. Zero for a record stamped in
49    /// the future, which a clock change can produce.
50    pub fn age(&self) -> Duration {
51        SystemTime::now()
52            .duration_since(self.learned_at)
53            .unwrap_or_default()
54    }
55
56    pub fn expired(&self) -> bool {
57        self.age() >= ROUTE_TTL
58    }
59}
60
61/// Every route this tool remembers.
62#[derive(Debug, Default)]
63pub struct RouteCache {
64    entries: HashMap<[u8; 32], RouteRecord>,
65    /// Keys this session deliberately forgot. Kept so that a merge with
66    /// whatever another invocation wrote does not quietly put them back:
67    /// forgetting is something somebody asked for, and it should not
68    /// depend on who writes last.
69    forgotten: Vec<[u8; 32]>,
70    dirty: bool,
71}
72
73impl RouteCache {
74    /// Read the remembered routes, dropping whatever has expired.
75    ///
76    /// A missing file is an empty cache, and so is an unreadable or
77    /// malformed one: this is a cache, and refusing to run because it
78    /// cannot be parsed would trade a slow command for no command.
79    pub fn load() -> Self {
80        let Some(path) = connection::routes_path() else {
81            return Self::default();
82        };
83        let Ok(text) = std::fs::read_to_string(&path) else {
84            return Self::default();
85        };
86        let mut cache = Self::parse(&text);
87        // Expiry is enforced on the way in, so nothing downstream has to
88        // remember to ask.
89        cache.entries.retain(|_, record| !record.expired());
90        cache
91    }
92
93    fn parse(text: &str) -> Self {
94        let mut entries = HashMap::new();
95        for line in text.lines() {
96            let line = line.trim();
97            if line.is_empty() || line.starts_with('#') {
98                continue;
99            }
100            // One unreadable line loses one route, not the file.
101            if let Some((key, record)) = parse_line(line) {
102                entries.insert(key, record);
103            }
104        }
105        Self {
106            entries,
107            forgotten: Vec::new(),
108            dirty: false,
109        }
110    }
111
112    /// The route remembered for `peer`, if it has not expired.
113    pub fn get(&self, peer: &PublicKey) -> Option<&RouteRecord> {
114        self.entries.get(&peer.0).filter(|record| !record.expired())
115    }
116
117    /// Remember `route` for `peer`, stamped now.
118    ///
119    /// A route identical to the one already held keeps its original
120    /// timestamp: the age is meant to say when the path was learned, not
121    /// when it was last written down.
122    pub fn record(&mut self, peer: &PublicKey, route: CachedRoute) {
123        if let Some(existing) = self.entries.get(&peer.0)
124            && existing.route == route
125            && !existing.expired()
126        {
127            return;
128        }
129        self.forgotten.retain(|key| key != &peer.0);
130        self.entries.insert(
131            peer.0,
132            RouteRecord {
133                route,
134                learned_at: SystemTime::now(),
135            },
136        );
137        self.dirty = true;
138    }
139
140    /// Forget `peer`'s route, reporting whether one was held.
141    pub fn remove(&mut self, peer: &PublicKey) -> bool {
142        let removed = self.entries.remove(&peer.0).is_some();
143        if !self.forgotten.contains(&peer.0) {
144            self.forgotten.push(peer.0);
145        }
146        self.dirty |= removed;
147        removed
148    }
149
150    /// Forget everything, reporting how many routes went.
151    pub fn clear(&mut self) -> usize {
152        let count = self.entries.len();
153        for key in self.entries.keys() {
154            if !self.forgotten.contains(key) {
155                self.forgotten.push(*key);
156            }
157        }
158        self.entries.clear();
159        self.dirty |= count > 0;
160        count
161    }
162
163    /// Every remembered route, newest first.
164    pub fn iter(&self) -> Vec<(PublicKey, &RouteRecord)> {
165        let mut all: Vec<(PublicKey, &RouteRecord)> = self
166            .entries
167            .iter()
168            .map(|(key, record)| (PublicKey(*key), record))
169            .collect();
170        all.sort_by_key(|(key, record)| (std::cmp::Reverse(record.learned_at), key.0));
171        all
172    }
173
174    pub fn is_empty(&self) -> bool {
175        self.entries.is_empty()
176    }
177
178    /// Copy the live MAC's routes into the cache.
179    ///
180    /// Only what changed is stamped, so an unchanged route keeps saying
181    /// when it was learned rather than when it was last looked at.
182    pub async fn harvest<R: Radio>(&mut self, handle: &crate::mesh::CtlHandle<'_, R>) {
183        let mut peers = Vec::new();
184        handle.for_each_peer(&mut |peer| peers.push(peer)).await;
185        for peer in peers {
186            match handle.peer_route(&peer).await {
187                Some(route) => self.record(&peer, route),
188                // A peer whose route the MAC dropped has had it
189                // invalidated; keeping ours would put it straight back.
190                None => {
191                    self.remove(&peer);
192                }
193            }
194        }
195    }
196
197    /// Write the cache back, merging whatever another invocation wrote
198    /// while this one was running.
199    ///
200    /// Two shells against two radios is an ordinary way to use this
201    /// tool, and last-writer-wins over the whole file would throw away
202    /// the other's fresh routes. Merging by timestamp keeps the newer of
203    /// each, which is the same rule the cache uses against itself.
204    pub fn store(&mut self) -> Result<()> {
205        if !self.dirty {
206            return Ok(());
207        }
208        let Some(path) = connection::routes_path() else {
209            // No state directory is not an error for a cache; the tool
210            // simply forgets between runs, as it always used to.
211            return Ok(());
212        };
213        if let Some(parent) = path.parent() {
214            std::fs::create_dir_all(parent)
215                .with_context(|| format!("creating {}", parent.display()))?;
216        }
217        let mut merged = Self::load();
218        merged
219            .entries
220            .retain(|key, _| !self.forgotten.contains(key));
221        for (key, record) in &self.entries {
222            let newer = merged
223                .entries
224                .get(key)
225                .is_none_or(|existing| existing.learned_at <= record.learned_at);
226            if newer {
227                merged.entries.insert(*key, record.clone());
228            }
229        }
230        std::fs::write(&path, merged.render())
231            .with_context(|| format!("writing {}", path.display()))?;
232        self.dirty = false;
233        Ok(())
234    }
235
236    fn render(&self) -> String {
237        let mut out = format!("{HEADER}\n");
238        for (key, record) in self.iter() {
239            let seconds = record
240                .learned_at
241                .duration_since(UNIX_EPOCH)
242                .unwrap_or_default()
243                .as_secs();
244            let _ = writeln!(out, "{key} {} {seconds}", render_route(&record.route));
245        }
246        out
247    }
248}
249
250/// A route as one field of kind and its parameters:
251///
252/// ```text
253/// <key> direct <unix-seconds>
254/// <key> source a1b2,c3d4 <unix-seconds>
255/// <key> flood 5 68ac,9b21 <unix-seconds>
256/// ```
257///
258/// A dash stands in for an empty hint or region list, so every form has
259/// the same field count and a line can be read without counting.
260fn render_route(route: &CachedRoute) -> String {
261    match route {
262        CachedRoute::Direct => "direct".to_string(),
263        CachedRoute::Source(hints) => {
264            format!("source {}", render_pairs(hints.iter().map(|hint| hint.0)))
265        }
266        CachedRoute::Flood { hops, regions } => {
267            format!("flood {hops} {}", render_pairs(regions.iter().copied()))
268        }
269    }
270}
271
272fn render_pairs(pairs: impl Iterator<Item = [u8; 2]>) -> String {
273    let rendered: Vec<String> = pairs
274        .map(|pair| format!("{:02x}{:02x}", pair[0], pair[1]))
275        .collect();
276    if rendered.is_empty() {
277        return "-".to_string();
278    }
279    rendered.join(",")
280}
281
282fn parse_pairs(text: &str) -> Option<Vec<[u8; 2]>> {
283    if text == "-" {
284        return Some(Vec::new());
285    }
286    text.split(',')
287        .map(|pair| {
288            let bytes = u16::from_str_radix(pair, 16).ok()?;
289            (pair.len() == 4).then(|| bytes.to_be_bytes())
290        })
291        .collect()
292}
293
294fn parse_line(line: &str) -> Option<([u8; 32], RouteRecord)> {
295    let mut fields = line.split_whitespace();
296    let key = fields.next()?.parse::<PublicKey>().ok()?;
297    let route = match fields.next()? {
298        "direct" => CachedRoute::Direct,
299        "source" => {
300            let hints: Vec<RouterHint> = parse_pairs(fields.next()?)?
301                .into_iter()
302                .map(RouterHint)
303                .collect();
304            // A route longer than a packet can carry is not one to
305            // truncate into something that goes somewhere else.
306            CachedRoute::source(&hints)?
307        }
308        "flood" => {
309            let hops = fields.next()?.parse::<u8>().ok()?;
310            CachedRoute::flood(hops, &parse_pairs(fields.next()?)?)?
311        }
312        _ => return None,
313    };
314    let seconds = fields.next()?.parse::<u64>().ok()?;
315    // A line with more fields than this format defines was written by
316    // something else, and guessing at it would be worse than skipping it.
317    if fields.next().is_some() {
318        return None;
319    }
320    Some((
321        key.0,
322        RouteRecord {
323            route,
324            learned_at: UNIX_EPOCH + Duration::from_secs(seconds),
325        },
326    ))
327}
328
329/// How a route reads in a listing: what it does, not how it is encoded.
330pub fn describe(route: &CachedRoute) -> String {
331    match route {
332        CachedRoute::Direct => "direct".to_string(),
333        CachedRoute::Source(hints) if hints.is_empty() => "source route, no hops".to_string(),
334        CachedRoute::Source(hints) => format!(
335            "via {}",
336            hints
337                .iter()
338                .map(|hint| format!("{:02x}{:02x}", hint.0[0], hint.0[1]))
339                .collect::<Vec<_>>()
340                .join(" > ")
341        ),
342        CachedRoute::Flood { hops, regions } if regions.is_empty() => {
343            format!("flood, {hops} hops")
344        }
345        CachedRoute::Flood { hops, regions } => format!(
346            "flood, {hops} hops, regions {}",
347            regions
348                .iter()
349                .map(|code| umsh::core::RegionCode::from_bytes(*code).to_string())
350                .collect::<Vec<_>>()
351                .join(" ")
352        ),
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    fn key(byte: u8) -> PublicKey {
361        PublicKey([byte; 32])
362    }
363
364    fn record(route: CachedRoute, age: Duration) -> RouteRecord {
365        RouteRecord {
366            route,
367            learned_at: SystemTime::now() - age,
368        }
369    }
370
371    fn cache_of(entries: &[(PublicKey, RouteRecord)]) -> RouteCache {
372        let mut cache = RouteCache::default();
373        for (key, record) in entries {
374            cache.entries.insert(key.0, record.clone());
375        }
376        cache
377    }
378
379    /// Forgetting is something somebody asked for, so a merge with
380    /// another invocation's file must not put it back.
381    #[test]
382    fn a_forgotten_route_stays_forgotten_through_a_merge() {
383        let mut cache = cache_of(&[
384            (key(20), record(CachedRoute::Direct, Duration::from_secs(5))),
385            (key(21), record(CachedRoute::Direct, Duration::from_secs(5))),
386        ]);
387        assert!(cache.remove(&key(20)));
388
389        // What the other invocation left on disk, including the route
390        // this session just forgot.
391        let theirs = cache_of(&[
392            (key(20), record(CachedRoute::Direct, Duration::ZERO)),
393            (key(22), record(CachedRoute::Direct, Duration::ZERO)),
394        ]);
395        let mut merged = RouteCache::parse(&theirs.render());
396        merged
397            .entries
398            .retain(|key, _| !cache.forgotten.contains(key));
399        for (key, record) in &cache.entries {
400            merged.entries.insert(*key, record.clone());
401        }
402
403        assert!(!merged.entries.contains_key(&key(20).0), "forgotten");
404        assert!(merged.entries.contains_key(&key(21).0), "ours");
405        assert!(merged.entries.contains_key(&key(22).0), "theirs");
406    }
407
408    /// Learning a route again after forgetting it is not a contradiction
409    /// — the forget is spent.
410    #[test]
411    fn relearning_a_forgotten_route_un_forgets_it() {
412        let mut cache = cache_of(&[(key(23), record(CachedRoute::Direct, Duration::ZERO))]);
413        cache.remove(&key(23));
414        assert!(cache.forgotten.contains(&key(23).0));
415        cache.record(&key(23), CachedRoute::Direct);
416        assert!(!cache.forgotten.contains(&key(23).0));
417        assert!(cache.get(&key(23)).is_some());
418    }
419
420    #[test]
421    fn every_route_shape_survives_the_round_trip() {
422        let source =
423            CachedRoute::source(&[RouterHint([0xA1, 0xB2]), RouterHint([0xC3, 0xD4])]).unwrap();
424        let flood = CachedRoute::flood(5, &[[0x68, 0xAC], [0x9B, 0x21]]).unwrap();
425        let cache = cache_of(&[
426            (key(1), record(CachedRoute::Direct, Duration::from_secs(10))),
427            (key(2), record(source.clone(), Duration::from_secs(20))),
428            (key(3), record(flood.clone(), Duration::from_secs(30))),
429        ]);
430
431        let parsed = RouteCache::parse(&cache.render());
432        assert_eq!(parsed.get(&key(1)).unwrap().route, CachedRoute::Direct);
433        assert_eq!(parsed.get(&key(2)).unwrap().route, source);
434        assert_eq!(parsed.get(&key(3)).unwrap().route, flood);
435    }
436
437    /// An empty hint or region list is a real state, and must not come
438    /// back as a missing field or a route of a different shape.
439    #[test]
440    fn the_empty_forms_round_trip_as_themselves() {
441        let empty_source = CachedRoute::source(&[]).unwrap();
442        let plain_flood = CachedRoute::flood(3, &[]).unwrap();
443        let cache = cache_of(&[
444            (key(4), record(empty_source.clone(), Duration::ZERO)),
445            (key(5), record(plain_flood.clone(), Duration::ZERO)),
446        ]);
447
448        let parsed = RouteCache::parse(&cache.render());
449        assert_eq!(parsed.get(&key(4)).unwrap().route, empty_source);
450        assert_eq!(parsed.get(&key(5)).unwrap().route, plain_flood);
451    }
452
453    #[test]
454    fn an_expired_route_is_not_offered() {
455        let cache = cache_of(&[
456            (key(6), record(CachedRoute::Direct, Duration::from_secs(60))),
457            (key(7), record(CachedRoute::Direct, ROUTE_TTL)),
458        ]);
459        assert!(cache.get(&key(6)).is_some());
460        assert!(cache.get(&key(7)).is_none(), "a route at the TTL is spent");
461    }
462
463    /// The file is a cache somebody may have edited. One bad line costs
464    /// one route.
465    #[test]
466    fn a_malformed_line_costs_only_itself() {
467        let good = format!("{} direct 1000", key(8));
468        let text = format!(
469            "{HEADER}\n\
470             {good}\n\
471             not-a-key direct 1000\n\
472             {} teleport 1000\n\
473             {} source zzzz 1000\n\
474             {} direct 1000 extra\n\
475             \n\
476             # a comment\n",
477            key(9),
478            key(10),
479            key(11),
480        );
481        let parsed = RouteCache::parse(&text);
482        assert_eq!(parsed.entries.len(), 1);
483        assert!(parsed.entries.contains_key(&key(8).0));
484    }
485
486    /// The age is when the path was learned, not when it was last
487    /// written down: an unchanged route keeps its stamp so a listing
488    /// says something true about how old the path is.
489    #[test]
490    fn recording_the_same_route_again_does_not_refresh_its_age() {
491        let mut cache = cache_of(&[(
492            key(12),
493            record(CachedRoute::Direct, Duration::from_secs(600)),
494        )]);
495        let before = cache.get(&key(12)).unwrap().learned_at;
496        cache.record(&key(12), CachedRoute::Direct);
497        assert_eq!(cache.get(&key(12)).unwrap().learned_at, before);
498        assert!(!cache.dirty, "an unchanged route is not a reason to write");
499
500        // A different route is news, and is stamped now.
501        cache.record(&key(12), CachedRoute::flood(4, &[]).unwrap());
502        assert!(cache.get(&key(12)).unwrap().learned_at > before);
503        assert!(cache.dirty);
504    }
505
506    #[test]
507    fn a_header_only_file_is_an_empty_cache() {
508        assert!(RouteCache::parse(&format!("{HEADER}\n")).is_empty());
509        assert!(RouteCache::parse("").is_empty());
510    }
511}