Skip to main content

ados_protocol/
pair_proof.rs

1//! The WFB pair-proof record: per-key evidence that a radio key has ever
2//! actually carried a link.
3//!
4//! ## Why this exists
5//!
6//! Auto-pair used to decide "am I already paired?" by looking at the shape of
7//! the key file — 64 bytes with a fingerprintable peer-public half. That is a
8//! FILE check, not a LINK check, and a key left behind by a ground station that
9//! was since reflashed is a perfectly well-formed key. Two rigs sat unlinked for
10//! an entire session on exactly that: the drone injected into a void holding a
11//! structurally valid key from a peer that no longer existed, its own radio
12//! reporting `rf_unverified` (transmitting, zero confirmed reception), while
13//! auto-pair stayed permanently disarmed because the file looked fine.
14//!
15//! ## The rule this record encodes
16//!
17//! Auto-pair may re-arm itself ONLY for a key whose fingerprint has NEVER ONCE
18//! been confirmed to work. The fingerprint is the lifetime key:
19//!
20//! - A stale key from a reflashed peer re-arms, because its fingerprint never
21//!   entered the proof record — the peer that would have proven it is gone.
22//! - A healthy link NEVER re-arms. Once a fingerprint is latched as proven, no
23//!   amount of downtime re-arms it, so a transient (or multi-day) RF dropout is
24//!   structurally incapable of triggering a re-bind. Silently re-binding a
25//!   proven pair would be a worse failure than the deadlock this fixes.
26//! - A fingerprint mismatch is a FULL reset, so a successful re-bind hands the
27//!   new key a clean lifetime automatically.
28//!
29//! Deciding to OPEN a bind window is all this governs. Whether a bind may then
30//! DECLARE a pair is unchanged: the orchestrator's peer-evidence gate remains
31//! the sole authority on success.
32//!
33//! ## Where it lives
34//!
35//! `/var/lib`, deliberately — NOT `/run`. On tmpfs a reboot would erase the
36//! episode budget and the cooldown, and a rig stuck in a boot loop would
37//! reintroduce the bind storm the budget exists to bound.
38//!
39//! Two processes write it: the supervisor's re-arm reconciler (every episode)
40//! and the operator force escape hatch on the auto-pair route. The record type
41//! and its one atomic writer therefore live here, beside the other sidecar
42//! records, rather than in either writer's own crate.
43
44use std::path::Path;
45
46use serde::{Deserialize, Serialize};
47
48/// The pair-proof record path. Persistent by design — see the module docs.
49pub const PAIR_PROOF_PATH: &str = "/var/lib/ados/wfb-pair-proof.json";
50
51/// The record schema version, stamped on write and checked (warn-only) on read.
52pub const PAIR_PROOF_SIDECAR_VERSION: u16 = 1;
53
54/// File mode. World-readable so a diagnostic can show the latch state without
55/// root; only root writes it.
56pub const PAIR_PROOF_MODE: u32 = 0o644;
57
58/// How often `proven_at` may be re-stamped while the key stays proven. The
59/// latch only needs the boolean, so the timestamp is a courtesy for an operator
60/// reading the file — and this is a flash card, so it is refreshed at most
61/// hourly rather than on every tick.
62pub const PROOF_RESTAMP_INTERVAL_S: u64 = 3600;
63
64/// What a `mark_proven` call did, so the caller knows whether a write is owed
65/// and whether this was the first-ever proof for the key (worth an event).
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum ProofStamp {
68    /// The key had never been proven; it is now. Write, and announce it.
69    First,
70    /// Already proven, and the stamp was older than the re-stamp interval.
71    /// Write, but there is nothing new to announce.
72    Refreshed,
73    /// Already proven and re-stamped recently: nothing to do, no write.
74    Unchanged,
75}
76
77/// The per-key proof record.
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79pub struct PairProof {
80    /// Schema version (absent ⇒ `0` from an older writer).
81    #[serde(default)]
82    pub version: u16,
83    /// The bind role this record belongs to (`drone` / `gs`). A box re-profiled
84    /// to the other role gets a fresh record.
85    pub role: String,
86    /// The blake2b-8 fingerprint of the key's peer-public half. THE lifetime
87    /// key: a different fingerprint is a different key and resets everything.
88    pub key_fingerprint: String,
89    /// Unix seconds when this fingerprint was last confirmed to carry a link.
90    /// `None` ⇒ never proven ⇒ the latch may re-arm.
91    #[serde(default)]
92    pub proven_at: Option<u64>,
93    /// How many re-arm episodes this fingerprint has already consumed.
94    #[serde(default)]
95    pub rearm_episodes: u32,
96    /// Unix seconds of the most recent re-arm, so the cooldown is wall-clock and
97    /// survives a restart.
98    #[serde(default)]
99    pub last_rearm_at: Option<u64>,
100    /// An operator asked for one re-arm regardless of the latch. Self-clearing:
101    /// consumed by the next arm.
102    #[serde(default)]
103    pub force_rearm: bool,
104}
105
106impl PairProof {
107    /// A never-proven record for `role` + `fingerprint`.
108    pub fn fresh(role: &str, fingerprint: &str) -> Self {
109        Self {
110            version: PAIR_PROOF_SIDECAR_VERSION,
111            role: role.to_string(),
112            key_fingerprint: fingerprint.to_string(),
113            proven_at: None,
114            rearm_episodes: 0,
115            last_rearm_at: None,
116            force_rearm: false,
117        }
118    }
119
120    /// True once this fingerprint has been confirmed to carry a link at least
121    /// once. The single fact the never-re-arm-a-healthy-link rule turns on.
122    pub fn is_proven(&self) -> bool {
123        self.proven_at.is_some()
124    }
125
126    /// Whether this record describes the given role + key.
127    pub fn matches(&self, role: &str, fingerprint: &str) -> bool {
128        self.role == role && self.key_fingerprint == fingerprint
129    }
130
131    /// Record that the key was observed carrying a link at `now_unix`. Returns
132    /// what changed so the caller can skip a needless write (flash) and can
133    /// announce only the first-ever proof.
134    pub fn mark_proven(&mut self, now_unix: u64) -> ProofStamp {
135        match self.proven_at {
136            None => {
137                self.proven_at = Some(now_unix);
138                ProofStamp::First
139            }
140            Some(at) if now_unix.saturating_sub(at) >= PROOF_RESTAMP_INTERVAL_S => {
141                self.proven_at = Some(now_unix);
142                ProofStamp::Refreshed
143            }
144            Some(_) => ProofStamp::Unchanged,
145        }
146    }
147
148    /// Consume one re-arm episode at `now_unix`: bump the budget, stamp the
149    /// wall-clock cooldown anchor, and clear any operator force (it is a
150    /// one-shot).
151    pub fn record_rearm(&mut self, now_unix: u64) {
152        self.rearm_episodes = self.rearm_episodes.saturating_add(1);
153        self.last_rearm_at = Some(now_unix);
154        self.force_rearm = false;
155    }
156
157    /// The on-disk body: pretty JSON plus a trailing newline, so an operator can
158    /// read the latch state with `cat`.
159    pub fn to_bytes(&self) -> Vec<u8> {
160        let mut body = serde_json::to_vec_pretty(self).unwrap_or_default();
161        body.push(b'\n');
162        body
163    }
164}
165
166/// The result of loading a record for a given role + key.
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct LoadedProof {
169    /// The record to act on: the stored one when it matches, else a fresh one.
170    pub proof: PairProof,
171    /// True only when a PARSED record was discarded because it described a
172    /// different role or key — i.e. the key genuinely changed under us, which is
173    /// worth announcing. An absent or unparseable file is not a discard.
174    pub reset: bool,
175}
176
177/// Read + parse the record at `path`. `None` when absent or unparseable — both
178/// of which the caller must treat as never-proven with a zero budget, failing
179/// toward recoverability rather than crashing the loop.
180pub fn read_pair_proof_from(path: &Path) -> Option<PairProof> {
181    let text = std::fs::read_to_string(path).ok()?;
182    let proof: PairProof = serde_json::from_str(&text).ok()?;
183    crate::sidecar::check_sidecar_version(
184        "wfb-pair-proof",
185        proof.version,
186        PAIR_PROOF_SIDECAR_VERSION,
187    );
188    Some(proof)
189}
190
191/// Load the record for `role` + `fingerprint`, resetting on any mismatch.
192///
193/// A stored record for a DIFFERENT fingerprint is not partially reusable: its
194/// proof, its episode count and its cooldown all describe a key that is no
195/// longer on disk. Reusing any of it would either hand a brand-new key a spent
196/// budget or, worse, hand it someone else's proof.
197pub fn load_for(path: &Path, role: &str, fingerprint: &str) -> LoadedProof {
198    match read_pair_proof_from(path) {
199        Some(stored) if stored.matches(role, fingerprint) => LoadedProof {
200            proof: stored,
201            reset: false,
202        },
203        Some(_) => LoadedProof {
204            proof: PairProof::fresh(role, fingerprint),
205            reset: true,
206        },
207        None => LoadedProof {
208            proof: PairProof::fresh(role, fingerprint),
209            reset: false,
210        },
211    }
212}
213
214/// Atomically write `proof` to `path` through
215/// [`crate::sidecar::write_atomic`]: unique temp sibling, explicit mode, fsync,
216/// rename, directory fsync. The fsync is the point — this record is the only
217/// thing bounding a bind storm across a reboot, so it must survive a power cut
218/// mid-write.
219pub fn write_pair_proof_to(path: &Path, proof: &PairProof) -> std::io::Result<()> {
220    crate::sidecar::write_atomic(path, &proof.to_bytes(), PAIR_PROOF_MODE)
221}
222
223/// The operator's "retry the local link" request, consumed by the supervisor's
224/// auto-pair loop.
225///
226/// Once the loop has spent its local-bind attempts it parks on the cloud relay
227/// and stops trying. Re-arming the flag in the config does not reach that
228/// in-memory verdict, so the auto-pair route drops this file and the loop takes
229/// it on its next tick: it clears the park and the attempt count and binds
230/// again. `/run`, deliberately: a supervisor restart resets the park anyway, so
231/// a request must not outlive the boot it was made in.
232pub const AUTO_PAIR_RETRY_PATH: &str = "/run/ados/auto-pair-retry.request";
233
234/// Record a local-retry request at `path` (the content is informational; the
235/// file's presence is the request).
236pub fn request_local_retry_at(path: &Path) -> std::io::Result<()> {
237    if let Some(parent) = path.parent() {
238        std::fs::create_dir_all(parent)?;
239    }
240    std::fs::write(path, b"retry-local\n")
241}
242
243/// Consume a pending local-retry request at `path`. `true` exactly once per
244/// request: the removal is the acknowledgement, so two ticks cannot both act on
245/// one request. An unreadable directory reads as no request.
246pub fn take_local_retry_request_at(path: &Path) -> bool {
247    std::fs::remove_file(path).is_ok()
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn sidecar_version_matches_the_registry() {
256        assert_eq!(
257            PAIR_PROOF_SIDECAR_VERSION,
258            crate::contracts::sidecar_version("wfb-pair-proof").unwrap()
259        );
260    }
261
262    #[test]
263    fn the_record_lives_on_persistent_storage_not_tmpfs() {
264        // On tmpfs a reboot would erase the episode budget and the cooldown, so a
265        // rig in a boot loop would reintroduce the bind storm the budget bounds.
266        assert!(
267            PAIR_PROOF_PATH.starts_with("/var/lib/"),
268            "the proof record must survive a reboot, got {PAIR_PROOF_PATH}"
269        );
270    }
271
272    #[test]
273    fn a_fresh_record_is_never_proven_with_a_zero_budget() {
274        let p = PairProof::fresh("drone", "aabbccdd11223344");
275        assert!(!p.is_proven());
276        assert_eq!(p.rearm_episodes, 0);
277        assert_eq!(p.last_rearm_at, None);
278        assert!(!p.force_rearm);
279    }
280
281    #[test]
282    fn first_proof_stamps_and_later_proofs_are_rate_limited() {
283        let mut p = PairProof::fresh("drone", "ff00");
284        assert_eq!(p.mark_proven(1_000), ProofStamp::First);
285        assert!(p.is_proven());
286        // Inside the re-stamp interval: no write owed (this is a flash card).
287        assert_eq!(p.mark_proven(1_001), ProofStamp::Unchanged);
288        assert_eq!(
289            p.mark_proven(1_000 + PROOF_RESTAMP_INTERVAL_S - 1),
290            ProofStamp::Unchanged
291        );
292        // Past it: refreshed, but not announced again.
293        assert_eq!(
294            p.mark_proven(1_000 + PROOF_RESTAMP_INTERVAL_S),
295            ProofStamp::Refreshed
296        );
297        assert_eq!(p.proven_at, Some(1_000 + PROOF_RESTAMP_INTERVAL_S));
298    }
299
300    #[test]
301    fn recording_a_rearm_spends_budget_stamps_the_clock_and_clears_force() {
302        let mut p = PairProof::fresh("gs", "1234");
303        p.force_rearm = true;
304        p.record_rearm(500);
305        assert_eq!(p.rearm_episodes, 1);
306        assert_eq!(p.last_rearm_at, Some(500));
307        assert!(!p.force_rearm, "force is a one-shot");
308        p.record_rearm(900);
309        assert_eq!(p.rearm_episodes, 2);
310        assert_eq!(p.last_rearm_at, Some(900));
311    }
312
313    #[test]
314    fn a_record_round_trips_through_disk() {
315        let dir = tempfile::tempdir().unwrap();
316        let path = dir.path().join("wfb-pair-proof.json");
317        let mut p = PairProof::fresh("drone", "abc123");
318        p.mark_proven(42);
319        p.record_rearm(7);
320        write_pair_proof_to(&path, &p).unwrap();
321        let stray = std::fs::read_dir(dir.path())
322            .unwrap()
323            .filter_map(|e| e.ok())
324            .any(|e| e.file_name().to_string_lossy().contains(".tmp"));
325        assert!(!stray);
326        assert_eq!(read_pair_proof_from(&path).unwrap(), p);
327
328        use std::os::unix::fs::PermissionsExt;
329        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
330        assert_eq!(mode, PAIR_PROOF_MODE);
331    }
332
333    #[test]
334    fn a_matching_record_loads_intact() {
335        let dir = tempfile::tempdir().unwrap();
336        let path = dir.path().join("proof.json");
337        let mut p = PairProof::fresh("drone", "same");
338        p.mark_proven(10);
339        p.record_rearm(11);
340        write_pair_proof_to(&path, &p).unwrap();
341
342        let loaded = load_for(&path, "drone", "same");
343        assert!(!loaded.reset);
344        assert!(loaded.proof.is_proven());
345        assert_eq!(loaded.proof.rearm_episodes, 1);
346    }
347
348    #[test]
349    fn a_new_fingerprint_resets_the_whole_record() {
350        // A successful re-bind writes a new key; that key must start with a clean
351        // lifetime, never inheriting the old key's proof or its spent budget.
352        let dir = tempfile::tempdir().unwrap();
353        let path = dir.path().join("proof.json");
354        let mut old = PairProof::fresh("drone", "old-key");
355        old.mark_proven(10);
356        old.record_rearm(11);
357        old.record_rearm(12);
358        write_pair_proof_to(&path, &old).unwrap();
359
360        let loaded = load_for(&path, "drone", "new-key");
361        assert!(loaded.reset, "a discarded record is worth announcing");
362        assert!(!loaded.proof.is_proven());
363        assert_eq!(loaded.proof.rearm_episodes, 0);
364        assert_eq!(loaded.proof.last_rearm_at, None);
365        assert_eq!(loaded.proof.key_fingerprint, "new-key");
366    }
367
368    #[test]
369    fn a_role_change_resets_the_record() {
370        let dir = tempfile::tempdir().unwrap();
371        let path = dir.path().join("proof.json");
372        let mut old = PairProof::fresh("drone", "k");
373        old.mark_proven(10);
374        write_pair_proof_to(&path, &old).unwrap();
375        let loaded = load_for(&path, "gs", "k");
376        assert!(loaded.reset);
377        assert!(!loaded.proof.is_proven());
378    }
379
380    #[test]
381    fn an_absent_or_unparseable_record_reads_as_never_proven_without_announcing() {
382        let dir = tempfile::tempdir().unwrap();
383        let missing = dir.path().join("nope.json");
384        let loaded = load_for(&missing, "drone", "k");
385        assert!(!loaded.proof.is_proven());
386        assert_eq!(loaded.proof.rearm_episodes, 0);
387        assert!(!loaded.reset, "nothing known was discarded");
388
389        let junk = dir.path().join("junk.json");
390        std::fs::write(&junk, b"{ not json at all").unwrap();
391        assert!(read_pair_proof_from(&junk).is_none());
392        let loaded = load_for(&junk, "drone", "k");
393        assert!(!loaded.proof.is_proven());
394        assert_eq!(loaded.proof.rearm_episodes, 0);
395        assert!(!loaded.reset);
396        // And it can be rewritten over rather than crashing the caller.
397        write_pair_proof_to(&junk, &loaded.proof).unwrap();
398        assert!(read_pair_proof_from(&junk).is_some());
399    }
400
401    #[test]
402    fn a_local_retry_request_is_consumed_exactly_once() {
403        let dir = tempfile::tempdir().unwrap();
404        let path = dir.path().join("run").join("auto-pair-retry.request");
405        assert!(!take_local_retry_request_at(&path));
406        request_local_retry_at(&path).unwrap();
407        // A second request before the loop ticks is still one request.
408        request_local_retry_at(&path).unwrap();
409        assert!(take_local_retry_request_at(&path));
410        assert!(!take_local_retry_request_at(&path));
411    }
412}