1use serde_json::{Value, json};
14
15pub const HEARTBEAT_KIND: u64 = 100;
17pub const HEARTBEAT_TYPE: &str = "heartbeat";
19
20pub fn probe_body(nonce: &str) -> Value {
22 json!({ "t": "probe", "nonce": nonce })
23}
24
25pub fn probe_ack_body(nonce: &str) -> Value {
27 json!({ "t": "probe_ack", "nonce": nonce })
28}
29
30pub fn probe_nonce(event: &Value) -> Option<String> {
35 if event.get("kind").and_then(Value::as_u64) != Some(HEARTBEAT_KIND) {
36 return None;
37 }
38 let body = event.get("body")?;
39 if body.get("t").and_then(Value::as_str) != Some("probe") {
40 return None;
41 }
42 body.get("nonce")
43 .and_then(Value::as_str)
44 .map(str::to_string)
45}
46
47pub fn is_probe_ack_for(event: &Value, nonce: &str) -> bool {
50 event.get("kind").and_then(Value::as_u64) == Some(HEARTBEAT_KIND)
51 && event
52 .get("body")
53 .and_then(|b| b.get("t"))
54 .and_then(Value::as_str)
55 == Some("probe_ack")
56 && event
57 .get("body")
58 .and_then(|b| b.get("nonce"))
59 .and_then(Value::as_str)
60 == Some(nonce)
61}
62
63pub fn record_ack_within_rate(times: &mut Vec<u64>, now: u64, window: u64, max: usize) -> bool {
69 times.retain(|t| now.saturating_sub(*t) < window);
70 if times.len() >= max {
71 return false;
72 }
73 times.push(now);
74 true
75}
76
77use std::collections::HashMap;
81use std::sync::{Mutex, OnceLock};
82
83static ACK_RATE: OnceLock<Mutex<HashMap<String, Vec<u64>>>> = OnceLock::new();
86const ACK_MAX_PER_WINDOW: usize = 10;
87const ACK_WINDOW_SECS: u64 = 10;
88
89fn unix_secs() -> u64 {
90 std::time::SystemTime::now()
91 .duration_since(std::time::UNIX_EPOCH)
92 .map(|d| d.as_secs())
93 .unwrap_or(0)
94}
95
96fn build_signed_heartbeat(peer: &str, body: Value) -> anyhow::Result<Value> {
99 use crate::config;
100 let sk_seed = config::read_private_key()?;
101 let card = config::read_agent_card()?;
102 let did = card
103 .get("did")
104 .and_then(Value::as_str)
105 .unwrap_or("")
106 .to_string();
107 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
108 let pk_b64 = card
109 .get("verify_keys")
110 .and_then(Value::as_object)
111 .and_then(|m| m.values().next())
112 .and_then(|v| v.get("key"))
113 .and_then(Value::as_str)
114 .ok_or_else(|| anyhow::anyhow!("agent-card missing verify_keys[*].key"))?;
115 let pk_bytes = crate::signing::b64decode(pk_b64)?;
116 let trust = config::read_trust().unwrap_or_else(|_| json!({"agents": {}}));
117 let to_did = crate::trust::resolve_peer_did(&trust, peer);
118 let now = time::OffsetDateTime::now_utc()
119 .format(&time::format_description::well_known::Rfc3339)
120 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
121 let event = json!({
122 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
123 "timestamp": now,
124 "from": did,
125 "to": to_did,
126 "type": HEARTBEAT_TYPE,
127 "kind": HEARTBEAT_KIND,
128 "body": body,
129 });
130 Ok(crate::signing::sign_message_v31(
131 &event, &sk_seed, &pk_bytes, &handle,
132 )?)
133}
134
135pub fn send_probe(peer: &str, nonce: &str) -> anyhow::Result<()> {
138 let signed = build_signed_heartbeat(peer, probe_body(nonce))?;
139 crate::send::attempt_deliver(peer, &signed)?;
140 Ok(())
141}
142
143pub fn respond_to_probes(probes: &[(String, String)]) {
148 if probes.is_empty() {
149 return;
150 }
151 let now = unix_secs();
152 let map = ACK_RATE.get_or_init(|| Mutex::new(HashMap::new()));
153 for (peer, nonce) in probes {
154 let allowed = {
155 let mut g = map.lock().unwrap_or_else(|e| e.into_inner());
156 let times = g.entry(peer.clone()).or_default();
157 record_ack_within_rate(times, now, ACK_WINDOW_SECS, ACK_MAX_PER_WINDOW)
158 };
159 if !allowed {
160 continue;
161 }
162 match build_signed_heartbeat(peer, probe_ack_body(nonce)) {
163 Ok(signed) => {
164 if let Err(e) = crate::send::attempt_deliver(peer, &signed) {
165 eprintln!("wire: probe_ack to {peer} failed (non-fatal): {e:#}");
166 }
167 }
168 Err(e) => eprintln!("wire: building probe_ack for {peer} failed (non-fatal): {e:#}"),
169 }
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 #[test]
178 fn probe_nonce_extracts_only_real_probes() {
179 let p = json!({"kind": 100, "type": "heartbeat", "body": {"t": "probe", "nonce": "abc"}});
180 assert_eq!(probe_nonce(&p).as_deref(), Some("abc"));
181 assert!(probe_nonce(&json!({"kind": 1, "body": {"t": "probe", "nonce": "x"}})).is_none());
183 assert!(
185 probe_nonce(&json!({"kind": 100, "body": {"t": "probe_ack", "nonce": "x"}})).is_none()
186 );
187 assert!(probe_nonce(&json!({"kind": 100, "body": {"t": "weird"}})).is_none());
189 assert!(probe_nonce(&json!({"kind": 100, "body": {"ct": "..."}})).is_none());
191 }
192
193 #[test]
194 fn is_probe_ack_matches_kind_t_and_nonce() {
195 let a = json!({"kind": 100, "body": {"t": "probe_ack", "nonce": "n1"}});
196 assert!(is_probe_ack_for(&a, "n1"));
197 assert!(!is_probe_ack_for(&a, "n2"), "nonce must match");
198 let p = json!({"kind": 100, "body": {"t": "probe", "nonce": "n1"}});
200 assert!(!is_probe_ack_for(&p, "n1"));
201 }
202
203 #[test]
204 fn ack_rate_gate_caps_a_flood() {
205 let mut times: Vec<u64> = Vec::new();
207 let now = 1_000u64;
208 let mut acked = 0;
209 for _ in 0..100 {
210 if record_ack_within_rate(&mut times, now, 10, 10) {
211 acked += 1;
212 }
213 }
214 assert_eq!(acked, 10, "a 100-probe flood must be capped at 10 acks");
215 assert!(record_ack_within_rate(&mut times, now + 11, 10, 10));
217 }
218}