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}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct Endpoint {
66 pub relay_url: String,
67 pub slot_id: String,
68 pub slot_token: String,
69 pub scope: EndpointScope,
70}
71
72impl Endpoint {
73 pub fn federation(relay_url: String, slot_id: String, slot_token: String) -> Self {
74 Self {
75 relay_url,
76 slot_id,
77 slot_token,
78 scope: EndpointScope::Federation,
79 }
80 }
81
82 pub fn local(relay_url: String, slot_id: String, slot_token: String) -> Self {
83 Self {
84 relay_url,
85 slot_id,
86 slot_token,
87 scope: EndpointScope::Local,
88 }
89 }
90}
91
92pub fn peer_endpoints_in_priority_order(
105 relay_state: &Value,
106 peer_handle: &str,
107) -> Vec<Endpoint> {
108 let our_local_relay_url = relay_state
109 .get("self")
110 .and_then(|s| s.get("endpoints"))
111 .and_then(Value::as_array)
112 .and_then(|arr| {
113 arr.iter()
114 .find(|e| e.get("scope").and_then(Value::as_str) == Some("local"))
115 .and_then(|e| e.get("relay_url"))
116 .and_then(Value::as_str)
117 .map(str::to_string)
118 });
119
120 let peer = match relay_state
121 .get("peers")
122 .and_then(|p| p.get(peer_handle))
123 {
124 Some(p) => p,
125 None => return Vec::new(),
126 };
127
128 let mut all: Vec<Endpoint> = Vec::new();
129
130 if let Some(arr) = peer.get("endpoints").and_then(Value::as_array) {
131 for ep in arr {
132 if let Ok(parsed) = serde_json::from_value::<Endpoint>(ep.clone()) {
133 all.push(parsed);
134 }
135 }
136 }
137
138 if all.is_empty() {
142 let relay_url = peer.get("relay_url").and_then(Value::as_str).unwrap_or("");
143 let slot_id = peer.get("slot_id").and_then(Value::as_str).unwrap_or("");
144 let slot_token = peer
145 .get("slot_token")
146 .and_then(Value::as_str)
147 .unwrap_or("");
148 if !relay_url.is_empty() && !slot_id.is_empty() && !slot_token.is_empty() {
149 all.push(Endpoint::federation(
150 relay_url.to_string(),
151 slot_id.to_string(),
152 slot_token.to_string(),
153 ));
154 }
155 }
156
157 let our_local = our_local_relay_url.clone();
160 all.sort_by_key(|ep| match (ep.scope, &our_local) {
161 (EndpointScope::Local, Some(our)) if &ep.relay_url == our => 0,
162 (EndpointScope::Federation, _) => 1,
163 _ => 2,
164 });
165 all.retain(|ep| match (ep.scope, &our_local) {
168 (EndpointScope::Local, None) => false,
169 (EndpointScope::Local, Some(our)) => &ep.relay_url == our,
170 (EndpointScope::Federation, _) => true,
171 });
172 all
173}
174
175pub fn self_endpoints(relay_state: &Value) -> Vec<Endpoint> {
179 let self_state = match relay_state.get("self") {
180 Some(s) if !s.is_null() => s,
181 _ => return Vec::new(),
182 };
183 let mut all: Vec<Endpoint> = Vec::new();
184 if let Some(arr) = self_state.get("endpoints").and_then(Value::as_array) {
185 for ep in arr {
186 if let Ok(parsed) = serde_json::from_value::<Endpoint>(ep.clone()) {
187 all.push(parsed);
188 }
189 }
190 }
191 if all.is_empty() {
192 let relay_url = self_state
197 .get("relay_url")
198 .and_then(Value::as_str)
199 .unwrap_or("");
200 let slot_id = self_state.get("slot_id").and_then(Value::as_str).unwrap_or("");
201 let slot_token = self_state
202 .get("slot_token")
203 .and_then(Value::as_str)
204 .unwrap_or("");
205 if !relay_url.is_empty() && !slot_id.is_empty() {
206 all.push(Endpoint::federation(
207 relay_url.to_string(),
208 slot_id.to_string(),
209 slot_token.to_string(),
210 ));
211 }
212 }
213 all
214}
215
216pub fn pin_peer_endpoints(
222 relay_state: &mut Value,
223 peer_handle: &str,
224 endpoints: &[Endpoint],
225) -> Result<()> {
226 let fed = endpoints
228 .iter()
229 .find(|e| e.scope == EndpointScope::Federation);
230 let peers = relay_state
231 .as_object_mut()
232 .map(|m| {
233 m.entry("peers")
234 .or_insert_with(|| Value::Object(Default::default()))
235 })
236 .ok_or_else(|| anyhow::anyhow!("relay_state.json root is not an object"))?
237 .as_object_mut()
238 .ok_or_else(|| anyhow::anyhow!("relay_state.peers is not an object"))?;
239 let mut entry = serde_json::Map::new();
240 if let Some(f) = fed {
241 entry.insert("relay_url".into(), Value::String(f.relay_url.clone()));
242 entry.insert("slot_id".into(), Value::String(f.slot_id.clone()));
243 entry.insert("slot_token".into(), Value::String(f.slot_token.clone()));
244 } else if let Some(loc) = endpoints
245 .iter()
246 .find(|e| e.scope == EndpointScope::Local)
247 {
248 entry.insert("relay_url".into(), Value::String(loc.relay_url.clone()));
252 entry.insert("slot_id".into(), Value::String(loc.slot_id.clone()));
253 entry.insert("slot_token".into(), Value::String(loc.slot_token.clone()));
254 }
255 entry.insert("endpoints".into(), serde_json::to_value(endpoints)?);
256 peers.insert(peer_handle.to_string(), Value::Object(entry));
257 Ok(())
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263 use serde_json::json;
264
265 #[test]
266 fn peer_endpoints_back_compat_falls_back_to_legacy_fields() {
267 let state = json!({
268 "peers": {
269 "alice": {
270 "relay_url": "https://wireup.net",
271 "slot_id": "abc",
272 "slot_token": "tok"
273 }
274 }
275 });
276 let eps = peer_endpoints_in_priority_order(&state, "alice");
277 assert_eq!(eps.len(), 1);
278 assert_eq!(eps[0].relay_url, "https://wireup.net");
279 assert_eq!(eps[0].scope, EndpointScope::Federation);
280 }
281
282 #[test]
283 fn peer_endpoints_orders_local_first_when_self_has_matching_local() {
284 let state = json!({
285 "self": {
286 "endpoints": [
287 {"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t1", "scope": "federation"},
288 {"relay_url": "http://127.0.0.1:8771", "slot_id": "self-loop", "slot_token": "t2", "scope": "local"}
289 ]
290 },
291 "peers": {
292 "alice": {
293 "endpoints": [
294 {"relay_url": "https://wireup.net", "slot_id": "a-fed", "slot_token": "ta1", "scope": "federation"},
295 {"relay_url": "http://127.0.0.1:8771", "slot_id": "a-loop", "slot_token": "ta2", "scope": "local"}
296 ]
297 }
298 }
299 });
300 let eps = peer_endpoints_in_priority_order(&state, "alice");
301 assert_eq!(eps.len(), 2);
302 assert_eq!(eps[0].scope, EndpointScope::Local);
303 assert_eq!(eps[1].scope, EndpointScope::Federation);
304 }
305
306 #[test]
307 fn peer_endpoints_drops_local_when_self_has_no_local() {
308 let state = json!({
309 "self": {
310 "endpoints": [
311 {"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t1", "scope": "federation"}
312 ]
313 },
314 "peers": {
315 "alice": {
316 "endpoints": [
317 {"relay_url": "https://wireup.net", "slot_id": "a-fed", "slot_token": "ta1", "scope": "federation"},
318 {"relay_url": "http://127.0.0.1:8771", "slot_id": "a-loop", "slot_token": "ta2", "scope": "local"}
319 ]
320 }
321 }
322 });
323 let eps = peer_endpoints_in_priority_order(&state, "alice");
324 assert_eq!(eps.len(), 1);
326 assert_eq!(eps[0].scope, EndpointScope::Federation);
327 }
328
329 #[test]
330 fn peer_endpoints_drops_local_when_relay_urls_dont_match() {
331 let state = json!({
332 "self": {
333 "endpoints": [
334 {"relay_url": "http://127.0.0.1:8771", "slot_id": "self-loop", "slot_token": "t2", "scope": "local"}
335 ]
336 },
337 "peers": {
338 "alice": {
339 "endpoints": [
340 {"relay_url": "http://127.0.0.1:9999", "slot_id": "a-loop", "slot_token": "ta2", "scope": "local"}
341 ]
342 }
343 }
344 });
345 let eps = peer_endpoints_in_priority_order(&state, "alice");
347 assert_eq!(eps.len(), 0, "different local relays cannot reach each other");
348 }
349
350 #[test]
351 fn pin_peer_endpoints_preserves_legacy_top_level_fields() {
352 let mut state = json!({"peers": {}});
353 let endpoints = vec![
354 Endpoint::federation(
355 "https://wireup.net".into(),
356 "abc".into(),
357 "tok".into(),
358 ),
359 Endpoint::local(
360 "http://127.0.0.1:8771".into(),
361 "loop".into(),
362 "loop-tok".into(),
363 ),
364 ];
365 pin_peer_endpoints(&mut state, "alice", &endpoints).unwrap();
366 let alice = &state["peers"]["alice"];
367 assert_eq!(alice["relay_url"], "https://wireup.net");
369 assert_eq!(alice["slot_id"], "abc");
370 assert_eq!(alice["slot_token"], "tok");
371 let eps = alice["endpoints"].as_array().unwrap();
373 assert_eq!(eps.len(), 2);
374 }
375
376 #[test]
377 fn self_endpoints_back_compat_falls_back_to_legacy_fields() {
378 let state = json!({
379 "self": {
380 "relay_url": "https://wireup.net",
381 "slot_id": "self-fed",
382 "slot_token": "t1"
383 }
384 });
385 let eps = self_endpoints(&state);
386 assert_eq!(eps.len(), 1);
387 assert_eq!(eps[0].scope, EndpointScope::Federation);
388 assert_eq!(eps[0].slot_id, "self-fed");
389 }
390
391 #[test]
392 fn self_endpoints_returns_both_when_dual_slot() {
393 let state = json!({
394 "self": {
395 "endpoints": [
396 {"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t1", "scope": "federation"},
397 {"relay_url": "http://127.0.0.1:8771", "slot_id": "self-loop", "slot_token": "t2", "scope": "local"}
398 ]
399 }
400 });
401 let eps = self_endpoints(&state);
402 assert_eq!(eps.len(), 2);
403 }
404}