use std::{
net::{Ipv6Addr, SocketAddr},
num::NonZeroU32,
path::{Path, PathBuf},
sync::Arc,
};
use clap::Parser;
use http::StatusCode;
use iroh_base::EndpointId;
use iroh_relay::{
defaults::{
DEFAULT_HTTP_PORT, DEFAULT_HTTPS_PORT, DEFAULT_METRICS_PORT, DEFAULT_RELAY_QUIC_PORT,
},
server::{
self as relay, Access, AccessControl, AcmeConfig, ClientRateLimit, ClientRequest,
DEFAULT_CERT_RELOAD_INTERVAL, QuicConfig, reloading_resolver,
},
tls::CaTlsConfig,
};
use n0_error::{AnyError, Result, StdResultExt, bail_any};
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};
use tracing_subscriber::{EnvFilter, prelude::*};
use url::Url;
use webpki_types::{CertificateDer, PrivateKeyDer, pem::PemObject};
const DEV_MODE_HTTP_PORT: u16 = 3340;
const X_IROH_ENDPOINT_ID: &str = "X-Iroh-NodeId";
const ENV_HTTP_BEARER_TOKEN: &str = "IROH_RELAY_HTTP_BEARER_TOKEN";
const ENV_RELAY_ACCESS_TOKEN: &str = "IROH_RELAY_ACCESS_TOKEN";
const ENV_ACME_URL: &str = "IROH_RELAY_ACME_URL";
const ENV_ACME_CA: &str = "IROH_RELAY_ACME_CA";
#[derive(Parser, Debug, Clone)]
#[clap(version, about, long_about = None)]
struct Cli {
#[clap(long, default_value_t = false)]
dev: bool,
#[clap(long, short)]
config_path: Option<PathBuf>,
}
#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
enum CertMode {
Manual,
LetsEncrypt,
#[cfg(feature = "server")]
Reloading,
}
fn load_certs(
filename: impl AsRef<Path>,
) -> Result<Vec<rustls::pki_types::CertificateDer<'static>>> {
let filename = filename.as_ref();
CertificateDer::pem_file_iter(filename)
.with_std_context(|_| format!("failed to open certificate file at {}", filename.display()))?
.collect::<Result<Vec<_>, _>>()
.with_std_context(|_| format!("failed to read certificates from {}", filename.display()))
}
fn load_secret_key(
filename: impl AsRef<Path>,
) -> Result<rustls::pki_types::PrivateKeyDer<'static>> {
let filename = filename.as_ref();
PrivateKeyDer::from_pem_file(filename)
.with_std_context(|_| format!("failed to read secret key from {}", filename.display()))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Config {
#[serde(default = "cfg_defaults::enable_relay")]
enable_relay: bool,
http_bind_addr: Option<SocketAddr>,
tls: Option<TlsConfig>,
#[serde(default = "cfg_defaults::enable_quic_addr_discovery")]
enable_quic_addr_discovery: bool,
limits: Option<Limits>,
#[serde(default = "cfg_defaults::enable_metrics")]
enable_metrics: bool,
metrics_bind_addr: Option<SocketAddr>,
key_cache_capacity: Option<usize>,
#[serde(default)]
access: AccessConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
enum AccessConfig {
#[default]
Everyone,
Allowlist(Vec<EndpointId>),
Denylist(Vec<EndpointId>),
Http(HttpAccessConfig),
#[serde(rename = "shared_token")]
SharedToken(Vec<String>),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
struct HttpAccessConfig {
url: Url,
bearer_token: Option<String>,
}
impl TryFrom<AccessConfig> for Arc<dyn iroh_relay::server::DynAccessControl> {
type Error = AnyError;
fn try_from(cfg: AccessConfig) -> Result<Self> {
match cfg {
AccessConfig::Everyone => Ok(Arc::new(iroh_relay::server::AllowAll)),
AccessConfig::Allowlist(allow_list) => Ok(Arc::new(AllowlistAccess(allow_list))),
AccessConfig::Denylist(deny_list) => Ok(Arc::new(DenylistAccess(deny_list))),
AccessConfig::Http(mut config) => {
let client = reqwest::Client::builder()
.use_rustls_tls()
.build()
.expect("request client builder");
if let Ok(token) = std::env::var(ENV_HTTP_BEARER_TOKEN) {
config.bearer_token = Some(token);
}
Ok(Arc::new(HttpAccess { client, config }))
}
AccessConfig::SharedToken(mut tokens) => {
if let Ok(env_token) = std::env::var(ENV_RELAY_ACCESS_TOKEN) {
tokens = vec![env_token];
}
if tokens.is_empty() || tokens.iter().any(|t| t.is_empty()) {
bail_any!("access.shared_token must not be empty or contain empty strings");
}
Ok(Arc::new(SharedTokenAccess(tokens)))
}
}
}
}
#[derive(Debug)]
struct AllowlistAccess(Vec<EndpointId>);
impl AccessControl for AllowlistAccess {
async fn on_connect(&self, request: &ClientRequest) -> Access {
if self.0.contains(&request.endpoint_id()) {
Access::Allow
} else {
Access::Deny { reason: None }
}
}
}
#[derive(Debug)]
struct DenylistAccess(Vec<EndpointId>);
impl AccessControl for DenylistAccess {
async fn on_connect(&self, request: &ClientRequest) -> Access {
if self.0.contains(&request.endpoint_id()) {
Access::Deny { reason: None }
} else {
Access::Allow
}
}
}
#[derive(Debug)]
struct SharedTokenAccess(Vec<String>);
impl AccessControl for SharedTokenAccess {
async fn on_connect(&self, request: &ClientRequest) -> Access {
match request.auth_token() {
Some(token) if self.0.contains(&token) => Access::Allow,
_ => Access::Deny { reason: None },
}
}
}
#[derive(Debug)]
struct HttpAccess {
client: reqwest::Client,
config: HttpAccessConfig,
}
impl AccessControl for HttpAccess {
#[tracing::instrument("http-access-check", skip_all, fields(endpoint_id=%request.endpoint_id().fmt_short()))]
async fn on_connect(&self, request: &ClientRequest) -> Access {
debug!(url=%self.config.url, "Check relay access via HTTP POST");
match http_access_check_inner(&self.client, &self.config, request.endpoint_id()).await {
Ok(()) => {
debug!("HTTP access check OK: Allow access");
Access::Allow
}
Err(err) => {
debug!("HTTP access check failed: Deny access (reason: {err:#})");
Access::Deny { reason: None }
}
}
}
}
async fn http_access_check_inner(
client: &reqwest::Client,
config: &HttpAccessConfig,
endpoint_id: EndpointId,
) -> Result<()> {
let mut request = client
.post(config.url.clone())
.header(X_IROH_ENDPOINT_ID, endpoint_id.to_string());
if let Some(token) = config.bearer_token.as_ref() {
request = request.header(http::header::AUTHORIZATION, format!("Bearer {token}"));
}
match request.send().await {
Err(err) => {
warn!("Failed to retrieve response for HTTP access check: {err:#}");
Err(err).std_context("Failed to fetch response")
}
Ok(res) if res.status() == StatusCode::OK => match res.text().await {
Ok(text) if text == "true" => Ok(()),
Ok(_) => bail_any!("Invalid response text (must be 'true')"),
Err(err) => Err(err).std_context("Failed to read response"),
},
Ok(res) => bail_any!("Received invalid status code ({})", res.status()),
}
}
impl Config {
fn http_bind_addr(&self) -> SocketAddr {
self.http_bind_addr
.unwrap_or((Ipv6Addr::UNSPECIFIED, DEFAULT_HTTP_PORT).into())
}
fn metrics_bind_addr(&self) -> SocketAddr {
self.metrics_bind_addr
.unwrap_or_else(|| SocketAddr::new(self.http_bind_addr().ip(), DEFAULT_METRICS_PORT))
}
}
impl Default for Config {
fn default() -> Self {
Self {
enable_relay: cfg_defaults::enable_relay(),
http_bind_addr: None,
tls: None,
enable_quic_addr_discovery: cfg_defaults::enable_quic_addr_discovery(),
limits: None,
enable_metrics: cfg_defaults::enable_metrics(),
metrics_bind_addr: None,
key_cache_capacity: Default::default(),
access: AccessConfig::Everyone,
}
}
}
mod cfg_defaults {
pub(crate) fn enable_relay() -> bool {
true
}
pub(crate) fn enable_quic_addr_discovery() -> bool {
false
}
pub(crate) fn enable_metrics() -> bool {
true
}
pub(crate) mod tls_config {
pub(crate) fn prod_tls() -> bool {
true
}
pub(crate) fn dangerous_http_only() -> bool {
false
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct TlsConfig {
https_bind_addr: Option<SocketAddr>,
quic_bind_addr: Option<SocketAddr>,
#[serde(default, deserialize_with = "string_or_seq")]
hostname: Vec<String>,
cert_mode: CertMode,
cert_dir: Option<PathBuf>,
manual_cert_path: Option<PathBuf>,
manual_key_path: Option<PathBuf>,
#[serde(default = "cfg_defaults::tls_config::prod_tls")]
prod_tls: bool,
contact: Option<String>,
#[serde(default = "cfg_defaults::tls_config::dangerous_http_only")]
dangerous_http_only: bool,
}
impl TlsConfig {
fn https_bind_addr(&self, cfg: &Config) -> SocketAddr {
self.https_bind_addr
.unwrap_or_else(|| SocketAddr::new(cfg.http_bind_addr().ip(), DEFAULT_HTTPS_PORT))
}
fn quic_bind_addr(&self, cfg: &Config) -> SocketAddr {
self.quic_bind_addr.unwrap_or_else(|| {
SocketAddr::new(self.https_bind_addr(cfg).ip(), DEFAULT_RELAY_QUIC_PORT)
})
}
fn cert_dir(&self) -> PathBuf {
self.cert_dir.clone().unwrap_or_else(|| PathBuf::from("."))
}
fn cert_path(&self) -> PathBuf {
self.manual_cert_path
.clone()
.unwrap_or_else(|| self.cert_dir().join("default.crt"))
}
fn key_path(&self) -> PathBuf {
self.manual_key_path
.clone()
.unwrap_or_else(|| self.cert_dir().join("default.key"))
}
}
fn string_or_seq<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum StringOrVec {
One(String),
Many(Vec<String>),
}
Ok(match StringOrVec::deserialize(deserializer)? {
StringOrVec::One(s) => vec![s],
StringOrVec::Many(v) => v,
})
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct Limits {
accept_conn_limit: Option<f64>,
accept_conn_burst: Option<usize>,
client: Option<PerClientRateLimitConfig>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct PerClientRateLimitConfig {
rx: Option<RateLimitConfig>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct RateLimitConfig {
bytes_per_second: Option<u32>,
max_burst_bytes: Option<u32>,
}
impl Config {
async fn load(opts: &Cli) -> Result<Self> {
let config_path = if let Some(config_path) = &opts.config_path {
config_path
} else {
return Ok(Config::default());
};
if config_path.exists() {
Self::read_from_file(&config_path).await
} else {
Ok(Config::default())
}
}
fn from_str(config: &str) -> Result<Self> {
toml::from_str(config).std_context("config must be valid toml")
}
async fn read_from_file(path: impl AsRef<Path>) -> Result<Self> {
if !path.as_ref().is_file() {
bail_any!("config-path must be a file");
}
let config_ser = tokio::fs::read_to_string(&path)
.await
.std_context("unable to read config")?;
Self::from_str(&config_ser)
}
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::registry()
.with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr))
.with(EnvFilter::from_default_env())
.init();
rustls::crypto::ring::default_provider()
.install_default()
.expect("failed to set default crypto provider");
let cli = Cli::parse();
let mut cfg = Config::load(&cli).await?;
if cfg.enable_quic_addr_discovery && cfg.tls.is_none() {
bail_any!("TLS must be configured in order to spawn a QUIC endpoint");
}
if cli.dev {
if let Some(ref mut tls) = cfg.tls {
tls.dangerous_http_only = true;
}
if cfg.http_bind_addr.is_none() {
cfg.http_bind_addr = Some((Ipv6Addr::UNSPECIFIED, DEV_MODE_HTTP_PORT).into());
}
}
if cfg.tls.is_none() && cfg.enable_quic_addr_discovery {
bail_any!("If QUIC address discovery is enabled, TLS must also be configured");
};
let relay_config = build_relay_config(cfg).await?;
debug!("{relay_config:#?}");
let mut relay = relay::Server::spawn(relay_config).await?;
tokio::select! {
biased;
_ = tokio::signal::ctrl_c() => (),
_ = relay.join() => (),
}
relay.shutdown().await?;
Ok(())
}
async fn load_cert_config(tls: &TlsConfig) -> Result<relay::CertConfig> {
let server_config = rustls::ServerConfig::builder_with_provider(std::sync::Arc::new(
rustls::crypto::ring::default_provider(),
))
.with_safe_default_protocol_versions()
.expect("protocols supported by ring")
.with_no_client_auth();
let cert_config = match tls.cert_mode {
CertMode::Manual => {
let cert_path = tls.cert_path();
let key_path = tls.key_path();
let (private_key, certs) = tokio::task::spawn_blocking(move || {
let key = load_secret_key(key_path)?;
let certs = load_certs(cert_path)?;
n0_error::Ok((key, certs))
})
.await
.std_context("join")??;
let server_config = server_config
.with_single_cert(certs, private_key)
.std_context("tls config")?;
relay::CertConfig::Manual { server_config }
}
CertMode::LetsEncrypt => {
let domains = tls.hostname.clone();
if domains.is_empty() {
bail_any!("LetsEncrypt needs at least one hostname");
}
let contact = tls
.contact
.clone()
.std_context("LetsEncrypt needs a contact email")?;
let acme_config = if let Ok(url) = std::env::var(ENV_ACME_URL) {
AcmeConfig::new(url)
} else {
AcmeConfig::letsencrypt(tls.prod_tls)
};
let mut acme_config = acme_config
.domains(domains)
.contact(vec![format!("mailto:{contact}")])
.cache_path(tls.cert_dir());
if let Ok(ca_path) = std::env::var(ENV_ACME_CA) {
let extra_roots = CertificateDer::pem_file_iter(&ca_path)
.std_context("failed to read IROH_RELAY_ACME_CA")?
.collect::<Result<Vec<_>, _>>()
.std_context("failed to parse IROH_RELAY_ACME_CA")?;
acme_config =
acme_config.tls_config(CaTlsConfig::default().with_extra_roots(extra_roots));
}
relay::CertConfig::LetsEncrypt {
acme_config,
server_config_builder: server_config,
}
}
CertMode::Reloading => {
let resolver = reloading_resolver(
server_config.crypto_provider(),
tls.cert_path(),
tls.key_path(),
DEFAULT_CERT_RELOAD_INTERVAL,
)
.await?;
let server_config = server_config.with_cert_resolver(resolver);
relay::CertConfig::Manual { server_config }
}
};
Ok(cert_config)
}
async fn build_relay_config(cfg: Config) -> Result<relay::ServerConfig> {
let (tls_config, quic_config) = if let Some(cfg_tls) = &cfg.tls {
let cert = load_cert_config(cfg_tls).await?;
let quic_config = cfg
.enable_quic_addr_discovery
.then(|| QuicConfig::new(cfg_tls.quic_bind_addr(&cfg)));
if cfg_tls.dangerous_http_only {
let quic_config = match quic_config {
None => None,
Some(mut quic_config) => {
quic_config.server_config = match cert {
relay::CertConfig::Manual { server_config } => Some(server_config),
relay::CertConfig::LetsEncrypt { .. } => {
bail_any!("--dev is incompatible with cert_mode LetsEncrypt")
}
_ => bail_any!("--dev is incompatible with this cert_mode"),
};
Some(quic_config)
}
};
(None, quic_config)
} else {
let tls_config = relay::TlsConfig::new(cfg_tls.https_bind_addr(&cfg), cert);
(Some(tls_config), quic_config)
}
} else if cfg.enable_quic_addr_discovery {
bail_any!("Must have TLS configuration to enable a QUIC server for QUIC address discovery");
} else {
(None, None)
};
let limits = match cfg.limits {
Some(ref limits) => {
let client_rx = match &limits.client {
Some(PerClientRateLimitConfig { rx: Some(rx) }) => {
if rx.bytes_per_second.is_none() && rx.max_burst_bytes.is_some() {
bail_any!("bytes_per_seconds must be specified to enable the rate-limiter");
}
match rx.bytes_per_second {
Some(bps) => {
let bps = TryInto::<NonZeroU32>::try_into(bps)
.std_context("bytes_per_second must be non-zero u32")?;
let mut limit = ClientRateLimit::new(bps);
limit.max_burst_bytes = rx
.max_burst_bytes
.map(|v| {
TryInto::<NonZeroU32>::try_into(v)
.std_context("max_burst_bytes must be non-zero u32")
})
.transpose()?;
Some(limit)
}
None => None,
}
}
Some(PerClientRateLimitConfig { rx: None }) | None => None,
};
let mut out = relay::Limits::default();
out.accept_conn_limit = limits.accept_conn_limit;
out.accept_conn_burst = limits.accept_conn_burst;
out.client_rx = client_rx;
out
}
None => Default::default(),
};
let relay_config = if cfg.enable_relay {
let mut relay_config = relay::RelayConfig::new(cfg.http_bind_addr());
relay_config.tls = tls_config;
relay_config.limits = limits;
relay_config.key_cache_capacity = cfg.key_cache_capacity;
relay_config.access = cfg.access.clone().try_into()?;
Some(relay_config)
} else {
None
};
let mut server_config = relay::ServerConfig::default();
server_config.relay = relay_config;
server_config.quic = quic_config;
#[cfg(feature = "metrics")]
{
server_config.metrics_addr = Some(cfg.metrics_bind_addr()).filter(|_| cfg.enable_metrics);
}
Ok(server_config)
}
#[cfg(test)]
mod tests {
use std::num::NonZeroU32;
use iroh_base::SecretKey;
use n0_error::Result;
use rand::{RngExt, SeedableRng};
use rand_chacha::ChaCha8Rng;
use super::*;
#[tokio::test]
async fn test_rate_limit_config() -> Result {
let config = "
[limits.client.rx]
bytes_per_second = 400
max_burst_bytes = 800
";
let config = Config::from_str(config)?;
let relay_config = build_relay_config(config).await?;
let relay = relay_config.relay.expect("no relay config");
assert_eq!(
relay.limits.client_rx.expect("ratelimit").bytes_per_second,
NonZeroU32::try_from(400).unwrap()
);
assert_eq!(
relay.limits.client_rx.expect("ratelimit").max_burst_bytes,
Some(NonZeroU32::try_from(800).unwrap())
);
Ok(())
}
#[tokio::test]
async fn test_rate_limit_default() -> Result {
let config = Config::from_str("")?;
let relay_config = build_relay_config(config).await?;
let relay = relay_config.relay.expect("no relay config");
assert!(relay.limits.client_rx.is_none());
Ok(())
}
#[tokio::test]
async fn test_access_config() -> Result {
let config = "
access = \"everyone\"
";
let config = Config::from_str(config)?;
assert_eq!(config.access, AccessConfig::Everyone);
let mut rng = ChaCha8Rng::seed_from_u64(0);
let endpoint_id = SecretKey::from_bytes(&rng.random()).public();
let config = format!(
"
access.allowlist = [
\"{endpoint_id}\",
]
"
);
let config = Config::from_str(dbg!(&config))?;
assert_eq!(config.access, AccessConfig::Allowlist(vec![endpoint_id]));
let config = r#"
access.http.url = "https://example.com/foo/bar?boo=baz"
"#
.to_string();
let config = Config::from_str(dbg!(&config))?;
assert_eq!(
config.access,
AccessConfig::Http(HttpAccessConfig {
url: "https://example.com/foo/bar?boo=baz".parse().unwrap(),
bearer_token: None
})
);
let config = r#"
access.http.url = "https://example.com/foo/bar?boo=baz"
access.http.bearer_token = "foo"
"#
.to_string();
let config = Config::from_str(dbg!(&config))?;
assert_eq!(
config.access,
AccessConfig::Http(HttpAccessConfig {
url: "https://example.com/foo/bar?boo=baz".parse().unwrap(),
bearer_token: Some("foo".to_string())
})
);
let config = r#"
access.http = { url = "https://example.com/foo" }
"#
.to_string();
let config = Config::from_str(dbg!(&config))?;
assert_eq!(
config.access,
AccessConfig::Http(HttpAccessConfig {
url: "https://example.com/foo".parse().unwrap(),
bearer_token: None
})
);
let config = r#"
access.http = { url = "https://example.com/foo", bearer_token = "foo" }
"#
.to_string();
let config = Config::from_str(dbg!(&config))?;
assert_eq!(
config.access,
AccessConfig::Http(HttpAccessConfig {
url: "https://example.com/foo".parse().unwrap(),
bearer_token: Some("foo".to_string())
})
);
let config = r#"
access.shared_token = ["token-a", "token-b"]
"#
.to_string();
let config = Config::from_str(dbg!(&config))?;
assert_eq!(
config.access,
AccessConfig::SharedToken(vec!["token-a".to_string(), "token-b".to_string()])
);
Ok(())
}
#[tokio::test]
async fn test_access_token_empty_is_rejected() -> Result {
let config = r#"
access.shared_token = []
"#;
let config = Config::from_str(config)?;
assert!(
build_relay_config(config).await.is_err(),
"empty token list should be rejected at startup"
);
let config = r#"
access.shared_token = [""]
"#;
let config = Config::from_str(config)?;
assert!(
build_relay_config(config).await.is_err(),
"empty string token should be rejected at startup"
);
Ok(())
}
#[tokio::test]
async fn test_enable_relay_config() -> Result {
let config = "
enable_relay = false
";
let config = Config::from_str(config)?;
let relay_config = build_relay_config(config).await?;
assert!(relay_config.relay.is_none());
let config = "
enable_relay = true
";
let config = Config::from_str(config)?;
let relay_config = build_relay_config(config).await?;
assert!(relay_config.relay.is_some());
let config = "";
let config = Config::from_str(config)?;
let relay_config = build_relay_config(config).await?;
assert!(relay_config.relay.is_some());
Ok(())
}
}