Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions swap-orchestrator/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
53 changes: 53 additions & 0 deletions swap-orchestrator/src/compose.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,28 @@ pub struct OrchestratorInput {
pub images: OrchestratorImages<OrchestratorImage>,
pub directories: OrchestratorDirectories,
pub want_tor: bool,
pub cloudflared: Option<CloudflaredConfig>,
}

/// 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/<host>/tcp/<port>/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:<internal_port>`.
pub internal_port: u16,
}

pub struct OrchestratorDirectories {
Expand All @@ -38,6 +60,7 @@ pub struct OrchestratorImages<T: IntoImageAttribute> {
pub asb_controller: T,
pub asb_tracing_logger: T,
pub rendezvous_node: T,
pub cloudflared: T,
}

pub struct OrchestratorPorts {
Expand Down Expand Up @@ -242,6 +265,35 @@ 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() {
// 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![
"cloudflared",
flag!("--no-autoupdate"),
flag!("tunnel"),
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()
};

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![
Expand Down Expand Up @@ -331,6 +383,7 @@ services:
entrypoint: ''
command: {command_electrs}
{tor_segment}
{cloudflared_segment}
asb:
container_name: asb
{image_asb}
Expand Down
4 changes: 4 additions & 0 deletions swap-orchestrator/src/images.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions swap-orchestrator/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 _;
167 changes: 165 additions & 2 deletions swap-orchestrator/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,82 @@ 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 libp2p::multiaddr::Protocol;
use std::path::PathBuf;
use std::str::FromStr;
use swap_env::config::{
Bitcoin, Config, ConfigNotInitialized, Data, Maker, Monero, Network, TorConf,
};
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<CloudflaredConfig> {
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();

Expand Down Expand Up @@ -60,11 +124,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,
Expand Down Expand Up @@ -219,12 +285,109 @@ 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");

// 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<u16> = 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);
}

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 {
Expand Down
37 changes: 26 additions & 11 deletions swap-orchestrator/tests/spec.rs
Original file line number Diff line number Diff line change
@@ -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<CloudflaredConfig>) -> OrchestratorInput {
OrchestratorInput {
ports: OrchestratorPorts {
monerod_rpc: 38081,
bitcoind_rpc: 18332,
Expand Down Expand Up @@ -38,16 +37,32 @@ 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,
};

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();
}
Loading