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 mut entry = serde_json::Map::new();
309 if let Some(f) = fed {
310 entry.insert("relay_url".into(), Value::String(f.relay_url.clone()));
311 entry.insert("slot_id".into(), Value::String(f.slot_id.clone()));
312 entry.insert("slot_token".into(), Value::String(f.slot_token.clone()));
313 } else if let Some(lan_ep) = endpoints.iter().find(|e| e.scope == EndpointScope::Lan) {
314 entry.insert("relay_url".into(), Value::String(lan_ep.relay_url.clone()));
315 entry.insert("slot_id".into(), Value::String(lan_ep.slot_id.clone()));
316 entry.insert(
317 "slot_token".into(),
318 Value::String(lan_ep.slot_token.clone()),
319 );
320 } else if let Some(loc) = endpoints.iter().find(|e| e.scope == EndpointScope::Local) {
321 entry.insert("relay_url".into(), Value::String(loc.relay_url.clone()));
325 entry.insert("slot_id".into(), Value::String(loc.slot_id.clone()));
326 entry.insert("slot_token".into(), Value::String(loc.slot_token.clone()));
327 }
328 entry.insert("endpoints".into(), serde_json::to_value(endpoints)?);
329 peers.insert(peer_handle.to_string(), Value::Object(entry));
330 Ok(())
331}
332
333pub fn infer_scope_from_url(url: &str) -> EndpointScope {
338 if url.starts_with("unix://") {
339 return EndpointScope::Uds;
340 }
341 let host = url
342 .trim_start_matches("http://")
343 .trim_start_matches("https://")
344 .split('/')
345 .next()
346 .unwrap_or("")
347 .split(':')
348 .next()
349 .unwrap_or("");
350 if host == "127.0.0.1" || host == "localhost" || host == "::1" {
351 EndpointScope::Local
352 } else {
353 EndpointScope::Federation
354 }
355}
356
357fn build_self_value(eps: &[Endpoint]) -> Value {
362 let legacy = eps
363 .iter()
364 .find(|e| e.scope == EndpointScope::Federation)
365 .or_else(|| eps.first());
366 let mut self_obj = serde_json::Map::new();
367 if let Some(l) = legacy {
368 self_obj.insert("relay_url".into(), Value::String(l.relay_url.clone()));
369 self_obj.insert("slot_id".into(), Value::String(l.slot_id.clone()));
370 self_obj.insert("slot_token".into(), Value::String(l.slot_token.clone()));
371 }
372 self_obj.insert(
373 "endpoints".into(),
374 serde_json::to_value(eps).unwrap_or(Value::Null),
375 );
376 Value::Object(self_obj)
377}
378
379pub fn upsert_self_endpoint(relay_state: &mut Value, ep: Endpoint) {
386 let mut eps = self_endpoints(relay_state);
387 eps.retain(|e| e.relay_url != ep.relay_url);
388 eps.push(ep);
389 relay_state["self"] = build_self_value(&eps);
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395 use serde_json::json;
396
397 #[test]
398 fn infer_scope_classifies_loopback_unix_and_federation() {
399 assert_eq!(
400 infer_scope_from_url("http://127.0.0.1:8771"),
401 EndpointScope::Local
402 );
403 assert_eq!(
404 infer_scope_from_url("http://localhost:8771"),
405 EndpointScope::Local
406 );
407 assert_eq!(
408 infer_scope_from_url("unix:///tmp/wire.sock"),
409 EndpointScope::Uds
410 );
411 assert_eq!(
412 infer_scope_from_url("https://wireup.net"),
413 EndpointScope::Federation
414 );
415 }
416
417 #[test]
418 fn upsert_self_endpoint_is_additive_then_updates_in_place() {
419 let mut state = json!({});
420 upsert_self_endpoint(
421 &mut state,
422 Endpoint::federation("https://wireup.net".into(), "fed1".into(), "ft".into()),
423 );
424 upsert_self_endpoint(
425 &mut state,
426 Endpoint::local("http://127.0.0.1:8771".into(), "loc1".into(), "lt".into()),
427 );
428 assert_eq!(self_endpoints(&state).len(), 2);
430 assert_eq!(state["self"]["relay_url"], "https://wireup.net");
432 upsert_self_endpoint(
434 &mut state,
435 Endpoint::local("http://127.0.0.1:8771".into(), "loc2".into(), "lt2".into()),
436 );
437 let eps = self_endpoints(&state);
438 assert_eq!(eps.len(), 2, "same-relay rebind replaces, not appends");
439 let loc = eps
440 .iter()
441 .find(|e| e.scope == EndpointScope::Local)
442 .unwrap();
443 assert_eq!(loc.slot_id, "loc2", "local slot updated in place");
444 }
445
446 #[test]
447 fn peer_endpoints_back_compat_falls_back_to_legacy_fields() {
448 let state = json!({
449 "peers": {
450 "alice": {
451 "relay_url": "https://wireup.net",
452 "slot_id": "abc",
453 "slot_token": "tok"
454 }
455 }
456 });
457 let eps = peer_endpoints_in_priority_order(&state, "alice");
458 assert_eq!(eps.len(), 1);
459 assert_eq!(eps[0].relay_url, "https://wireup.net");
460 assert_eq!(eps[0].scope, EndpointScope::Federation);
461 }
462
463 #[test]
464 fn peer_endpoints_lan_beats_federation() {
465 let state = json!({
470 "self": {
471 "endpoints": [
472 {"relay_url": "http://127.0.0.1:8771", "slot_id": "self-loop", "slot_token": "t1", "scope": "local"},
473 {"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t2", "scope": "federation"}
474 ]
475 },
476 "peers": {
477 "alice": {
478 "endpoints": [
479 {"relay_url": "https://wireup.net", "slot_id": "a-fed", "slot_token": "ta-f", "scope": "federation"},
480 {"relay_url": "http://192.168.1.50:8771", "slot_id": "a-lan", "slot_token": "ta-l", "scope": "lan"},
481 {"relay_url": "http://127.0.0.1:8771", "slot_id": "a-loop", "slot_token": "ta-loop", "scope": "local"}
482 ]
483 }
484 }
485 });
486 let eps = peer_endpoints_in_priority_order(&state, "alice");
487 assert_eq!(
488 eps.len(),
489 3,
490 "Local(matched) + Lan + Federation all reachable"
491 );
492 assert_eq!(
493 eps[0].scope,
494 EndpointScope::Local,
495 "loopback wins (same-machine)"
496 );
497 assert_eq!(
498 eps[1].scope,
499 EndpointScope::Lan,
500 "Lan second (same-network)"
501 );
502 assert_eq!(
503 eps[2].scope,
504 EndpointScope::Federation,
505 "Federation last (anywhere)"
506 );
507 }
508
509 #[test]
510 fn peer_endpoints_lan_kept_when_self_has_no_local() {
511 let state = json!({
515 "self": {
516 "endpoints": [
517 {"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t1", "scope": "federation"}
518 ]
519 },
520 "peers": {
521 "alice": {
522 "endpoints": [
523 {"relay_url": "https://wireup.net", "slot_id": "a-fed", "slot_token": "ta-f", "scope": "federation"},
524 {"relay_url": "http://192.168.1.50:8771", "slot_id": "a-lan", "slot_token": "ta-l", "scope": "lan"}
525 ]
526 }
527 }
528 });
529 let eps = peer_endpoints_in_priority_order(&state, "alice");
530 assert_eq!(eps.len(), 2);
531 assert_eq!(
532 eps[0].scope,
533 EndpointScope::Lan,
534 "Lan preferred over Federation"
535 );
536 assert_eq!(eps[1].scope, EndpointScope::Federation);
537 }
538
539 #[test]
540 fn pin_peer_endpoints_uses_lan_as_legacy_when_no_federation() {
541 let mut state = json!({});
546 let endpoints = vec![
547 Endpoint::lan(
548 "http://192.168.1.50:8771".to_string(),
549 "lan-slot".to_string(),
550 "lan-tok".to_string(),
551 ),
552 Endpoint::local(
553 "http://127.0.0.1:8771".to_string(),
554 "loop-slot".to_string(),
555 "loop-tok".to_string(),
556 ),
557 ];
558 pin_peer_endpoints(&mut state, "alice", &endpoints).unwrap();
559 let alice = &state["peers"]["alice"];
560 assert_eq!(
561 alice["relay_url"], "http://192.168.1.50:8771",
562 "LAN wins legacy fields"
563 );
564 assert_eq!(alice["slot_id"], "lan-slot");
565 }
566
567 #[test]
568 fn peer_endpoints_orders_local_first_when_self_has_matching_local() {
569 let state = json!({
570 "self": {
571 "endpoints": [
572 {"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t1", "scope": "federation"},
573 {"relay_url": "http://127.0.0.1:8771", "slot_id": "self-loop", "slot_token": "t2", "scope": "local"}
574 ]
575 },
576 "peers": {
577 "alice": {
578 "endpoints": [
579 {"relay_url": "https://wireup.net", "slot_id": "a-fed", "slot_token": "ta1", "scope": "federation"},
580 {"relay_url": "http://127.0.0.1:8771", "slot_id": "a-loop", "slot_token": "ta2", "scope": "local"}
581 ]
582 }
583 }
584 });
585 let eps = peer_endpoints_in_priority_order(&state, "alice");
586 assert_eq!(eps.len(), 2);
587 assert_eq!(eps[0].scope, EndpointScope::Local);
588 assert_eq!(eps[1].scope, EndpointScope::Federation);
589 }
590
591 #[test]
592 fn peer_endpoints_drops_local_when_self_has_no_local() {
593 let state = json!({
594 "self": {
595 "endpoints": [
596 {"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t1", "scope": "federation"}
597 ]
598 },
599 "peers": {
600 "alice": {
601 "endpoints": [
602 {"relay_url": "https://wireup.net", "slot_id": "a-fed", "slot_token": "ta1", "scope": "federation"},
603 {"relay_url": "http://127.0.0.1:8771", "slot_id": "a-loop", "slot_token": "ta2", "scope": "local"}
604 ]
605 }
606 }
607 });
608 let eps = peer_endpoints_in_priority_order(&state, "alice");
609 assert_eq!(eps.len(), 1);
611 assert_eq!(eps[0].scope, EndpointScope::Federation);
612 }
613
614 #[test]
615 fn peer_endpoints_drops_local_when_relay_urls_dont_match() {
616 let state = json!({
617 "self": {
618 "endpoints": [
619 {"relay_url": "http://127.0.0.1:8771", "slot_id": "self-loop", "slot_token": "t2", "scope": "local"}
620 ]
621 },
622 "peers": {
623 "alice": {
624 "endpoints": [
625 {"relay_url": "http://127.0.0.1:9999", "slot_id": "a-loop", "slot_token": "ta2", "scope": "local"}
626 ]
627 }
628 }
629 });
630 let eps = peer_endpoints_in_priority_order(&state, "alice");
632 assert_eq!(
633 eps.len(),
634 0,
635 "different local relays cannot reach each other"
636 );
637 }
638
639 #[test]
640 fn pin_peer_endpoints_preserves_legacy_top_level_fields() {
641 let mut state = json!({"peers": {}});
642 let endpoints = vec![
643 Endpoint::federation("https://wireup.net".into(), "abc".into(), "tok".into()),
644 Endpoint::local(
645 "http://127.0.0.1:8771".into(),
646 "loop".into(),
647 "loop-tok".into(),
648 ),
649 ];
650 pin_peer_endpoints(&mut state, "alice", &endpoints).unwrap();
651 let alice = &state["peers"]["alice"];
652 assert_eq!(alice["relay_url"], "https://wireup.net");
654 assert_eq!(alice["slot_id"], "abc");
655 assert_eq!(alice["slot_token"], "tok");
656 let eps = alice["endpoints"].as_array().unwrap();
658 assert_eq!(eps.len(), 2);
659 }
660
661 #[test]
662 fn self_endpoints_back_compat_falls_back_to_legacy_fields() {
663 let state = json!({
664 "self": {
665 "relay_url": "https://wireup.net",
666 "slot_id": "self-fed",
667 "slot_token": "t1"
668 }
669 });
670 let eps = self_endpoints(&state);
671 assert_eq!(eps.len(), 1);
672 assert_eq!(eps[0].scope, EndpointScope::Federation);
673 assert_eq!(eps[0].slot_id, "self-fed");
674 }
675
676 #[test]
677 fn self_endpoints_returns_both_when_dual_slot() {
678 let state = json!({
679 "self": {
680 "endpoints": [
681 {"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t1", "scope": "federation"},
682 {"relay_url": "http://127.0.0.1:8771", "slot_id": "self-loop", "slot_token": "t2", "scope": "local"}
683 ]
684 }
685 });
686 let eps = self_endpoints(&state);
687 assert_eq!(eps.len(), 2);
688 }
689}