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