From c93a0eb16279cb0c756922b54a4f6a85a1a34a82 Mon Sep 17 00:00:00 2001 From: Binarybaron Date: Fri, 10 Apr 2026 14:48:26 +0200 Subject: [PATCH 1/3] feat(orchestrator): add optional Cloudflare Tunnel support Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 1 + swap-orchestrator/Cargo.toml | 1 + swap-orchestrator/src/compose.rs | 62 +++++++++++++- swap-orchestrator/src/images.rs | 4 + swap-orchestrator/src/lib.rs | 1 + swap-orchestrator/src/main.rs | 143 ++++++++++++++++++++++++++++++- swap-orchestrator/tests/spec.rs | 2 + 7 files changed, 211 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 91694af984..a7193dded0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11028,6 +11028,7 @@ dependencies = [ "chrono", "compose_spec", "dialoguer", + "libp2p", "monero-address", "serde_yaml", "swap-env", diff --git a/swap-orchestrator/Cargo.toml b/swap-orchestrator/Cargo.toml index 651de7f106..deaaa3811e 100644 --- a/swap-orchestrator/Cargo.toml +++ b/swap-orchestrator/Cargo.toml @@ -13,6 +13,7 @@ bitcoin = { workspace = true } chrono = "0.4.41" compose_spec = "0.3.0" dialoguer = { workspace = true } +libp2p = { workspace = true } monero-address = { workspace = true } serde_yaml = "0.9.34" swap-env = { path = "../swap-env" } diff --git a/swap-orchestrator/src/compose.rs b/swap-orchestrator/src/compose.rs index 9c4ce371d3..ac94203595 100644 --- a/swap-orchestrator/src/compose.rs +++ b/swap-orchestrator/src/compose.rs @@ -17,6 +17,28 @@ pub struct OrchestratorInput { pub images: OrchestratorImages, pub directories: OrchestratorDirectories, pub want_tor: bool, + pub cloudflared: Option, +} + +/// Cloudflare Tunnel configuration. +/// +/// When set, the orchestrator adds a `cloudflared` service to the compose file +/// and configures the ASB to listen on a WebSocket transport and advertise the +/// tunnel's public hostname as an external libp2p address. +#[derive(Clone)] +pub struct CloudflaredConfig { + /// The tunnel run token from the Cloudflare Zero Trust dashboard. + pub token: String, + /// The public hostname assigned to the tunnel in the Cloudflare dashboard + /// (e.g. `asb.example.com`). Advertised to peers as `/dns4//tcp//wss`. + pub external_host: String, + /// The port clients will dial on the public hostname. + /// Almost always `443` for `wss`. + pub external_port: u16, + /// The port the ASB will listen on inside the docker network for the + /// WebSocket transport. The tunnel's ingress rule should point at + /// `http://asb:`. + pub internal_port: u16, } pub struct OrchestratorDirectories { @@ -38,6 +60,7 @@ pub struct OrchestratorImages { pub asb_controller: T, pub asb_tracing_logger: T, pub rendezvous_node: T, + pub cloudflared: T, } pub struct OrchestratorPorts { @@ -242,6 +265,42 @@ fn build(input: OrchestratorInput) -> String { .format("%Y-%m-%d %H:%M:%S UTC") .to_string(); + let cloudflared_segment = if let Some(cf) = input.cloudflared.as_ref() { + let command_cloudflared = command![ + "tunnel", + flag!("--no-autoupdate"), + flag!("run"), + flag!("--token"), + flag!("{}", cf.token), + ]; + + format!( + "\ + cloudflared: + container_name: cloudflared + {image_cloudflared} + restart: unless-stopped + depends_on: + - asb + entrypoint: '' + command: {command_cloudflared}\ +", + image_cloudflared = input.images.cloudflared.to_image_attribute(), + ) + } else { + String::new() + }; + + // When cloudflared is enabled, the asb container must expose the + // WebSocket listen port on the docker network so cloudflared can reach it. + let asb_ws_expose = match input.cloudflared.as_ref() { + Some(cf) => format!( + "\n expose:\n - {internal_port}", + internal_port = cf.internal_port + ), + None => String::new(), + }; + let (tor_segment, tor_volume) = if input.want_tor { // This image comes with an empty /etc/tor/, so this is the entire config let command_tor = command![ @@ -331,6 +390,7 @@ services: entrypoint: '' command: {command_electrs} {tor_segment} + {cloudflared_segment} asb: container_name: asb {image_asb} @@ -343,7 +403,7 @@ services: - '{asb_config_path_on_host}:{asb_config_path_inside_container}' - 'asb-data:{asb_data_dir}' ports: - - '0.0.0.0:{asb_port}:{asb_port}' + - '0.0.0.0:{asb_port}:{asb_port}'{asb_ws_expose} entrypoint: '' command: {command_asb} asb-controller: diff --git a/swap-orchestrator/src/images.rs b/swap-orchestrator/src/images.rs index 1847111cbd..dfe72be3d6 100644 --- a/swap-orchestrator/src/images.rs +++ b/swap-orchestrator/src/images.rs @@ -32,6 +32,10 @@ pub static TOR_IMAGE: &str = "thetorproject/obfs4-bridge@sha256:f86a942414716db7 pub static ASB_TRACING_LOGGER_IMAGE: &str = "alpine@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1"; +/// cloudflared 2026.3.0 (https://hub.docker.com/r/cloudflare/cloudflared) +pub static CLOUDFLARED_IMAGE: &str = + "cloudflare/cloudflared@sha256:6b599ca3e974349ead3286d178da61d291961182ec3fe9c505e1dd02c8ac31b0"; + /// These are built from source pub static ASB_IMAGE_FROM_SOURCE: DockerBuildInput = DockerBuildInput { // The context is the root of the Cargo workspace diff --git a/swap-orchestrator/src/lib.rs b/swap-orchestrator/src/lib.rs index 719099c42a..5cef190a81 100644 --- a/swap-orchestrator/src/lib.rs +++ b/swap-orchestrator/src/lib.rs @@ -4,6 +4,7 @@ pub mod images; use anyhow as _; use dialoguer as _; +use libp2p as _; use swap_env as _; use toml as _; use url as _; diff --git a/swap-orchestrator/src/main.rs b/swap-orchestrator/src/main.rs index b992962220..9d573ec61f 100644 --- a/swap-orchestrator/src/main.rs +++ b/swap-orchestrator/src/main.rs @@ -6,10 +6,12 @@ mod prompt; use swap_orchestrator as _; use crate::compose::{ - ASB_DATA_DIR, DOCKER_COMPOSE_FILE, IntoSpec, OrchestratorDirectories, OrchestratorImage, - OrchestratorImages, OrchestratorInput, OrchestratorNetworks, + ASB_DATA_DIR, CloudflaredConfig, DOCKER_COMPOSE_FILE, IntoSpec, OrchestratorDirectories, + OrchestratorImage, OrchestratorImages, OrchestratorInput, OrchestratorNetworks, }; +use libp2p::Multiaddr; use std::path::PathBuf; +use std::str::FromStr; use swap_env::config::{ Bitcoin, Config, ConfigNotInitialized, Data, Maker, Monero, Network, TorConf, }; @@ -17,7 +19,68 @@ use swap_env::prompt as config_prompt; use swap_env::{defaults::GetDefaults, env::Mainnet, env::Testnet}; use url::Url; +/// Environment variables that together configure the Cloudflare Tunnel +/// integration. Either all of them must be set, or none — a partial set +/// is a hard error. +const CLOUDFLARE_ENV_VARS: [&str; 4] = [ + "CLOUDFLARE_TUNNEL_TOKEN", + "CLOUDFLARE_TUNNEL_EXTERNAL_HOST", + "CLOUDFLARE_TUNNEL_EXTERNAL_PORT", + "CLOUDFLARE_TUNNEL_INTERNAL_PORT", +]; + +/// Reads the Cloudflare Tunnel configuration from the environment. +/// +/// Returns `None` if none of the variables are set. Returns `Some(..)` if +/// all of them are set. Panics if the set is partially populated, because +/// a half-configured tunnel would silently ship a broken deployment. +fn read_cloudflared_config_from_env() -> Option { + let present: Vec<&str> = CLOUDFLARE_ENV_VARS + .iter() + .copied() + .filter(|name| std::env::var(name).is_ok()) + .collect(); + + if present.is_empty() { + return None; + } + + if present.len() != CLOUDFLARE_ENV_VARS.len() { + let missing: Vec<&str> = CLOUDFLARE_ENV_VARS + .iter() + .copied() + .filter(|name| std::env::var(name).is_err()) + .collect(); + panic!( + "Cloudflare Tunnel is partially configured. The following variables are set: {:?}, but these are missing: {:?}. Set all four or none.", + present, missing + ); + } + + let token = std::env::var("CLOUDFLARE_TUNNEL_TOKEN").expect("checked above"); + let external_host = std::env::var("CLOUDFLARE_TUNNEL_EXTERNAL_HOST").expect("checked above"); + let external_port: u16 = std::env::var("CLOUDFLARE_TUNNEL_EXTERNAL_PORT") + .expect("checked above") + .parse() + .expect("CLOUDFLARE_TUNNEL_EXTERNAL_PORT must be a valid u16"); + let internal_port: u16 = std::env::var("CLOUDFLARE_TUNNEL_INTERNAL_PORT") + .expect("checked above") + .parse() + .expect("CLOUDFLARE_TUNNEL_INTERNAL_PORT must be a valid u16"); + + Some(CloudflaredConfig { + token, + external_host, + external_port, + internal_port, + }) +} + fn main() { + // Cloudflare Tunnel is opt-in via env vars so existing deployments + // keep working unchanged. + let cloudflared_config = read_cloudflared_config_from_env(); + let want_tor = prompt::tor_for_daemons(); let (bitcoin_network, monero_network) = prompt::network(); @@ -60,11 +123,13 @@ fn main() { rendezvous_node: OrchestratorImage::Build( images::RENDEZVOUS_NODE_IMAGE_FROM_SOURCE.clone(), ), + cloudflared: OrchestratorImage::Registry(images::CLOUDFLARED_IMAGE.to_string()), }, directories: OrchestratorDirectories { asb_data_dir: PathBuf::from(ASB_DATA_DIR), }, want_tor, + cloudflared: cloudflared_config.clone(), }; // If the config file already exists and be de-serialized, @@ -219,12 +284,86 @@ fn main() { .expect("Failed to write config.toml"); } + // If Cloudflare Tunnel is enabled, ensure the ASB config advertises the + // WebSocket listen address and the public wss external address. We do this + // after the wizard branch so it applies whether the config was just + // generated or already existed on disk. + if let Some(cf) = cloudflared_config.as_ref() { + ensure_cloudflared_addresses_in_config(&recipe, cf); + } + // Write the compose to ./docker-compose.yml let compose = recipe.to_spec(); std::fs::write(DOCKER_COMPOSE_FILE, compose).expect("Failed to write docker-compose.yml"); println!(); println!("Run `docker compose up -d` to start the services."); + + if let Some(cf) = cloudflared_config.as_ref() { + print_cloudflared_instructions(cf); + } +} + +/// Reads the ASB config from disk, inserts the WebSocket listen address and +/// the public wss external address required by the Cloudflare Tunnel, and +/// writes it back. Idempotent — running this repeatedly does not duplicate +/// entries. +fn ensure_cloudflared_addresses_in_config( + recipe: &OrchestratorInput, + cf: &CloudflaredConfig, +) { + let config_path = recipe.directories.asb_config_path_on_host_as_path_buf(); + + let mut config = swap_env::config::read_config(config_path.clone()) + .expect("Failed to read asb config for cloudflared patching") + .expect("asb config must exist by this point"); + + let ws_listen: Multiaddr = Multiaddr::from_str(&format!( + "/ip4/0.0.0.0/tcp/{}/ws", + cf.internal_port + )) + .expect("ws listen multiaddr to be valid"); + + let wss_external: Multiaddr = Multiaddr::from_str(&format!( + "/dns4/{}/tcp/{}/wss", + cf.external_host, cf.external_port + )) + .expect("wss external multiaddr to be valid"); + + if !config.network.listen.contains(&ws_listen) { + config.network.listen.push(ws_listen); + } + + if !config.network.external_addresses.contains(&wss_external) { + config.network.external_addresses.push(wss_external); + } + + std::fs::write( + &config_path, + toml::to_string(&config).expect("Failed to serialize patched config.toml"), + ) + .expect("Failed to write patched config.toml"); +} + +/// Prints the manual steps the operator must take in the Cloudflare Zero +/// Trust dashboard to finish configuring the tunnel. +fn print_cloudflared_instructions(cf: &CloudflaredConfig) { + println!(); + println!("Cloudflare Tunnel is enabled. Configure it in the dashboard:"); + println!(" 1. Open https://one.dash.cloudflare.com/ -> Networks -> Tunnels"); + println!(" 2. Select the tunnel matching your CLOUDFLARE_TUNNEL_TOKEN"); + println!(" 3. Under 'Public Hostnames', add (or verify) a hostname with:"); + println!(" - Subdomain / domain: {}", cf.external_host); + println!(" - Service type: HTTP"); + println!( + " - URL: asb:{} (the asb container on the docker network)", + cf.internal_port + ); + println!( + " 4. Peers will reach this ASB at /dns4/{}/tcp/{}/wss", + cf.external_host, cf.external_port + ); + println!(" 5. Do NOT put a Cloudflare Access policy in front of this hostname — libp2p clients cannot authenticate with it."); } fn unix_epoch_secs() -> u64 { diff --git a/swap-orchestrator/tests/spec.rs b/swap-orchestrator/tests/spec.rs index a24a882031..23fe286784 100644 --- a/swap-orchestrator/tests/spec.rs +++ b/swap-orchestrator/tests/spec.rs @@ -38,11 +38,13 @@ fn test_orchestrator_spec_generation() { asb_tracing_logger: OrchestratorImage::Registry( images::ASB_TRACING_LOGGER_IMAGE.to_string(), ), + cloudflared: OrchestratorImage::Registry(images::CLOUDFLARED_IMAGE.to_string()), }, directories: OrchestratorDirectories { asb_data_dir: std::path::PathBuf::from(swap_orchestrator::compose::ASB_DATA_DIR), }, want_tor: false, + cloudflared: None, }; let spec = input.to_spec(); From ca00d0c7f8f96c36bf57e864cbbf60a3c29c7e0c Mon Sep 17 00:00:00 2001 From: Binarybaron Date: Fri, 10 Apr 2026 17:32:24 +0200 Subject: [PATCH 2/3] fix(orchestrator): cloudflared command binary, port collision check, drop expose Co-Authored-By: Claude Opus 4.6 (1M context) --- swap-orchestrator/src/compose.rs | 17 +++++------------ swap-orchestrator/src/main.rs | 24 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/swap-orchestrator/src/compose.rs b/swap-orchestrator/src/compose.rs index ac94203595..43e8d81877 100644 --- a/swap-orchestrator/src/compose.rs +++ b/swap-orchestrator/src/compose.rs @@ -266,9 +266,12 @@ fn build(input: OrchestratorInput) -> String { .to_string(); let cloudflared_segment = if let Some(cf) = input.cloudflared.as_ref() { + // We clear the image's ENTRYPOINT below, so `command` must start with + // the binary name, matching every other service in this compose file. let command_cloudflared = command![ - "tunnel", + "cloudflared", flag!("--no-autoupdate"), + flag!("tunnel"), flag!("run"), flag!("--token"), flag!("{}", cf.token), @@ -291,16 +294,6 @@ fn build(input: OrchestratorInput) -> String { String::new() }; - // When cloudflared is enabled, the asb container must expose the - // WebSocket listen port on the docker network so cloudflared can reach it. - let asb_ws_expose = match input.cloudflared.as_ref() { - Some(cf) => format!( - "\n expose:\n - {internal_port}", - internal_port = cf.internal_port - ), - None => String::new(), - }; - let (tor_segment, tor_volume) = if input.want_tor { // This image comes with an empty /etc/tor/, so this is the entire config let command_tor = command![ @@ -403,7 +396,7 @@ services: - '{asb_config_path_on_host}:{asb_config_path_inside_container}' - 'asb-data:{asb_data_dir}' ports: - - '0.0.0.0:{asb_port}:{asb_port}'{asb_ws_expose} + - '0.0.0.0:{asb_port}:{asb_port}' entrypoint: '' command: {command_asb} asb-controller: diff --git a/swap-orchestrator/src/main.rs b/swap-orchestrator/src/main.rs index 9d573ec61f..453000dc07 100644 --- a/swap-orchestrator/src/main.rs +++ b/swap-orchestrator/src/main.rs @@ -10,6 +10,7 @@ use crate::compose::{ OrchestratorImage, OrchestratorImages, OrchestratorInput, OrchestratorNetworks, }; use libp2p::Multiaddr; +use libp2p::multiaddr::Protocol; use std::path::PathBuf; use std::str::FromStr; use swap_env::config::{ @@ -330,6 +331,29 @@ fn ensure_cloudflared_addresses_in_config( )) .expect("wss external multiaddr to be valid"); + // Reject CLOUDFLARE_TUNNEL_INTERNAL_PORT values that would collide with + // a TCP port the ASB is already bound to. The ASB binds every entry in + // `config.network.listen` individually, so a clash produces `AddrInUse` + // at startup and the tunnel silently never comes up. Also check the + // well-known orchestrator ports (libp2p TCP + RPC) for the same reason. + let mut reserved_ports: Vec = vec![recipe.ports.asb_libp2p, recipe.ports.asb_rpc_port]; + for existing in &config.network.listen { + if existing == &ws_listen { + continue; + } + for proto in existing.iter() { + if let Protocol::Tcp(port) = proto { + reserved_ports.push(port); + } + } + } + if reserved_ports.contains(&cf.internal_port) { + panic!( + "CLOUDFLARE_TUNNEL_INTERNAL_PORT={} collides with a port the ASB already binds ({:?}). Pick a different internal port.", + cf.internal_port, reserved_ports + ); + } + if !config.network.listen.contains(&ws_listen) { config.network.listen.push(ws_listen); } From 01f5329a9eb589eb7ab3773c5e98a5f0d89020d8 Mon Sep 17 00:00:00 2001 From: Binarybaron Date: Fri, 10 Apr 2026 21:44:50 +0200 Subject: [PATCH 3/3] test(orchestrator): cover tor and cloudflared compose segments The previous test only generated the no-tor / no-cloudflared variant, so YAML indentation regressions in either optional segment would have slipped past `validate_compose`. Exercise all four combinations. Co-Authored-By: Claude Opus 4.6 (1M context) --- swap-orchestrator/tests/spec.rs | 37 ++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/swap-orchestrator/tests/spec.rs b/swap-orchestrator/tests/spec.rs index 23fe286784..6e10b60c99 100644 --- a/swap-orchestrator/tests/spec.rs +++ b/swap-orchestrator/tests/spec.rs @@ -1,14 +1,13 @@ #![allow(unused_crate_dependencies)] use swap_orchestrator::compose::{ - IntoSpec, OrchestratorDirectories, OrchestratorImage, OrchestratorImages, OrchestratorInput, - OrchestratorNetworks, OrchestratorPorts, + CloudflaredConfig, IntoSpec, OrchestratorDirectories, OrchestratorImage, OrchestratorImages, + OrchestratorInput, OrchestratorNetworks, OrchestratorPorts, }; use swap_orchestrator::images; -#[test] -fn test_orchestrator_spec_generation() { - let input = OrchestratorInput { +fn make_input(want_tor: bool, cloudflared: Option) -> OrchestratorInput { + OrchestratorInput { ports: OrchestratorPorts { monerod_rpc: 38081, bitcoind_rpc: 18332, @@ -43,13 +42,27 @@ fn test_orchestrator_spec_generation() { directories: OrchestratorDirectories { asb_data_dir: std::path::PathBuf::from(swap_orchestrator::compose::ASB_DATA_DIR), }, - want_tor: false, - cloudflared: None, - }; - - let spec = input.to_spec(); + want_tor, + cloudflared, + } +} - println!("{}", spec); +fn sample_cloudflared_config() -> CloudflaredConfig { + CloudflaredConfig { + token: "test-token".to_string(), + external_host: "atomic.exolix.com".to_string(), + external_port: 443, + internal_port: 8080, + } +} - // TODO: Here we should use the docker binary to verify the compose file +#[test] +fn test_orchestrator_spec_generation() { + // `to_spec` runs `validate_compose` internally, so generating each + // variant is enough to catch indentation regressions in the optional + // tor / cloudflared segments. + let _ = make_input(false, None).to_spec(); + let _ = make_input(true, None).to_spec(); + let _ = make_input(false, Some(sample_cloudflared_config())).to_spec(); + let _ = make_input(true, Some(sample_cloudflared_config())).to_spec(); }