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!(infer_scope_from_url("http://127.0.0.1:8771"), EndpointScope::Local);
400 assert_eq!(infer_scope_from_url("http://localhost:8771"), EndpointScope::Local);
401 assert_eq!(infer_scope_from_url("unix:///tmp/wire.sock"), EndpointScope::Uds);
402 assert_eq!(infer_scope_from_url("https://wireup.net"), EndpointScope::Federation);
403 }
404
405 #[test]
406 fn upsert_self_endpoint_is_additive_then_updates_in_place() {
407 let mut state = json!({});
408 upsert_self_endpoint(
409 &mut state,
410 Endpoint::federation("https://wireup.net".into(), "fed1".into(), "ft".into()),
411 );
412 upsert_self_endpoint(
413 &mut state,
414 Endpoint::local("http://127.0.0.1:8771".into(), "loc1".into(), "lt".into()),
415 );
416 assert_eq!(self_endpoints(&state).len(), 2);
418 assert_eq!(state["self"]["relay_url"], "https://wireup.net");
420 upsert_self_endpoint(
422 &mut state,
423 Endpoint::local("http://127.0.0.1:8771".into(), "loc2".into(), "lt2".into()),
424 );
425 let eps = self_endpoints(&state);
426 assert_eq!(eps.len(), 2, "same-relay rebind replaces, not appends");
427 let loc = eps
428 .iter()
429 .find(|e| e.scope == EndpointScope::Local)
430 .unwrap();
431 assert_eq!(loc.slot_id, "loc2", "local slot updated in place");
432 }
433
434 #[test]
435 fn peer_endpoints_back_compat_falls_back_to_legacy_fields() {
436 let state = json!({
437 "peers": {
438 "alice": {
439 "relay_url": "https://wireup.net",
440 "slot_id": "abc",
441 "slot_token": "tok"
442 }
443 }
444 });
445 let eps = peer_endpoints_in_priority_order(&state, "alice");
446 assert_eq!(eps.len(), 1);
447 assert_eq!(eps[0].relay_url, "https://wireup.net");
448 assert_eq!(eps[0].scope, EndpointScope::Federation);
449 }
450
451 #[test]
452 fn peer_endpoints_lan_beats_federation() {
453 let state = json!({
458 "self": {
459 "endpoints": [
460 {"relay_url": "http://127.0.0.1:8771", "slot_id": "self-loop", "slot_token": "t1", "scope": "local"},
461 {"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t2", "scope": "federation"}
462 ]
463 },
464 "peers": {
465 "alice": {
466 "endpoints": [
467 {"relay_url": "https://wireup.net", "slot_id": "a-fed", "slot_token": "ta-f", "scope": "federation"},
468 {"relay_url": "http://192.168.1.50:8771", "slot_id": "a-lan", "slot_token": "ta-l", "scope": "lan"},
469 {"relay_url": "http://127.0.0.1:8771", "slot_id": "a-loop", "slot_token": "ta-loop", "scope": "local"}
470 ]
471 }
472 }
473 });
474 let eps = peer_endpoints_in_priority_order(&state, "alice");
475 assert_eq!(
476 eps.len(),
477 3,
478 "Local(matched) + Lan + Federation all reachable"
479 );
480 assert_eq!(
481 eps[0].scope,
482 EndpointScope::Local,
483 "loopback wins (same-machine)"
484 );
485 assert_eq!(
486 eps[1].scope,
487 EndpointScope::Lan,
488 "Lan second (same-network)"
489 );
490 assert_eq!(
491 eps[2].scope,
492 EndpointScope::Federation,
493 "Federation last (anywhere)"
494 );
495 }
496
497 #[test]
498 fn peer_endpoints_lan_kept_when_self_has_no_local() {
499 let state = json!({
503 "self": {
504 "endpoints": [
505 {"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t1", "scope": "federation"}
506 ]
507 },
508 "peers": {
509 "alice": {
510 "endpoints": [
511 {"relay_url": "https://wireup.net", "slot_id": "a-fed", "slot_token": "ta-f", "scope": "federation"},
512 {"relay_url": "http://192.168.1.50:8771", "slot_id": "a-lan", "slot_token": "ta-l", "scope": "lan"}
513 ]
514 }
515 }
516 });
517 let eps = peer_endpoints_in_priority_order(&state, "alice");
518 assert_eq!(eps.len(), 2);
519 assert_eq!(
520 eps[0].scope,
521 EndpointScope::Lan,
522 "Lan preferred over Federation"
523 );
524 assert_eq!(eps[1].scope, EndpointScope::Federation);
525 }
526
527 #[test]
528 fn pin_peer_endpoints_uses_lan_as_legacy_when_no_federation() {
529 let mut state = json!({});
534 let endpoints = vec![
535 Endpoint::lan(
536 "http://192.168.1.50:8771".to_string(),
537 "lan-slot".to_string(),
538 "lan-tok".to_string(),
539 ),
540 Endpoint::local(
541 "http://127.0.0.1:8771".to_string(),
542 "loop-slot".to_string(),
543 "loop-tok".to_string(),
544 ),
545 ];
546 pin_peer_endpoints(&mut state, "alice", &endpoints).unwrap();
547 let alice = &state["peers"]["alice"];
548 assert_eq!(
549 alice["relay_url"], "http://192.168.1.50:8771",
550 "LAN wins legacy fields"
551 );
552 assert_eq!(alice["slot_id"], "lan-slot");
553 }
554
555 #[test]
556 fn peer_endpoints_orders_local_first_when_self_has_matching_local() {
557 let state = json!({
558 "self": {
559 "endpoints": [
560 {"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t1", "scope": "federation"},
561 {"relay_url": "http://127.0.0.1:8771", "slot_id": "self-loop", "slot_token": "t2", "scope": "local"}
562 ]
563 },
564 "peers": {
565 "alice": {
566 "endpoints": [
567 {"relay_url": "https://wireup.net", "slot_id": "a-fed", "slot_token": "ta1", "scope": "federation"},
568 {"relay_url": "http://127.0.0.1:8771", "slot_id": "a-loop", "slot_token": "ta2", "scope": "local"}
569 ]
570 }
571 }
572 });
573 let eps = peer_endpoints_in_priority_order(&state, "alice");
574 assert_eq!(eps.len(), 2);
575 assert_eq!(eps[0].scope, EndpointScope::Local);
576 assert_eq!(eps[1].scope, EndpointScope::Federation);
577 }
578
579 #[test]
580 fn peer_endpoints_drops_local_when_self_has_no_local() {
581 let state = json!({
582 "self": {
583 "endpoints": [
584 {"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t1", "scope": "federation"}
585 ]
586 },
587 "peers": {
588 "alice": {
589 "endpoints": [
590 {"relay_url": "https://wireup.net", "slot_id": "a-fed", "slot_token": "ta1", "scope": "federation"},
591 {"relay_url": "http://127.0.0.1:8771", "slot_id": "a-loop", "slot_token": "ta2", "scope": "local"}
592 ]
593 }
594 }
595 });
596 let eps = peer_endpoints_in_priority_order(&state, "alice");
597 assert_eq!(eps.len(), 1);
599 assert_eq!(eps[0].scope, EndpointScope::Federation);
600 }
601
602 #[test]
603 fn peer_endpoints_drops_local_when_relay_urls_dont_match() {
604 let state = json!({
605 "self": {
606 "endpoints": [
607 {"relay_url": "http://127.0.0.1:8771", "slot_id": "self-loop", "slot_token": "t2", "scope": "local"}
608 ]
609 },
610 "peers": {
611 "alice": {
612 "endpoints": [
613 {"relay_url": "http://127.0.0.1:9999", "slot_id": "a-loop", "slot_token": "ta2", "scope": "local"}
614 ]
615 }
616 }
617 });
618 let eps = peer_endpoints_in_priority_order(&state, "alice");
620 assert_eq!(
621 eps.len(),
622 0,
623 "different local relays cannot reach each other"
624 );
625 }
626
627 #[test]
628 fn pin_peer_endpoints_preserves_legacy_top_level_fields() {
629 let mut state = json!({"peers": {}});
630 let endpoints = vec![
631 Endpoint::federation("https://wireup.net".into(), "abc".into(), "tok".into()),
632 Endpoint::local(
633 "http://127.0.0.1:8771".into(),
634 "loop".into(),
635 "loop-tok".into(),
636 ),
637 ];
638 pin_peer_endpoints(&mut state, "alice", &endpoints).unwrap();
639 let alice = &state["peers"]["alice"];
640 assert_eq!(alice["relay_url"], "https://wireup.net");
642 assert_eq!(alice["slot_id"], "abc");
643 assert_eq!(alice["slot_token"], "tok");
644 let eps = alice["endpoints"].as_array().unwrap();
646 assert_eq!(eps.len(), 2);
647 }
648
649 #[test]
650 fn self_endpoints_back_compat_falls_back_to_legacy_fields() {
651 let state = json!({
652 "self": {
653 "relay_url": "https://wireup.net",
654 "slot_id": "self-fed",
655 "slot_token": "t1"
656 }
657 });
658 let eps = self_endpoints(&state);
659 assert_eq!(eps.len(), 1);
660 assert_eq!(eps[0].scope, EndpointScope::Federation);
661 assert_eq!(eps[0].slot_id, "self-fed");
662 }
663
664 #[test]
665 fn self_endpoints_returns_both_when_dual_slot() {
666 let state = json!({
667 "self": {
668 "endpoints": [
669 {"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t1", "scope": "federation"},
670 {"relay_url": "http://127.0.0.1:8771", "slot_id": "self-loop", "slot_token": "t2", "scope": "local"}
671 ]
672 }
673 });
674 let eps = self_endpoints(&state);
675 assert_eq!(eps.len(), 2);
676 }
677}