1use std::sync::Mutex;
20use std::time::{Duration, Instant};
21
22use agentmux_common::jekt_sign::{self, WanCarried, WanCheckFailure, WanKeyRecord};
23
24use crate::backend::reactive::types::{WanInstanceInfo, WanInstanceStatus};
25use crate::backend::storage::wan_identity::WanIdentityStore;
26
27const FETCH_TIMEOUT: Duration = Duration::from_secs(2);
30
31const REVOCATION_REFRESH_SECS: i64 = 60 * 60;
34
35const FETCHES_PER_MINUTE: u32 = 60;
39
40#[derive(Debug, Clone, Default)]
42pub(crate) struct CarriedRow {
43 pub wan_sig: Option<String>,
44 pub wan_msg_id: Option<String>,
45 pub wan_ts_secs: Option<i64>,
46 pub wan_source_agent: Option<String>,
47 pub wan_target_agent: Option<String>,
48 pub wan_source_host: Option<String>,
49 pub wan_source_channel: Option<String>,
50 pub wan_key_fp: Option<String>,
51 pub sender_same_account: Option<bool>,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Default)]
56pub(crate) struct WanVerdict {
57 pub verified: Option<bool>,
58 pub instance: Option<WanInstanceInfo>,
59 pub reason: Option<&'static str>,
60 pub detail: Option<String>,
64}
65
66impl WanVerdict {
67 fn none(reason: &'static str) -> Self {
68 Self { verified: None, instance: None, reason: Some(reason), detail: None }
69 }
70
71 pub(crate) fn unchecked(reason: &'static str, detail: impl Into<String>) -> Self {
74 Self { verified: None, instance: None, reason: Some(reason), detail: Some(detail.into()) }
75 }
76
77 fn failed(reason: &'static str) -> Self {
78 Self { verified: Some(false), instance: None, reason: Some(reason), detail: None }
79 }
80
81 fn from_failure(f: WanCheckFailure) -> Self {
82 let reason = match f {
83 WanCheckFailure::EnvelopeMismatch => "wan_envelope_mismatch",
84 WanCheckFailure::Stale => "wan_sig_stale",
85 WanCheckFailure::CertInvalid => "wan_cert_invalid",
86 WanCheckFailure::RecordMismatch => "wan_record_mismatch",
87 WanCheckFailure::BadSignature => "wan_sig_invalid",
88 };
89 Self { verified: f.verdict(), instance: None, reason: Some(reason), detail: None }
90 }
91}
92
93pub(crate) struct Directory<'a> {
96 pub base_url: &'a str,
97 pub http: &'a reqwest::Client,
98 pub token: &'a str,
99 pub budget: &'a FetchBudget,
100}
101
102enum Fetched<T> {
103 Found(T),
104 NotFound,
105 Unavailable(String),
107}
108
109pub(crate) struct FetchBudget {
111 limit: u32,
112 window: Mutex<Option<(Instant, u32)>>,
113}
114
115impl FetchBudget {
116 pub(crate) const fn new(limit: u32) -> Self {
117 Self { limit, window: Mutex::new(None) }
118 }
119
120 fn take(&self) -> bool {
121 let mut window = self.window.lock().unwrap_or_else(|e| e.into_inner());
122 let now = Instant::now();
123 let (start, used) = window.get_or_insert((now, 0));
124 if now.duration_since(*start) >= Duration::from_secs(60) {
125 *start = now;
126 *used = 0;
127 }
128 if *used >= self.limit {
129 return false;
130 }
131 *used += 1;
132 true
133 }
134}
135
136pub(crate) static GLOBAL_BUDGET: FetchBudget = FetchBudget::new(FETCHES_PER_MINUTE);
138
139async fn get_json<T: serde::de::DeserializeOwned>(dir: &Directory<'_>, url: &str) -> Fetched<T> {
142 let mut cause = String::new();
143 for _attempt in 0..2 {
144 if !dir.budget.take() {
145 return Fetched::Unavailable("directory fetch budget exhausted".to_string());
146 }
147 let resp = dir
148 .http
149 .get(url)
150 .header("Authorization", format!("Bearer {}", dir.token))
151 .timeout(FETCH_TIMEOUT)
152 .send()
153 .await;
154 match resp {
155 Ok(r) if r.status() == reqwest::StatusCode::NOT_FOUND => return Fetched::NotFound,
156 Ok(r) if r.status().is_success() => {
157 return match r.json::<T>().await {
158 Ok(v) => Fetched::Found(v),
159 Err(e) => Fetched::Unavailable(format!("directory record doesn't parse: {e}")),
162 }
163 }
164 Ok(r) => cause = format!("directory answered {}", r.status()),
171 Err(e) => cause = format!("directory unreachable: {}", e.without_url()),
173 }
174 }
175 Fetched::Unavailable(cause)
176}
177
178async fn fetch_record(dir: &Directory<'_>, carried: &WanCarried<'_>) -> Fetched<WanKeyRecord> {
179 let mut url = match reqwest::Url::parse(dir.base_url) {
180 Ok(u) => u,
181 Err(e) => return Fetched::Unavailable(format!("bad directory URL: {e}")),
182 };
183 match url.path_segments_mut() {
184 Ok(mut segs) => {
185 segs.pop_if_empty().push("agents").push(&carried.source_agent.to_lowercase()).push("wan-key");
186 }
187 Err(_) => return Fetched::Unavailable("bad directory URL: cannot be a base".to_string()),
188 }
189 url.query_pairs_mut()
190 .append_pair("instance", &carried.source_host.to_lowercase())
191 .append_pair("channel", carried.source_channel)
192 .append_pair("fp", carried.key_fp);
193 get_json(dir, url.as_str()).await
194}
195
196#[derive(serde::Deserialize)]
197struct InstanceStatusResp {
198 revoked: bool,
199}
200
201async fn fetch_revoked(dir: &Directory<'_>, instance_id: &str) -> Option<bool> {
202 let url = format!("{}/wan-instances/{}", dir.base_url.trim_end_matches('/'), instance_id);
203 match get_json::<InstanceStatusResp>(dir, &url).await {
204 Fetched::Found(s) => Some(s.revoked),
205 Fetched::NotFound => Some(false),
207 Fetched::Unavailable(cause) => {
209 tracing::warn!(instance_id, cause = %cause, "wan verify: revocation check couldn't reach the directory");
210 None
211 }
212 }
213}
214
215async fn instance_status(
220 wan: &WanIdentityStore,
221 own_instance_id: &str,
222 record: &WanKeyRecord,
223 dir: &Directory<'_>,
224 now: i64,
225) -> WanInstanceStatus {
226 let hint = if jekt_sign::is_valid_wan_host_hint(&record.host_hint) { record.host_hint.as_str() } else { "?" };
227 let Ok(mut known) = wan.known_instance_observe(&record.instance_id, hint, now) else {
228 return WanInstanceStatus::New;
229 };
230 let due = known.revocation_checked_at.is_none_or(|t| now.saturating_sub(t) >= REVOCATION_REFRESH_SECS);
231 if known.revoked_at.is_none() && due {
232 if let Some(revoked) = fetch_revoked(dir, &record.instance_id).await {
233 let _ = wan.known_instance_record_revocation_check(&record.instance_id, revoked, now);
234 if revoked {
235 known.revoked_at = Some(now);
236 }
237 }
238 }
239 if known.revoked_at.is_some() {
240 WanInstanceStatus::Revoked
241 } else if record.instance_id == own_instance_id {
242 WanInstanceStatus::Approved
243 } else {
244 WanInstanceStatus::New
245 }
246}
247
248#[allow(clippy::too_many_arguments)]
250pub(crate) async fn verify(
251 wan: &WanIdentityStore,
252 own_instance_id: &str,
253 polled_agent_id: &str,
254 row_source_agent: &str,
255 row: &CarriedRow,
256 message: &str,
257 now: i64,
258 dir: &Directory<'_>,
259) -> WanVerdict {
260 let (Some(sig), Some(key_fp)) = (row.wan_sig.as_deref(), row.wan_key_fp.as_deref()) else {
262 return WanVerdict::default();
263 };
264 if row.sender_same_account != Some(true) {
266 return WanVerdict::none("wan_not_same_account");
267 }
268 let (Some(msg_id), Some(ts_secs), Some(source_agent), Some(target_agent), Some(source_host), Some(source_channel)) = (
269 row.wan_msg_id.as_deref(),
270 row.wan_ts_secs,
271 row.wan_source_agent.as_deref(),
272 row.wan_target_agent.as_deref(),
273 row.wan_source_host.as_deref(),
274 row.wan_source_channel.as_deref(),
275 ) else {
276 return WanVerdict::none("wan_fields_incomplete");
279 };
280 let carried = WanCarried {
281 sig,
282 msg_id,
283 ts_secs,
284 source_agent,
285 target_agent,
286 source_host,
287 source_channel,
288 key_fp,
289 };
290 if let Err(f) = jekt_sign::check_wan_envelope(&carried, row_source_agent, polled_agent_id, now) {
291 return WanVerdict::from_failure(f);
292 }
293
294 let record = match wan.peer_record_get(source_host, source_agent, source_channel, key_fp) {
295 Ok(Some(cached)) => cached,
296 _ => match fetch_record(dir, &carried).await {
297 Fetched::Found(record) => record,
298 Fetched::NotFound => return WanVerdict::none("wan_key_not_found"),
299 Fetched::Unavailable(cause) => return WanVerdict::unchecked("wan_key_unavailable", cause),
300 },
301 };
302 if let Err(f) = jekt_sign::verify_wan_against_record(&carried, &record, message) {
303 return WanVerdict::from_failure(f);
304 }
305 let _ = wan.peer_record_put(&record);
307
308 if wan.seen_sig_contains(&record.instance_id, source_agent, msg_id).unwrap_or(false) {
309 return WanVerdict::failed("wan_sig_replay");
310 }
311
312 let status = instance_status(wan, own_instance_id, &record, dir, now).await;
313 WanVerdict {
314 verified: Some(true),
315 instance: Some(WanInstanceInfo {
316 id: record.instance_id.clone(),
317 label: jekt_sign::wan_instance_display_label(&record.host_hint, &record.instance_id),
318 status,
319 }),
320 reason: None,
321 detail: None,
322 }
323}
324
325pub(crate) fn record_delivered(wan: &WanIdentityStore, verdict: &WanVerdict, row: &CarriedRow, now: i64) {
329 let (Some(instance), Some(agent), Some(msg_id), Some(ts)) = (
330 verdict.instance.as_ref(),
331 row.wan_source_agent.as_deref(),
332 row.wan_msg_id.as_deref(),
333 row.wan_ts_secs,
334 ) else {
335 return;
336 };
337 if verdict.verified != Some(true) {
338 return;
339 }
340 if let Err(e) = wan.seen_sig_record(&instance.id, agent, msg_id, ts.saturating_add(jekt_sign::WAN_SIG_MAX_AGE_SECS), now) {
341 tracing::warn!(error = %e, "wan verify: could not record a delivered signature — a replay of it would verify");
342 }
343}
344
345#[cfg(test)]
346mod tests;