1use anyhow::{Context, Result};
33use axum::{
34 Json, Router,
35 extract::{Path, Query, State},
36 http::{HeaderMap, StatusCode, header::AUTHORIZATION},
37 response::IntoResponse,
38 routing::{delete, get, post},
39};
40use rand::RngCore;
41use serde::Deserialize;
42use serde_json::{Value, json};
43use std::collections::HashMap;
44use std::path::PathBuf;
45use std::sync::Arc;
46use std::sync::atomic::{AtomicU64, Ordering};
47use std::time::{SystemTime, UNIX_EPOCH};
48use tokio::sync::Mutex;
49use tower_governor::{
50 GovernorLayer, governor::GovernorConfigBuilder, key_extractor::GlobalKeyExtractor,
51};
52
53const MAX_EVENT_BYTES: usize = 256 * 1024;
54const MAX_SLOT_BYTES: usize = 64 * 1024 * 1024;
59
60const INTRO_MAX_PER_WINDOW: usize = 5;
65const INTRO_WINDOW_SECS: u64 = 300;
66
67const MAX_SLOTS: usize = 200_000;
72const MAX_HANDLES: usize = 100_000;
73const MAX_PAIR_SLOTS: usize = 50_000;
74const MAX_INVITES: usize = 50_000;
75const MAX_STREAMS_PER_SLOT: usize = 256;
80const MAX_INTRO_TRACKING_NICKS: usize = 10_000;
84
85fn at_capacity(len: usize, max: usize) -> bool {
88 len >= max
89}
90
91fn retain_live_subscribers(subs: &mut Vec<tokio::sync::mpsc::UnboundedSender<Value>>) {
97 subs.retain(|tx| !tx.is_closed());
98}
99
100fn unix_now() -> u64 {
102 SystemTime::now()
103 .duration_since(UNIX_EPOCH)
104 .map(|d| d.as_secs())
105 .unwrap_or(0)
106}
107
108fn record_intro_within_rate(times: &mut Vec<u64>, now: u64, window: u64, max: usize) -> bool {
112 times.retain(|t| now.saturating_sub(*t) < window);
113 if times.len() >= max {
114 return false;
115 }
116 times.push(now);
117 true
118}
119
120fn evict_stale_intro_nicks(times_by_nick: &mut HashMap<String, Vec<u64>>, now: u64, window: u64) {
124 times_by_nick.retain(|_, ts| ts.iter().any(|t| now.saturating_sub(*t) < window));
125}
126
127#[derive(Clone)]
128pub struct Relay {
129 inner: Arc<Mutex<Inner>>,
130 state_dir: PathBuf,
131 counters: Arc<RelayCounters>,
132}
133
134struct RelayCounters {
139 boot_unix: u64,
140 handle_claims_total: AtomicU64,
141 handle_first_claims_total: AtomicU64,
142 slot_allocations_total: AtomicU64,
143 pair_opens_total: AtomicU64,
144 events_posted_total: AtomicU64,
145}
146
147#[derive(serde::Serialize, serde::Deserialize, Default)]
148struct CountersSnapshot {
149 handle_claims_total: u64,
150 handle_first_claims_total: u64,
151 slot_allocations_total: u64,
152 pair_opens_total: u64,
153 events_posted_total: u64,
154}
155
156#[derive(serde::Serialize, serde::Deserialize)]
161struct HistoryEntry {
162 ts: u64,
163 handles_active: usize,
164 slots_active: usize,
165 pair_slots_open: usize,
166 streams_active: usize,
167 handle_claims_total: u64,
168 handle_first_claims_total: u64,
169 slot_allocations_total: u64,
170 pair_opens_total: u64,
171 events_posted_total: u64,
172}
173
174#[derive(Deserialize)]
175pub struct StatsHistoryQuery {
176 pub hours: Option<u64>,
178}
179
180struct Inner {
181 slots: HashMap<String, Vec<Value>>,
183 tokens: HashMap<String, String>,
185 slot_bytes: HashMap<String, usize>,
187 last_pull_at_unix: HashMap<String, u64>,
192 streams: HashMap<String, Vec<tokio::sync::mpsc::UnboundedSender<Value>>>,
198 pair_lookup: HashMap<String, String>,
200 pair_slots: HashMap<String, PairSlot>,
202 handles: HashMap<String, HandleRecord>,
204 responder_health: HashMap<String, ResponderHealthRecord>,
206 invites: HashMap<String, InviteRecord>,
210 intro_times: HashMap<String, Vec<u64>>,
215}
216
217#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
220struct InviteRecord {
221 token: String,
222 invite_url: String,
223 expires_unix: u64,
224 uses_remaining: Option<u32>,
226 created_unix: u64,
227}
228
229#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
233struct HandleRecord {
234 pub nick: String,
235 pub did: String,
236 pub card: Value,
237 pub slot_id: String,
238 pub relay_url: Option<String>,
239 pub claimed_at: String,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub discoverable: Option<bool>,
247}
248
249impl HandleRecord {
250 fn is_discoverable(&self) -> bool {
251 self.discoverable.unwrap_or(true)
252 }
253}
254
255#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
256pub struct ResponderHealthRecord {
257 pub status: String,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub reason: Option<String>,
260 #[serde(default, skip_serializing_if = "Option::is_none")]
261 pub last_success_at: Option<String>,
262 pub set_at: String,
263}
264
265#[derive(Clone, Debug)]
266struct PairSlot {
267 host_msg: Option<String>,
269 guest_msg: Option<String>,
271 host_bootstrap: Option<String>,
273 guest_bootstrap: Option<String>,
275 last_touched: std::time::Instant,
277}
278
279impl Default for PairSlot {
280 fn default() -> Self {
281 Self {
282 host_msg: None,
283 guest_msg: None,
284 host_bootstrap: None,
285 guest_bootstrap: None,
286 last_touched: std::time::Instant::now(),
287 }
288 }
289}
290
291const PAIR_SLOT_TTL_SECS: u64 = 300;
294
295#[derive(Deserialize)]
296pub struct AllocateRequest {
297 #[serde(default)]
299 pub handle: Option<String>,
300}
301
302#[derive(Deserialize)]
303pub struct PostEventRequest {
304 pub event: Value,
305}
306
307#[derive(Deserialize)]
308pub struct ListEventsQuery {
309 pub since: Option<String>,
311 pub limit: Option<usize>,
313}
314
315impl Relay {
316 pub async fn new(state_dir: PathBuf) -> Result<Self> {
317 tokio::fs::create_dir_all(state_dir.join("slots")).await?;
318 tokio::fs::create_dir_all(state_dir.join("handles")).await?;
319 tokio::fs::create_dir_all(state_dir.join("responder-health")).await?;
320 let mut inner = Inner {
321 slots: HashMap::new(),
322 tokens: HashMap::new(),
323 slot_bytes: HashMap::new(),
324 last_pull_at_unix: HashMap::new(),
325 streams: HashMap::new(),
326 pair_lookup: HashMap::new(),
327 pair_slots: HashMap::new(),
328 handles: HashMap::new(),
329 responder_health: HashMap::new(),
330 invites: HashMap::new(),
331 intro_times: HashMap::new(),
332 };
333 let token_path = state_dir.join("tokens.json");
335 if token_path.exists() {
336 let body = tokio::fs::read_to_string(&token_path).await?;
337 inner.tokens = serde_json::from_str(&body).unwrap_or_default();
338 }
339 let mut slots_dir = tokio::fs::read_dir(state_dir.join("slots")).await?;
341 while let Some(entry) = slots_dir.next_entry().await? {
342 let path = entry.path();
343 if path.extension().map(|x| x != "jsonl").unwrap_or(true) {
344 continue;
345 }
346 let stem = match path.file_stem().and_then(|s| s.to_str()) {
347 Some(s) => s.to_string(),
348 None => continue,
349 };
350 let body = tokio::fs::read_to_string(&path).await.unwrap_or_default();
351 let mut events = Vec::new();
352 for line in body.lines() {
353 if let Ok(v) = serde_json::from_str::<Value>(line) {
354 events.push(v);
355 }
356 }
357 let bytes: usize = events
359 .iter()
360 .map(|e| serde_json::to_vec(e).map(|v| v.len()).unwrap_or(0))
361 .sum();
362 inner.slot_bytes.insert(stem.clone(), bytes);
363 inner.slots.insert(stem, events);
364 }
365 let handles_dir = state_dir.join("handles");
367 if handles_dir.exists() {
368 let mut rd = tokio::fs::read_dir(&handles_dir).await?;
369 while let Some(entry) = rd.next_entry().await? {
370 let path = entry.path();
371 if path.extension().and_then(|x| x.to_str()) != Some("json") {
372 continue;
373 }
374 let body = tokio::fs::read_to_string(&path).await.unwrap_or_default();
375 if let Ok(rec) = serde_json::from_str::<HandleRecord>(&body) {
376 inner.handles.insert(rec.nick.clone(), rec);
377 }
378 }
379 }
380 let responder_health_dir = state_dir.join("responder-health");
382 if responder_health_dir.exists() {
383 let mut rd = tokio::fs::read_dir(&responder_health_dir).await?;
384 while let Some(entry) = rd.next_entry().await? {
385 let path = entry.path();
386 if path.extension().and_then(|x| x.to_str()) != Some("json") {
387 continue;
388 }
389 let Some(slot_id) = path.file_stem().and_then(|s| s.to_str()) else {
390 continue;
391 };
392 let body = tokio::fs::read_to_string(&path).await.unwrap_or_default();
393 if let Ok(rec) = serde_json::from_str::<ResponderHealthRecord>(&body) {
394 inner.responder_health.insert(slot_id.to_string(), rec);
395 }
396 }
397 }
398 let invites_path = state_dir.join("invites.jsonl");
402 if invites_path.exists() {
403 let now_unix = SystemTime::now()
404 .duration_since(UNIX_EPOCH)
405 .map(|d| d.as_secs())
406 .unwrap_or(0);
407 let body = tokio::fs::read_to_string(&invites_path)
408 .await
409 .unwrap_or_default();
410 for line in body.lines() {
411 if let Ok(rec) = serde_json::from_str::<InviteRecord>(line)
412 && rec.expires_unix > now_unix
413 {
414 inner.invites.insert(rec.token.clone(), rec);
415 }
416 }
417 }
418 let boot_unix = SystemTime::now()
419 .duration_since(UNIX_EPOCH)
420 .map(|d| d.as_secs())
421 .unwrap_or(0);
422 let snap: CountersSnapshot =
424 match tokio::fs::read_to_string(state_dir.join("counters.json")).await {
425 Ok(body) => serde_json::from_str(&body).unwrap_or_default(),
426 Err(_) => CountersSnapshot::default(),
427 };
428 Ok(Self {
429 inner: Arc::new(Mutex::new(inner)),
430 state_dir,
431 counters: Arc::new(RelayCounters {
432 boot_unix,
433 handle_claims_total: AtomicU64::new(snap.handle_claims_total),
434 handle_first_claims_total: AtomicU64::new(snap.handle_first_claims_total),
435 slot_allocations_total: AtomicU64::new(snap.slot_allocations_total),
436 pair_opens_total: AtomicU64::new(snap.pair_opens_total),
437 events_posted_total: AtomicU64::new(snap.events_posted_total),
438 }),
439 })
440 }
441
442 pub fn router(self) -> Router {
443 self.router_with_mode(ServerMode::default())
444 }
445
446 pub fn router_with_mode(self, mode: ServerMode) -> Router {
447 let governor_conf = std::sync::Arc::new(
454 GovernorConfigBuilder::default()
455 .per_second(10)
456 .burst_size(50)
457 .key_extractor(GlobalKeyExtractor)
458 .finish()
459 .expect("valid governor config"),
460 );
461 let governor_layer = GovernorLayer {
462 config: governor_conf,
463 };
464
465 let hot_writes = Router::new()
467 .route("/v1/slot/allocate", post(allocate_slot))
468 .route("/v1/pair", post(pair_open))
469 .route("/v1/pair/:pair_id/bootstrap", post(pair_bootstrap))
470 .route("/v1/pair/abandon", post(pair_abandon))
471 .layer(governor_layer);
472
473 let mut router = Router::new()
476 .route("/healthz", get(healthz))
477 .route("/v1/events/:slot_id", post(post_event).get(list_events))
478 .route("/v1/slot/:slot_id/state", get(slot_state))
479 .route(
480 "/v1/slot/:slot_id/responder-health",
481 post(responder_health_set),
482 )
483 .route("/v1/events/:slot_id/stream", get(stream_events))
484 .route("/v1/pair/:pair_id", get(pair_get))
485 .route("/v1/handle/intro/:nick", post(handle_intro));
486
487 if !mode.local_only {
493 router = router
494 .route("/", get(landing_index))
495 .route("/favicon.svg", get(landing_favicon))
496 .route("/og.png", get(landing_og))
497 .route("/install", get(landing_install_sh))
498 .route("/install.sh", get(landing_install_sh))
499 .route("/openshell-policy.sh", get(landing_openshell_policy_sh))
500 .route("/stats", get(stats_root))
501 .route("/stats.json", get(stats_json))
502 .route("/stats.html", get(landing_stats_html))
503 .route("/stats.history", get(stats_history))
504 .route("/phonebook", get(landing_phonebook_html))
505 .route("/phonebook.html", get(landing_phonebook_html))
506 .route("/v1/handle/claim", post(handle_claim))
507 .route("/v1/handle/claim/:nick", delete(handle_unclaim))
508 .route("/v1/handles", get(handles_directory))
509 .route("/v1/invite/register", post(invite_register))
510 .route("/i/:token", get(invite_script))
511 .route("/.well-known/wire/agent", get(well_known_agent))
512 .route(
513 "/.well-known/agent-card.json",
514 get(well_known_agent_card_a2a),
515 );
516 } else {
517 router = router
523 .route("/v1/handle/claim", post(handle_claim))
524 .route("/v1/handle/claim/:nick", delete(handle_unclaim));
525 }
526
527 router.merge(hot_writes).with_state(self)
528 }
529
530 async fn evict_expired_pair_slots(&self) {
534 let now = std::time::Instant::now();
535 let ttl = std::time::Duration::from_secs(PAIR_SLOT_TTL_SECS);
536 let mut inner = self.inner.lock().await;
537 let mut to_remove = Vec::new();
538 for (id, slot) in inner.pair_slots.iter() {
539 if now.duration_since(slot.last_touched) > ttl {
540 to_remove.push(id.clone());
541 }
542 }
543 for id in to_remove {
544 inner.pair_slots.remove(&id);
545 inner.pair_lookup.retain(|_, v| v != &id);
546 }
547 }
548
549 async fn sweep_intro_times(&self) {
559 let now = unix_now();
560 let mut inner = self.inner.lock().await;
561 evict_stale_intro_nicks(&mut inner.intro_times, now, INTRO_WINDOW_SECS);
562 }
563
564 pub fn spawn_pair_sweeper(&self) {
565 let me = self.clone();
566 tokio::spawn(async move {
567 let mut tick = tokio::time::interval(std::time::Duration::from_secs(60));
568 loop {
569 tick.tick().await;
570 me.evict_expired_pair_slots().await;
571 me.sweep_intro_times().await;
572 }
573 });
574 }
575
576 pub async fn persist_counters(&self) -> Result<()> {
580 let snap = CountersSnapshot {
581 handle_claims_total: self.counters.handle_claims_total.load(Ordering::Relaxed),
582 handle_first_claims_total: self
583 .counters
584 .handle_first_claims_total
585 .load(Ordering::Relaxed),
586 slot_allocations_total: self.counters.slot_allocations_total.load(Ordering::Relaxed),
587 pair_opens_total: self.counters.pair_opens_total.load(Ordering::Relaxed),
588 events_posted_total: self.counters.events_posted_total.load(Ordering::Relaxed),
589 };
590 let body = serde_json::to_vec_pretty(&snap)?;
591 let path = self.state_dir.join("counters.json");
592 tokio::fs::write(path, body).await?;
593 Ok(())
594 }
595
596 pub async fn append_history(&self) -> Result<()> {
601 use tokio::io::AsyncWriteExt;
602 let now = SystemTime::now()
603 .duration_since(UNIX_EPOCH)
604 .map(|d| d.as_secs())
605 .unwrap_or(0);
606 let (handles_active, slots_active, pair_slots_open, streams_active) = {
607 let inner = self.inner.lock().await;
608 (
609 inner.handles.len(),
610 inner.slots.len(),
611 inner.pair_slots.len(),
612 inner.streams.values().map(Vec::len).sum::<usize>(),
613 )
614 };
615 let entry = HistoryEntry {
616 ts: now,
617 handles_active,
618 slots_active,
619 pair_slots_open,
620 streams_active,
621 handle_claims_total: self.counters.handle_claims_total.load(Ordering::Relaxed),
622 handle_first_claims_total: self
623 .counters
624 .handle_first_claims_total
625 .load(Ordering::Relaxed),
626 slot_allocations_total: self.counters.slot_allocations_total.load(Ordering::Relaxed),
627 pair_opens_total: self.counters.pair_opens_total.load(Ordering::Relaxed),
628 events_posted_total: self.counters.events_posted_total.load(Ordering::Relaxed),
629 };
630 let line = serde_json::to_vec(&entry)?;
631 let path = self.state_dir.join("stats-history.jsonl");
632 let mut f = tokio::fs::OpenOptions::new()
633 .create(true)
634 .append(true)
635 .open(&path)
636 .await?;
637 f.write_all(&line).await?;
638 f.write_all(b"\n").await?;
639 f.flush().await?;
640 Ok(())
641 }
642
643 pub fn spawn_counter_persister(&self) {
647 let me = self.clone();
648 tokio::spawn(async move {
649 let mut tick = tokio::time::interval(std::time::Duration::from_secs(30));
650 tick.tick().await;
653 loop {
654 tick.tick().await;
655 if let Err(e) = me.persist_counters().await {
656 eprintln!("counter persist failed: {e}");
657 }
658 if let Err(e) = me.append_history().await {
659 eprintln!("history append failed: {e}");
660 }
661 }
662 });
663 }
664
665 async fn persist_tokens(&self) -> Result<()> {
666 let body = {
667 let inner = self.inner.lock().await;
668 serde_json::to_string_pretty(&inner.tokens)?
669 };
670 let path = self.state_dir.join("tokens.json");
671 tokio::fs::write(path, body).await?;
672 Ok(())
673 }
674
675 async fn append_event_to_disk(&self, slot_id: &str, event: &Value) -> Result<()> {
676 if !is_valid_slot_id(slot_id) {
682 return Err(anyhow::anyhow!("invalid slot_id format: {slot_id:?}"));
683 }
684 let path = self
685 .state_dir
686 .join("slots")
687 .join(format!("{slot_id}.jsonl"));
688 let mut line = serde_json::to_vec(event)?;
689 line.push(b'\n');
690 use tokio::io::AsyncWriteExt;
691 let mut f = tokio::fs::OpenOptions::new()
692 .create(true)
693 .append(true)
694 .open(&path)
695 .await
696 .with_context(|| format!("opening {path:?}"))?;
697 f.write_all(&line).await?;
698 f.flush().await?;
699 Ok(())
700 }
701}
702
703async fn healthz() -> impl IntoResponse {
704 (StatusCode::OK, "ok\n")
705}
706
707async fn stats_history(
711 State(relay): State<Relay>,
712 Query(q): Query<StatsHistoryQuery>,
713) -> impl IntoResponse {
714 let hours = q.hours.unwrap_or(24).min(168);
715 let now = SystemTime::now()
716 .duration_since(UNIX_EPOCH)
717 .map(|d| d.as_secs())
718 .unwrap_or(0);
719 let cutoff = now.saturating_sub(hours * 3600);
720 let path = relay.state_dir.join("stats-history.jsonl");
721 let body = tokio::fs::read_to_string(&path).await.unwrap_or_default();
722 let entries: Vec<Value> = body
723 .lines()
724 .filter_map(|l| serde_json::from_str::<Value>(l).ok())
725 .filter(|v| {
726 v.get("ts")
727 .and_then(Value::as_u64)
728 .map(|t| t >= cutoff)
729 .unwrap_or(false)
730 })
731 .collect();
732 (
733 StatusCode::OK,
734 Json(json!({
735 "hours": hours,
736 "now_unix": now,
737 "count": entries.len(),
738 "entries": entries,
739 })),
740 )
741}
742
743async fn landing_stats_html() -> impl IntoResponse {
744 static STATS_HTML: &[u8] = include_bytes!("../landing/stats.html");
745 (
746 StatusCode::OK,
747 [
748 (axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8"),
749 (axum::http::header::CACHE_CONTROL, "public, max-age=60"),
750 ],
751 STATS_HTML,
752 )
753}
754
755async fn landing_phonebook_html() -> impl IntoResponse {
756 static PHONEBOOK_HTML: &[u8] = include_bytes!("../landing/phonebook.html");
757 (
758 StatusCode::OK,
759 [
760 (axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8"),
761 (axum::http::header::CACHE_CONTROL, "public, max-age=60"),
762 ],
763 PHONEBOOK_HTML,
764 )
765}
766
767async fn stats_root(State(relay): State<Relay>, headers: HeaderMap) -> axum::response::Response {
772 let wants_html = headers
773 .get(axum::http::header::ACCEPT)
774 .and_then(|v| v.to_str().ok())
775 .unwrap_or("")
776 .contains("text/html");
777 if wants_html {
778 landing_stats_html().await.into_response()
779 } else {
780 stats_json(State(relay)).await.into_response()
781 }
782}
783
784async fn stats_json(State(relay): State<Relay>) -> impl IntoResponse {
785 let now = SystemTime::now()
786 .duration_since(UNIX_EPOCH)
787 .map(|d| d.as_secs())
788 .unwrap_or(0);
789 let inner = relay.inner.lock().await;
790 let streams_active: usize = inner.streams.values().map(Vec::len).sum();
791 let body = json!({
792 "version": env!("CARGO_PKG_VERSION"),
793 "uptime_seconds": now.saturating_sub(relay.counters.boot_unix),
794 "handles_active": inner.handles.len(),
795 "slots_active": inner.slots.len(),
796 "pair_slots_open": inner.pair_slots.len(),
797 "streams_active": streams_active,
798 "handle_claims_total": relay.counters.handle_claims_total.load(Ordering::Relaxed),
799 "handle_first_claims_total": relay.counters.handle_first_claims_total.load(Ordering::Relaxed),
800 "slot_allocations_total": relay.counters.slot_allocations_total.load(Ordering::Relaxed),
801 "pair_opens_total": relay.counters.pair_opens_total.load(Ordering::Relaxed),
802 "events_posted_total": relay.counters.events_posted_total.load(Ordering::Relaxed),
803 });
804 (StatusCode::OK, Json(body))
805}
806
807async fn landing_index() -> impl IntoResponse {
811 static INDEX_HTML: &[u8] = include_bytes!("../landing/index.html");
812 (
813 StatusCode::OK,
814 [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
815 INDEX_HTML,
816 )
817}
818
819async fn landing_favicon() -> impl IntoResponse {
820 static FAVICON_SVG: &[u8] = include_bytes!("../landing/favicon.svg");
821 (
822 StatusCode::OK,
823 [(axum::http::header::CONTENT_TYPE, "image/svg+xml")],
824 FAVICON_SVG,
825 )
826}
827
828async fn landing_og() -> impl IntoResponse {
829 static OG_PNG: &[u8] = include_bytes!("../landing/og.png");
830 (
831 StatusCode::OK,
832 [
833 (axum::http::header::CONTENT_TYPE, "image/png"),
834 (axum::http::header::CACHE_CONTROL, "public, max-age=86400"),
835 ],
836 OG_PNG,
837 )
838}
839
840async fn landing_install_sh() -> impl IntoResponse {
841 static INSTALL_SH: &[u8] = include_bytes!("../landing/install.sh");
842 (
843 StatusCode::OK,
844 [
845 (
846 axum::http::header::CONTENT_TYPE,
847 "text/x-shellscript; charset=utf-8",
848 ),
849 (axum::http::header::CACHE_CONTROL, "public, max-age=300"),
850 ],
851 INSTALL_SH,
852 )
853}
854
855async fn landing_openshell_policy_sh() -> impl IntoResponse {
856 static POLICY_SH: &[u8] = include_bytes!("../landing/openshell-policy.sh");
857 (
858 StatusCode::OK,
859 [
860 (
861 axum::http::header::CONTENT_TYPE,
862 "text/x-shellscript; charset=utf-8",
863 ),
864 (axum::http::header::CACHE_CONTROL, "public, max-age=300"),
865 ],
866 POLICY_SH,
867 )
868}
869
870async fn allocate_slot(
871 State(relay): State<Relay>,
872 Json(_req): Json<AllocateRequest>,
873) -> impl IntoResponse {
874 let slot_id = random_hex(16);
875 let slot_token = random_hex(32);
876 {
877 let mut inner = relay.inner.lock().await;
878 if at_capacity(inner.slots.len(), MAX_SLOTS) {
879 return (
880 StatusCode::SERVICE_UNAVAILABLE,
881 Json(json!({"error": "relay slot capacity reached"})),
882 )
883 .into_response();
884 }
885 inner.slots.insert(slot_id.clone(), Vec::new());
886 inner.tokens.insert(slot_id.clone(), slot_token.clone());
887 }
888 if let Err(e) = relay.persist_tokens().await {
889 return (
890 StatusCode::INTERNAL_SERVER_ERROR,
891 Json(json!({"error": format!("persist failed: {e}")})),
892 )
893 .into_response();
894 }
895 relay
896 .counters
897 .slot_allocations_total
898 .fetch_add(1, Ordering::Relaxed);
899 (
900 StatusCode::CREATED,
901 Json(json!({"slot_id": slot_id, "slot_token": slot_token})),
902 )
903 .into_response()
904}
905
906async fn post_event(
907 State(relay): State<Relay>,
908 Path(slot_id): Path<String>,
909 headers: HeaderMap,
910 Json(req): Json<PostEventRequest>,
911) -> impl IntoResponse {
912 if let Err(resp) = check_token(&relay, &headers, &slot_id).await {
913 return resp;
914 }
915 let body_bytes = match serde_json::to_vec(&req.event) {
917 Ok(b) => b,
918 Err(e) => {
919 return (
920 StatusCode::BAD_REQUEST,
921 Json(json!({"error": format!("event not serializable: {e}")})),
922 )
923 .into_response();
924 }
925 };
926 if body_bytes.len() > MAX_EVENT_BYTES {
927 return (
928 StatusCode::PAYLOAD_TOO_LARGE,
929 Json(json!({"error": "event exceeds 256 KiB", "max_bytes": MAX_EVENT_BYTES})),
930 )
931 .into_response();
932 }
933 {
935 let inner = relay.inner.lock().await;
936 let used = inner.slot_bytes.get(&slot_id).copied().unwrap_or(0);
937 if used + body_bytes.len() > MAX_SLOT_BYTES {
938 return (
939 StatusCode::PAYLOAD_TOO_LARGE,
940 Json(json!({
941 "error": "slot quota exceeded",
942 "slot_bytes_used": used,
943 "slot_bytes_max": MAX_SLOT_BYTES,
944 "remediation": "operator should `wire rotate-slot` to drain old slot",
945 })),
946 )
947 .into_response();
948 }
949 }
950 let event_id = req
951 .event
952 .get("event_id")
953 .and_then(Value::as_str)
954 .map(str::to_string);
955
956 let dup = {
958 let inner = relay.inner.lock().await;
959 let slot = inner.slots.get(&slot_id);
960 if let (Some(eid), Some(slot)) = (&event_id, slot) {
961 slot.iter()
962 .any(|e| e.get("event_id").and_then(Value::as_str) == Some(eid))
963 } else {
964 false
965 }
966 };
967 if dup {
968 return (
969 StatusCode::OK,
970 Json(json!({"event_id": event_id, "status": "duplicate"})),
971 )
972 .into_response();
973 }
974
975 {
976 let mut inner = relay.inner.lock().await;
977 let event_size = body_bytes.len();
978 let slot = inner.slots.entry(slot_id.clone()).or_default();
979 slot.push(req.event.clone());
980 *inner.slot_bytes.entry(slot_id.clone()).or_insert(0) += event_size;
981 }
982 if let Err(e) = relay.append_event_to_disk(&slot_id, &req.event).await {
983 return (
984 StatusCode::INTERNAL_SERVER_ERROR,
985 Json(json!({"error": format!("persist failed: {e}")})),
986 )
987 .into_response();
988 }
989 relay
990 .counters
991 .events_posted_total
992 .fetch_add(1, Ordering::Relaxed);
993 {
998 let mut inner = relay.inner.lock().await;
999 if let Some(subs) = inner.streams.get_mut(&slot_id) {
1000 subs.retain(|tx| tx.send(req.event.clone()).is_ok());
1001 if subs.is_empty() {
1002 inner.streams.remove(&slot_id);
1005 }
1006 }
1007 }
1008 (
1009 StatusCode::CREATED,
1010 Json(json!({"event_id": event_id, "status": "stored"})),
1011 )
1012 .into_response()
1013}
1014
1015async fn stream_events(
1028 State(relay): State<Relay>,
1029 Path(slot_id): Path<String>,
1030 headers: HeaderMap,
1031) -> axum::response::Response {
1032 use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
1033 use futures::stream::StreamExt;
1034
1035 if let Err(resp) = check_token(&relay, &headers, &slot_id).await {
1036 return resp;
1037 }
1038
1039 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<Value>();
1040 {
1041 let mut inner = relay.inner.lock().await;
1042 if let Some(subs) = inner.streams.get_mut(&slot_id) {
1046 retain_live_subscribers(subs);
1047 if subs.is_empty() {
1048 inner.streams.remove(&slot_id);
1049 }
1050 }
1051 let current = inner.streams.get(&slot_id).map_or(0, Vec::len);
1055 if at_capacity(current, MAX_STREAMS_PER_SLOT) {
1056 return (
1057 StatusCode::SERVICE_UNAVAILABLE,
1058 Json(json!({
1059 "error": format!(
1060 "phyllis: too many open lines on that slot — max {MAX_STREAMS_PER_SLOT} subscribers"
1061 )
1062 })),
1063 )
1064 .into_response();
1065 }
1066 inner.streams.entry(slot_id.clone()).or_default().push(tx);
1067 }
1068
1069 let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx).map(|ev| {
1070 SseEvent::default()
1071 .json_data(&ev)
1072 .map_err(|e| std::io::Error::other(e.to_string()))
1073 });
1074
1075 Sse::new(stream)
1076 .keep_alive(
1077 KeepAlive::new()
1078 .interval(std::time::Duration::from_secs(30))
1079 .text("phyllis: still on the line"),
1080 )
1081 .into_response()
1082}
1083
1084#[derive(Deserialize)]
1087pub struct PairOpenRequest {
1088 pub code_hash: String,
1089 pub msg: String,
1091 pub role: String, }
1093
1094#[derive(Deserialize)]
1095pub struct PairBootstrapRequest {
1096 pub role: String,
1097 pub sealed: String,
1098}
1099
1100#[derive(Deserialize)]
1101pub struct PairAbandonRequest {
1102 pub code_hash: String,
1105}
1106
1107async fn pair_abandon(
1113 State(relay): State<Relay>,
1114 Json(req): Json<PairAbandonRequest>,
1115) -> impl IntoResponse {
1116 let mut inner = relay.inner.lock().await;
1117 if let Some(pair_id) = inner.pair_lookup.remove(&req.code_hash) {
1118 inner.pair_slots.remove(&pair_id);
1119 }
1120 StatusCode::NO_CONTENT.into_response()
1121}
1122
1123async fn pair_open(
1124 State(relay): State<Relay>,
1125 Json(req): Json<PairOpenRequest>,
1126) -> impl IntoResponse {
1127 if req.role != "host" && req.role != "guest" {
1128 return (
1129 StatusCode::BAD_REQUEST,
1130 Json(json!({"error": "role must be 'host' or 'guest'"})),
1131 )
1132 .into_response();
1133 }
1134 relay.evict_expired_pair_slots().await;
1135 let mut inner = relay.inner.lock().await;
1136 let pair_id = match inner.pair_lookup.get(&req.code_hash).cloned() {
1137 Some(id) => id,
1138 None => {
1139 if at_capacity(inner.pair_slots.len(), MAX_PAIR_SLOTS) {
1140 return (
1141 StatusCode::SERVICE_UNAVAILABLE,
1142 Json(json!({"error": "relay pair-slot capacity reached"})),
1143 )
1144 .into_response();
1145 }
1146 let new_id = random_hex(16);
1147 inner
1148 .pair_lookup
1149 .insert(req.code_hash.clone(), new_id.clone());
1150 inner.pair_slots.insert(new_id.clone(), PairSlot::default());
1151 relay
1152 .counters
1153 .pair_opens_total
1154 .fetch_add(1, Ordering::Relaxed);
1155 new_id
1156 }
1157 };
1158 let slot = inner.pair_slots.entry(pair_id.clone()).or_default();
1159 slot.last_touched = std::time::Instant::now();
1160 if req.role == "host" {
1161 if slot.host_msg.is_some() {
1162 return (
1163 StatusCode::CONFLICT,
1164 Json(json!({"error": "host already registered for this code"})),
1165 )
1166 .into_response();
1167 }
1168 slot.host_msg = Some(req.msg);
1169 } else {
1170 if slot.guest_msg.is_some() {
1171 return (
1172 StatusCode::CONFLICT,
1173 Json(json!({"error": "guest already registered for this code"})),
1174 )
1175 .into_response();
1176 }
1177 slot.guest_msg = Some(req.msg);
1178 }
1179 (StatusCode::CREATED, Json(json!({"pair_id": pair_id}))).into_response()
1180}
1181
1182#[derive(Deserialize)]
1183pub struct PairGetQuery {
1184 pub as_role: String,
1186}
1187
1188async fn pair_get(
1189 State(relay): State<Relay>,
1190 Path(pair_id): Path<String>,
1191 Query(q): Query<PairGetQuery>,
1192) -> impl IntoResponse {
1193 relay.evict_expired_pair_slots().await;
1194 let mut inner = relay.inner.lock().await;
1195 let slot = match inner.pair_slots.get_mut(&pair_id) {
1196 Some(s) => {
1197 s.last_touched = std::time::Instant::now();
1198 s.clone()
1199 }
1200 None => {
1201 return (
1202 StatusCode::NOT_FOUND,
1203 Json(json!({"error": "unknown pair_id"})),
1204 )
1205 .into_response();
1206 }
1207 };
1208 let (peer_msg, peer_bootstrap) = match q.as_role.as_str() {
1209 "host" => (slot.guest_msg, slot.guest_bootstrap),
1210 "guest" => (slot.host_msg, slot.host_bootstrap),
1211 _ => {
1212 return (
1213 StatusCode::BAD_REQUEST,
1214 Json(json!({"error": "as_role must be 'host' or 'guest'"})),
1215 )
1216 .into_response();
1217 }
1218 };
1219 (
1220 StatusCode::OK,
1221 Json(json!({"peer_msg": peer_msg, "peer_bootstrap": peer_bootstrap})),
1222 )
1223 .into_response()
1224}
1225
1226async fn pair_bootstrap(
1227 State(relay): State<Relay>,
1228 Path(pair_id): Path<String>,
1229 Json(req): Json<PairBootstrapRequest>,
1230) -> impl IntoResponse {
1231 relay.evict_expired_pair_slots().await;
1232 let mut inner = relay.inner.lock().await;
1233 let slot = match inner.pair_slots.get_mut(&pair_id) {
1234 Some(s) => s,
1235 None => {
1236 return (
1237 StatusCode::NOT_FOUND,
1238 Json(json!({"error": "unknown pair_id"})),
1239 )
1240 .into_response();
1241 }
1242 };
1243 slot.last_touched = std::time::Instant::now();
1244 match req.role.as_str() {
1245 "host" => slot.host_bootstrap = Some(req.sealed),
1246 "guest" => slot.guest_bootstrap = Some(req.sealed),
1247 _ => {
1248 return (
1249 StatusCode::BAD_REQUEST,
1250 Json(json!({"error": "role must be 'host' or 'guest'"})),
1251 )
1252 .into_response();
1253 }
1254 }
1255 (StatusCode::CREATED, Json(json!({"ok": true}))).into_response()
1256}
1257
1258#[derive(Deserialize)]
1261pub struct HandleClaimRequest {
1262 pub nick: String,
1265 pub slot_id: String,
1267 pub relay_url: Option<String>,
1271 pub card: Value,
1274 #[serde(default, skip_serializing_if = "Option::is_none")]
1278 pub discoverable: Option<bool>,
1279}
1280
1281async fn handle_claim(
1288 State(relay): State<Relay>,
1289 headers: HeaderMap,
1290 Json(req): Json<HandleClaimRequest>,
1291) -> impl IntoResponse {
1292 if let Err(resp) = check_token(&relay, &headers, &req.slot_id).await {
1295 return resp;
1296 }
1297 if !crate::pair_profile::is_valid_nick(&req.nick) {
1299 return (
1300 StatusCode::BAD_REQUEST,
1301 Json(json!({
1302 "error": "phyllis: that handle won't fit in the books — nicks need 2-32 chars, lowercase [a-z0-9_-], not on the reserved list",
1303 "nick": req.nick,
1304 })),
1305 )
1306 .into_response();
1307 }
1308 if let Some(url) = req.relay_url.as_deref()
1314 && !url.is_empty()
1315 && !is_valid_public_relay_url(url)
1316 {
1317 return (
1318 StatusCode::BAD_REQUEST,
1319 Json(json!({
1320 "error": "relay_url must be http(s)://host[:port] with no userinfo",
1321 "relay_url": url,
1322 })),
1323 )
1324 .into_response();
1325 }
1326 if let Err(e) = crate::agent_card::verify_agent_card(&req.card) {
1328 return (
1329 StatusCode::BAD_REQUEST,
1330 Json(json!({"error": format!("card signature invalid: {e}")})),
1331 )
1332 .into_response();
1333 }
1334 let did = match req.card.get("did").and_then(Value::as_str) {
1335 Some(d) => d.to_string(),
1336 None => {
1337 return (
1338 StatusCode::BAD_REQUEST,
1339 Json(json!({"error": "card missing 'did' field"})),
1340 )
1341 .into_response();
1342 }
1343 };
1344
1345 let canonical_nick = crate::agent_card::display_handle_from_did(&did);
1353 if req.nick != canonical_nick {
1354 return (
1355 StatusCode::BAD_REQUEST,
1356 Json(json!({
1357 "error": "phyllis: that nick doesn't match your DID — wire publishes one name, and it's the one your key spells out",
1358 "nick": req.nick,
1359 "expected": canonical_nick,
1360 })),
1361 )
1362 .into_response();
1363 }
1364
1365 let prior_record: Option<HandleRecord>;
1371 let first_claim = {
1372 let inner = relay.inner.lock().await;
1373 match inner.handles.get(&req.nick) {
1374 Some(existing) if existing.did != did => {
1375 return (
1376 StatusCode::CONFLICT,
1377 Json(json!({
1378 "error": "phyllis: this line's already taken by a different identity (persona collision). Your handle is fixed to your key, so claim on another relay (`wire up @<other-relay>`) or mint a fresh identity (`wire nuke` then `wire up`).",
1379 "nick": req.nick,
1380 "claimed_by": existing.did,
1381 })),
1382 )
1383 .into_response();
1384 }
1385 Some(prev) => {
1386 prior_record = Some(prev.clone());
1387 false
1388 }
1389 None => {
1390 prior_record = None;
1391 true
1392 }
1393 }
1394 };
1395
1396 if first_claim {
1400 let inner = relay.inner.lock().await;
1401 if at_capacity(inner.handles.len(), MAX_HANDLES) {
1402 return (
1403 StatusCode::SERVICE_UNAVAILABLE,
1404 Json(json!({"error": "relay handle-directory capacity reached"})),
1405 )
1406 .into_response();
1407 }
1408 }
1409
1410 let now = time::OffsetDateTime::now_utc()
1415 .replace_nanosecond(0)
1416 .unwrap_or_else(|_| time::OffsetDateTime::now_utc())
1417 .format(&time::format_description::well_known::Rfc3339)
1418 .unwrap_or_default();
1419 let discoverable = match (req.discoverable, &prior_record) {
1425 (Some(d), _) => Some(d),
1426 (None, Some(prev)) => prev.discoverable,
1427 (None, None) => Some(true),
1428 };
1429 let record = HandleRecord {
1430 nick: req.nick.clone(),
1431 did: did.clone(),
1432 card: req.card.clone(),
1433 slot_id: req.slot_id.clone(),
1434 relay_url: req.relay_url.clone(),
1435 claimed_at: now,
1436 discoverable,
1437 };
1438
1439 let path = relay
1441 .state_dir
1442 .join("handles")
1443 .join(format!("{}.json", req.nick));
1444 let body = match serde_json::to_vec_pretty(&record) {
1445 Ok(b) => b,
1446 Err(e) => {
1447 return (
1448 StatusCode::INTERNAL_SERVER_ERROR,
1449 Json(json!({"error": format!("serialize failed: {e}")})),
1450 )
1451 .into_response();
1452 }
1453 };
1454 if let Err(e) = tokio::fs::write(&path, &body).await {
1455 return (
1456 StatusCode::INTERNAL_SERVER_ERROR,
1457 Json(json!({"error": format!("persist failed: {e}")})),
1458 )
1459 .into_response();
1460 }
1461 {
1462 let mut inner = relay.inner.lock().await;
1463 inner.handles.insert(req.nick.clone(), record);
1464 }
1465 relay
1466 .counters
1467 .handle_claims_total
1468 .fetch_add(1, Ordering::Relaxed);
1469 if first_claim {
1470 relay
1471 .counters
1472 .handle_first_claims_total
1473 .fetch_add(1, Ordering::Relaxed);
1474 }
1475 (
1476 StatusCode::CREATED,
1477 Json(json!({
1478 "nick": req.nick,
1479 "did": did,
1480 "status": if first_claim { "claimed" } else { "re-claimed" },
1481 })),
1482 )
1483 .into_response()
1484}
1485
1486async fn handle_unclaim(
1493 State(relay): State<Relay>,
1494 Path(nick): Path<String>,
1495 headers: HeaderMap,
1496) -> impl IntoResponse {
1497 if !crate::pair_profile::is_valid_nick(&nick) {
1499 return (
1500 StatusCode::BAD_REQUEST,
1501 Json(json!({"error": "invalid nick"})),
1502 )
1503 .into_response();
1504 }
1505 let slot_id = {
1507 let inner = relay.inner.lock().await;
1508 match inner.handles.get(&nick) {
1509 Some(rec) => rec.slot_id.clone(),
1510 None => {
1511 return (
1512 StatusCode::NOT_FOUND,
1513 Json(json!({"error": format!("{nick:?} isn't claimed")})),
1514 )
1515 .into_response();
1516 }
1517 }
1518 };
1519 if let Err(resp) = check_token(&relay, &headers, &slot_id).await {
1521 return resp;
1522 }
1523 let path = relay.state_dir.join("handles").join(format!("{nick}.json"));
1525 let _ = tokio::fs::remove_file(&path).await;
1526 {
1527 let mut inner = relay.inner.lock().await;
1528 inner.handles.remove(&nick);
1529 }
1530 (
1531 StatusCode::OK,
1532 Json(json!({"nick": nick, "status": "unclaimed"})),
1533 )
1534 .into_response()
1535}
1536
1537#[derive(Deserialize)]
1538pub struct WellKnownAgentQuery {
1539 pub handle: String,
1540}
1541
1542#[derive(Deserialize)]
1543pub struct HandlesDirectoryQuery {
1544 pub cursor: Option<String>,
1545 pub limit: Option<usize>,
1546 pub vibe: Option<String>,
1547}
1548
1549#[derive(Deserialize)]
1559pub struct InviteRegisterRequest {
1560 pub invite_url: String,
1562 #[serde(default)]
1564 pub ttl_seconds: Option<u64>,
1565 #[serde(default)]
1568 pub uses: Option<u32>,
1569}
1570
1571impl Relay {
1572 async fn persist_invite(&self, rec: &InviteRecord) -> Result<()> {
1574 use tokio::io::AsyncWriteExt;
1575 let mut line = serde_json::to_vec(rec)?;
1576 line.push(b'\n');
1577 let path = self.state_dir.join("invites.jsonl");
1578 let mut f = tokio::fs::OpenOptions::new()
1579 .create(true)
1580 .append(true)
1581 .open(&path)
1582 .await?;
1583 f.write_all(&line).await?;
1584 f.flush().await?;
1585 Ok(())
1586 }
1587}
1588
1589async fn invite_register(
1590 State(relay): State<Relay>,
1591 Json(req): Json<InviteRegisterRequest>,
1592) -> impl IntoResponse {
1593 if req.invite_url.is_empty() {
1594 return (
1595 StatusCode::BAD_REQUEST,
1596 Json(json!({"error": "invite_url required"})),
1597 )
1598 .into_response();
1599 }
1600 if req.invite_url.len() > 8_192 {
1602 return (
1603 StatusCode::PAYLOAD_TOO_LARGE,
1604 Json(json!({"error": "invite_url > 8 KiB"})),
1605 )
1606 .into_response();
1607 }
1608 let ttl = req.ttl_seconds.unwrap_or(86_400).clamp(60, 7 * 86_400);
1609 let now = SystemTime::now()
1610 .duration_since(UNIX_EPOCH)
1611 .map(|d| d.as_secs())
1612 .unwrap_or(0);
1613 let token = random_hex(8);
1615 let rec = InviteRecord {
1616 token: token.clone(),
1617 invite_url: req.invite_url,
1618 expires_unix: now + ttl,
1619 uses_remaining: req.uses,
1620 created_unix: now,
1621 };
1622 {
1623 let mut inner = relay.inner.lock().await;
1624 if at_capacity(inner.invites.len(), MAX_INVITES) {
1625 return (
1626 StatusCode::SERVICE_UNAVAILABLE,
1627 Json(json!({"error": "relay invite capacity reached"})),
1628 )
1629 .into_response();
1630 }
1631 if inner.invites.contains_key(&token) {
1632 return (
1633 StatusCode::CONFLICT,
1634 Json(json!({"error": "token collision, retry"})),
1635 )
1636 .into_response();
1637 }
1638 inner.invites.insert(token.clone(), rec.clone());
1639 }
1640 if let Err(e) = relay.persist_invite(&rec).await {
1641 return (
1642 StatusCode::INTERNAL_SERVER_ERROR,
1643 Json(json!({"error": format!("persist failed: {e}")})),
1644 )
1645 .into_response();
1646 }
1647 (
1648 StatusCode::CREATED,
1649 Json(json!({
1650 "token": token,
1651 "path": format!("/i/{token}"),
1652 "expires_unix": rec.expires_unix,
1653 "uses_remaining": rec.uses_remaining,
1654 })),
1655 )
1656 .into_response()
1657}
1658
1659#[derive(Deserialize)]
1660pub struct InviteScriptQuery {
1661 pub format: Option<String>,
1668}
1669
1670async fn invite_script(
1671 State(relay): State<Relay>,
1672 Path(token): Path<String>,
1673 Query(q): Query<InviteScriptQuery>,
1674) -> impl IntoResponse {
1675 if token.len() != 6 || !token.chars().all(|c| c.is_ascii_hexdigit()) {
1678 return (StatusCode::NOT_FOUND, "not found\n").into_response();
1679 }
1680 let want_raw_url = q.format.as_deref() == Some("url");
1681 let now = SystemTime::now()
1682 .duration_since(UNIX_EPOCH)
1683 .map(|d| d.as_secs())
1684 .unwrap_or(0);
1685 let invite_url = {
1686 let mut inner = relay.inner.lock().await;
1687 let Some(rec) = inner.invites.get_mut(&token) else {
1688 return (StatusCode::NOT_FOUND, "not found\n").into_response();
1689 };
1690 if rec.expires_unix <= now {
1691 return (StatusCode::GONE, "this invite has expired\n").into_response();
1692 }
1693 if let Some(n) = rec.uses_remaining {
1694 if n == 0 {
1695 return (StatusCode::GONE, "this invite has been used up\n").into_response();
1696 }
1697 if !want_raw_url {
1701 rec.uses_remaining = Some(n - 1);
1702 }
1703 }
1704 rec.invite_url.clone()
1705 };
1706 if want_raw_url {
1707 return (
1708 StatusCode::OK,
1709 [
1710 (
1711 axum::http::header::CONTENT_TYPE,
1712 "text/plain; charset=utf-8",
1713 ),
1714 (
1715 axum::http::header::CACHE_CONTROL,
1716 "private, no-store, max-age=0",
1717 ),
1718 ],
1719 invite_url,
1720 )
1721 .into_response();
1722 }
1723 let escaped = invite_url.replace('\'', "'\\''");
1724 let script = format!(
1725 "#!/bin/sh\n\
1726 # wire — one-curl onboarding (install + pair in one shot)\n\
1727 # source: https://github.com/SlanchaAi/wire\n\
1728 set -eu\n\
1729 INVITE='{escaped}'\n\
1730 echo \"\u{2192} checking for wire CLI...\"\n\
1731 if ! command -v wire >/dev/null 2>&1; then\n \
1732 echo \"\u{2192} wire not installed; installing first...\"\n \
1733 curl -fsSL https://wireup.net/install.sh | sh\n \
1734 case \":$PATH:\" in\n \
1735 *:\"$HOME/.local/bin\":*) ;;\n \
1736 *) export PATH=\"$HOME/.local/bin:$PATH\" ;;\n \
1737 esac\n \
1738 if ! command -v wire >/dev/null 2>&1; then\n \
1739 echo \"\"\n \
1740 echo \"wire was installed to ~/.local/bin but it's not on \\$PATH yet.\"\n \
1741 echo \"Open a new shell, then run:\"\n \
1742 echo \" wire accept '$INVITE'\"\n \
1743 exit 0\n \
1744 fi\n\
1745 fi\n\
1746 echo \"\u{2192} accepting invite...\"\n\
1747 wire accept \"$INVITE\"\n"
1748 );
1749 (
1750 StatusCode::OK,
1751 [
1752 (
1753 axum::http::header::CONTENT_TYPE,
1754 "text/x-shellscript; charset=utf-8",
1755 ),
1756 (
1757 axum::http::header::CACHE_CONTROL,
1758 "private, no-store, max-age=0",
1759 ),
1760 ],
1761 script,
1762 )
1763 .into_response()
1764}
1765
1766async fn handles_directory(
1767 State(relay): State<Relay>,
1768 Query(q): Query<HandlesDirectoryQuery>,
1769) -> impl IntoResponse {
1770 let limit = q.limit.unwrap_or(100).clamp(1, 500);
1771 let vibe_filter = q.vibe.as_ref().map(|v| v.to_ascii_lowercase());
1772 let inner = relay.inner.lock().await;
1773 let mut records: Vec<HandleRecord> = inner.handles.values().cloned().collect();
1774 drop(inner);
1775 records.sort_by(|a, b| a.nick.cmp(&b.nick));
1776
1777 let cursor = q.cursor.as_deref();
1778 let mut eligible = Vec::new();
1779 for rec in records {
1780 if cursor.is_some_and(|c| rec.nick.as_str() <= c) {
1781 continue;
1782 }
1783 if rec.nick.starts_with("demo-") || rec.nick.starts_with("test-") {
1788 continue;
1789 }
1790 if !rec.is_discoverable() {
1794 continue;
1795 }
1796 let profile = rec.card.get("profile").cloned().unwrap_or(Value::Null);
1797 if profile
1798 .get("listed")
1799 .and_then(Value::as_bool)
1800 .is_some_and(|listed| !listed)
1801 {
1802 continue;
1803 }
1804 if let Some(want) = &vibe_filter {
1805 let matched = profile
1806 .get("vibe")
1807 .and_then(Value::as_array)
1808 .map(|arr| {
1809 arr.iter().any(|v| {
1810 v.as_str()
1811 .map(|s| s.eq_ignore_ascii_case(want))
1812 .unwrap_or(false)
1813 })
1814 })
1815 .unwrap_or(false);
1816 if !matched {
1817 continue;
1818 }
1819 }
1820 eligible.push((rec, profile));
1821 }
1822
1823 let has_more = eligible.len() > limit;
1824 let page = eligible.into_iter().take(limit).collect::<Vec<_>>();
1825 let next_cursor = if has_more {
1826 page.last().map(|(rec, _)| rec.nick.clone())
1827 } else {
1828 None
1829 };
1830 let handles: Vec<Value> = page
1831 .into_iter()
1832 .map(|(rec, profile)| {
1833 let emoji = profile
1839 .get("emoji")
1840 .and_then(Value::as_str)
1841 .filter(|s| !s.is_empty())
1842 .map(str::to_string)
1843 .unwrap_or_else(|| crate::character::Character::from_did(&rec.did).emoji);
1844 json!({
1845 "nick": rec.nick,
1846 "did": rec.did,
1847 "profile": {
1848 "emoji": emoji,
1849 "motto": profile.get("motto").cloned().unwrap_or(Value::Null),
1850 "vibe": profile.get("vibe").cloned().unwrap_or(Value::Null),
1851 "pronouns": profile.get("pronouns").cloned().unwrap_or(Value::Null),
1852 "now": profile.get("now").cloned().unwrap_or(Value::Null),
1853 },
1854 "claimed_at": rec.claimed_at,
1855 })
1856 })
1857 .collect();
1858 (
1859 StatusCode::OK,
1860 Json(json!({
1861 "handles": handles,
1862 "next_cursor": next_cursor,
1863 })),
1864 )
1865 .into_response()
1866}
1867
1868async fn handle_intro(
1889 State(relay): State<Relay>,
1890 Path(nick): Path<String>,
1891 Json(req): Json<PostEventRequest>,
1892) -> impl IntoResponse {
1893 let slot_id = {
1895 let mut inner = relay.inner.lock().await;
1896 let slot_id = match inner.handles.get(&nick) {
1897 Some(rec) => rec.slot_id.clone(),
1898 None => {
1899 return (
1900 StatusCode::NOT_FOUND,
1901 Json(json!({"error": format!("phyllis: that number's been disconnected — {nick:?} isn't claimed on this switchboard")})),
1902 )
1903 .into_response();
1904 }
1905 };
1906 let now = unix_now();
1913 if at_capacity(inner.intro_times.len(), MAX_INTRO_TRACKING_NICKS) {
1917 evict_stale_intro_nicks(&mut inner.intro_times, now, INTRO_WINDOW_SECS);
1918 }
1919 let times = inner.intro_times.entry(nick.clone()).or_default();
1920 if !record_intro_within_rate(times, now, INTRO_WINDOW_SECS, INTRO_MAX_PER_WINDOW) {
1921 return (
1922 StatusCode::TOO_MANY_REQUESTS,
1923 Json(json!({
1924 "error": format!(
1925 "too many pair-intros to {nick:?} — rate limited ({INTRO_MAX_PER_WINDOW} per {INTRO_WINDOW_SECS}s). Try again shortly."
1926 ),
1927 })),
1928 )
1929 .into_response();
1930 }
1931 slot_id
1932 };
1933
1934 let kind = req.event.get("kind").and_then(Value::as_u64).unwrap_or(0);
1937 let type_str = req.event.get("type").and_then(Value::as_str).unwrap_or("");
1938 if !intro_event_allowed(kind, type_str) {
1939 return (
1940 StatusCode::BAD_REQUEST,
1941 Json(json!({
1942 "error": "intro endpoint only accepts kind=1100 pair_drop / agent_card events",
1943 "got_kind": kind,
1944 "got_type": type_str,
1945 })),
1946 )
1947 .into_response();
1948 }
1949
1950 let embedded_card = match req.event.get("body").and_then(|b| b.get("card")) {
1952 Some(c) => c.clone(),
1953 None => {
1954 return (
1955 StatusCode::BAD_REQUEST,
1956 Json(json!({"error": "intro event body must embed 'card' field"})),
1957 )
1958 .into_response();
1959 }
1960 };
1961 if let Err(e) = crate::agent_card::verify_agent_card(&embedded_card) {
1962 return (
1963 StatusCode::BAD_REQUEST,
1964 Json(json!({"error": format!("embedded card signature invalid: {e}")})),
1965 )
1966 .into_response();
1967 }
1968
1969 let body_bytes = match serde_json::to_vec(&req.event) {
1971 Ok(b) => b,
1972 Err(e) => {
1973 return (
1974 StatusCode::BAD_REQUEST,
1975 Json(json!({"error": format!("event not serializable: {e}")})),
1976 )
1977 .into_response();
1978 }
1979 };
1980 if body_bytes.len() > MAX_EVENT_BYTES {
1981 return (
1982 StatusCode::PAYLOAD_TOO_LARGE,
1983 Json(json!({"error": "intro event exceeds 256 KiB", "max_bytes": MAX_EVENT_BYTES})),
1984 )
1985 .into_response();
1986 }
1987 {
1988 let inner = relay.inner.lock().await;
1989 let used = inner.slot_bytes.get(&slot_id).copied().unwrap_or(0);
1990 if used + body_bytes.len() > MAX_SLOT_BYTES {
1991 return (
1992 StatusCode::PAYLOAD_TOO_LARGE,
1993 Json(json!({
1994 "error": "target slot quota exceeded",
1995 "slot_bytes_used": used,
1996 "slot_bytes_max": MAX_SLOT_BYTES,
1997 })),
1998 )
1999 .into_response();
2000 }
2001 }
2002
2003 let event_id = req
2004 .event
2005 .get("event_id")
2006 .and_then(Value::as_str)
2007 .map(str::to_string);
2008
2009 let dup = {
2011 let inner = relay.inner.lock().await;
2012 let slot = inner.slots.get(&slot_id);
2013 if let (Some(eid), Some(slot)) = (&event_id, slot) {
2014 slot.iter()
2015 .any(|e| e.get("event_id").and_then(Value::as_str) == Some(eid))
2016 } else {
2017 false
2018 }
2019 };
2020 if dup {
2021 return (
2022 StatusCode::OK,
2023 Json(json!({"event_id": event_id, "status": "duplicate"})),
2024 )
2025 .into_response();
2026 }
2027
2028 {
2029 let mut inner = relay.inner.lock().await;
2030 let event_size = body_bytes.len();
2031 let slot = inner.slots.entry(slot_id.clone()).or_default();
2032 slot.push(req.event.clone());
2033 *inner.slot_bytes.entry(slot_id.clone()).or_insert(0) += event_size;
2034 }
2035 if let Err(e) = relay.append_event_to_disk(&slot_id, &req.event).await {
2036 return (
2037 StatusCode::INTERNAL_SERVER_ERROR,
2038 Json(json!({"error": format!("persist failed: {e}")})),
2039 )
2040 .into_response();
2041 }
2042 (
2043 StatusCode::CREATED,
2044 Json(json!({"event_id": event_id, "status": "dropped", "to_nick": nick})),
2045 )
2046 .into_response()
2047}
2048
2049async fn well_known_agent_card_a2a(
2065 State(relay): State<Relay>,
2066 Query(q): Query<WellKnownAgentQuery>,
2067) -> impl IntoResponse {
2068 let nick = q.handle.split('@').next().unwrap_or("").to_string();
2069 if nick.is_empty() {
2070 return (
2071 StatusCode::BAD_REQUEST,
2072 Json(json!({"error": "handle missing nick"})),
2073 )
2074 .into_response();
2075 }
2076 let inner = relay.inner.lock().await;
2077 let rec = match inner.handles.get(&nick) {
2078 Some(r) => r.clone(),
2079 None => {
2080 return (
2081 StatusCode::NOT_FOUND,
2082 Json(json!({"error": format!("phyllis: that number's been disconnected — {nick:?} isn't claimed on this switchboard")})),
2083 )
2084 .into_response();
2085 }
2086 };
2087 drop(inner);
2088
2089 let profile = rec.card.get("profile").cloned().unwrap_or(Value::Null);
2090 let description = profile
2091 .get("motto")
2092 .and_then(Value::as_str)
2093 .unwrap_or("")
2094 .to_string();
2095 let display_name = profile
2096 .get("display_name")
2097 .and_then(Value::as_str)
2098 .unwrap_or(&rec.nick)
2099 .to_string();
2100 let relay_url = rec.relay_url.clone().unwrap_or_default();
2101 let endpoint = if !relay_url.is_empty() {
2103 format!(
2104 "{}/v1/handle/intro/{}",
2105 relay_url.trim_end_matches('/'),
2106 rec.nick
2107 )
2108 } else {
2109 format!("/v1/handle/intro/{}", rec.nick)
2110 };
2111 let card_sig = rec.card.get("signature").cloned().unwrap_or(Value::Null);
2112
2113 let a2a_card = json!({
2117 "id": rec.did,
2118 "name": display_name,
2119 "description": description,
2120 "version": "wire/0.5",
2121 "endpoint": endpoint,
2122 "provider": {
2123 "name": "wire",
2124 "url": "https://github.com/SlanchaAi/wire"
2125 },
2126 "capabilities": {
2127 "streaming": false,
2128 "pushNotifications": false,
2129 "extendedAgentCard": true
2130 },
2131 "securitySchemes": {
2132 "ed25519-event-sig": {
2133 "type": "signature",
2134 "alg": "EdDSA",
2135 "description": "Wire-style signed events (kind=1100 pair_drop for intro; verify against embedded card pubkey)."
2136 }
2137 },
2138 "security": [{"ed25519-event-sig": []}],
2139 "skills": [],
2140 "extensions": [{
2141 "uri": "https://slancha.ai/wire/ext/v0.5",
2145 "description": "Wire-native fields: full signed agent-card, profile blob, DID, slot_id, mailbox relay coords.",
2146 "required": false,
2147 "params": {
2148 "did": rec.did,
2149 "handle": rec.nick,
2150 "slot_id": rec.slot_id,
2151 "relay_url": rec.relay_url,
2152 "card": rec.card,
2153 "profile": profile,
2154 "claimed_at": rec.claimed_at,
2155 }
2156 }],
2157 "signature": card_sig,
2158 });
2159 (StatusCode::OK, Json(a2a_card)).into_response()
2160}
2161
2162async fn well_known_agent(
2163 State(relay): State<Relay>,
2164 Query(q): Query<WellKnownAgentQuery>,
2165) -> impl IntoResponse {
2166 let nick = q.handle.split('@').next().unwrap_or("").to_string();
2167 if nick.is_empty() {
2168 return (
2169 StatusCode::BAD_REQUEST,
2170 Json(json!({"error": "handle missing nick"})),
2171 )
2172 .into_response();
2173 }
2174 let inner = relay.inner.lock().await;
2175 match inner.handles.get(&nick) {
2176 Some(rec) => (
2177 StatusCode::OK,
2178 Json(json!({
2179 "nick": rec.nick,
2180 "did": rec.did,
2181 "card": rec.card,
2182 "slot_id": rec.slot_id,
2183 "relay_url": rec.relay_url,
2184 "claimed_at": rec.claimed_at,
2185 })),
2186 )
2187 .into_response(),
2188 None => (
2189 StatusCode::NOT_FOUND,
2190 Json(json!({"error": format!("phyllis: that number's been disconnected — {nick:?} isn't claimed on this switchboard")})),
2191 )
2192 .into_response(),
2193 }
2194}
2195
2196async fn list_events(
2197 State(relay): State<Relay>,
2198 Path(slot_id): Path<String>,
2199 Query(q): Query<ListEventsQuery>,
2200 headers: HeaderMap,
2201) -> impl IntoResponse {
2202 if let Err(resp) = check_token(&relay, &headers, &slot_id).await {
2203 return resp;
2204 }
2205 let limit = q.limit.unwrap_or(100).min(1000);
2206 let now_unix = std::time::SystemTime::now()
2210 .duration_since(std::time::UNIX_EPOCH)
2211 .map(|d| d.as_secs())
2212 .unwrap_or(0);
2213 let mut inner = relay.inner.lock().await;
2214 inner.last_pull_at_unix.insert(slot_id.clone(), now_unix);
2215 let slice: Vec<Value> = match inner.slots.get(&slot_id) {
2222 Some(events) => {
2223 let start = match q.since {
2224 Some(ref eid) => events
2225 .iter()
2226 .position(|e| e.get("event_id").and_then(Value::as_str) == Some(eid.as_str()))
2227 .map(|i| i + 1)
2228 .unwrap_or(0),
2229 None => 0,
2230 };
2231 let end = (start + limit).min(events.len());
2232 events[start..end].to_vec()
2233 }
2234 None => Vec::new(),
2235 };
2236 drop(inner); (StatusCode::OK, Json(slice)).into_response()
2238}
2239
2240async fn slot_state(
2246 State(relay): State<Relay>,
2247 Path(slot_id): Path<String>,
2248 headers: HeaderMap,
2249) -> impl IntoResponse {
2250 if let Err(resp) = check_token(&relay, &headers, &slot_id).await {
2251 return resp;
2252 }
2253 let inner = relay.inner.lock().await;
2254 let event_count = inner.slots.get(&slot_id).map(|v| v.len()).unwrap_or(0);
2255 let last_pull_at_unix = inner.last_pull_at_unix.get(&slot_id).copied();
2256 let responder_health = inner.responder_health.get(&slot_id).cloned();
2257 (
2258 StatusCode::OK,
2259 Json(json!({
2260 "slot_id": slot_id,
2261 "event_count": event_count,
2262 "last_pull_at_unix": last_pull_at_unix,
2263 "responder_health": responder_health,
2264 })),
2265 )
2266 .into_response()
2267}
2268
2269async fn responder_health_set(
2270 State(relay): State<Relay>,
2271 Path(slot_id): Path<String>,
2272 headers: HeaderMap,
2273 Json(record): Json<ResponderHealthRecord>,
2274) -> impl IntoResponse {
2275 if !is_valid_slot_id(&slot_id) {
2280 return (
2281 StatusCode::BAD_REQUEST,
2282 Json(json!({"error": "invalid slot_id"})),
2283 )
2284 .into_response();
2285 }
2286 if let Err(resp) = check_token(&relay, &headers, &slot_id).await {
2287 return resp;
2288 }
2289 let path = relay
2290 .state_dir
2291 .join("responder-health")
2292 .join(format!("{slot_id}.json"));
2293 let body = match serde_json::to_vec_pretty(&record) {
2294 Ok(b) => b,
2295 Err(e) => {
2296 return (
2297 StatusCode::INTERNAL_SERVER_ERROR,
2298 Json(json!({"error": format!("serialize failed: {e}")})),
2299 )
2300 .into_response();
2301 }
2302 };
2303 if let Err(e) = tokio::fs::write(&path, body).await {
2304 return (
2305 StatusCode::INTERNAL_SERVER_ERROR,
2306 Json(json!({"error": format!("persist failed: {e}")})),
2307 )
2308 .into_response();
2309 }
2310 {
2311 let mut inner = relay.inner.lock().await;
2312 inner
2313 .responder_health
2314 .insert(slot_id.clone(), record.clone());
2315 }
2316 (StatusCode::OK, Json(record)).into_response()
2317}
2318
2319async fn check_token(
2320 relay: &Relay,
2321 headers: &HeaderMap,
2322 slot_id: &str,
2323) -> std::result::Result<(), axum::response::Response> {
2324 let auth = headers
2325 .get(AUTHORIZATION)
2326 .and_then(|h| h.to_str().ok())
2327 .and_then(|s| s.strip_prefix("Bearer "))
2328 .map(str::to_string);
2329 let presented = match auth {
2330 Some(t) => t,
2331 None => {
2332 return Err((
2333 StatusCode::UNAUTHORIZED,
2334 Json(json!({"error": "missing Bearer token"})),
2335 )
2336 .into_response());
2337 }
2338 };
2339 let inner = relay.inner.lock().await;
2340 let expected = match inner.tokens.get(slot_id) {
2341 Some(t) => t.clone(),
2342 None => {
2343 return Err((
2344 StatusCode::NOT_FOUND,
2345 Json(json!({"error": "unknown slot"})),
2346 )
2347 .into_response());
2348 }
2349 };
2350 drop(inner);
2351 if !constant_time_eq(presented.as_bytes(), expected.as_bytes()) {
2352 return Err((StatusCode::FORBIDDEN, Json(json!({"error": "bad token"}))).into_response());
2353 }
2354 Ok(())
2355}
2356
2357fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
2358 if a.len() != b.len() {
2359 return false;
2360 }
2361 let mut acc = 0u8;
2362 for (x, y) in a.iter().zip(b.iter()) {
2363 acc |= x ^ y;
2364 }
2365 acc == 0
2366}
2367
2368fn is_valid_slot_id(s: &str) -> bool {
2369 s.len() == 32
2370 && s.bytes()
2371 .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
2372}
2373
2374fn intro_event_allowed(kind: u64, type_str: &str) -> bool {
2382 kind == 1100 && (type_str == "pair_drop" || type_str == "agent_card")
2383}
2384
2385fn is_valid_public_relay_url(u: &str) -> bool {
2393 let rest = match u
2394 .strip_prefix("https://")
2395 .or_else(|| u.strip_prefix("http://"))
2396 {
2397 Some(r) => r,
2398 None => return false,
2399 };
2400 let authority = rest.split('/').next().unwrap_or("");
2402 if authority.is_empty() {
2403 return false;
2404 }
2405 if authority.contains('@') {
2407 return false;
2408 }
2409 if u.chars().any(|c| c.is_whitespace() || c.is_control()) {
2411 return false;
2412 }
2413 let host = authority.split(':').next().unwrap_or("");
2415 !host.is_empty()
2416}
2417
2418fn random_hex(n_bytes: usize) -> String {
2419 let mut buf = vec![0u8; n_bytes];
2420 rand::thread_rng().fill_bytes(&mut buf);
2421 hex::encode(buf)
2422}
2423
2424pub async fn serve(bind: &str, state_dir: PathBuf) -> Result<()> {
2426 serve_with_mode(bind, state_dir, ServerMode::default()).await
2427}
2428
2429pub async fn serve_with_mode(bind: &str, state_dir: PathBuf, mode: ServerMode) -> Result<()> {
2433 let relay = Relay::new(state_dir).await?;
2434 relay.spawn_pair_sweeper();
2435 relay.spawn_counter_persister();
2436 let app = relay.clone().router_with_mode(mode);
2437 let listener = tokio::net::TcpListener::bind(bind)
2438 .await
2439 .with_context(|| format!("binding {bind}"))?;
2440 if mode.local_only {
2441 eprintln!(
2442 "wire relay-server (LOCAL-ONLY) listening on {bind} — phonebook + well-known endpoints disabled"
2443 );
2444 } else {
2445 eprintln!("wire relay-server listening on {bind}");
2446 }
2447 let shutdown_relay = relay.clone();
2448 axum::serve(listener, app)
2449 .with_graceful_shutdown(async move {
2450 let _ = tokio::signal::ctrl_c().await;
2451 eprintln!("\nshutting down — final counter snapshot");
2452 if let Err(e) = shutdown_relay.persist_counters().await {
2453 eprintln!("final counter persist failed: {e}");
2454 }
2455 })
2456 .await?;
2457 Ok(())
2458}
2459
2460#[cfg(unix)]
2474pub async fn serve_uds(socket_path: PathBuf, state_dir: PathBuf) -> Result<()> {
2475 use hyper::server::conn::http1;
2476 use hyper_util::rt::TokioIo;
2477 use tower_service::Service;
2478
2479 if socket_path.exists() {
2481 std::fs::remove_file(&socket_path)
2482 .with_context(|| format!("removing stale socket at {socket_path:?}"))?;
2483 }
2484 if let Some(parent) = socket_path.parent() {
2485 std::fs::create_dir_all(parent)
2486 .with_context(|| format!("creating socket parent {parent:?}"))?;
2487 }
2488 let relay = Relay::new(state_dir).await?;
2489 relay.spawn_pair_sweeper();
2490 relay.spawn_counter_persister();
2491 let app: axum::Router = relay
2492 .clone()
2493 .router_with_mode(ServerMode { local_only: true });
2494 let listener = tokio::net::UnixListener::bind(&socket_path)
2495 .with_context(|| format!("binding UDS at {socket_path:?}"))?;
2496
2497 use std::os::unix::fs::PermissionsExt;
2500 if let Err(e) = std::fs::set_permissions(&socket_path, std::fs::Permissions::from_mode(0o600)) {
2501 eprintln!(
2502 "wire relay-server (UDS): chmod 0600 on {socket_path:?} failed: {e} — \
2503 socket may be accessible to other uids. Investigate."
2504 );
2505 }
2506 eprintln!(
2507 "wire relay-server (UDS) listening on unix://{} — same-host, owner-uid only",
2508 socket_path.display()
2509 );
2510
2511 let shutdown_relay = relay.clone();
2514 let socket_path_for_cleanup = socket_path.clone();
2515 let mut make_service = app.into_make_service();
2516
2517 let serve_loop = async {
2518 loop {
2519 let (stream, _peer_addr) = match listener.accept().await {
2520 Ok(p) => p,
2521 Err(e) => {
2522 eprintln!("wire relay-server (UDS): accept failed: {e}");
2523 continue;
2524 }
2525 };
2526 let tower_service = match make_service.call(&stream).await {
2527 Ok(s) => s,
2528 Err(infallible) => match infallible {},
2529 };
2530 let io = TokioIo::new(stream);
2531 let hyper_service =
2532 hyper::service::service_fn(move |req: hyper::Request<hyper::body::Incoming>| {
2533 let mut svc = tower_service.clone();
2534 async move { Service::call(&mut svc, req).await }
2535 });
2536 tokio::task::spawn(async move {
2537 if let Err(e) = http1::Builder::new()
2538 .serve_connection(io, hyper_service)
2539 .await
2540 {
2541 if !e.is_incomplete_message() {
2543 eprintln!("wire relay-server (UDS): conn error: {e}");
2544 }
2545 }
2546 });
2547 }
2548 };
2549
2550 let shutdown = async {
2551 let _ = tokio::signal::ctrl_c().await;
2552 eprintln!("\nshutting down — final counter snapshot");
2553 if let Err(e) = shutdown_relay.persist_counters().await {
2554 eprintln!("final counter persist failed: {e}");
2555 }
2556 let _ = std::fs::remove_file(&socket_path_for_cleanup);
2557 };
2558
2559 tokio::select! {
2560 _ = serve_loop => {},
2561 _ = shutdown => {},
2562 };
2563 Ok(())
2564}
2565
2566#[cfg(not(unix))]
2567pub async fn serve_uds(_socket_path: PathBuf, _state_dir: PathBuf) -> Result<()> {
2568 Err(anyhow::anyhow!(
2569 "UDS transport is Unix-only; Windows falls back to loopback HTTP. \
2570 Use `wire relay-server --bind 127.0.0.1:8771 --local-only` on Windows."
2571 ))
2572}
2573
2574#[derive(Debug, Clone, Copy, Default)]
2579pub struct ServerMode {
2580 pub local_only: bool,
2585}
2586
2587#[cfg(test)]
2588mod tests {
2589 use super::*;
2590
2591 #[test]
2592 fn constant_time_eq_basic() {
2593 assert!(constant_time_eq(b"abc", b"abc"));
2594 assert!(!constant_time_eq(b"abc", b"abd"));
2595 assert!(!constant_time_eq(b"abc", b"abcd")); }
2597
2598 #[test]
2599 fn at_capacity_is_inclusive_at_the_ceiling() {
2600 assert!(!at_capacity(0, 5));
2602 assert!(!at_capacity(4, 5));
2603 assert!(at_capacity(5, 5), "at the ceiling, refuse");
2604 assert!(at_capacity(6, 5));
2605 }
2606
2607 #[test]
2608 fn intro_rate_gate_allows_up_to_max_then_429s_then_recovers() {
2609 let mut times: Vec<u64> = Vec::new();
2612 let t0 = 1_000_000u64;
2613 for i in 0..5 {
2615 assert!(
2616 record_intro_within_rate(&mut times, t0, 300, 5),
2617 "intro {i} within limit must pass"
2618 );
2619 }
2620 assert!(!record_intro_within_rate(&mut times, t0 + 10, 300, 5));
2622 assert_eq!(times.len(), 5, "rejected intro must not be recorded");
2623 assert!(!record_intro_within_rate(&mut times, t0 + 299, 300, 5));
2625 assert!(record_intro_within_rate(&mut times, t0 + 301, 300, 5));
2627 assert_eq!(
2628 times.len(),
2629 1,
2630 "stale entries pruned, only the new one remains"
2631 );
2632 }
2633
2634 #[test]
2635 fn valid_public_relay_url_accepts_clean_https_and_ports() {
2636 assert!(is_valid_public_relay_url("https://wireup.net"));
2637 assert!(is_valid_public_relay_url("https://wireup.net/"));
2638 assert!(is_valid_public_relay_url("http://127.0.0.1:8771"));
2639 assert!(is_valid_public_relay_url(
2640 "https://relay.example.com:443/path"
2641 ));
2642 }
2643
2644 #[test]
2645 fn valid_public_relay_url_rejects_userinfo_and_bad_schemes() {
2646 assert!(!is_valid_public_relay_url(
2648 "https://raven-kettle@wireup.net"
2649 ));
2650 assert!(!is_valid_public_relay_url("https://user:pass@host"));
2651 assert!(!is_valid_public_relay_url("javascript:alert(1)"));
2653 assert!(!is_valid_public_relay_url("file:///etc/passwd"));
2654 assert!(!is_valid_public_relay_url("wireup.net")); assert!(!is_valid_public_relay_url("https://"));
2657 assert!(!is_valid_public_relay_url("https:///path"));
2658 assert!(!is_valid_public_relay_url("https://wireup.net\n"));
2660 assert!(!is_valid_public_relay_url("https://wire up.net"));
2661 }
2662
2663 #[test]
2664 fn random_hex_length() {
2665 let s = random_hex(16);
2666 assert_eq!(s.len(), 32); assert!(s.chars().all(|c| c.is_ascii_hexdigit()));
2668 }
2669
2670 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2671 async fn sweep_intro_times_drops_aged_nicks_off_the_hot_path() {
2672 let dir = std::env::temp_dir().join(format!("wire-introsweep-{}", random_hex(8)));
2673 let _ = std::fs::remove_dir_all(&dir);
2674 let relay = Relay::new(dir.clone()).await.unwrap();
2675 let now = unix_now();
2676 {
2677 let mut inner = relay.inner.lock().await;
2678 inner.intro_times.insert("fresh".into(), vec![now]); inner
2680 .intro_times
2681 .insert("stale".into(), vec![now - INTRO_WINDOW_SECS - 1]); assert_eq!(inner.intro_times.len(), 2);
2683 }
2684 relay.sweep_intro_times().await;
2685 {
2686 let inner = relay.inner.lock().await;
2687 assert!(inner.intro_times.contains_key("fresh"), "fresh nick kept");
2688 assert!(!inner.intro_times.contains_key("stale"), "aged nick swept");
2689 }
2690 let _ = std::fs::remove_dir_all(&dir);
2691 }
2692
2693 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2694 async fn pair_slot_evicts_when_idle_past_ttl() {
2695 let dir = std::env::temp_dir().join(format!("wire-evict-{}", random_hex(8)));
2696 let _ = std::fs::remove_dir_all(&dir);
2697 let relay = Relay::new(dir.clone()).await.unwrap();
2698
2699 {
2701 let mut inner = relay.inner.lock().await;
2702 inner
2703 .pair_lookup
2704 .insert("hash-A".to_string(), "id-A".to_string());
2705 inner.pair_slots.insert(
2706 "id-A".to_string(),
2707 PairSlot {
2708 last_touched: std::time::Instant::now()
2709 - std::time::Duration::from_secs(PAIR_SLOT_TTL_SECS + 60),
2710 ..PairSlot::default()
2711 },
2712 );
2713
2714 inner
2716 .pair_lookup
2717 .insert("hash-B".to_string(), "id-B".to_string());
2718 inner
2719 .pair_slots
2720 .insert("id-B".to_string(), PairSlot::default());
2721
2722 assert_eq!(inner.pair_slots.len(), 2);
2723 assert_eq!(inner.pair_lookup.len(), 2);
2724 }
2725
2726 relay.evict_expired_pair_slots().await;
2727
2728 let inner = relay.inner.lock().await;
2729 assert_eq!(
2730 inner.pair_slots.len(),
2731 1,
2732 "expired slot should have been evicted"
2733 );
2734 assert!(inner.pair_slots.contains_key("id-B"));
2735 assert_eq!(inner.pair_lookup.len(), 1);
2736 assert!(inner.pair_lookup.contains_key("hash-B"));
2737 let _ = std::fs::remove_dir_all(&dir);
2738 }
2739
2740 #[test]
2741 fn slot_id_validator_accepts_only_lowercase_32hex() {
2742 assert!(is_valid_slot_id("0123456789abcdef0123456789abcdef"));
2743 assert!(is_valid_slot_id(&random_hex(16)));
2744 assert!(!is_valid_slot_id("abc"));
2746 assert!(!is_valid_slot_id("0123456789abcdef0123456789abcde")); assert!(!is_valid_slot_id("0123456789abcdef0123456789abcdef0")); assert!(!is_valid_slot_id("0123456789ABCDEF0123456789abcdef"));
2750 assert!(!is_valid_slot_id("../etc/passwd0123456789abcdef0000"));
2752 assert!(!is_valid_slot_id("..%2Fetc%2Fpasswd00000000000000000"));
2753 assert!(!is_valid_slot_id("/absolute/path/that/looks/like/key"));
2754 assert!(!is_valid_slot_id(
2756 "0123456789abcdef\0\x31\x32\x33456789abcdef"
2757 ));
2758 }
2759
2760 #[test]
2761 fn intro_filter_accepts_only_kind_1100_pair_drop_or_agent_card() {
2762 assert!(intro_event_allowed(1100, "pair_drop"));
2764 assert!(intro_event_allowed(1100, "agent_card"));
2765
2766 assert!(!intro_event_allowed(1100, "decision"));
2770 assert!(!intro_event_allowed(1100, ""));
2771 assert!(!intro_event_allowed(1100, "trust_revoke_key"));
2772 assert!(!intro_event_allowed(1, "pair_drop"));
2774 assert!(!intro_event_allowed(1101, "agent_card"));
2775 assert!(!intro_event_allowed(0, "pair_drop"));
2776
2777 assert!(!intro_event_allowed(1, "decision"));
2779 }
2780
2781 #[test]
2782 fn stream_subscriber_ceiling_is_inclusive() {
2783 assert!(!at_capacity(0, MAX_STREAMS_PER_SLOT));
2785 assert!(!at_capacity(MAX_STREAMS_PER_SLOT - 1, MAX_STREAMS_PER_SLOT));
2786 assert!(at_capacity(MAX_STREAMS_PER_SLOT, MAX_STREAMS_PER_SLOT));
2787 assert!(at_capacity(MAX_STREAMS_PER_SLOT + 1, MAX_STREAMS_PER_SLOT));
2788 }
2789
2790 #[test]
2791 fn retain_live_subscribers_drops_disconnected() {
2792 let (live_tx, _live_rx) = tokio::sync::mpsc::unbounded_channel::<Value>();
2794 let (dead_tx, dead_rx) = tokio::sync::mpsc::unbounded_channel::<Value>();
2795 drop(dead_rx); let mut subs = vec![live_tx, dead_tx];
2797 retain_live_subscribers(&mut subs);
2798 assert_eq!(subs.len(), 1, "the disconnected subscriber is pruned");
2799 assert!(!subs[0].is_closed(), "the survivor is the live sender");
2800 }
2801
2802 #[test]
2803 fn evict_stale_intro_nicks_drops_only_fully_aged_entries() {
2804 let now = 10_000u64;
2805 let window = INTRO_WINDOW_SECS;
2806 let mut m: HashMap<String, Vec<u64>> = HashMap::new();
2807 m.insert("fresh".into(), vec![now - 1]); m.insert("stale".into(), vec![now - window - 1]); m.insert("mixed".into(), vec![now - window - 5, now - 2]); m.insert("empty".into(), vec![]); evict_stale_intro_nicks(&mut m, now, window);
2812 assert!(m.contains_key("fresh"));
2813 assert!(m.contains_key("mixed"));
2814 assert!(!m.contains_key("stale"));
2815 assert!(!m.contains_key("empty"));
2816 }
2817}