From 0fbf6c1cbef0fb6cfec4ce4e1a80fc8253e9f51b Mon Sep 17 00:00:00 2001 From: rldyourmnd Date: Tue, 22 Sep 2026 22:23:15 +0500 Subject: [PATCH 1/2] =?UTF-8?q?feat(relay):=20native=20TLS=20=E2=80=94=20m?= =?UTF-8?q?anual=20PEM=20or=20Let's=20Encrypt=20ACME?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The documented recommendation was a TLS terminator in front of 3340; wiring iroh-relay's TlsConfig through serve() was listed as future work. This implements it: - RelayTls::Manual — PEM cert chain + key via rustls ring provider. - RelayTls::LetsEncrypt — in-process ACME (TLS-ALPN-01) with a certificate cache dir so restarts do not re-issue and hit LE rate limits. - Both rds-server and rds-relay gain --tls-cert/--tls-key, --tls-acme-*, --tls-https-addr (default 3443 — the unprivileged systemd unit cannot bind 443). - tls_from_flags() shares flag validation between the two binaries. - HTTP port keeps serving only the captive-portal probe; /healthz (built into iroh-relay) is documented for monitoring. - Deployment doc + unit comments updated; smoke-verified: HTTPS listener serves /healthz 200 over real TLS. Also changelog entry covering this and the merged lifecycle fixes. --- CHANGELOG.md | 15 ++++ Cargo.lock | 2 + Cargo.toml | 2 + crates/rds-relay/Cargo.toml | 2 + crates/rds-relay/src/lib.rs | 121 +++++++++++++++++++++++++++++- crates/rds-relay/src/main.rs | 37 ++++++++- crates/rds-server/src/main.rs | 37 ++++++++- deploy/systemd/rds-server.service | 5 ++ docs/deployment.md | 30 ++++++-- 9 files changed, 241 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2a6bbc..dc61e4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ ## [Unreleased] +- Relay TLS and lifecycle polish: + - `rds-server`/`rds-relay` gain native TLS on the relay listener: + `--tls-cert/--tls-key` for PEM files (rustls `ring` provider), or + in-process Let's Encrypt via `--tls-acme-domain/--tls-acme-contact/ + --tls-acme-cache` (TLS-ALPN-01, needs :443). HTTPS binds + `--tls-https-addr` (default 3443); the HTTP port keeps only the + captive-portal probe and `/healthz` for monitoring. + - All binaries shut down gracefully: `rds` closes its endpoint on + exit (no more iroh "ungraceful abort" on `rds id`/`ticket`), and + `rds-agent`/`rds-server`/`rds-relay` handle SIGINT+SIGTERM — + peers get `CONNECTION_CLOSE` and relay websockets close cleanly + under `systemctl stop` instead of dying mid-accept. + - `rds id` no longer binds a socket: it prints the key's identity + directly, so it is instant, offline, and refuses clearly when no + key file can be resolved instead of printing a fresh ephemeral id. - Stability/latency hardening across the workspace: - `rds-net`: the uni demux hands each accepted stream its own tag-read task under a 10s bound — a peer that opens a stream and diff --git a/Cargo.lock b/Cargo.lock index a75863a..8954c69 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2964,6 +2964,8 @@ dependencies = [ "postcard", "rds-core", "rds-net", + "rustls", + "rustls-pki-types", "tokio", "tracing", "tracing-subscriber", diff --git a/Cargo.toml b/Cargo.toml index 7d7e971..6b57754 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,8 @@ noq-proto = "1" postcard = "1" proptest = "1" rand = "0.9" +rustls = { version = "0.23", default-features = false, features = ["std", "logging", "tls12", "ring"] } +rustls-pki-types = { version = "1", features = ["alloc"] } rds-agent = { path = "crates/rds-agent" } rds-audio = { path = "crates/rds-audio" } rds-bench = { path = "crates/rds-bench" } diff --git a/crates/rds-relay/Cargo.toml b/crates/rds-relay/Cargo.toml index d43d9c4..a346859 100644 --- a/crates/rds-relay/Cargo.toml +++ b/crates/rds-relay/Cargo.toml @@ -14,6 +14,8 @@ iroh-relay = { workspace = true, features = ["server"] } postcard = { workspace = true, features = ["alloc", "use-std"] } rds-core.workspace = true rds-net = { workspace = true, optional = true } +rustls.workspace = true +rustls-pki-types.workspace = true tokio.workspace = true tracing.workspace = true tracing-subscriber.workspace = true diff --git a/crates/rds-relay/src/lib.rs b/crates/rds-relay/src/lib.rs index 1c148c5..07c80ca 100644 --- a/crates/rds-relay/src/lib.rs +++ b/crates/rds-relay/src/lib.rs @@ -15,11 +15,13 @@ pub mod server; use std::collections::HashSet; use std::net::SocketAddr; +use std::path::PathBuf; use std::sync::Arc; use iroh::EndpointId; use iroh_relay::server::{ - Access, AccessControl, ClientRequest, ConnectionId, RelayConfig, Server, ServerConfig, + Access, AccessControl, AcmeConfig, CertConfig, ClientRequest, ConnectionId, RelayConfig, + Server, ServerConfig, TlsConfig, }; /// Admit only endpoint ids on the relay allowlist. @@ -40,14 +42,129 @@ impl AccessControl for AllowList { fn on_disconnect(&self, _endpoint_id: EndpointId, _connection_id: ConnectionId) {} } +/// TLS mode for the relay's HTTPS listener. +/// +/// When set, the relay protocol moves to `https_addr`; `addr` keeps +/// serving only the plaintext captive-portal probe. Endpoints then dial +/// `--relay https://:`. +#[derive(Debug)] +pub enum RelayTls { + /// PEM certificate chain and private key files (e.g. certbot or an + /// internal CA). Relayed payloads stay end-to-end encrypted either + /// way; TLS here hides relay *usage* metadata from on-path observers. + Manual { + https_addr: SocketAddr, + cert_pem: PathBuf, + key_pem: PathBuf, + }, + /// Let's Encrypt via in-process ACME (TLS-ALPN-01). Requires the + /// HTTPS listener on port 443, reachable from the internet. + LetsEncrypt { + https_addr: SocketAddr, + domains: Vec, + /// ACME contacts; emails need a `mailto:` prefix. + contact: Vec, + /// Directory caching issued certificates across restarts. + cache_dir: PathBuf, + /// Use the LE staging directory (for tests; certs untrusted). + staging: bool, + }, +} + /// Spawn the iroh relay on `addr`, restricted to `allow` when non-empty. /// Returns the bound server; dropping it stops the relay. -pub async fn serve(addr: SocketAddr, allow: Vec) -> anyhow::Result { +pub async fn serve( + addr: SocketAddr, + allow: Vec, + tls: Option, +) -> anyhow::Result { let mut relay_config = RelayConfig::new(addr); if !allow.is_empty() { relay_config.access = Arc::new(AllowList(allow.into_iter().collect())); } + if let Some(tls) = tls { + relay_config.tls = Some(tls_config(tls)?); + } let mut config = ServerConfig::default(); config.relay = Some(relay_config); Ok(Server::spawn(config).await?) } + +/// Resolve CLI-shaped TLS flags into a [`RelayTls`]. +/// +/// `None` when neither manual nor ACME flags are present. ACME requires a +/// cache directory: without one every restart re-issues certificates and +/// hits Let's Encrypt rate limits. +pub fn tls_from_flags( + https_addr: SocketAddr, + cert: Option, + key: Option, + acme_domains: Vec, + acme_contact: Vec, + acme_cache: Option, + acme_staging: bool, +) -> anyhow::Result> { + match (cert, key, acme_domains.is_empty()) { + (Some(cert_pem), Some(key_pem), _) => Ok(Some(RelayTls::Manual { + https_addr, + cert_pem, + key_pem, + })), + (None, None, false) => { + let cache_dir = acme_cache + .ok_or_else(|| anyhow::anyhow!("--tls-acme-cache is required for ACME"))?; + Ok(Some(RelayTls::LetsEncrypt { + https_addr, + domains: acme_domains, + contact: acme_contact, + cache_dir, + staging: acme_staging, + })) + } + (None, None, true) => Ok(None), + // clap `requires`/`conflicts_with` guard the halves. + _ => anyhow::bail!("--tls-cert and --tls-key must be given together"), + } +} + +fn tls_config(tls: RelayTls) -> anyhow::Result { + // ring: pure-Rust provider already in the tree; aws-lc-rs would add a + // C build dependency for no gain here. + let provider = Arc::new(rustls::crypto::ring::default_provider()); + let builder = rustls::ServerConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions()?; + Ok(match tls { + RelayTls::Manual { + https_addr, + cert_pem, + key_pem, + } => { + use rustls_pki_types::pem::PemObject; + let certs = rustls_pki_types::CertificateDer::pem_file_iter(&cert_pem)? + .collect::, _>>()?; + let key = rustls_pki_types::PrivateKeyDer::from_pem_file(&key_pem)?; + let server_config = builder.with_no_client_auth().with_single_cert(certs, key)?; + TlsConfig::new(https_addr, CertConfig::Manual { server_config }) + } + RelayTls::LetsEncrypt { + https_addr, + domains, + contact, + cache_dir, + staging, + } => { + let acme = AcmeConfig::letsencrypt(!staging) + .domains(domains) + .contact(contact) + .cache_path(cache_dir); + TlsConfig::new( + https_addr, + CertConfig::LetsEncrypt { + acme_config: acme, + // iroh-relay injects the ACME cert resolver itself. + server_config_builder: builder.with_no_client_auth(), + }, + ) + } + }) +} diff --git a/crates/rds-relay/src/main.rs b/crates/rds-relay/src/main.rs index de091c2..fb5c6e2 100644 --- a/crates/rds-relay/src/main.rs +++ b/crates/rds-relay/src/main.rs @@ -18,6 +18,29 @@ struct Cli { /// Restrict relay use to these endpoint ids. Empty = open relay. #[arg(long = "allow")] allow: Vec, + /// PEM certificate chain enabling HTTPS relaying. Requires --tls-key. + /// Unprivileged services cannot bind :443 — use --tls-https-addr ≥1024. + #[arg(long, requires = "tls_key")] + tls_cert: Option, + /// PEM private key for --tls-cert. + #[arg(long, requires = "tls_cert")] + tls_key: Option, + /// HTTPS bind address when TLS is enabled. ACME validation needs :443. + #[arg(long, default_value = "0.0.0.0:3443")] + tls_https_addr: SocketAddr, + /// Let's Encrypt domain via in-process ACME (TLS-ALPN-01, needs :443 + /// reachable). Repeatable. Mutually exclusive with --tls-cert. + #[arg(long, conflicts_with = "tls_cert")] + tls_acme_domain: Vec, + /// ACME contact (repeatable); emails need a `mailto:` prefix. + #[arg(long, requires = "tls_acme_domain")] + tls_acme_contact: Vec, + /// Directory caching issued ACME certificates across restarts. + #[arg(long, requires = "tls_acme_domain")] + tls_acme_cache: Option, + /// Use the Let's Encrypt staging directory (untrusted certs; testing). + #[arg(long)] + tls_acme_staging: bool, } #[tokio::main] @@ -34,11 +57,23 @@ async fn main() -> anyhow::Result<()> { .map(|s| s.parse()) .collect::>()?; - let server = rds_relay::serve(cli.addr, allow).await?; + let tls = rds_relay::tls_from_flags( + cli.tls_https_addr, + cli.tls_cert, + cli.tls_key, + cli.tls_acme_domain, + cli.tls_acme_contact, + cli.tls_acme_cache, + cli.tls_acme_staging, + )?; + let server = rds_relay::serve(cli.addr, allow, tls).await?; println!( "relay listening on http://{}", server.http_addr().expect("relay config enabled") ); + if let Some(addr) = server.https_addr() { + println!("relay tls url: https://{addr}"); + } shutdown_signal().await; // Graceful stop: close listener + client websockets instead of // letting attached endpoints hit a silent RST. diff --git a/crates/rds-server/src/main.rs b/crates/rds-server/src/main.rs index 39fdf47..5c450d2 100644 --- a/crates/rds-server/src/main.rs +++ b/crates/rds-server/src/main.rs @@ -46,6 +46,29 @@ struct Cli { /// JSON file with the initial estate-signed registry snapshot. #[arg(long)] registry: Option, + /// PEM certificate chain enabling HTTPS relaying. Requires --tls-key. + /// Unprivileged services cannot bind :443 — use --tls-https-addr ≥1024. + #[arg(long, requires = "tls_key")] + tls_cert: Option, + /// PEM private key for --tls-cert. + #[arg(long, requires = "tls_cert")] + tls_key: Option, + /// HTTPS bind address when relay TLS is enabled. ACME needs :443. + #[arg(long, default_value = "0.0.0.0:3443")] + tls_https_addr: SocketAddr, + /// Let's Encrypt domain via in-process ACME (TLS-ALPN-01, needs :443 + /// reachable). Repeatable. Mutually exclusive with --tls-cert. + #[arg(long, conflicts_with = "tls_cert")] + tls_acme_domain: Vec, + /// ACME contact (repeatable); emails need a `mailto:` prefix. + #[arg(long, requires = "tls_acme_domain")] + tls_acme_contact: Vec, + /// Directory caching issued ACME certificates across restarts. + #[arg(long, requires = "tls_acme_domain")] + tls_acme_cache: Option, + /// Use the Let's Encrypt staging directory (untrusted certs; testing). + #[arg(long)] + tls_acme_staging: bool, } #[tokio::main] @@ -103,8 +126,20 @@ async fn main() -> anyhow::Result<()> { .iter() .map(|s| s.parse()) .collect::>()?; - let relay = rds_relay::serve(cli.relay_addr, allow).await?; + let tls = rds_relay::tls_from_flags( + cli.tls_https_addr, + cli.tls_cert, + cli.tls_key, + cli.tls_acme_domain, + cli.tls_acme_contact, + cli.tls_acme_cache, + cli.tls_acme_staging, + )?; + let relay = rds_relay::serve(cli.relay_addr, allow, tls).await?; info!(addr = %relay.http_addr().expect("relay config enabled"), "relay listening"); + if let Some(addr) = relay.https_addr() { + info!(%addr, "relay tls listening"); + } shutdown_signal().await; // Graceful stop: close listener + client websockets instead of diff --git a/deploy/systemd/rds-server.service b/deploy/systemd/rds-server.service index c538882..947edec 100644 --- a/deploy/systemd/rds-server.service +++ b/deploy/systemd/rds-server.service @@ -22,6 +22,11 @@ ExecStart=/usr/local/bin/rds-server \ # --registry-key --registry /var/lib/rds/registry.json # --allow … (empty = open relay — do NOT expose open # relays on a public IP beyond short-lived bring-up) +# Optional native relay TLS (docs/deployment.md): +# --tls-cert /var/lib/rds/cert.pem --tls-key /var/lib/rds/key.pem +# or --tls-acme-domain … --tls-acme-cache /var/lib/rds/acme +# ACME's TLS-ALPN-01 needs :443 reachable — that also requires +# AmbientCapabilities=CAP_NET_BIND_SERVICE below (default :3443). Environment=RUST_LOG=info Restart=on-failure diff --git a/docs/deployment.md b/docs/deployment.md index 5e368ea..5637b57 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -68,12 +68,30 @@ endpoint's discovered addresses). (WebSocket over HTTP) on 3340 in plaintext: relayed payloads are end-to-end-encrypted QUIC the relay cannot read, and relay admission is keyed by `EndpointId` signature challenge, so a network MITM can only -disrupt, not decrypt. For defence-in-depth on a public IP, terminate TLS -in front of 3340 with any TCP-level TLS terminator (nginx `stream`, -haproxy) and give endpoints `--relay https://:`; the -relay protocol rides WebSocket inside TLS unchanged. The embedded -iroh-relay also supports ACME natively — wiring `TlsConfig` through -`rds_relay::serve` is future work, not required for launch. +disrupt, not decrypt. For defence-in-depth on a public IP the relay +supports native TLS in two modes: + +```bash +# manual PEM (certbot, internal CA, any provider) +rds-server --tls-cert /etc/rds/cert.pem --tls-key /etc/rds/key.pem + +# or in-process Let's Encrypt (TLS-ALPN-01 — requires port 443 +# reachable from the internet, so a privileged bind or a redirect) +rds-server --tls-acme-domain relay.example.com \ + --tls-acme-contact mailto:ops@example.com \ + --tls-acme-cache /var/lib/rds/acme +``` + +HTTPS binds `--tls-https-addr` (default `0.0.0.0:3443`; the +unprivileged unit cannot bind 443 — for 443 add +`AmbientCapabilities=CAP_NET_BIND_SERVICE` to the unit). The plaintext +HTTP port keeps serving only the captive-portal probe. Endpoints then +dial `--relay https://:`; the relay protocol rides WebSocket +inside TLS unchanged. A TCP-level TLS terminator (nginx `stream`, +haproxy) in front of 3340 remains a valid alternative. + +The relay serves `GET /healthz` → `200` on its HTTP(S) listener for +load-balancer and monitoring probes. **No tunnel/VPN dependency.** The design assumes only *outbound* connectivity from endpoints: tcp/3340 (relay) + tcp/3341 (directory) + From 27abd011583bbc92b6463555dac0aa49420a9ce4 Mon Sep 17 00:00:00 2001 From: rldyourmnd Date: Tue, 22 Sep 2026 22:23:21 +0500 Subject: [PATCH 2/2] fix(cli): key-only id; bounded ticket online wait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rds id is a pure function of the secret key — it no longer binds a socket or contacts a relay (13ms, offline). With no resolvable key file it now refuses clearly instead of printing a fresh ephemeral endpoint id on every run. - rds ticket bounds its online() wait at 15s: an unreachable relay previously hung the command forever; now it warns and still prints the ticket with whatever addresses resolved. --- crates/rds-cli/src/main.rs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/crates/rds-cli/src/main.rs b/crates/rds-cli/src/main.rs index bf6f3c4..60bb250 100644 --- a/crates/rds-cli/src/main.rs +++ b/crates/rds-cli/src/main.rs @@ -106,6 +106,16 @@ async fn main() -> anyhow::Result<()> { .map(|p| load_or_create_key(&p)) .transpose()?, }; + // `id` is a pure function of the key: no socket, no relay contact. + // (An unresolvable key previously fell back to an ephemeral identity, + // printing a different id on every run.) + if let Command::Id = cli.command { + let key = key.ok_or_else(|| { + anyhow::anyhow!("no endpoint key: pass --key-file or set HOME/XDG_CONFIG_HOME") + })?; + println!("{}", key.public()); + return Ok(()); + } let backend = match cli.backend.as_str() { "iroh" => rds_net::Backend::Iroh, #[cfg(feature = "transport-noq")] @@ -130,9 +140,16 @@ async fn main() -> anyhow::Result<()> { }) .transpose()?; match cli.command { - Command::Id => println!("{}", endpoint.id()), + Command::Id => unreachable!("handled before endpoint bind"), Command::Ticket => { - endpoint.online().await; + // Bound the wait: an unreachable relay must not hang the + // command; the ticket still carries any resolved addresses. + if tokio::time::timeout(std::time::Duration::from_secs(15), endpoint.online()) + .await + .is_err() + { + eprintln!("warning: relay unreachable after 15s; ticket may lack a relay address"); + } println!("{}", Ticket::of(&endpoint)); } Command::Ping { target, count } => {