Skip to main content

borg_mesh/
addr.rs

1//! How a node is reached: by its key, with optional hints, through the relay its collective uses.
2//!
3//! - **Relay** (the collective's `relay` in collectives.json, `BORG_RELAY` overrides it):
4//!   `default` uses n0's public relays and n0's DNS address lookup (pkarr), so two laptops behind
5//!   different NATs find each other by key alone and iroh hole-punches a direct path, falling back
6//!   to the relay when it cannot. A URL (or several, comma-separated) uses a self-hosted
7//!   `iroh-relay` and no public lookup. `disabled` dials direct addresses only (LAN or VPN). Traffic
8//!   is end-to-end encrypted either way: a relay forwards QUIC packets it cannot read.
9//! - **Target** (`<key>[@<hint>,<hint>...]`): what `sync`, `peer` and peers.json name. A hint is a
10//!   direct address (`ip:port`, the old `<key>@<ip:port>` form) or a relay URL (`https://...`). The
11//!   key is what counts: hints only save a lookup.
12
13use std::net::SocketAddr;
14
15use iroh::{EndpointAddr, PublicKey, RelayMode, RelayUrl};
16
17use crate::from_hex;
18
19pub const ENV: &str = "BORG_RELAY";
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum Relay {
23    /// n0's public relays and DNS address lookup.
24    Default,
25    /// Direct addresses only.
26    Disabled,
27    /// Self-hosted relays, no public address lookup.
28    Custom(Vec<RelayUrl>),
29}
30
31impl Relay {
32    /// `BORG_RELAY` if set, else the collective's setting, else `default`.
33    pub fn choose(configured: Option<&str>) -> Result<Relay, String> {
34        let env = std::env::var(ENV).ok().filter(|v| !v.trim().is_empty());
35        Relay::parse(env.as_deref().or(configured).unwrap_or("default"))
36    }
37
38    pub fn parse(s: &str) -> Result<Relay, String> {
39        match s.trim() {
40            "" | "default" | "n0" => Ok(Relay::Default),
41            "disabled" | "off" | "none" => Ok(Relay::Disabled),
42            urls => urls.split(',').map(|u| relay_url(u.trim())).collect::<Result<Vec<_>, _>>().map(Relay::Custom),
43        }
44    }
45
46    /// The setting as collectives.json spells it.
47    pub fn setting(&self) -> String {
48        match self {
49            Relay::Default => "default".into(),
50            Relay::Disabled => "disabled".into(),
51            Relay::Custom(urls) => urls.iter().map(|u| u.to_string()).collect::<Vec<_>>().join(","),
52        }
53    }
54
55    pub fn mode(&self) -> RelayMode {
56        match self {
57            Relay::Default => iroh::endpoint::default_relay_mode(),
58            Relay::Disabled => RelayMode::Disabled,
59            Relay::Custom(urls) => RelayMode::custom(urls.iter().cloned()),
60        }
61    }
62}
63
64fn relay_url(u: &str) -> Result<RelayUrl, String> {
65    if !(u.starts_with("https://") || u.starts_with("http://")) {
66        return Err(format!("relay {u:?}: expected \"default\", \"disabled\" or an http(s) URL"));
67    }
68    u.parse().map_err(|e| format!("relay {u:?}: {e}"))
69}
70
71/// One hint: a direct address or a relay URL. `None` for something nobody could dial.
72fn hint(h: &str) -> Option<Result<Hint, String>> {
73    let h = h.trim();
74    if h.is_empty() {
75        return None;
76    }
77    if h.starts_with("https://") || h.starts_with("http://") {
78        return Some(relay_url(h).map(Hint::Relay));
79    }
80    Some(match h.parse::<SocketAddr>() {
81        Ok(a) if a.ip().is_unspecified() || a.port() == 0 => Err(format!("{h}: not an address a peer can dial")),
82        Ok(a) => Ok(Hint::Direct(a)),
83        Err(e) => Err(format!("bad address {h}: {e}")),
84    })
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub enum Hint {
89    Direct(SocketAddr),
90    Relay(RelayUrl),
91}
92
93impl std::fmt::Display for Hint {
94    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95        match self {
96            Hint::Direct(a) => write!(f, "{a}"),
97            Hint::Relay(u) => write!(f, "{u}"),
98        }
99    }
100}
101
102/// Parses comma-separated hints; an empty string is no hint.
103pub fn hints(s: &str) -> Result<Vec<Hint>, String> {
104    s.split(',').filter_map(hint).collect()
105}
106
107pub fn join_hints(h: &[Hint]) -> String {
108    h.iter().map(|h| h.to_string()).collect::<Vec<_>>().join(",")
109}
110
111/// A member to dial: its key and what is known about where it is.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct Target {
114    pub key: String,
115    pub hints: Vec<Hint>,
116}
117
118impl Target {
119    /// `<key>`, `<key>@<ip:port>` or `<key>@<hint>,<hint>...`.
120    pub fn parse(s: &str) -> Result<Target, String> {
121        let (key, rest) = s.split_once('@').unwrap_or((s, ""));
122        crate::identity::check_key(key)?;
123        Ok(Target { key: key.into(), hints: hints(rest)? })
124    }
125
126    pub fn id(&self) -> Result<PublicKey, String> {
127        PublicKey::from_bytes(&from_hex::<32>(&self.key)?).map_err(|e| e.to_string())
128    }
129
130    /// The iroh address: the key, the hints, and the relays a self-hosted setting names (members of
131    /// one company share them). Refused when relays are off and no direct address is known.
132    pub fn endpoint_addr(&self, relay: &Relay) -> Result<EndpointAddr, String> {
133        let mut a = EndpointAddr::new(self.id()?);
134        for h in &self.hints {
135            a = match h {
136                Hint::Direct(d) => a.with_ip_addr(*d),
137                Hint::Relay(u) if *relay != Relay::Disabled => a.with_relay_url(u.clone()),
138                Hint::Relay(_) => a,
139            };
140        }
141        if let Relay::Custom(urls) = relay {
142            for u in urls {
143                a = a.with_relay_url(u.clone());
144            }
145        }
146        if *relay == Relay::Disabled && a.ip_addrs().next().is_none() {
147            return Err(format!("no direct address for {}: relays are disabled, give <key>@<ip:port>", self.key));
148        }
149        Ok(a)
150    }
151}
152
153impl std::fmt::Display for Target {
154    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155        if self.hints.is_empty() { write!(f, "{}", self.key) } else { write!(f, "{}@{}", self.key, join_hints(&self.hints)) }
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    const KEY: &str = "3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29";
164
165    #[test]
166    fn relay_settings_parse() {
167        assert_eq!(Relay::parse("").unwrap(), Relay::Default);
168        assert_eq!(Relay::parse("disabled").unwrap(), Relay::Disabled);
169        let Relay::Custom(u) = Relay::parse("https://relay.corp.example").unwrap() else { panic!() };
170        assert_eq!(u.len(), 1);
171        assert!(Relay::parse("relay.corp.example").is_err(), "a bare host is a typo, not a relay");
172        assert_eq!(
173            Relay::parse(&Relay::parse("https://a.example,https://b.example").unwrap().setting()).unwrap().setting(),
174            "https://a.example/,https://b.example/"
175        );
176    }
177
178    #[test]
179    fn targets_by_key_with_old_and_new_hints() {
180        let t = Target::parse(KEY).unwrap();
181        assert!(t.hints.is_empty());
182        assert!(t.endpoint_addr(&Relay::Default).is_ok(), "the key alone: address lookup finds it");
183        assert!(t.endpoint_addr(&Relay::Disabled).unwrap_err().contains("relays are disabled"));
184        let old = Target::parse(&format!("{KEY}@10.0.0.5:7878")).unwrap();
185        assert_eq!(old.hints, vec![Hint::Direct("10.0.0.5:7878".parse().unwrap())]);
186        assert_eq!(old.to_string(), format!("{KEY}@10.0.0.5:7878"));
187        let both = Target::parse(&format!("{KEY}@10.0.0.5:7878,https://relay.corp.example")).unwrap();
188        let a = both.endpoint_addr(&Relay::Default).unwrap();
189        assert_eq!((a.ip_addrs().count(), a.relay_urls().count()), (1, 1));
190        assert_eq!(Target::parse(&both.to_string()).unwrap(), both);
191        assert!(Target::parse(&format!("{KEY}@0.0.0.0:7878")).is_err());
192        assert!(Target::parse("nokey@10.0.0.5:1").is_err());
193    }
194}