1use std::str::FromStr;2use std::time::Duration;34use anyhow::{Context, Result};5use iroh::endpoint::presets::Preset;6use iroh::{RelayMap, RelayMode, RelayUrl, SecretKey};7use iroh_services::caps::{Cap, Caps, RelayCap};8use iroh_services::{ApiSecret, API_SECRET_ENV_VAR_NAME};9use serde::{Deserialize, Serialize};1011pub const RELAY_URLS_ENV_VAR_NAME: &str = "DEVSITE_RELAY_URLS";1213const RELAY_TOKEN_LIFETIME: Duration = Duration::from_secs(30 * 24 * 60 * 60);1415/// The relay settings that the control plane gives to an endpoint.1617pub struct RelayAccess {18 /// The endpoint-scoped Iroh Services token, when the server has an API secret.19 20 pub relay_token: Option<String>,21 /// Custom relay URLs. An empty list selects the n0 relay preset.22 23 pub relay_urls: Vec<String>,24}2526/// The control-plane relay configuration.27pub struct RelayConfig {28 api_secret: Option<ApiSecret>,29 relay_urls: Vec<RelayUrl>,30}3132impl RelayConfig {33 /// Load the optional Iroh Services key and custom relay URLs.34 pub fn from_env() -> Result<Self> {35 let api_secret = if std::env::var_os(API_SECRET_ENV_VAR_NAME).is_some() {36 Some(37 ApiSecret::from_env_var(API_SECRET_ENV_VAR_NAME)38 .context("reading IROH_SERVICES_API_SECRET")?,39 )40 } else {41 None42 };43 let relay_urls = std::env::var(RELAY_URLS_ENV_VAR_NAME)44 .ok()45 .map(|value| parse_relay_urls(&value))46 .transpose()?47 .unwrap_or_default();48 Ok(Self {49 api_secret,50 relay_urls,51 })52 }5354 /// Make the relay settings for one endpoint.55 pub fn access(&self, endpoint_key: &[u8; 32]) -> Result<RelayAccess> {56 let relay_token = self57 .api_secret58 .as_ref()59 .map(|api_secret| relay_token(api_secret, endpoint_key))60 .transpose()?;61 let relay_urls = self.relay_urls.iter().map(ToString::to_string).collect();62 Ok(RelayAccess {63 relay_token,64 relay_urls,65 })66 }67}6869fn parse_relay_urls(value: &str) -> Result<Vec<RelayUrl>> {70 value71 .split(',')72 .map(str::trim)73 .filter(|url| !url.is_empty())74 .map(|url| {75 RelayUrl::from_str(url)76 .with_context(|| format!("invalid relay URL in {RELAY_URLS_ENV_VAR_NAME}: {url}"))77 })78 .collect()79}8081fn relay_token(api_secret: &ApiSecret, endpoint_key: &[u8; 32]) -> Result<String> {82 let endpoint_id =83 iroh::EndpointId::from_bytes(endpoint_key).context("the relay endpoint key is invalid")?;84 let token = iroh_services::caps::create_api_token_from_secret_key(85 api_secret.secret.clone(),86 endpoint_id,87 RELAY_TOKEN_LIFETIME,88 Caps::new([Cap::Relay(RelayCap::Use)]),89 )?;90 let mut encoded = data_encoding::BASE32_NOPAD.encode(&token.encode());91 encoded.make_ascii_lowercase();92 Ok(encoded)93}9495/// The endpoint preset for the configured relay network.96pub struct DevsitePreset {97 secret_key: SecretKey,98 relay_mode: Option<RelayMode>,99}100101impl Preset for DevsitePreset {102 fn apply(self, builder: iroh::endpoint::Builder) -> iroh::endpoint::Builder {103 let builder = iroh::endpoint::presets::N0.apply(builder);104 let builder = match self.relay_mode {105 Some(relay_mode) => builder.relay_mode(relay_mode),106 None => builder,107 };108 builder.secret_key(self.secret_key)109 }110}111112/// Make an Iroh preset from relay settings supplied by the control plane.113pub fn preset(secret_key: SecretKey, access: RelayAccess) -> Result<DevsitePreset> {114 let relay_mode = relay_mode(&access)?;115 Ok(DevsitePreset {116 secret_key,117 relay_mode,118 })119}120121fn relay_mode(access: &RelayAccess) -> Result<Option<RelayMode>> {122 if access.relay_urls.is_empty() && access.relay_token.is_none() {123 return Ok(None);124 }125126 let relays = if access.relay_urls.is_empty() {127 iroh::endpoint::default_relay_mode().relay_map()128 } else {129 RelayMap::from_iter(130 access131 .relay_urls132 .iter()133 .map(|url| {134 url.parse::<RelayUrl>()135 .with_context(|| format!("invalid relay URL from the control plane: {url}"))136 })137 .collect::<Result<Vec<_>>>()?,138 )139 };140 let relays = match &access.relay_token {141 Some(token) => relays.with_auth_token(token.clone()),142 None => relays,143 };144 Ok(Some(RelayMode::Custom(relays)))145}146147148mod tests {149 use super::*;150151 152 fn relay_tokens_are_scoped_to_endpoint_keys() {153 let api_secret = ApiSecret::new(SecretKey::generate(), SecretKey::generate().public());154 let first = SecretKey::generate().public();155 let second = SecretKey::generate().public();156157 let first_token = relay_token(&api_secret, first.as_bytes()).unwrap();158 let second_token = relay_token(&api_secret, second.as_bytes()).unwrap();159160 assert_ne!(first_token, second_token);161 assert!(first_token162 .bytes()163 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()));164 }165166 167 fn relay_urls_are_configured_as_a_comma_separated_list() {168 let urls =169 parse_relay_urls("https://use1.example.test/, https://euc1.example.test/").unwrap();170171 assert_eq!(urls.len(), 2);172 assert_eq!(urls[0].to_string(), "https://use1.example.test/");173 assert_eq!(urls[1].to_string(), "https://euc1.example.test/");174 }175176 177 fn empty_access_keeps_the_n0_preset() {178 assert!(relay_mode(&RelayAccess::default()).unwrap().is_none());179 }180181 182 fn a_token_uses_the_n0_relay_map_when_urls_are_not_configured() {183 let access = RelayAccess {184 relay_token: Some("token".to_string()),185 relay_urls: Vec::new(),186 };187188 assert!(matches!(189 relay_mode(&access).unwrap(),190 Some(RelayMode::Custom(_))191 ));192 }193194 195 fn invalid_custom_relay_urls_fail_before_binding() {196 let access = RelayAccess {197 relay_token: None,198 relay_urls: vec!["not a URL".to_string()],199 };200201 assert!(relay_mode(&access).is_err());202 }203}