diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 25d46c51..24a3ef72 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -37,14 +37,10 @@ jobs: run: cargo test --package payjoin --verbose --features=send,receive --test integration - name: test payjoin v2 integration run: cargo test --package payjoin --verbose --features=send,receive,danger-local-https,v2 --test integration + - name: test payjoin-cli bin v2 + run: cargo test --package payjoin-cli --verbose --features=danger-local-https,v2 --test e2e - name: test payjoin-cli bin v1 run: cargo test --package payjoin-cli --verbose --features=danger-local-https - - name: build payjoin-cli bin v2 - if: matrix.rust != '1.63.0' - run: cargo build --package payjoin-cli --verbose --features=v2 - - name: build payjoin-cli bin v2 with danger-local-https - if: matrix.rust != '1.63.0' - run: cargo build --package payjoin-cli --verbose --features=danger-local-https,v2 rustfmt: runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index c5d44efd..acfb886d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1706,13 +1706,19 @@ dependencies = [ "hyper-rustls 0.25.0", "log", "ohttp-relay", + "once_cell", "payjoin", + "payjoin-directory", "rcgen", "reqwest", "rustls 0.22.4", "serde", "sled", + "testcontainers", + "testcontainers-modules", "tokio", + "tracing", + "tracing-subscriber", "url", ] diff --git a/payjoin-cli/Cargo.toml b/payjoin-cli/Cargo.toml index cc8b9cf2..3d500d84 100644 --- a/payjoin-cli/Cargo.toml +++ b/payjoin-cli/Cargo.toml @@ -46,4 +46,10 @@ url = { version = "2.3.1", features = ["serde"] } bitcoind = { version = "0.31.1", features = ["0_21_2"] } http = "1" ohttp-relay = "0.0.8" +once_cell = "1" +payjoin-directory = { path = "../payjoin-directory", features = ["danger-local-https"] } +testcontainers = "0.15.0" +testcontainers-modules = { version = "0.1.3", features = ["redis"] } tokio = { version = "1.12.0", features = ["full"] } +tracing = "0.1.40" +tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } diff --git a/payjoin-cli/src/app/mod.rs b/payjoin-cli/src/app/mod.rs index cc1c9c17..5cdf8712 100644 --- a/payjoin-cli/src/app/mod.rs +++ b/payjoin-cli/src/app/mod.rs @@ -147,9 +147,7 @@ fn http_agent_builder() -> Result { use rustls::pki_types::CertificateDer; use rustls::RootCertStore; - let mut local_cert_path = std::env::temp_dir(); - local_cert_path.push(LOCAL_CERT_FILE); - let cert_der = std::fs::read(local_cert_path)?; + let cert_der = read_local_cert()?; let mut root_cert_store = RootCertStore::empty(); root_cert_store.add(CertificateDer::from(cert_der.as_slice()))?; Ok(reqwest::ClientBuilder::new() @@ -157,3 +155,10 @@ fn http_agent_builder() -> Result { .use_rustls_tls() .add_root_certificate(reqwest::tls::Certificate::from_der(cert_der.as_slice())?)) } + +#[cfg(feature = "danger-local-https")] +fn read_local_cert() -> Result> { + let mut local_cert_path = std::env::temp_dir(); + local_cert_path.push(LOCAL_CERT_FILE); + Ok(std::fs::read(local_cert_path)?) +} diff --git a/payjoin-cli/src/app/v2.rs b/payjoin-cli/src/app/v2.rs index 0b918ac8..2522c8a8 100644 --- a/payjoin-cli/src/app/v2.rs +++ b/payjoin-cli/src/app/v2.rs @@ -92,10 +92,9 @@ impl AppTrait for App { .send() .await .map_err(map_reqwest_err)?; - let session = initializer .process_res(ohttp_response.bytes().await?.to_vec().as_slice(), ctx) - .map_err(|_| anyhow!("Enrollment failed"))?; + .map_err(|e| anyhow!("Enrollment failed {}", e))?; self.db.insert_recv_session(session.clone())?; self.spawn_payjoin_receiver(session, Some(amount)).await } @@ -339,11 +338,7 @@ async fn unwrap_ohttp_keys_or_else_fetch(config: &AppConfig) -> Result; + type Result = std::result::Result; + + static INIT_TRACING: OnceCell<()> = OnceCell::new(); + static TESTS_TIMEOUT: Lazy = Lazy::new(|| Duration::from_secs(20)); + static WAIT_SERVICE_INTERVAL: Lazy = Lazy::new(|| Duration::from_secs(3)); + + env::set_var("RUST_LOG", "debug"); + init_tracing(); + let (cert, key) = local_cert_key(); + let ohttp_relay_port = find_free_port(); + let ohttp_relay = Url::parse(&format!("http://localhost:{}", ohttp_relay_port)).unwrap(); + let directory_port = find_free_port(); + let directory = Url::parse(&format!("https://localhost:{}", directory_port)).unwrap(); + let gateway_origin = http::Uri::from_str(directory.as_str()).unwrap(); + tokio::select!( + _ = ohttp_relay::listen_tcp(ohttp_relay_port, gateway_origin) => assert!(false, "Ohttp relay is long running"), + _ = init_directory(directory_port, (cert.clone(), key)) => assert!(false, "Directory server is long running"), + res = send_receive_cli_async(ohttp_relay, directory, cert) => assert!(res.is_ok(), "send_receive failed: {:?}", res), + ); + + async fn send_receive_cli_async( + ohttp_relay: Url, + directory: Url, + cert: Vec, + ) -> Result<()> { + let bitcoind_exe = env::var("BITCOIND_EXE") + .ok() + .or_else(|| bitcoind::downloaded_exe_path().ok()) + .expect("version feature or env BITCOIND_EXE is required for tests"); + let mut conf = bitcoind::Conf::default(); + conf.view_stdout = log_enabled!(Level::Debug); + let bitcoind = bitcoind::BitcoinD::with_conf(bitcoind_exe, &conf)?; + let receiver = bitcoind.create_wallet("receiver")?; + let receiver_address = + receiver.get_new_address(None, Some(AddressType::Bech32))?.assume_checked(); + let sender = bitcoind.create_wallet("sender")?; + let sender_address = + sender.get_new_address(None, Some(AddressType::Bech32))?.assume_checked(); + bitcoind.client.generate_to_address(1, &receiver_address)?; + bitcoind.client.generate_to_address(101, &sender_address)?; + + assert_eq!( + Amount::from_btc(50.0)?, + receiver.get_balances()?.mine.trusted, + "receiver doesn't own bitcoin" + ); + + assert_eq!( + Amount::from_btc(50.0)?, + sender.get_balances()?.mine.trusted, + "sender doesn't own bitcoin" + ); + let temp_dir = env::temp_dir(); + let cert_path = temp_dir.join("localhost.der"); + tokio::fs::write(&cert_path, cert.clone()).await?; + let agent = Arc::new(http_agent(cert.clone()).unwrap()); + wait_for_service_ready(ohttp_relay.clone(), agent.clone()).await?; + wait_for_service_ready(directory.clone(), agent).await?; + + // fetch for setup here since ohttp_relay doesn't know the certificate for the directory + // so payjoin-cli is set up with the mock_ohttp_relay which is the directory + let ohttp_keys = + payjoin::io::fetch_ohttp_keys(ohttp_relay.clone(), directory.clone(), cert.clone()) + .await?; + let bytes = ohttp_keys.0.encode()?; + let ohttp_keys = base64::encode_config(bytes, base64::URL_SAFE); + + let receiver_rpchost = format!("http://{}/wallet/receiver", bitcoind.params.rpc_socket); + let sender_rpchost = format!("http://{}/wallet/sender", bitcoind.params.rpc_socket); + let temp_dir = env::temp_dir(); + let receiver_db_path = temp_dir.join("receiver_db"); + let sender_db_path = temp_dir.join("sender_db"); + let cookie_file = &bitcoind.params.cookie_file; + + let payjoin_cli = env!("CARGO_BIN_EXE_payjoin-cli"); + + let directory = directory.as_str(); + // Mock ohttp_relay since the ohttp_relay's http client doesn't have the certificate for the directory + let mock_ohttp_relay = directory; + + let cli_receive_initiator = Command::new(payjoin_cli) + .arg("--rpchost") + .arg(&receiver_rpchost) + .arg("--cookie-file") + .arg(&cookie_file) + .arg("--db-path") + .arg(&receiver_db_path) + .arg("--ohttp-relay") + .arg(&mock_ohttp_relay) + .arg("receive") + .arg(RECEIVE_SATS) + .arg("--pj-directory") + .arg(&directory) + .arg("--ohttp-keys") + .arg(&ohttp_keys) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("Failed to execute payjoin-cli"); + let bip21 = get_bip21_from_receiver(cli_receive_initiator).await; + + let cli_send_initiator = Command::new(payjoin_cli) + .arg("--rpchost") + .arg(&sender_rpchost) + .arg("--cookie-file") + .arg(&cookie_file) + .arg("--db-path") + .arg(&sender_db_path) + .arg("--ohttp-relay") + .arg(&mock_ohttp_relay) + .arg("send") + .arg(&bip21) + .arg("--fee-rate") + .arg("1") + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("Failed to execute payjoin-cli"); + let _ = send_until_request_timeout(cli_send_initiator).await; + + let cli_receive_resumer = Command::new(payjoin_cli) + .arg("--rpchost") + .arg(&receiver_rpchost) + .arg("--cookie-file") + .arg(&cookie_file) + .arg("--db-path") + .arg(&receiver_db_path) + .arg("--ohttp-relay") + .arg(&mock_ohttp_relay) + .arg("resume") + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("Failed to execute payjoin-cli"); + let _ = respond_with_payjoin(cli_receive_resumer).await; + + let cli_send_resumer = Command::new(payjoin_cli) + .arg("--rpchost") + .arg(&sender_rpchost) + .arg("--cookie-file") + .arg(&cookie_file) + .arg("--db-path") + .arg(&sender_db_path) + .arg("--ohttp-relay") + .arg(&mock_ohttp_relay) + .arg("send") + .arg(&bip21) + .arg("--fee-rate") + .arg("1") + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("Failed to execute payjoin-cli"); + let _ = check_payjoin_sent(cli_send_resumer).await; + Ok(()) + } + + async fn get_bip21_from_receiver(mut cli_receiver: Child) -> String { + let stdout = + cli_receiver.stdout.take().expect("Failed to take stdout of child process"); + let reader = BufReader::new(stdout); + let mut stdout = tokio::io::stdout(); + let mut bip21 = String::new(); + + let mut lines = reader.lines(); + + while let Some(line) = lines.next_line().await.expect("Failed to read line from stdout") + { + // Write to stdout regardless + stdout + .write_all(format!("{}\n", line).as_bytes()) + .await + .expect("Failed to write to stdout"); + + if line.to_ascii_uppercase().starts_with("BITCOIN") { + bip21 = line; + break; + } + } + log::debug!("Got bip21 {}", &bip21); - fn find_free_port() -> u16 { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - listener.local_addr().unwrap().port() + cli_receiver.kill().await.expect("Failed to kill payjoin-cli"); + bip21 } + + async fn send_until_request_timeout(mut cli_sender: Child) -> Result<()> { + let stdout = cli_sender.stdout.take().expect("Failed to take stdout of child process"); + let reader = BufReader::new(stdout); + let (tx, mut rx) = tokio::sync::mpsc::channel(1); + + let mut lines = reader.lines(); + tokio::spawn(async move { + let mut stdout = tokio::io::stdout(); + while let Some(line) = + lines.next_line().await.expect("Failed to read line from stdout") + { + stdout + .write_all(format!("{}\n", line).as_bytes()) + .await + .expect("Failed to write to stdout"); + if line.contains("No response yet.") { + let _ = tx.send(true).await; + break; + } + } + }); + + let timeout = tokio::time::Duration::from_secs(35); + let fallback_sent = tokio::time::timeout(timeout, rx.recv()).await?; + + cli_sender.kill().await.expect("Failed to kill payjoin-cli initial sender"); + + assert!(fallback_sent.unwrap_or(false), "Fallback send was not detected"); + Ok(()) + } + + async fn respond_with_payjoin(mut cli_receive_resumer: Child) -> Result<()> { + let stdout = + cli_receive_resumer.stdout.take().expect("Failed to take stdout of child process"); + let reader = BufReader::new(stdout); + let (tx, mut rx) = tokio::sync::mpsc::channel(1); + + let mut lines = reader.lines(); + tokio::spawn(async move { + let mut stdout = tokio::io::stdout(); + while let Some(line) = + lines.next_line().await.expect("Failed to read line from stdout") + { + stdout + .write_all(format!("{}\n", line).as_bytes()) + .await + .expect("Failed to write to stdout"); + if line.contains("Response successful") { + let _ = tx.send(true).await; + break; + } + } + }); + + let timeout = tokio::time::Duration::from_secs(10); + let response_successful = tokio::time::timeout(timeout, rx.recv()).await?; + + cli_receive_resumer.kill().await.expect("Failed to kill payjoin-cli"); + + assert!(response_successful.unwrap_or(false), "Did not respond with Payjoin PSBT"); + Ok(()) + } + + async fn check_payjoin_sent(mut cli_send_resumer: Child) -> Result<()> { + let stdout = + cli_send_resumer.stdout.take().expect("Failed to take stdout of child process"); + let reader = BufReader::new(stdout); + let (tx, mut rx) = tokio::sync::mpsc::channel(1); + + let mut lines = reader.lines(); + tokio::spawn(async move { + let mut stdout = tokio::io::stdout(); + while let Some(line) = + lines.next_line().await.expect("Failed to read line from stdout") + { + stdout + .write_all(format!("{}\n", line).as_bytes()) + .await + .expect("Failed to write to stdout"); + if line.contains("Payjoin sent") { + let _ = tx.send(true).await; + break; + } + } + }); + + let timeout = tokio::time::Duration::from_secs(10); + let payjoin_sent = tokio::time::timeout(timeout, rx.recv()).await?; + + cli_send_resumer.kill().await.expect("Failed to kill payjoin-cli"); + + assert!(payjoin_sent.unwrap_or(false), "Payjoin send was not detected"); + Ok(()) + } + + async fn wait_for_service_ready(service_url: Url, agent: Arc) -> Result<()> { + let health_url = service_url.join("/health").map_err(|_| "Invalid URL")?; + let start = std::time::Instant::now(); + + while start.elapsed() < *TESTS_TIMEOUT { + let request_result = + agent.get(health_url.as_str()).send().await.map_err(|_| "Bad request")?; + + match request_result.status() { + StatusCode::OK => { + println!("READY {}", service_url); + return Ok(()); + } + StatusCode::NOT_FOUND => return Err("Endpoint not found".into()), + _ => std::thread::sleep(*WAIT_SERVICE_INTERVAL), + } + } + + Err("Timeout waiting for service to be ready".into()) + } + + async fn init_directory(port: u16, local_cert_key: (Vec, Vec)) -> Result<()> { + let docker: Cli = Cli::default(); + let timeout = Duration::from_secs(2); + let db = docker.run(Redis::default()); + let db_host = format!("127.0.0.1:{}", db.get_host_port_ipv4(6379)); + println!("Database running on {}", db.get_host_port_ipv4(6379)); + payjoin_directory::listen_tcp_with_tls(port, db_host, timeout, local_cert_key).await + } + + // generates or gets a DER encoded localhost cert and key. + fn local_cert_key() -> (Vec, Vec) { + let cert = rcgen::generate_simple_self_signed(vec![ + "0.0.0.0".to_string(), + "localhost".to_string(), + ]) + .expect("Failed to generate cert"); + let cert_der = cert.serialize_der().expect("Failed to serialize cert"); + let key_der = cert.serialize_private_key_der(); + (cert_der, key_der) + } + + fn http_agent(cert_der: Vec) -> Result { + Ok(http_agent_builder(cert_der)?.build()?) + } + + fn http_agent_builder(cert_der: Vec) -> Result { + Ok(ClientBuilder::new() + .danger_accept_invalid_certs(true) + .use_rustls_tls() + .add_root_certificate(reqwest::tls::Certificate::from_der(cert_der.as_slice())?)) + } + + fn init_tracing() { + INIT_TRACING.get_or_init(|| { + let subscriber = tracing_subscriber::FmtSubscriber::builder() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .with_test_writer() + .finish(); + + tracing::subscriber::set_global_default(subscriber) + .expect("failed to set global default subscriber"); + }); + } + } + + fn find_free_port() -> u16 { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.local_addr().unwrap().port() } } diff --git a/payjoin/tests/integration.rs b/payjoin/tests/integration.rs index a10ff98b..8e0f41c8 100644 --- a/payjoin/tests/integration.rs +++ b/payjoin/tests/integration.rs @@ -378,7 +378,6 @@ mod integration { let ohttp_keys = payjoin::io::fetch_ohttp_keys(ohttp_relay, directory.clone(), cert_der.clone()) .await?; - // ********************** // Inside the Receiver: let address = receiver.get_new_address(None, None)?.assume_checked();