1use anyhow::Result;
45use serde::{Deserialize, Serialize};
46use serde_json::Value;
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "lowercase")]
51pub enum EndpointScope {
52 Federation,
54 Local,
56 Lan,
64 Uds,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct Endpoint {
83 pub relay_url: String,
84 pub slot_id: String,
85 pub slot_token: String,
86 pub scope: EndpointScope,
87}
88
89impl Endpoint {
90 pub fn federation(relay_url: String, slot_id: String, slot_token: String) -> Self {
91 Self {
92 relay_url,
93 slot_id,
94 slot_token,
95 scope: EndpointScope::Federation,
96 }
97 }
98
99 pub fn local(relay_url: String, slot_id: String, slot_token: String) -> Self {
100 Self {
101 relay_url,
102 slot_id,
103 slot_token,
104 scope: EndpointScope::Local,
105 }
106 }
107
108 pub fn lan(relay_url: String, slot_id: String, slot_token: String) -> Self {
110 Self {
111 relay_url,
112 slot_id,
113 slot_token,
114 scope: EndpointScope::Lan,
115 }
116 }
117
118 pub fn uds(relay_url: String, slot_id: String, slot_token: String) -> Self {
123 Self {
124 relay_url,
125 slot_id,
126 slot_token,
127 scope: EndpointScope::Uds,
128 }
129 }
130}
131
132pub fn peer_endpoints_in_priority_order(relay_state: &Value, peer_handle: &str) -> Vec<Endpoint> {
145 let our_local_relay_url = relay_state
146 .get("self")
147 .and_then(|s| s.get("endpoints"))
148 .and_then(Value::as_array)
149 .and_then(|arr| {
150 arr.iter()
151 .find(|e| e.get("scope").and_then(Value::as_str) == Some("local"))
152 .and_then(|e| e.get("relay_url"))
153 .and_then(Value::as_str)
154 .map(str::to_string)
155 });
156
157 let peer = match relay_state.get("peers").and_then(|p| p.get(peer_handle)) {
158 Some(p) => p,
159 None => return Vec::new(),
160 };
161
162 let mut all: Vec<Endpoint> = Vec::new();
163
164 if let Some(arr) = peer.get("endpoints").and_then(Value::as_array) {
165 for ep in arr {
166 if let Ok(parsed) = serde_json::from_value::<Endpoint>(ep.clone()) {
167 all.push(parsed);
168 }
169 }
170 }
171
172 if all.is_empty() {
176 let relay_url = peer.get("relay_url").and_then(Value::as_str).unwrap_or("");
177 let slot_id = peer.get("slot_id").and_then(Value::as_str).unwrap_or("");
178 let slot_token = peer.get("slot_token").and_then(Value::as_str).unwrap_or("");
179 if !relay_url.is_empty() && !slot_id.is_empty() && !slot_token.is_empty() {
180 all.push(Endpoint::federation(
181 relay_url.to_string(),
182 slot_id.to_string(),
183 slot_token.to_string(),
184 ));
185 }
186 }
187
188 let our_local = our_local_relay_url.clone();
202 all.sort_by_key(|ep| match (ep.scope, &our_local) {
203 (EndpointScope::Uds, _) => 0,
204 (EndpointScope::Local, Some(our)) if &ep.relay_url == our => 1,
205 (EndpointScope::Lan, _) => 2,
206 (EndpointScope::Federation, _) => 3,
207 _ => 4,
208 });
209 all.retain(|ep| match (ep.scope, &our_local) {
215 (EndpointScope::Local, None) => false,
216 (EndpointScope::Local, Some(our)) => &ep.relay_url == our,
217 (EndpointScope::Lan, _) => true,
218 (EndpointScope::Uds, _) => true,
219 (EndpointScope::Federation, _) => true,
220 });
221 all
222}
223
224pub fn self_endpoints(relay_state: &Value) -> Vec<Endpoint> {
228 let self_state = match relay_state.get("self") {
229 Some(s) if !s.is_null() => s,
230 _ => return Vec::new(),
231 };
232 let mut all: Vec<Endpoint> = Vec::new();
233 if let Some(arr) = self_state.get("endpoints").and_then(Value::as_array) {
234 for ep in arr {
235 if let Ok(parsed) = serde_json::from_value::<Endpoint>(ep.clone()) {
236 all.push(parsed);
237 }
238 }
239 }
240 if all.is_empty() {
241 let relay_url = self_state
246 .get("relay_url")
247 .and_then(Value::as_str)
248 .unwrap_or("");
249 let slot_id = self_state
250 .get("slot_id")
251 .and_then(Value::as_str)
252 .unwrap_or("");
253 let slot_token = self_state
254 .get("slot_token")
255 .and_then(Value::as_str)
256 .unwrap_or("");
257 if !relay_url.is_empty() && !slot_id.is_empty() {
258 all.push(Endpoint::federation(
259 relay_url.to_string(),
260 slot_id.to_string(),
261 slot_token.to_string(),
262 ));
263 }
264 }
265 all
266}
267
268pub fn self_primary_endpoint(relay_state: &Value) -> Option<Endpoint> {
280 self_endpoints(relay_state).into_iter().next()
281}
282
283pub fn pin_peer_endpoints(
289 relay_state: &mut Value,
290 peer_handle: &str,
291 endpoints: &[Endpoint],
292) -> Result<()> {
293 let fed = endpoints
297 .iter()
298 .find(|e| e.scope == EndpointScope::Federation);
299 let peers = relay_state
300 .as_object_mut()
301 .map(|m| {
302 m.entry("peers")
303 .or_insert_with(|| Value::Object(Default::default()))
304 })
305 .ok_or_else(|| anyhow::anyhow!("relay_state.json root is not an object"))?
306 .as_object_mut()
307 .ok_or_else(|| anyhow::anyhow!("relay_state.peers is not an object"))?;
308 let preserved: serde_json::Map<String, Value> = peers
319 .get(peer_handle)
320 .and_then(Value::as_object)
321 .map(|m| {
322 m.iter()
323 .filter(|(k, _)| {
324 matches!(
325 k.as_str(),
326 "bilateral_completed_at" | "persona" | "profile" | "first_seen_at"
327 )
328 })
329 .map(|(k, v)| (k.clone(), v.clone()))
330 .collect()
331 })
332 .unwrap_or_default();
333 let mut entry = preserved;
334 if let Some(f) = fed {
335 entry.insert("relay_url".into(), Value::String(f.relay_url.clone()));
336 entry.insert("slot_id".into(), Value::String(f.slot_id.clone()));
337 entry.insert("slot_token".into(), Value::String(f.slot_token.clone()));
338 } else if let Some(lan_ep) = endpoints.iter().find(|e| e.scope == EndpointScope::Lan) {
339 entry.insert("relay_url".into(), Value::String(lan_ep.relay_url.clone()));
340 entry.insert("slot_id".into(), Value::String(lan_ep.slot_id.clone()));
341 entry.insert(
342 "slot_token".into(),
343 Value::String(lan_ep.slot_token.clone()),
344 );
345 } else if let Some(loc) = endpoints.iter().find(|e| e.scope == EndpointScope::Local) {
346 entry.insert("relay_url".into(), Value::String(loc.relay_url.clone()));
350 entry.insert("slot_id".into(), Value::String(loc.slot_id.clone()));
351 entry.insert("slot_token".into(), Value::String(loc.slot_token.clone()));
352 }
353 entry.insert("endpoints".into(), serde_json::to_value(endpoints)?);
354 peers.insert(peer_handle.to_string(), Value::Object(entry));
355 Ok(())
356}
357
358pub fn infer_scope_from_url(url: &str) -> EndpointScope {
363 if url.starts_with("unix://") {
364 return EndpointScope::Uds;
365 }
366 let host = url
367 .trim_start_matches("http://")
368 .trim_start_matches("https://")
369 .split('/')
370 .next()
371 .unwrap_or("")
372 .split(':')
373 .next()
374 .unwrap_or("");
375 if host == "127.0.0.1" || host == "localhost" || host == "::1" {
376 EndpointScope::Local
377 } else {
378 EndpointScope::Federation
379 }
380}
381
382fn build_self_value(eps: &[Endpoint]) -> Value {
387 let legacy = eps
388 .iter()
389 .find(|e| e.scope == EndpointScope::Federation)
390 .or_else(|| eps.first());
391 let mut self_obj = serde_json::Map::new();
392 if let Some(l) = legacy {
393 self_obj.insert("relay_url".into(), Value::String(l.relay_url.clone()));
394 self_obj.insert("slot_id".into(), Value::String(l.slot_id.clone()));
395 self_obj.insert("slot_token".into(), Value::String(l.slot_token.clone()));
396 }
397 self_obj.insert(
398 "endpoints".into(),
399 serde_json::to_value(eps).unwrap_or(Value::Null),
400 );
401 Value::Object(self_obj)
402}
403
404pub fn upsert_self_endpoint(relay_state: &mut Value, ep: Endpoint) {
411 let mut eps = self_endpoints(relay_state);
412 eps.retain(|e| e.relay_url != ep.relay_url);
413 eps.push(ep);
414 relay_state["self"] = build_self_value(&eps);
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420 use serde_json::json;
421
422 #[test]
423 fn infer_scope_classifies_loopback_unix_and_federation() {
424 assert_eq!(
425 infer_scope_from_url("http://127.0.0.1:8771"),
426 EndpointScope::Local
427 );
428 assert_eq!(
429 infer_scope_from_url("http://localhost:8771"),
430 EndpointScope::Local
431 );
432 assert_eq!(
433 infer_scope_from_url("unix:///tmp/wire.sock"),
434 EndpointScope::Uds
435 );
436 assert_eq!(
437 infer_scope_from_url("https://wireup.net"),
438 EndpointScope::Federation
439 );
440 }
441
442 #[test]
443 fn upsert_self_endpoint_is_additive_then_updates_in_place() {
444 let mut state = json!({});
445 upsert_self_endpoint(
446 &mut state,
447 Endpoint::federation("https://wireup.net".into(), "fed1".into(), "ft".into()),
448 );
449 upsert_self_endpoint(
450 &mut state,
451 Endpoint::local("http://127.0.0.1:8771".into(), "loc1".into(), "lt".into()),
452 );
453 assert_eq!(self_endpoints(&state).len(), 2);
455 assert_eq!(state["self"]["relay_url"], "https://wireup.net");
457 upsert_self_endpoint(
459 &mut state,
460 Endpoint::local("http://127.0.0.1:8771".into(), "loc2".into(), "lt2".into()),
461 );
462 let eps = self_endpoints(&state);
463 assert_eq!(eps.len(), 2, "same-relay rebind replaces, not appends");
464 let loc = eps
465 .iter()
466 .find(|e| e.scope == EndpointScope::Local)
467 .unwrap();
468 assert_eq!(loc.slot_id, "loc2", "local slot updated in place");
469 }
470
471 #[test]
472 fn peer_endpoints_back_compat_falls_back_to_legacy_fields() {
473 let state = json!({
474 "peers": {
475 "alice": {
476 "relay_url": "https://wireup.net",
477 "slot_id": "abc",
478 "slot_token": "tok"
479 }
480 }
481 });
482 let eps = peer_endpoints_in_priority_order(&state, "alice");
483 assert_eq!(eps.len(), 1);
484 assert_eq!(eps[0].relay_url, "https://wireup.net");
485 assert_eq!(eps[0].scope, EndpointScope::Federation);
486 }
487
488 #[test]
489 fn peer_endpoints_lan_beats_federation() {
490 let state = json!({
495 "self": {
496 "endpoints": [
497 {"relay_url": "http://127.0.0.1:8771", "slot_id": "self-loop", "slot_token": "t1", "scope": "local"},
498 {"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t2", "scope": "federation"}
499 ]
500 },
501 "peers": {
502 "alice": {
503 "endpoints": [
504 {"relay_url": "https://wireup.net", "slot_id": "a-fed", "slot_token": "ta-f", "scope": "federation"},
505 {"relay_url": "http://192.168.1.50:8771", "slot_id": "a-lan", "slot_token": "ta-l", "scope": "lan"},
506 {"relay_url": "http://127.0.0.1:8771", "slot_id": "a-loop", "slot_token": "ta-loop", "scope": "local"}
507 ]
508 }
509 }
510 });
511 let eps = peer_endpoints_in_priority_order(&state, "alice");
512 assert_eq!(
513 eps.len(),
514 3,
515 "Local(matched) + Lan + Federation all reachable"
516 );
517 assert_eq!(
518 eps[0].scope,
519 EndpointScope::Local,
520 "loopback wins (same-machine)"
521 );
522 assert_eq!(
523 eps[1].scope,
524 EndpointScope::Lan,
525 "Lan second (same-network)"
526 );
527 assert_eq!(
528 eps[2].scope,
529 EndpointScope::Federation,
530 "Federation last (anywhere)"
531 );
532 }
533
534 #[test]
535 fn peer_endpoints_lan_kept_when_self_has_no_local() {
536 let state = json!({
540 "self": {
541 "endpoints": [
542 {"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t1", "scope": "federation"}
543 ]
544 },
545 "peers": {
546 "alice": {
547 "endpoints": [
548 {"relay_url": "https://wireup.net", "slot_id": "a-fed", "slot_token": "ta-f", "scope": "federation"},
549 {"relay_url": "http://192.168.1.50:8771", "slot_id": "a-lan", "slot_token": "ta-l", "scope": "lan"}
550 ]
551 }
552 }
553 });
554 let eps = peer_endpoints_in_priority_order(&state, "alice");
555 assert_eq!(eps.len(), 2);
556 assert_eq!(
557 eps[0].scope,
558 EndpointScope::Lan,
559 "Lan preferred over Federation"
560 );
561 assert_eq!(eps[1].scope, EndpointScope::Federation);
562 }
563
564 #[test]
565 fn pin_peer_endpoints_uses_lan_as_legacy_when_no_federation() {
566 let mut state = json!({});
571 let endpoints = vec![
572 Endpoint::lan(
573 "http://192.168.1.50:8771".to_string(),
574 "lan-slot".to_string(),
575 "lan-tok".to_string(),
576 ),
577 Endpoint::local(
578 "http://127.0.0.1:8771".to_string(),
579 "loop-slot".to_string(),
580 "loop-tok".to_string(),
581 ),
582 ];
583 pin_peer_endpoints(&mut state, "alice", &endpoints).unwrap();
584 let alice = &state["peers"]["alice"];
585 assert_eq!(
586 alice["relay_url"], "http://192.168.1.50:8771",
587 "LAN wins legacy fields"
588 );
589 assert_eq!(alice["slot_id"], "lan-slot");
590 }
591
592 #[test]
593 fn peer_endpoints_orders_local_first_when_self_has_matching_local() {
594 let state = json!({
595 "self": {
596 "endpoints": [
597 {"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t1", "scope": "federation"},
598 {"relay_url": "http://127.0.0.1:8771", "slot_id": "self-loop", "slot_token": "t2", "scope": "local"}
599 ]
600 },
601 "peers": {
602 "alice": {
603 "endpoints": [
604 {"relay_url": "https://wireup.net", "slot_id": "a-fed", "slot_token": "ta1", "scope": "federation"},
605 {"relay_url": "http://127.0.0.1:8771", "slot_id": "a-loop", "slot_token": "ta2", "scope": "local"}
606 ]
607 }
608 }
609 });
610 let eps = peer_endpoints_in_priority_order(&state, "alice");
611 assert_eq!(eps.len(), 2);
612 assert_eq!(eps[0].scope, EndpointScope::Local);
613 assert_eq!(eps[1].scope, EndpointScope::Federation);
614 }
615
616 #[test]
617 fn peer_endpoints_drops_local_when_self_has_no_local() {
618 let state = json!({
619 "self": {
620 "endpoints": [
621 {"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t1", "scope": "federation"}
622 ]
623 },
624 "peers": {
625 "alice": {
626 "endpoints": [
627 {"relay_url": "https://wireup.net", "slot_id": "a-fed", "slot_token": "ta1", "scope": "federation"},
628 {"relay_url": "http://127.0.0.1:8771", "slot_id": "a-loop", "slot_token": "ta2", "scope": "local"}
629 ]
630 }
631 }
632 });
633 let eps = peer_endpoints_in_priority_order(&state, "alice");
634 assert_eq!(eps.len(), 1);
636 assert_eq!(eps[0].scope, EndpointScope::Federation);
637 }
638
639 #[test]
640 fn peer_endpoints_drops_local_when_relay_urls_dont_match() {
641 let state = json!({
642 "self": {
643 "endpoints": [
644 {"relay_url": "http://127.0.0.1:8771", "slot_id": "self-loop", "slot_token": "t2", "scope": "local"}
645 ]
646 },
647 "peers": {
648 "alice": {
649 "endpoints": [
650 {"relay_url": "http://127.0.0.1:9999", "slot_id": "a-loop", "slot_token": "ta2", "scope": "local"}
651 ]
652 }
653 }
654 });
655 let eps = peer_endpoints_in_priority_order(&state, "alice");
657 assert_eq!(
658 eps.len(),
659 0,
660 "different local relays cannot reach each other"
661 );
662 }
663
664 #[test]
665 fn pin_peer_endpoints_preserves_legacy_top_level_fields() {
666 let mut state = json!({"peers": {}});
667 let endpoints = vec![
668 Endpoint::federation("https://wireup.net".into(), "abc".into(), "tok".into()),
669 Endpoint::local(
670 "http://127.0.0.1:8771".into(),
671 "loop".into(),
672 "loop-tok".into(),
673 ),
674 ];
675 pin_peer_endpoints(&mut state, "alice", &endpoints).unwrap();
676 let alice = &state["peers"]["alice"];
677 assert_eq!(alice["relay_url"], "https://wireup.net");
679 assert_eq!(alice["slot_id"], "abc");
680 assert_eq!(alice["slot_token"], "tok");
681 let eps = alice["endpoints"].as_array().unwrap();
683 assert_eq!(eps.len(), 2);
684 }
685
686 #[test]
687 fn self_endpoints_back_compat_falls_back_to_legacy_fields() {
688 let state = json!({
689 "self": {
690 "relay_url": "https://wireup.net",
691 "slot_id": "self-fed",
692 "slot_token": "t1"
693 }
694 });
695 let eps = self_endpoints(&state);
696 assert_eq!(eps.len(), 1);
697 assert_eq!(eps[0].scope, EndpointScope::Federation);
698 assert_eq!(eps[0].slot_id, "self-fed");
699 }
700
701 #[test]
702 fn self_endpoints_returns_both_when_dual_slot() {
703 let state = json!({
704 "self": {
705 "endpoints": [
706 {"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t1", "scope": "federation"},
707 {"relay_url": "http://127.0.0.1:8771", "slot_id": "self-loop", "slot_token": "t2", "scope": "local"}
708 ]
709 }
710 });
711 let eps = self_endpoints(&state);
712 assert_eq!(eps.len(), 2);
713 }
714}