1use std::sync::{Arc, OnceLock};
30use std::time::Duration;
31
32use agentmux_common::jekt_sign::WanKeyRecord;
33
34use crate::backend::storage::store::Store;
35use crate::backend::storage::wan_identity::{WanAgentKey, WanIdentityStore, WanInstance};
36
37const RETRY_SECS: u64 = 5 * 60;
39
40const NOT_SUPPORTED_RETRY_SECS: u64 = 60 * 60;
42
43const IDLE_RECHECK_SECS: u64 = 5 * 60;
48
49const PUBLISH_TIMEOUT_SECS: u64 = 10;
50
51pub(crate) fn directory_id(base_url: &str, account_sub: &str) -> String {
57 format!("{}#{}", base_url.trim_end_matches('/'), account_sub)
58}
59
60static NUDGE: OnceLock<Arc<tokio::sync::Notify>> = OnceLock::new();
61
62fn nudge_handle() -> Arc<tokio::sync::Notify> {
63 NUDGE.get_or_init(|| Arc::new(tokio::sync::Notify::new())).clone()
64}
65
66pub(crate) fn nudge() {
70 nudge_handle().notify_one();
71}
72
73pub(crate) fn certify(
78 instance: &WanInstance,
79 agent_id: &str,
80 channel: &str,
81 key: &WanAgentKey,
82) -> Option<WanKeyRecord> {
83 WanKeyRecord::certify(
84 &instance.private_key,
85 agent_id,
86 channel,
87 &key.public_key_bytes()?,
88 &instance.host_hint,
89 key.created_at,
90 )
91}
92
93#[derive(Debug, PartialEq, Eq)]
94pub(crate) enum PublishOutcome {
95 Published,
96 NotSupported,
98 Failed(String),
99}
100
101pub(crate) async fn publish_record(
104 base_url: &str,
105 http: &reqwest::Client,
106 token: &str,
107 record: &WanKeyRecord,
108) -> PublishOutcome {
109 let url = format!("{}/agents/{}/wan-key", base_url.trim_end_matches('/'), record.agent_id);
110 let resp = http
111 .put(&url)
112 .header("Authorization", format!("Bearer {token}"))
113 .timeout(Duration::from_secs(PUBLISH_TIMEOUT_SECS))
114 .json(record)
115 .send()
116 .await;
117 match resp {
118 Ok(r) if r.status().is_success() => PublishOutcome::Published,
119 Ok(r) if r.status() == reqwest::StatusCode::NOT_FOUND => PublishOutcome::NotSupported,
120 Ok(r) => {
121 let status = r.status();
122 PublishOutcome::Failed(format!("HTTP {status}: {}", r.text().await.unwrap_or_default()))
123 }
124 Err(e) => PublishOutcome::Failed(format!("unreachable: {e}")),
125 }
126}
127
128#[derive(Debug, Default, PartialEq, Eq)]
130pub(crate) struct PassResult {
131 pub published: usize,
132 pub not_supported: bool,
133 pub failed: usize,
134 pub remaining: usize,
136}
137
138pub(crate) async fn publish_pending(
141 wan: &WanIdentityStore,
142 instance: &WanInstance,
143 channel: &str,
144 base_url: &str,
145 http: &reqwest::Client,
146 token: &str,
147 directory: &str,
148) -> PassResult {
149 let pending = match wan.agent_keys_unpublished(directory) {
150 Ok(p) => p,
151 Err(e) => {
152 tracing::warn!(error = %e, "wan publish: could not read wan.db");
153 return PassResult { failed: 1, ..Default::default() };
154 }
155 };
156 let mut result = PassResult::default();
157 for (agent_id, key) in &pending {
158 let Some(record) = certify(instance, agent_id, channel, key) else {
159 tracing::warn!(agent = %agent_id, "wan publish: malformed key in wan.db — skipped");
160 result.failed += 1;
161 continue;
162 };
163 match publish_record(base_url, http, token, &record).await {
164 PublishOutcome::Published => match wan.agent_key_mark_published(agent_id, &key.public_key, directory) {
168 Ok(true) => result.published += 1,
169 Ok(false) => {}
172 Err(e) => {
173 tracing::warn!(agent = %agent_id, error = %e, "wan publish: could not record publication");
174 result.failed += 1;
175 }
176 },
177 PublishOutcome::NotSupported => {
178 result.not_supported = true;
179 break;
180 }
181 PublishOutcome::Failed(e) => {
182 tracing::warn!(agent = %agent_id, error = %e, "wan publish: directory rejected or unreachable");
183 result.failed += 1;
184 }
185 }
186 }
187 result.remaining = wan.agent_keys_unpublished(directory).map(|p| p.len()).unwrap_or(pending.len());
188 result
189}
190
191pub(crate) fn spawn(mstore: Arc<Store>, id_store: Arc<Store>) {
197 let Some(wan) = mstore.wan_identity() else { return };
198 let notify = nudge_handle();
199 tokio::spawn(async move {
200 let http = reqwest::Client::new();
201 loop {
202 let wait = run_pass(&wan, &id_store, &http).await;
203 match wait {
204 Wait::NotSupported => tokio::time::sleep(Duration::from_secs(NOT_SUPPORTED_RETRY_SECS)).await,
205 Wait::Retry => {
206 tokio::select! {
207 _ = notify.notified() => {}
208 _ = tokio::time::sleep(Duration::from_secs(RETRY_SECS)) => {}
209 }
210 }
211 Wait::Idle => {
212 tokio::select! {
213 _ = notify.notified() => {}
214 _ = tokio::time::sleep(Duration::from_secs(IDLE_RECHECK_SECS)) => {}
215 }
216 }
217 }
218 }
219 });
220}
221
222enum Wait {
223 Idle,
226 Retry,
227 NotSupported,
228}
229
230async fn run_pass(wan: &Arc<WanIdentityStore>, id_store: &Arc<Store>, http: &reqwest::Client) -> Wait {
231 let instance = match wan.instance_ensure(&crate::backend::reactive::registry::local_host_label()) {
232 Ok(i) => i,
233 Err(e) => {
234 tracing::warn!(error = %e, "wan publish: no instance");
235 return Wait::Retry;
236 }
237 };
238 let Some(scheduler) = crate::broker::get_global() else { return Wait::Retry };
239 let Some(token) = crate::muxbus::cloud_subscriber::load_valid_token(id_store, &scheduler).await else {
240 tracing::debug!("wan publish: not logged in to muxbus — keys stay unpublished");
241 return Wait::Retry;
242 };
243 let account = crate::muxbus::pkce::token_sub(&token);
244 if account.is_empty() {
245 tracing::warn!("wan publish: the account token names no account — keys stay unpublished");
246 return Wait::Retry;
247 }
248 let base_url = crate::muxbus::relay::rest_base_url();
249 let directory = directory_id(&base_url, &account);
250 if wan.agent_keys_unpublished(&directory).map(|p| p.is_empty()).unwrap_or(false) {
251 return Wait::Idle;
252 }
253 let result = publish_pending(
254 wan,
255 &instance,
256 &crate::backend::reactive::registry::local_channel_id(),
257 &base_url,
258 http,
259 &token,
260 &directory,
261 )
262 .await;
263 if result.published > 0 {
264 tracing::info!(published = result.published, instance = %instance.instance_id, "wan publish: agent keys published");
265 }
266 if result.not_supported {
267 tracing::info!("wan publish: cloud has no WAN key directory yet — retrying hourly");
268 Wait::NotSupported
269 } else if result.remaining > 0 {
270 Wait::Retry
271 } else {
272 Wait::Idle
273 }
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279
280 type Seen = Vec<(String, Option<String>, serde_json::Value)>;
282
283 struct Stub {
284 seen: Arc<std::sync::Mutex<Seen>>,
285 _guard: tokio_util::sync::DropGuard,
286 url: String,
287 }
288
289 async fn stub_directory(status: axum::http::StatusCode) -> Stub {
291 let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
292 let sink = seen.clone();
293 let app = axum::Router::new().route(
294 "/agents/:agent_id/wan-key",
295 axum::routing::put(
296 move |axum::extract::Path(agent_id): axum::extract::Path<String>,
297 headers: axum::http::HeaderMap,
298 body: axum::Json<serde_json::Value>| {
299 let sink = sink.clone();
300 async move {
301 let auth = headers.get("authorization").and_then(|v| v.to_str().ok()).map(str::to_string);
302 sink.lock().unwrap().push((agent_id, auth, body.0));
303 (status, "{}")
304 }
305 },
306 ),
307 );
308 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
309 let addr = listener.local_addr().unwrap();
310 let token = tokio_util::sync::CancellationToken::new();
311 let child = token.clone();
312 tokio::spawn(async move {
313 let _ = axum::serve(listener, app).with_graceful_shutdown(async move { child.cancelled().await }).await;
314 });
315 Stub { seen, _guard: token.drop_guard(), url: format!("http://{addr}") }
316 }
317
318 const DIR: &str = "https://relay.test#acct";
319
320 fn published(wan: &WanIdentityStore, agent_id: &str, directory: &str) -> bool {
321 let key = wan.agent_key_load(agent_id).unwrap().unwrap();
322 wan.agent_key_is_published_to(agent_id, &key, directory).unwrap()
323 }
324
325 fn temp_wan() -> (tempfile::TempDir, WanIdentityStore, WanInstance) {
326 let dir = tempfile::tempdir().unwrap();
327 let wan = WanIdentityStore::open(&dir.path().join("wan.db")).unwrap();
328 let instance = wan.instance_ensure("narko").unwrap();
329 (dir, wan, instance)
330 }
331
332 #[test]
333 fn a_certified_record_passes_the_chain_and_is_stable_across_calls() {
334 let (_dir, wan, instance) = temp_wan();
335 let key = wan.agent_key_ensure("Camper", None).unwrap();
336 let record = certify(&instance, "camper", "stable", &key).unwrap();
337 assert!(record.check_chain().is_some());
338 assert_eq!(record.instance_id, instance.instance_id);
339 assert_eq!(record.issued_at, key.created_at);
340 assert_eq!(record.host_hint, "narko");
341 assert_eq!(certify(&instance, "camper", "stable", &key), Some(record), "same key → same record");
342 }
343
344 #[tokio::test]
345 async fn a_pass_publishes_every_pending_key_and_marks_it() {
346 let stub = stub_directory(axum::http::StatusCode::OK).await;
347 let (_dir, wan, instance) = temp_wan();
348 wan.agent_key_ensure("camper", None).unwrap();
349 wan.agent_key_ensure("lark", None).unwrap();
350
351 let result = publish_pending(&wan, &instance, "stable", &stub.url, &reqwest::Client::new(), "tok", DIR).await;
352 assert_eq!(result, PassResult { published: 2, remaining: 0, ..Default::default() });
353 assert!(published(&wan, "camper", DIR));
354
355 let seen = stub.seen.lock().unwrap().clone();
356 assert_eq!(seen.len(), 2);
357 for (agent_id, auth, body) in seen {
358 assert_eq!(auth.as_deref(), Some("Bearer tok"));
359 let record: WanKeyRecord = serde_json::from_value(body).unwrap();
360 assert_eq!(record.agent_id, agent_id, "the path names the record's agent");
361 assert!(record.check_chain().is_some(), "what goes on the wire passes the cloud's chain check");
362 assert_eq!(record.channel, "stable");
363 }
364
365 let again = publish_pending(&wan, &instance, "stable", &stub.url, &reqwest::Client::new(), "tok", DIR).await;
367 assert_eq!(again, PassResult::default());
368 assert_eq!(stub.seen.lock().unwrap().len(), 2);
369 }
370
371 #[tokio::test]
372 async fn a_404_means_no_directory_and_stops_the_pass() {
373 let stub = stub_directory(axum::http::StatusCode::NOT_FOUND).await;
374 let (_dir, wan, instance) = temp_wan();
375 wan.agent_key_ensure("camper", None).unwrap();
376 wan.agent_key_ensure("lark", None).unwrap();
377 let result = publish_pending(&wan, &instance, "stable", &stub.url, &reqwest::Client::new(), "tok", DIR).await;
378 assert!(result.not_supported);
379 assert_eq!(result.remaining, 2);
380 assert_eq!(stub.seen.lock().unwrap().len(), 1, "one 404 is enough to know");
381 }
382
383 #[tokio::test]
384 async fn a_rejection_leaves_the_key_unpublished_for_the_next_pass() {
385 let stub = stub_directory(axum::http::StatusCode::BAD_REQUEST).await;
386 let (_dir, wan, instance) = temp_wan();
387 wan.agent_key_ensure("camper", None).unwrap();
388 let result = publish_pending(&wan, &instance, "stable", &stub.url, &reqwest::Client::new(), "tok", DIR).await;
389 assert_eq!(result, PassResult { failed: 1, remaining: 1, ..Default::default() });
390 assert!(!published(&wan, "camper", DIR));
391 }
392
393 #[tokio::test]
394 async fn an_unreachable_directory_is_a_failure_not_a_hang() {
395 let url = {
396 let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
397 format!("http://{}", l.local_addr().unwrap())
398 };
399 let (_dir, wan, instance) = temp_wan();
400 wan.agent_key_ensure("camper", None).unwrap();
401 let result = publish_pending(&wan, &instance, "stable", &url, &reqwest::Client::new(), "tok", DIR).await;
402 assert_eq!(result.failed, 1);
403 assert_eq!(result.remaining, 1);
404 }
405
406 #[test]
407 fn a_publication_marks_only_the_key_it_was_for() {
408 let (_dir, wan, _instance) = temp_wan();
409 let old = wan.agent_key_ensure("camper", None).unwrap();
410 wan.agent_keys_delete(&["camper".to_string()]).unwrap();
412 let new = wan.agent_key_ensure("camper", None).unwrap();
413 assert!(!wan.agent_key_mark_published("camper", &old.public_key, DIR).unwrap());
414 assert!(!published(&wan, "camper", DIR));
415 assert!(wan.agent_key_mark_published("camper", &new.public_key, DIR).unwrap());
416 assert!(published(&wan, "camper", DIR));
417 }
418
419 #[tokio::test]
420 async fn a_sign_in_to_another_account_publishes_every_key_again() {
421 let stub = stub_directory(axum::http::StatusCode::OK).await;
426 let (_dir, wan, instance) = temp_wan();
427 wan.agent_key_ensure("camper", None).unwrap();
428 let old = directory_id(&stub.url, "old-account");
429 let new = directory_id(&stub.url, "new-account");
430 let first = publish_pending(&wan, &instance, "stable", &stub.url, &reqwest::Client::new(), "tok", &old).await;
431 assert_eq!(first.published, 1);
432
433 let second = publish_pending(&wan, &instance, "stable", &stub.url, &reqwest::Client::new(), "tok2", &new).await;
434 assert_eq!(second, PassResult { published: 1, remaining: 0, ..Default::default() });
435 assert!(published(&wan, "camper", &new));
436 assert!(published(&wan, "camper", &old), "the old directory's record stays true");
437 assert_eq!(stub.seen.lock().unwrap().len(), 2);
438 }
439
440 #[test]
441 fn a_directory_is_the_relay_plus_the_account() {
442 assert_eq!(directory_id("https://relay.example/", "sub-1"), "https://relay.example#sub-1");
443 assert_ne!(directory_id("https://relay.example", "sub-1"), directory_id("https://relay.example", "sub-2"));
444 assert_ne!(directory_id("https://a.example", "sub-1"), directory_id("https://b.example", "sub-1"));
445 }
446}