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 pin_peer_endpoints(
274 relay_state: &mut Value,
275 peer_handle: &str,
276 endpoints: &[Endpoint],
277) -> Result<()> {
278 let fed = endpoints
282 .iter()
283 .find(|e| e.scope == EndpointScope::Federation);
284 let peers = relay_state
285 .as_object_mut()
286 .map(|m| {
287 m.entry("peers")
288 .or_insert_with(|| Value::Object(Default::default()))
289 })
290 .ok_or_else(|| anyhow::anyhow!("relay_state.json root is not an object"))?
291 .as_object_mut()
292 .ok_or_else(|| anyhow::anyhow!("relay_state.peers is not an object"))?;
293 let mut entry = serde_json::Map::new();
294 if let Some(f) = fed {
295 entry.insert("relay_url".into(), Value::String(f.relay_url.clone()));
296 entry.insert("slot_id".into(), Value::String(f.slot_id.clone()));
297 entry.insert("slot_token".into(), Value::String(f.slot_token.clone()));
298 } else if let Some(lan_ep) = endpoints.iter().find(|e| e.scope == EndpointScope::Lan) {
299 entry.insert("relay_url".into(), Value::String(lan_ep.relay_url.clone()));
300 entry.insert("slot_id".into(), Value::String(lan_ep.slot_id.clone()));
301 entry.insert(
302 "slot_token".into(),
303 Value::String(lan_ep.slot_token.clone()),
304 );
305 } else if let Some(loc) = endpoints.iter().find(|e| e.scope == EndpointScope::Local) {
306 entry.insert("relay_url".into(), Value::String(loc.relay_url.clone()));
310 entry.insert("slot_id".into(), Value::String(loc.slot_id.clone()));
311 entry.insert("slot_token".into(), Value::String(loc.slot_token.clone()));
312 }
313 entry.insert("endpoints".into(), serde_json::to_value(endpoints)?);
314 peers.insert(peer_handle.to_string(), Value::Object(entry));
315 Ok(())
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321 use serde_json::json;
322
323 #[test]
324 fn peer_endpoints_back_compat_falls_back_to_legacy_fields() {
325 let state = json!({
326 "peers": {
327 "alice": {
328 "relay_url": "https://wireup.net",
329 "slot_id": "abc",
330 "slot_token": "tok"
331 }
332 }
333 });
334 let eps = peer_endpoints_in_priority_order(&state, "alice");
335 assert_eq!(eps.len(), 1);
336 assert_eq!(eps[0].relay_url, "https://wireup.net");
337 assert_eq!(eps[0].scope, EndpointScope::Federation);
338 }
339
340 #[test]
341 fn peer_endpoints_lan_beats_federation() {
342 let state = json!({
347 "self": {
348 "endpoints": [
349 {"relay_url": "http://127.0.0.1:8771", "slot_id": "self-loop", "slot_token": "t1", "scope": "local"},
350 {"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t2", "scope": "federation"}
351 ]
352 },
353 "peers": {
354 "alice": {
355 "endpoints": [
356 {"relay_url": "https://wireup.net", "slot_id": "a-fed", "slot_token": "ta-f", "scope": "federation"},
357 {"relay_url": "http://192.168.1.50:8771", "slot_id": "a-lan", "slot_token": "ta-l", "scope": "lan"},
358 {"relay_url": "http://127.0.0.1:8771", "slot_id": "a-loop", "slot_token": "ta-loop", "scope": "local"}
359 ]
360 }
361 }
362 });
363 let eps = peer_endpoints_in_priority_order(&state, "alice");
364 assert_eq!(
365 eps.len(),
366 3,
367 "Local(matched) + Lan + Federation all reachable"
368 );
369 assert_eq!(
370 eps[0].scope,
371 EndpointScope::Local,
372 "loopback wins (same-machine)"
373 );
374 assert_eq!(
375 eps[1].scope,
376 EndpointScope::Lan,
377 "Lan second (same-network)"
378 );
379 assert_eq!(
380 eps[2].scope,
381 EndpointScope::Federation,
382 "Federation last (anywhere)"
383 );
384 }
385
386 #[test]
387 fn peer_endpoints_lan_kept_when_self_has_no_local() {
388 let state = json!({
392 "self": {
393 "endpoints": [
394 {"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t1", "scope": "federation"}
395 ]
396 },
397 "peers": {
398 "alice": {
399 "endpoints": [
400 {"relay_url": "https://wireup.net", "slot_id": "a-fed", "slot_token": "ta-f", "scope": "federation"},
401 {"relay_url": "http://192.168.1.50:8771", "slot_id": "a-lan", "slot_token": "ta-l", "scope": "lan"}
402 ]
403 }
404 }
405 });
406 let eps = peer_endpoints_in_priority_order(&state, "alice");
407 assert_eq!(eps.len(), 2);
408 assert_eq!(
409 eps[0].scope,
410 EndpointScope::Lan,
411 "Lan preferred over Federation"
412 );
413 assert_eq!(eps[1].scope, EndpointScope::Federation);
414 }
415
416 #[test]
417 fn pin_peer_endpoints_uses_lan_as_legacy_when_no_federation() {
418 let mut state = json!({});
423 let endpoints = vec![
424 Endpoint::lan(
425 "http://192.168.1.50:8771".to_string(),
426 "lan-slot".to_string(),
427 "lan-tok".to_string(),
428 ),
429 Endpoint::local(
430 "http://127.0.0.1:8771".to_string(),
431 "loop-slot".to_string(),
432 "loop-tok".to_string(),
433 ),
434 ];
435 pin_peer_endpoints(&mut state, "alice", &endpoints).unwrap();
436 let alice = &state["peers"]["alice"];
437 assert_eq!(
438 alice["relay_url"], "http://192.168.1.50:8771",
439 "LAN wins legacy fields"
440 );
441 assert_eq!(alice["slot_id"], "lan-slot");
442 }
443
444 #[test]
445 fn peer_endpoints_orders_local_first_when_self_has_matching_local() {
446 let state = json!({
447 "self": {
448 "endpoints": [
449 {"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t1", "scope": "federation"},
450 {"relay_url": "http://127.0.0.1:8771", "slot_id": "self-loop", "slot_token": "t2", "scope": "local"}
451 ]
452 },
453 "peers": {
454 "alice": {
455 "endpoints": [
456 {"relay_url": "https://wireup.net", "slot_id": "a-fed", "slot_token": "ta1", "scope": "federation"},
457 {"relay_url": "http://127.0.0.1:8771", "slot_id": "a-loop", "slot_token": "ta2", "scope": "local"}
458 ]
459 }
460 }
461 });
462 let eps = peer_endpoints_in_priority_order(&state, "alice");
463 assert_eq!(eps.len(), 2);
464 assert_eq!(eps[0].scope, EndpointScope::Local);
465 assert_eq!(eps[1].scope, EndpointScope::Federation);
466 }
467
468 #[test]
469 fn peer_endpoints_drops_local_when_self_has_no_local() {
470 let state = json!({
471 "self": {
472 "endpoints": [
473 {"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t1", "scope": "federation"}
474 ]
475 },
476 "peers": {
477 "alice": {
478 "endpoints": [
479 {"relay_url": "https://wireup.net", "slot_id": "a-fed", "slot_token": "ta1", "scope": "federation"},
480 {"relay_url": "http://127.0.0.1:8771", "slot_id": "a-loop", "slot_token": "ta2", "scope": "local"}
481 ]
482 }
483 }
484 });
485 let eps = peer_endpoints_in_priority_order(&state, "alice");
486 assert_eq!(eps.len(), 1);
488 assert_eq!(eps[0].scope, EndpointScope::Federation);
489 }
490
491 #[test]
492 fn peer_endpoints_drops_local_when_relay_urls_dont_match() {
493 let state = json!({
494 "self": {
495 "endpoints": [
496 {"relay_url": "http://127.0.0.1:8771", "slot_id": "self-loop", "slot_token": "t2", "scope": "local"}
497 ]
498 },
499 "peers": {
500 "alice": {
501 "endpoints": [
502 {"relay_url": "http://127.0.0.1:9999", "slot_id": "a-loop", "slot_token": "ta2", "scope": "local"}
503 ]
504 }
505 }
506 });
507 let eps = peer_endpoints_in_priority_order(&state, "alice");
509 assert_eq!(
510 eps.len(),
511 0,
512 "different local relays cannot reach each other"
513 );
514 }
515
516 #[test]
517 fn pin_peer_endpoints_preserves_legacy_top_level_fields() {
518 let mut state = json!({"peers": {}});
519 let endpoints = vec![
520 Endpoint::federation("https://wireup.net".into(), "abc".into(), "tok".into()),
521 Endpoint::local(
522 "http://127.0.0.1:8771".into(),
523 "loop".into(),
524 "loop-tok".into(),
525 ),
526 ];
527 pin_peer_endpoints(&mut state, "alice", &endpoints).unwrap();
528 let alice = &state["peers"]["alice"];
529 assert_eq!(alice["relay_url"], "https://wireup.net");
531 assert_eq!(alice["slot_id"], "abc");
532 assert_eq!(alice["slot_token"], "tok");
533 let eps = alice["endpoints"].as_array().unwrap();
535 assert_eq!(eps.len(), 2);
536 }
537
538 #[test]
539 fn self_endpoints_back_compat_falls_back_to_legacy_fields() {
540 let state = json!({
541 "self": {
542 "relay_url": "https://wireup.net",
543 "slot_id": "self-fed",
544 "slot_token": "t1"
545 }
546 });
547 let eps = self_endpoints(&state);
548 assert_eq!(eps.len(), 1);
549 assert_eq!(eps[0].scope, EndpointScope::Federation);
550 assert_eq!(eps[0].slot_id, "self-fed");
551 }
552
553 #[test]
554 fn self_endpoints_returns_both_when_dual_slot() {
555 let state = json!({
556 "self": {
557 "endpoints": [
558 {"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t1", "scope": "federation"},
559 {"relay_url": "http://127.0.0.1:8771", "slot_id": "self-loop", "slot_token": "t2", "scope": "local"}
560 ]
561 }
562 });
563 let eps = self_endpoints(&state);
564 assert_eq!(eps.len(), 2);
565 }
566}