clone

one developer profile for links and private tcp services, reached peer to peer over iroh with no open ports

fix: configure Iroh relay selection

Dami committed 3298c4a
Use the n0 preset by default and accept custom relay URLs. Send relay settings from the control plane to each endpoint. Prepare version 0.6.1.
commit
3298c4aec224ee2579c84439d68f38ad99c0df1d
parent
36e521e55a7b0913414f62e1d48b7d355b37757b
author
Dami <dami.etoile@gmail.com>
15 files changed, +224 -113
M.github/release-intro.md28+---
MCargo.lock14+-
MCargo.toml2+-
MREADME.md10+-
Mcrates/devsite-cli/Cargo.toml1+
Mcrates/devsite-cli/src/main.rs22+--
Mcrates/devsite-client/src/lib.rs6+-
Mcrates/devsite-daemon/src/lib.rs6+-
Mcrates/devsite-iroh/Cargo.toml1+
Mcrates/devsite-iroh/src/lib.rs201+++++++++++++++++++++++-------
Mcrates/devsite-server/src/api.rs34+++--
Mcrates/devsite-server/src/main.rs4+-
Mdeploy/README.md3+-
Mfly.toml3+
Mplugins/devsite-access/.codex-plugin/plugin.json2+-
M .github/release-intro.md +9 -19
@@ -1,29 +1,19 @@
1-dev.site v0.6.0 gives static profile pages a small server-rendered update path.1+dev.site v0.6.1 makes relay selection part of the control-plane configuration.22 33 ## Highlights44 5-- The control plane renders profile HTML for each viewer.6-- Fixi starts profile requests and handles incoming share actions.7-- SSEXi receives the initial profile and later profile changes.8-- Paxi morphs each profile fragment while the page keeps local folder state.9-- Strict TypeScript and Biome now check the browser code.10-- The control plane filters profile updates by affected account.11-12-## Identity file change13-14-Version 0.6.0 removes the legacy identity file migration promised in version 0.5.1.15-16-The CLI and daemon use `devsite-endpoint.key` and `devsite-endpoint.pub`. They do not read17-or move `identity.key` or `identity.pub`.18-19-If these legacy files remain, start version 0.5.1 once before you install version 0.6.0.5+- The control plane uses the n0 relay preset by default.6+- `IROH_SERVICES_API_SECRET` adds endpoint-scoped relay tokens.7+- `DEVSITE_RELAY_URLS` selects a comma-separated custom relay list.8+- The control plane sends the relay list and scoped token to each endpoint.9+- The dev.site deployment keeps its custom relay list in `fly.toml`.2010 2111 ## Upgrade notes2212 2313 - Database migrations are not required.24-- Existing profiles and themes continue to work.25-- Existing `devsite-endpoint.key` and `devsite-endpoint.pub` files continue to work.26-- The release workflow now checks Rust, TypeScript, Biome, and browser tests.14+- Existing endpoint identities continue to work.15+- Self-hosted deployments can remove `DEVSITE_RELAY_URLS` to use the n0 preset.16+- Self-hosted deployments can replace `DEVSITE_RELAY_URLS` with their Iroh Services relays.2717 2818 Homebrew users can upgrade with:2919
M Cargo.lock +8 -6
M Cargo.toml +1 -1
M README.md +6 -4
@@ -248,8 +248,10 @@ reader's folder state during each morph.
248248 249249 Prerequisites: stable Rust and Node.js 24.250250 251-Set `IROH_SERVICES_API_SECRET` before you start the control plane.252-The control plane uses it to make endpoint-scoped relay tokens.251+The control plane uses the n0 relay preset by default.252+Set `IROH_SERVICES_API_SECRET` to add endpoint-scoped Iroh Services tokens.253+Set `DEVSITE_RELAY_URLS` to a comma-separated list to use custom relays.254+The control plane sends the configured URLs and scoped token to each endpoint.253255 254256 ```bash255257 npm ci
@@ -306,7 +308,7 @@ the configured issuer, client, and subjects.
306308 | Path | Responsibility |307309 | --- | --- |308310 | `crates/devsite-proto` | Opaque ids, signed capabilities, and the TCP stream handshake. |309-| `crates/devsite-iroh` | Shared Iroh Services relay configuration. |311+| `crates/devsite-iroh` | Shared n0 and custom relay configuration. |310312 | `crates/devsite-client` | Native Iroh client endpoint and authorized service streams. |311313 | `crates/devsite-daemon` | Capability verification and fixed-target TCP forwarding. |312314 | `crates/devsite-cli` | Login, links, service hosting/connect, themes, and daemon lifecycle. |
@@ -323,7 +325,7 @@ the configured issuer, client, and subjects.
323325 memory, while the control plane stores its hash until expiry or disconnect.324326 - Capabilities are bound to the authenticated Iroh client endpoint and cannot be replayed325327 by another endpoint.326-- The control plane keeps the Iroh Services API key and gives each endpoint a scoped relay token.328+- When configured, the control plane keeps the Iroh Services key and gives each endpoint a scoped relay token.327329 - Each capability opens one stream and its nonce is consumed once.328330 - Unknown resources, invalid signatures, wrong audiences, wrong clients, expiry, and replay329331 all produce the same denial.
M crates/devsite-cli/Cargo.toml +1 -0
M crates/devsite-cli/src/main.rs +9 -13
@@ -388,7 +388,8 @@ struct CapabilityResponse {
388388 #[derive(Deserialize)]389389 struct RedeemTicketResponse {390390 session_token: String,391- relay_token: String,391+ #[serde(flatten)]392+ relay_access: devsite_iroh::RelayAccess,392393 resource_id: String,393394 name: String,394395 expires_at: u64,
@@ -442,16 +443,12 @@ struct TunnelSessionInfo {
442443 resource_id: String,443444 name: String,444445 requester_endpoint_id: String,445- relay_token: String,446+ #[serde(flatten)]447+ relay_access: devsite_iroh::RelayAccess,446448 expires_at: u64,447449 brokered: bool,448450 }449451 450-#[derive(Deserialize)]451-struct RegisterDaemonResponse {452- relay_token: String,453-}454-455452 #[derive(Deserialize)]456453 struct AuthorizationListing {457454 authorizations: Vec<ServiceAuthorization>,
@@ -1183,7 +1180,7 @@ async fn connect(server: &str, ticket: &str, listen: SocketAddr, output: Output)
11831180 .await11841181 .context("redeeming connection ticket")?;11851182 let client =1186- Arc::new(ClientEndpoint::create_with_relay(secret_key, redeemed.relay_token).await?);1183+ Arc::new(ClientEndpoint::create_with_relay(secret_key, redeemed.relay_access).await?);11871184 let api = Arc::new(ControlPlane::new(server, Some(redeemed.session_token)));11881185 run_tunnel(11891186 "connect",
@@ -1479,7 +1476,7 @@ async fn connect_grant(
14791476 bail!("the grant is not bound to the supplied requester endpoint key");14801477 }14811478 let client =1482- Arc::new(ClientEndpoint::create_with_relay(secret_key, session.relay_token).await?);1479+ Arc::new(ClientEndpoint::create_with_relay(secret_key, session.relay_access).await?);14831480 run_tunnel(14841481 "access.connect",14851482 server,
@@ -2850,14 +2847,13 @@ async fn run_daemon(paths: &Paths, server: &str, output: Output) -> Result<()> {
28502847 28512848 // The endpoint id is the public half of the key in DEVSITE_HOME/identity, so28522849 // it is the same on every run. Register it before the endpoint binds so the2853- // control plane can make its scoped relay token.2850+ // control plane can return its relay settings.28542851 let api = ControlPlane::new(&server_url, token);2855- let registration: RegisterDaemonResponse = api2852+ let relay_access: devsite_iroh::RelayAccess = api28562853 .put("/api/daemon", &serde_json::json!({ "proof": proof }))28572854 .await28582855 .context("registering this daemon with the control plane")?;2859- let daemon =2860- Arc::new(Daemon::bind_with_relay(secret_key, config, registration.relay_token).await?);2856+ let daemon = Arc::new(Daemon::bind_with_relay(secret_key, config, relay_access).await?);28612857 28622858 let relay = daemon28632859 .endpoint()
M crates/devsite-client/src/lib.rs +3 -3
M crates/devsite-daemon/src/lib.rs +3 -3
M crates/devsite-iroh/Cargo.toml +1 -0
M crates/devsite-iroh/src/lib.rs +155 -46
@@ -1,3 +1,4 @@
1+use std::str::FromStr;12 use std::time::Duration;23 34 use anyhow::{Context, Result};
@@ -5,71 +6,142 @@ use iroh::endpoint::presets::Preset;
56 use iroh::{RelayMap, RelayMode, RelayUrl, SecretKey};67 use iroh_services::caps::{Cap, Caps, RelayCap};78 use iroh_services::{ApiSecret, API_SECRET_ENV_VAR_NAME};9+use serde::{Deserialize, Serialize};810 9-const RELAYS: [&str; 4] = [10- "https://e67jyfby7vj7ak9h9.euc1.relay.iroh-svc.com/",11- "https://e67jyfby7vj7ak9h9.use1.relay.iroh-svc.com/",12- "https://e67jyfby7vj7ak9h9.usw1.relay.iroh-svc.com/",13- "https://e67jyfby7vj7ak9h9.aps1.relay.iroh-svc.com/",14-];11+pub const RELAY_URLS_ENV_VAR_NAME: &str = "DEVSITE_RELAY_URLS";1512 1613 const RELAY_TOKEN_LIFETIME: Duration = Duration::from_secs(30 * 24 * 60 * 60);1714 18-/// The server-side key that grants relay access to one endpoint.19-pub struct RelayIssuer {20- api_secret: ApiSecret,15+/// The relay settings that the control plane gives to an endpoint.16+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]17+pub struct RelayAccess {18+ /// The endpoint-scoped Iroh Services token, when the server has an API secret.19+ #[serde(default, skip_serializing_if = "Option::is_none")]20+ pub relay_token: Option<String>,21+ /// Custom relay URLs. An empty list selects the n0 relay preset.22+ #[serde(default, skip_serializing_if = "Vec::is_empty")]23+ pub relay_urls: Vec<String>,2124 }2225 23-impl RelayIssuer {24- /// Load the Iroh Services API secret from its standard environment variable.26+/// The control-plane relay configuration.27+pub struct RelayConfig {28+ api_secret: Option<ApiSecret>,29+ relay_urls: Vec<RelayUrl>,30+}31+32+impl RelayConfig {33+ /// Load the optional Iroh Services key and custom relay URLs.2534 pub fn from_env() -> Result<Self> {26- let api_secret = ApiSecret::from_env_var(API_SECRET_ENV_VAR_NAME)27- .context("reading IROH_SERVICES_API_SECRET")?;28- Ok(Self { api_secret })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+ })2952 }3053 31- /// Make a relay-only token for one endpoint.32- pub fn token(&self, endpoint_key: &[u8; 32]) -> Result<String> {33- let endpoint_id = iroh::EndpointId::from_bytes(endpoint_key)34- .context("the relay endpoint key is invalid")?;35- let token = iroh_services::caps::create_api_token_from_secret_key(36- self.api_secret.secret.clone(),37- endpoint_id,38- RELAY_TOKEN_LIFETIME,39- Caps::new([Cap::Relay(RelayCap::Use)]),40- )?;41- let mut encoded = data_encoding::BASE32_NOPAD.encode(&token.encode());42- encoded.make_ascii_lowercase();43- Ok(encoded)54+ /// 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+ })4466 }4567 }4668 47-/// The endpoint preset for the dev.site relay network.69+fn 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+}80+81+fn 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+}94+95+/// The endpoint preset for the configured relay network.4896 pub struct DevsitePreset {4997 secret_key: SecretKey,50- relays: RelayMap,98+ relay_mode: Option<RelayMode>,5199 }52100 53101 impl Preset for DevsitePreset {54102 fn apply(self, builder: iroh::endpoint::Builder) -> iroh::endpoint::Builder {55103 let builder = iroh::endpoint::presets::N0.apply(builder);56- builder57- .relay_mode(RelayMode::Custom(self.relays))58- .secret_key(self.secret_key)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)59109 }60110 }61111 62-/// Make the Iroh preset for the dev.site relay network.63-pub fn preset(secret_key: SecretKey, relay_token: impl Into<String>) -> Result<DevsitePreset> {64- let relays = RELAYS65- .into_iter()66- .map(|url| {67- url.parse::<RelayUrl>()68- .with_context(|| format!("invalid dev.site relay URL {url}"))69- })70- .collect::<Result<Vec<_>>>()?;71- let relays = RelayMap::from_iter(relays).with_auth_token(relay_token);72- Ok(DevsitePreset { secret_key, relays })112+/// Make an Iroh preset from relay settings supplied by the control plane.113+pub 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+}120+121+fn relay_mode(access: &RelayAccess) -> Result<Option<RelayMode>> {122+ if access.relay_urls.is_empty() && access.relay_token.is_none() {123+ return Ok(None);124+ }125+126+ 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)))73145 }74146 75147 #[cfg(test)]
@@ -79,16 +151,53 @@ mod tests {
79151 #[test]80152 fn relay_tokens_are_scoped_to_endpoint_keys() {81153 let api_secret = ApiSecret::new(SecretKey::generate(), SecretKey::generate().public());82- let issuer = RelayIssuer { api_secret };83154 let first = SecretKey::generate().public();84155 let second = SecretKey::generate().public();85156 86- let first_token = issuer.token(first.as_bytes()).unwrap();87- let second_token = issuer.token(second.as_bytes()).unwrap();157+ let first_token = relay_token(&api_secret, first.as_bytes()).unwrap();158+ let second_token = relay_token(&api_secret, second.as_bytes()).unwrap();88159 89160 assert_ne!(first_token, second_token);90161 assert!(first_token91162 .bytes()92163 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()));93164 }165+166+ #[test]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();170+171+ 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+ }175+176+ #[test]177+ fn empty_access_keeps_the_n0_preset() {178+ assert!(relay_mode(&RelayAccess::default()).unwrap().is_none());179+ }180+181+ #[test]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+ };187+188+ assert!(matches!(189+ relay_mode(&access).unwrap(),190+ Some(RelayMode::Custom(_))191+ ));192+ }193+194+ #[test]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+ };200+201+ assert!(relay_mode(&access).is_err());202+ }94203 }
M crates/devsite-server/src/api.rs +20 -14
@@ -35,7 +35,7 @@ pub struct AppState {
3535 pub issuer: Issuer,3636 pub identity_namespace: String,3737 pub public_origin: String,38- pub relay_issuer: devsite_iroh::RelayIssuer,38+ pub relay_config: devsite_iroh::RelayConfig,3939 pub profile_changes: broadcast::Sender<ProfileChange>,4040 pub profile_revision: AtomicU64,4141 }
@@ -431,7 +431,8 @@ pub struct RedeemTicketRequest {
431431 #[derive(Serialize)]432432 pub struct RedeemTicketResponse {433433 pub session_token: String,434- pub relay_token: String,434+ #[serde(flatten)]435+ pub relay_access: devsite_iroh::RelayAccess,435436 pub resource_id: String,436437 pub name: String,437438 pub expires_at: u64,
@@ -1293,11 +1294,11 @@ async fn register_daemon(
12931294 12941295 let endpoint_key = parse_endpoint_id(&machine.endpoint_id)12951296 .ok_or_else(|| ApiError::internal(anyhow::anyhow!("stored endpoint id is invalid")))?;1296- let relay_token = state1297- .relay_issuer1298- .token(&endpoint_key)1297+ let relay_access = state1298+ .relay_config1299+ .access(&endpoint_key)12991300 .map_err(ApiError::internal)?;1300- Ok(Json(serde_json::json!({ "relay_token": relay_token })))1301+ Ok(Json(relay_access))13011302 }13021303 13031304 /// Report endpoint identities without implying daemon liveness. Registration is
@@ -2336,9 +2337,9 @@ async fn redeem_connection_ticket(
23362337 23372338 Ok(Json(RedeemTicketResponse {23382339 session_token,2339- relay_token: state2340- .relay_issuer2341- .token(&client_key)2340+ relay_access: state2341+ .relay_config2342+ .access(&client_key)23422343 .map_err(ApiError::internal)?,23432344 resource_id: resource.id.to_string(),23442345 name: resource.name,
@@ -2459,15 +2460,20 @@ async fn tunnel_session_info(
24592460 {24602461 return Err(invalid_tunnel_session());24612462 }2463+ let relay_access = state2464+ .relay_config2465+ .access(2466+ &parse_endpoint_id(&session.client_endpoint_id).ok_or_else(|| {2467+ ApiError::internal(anyhow::anyhow!("stored client endpoint id is invalid"))2468+ })?,2469+ )2470+ .map_err(ApiError::internal)?;24622471 Ok(Json(serde_json::json!({24632472 "resource_id": resource.id.to_string(),24642473 "name": resource.name,24652474 "requester_endpoint_id": session.client_endpoint_id,2466- "relay_token": state.relay_issuer.token(2467- &parse_endpoint_id(&session.client_endpoint_id).ok_or_else(||2468- ApiError::internal(anyhow::anyhow!("stored client endpoint id is invalid"))2469- )?2470- ).map_err(ApiError::internal)?,2475+ "relay_token": relay_access.relay_token,2476+ "relay_urls": relay_access.relay_urls,24712477 "expires_at": session.expires_at,24722478 "brokered": session.issuer_credential_id.is_some(),24732479 })))
M crates/devsite-server/src/main.rs +2 -2
M deploy/README.md +2 -1
M fly.toml +3 -0
M plugins/devsite-access/.codex-plugin/plugin.json +1 -1