feat(relay): add --relay / PIXELPASS_RELAY override

Both host and viewer hardcoded presets::N0, pinning every session to the
bundled relays (which on iroh rc.0 are the canary-grade defaults). Add a
shared common::endpoint::bind() that keeps N0's DNS discovery + crypto but
swaps in a RelayMode::Custom single-relay map when --relay (or the
PIXELPASS_RELAY env var, so GUI children inherit it) is set.

Lets users point at a self-hosted relay or staging today; the production
relays (*.relay.iroh.network) speak a newer protocol that rc.0 rejects
("invalid iroh-relay version header"), so they only become usable — and
the default — after an iroh GA bump. Verified: override connects cleanly
through staging; bad URLs are rejected before any network work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 15:59:36 -04:00
parent 32131b0ccb
commit eb077d81f0
6 changed files with 66 additions and 14 deletions
+17 -1
View File
@@ -68,6 +68,13 @@ pub struct Cli {
pub port: u16,
// ── global ────────────────────────────────────────────────────────
/// Relay server URL to use instead of the bundled defaults, e.g.
/// `https://relay.example/`. Applies to both host and viewer. Falls back
/// to the `PIXELPASS_RELAY` environment variable. Use this to get off the
/// pre-release default relays or to point at a self-hosted relay.
#[arg(long, value_name = "URL")]
pub relay: Option<String>,
/// Launch the graphical front-end (a window with Host/View controls)
/// instead of the terminal menu. Requires a build with `--features gui`.
#[arg(long)]
@@ -140,12 +147,16 @@ pub struct HostOpts {
pub no_hwencode: bool,
pub max_viewers: Option<u32>,
pub interactive: bool,
/// Relay override (resolved from `--relay` / `PIXELPASS_RELAY`); None = defaults.
pub relay: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ViewerOpts {
pub port: u16,
pub interactive: bool,
/// Relay override (resolved from `--relay` / `PIXELPASS_RELAY`); None = defaults.
pub relay: Option<String>,
}
impl Cli {
@@ -163,10 +174,15 @@ impl Cli {
no_hwencode: self.no_hwencode,
max_viewers: self.max_viewers,
interactive,
relay: crate::common::endpoint::relay_override(self.relay.as_deref()),
}
}
pub fn into_viewer_opts(self, interactive: bool) -> ViewerOpts {
ViewerOpts { port: self.port, interactive }
ViewerOpts {
port: self.port,
interactive,
relay: crate::common::endpoint::relay_override(self.relay.as_deref()),
}
}
}
+42
View File
@@ -0,0 +1,42 @@
//! Shared iroh endpoint construction for the host and viewer.
//!
//! Both sides bind an endpoint with the same ALPN; the only knob is the relay.
use std::str::FromStr;
use anyhow::{Context, Result};
use iroh::endpoint::presets;
use iroh::{Endpoint, RelayMap, RelayMode, RelayUrl};
use super::alpn::ALPN;
/// Environment variable consulted when `--relay` isn't passed. Lets the GUI's
/// child processes and scripted runs inherit a relay choice without a flag.
pub const RELAY_ENV: &str = "PIXELPASS_RELAY";
/// Resolve the relay override: explicit `--relay` wins, else `PIXELPASS_RELAY`,
/// else `None` (use the bundled defaults).
pub fn relay_override(flag: Option<&str>) -> Option<String> {
flag.map(str::to_owned)
.or_else(|| std::env::var(RELAY_ENV).ok().filter(|s| !s.trim().is_empty()))
}
/// Bind the iroh endpoint with our ALPN.
///
/// With no `relay` override we use [`presets::N0`] — n0 DNS discovery, the
/// library's default relays, and the chosen crypto provider. With an override
/// we keep all of that but swap in a single custom relay via
/// [`RelayMode::Custom`]; this is how a user gets off the rc's bundled
/// (canary-grade) relays or points at a self-hosted one. Discovery is
/// unchanged, so peers still resolve each other by endpoint id.
pub async fn bind(relay: Option<&str>) -> Result<Endpoint> {
let mut builder = Endpoint::builder(presets::N0).alpns(vec![ALPN.to_vec()]);
if let Some(url) = relay {
let url = RelayUrl::from_str(url)
.with_context(|| format!("invalid relay URL {url:?} (expected e.g. https://relay.example/)"))?;
builder = builder.relay_mode(RelayMode::Custom(RelayMap::from(url)));
}
builder.bind().await.context("failed to bind the iroh endpoint")
}
+1
View File
@@ -2,6 +2,7 @@ pub mod alpn;
pub mod bandwidth;
pub mod config;
pub mod deps;
pub mod endpoint;
pub mod display;
pub mod output;
pub mod process;
+3 -6
View File
@@ -7,7 +7,7 @@ mod wayland;
mod x11;
use anyhow::{Result, bail};
use iroh::endpoint::{Connection, presets};
use iroh::endpoint::Connection;
use iroh::{Endpoint, EndpointAddr};
use iroh_tickets::endpoint::EndpointTicket;
use std::collections::HashMap;
@@ -17,7 +17,7 @@ use tokio_util::sync::CancellationToken;
use crate::cli::HostOpts;
use crate::common::{
alpn::ALPN, bandwidth, config, config::BandwidthStatus, deps, display::DisplayServer, output,
bandwidth, config, config::BandwidthStatus, deps, display::DisplayServer, endpoint, output,
signal, tunnel,
};
@@ -74,10 +74,7 @@ pub async fn run(opts: HostOpts) -> Result<()> {
let cancel = signal::install_ctrl_c();
let endpoint = Endpoint::builder(presets::N0)
.alpns(vec![ALPN.to_vec()])
.bind()
.await?;
let endpoint = endpoint::bind(opts.relay.as_deref()).await?;
// Relay-only ticket: wait for the home relay to connect, then keep only
// the endpoint id + relay URL and drop the direct IP candidates. The relay
+1
View File
@@ -189,6 +189,7 @@ mod tests {
no_hwencode: false,
max_viewers,
interactive: false,
relay: None,
}
}
+2 -7
View File
@@ -1,12 +1,10 @@
use anyhow::{Context, Result, bail};
use iroh::Endpoint;
use iroh::endpoint::presets;
use iroh_tickets::endpoint::EndpointTicket;
use std::time::Duration;
use tokio::net::TcpListener;
use crate::cli::ViewerOpts;
use crate::common::{alpn::ALPN, output, signal};
use crate::common::{alpn::ALPN, endpoint, output, signal};
/// Cap on the initial QUIC connect. `endpoint.connect()` has no built-in
/// deadline, so an offline host / stale code / unreachable relay otherwise
@@ -17,10 +15,7 @@ const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
pub async fn run(ticket: EndpointTicket, opts: ViewerOpts) -> Result<()> {
let cancel = signal::install_ctrl_c();
let endpoint = Endpoint::builder(presets::N0)
.alpns(vec![ALPN.to_vec()])
.bind()
.await?;
let endpoint = endpoint::bind(opts.relay.as_deref()).await?;
let addr = ticket.endpoint_addr().clone();
tracing::info!(remote = %addr.id, "connecting to host");