From 6e2ec566b4890c537f0b882d79abf75698477ddb Mon Sep 17 00:00:00 2001 From: spacebear Date: Wed, 4 Feb 2026 09:55:16 -0500 Subject: [PATCH 1/4] Bump tokio-rustls-acme to 0.9.0 This satisfies trait bounds for AxumAcceptor in the next commit. --- payjoin-directory/Cargo.toml | 2 +- payjoin-service/Cargo.toml | 2 +- payjoin-service/src/lib.rs | 2 +- payjoin-test-utils/Cargo.toml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/payjoin-directory/Cargo.toml b/payjoin-directory/Cargo.toml index 9e916db86..46cda3064 100644 --- a/payjoin-directory/Cargo.toml +++ b/payjoin-directory/Cargo.toml @@ -40,7 +40,7 @@ tokio = { version = "1.47.1", features = ["full"] } tokio-rustls = { version = "0.26.2", features = [ "ring", ], default-features = false, optional = true } -tokio-rustls-acme = { version = "0.7.1", optional = true } +tokio-rustls-acme = { version = "0.9.0", optional = true } tokio-stream = { version = "0.1.17", features = ["net"] } tower = "0.5" tracing = "0.1.41" diff --git a/payjoin-service/Cargo.toml b/payjoin-service/Cargo.toml index 71bd27aa4..500261f77 100644 --- a/payjoin-service/Cargo.toml +++ b/payjoin-service/Cargo.toml @@ -20,7 +20,7 @@ _manual-tls = ["dep:axum-server", "dep:rustls", "ohttp-relay/_test-util"] [dependencies] anyhow = "1.0" axum = "0.8" -axum-server = { version = "0.7", features = [ +axum-server = { version = "0.8", features = [ "tls-rustls-no-provider", ], optional = true } clap = { version = "4.5", features = ["derive", "env"] } diff --git a/payjoin-service/src/lib.rs b/payjoin-service/src/lib.rs index 1dee13f38..cd778bfb4 100644 --- a/payjoin-service/src/lib.rs +++ b/payjoin-service/src/lib.rs @@ -71,7 +71,7 @@ pub async fn serve_manual_tls( Some(tls) => { info!("Payjoin service listening on port {} with TLS", port); tokio::spawn(async move { - axum_server::from_tcp_rustls(listener.into_std()?, tls) + axum_server::from_tcp_rustls(listener.into_std()?, tls)? .serve(app.into_make_service()) .await .map_err(Into::into) diff --git a/payjoin-test-utils/Cargo.toml b/payjoin-test-utils/Cargo.toml index 254396e27..93c58723a 100644 --- a/payjoin-test-utils/Cargo.toml +++ b/payjoin-test-utils/Cargo.toml @@ -9,7 +9,7 @@ rust-version = "1.85" license = "MIT" [dependencies] -axum-server = { version = "0.7", features = ["tls-rustls-no-provider"] } +axum-server = { version = "0.8", features = ["tls-rustls-no-provider"] } bitcoin = { version = "0.32.7", features = ["base64"] } corepc-node = { version = "0.10.0", features = ["download", "29_0"] } http = "1.3.1" From e060a784254ec9bf7980c4c7567cae4b8bdcb83d Mon Sep 17 00:00:00 2001 From: spacebear Date: Wed, 4 Feb 2026 10:10:05 -0500 Subject: [PATCH 2/4] Add ACME certificate management to payjoin-service Add an `acme` feature flag and config section modeled after the payjoin-directory feature. This implementation uses `tokio-rustls-acme`'s AxumAcceptor following the example in https://github.com/FlorianUekermann/rustls-acme/blob/main/examples/low_level_axum.rs As a result the AcmeConfig differs slightly from the one in payjoin-directory to more closely reflect rustls-acme's AcmeConfig struct. --- Cargo-minimal.lock | 99 ++++++++++++++++++----------------- Cargo-recent.lock | 99 ++++++++++++++++++----------------- flake.nix | 2 +- payjoin-service/Cargo.toml | 8 +++ payjoin-service/src/config.rs | 29 +++++++++- payjoin-service/src/lib.rs | 56 +++++++++++++++++++- payjoin-service/src/main.rs | 5 ++ payjoin-test-utils/src/lib.rs | 4 +- 8 files changed, 202 insertions(+), 100 deletions(-) diff --git a/Cargo-minimal.lock b/Cargo-minimal.lock index c24e3132a..0c2fec09b 100644 --- a/Cargo-minimal.lock +++ b/Cargo-minimal.lock @@ -211,9 +211,9 @@ dependencies = [ [[package]] name = "asn1-rs" -version = "0.6.2" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" dependencies = [ "asn1-rs-derive", "asn1-rs-impl", @@ -221,15 +221,15 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror 1.0.63", + "thiserror 2.0.17", "time", ] [[package]] name = "asn1-rs-derive" -version = "0.5.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", @@ -364,12 +364,13 @@ dependencies = [ [[package]] name = "axum-server" -version = "0.7.3" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1ab4a3ec9ea8a657c72d99a03a824af695bd0fb5ec639ccbd9cd3543b41a5f9" +checksum = "b1df331683d982a0b9492b38127151e6453639cd34926eb9c07d4cd8c6d22bfc" dependencies = [ "arc-swap", "bytes", + "either", "fs-err 3.2.2", "http", "http-body", @@ -377,7 +378,6 @@ dependencies = [ "hyper-util", "pin-project-lite", "rustls 0.23.31", - "rustls-pemfile", "rustls-pki-types", "tokio", "tokio-rustls", @@ -1204,9 +1204,9 @@ checksum = "e8566979429cf69b49a5c740c60791108e86440e8be149bbea4fe54d2c32d6e2" [[package]] name = "der-parser" -version = "9.0.0" +version = "10.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" dependencies = [ "asn1-rs", "displaydoc", @@ -1302,6 +1302,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "electrum-client" version = "0.18.0" @@ -1824,9 +1830,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.7.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" dependencies = [ "atomic-waker", "bytes", @@ -1865,14 +1871,13 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.16" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "base64 0.22.1", "bytes", "futures-channel", - "futures-core", "futures-util", "http", "http-body", @@ -2437,9 +2442,9 @@ dependencies = [ [[package]] name = "oid-registry" -version = "0.7.1" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" dependencies = [ "asn1-rs", ] @@ -2662,6 +2667,8 @@ dependencies = [ "tempfile", "tokio", "tokio-listener", + "tokio-rustls-acme", + "tokio-stream", "tower", "tracing", "tracing-subscriber", @@ -3051,19 +3058,6 @@ dependencies = [ "yasna", ] -[[package]] -name = "rcgen" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" -dependencies = [ - "pem", - "ring", - "rustls-pki-types", - "time", - "yasna", -] - [[package]] name = "rcgen" version = "0.14.3" @@ -3074,6 +3068,7 @@ dependencies = [ "ring", "rustls-pki-types", "time", + "x509-parser 0.17.0", "yasna", ] @@ -3337,15 +3332,6 @@ dependencies = [ "security-framework", ] -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "rustls-pki-types" version = "1.12.0" @@ -4083,11 +4069,12 @@ dependencies = [ [[package]] name = "tokio-rustls-acme" -version = "0.7.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f296d48ff72e0df96e2d7ef064ad5904d016a130869e542f00b08c8e05cc18cf" +checksum = "c31fcc374ec87d754358a5d0709ed1ab7671d51e0f70ddc3b17a11ac36604cfa" dependencies = [ "async-trait", + "axum-server", "base64 0.22.1", "chrono", "futures", @@ -4095,7 +4082,7 @@ dependencies = [ "num-bigint", "pem", "proc-macro2", - "rcgen 0.13.2", + "rcgen 0.14.3", "reqwest", "ring", "rustls 0.23.31", @@ -4105,8 +4092,8 @@ dependencies = [ "time", "tokio", "tokio-rustls", - "webpki-roots 0.26.11", - "x509-parser", + "webpki-roots 1.0.2", + "x509-parser 0.18.0", ] [[package]] @@ -5099,9 +5086,9 @@ checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" [[package]] name = "x509-parser" -version = "0.16.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +checksum = "4569f339c0c402346d4a75a9e39cf8dad310e287eef1ff56d4c68e5067f53460" dependencies = [ "asn1-rs", "data-encoding", @@ -5109,8 +5096,26 @@ dependencies = [ "lazy_static", "nom", "oid-registry", + "ring", "rusticata-macros", - "thiserror 1.0.63", + "thiserror 2.0.17", + "time", +] + +[[package]] +name = "x509-parser" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3e137310115a65136898d2079f003ce33331a6c4b0d51f1531d1be082b6425" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 2.0.17", "time", ] diff --git a/Cargo-recent.lock b/Cargo-recent.lock index c24e3132a..0c2fec09b 100644 --- a/Cargo-recent.lock +++ b/Cargo-recent.lock @@ -211,9 +211,9 @@ dependencies = [ [[package]] name = "asn1-rs" -version = "0.6.2" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" dependencies = [ "asn1-rs-derive", "asn1-rs-impl", @@ -221,15 +221,15 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror 1.0.63", + "thiserror 2.0.17", "time", ] [[package]] name = "asn1-rs-derive" -version = "0.5.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", @@ -364,12 +364,13 @@ dependencies = [ [[package]] name = "axum-server" -version = "0.7.3" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1ab4a3ec9ea8a657c72d99a03a824af695bd0fb5ec639ccbd9cd3543b41a5f9" +checksum = "b1df331683d982a0b9492b38127151e6453639cd34926eb9c07d4cd8c6d22bfc" dependencies = [ "arc-swap", "bytes", + "either", "fs-err 3.2.2", "http", "http-body", @@ -377,7 +378,6 @@ dependencies = [ "hyper-util", "pin-project-lite", "rustls 0.23.31", - "rustls-pemfile", "rustls-pki-types", "tokio", "tokio-rustls", @@ -1204,9 +1204,9 @@ checksum = "e8566979429cf69b49a5c740c60791108e86440e8be149bbea4fe54d2c32d6e2" [[package]] name = "der-parser" -version = "9.0.0" +version = "10.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" dependencies = [ "asn1-rs", "displaydoc", @@ -1302,6 +1302,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "electrum-client" version = "0.18.0" @@ -1824,9 +1830,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.7.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" dependencies = [ "atomic-waker", "bytes", @@ -1865,14 +1871,13 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.16" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "base64 0.22.1", "bytes", "futures-channel", - "futures-core", "futures-util", "http", "http-body", @@ -2437,9 +2442,9 @@ dependencies = [ [[package]] name = "oid-registry" -version = "0.7.1" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" dependencies = [ "asn1-rs", ] @@ -2662,6 +2667,8 @@ dependencies = [ "tempfile", "tokio", "tokio-listener", + "tokio-rustls-acme", + "tokio-stream", "tower", "tracing", "tracing-subscriber", @@ -3051,19 +3058,6 @@ dependencies = [ "yasna", ] -[[package]] -name = "rcgen" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" -dependencies = [ - "pem", - "ring", - "rustls-pki-types", - "time", - "yasna", -] - [[package]] name = "rcgen" version = "0.14.3" @@ -3074,6 +3068,7 @@ dependencies = [ "ring", "rustls-pki-types", "time", + "x509-parser 0.17.0", "yasna", ] @@ -3337,15 +3332,6 @@ dependencies = [ "security-framework", ] -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "rustls-pki-types" version = "1.12.0" @@ -4083,11 +4069,12 @@ dependencies = [ [[package]] name = "tokio-rustls-acme" -version = "0.7.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f296d48ff72e0df96e2d7ef064ad5904d016a130869e542f00b08c8e05cc18cf" +checksum = "c31fcc374ec87d754358a5d0709ed1ab7671d51e0f70ddc3b17a11ac36604cfa" dependencies = [ "async-trait", + "axum-server", "base64 0.22.1", "chrono", "futures", @@ -4095,7 +4082,7 @@ dependencies = [ "num-bigint", "pem", "proc-macro2", - "rcgen 0.13.2", + "rcgen 0.14.3", "reqwest", "ring", "rustls 0.23.31", @@ -4105,8 +4092,8 @@ dependencies = [ "time", "tokio", "tokio-rustls", - "webpki-roots 0.26.11", - "x509-parser", + "webpki-roots 1.0.2", + "x509-parser 0.18.0", ] [[package]] @@ -5099,9 +5086,9 @@ checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" [[package]] name = "x509-parser" -version = "0.16.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +checksum = "4569f339c0c402346d4a75a9e39cf8dad310e287eef1ff56d4c68e5067f53460" dependencies = [ "asn1-rs", "data-encoding", @@ -5109,8 +5096,26 @@ dependencies = [ "lazy_static", "nom", "oid-registry", + "ring", "rusticata-macros", - "thiserror 1.0.63", + "thiserror 2.0.17", + "time", +] + +[[package]] +name = "x509-parser" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3e137310115a65136898d2079f003ce33331a6c4b0d51f1531d1be082b6425" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 2.0.17", "time", ] diff --git a/flake.nix b/flake.nix index af3bd1e83..cc33a26d5 100644 --- a/flake.nix +++ b/flake.nix @@ -162,7 +162,7 @@ "payjoin-cli" = "--features v1,v2"; "payjoin-directory" = ""; "ohttp-relay" = ""; - "payjoin-service" = ""; + "payjoin-service" = "--features acme"; }; # nix2container for building OCI/Docker images diff --git a/payjoin-service/Cargo.toml b/payjoin-service/Cargo.toml index 500261f77..a163cce39 100644 --- a/payjoin-service/Cargo.toml +++ b/payjoin-service/Cargo.toml @@ -16,6 +16,12 @@ rust-version = "1.85.0" [features] default = [] _manual-tls = ["dep:axum-server", "dep:rustls", "ohttp-relay/_test-util"] +acme = [ + "dep:tokio-rustls-acme", + "dep:axum-server", + "dep:rustls", + "dep:tokio-stream", +] [dependencies] anyhow = "1.0" @@ -34,6 +40,8 @@ rustls = { version = "0.23", default-features = false, features = [ serde = { version = "1.0", features = ["derive"] } tokio = { version = "1.47", features = ["full"] } tokio-listener = { version = "0.5", features = ["axum08", "serde"] } +tokio-rustls-acme = { version = "0.9.0", features = ["axum"], optional = true } +tokio-stream = { version = "0.1.17", optional = true } tower = "0.5" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/payjoin-service/src/config.rs b/payjoin-service/src/config.rs index 96b12e46b..8dad2c84b 100644 --- a/payjoin-service/src/config.rs +++ b/payjoin-service/src/config.rs @@ -12,6 +12,31 @@ pub struct Config { pub storage_dir: PathBuf, #[serde(deserialize_with = "deserialize_duration_secs")] pub timeout: Duration, + #[cfg(feature = "acme")] + pub acme: Option, +} + +#[cfg(feature = "acme")] +#[derive(Debug, Clone, Deserialize)] +pub struct AcmeConfig { + pub domains: Vec, + pub contact: Vec, + pub cache_dir: PathBuf, + #[serde(default)] + pub directory_url: Option, +} + +#[cfg(feature = "acme")] +impl From for tokio_rustls_acme::AcmeConfig { + fn from(acme_config: AcmeConfig) -> Self { + let config = tokio_rustls_acme::AcmeConfig::new(acme_config.domains) + .contact(acme_config.contact) + .cache(tokio_rustls_acme::caches::DirCache::new(acme_config.cache_dir)); + match acme_config.directory_url { + Some(url) => config.directory(url), + None => config.directory_lets_encrypt(true), + } + } } impl Default for Config { @@ -20,6 +45,8 @@ impl Default for Config { listener: "[::]:8080".parse().expect("valid default listener address"), storage_dir: PathBuf::from("./data"), timeout: Duration::from_secs(30), + #[cfg(feature = "acme")] + acme: None, } } } @@ -39,7 +66,7 @@ impl Config { .add_source(File::from(path).required(false)) // Add from the environment (with a prefix of PJ) // e.g. `PJ_PORT=9090` would set the `port`. - .add_source(config::Environment::with_prefix("PJ")) + .add_source(config::Environment::with_prefix("PJ").separator("_")) .build()? .try_deserialize() } diff --git a/payjoin-service/src/lib.rs b/payjoin-service/src/lib.rs index cd778bfb4..518d1e4be 100644 --- a/payjoin-service/src/lib.rs +++ b/payjoin-service/src/lib.rs @@ -86,6 +86,59 @@ pub async fn serve_manual_tls( Ok((port, handle)) } +/// Serves payjoin-service with ACME-managed TLS certificates. +/// +/// Uses `tokio-rustls-acme` to automatically obtain and renew TLS +/// certificates from Let's Encrypt via the TLS-ALPN-01 challenge. +#[cfg(feature = "acme")] +pub async fn serve_acme(config: Config) -> anyhow::Result<()> { + use std::net::SocketAddr; + use std::sync::Arc; + + let acme_config = config + .acme + .as_ref() + .ok_or_else(|| anyhow::anyhow!("ACME configuration is required for serve_acme"))? + .clone(); + + let sentinel_tag = generate_sentinel_tag(); + + let services = Services { + directory: init_directory(&config, sentinel_tag).await?, + relay: ohttp_relay::Service::new(sentinel_tag).await, + }; + let app = Router::new().fallback(route_request).with_state(services); + + let addr: SocketAddr = config + .listener + .to_string() + .parse() + .map_err(|_| anyhow::anyhow!("ACME mode requires a TCP address (e.g., '[::]:443')"))?; + + let acme: tokio_rustls_acme::AcmeConfig<_, _> = acme_config.into(); + let mut state = acme.state(); + let rustls_config = Arc::new( + rustls::ServerConfig::builder().with_no_client_auth().with_cert_resolver(state.resolver()), + ); + let acceptor = state.axum_acceptor(rustls_config); + + // Drive ACME cert renewal in background + tokio::spawn(async move { + use tokio_stream::StreamExt; + loop { + match state.next().await { + Some(Ok(ok)) => info!("ACME event: {:?}", ok), + Some(Err(err)) => tracing::error!("ACME error: {:?}", err), + None => break, + } + } + }); + + info!("Payjoin service listening on {} with ACME TLS", addr); + axum_server::bind(addr).acceptor(acceptor).serve(app.into_make_service()).await?; + Ok(()) +} + /// Generate random sentinel tag at startup. /// The relay and directory share this tag in a best-effort attempt /// at detecting self loops. @@ -162,7 +215,6 @@ fn is_relay_request(req: &axum::extract::Request) -> bool { #[cfg(test)] mod tests { use std::sync::Arc; - use std::time::Duration; use axum_server::tls_rustls::RustlsConfig; use payjoin_test_utils::{http_agent, local_cert_key, wait_for_service_ready}; @@ -180,7 +232,7 @@ mod tests { let config = Config { listener: "[::]:0".parse().expect("valid listener address"), storage_dir: tempdir.path().to_path_buf(), - timeout: Duration::from_secs(2), + ..Default::default() }; let mut root_store = RootCertStore::empty(); diff --git a/payjoin-service/src/main.rs b/payjoin-service/src/main.rs index d04269210..7f847fdf6 100644 --- a/payjoin-service/src/main.rs +++ b/payjoin-service/src/main.rs @@ -11,6 +11,11 @@ async fn main() -> anyhow::Result<()> { let config_path = args.config.unwrap_or_else(|| "config.toml".into()); let config = config::Config::from_file(&config_path)?; + #[cfg(feature = "acme")] + if config.acme.is_some() { + return payjoin_service::serve_acme(config).await; + } + payjoin_service::serve(config).await } diff --git a/payjoin-test-utils/src/lib.rs b/payjoin-test-utils/src/lib.rs index bb62ffe41..e72fb3206 100644 --- a/payjoin-test-utils/src/lib.rs +++ b/payjoin-test-utils/src/lib.rs @@ -120,7 +120,7 @@ pub async fn init_directory( let config = payjoin_service::config::Config { listener: "[::]:0".parse().expect("valid listener address"), // let OS assign a free port storage_dir: tempdir.path().to_path_buf(), - timeout: Duration::from_secs(2), + ..Default::default() }; let tls_config = RustlsConfig::from_der(vec![local_cert_key.0], local_cert_key.1).await?; @@ -147,7 +147,7 @@ async fn init_ohttp_relay( let config = payjoin_service::config::Config { listener: "[::]:0".parse().expect("valid listener address"), // let OS assign a free port storage_dir: tempdir.path().to_path_buf(), - timeout: Duration::from_secs(2), + ..Default::default() }; let (port, handle) = payjoin_service::serve_manual_tls(config, None, root_store) From 8e7e3fba610d74f0112a862d951739d8206f2b46 Mon Sep 17 00:00:00 2001 From: DanGould Date: Thu, 5 Feb 2026 16:47:59 +0800 Subject: [PATCH 3/4] Listen for both HTTP and HTTPS This allows us to fully rely on the payjoin-service without a reverse proxy. Without this change, It's not possible to support TLS bootstrapping and environments, like Bitcoin Core, which don't have TLS available. --- payjoin-service/src/config.rs | 6 ++-- payjoin-service/src/lib.rs | 65 ++++++++++++++++++++++------------- payjoin-test-utils/src/lib.rs | 6 ++-- 3 files changed, 49 insertions(+), 28 deletions(-) diff --git a/payjoin-service/src/config.rs b/payjoin-service/src/config.rs index 8dad2c84b..a2627cf33 100644 --- a/payjoin-service/src/config.rs +++ b/payjoin-service/src/config.rs @@ -8,7 +8,8 @@ use tokio_listener::ListenerAddress; #[derive(Debug, Clone, Deserialize)] #[serde(default)] pub struct Config { - pub listener: ListenerAddress, + pub http_listener: ListenerAddress, + pub https_listener: ListenerAddress, pub storage_dir: PathBuf, #[serde(deserialize_with = "deserialize_duration_secs")] pub timeout: Duration, @@ -42,7 +43,8 @@ impl From for tokio_rustls_acme::AcmeConfig Self { Self { - listener: "[::]:8080".parse().expect("valid default listener address"), + http_listener: "[::]:8080".parse().expect("valid default listener address"), + https_listener: "[::]:4433".parse().expect("valid default listener address"), storage_dir: PathBuf::from("./data"), timeout: Duration::from_secs(30), #[cfg(feature = "acme")] diff --git a/payjoin-service/src/lib.rs b/payjoin-service/src/lib.rs index 518d1e4be..f7848a816 100644 --- a/payjoin-service/src/lib.rs +++ b/payjoin-service/src/lib.rs @@ -1,3 +1,9 @@ +//! Unified Payjoin Directory and OHTTP Relay service. +//! +//! This crate exposes helpers for running the service with plain HTTP only, +//! or with HTTPS backed by ACME-managed certificates (recommended for production +//! when running without a reverse proxy). + use axum::extract::State; use axum::http::Method; use axum::response::{IntoResponse, Response}; @@ -5,7 +11,7 @@ use axum::Router; use config::Config; use ohttp_relay::SentinelTag; use rand::Rng; -use tokio_listener::{Listener, SystemOptions, UserOptions}; +use tokio_listener::{Listener, ListenerAddress, SystemOptions, UserOptions}; use tower::Service; use tracing::info; @@ -27,18 +33,12 @@ pub async fn serve(config: Config) -> anyhow::Result<()> { }; let app = Router::new().fallback(route_request).with_state(services); - let listener = - Listener::bind(&config.listener, &SystemOptions::default(), &UserOptions::default()) - .await?; - info!("Payjoin service listening on {:?}", listener.local_addr()); - axum::serve(listener, app).await?; - - Ok(()) + serve_http(config.http_listener, app).await } /// Serves payjoin-service with manual TLS configuration. /// -/// Binds to `config.listener` (use port 0 to let the OS assign a free port) and returns +/// Binds to `config.https_listener` (use port 0 to let the OS assign a free port) and returns /// the actual bound port along with a task handle. /// /// If `tls_config` is provided, the server will use TLS for incoming connections. @@ -60,7 +60,7 @@ pub async fn serve_manual_tls( let app = Router::new().fallback(route_request).with_state(services); let addr: SocketAddr = config - .listener + .https_listener .to_string() .parse() .map_err(|_| anyhow::anyhow!("TLS mode requires a TCP address (e.g., '[::]:8080')"))?; @@ -86,20 +86,12 @@ pub async fn serve_manual_tls( Ok((port, handle)) } -/// Serves payjoin-service with ACME-managed TLS certificates. -/// -/// Uses `tokio-rustls-acme` to automatically obtain and renew TLS -/// certificates from Let's Encrypt via the TLS-ALPN-01 challenge. #[cfg(feature = "acme")] pub async fn serve_acme(config: Config) -> anyhow::Result<()> { - use std::net::SocketAddr; - use std::sync::Arc; - let acme_config = config .acme - .as_ref() - .ok_or_else(|| anyhow::anyhow!("ACME configuration is required for serve_acme"))? - .clone(); + .clone() + .ok_or_else(|| anyhow::anyhow!("ACME configuration is required for serve_acme"))?; let sentinel_tag = generate_sentinel_tag(); @@ -109,8 +101,33 @@ pub async fn serve_acme(config: Config) -> anyhow::Result<()> { }; let app = Router::new().fallback(route_request).with_state(services); - let addr: SocketAddr = config - .listener + let http_listener = config.http_listener.clone(); + let https_listener = config.https_listener.clone(); + + let https_future = serve_acme_https(https_listener, app.clone(), acme_config); + let http_future = serve_http(http_listener, app); + + tokio::try_join!(https_future, http_future).map(|_| ()) +} + +async fn serve_http(listener_addr: ListenerAddress, app: Router) -> anyhow::Result<()> { + let listener = + Listener::bind(&listener_addr, &SystemOptions::default(), &UserOptions::default()).await?; + info!("Payjoin service listening on {:?}", listener.local_addr()); + axum::serve(listener, app).await?; + Ok(()) +} + +#[cfg(feature = "acme")] +async fn serve_acme_https( + listener_addr: ListenerAddress, + app: Router, + acme_config: config::AcmeConfig, +) -> anyhow::Result<()> { + use std::net::SocketAddr; + use std::sync::Arc; + + let addr: SocketAddr = listener_addr .to_string() .parse() .map_err(|_| anyhow::anyhow!("ACME mode requires a TCP address (e.g., '[::]:443')"))?; @@ -122,7 +139,6 @@ pub async fn serve_acme(config: Config) -> anyhow::Result<()> { ); let acceptor = state.axum_acceptor(rustls_config); - // Drive ACME cert renewal in background tokio::spawn(async move { use tokio_stream::StreamExt; loop { @@ -230,7 +246,8 @@ mod tests { ) -> (u16, tokio::task::JoinHandle>, tempfile::TempDir) { let tempdir = tempdir().unwrap(); let config = Config { - listener: "[::]:0".parse().expect("valid listener address"), + http_listener: "[::]:0".parse().expect("valid listener address"), + https_listener: "[::]:0".parse().expect("valid listener address"), storage_dir: tempdir.path().to_path_buf(), ..Default::default() }; diff --git a/payjoin-test-utils/src/lib.rs b/payjoin-test-utils/src/lib.rs index e72fb3206..289bc08d7 100644 --- a/payjoin-test-utils/src/lib.rs +++ b/payjoin-test-utils/src/lib.rs @@ -118,7 +118,8 @@ pub async fn init_directory( > { let tempdir = tempdir()?; let config = payjoin_service::config::Config { - listener: "[::]:0".parse().expect("valid listener address"), // let OS assign a free port + http_listener: "[::]:0".parse().expect("valid listener address"), + https_listener: "[::]:0".parse().expect("valid listener address"), storage_dir: tempdir.path().to_path_buf(), ..Default::default() }; @@ -145,7 +146,8 @@ async fn init_ohttp_relay( > { let tempdir = tempdir()?; let config = payjoin_service::config::Config { - listener: "[::]:0".parse().expect("valid listener address"), // let OS assign a free port + http_listener: "[::]:0".parse().expect("valid listener address"), + https_listener: "[::]:0".parse().expect("valid listener address"), storage_dir: tempdir.path().to_path_buf(), ..Default::default() }; From 8ca6f28259fbc12db31b9116175d562fceff2f14 Mon Sep 17 00:00:00 2001 From: DanGould Date: Thu, 5 Feb 2026 16:53:10 +0800 Subject: [PATCH 4/4] Document new fn names and http/acme-tls modes Describe the service functions as standalone with acme or with reverse proxy TLS termination. --- payjoin-service/src/lib.rs | 32 ++++++++++++++++++-------------- payjoin-service/src/main.rs | 4 ++-- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/payjoin-service/src/lib.rs b/payjoin-service/src/lib.rs index f7848a816..c1d269d57 100644 --- a/payjoin-service/src/lib.rs +++ b/payjoin-service/src/lib.rs @@ -1,8 +1,11 @@ //! Unified Payjoin Directory and OHTTP Relay service. //! -//! This crate exposes helpers for running the service with plain HTTP only, -//! or with HTTPS backed by ACME-managed certificates (recommended for production -//! when running without a reverse proxy). +//! ## Deployment modes +//! +//! - Recommended: HTTPS with ACME-managed certificates (no reverse proxy) +//! - Use `serve_http_and_acme_tls` and configure `http_listener`, `https_listener`, and `acme`. +//! - Behind a reverse proxy (TLS termination outside the service) +//! - Use `serve_http_only` and expose only `http_listener`. use axum::extract::State; use axum::http::Method; @@ -24,7 +27,8 @@ struct Services { relay: ohttp_relay::Service, } -pub async fn serve(config: Config) -> anyhow::Result<()> { +/// Serve HTTP only on `config.http_listener`. +pub async fn serve_http_only(config: Config) -> anyhow::Result<()> { let sentinel_tag = generate_sentinel_tag(); let services = Services { @@ -33,7 +37,7 @@ pub async fn serve(config: Config) -> anyhow::Result<()> { }; let app = Router::new().fallback(route_request).with_state(services); - serve_http(config.http_listener, app).await + serve_http_listener(config.http_listener, app).await } /// Serves payjoin-service with manual TLS configuration. @@ -86,12 +90,12 @@ pub async fn serve_manual_tls( Ok((port, handle)) } +/// Serve HTTP on `config.http_listener` and HTTPS (ACME) on `config.https_listener`. #[cfg(feature = "acme")] -pub async fn serve_acme(config: Config) -> anyhow::Result<()> { - let acme_config = config - .acme - .clone() - .ok_or_else(|| anyhow::anyhow!("ACME configuration is required for serve_acme"))?; +pub async fn serve_http_and_acme_tls(config: Config) -> anyhow::Result<()> { + let acme_config = config.acme.clone().ok_or_else(|| { + anyhow::anyhow!("ACME configuration is required for serve_http_and_acme_tls") + })?; let sentinel_tag = generate_sentinel_tag(); @@ -104,13 +108,13 @@ pub async fn serve_acme(config: Config) -> anyhow::Result<()> { let http_listener = config.http_listener.clone(); let https_listener = config.https_listener.clone(); - let https_future = serve_acme_https(https_listener, app.clone(), acme_config); - let http_future = serve_http(http_listener, app); + let https_future = serve_acme_tls_listener(https_listener, app.clone(), acme_config); + let http_future = serve_http_listener(http_listener, app); tokio::try_join!(https_future, http_future).map(|_| ()) } -async fn serve_http(listener_addr: ListenerAddress, app: Router) -> anyhow::Result<()> { +async fn serve_http_listener(listener_addr: ListenerAddress, app: Router) -> anyhow::Result<()> { let listener = Listener::bind(&listener_addr, &SystemOptions::default(), &UserOptions::default()).await?; info!("Payjoin service listening on {:?}", listener.local_addr()); @@ -119,7 +123,7 @@ async fn serve_http(listener_addr: ListenerAddress, app: Router) -> anyhow::Resu } #[cfg(feature = "acme")] -async fn serve_acme_https( +async fn serve_acme_tls_listener( listener_addr: ListenerAddress, app: Router, acme_config: config::AcmeConfig, diff --git a/payjoin-service/src/main.rs b/payjoin-service/src/main.rs index 7f847fdf6..cef7994f1 100644 --- a/payjoin-service/src/main.rs +++ b/payjoin-service/src/main.rs @@ -13,10 +13,10 @@ async fn main() -> anyhow::Result<()> { #[cfg(feature = "acme")] if config.acme.is_some() { - return payjoin_service::serve_acme(config).await; + return payjoin_service::serve_http_and_acme_tls(config).await; } - payjoin_service::serve(config).await + payjoin_service::serve_http_only(config).await } fn init_tracing() {