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
333#[cfg(test)]
334mod tests {
335 use super::*;
336 use serde_json::json;
337
338 #[test]
339 fn peer_endpoints_back_compat_falls_back_to_legacy_fields() {
340 let state = json!({
341 "peers": {
342 "alice": {
343 "relay_url": "https://wireup.net",
344 "slot_id": "abc",
345 "slot_token": "tok"
346 }
347 }
348 });
349 let eps = peer_endpoints_in_priority_order(&state, "alice");
350 assert_eq!(eps.len(), 1);
351 assert_eq!(eps[0].relay_url, "https://wireup.net");
352 assert_eq!(eps[0].scope, EndpointScope::Federation);
353 }
354
355 #[test]
356 fn peer_endpoints_lan_beats_federation() {
357 let state = json!({
362 "self": {
363 "endpoints": [
364 {"relay_url": "http://127.0.0.1:8771", "slot_id": "self-loop", "slot_token": "t1", "scope": "local"},
365 {"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t2", "scope": "federation"}
366 ]
367 },
368 "peers": {
369 "alice": {
370 "endpoints": [
371 {"relay_url": "https://wireup.net", "slot_id": "a-fed", "slot_token": "ta-f", "scope": "federation"},
372 {"relay_url": "http://192.168.1.50:8771", "slot_id": "a-lan", "slot_token": "ta-l", "scope": "lan"},
373 {"relay_url": "http://127.0.0.1:8771", "slot_id": "a-loop", "slot_token": "ta-loop", "scope": "local"}
374 ]
375 }
376 }
377 });
378 let eps = peer_endpoints_in_priority_order(&state, "alice");
379 assert_eq!(
380 eps.len(),
381 3,
382 "Local(matched) + Lan + Federation all reachable"
383 );
384 assert_eq!(
385 eps[0].scope,
386 EndpointScope::Local,
387 "loopback wins (same-machine)"
388 );
389 assert_eq!(
390 eps[1].scope,
391 EndpointScope::Lan,
392 "Lan second (same-network)"
393 );
394 assert_eq!(
395 eps[2].scope,
396 EndpointScope::Federation,
397 "Federation last (anywhere)"
398 );
399 }
400
401 #[test]
402 fn peer_endpoints_lan_kept_when_self_has_no_local() {
403 let state = json!({
407 "self": {
408 "endpoints": [
409 {"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t1", "scope": "federation"}
410 ]
411 },
412 "peers": {
413 "alice": {
414 "endpoints": [
415 {"relay_url": "https://wireup.net", "slot_id": "a-fed", "slot_token": "ta-f", "scope": "federation"},
416 {"relay_url": "http://192.168.1.50:8771", "slot_id": "a-lan", "slot_token": "ta-l", "scope": "lan"}
417 ]
418 }
419 }
420 });
421 let eps = peer_endpoints_in_priority_order(&state, "alice");
422 assert_eq!(eps.len(), 2);
423 assert_eq!(
424 eps[0].scope,
425 EndpointScope::Lan,
426 "Lan preferred over Federation"
427 );
428 assert_eq!(eps[1].scope, EndpointScope::Federation);
429 }
430
431 #[test]
432 fn pin_peer_endpoints_uses_lan_as_legacy_when_no_federation() {
433 let mut state = json!({});
438 let endpoints = vec![
439 Endpoint::lan(
440 "http://192.168.1.50:8771".to_string(),
441 "lan-slot".to_string(),
442 "lan-tok".to_string(),
443 ),
444 Endpoint::local(
445 "http://127.0.0.1:8771".to_string(),
446 "loop-slot".to_string(),
447 "loop-tok".to_string(),
448 ),
449 ];
450 pin_peer_endpoints(&mut state, "alice", &endpoints).unwrap();
451 let alice = &state["peers"]["alice"];
452 assert_eq!(
453 alice["relay_url"], "http://192.168.1.50:8771",
454 "LAN wins legacy fields"
455 );
456 assert_eq!(alice["slot_id"], "lan-slot");
457 }
458
459 #[test]
460 fn peer_endpoints_orders_local_first_when_self_has_matching_local() {
461 let state = json!({
462 "self": {
463 "endpoints": [
464 {"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t1", "scope": "federation"},
465 {"relay_url": "http://127.0.0.1:8771", "slot_id": "self-loop", "slot_token": "t2", "scope": "local"}
466 ]
467 },
468 "peers": {
469 "alice": {
470 "endpoints": [
471 {"relay_url": "https://wireup.net", "slot_id": "a-fed", "slot_token": "ta1", "scope": "federation"},
472 {"relay_url": "http://127.0.0.1:8771", "slot_id": "a-loop", "slot_token": "ta2", "scope": "local"}
473 ]
474 }
475 }
476 });
477 let eps = peer_endpoints_in_priority_order(&state, "alice");
478 assert_eq!(eps.len(), 2);
479 assert_eq!(eps[0].scope, EndpointScope::Local);
480 assert_eq!(eps[1].scope, EndpointScope::Federation);
481 }
482
483 #[test]
484 fn peer_endpoints_drops_local_when_self_has_no_local() {
485 let state = json!({
486 "self": {
487 "endpoints": [
488 {"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t1", "scope": "federation"}
489 ]
490 },
491 "peers": {
492 "alice": {
493 "endpoints": [
494 {"relay_url": "https://wireup.net", "slot_id": "a-fed", "slot_token": "ta1", "scope": "federation"},
495 {"relay_url": "http://127.0.0.1:8771", "slot_id": "a-loop", "slot_token": "ta2", "scope": "local"}
496 ]
497 }
498 }
499 });
500 let eps = peer_endpoints_in_priority_order(&state, "alice");
501 assert_eq!(eps.len(), 1);
503 assert_eq!(eps[0].scope, EndpointScope::Federation);
504 }
505
506 #[test]
507 fn peer_endpoints_drops_local_when_relay_urls_dont_match() {
508 let state = json!({
509 "self": {
510 "endpoints": [
511 {"relay_url": "http://127.0.0.1:8771", "slot_id": "self-loop", "slot_token": "t2", "scope": "local"}
512 ]
513 },
514 "peers": {
515 "alice": {
516 "endpoints": [
517 {"relay_url": "http://127.0.0.1:9999", "slot_id": "a-loop", "slot_token": "ta2", "scope": "local"}
518 ]
519 }
520 }
521 });
522 let eps = peer_endpoints_in_priority_order(&state, "alice");
524 assert_eq!(
525 eps.len(),
526 0,
527 "different local relays cannot reach each other"
528 );
529 }
530
531 #[test]
532 fn pin_peer_endpoints_preserves_legacy_top_level_fields() {
533 let mut state = json!({"peers": {}});
534 let endpoints = vec![
535 Endpoint::federation("https://wireup.net".into(), "abc".into(), "tok".into()),
536 Endpoint::local(
537 "http://127.0.0.1:8771".into(),
538 "loop".into(),
539 "loop-tok".into(),
540 ),
541 ];
542 pin_peer_endpoints(&mut state, "alice", &endpoints).unwrap();
543 let alice = &state["peers"]["alice"];
544 assert_eq!(alice["relay_url"], "https://wireup.net");
546 assert_eq!(alice["slot_id"], "abc");
547 assert_eq!(alice["slot_token"], "tok");
548 let eps = alice["endpoints"].as_array().unwrap();
550 assert_eq!(eps.len(), 2);
551 }
552
553 #[test]
554 fn self_endpoints_back_compat_falls_back_to_legacy_fields() {
555 let state = json!({
556 "self": {
557 "relay_url": "https://wireup.net",
558 "slot_id": "self-fed",
559 "slot_token": "t1"
560 }
561 });
562 let eps = self_endpoints(&state);
563 assert_eq!(eps.len(), 1);
564 assert_eq!(eps[0].scope, EndpointScope::Federation);
565 assert_eq!(eps[0].slot_id, "self-fed");
566 }
567
568 #[test]
569 fn self_endpoints_returns_both_when_dual_slot() {
570 let state = json!({
571 "self": {
572 "endpoints": [
573 {"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t1", "scope": "federation"},
574 {"relay_url": "http://127.0.0.1:8771", "slot_id": "self-loop", "slot_token": "t2", "scope": "local"}
575 ]
576 }
577 });
578 let eps = self_endpoints(&state);
579 assert_eq!(eps.len(), 2);
580 }
581}