From c2911107e278afef6597446a27543ee182627075 Mon Sep 17 00:00:00 2001 From: DanGould Date: Thu, 19 Feb 2026 22:37:38 +0800 Subject: [PATCH 1/8] Add GeoIP region filtering middleware Add access-control feature to payjoin-mailroom providing IP-based region filtering via axum middleware. Requests from blocked ISO country codes are rejected at the network layer. A free DB-IP Lite database is fetched automatically when no explicit path is configured. --- Cargo-minimal.lock | 36 +++- Cargo-recent.lock | 36 +++- flake.nix | 3 +- payjoin-mailroom/Cargo.toml | 6 + payjoin-mailroom/config.example.toml | 35 ++++ payjoin-mailroom/src/access_control.rs | 191 ++++++++++++++++++ payjoin-mailroom/src/config.rs | 15 ++ payjoin-mailroom/src/lib.rs | 95 ++++++++- payjoin-mailroom/src/middleware.rs | 29 +++ .../test-data/GeoIP2-Country-Test.mmdb | Bin 0 -> 19492 bytes 10 files changed, 429 insertions(+), 17 deletions(-) create mode 100644 payjoin-mailroom/config.example.toml create mode 100644 payjoin-mailroom/src/access_control.rs create mode 100644 payjoin-mailroom/test-data/GeoIP2-Country-Test.mmdb diff --git a/Cargo-minimal.lock b/Cargo-minimal.lock index a958e7350..a6eb29a82 100644 --- a/Cargo-minimal.lock +++ b/Cargo-minimal.lock @@ -1413,9 +1413,9 @@ dependencies = [ [[package]] name = "flate2" -version = "1.0.33" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "324a1be68054ef05ad64b861cc9eaf1d623d2d8cb25b4bf2cb9cdd902b4bf253" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", @@ -2090,6 +2090,12 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "187674a687eed5fe42285b40c6291f9a01517d415fad1c3cbc6a9f778af7fcd4" +[[package]] +name = "ipnetwork" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf370abdafd54d13e54a620e8c3e1145f28e46cc9d704bc6d94414559df41763" + [[package]] name = "iri-string" version = "0.7.8" @@ -2280,6 +2286,19 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "maxminddb" +version = "0.27.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76371bd37ce742f8954daabd0fde7f1594ee43ac2200e20c003ba5c3d65e2192" +dependencies = [ + "ipnetwork", + "log", + "memchr", + "serde", + "thiserror 2.0.17", +] + [[package]] name = "memchr" version = "2.7.4" @@ -2311,11 +2330,12 @@ dependencies = [ [[package]] name = "miniz_oxide" -version = "0.8.0" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", + "simd-adler32", ] [[package]] @@ -2777,6 +2797,8 @@ dependencies = [ "axum-server", "clap", "config", + "flate2", + "maxminddb", "ohttp-relay", "opentelemetry", "opentelemetry-otlp", @@ -3822,6 +3844,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + [[package]] name = "similar" version = "2.7.0" diff --git a/Cargo-recent.lock b/Cargo-recent.lock index a958e7350..a6eb29a82 100644 --- a/Cargo-recent.lock +++ b/Cargo-recent.lock @@ -1413,9 +1413,9 @@ dependencies = [ [[package]] name = "flate2" -version = "1.0.33" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "324a1be68054ef05ad64b861cc9eaf1d623d2d8cb25b4bf2cb9cdd902b4bf253" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", @@ -2090,6 +2090,12 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "187674a687eed5fe42285b40c6291f9a01517d415fad1c3cbc6a9f778af7fcd4" +[[package]] +name = "ipnetwork" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf370abdafd54d13e54a620e8c3e1145f28e46cc9d704bc6d94414559df41763" + [[package]] name = "iri-string" version = "0.7.8" @@ -2280,6 +2286,19 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "maxminddb" +version = "0.27.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76371bd37ce742f8954daabd0fde7f1594ee43ac2200e20c003ba5c3d65e2192" +dependencies = [ + "ipnetwork", + "log", + "memchr", + "serde", + "thiserror 2.0.17", +] + [[package]] name = "memchr" version = "2.7.4" @@ -2311,11 +2330,12 @@ dependencies = [ [[package]] name = "miniz_oxide" -version = "0.8.0" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", + "simd-adler32", ] [[package]] @@ -2777,6 +2797,8 @@ dependencies = [ "axum-server", "clap", "config", + "flate2", + "maxminddb", "ohttp-relay", "opentelemetry", "opentelemetry-otlp", @@ -3822,6 +3844,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + [[package]] name = "similar" version = "2.7.0" diff --git a/flake.nix b/flake.nix index 1957ae532..98d804ce3 100644 --- a/flake.nix +++ b/flake.nix @@ -85,6 +85,7 @@ filter = path: type: (builtins.match ".*nginx.conf.template$" path != null) + || (builtins.match ".*\\.mmdb$" path != null) || (craneLibVersions.msrv.filterCargoSources path type); name = "source"; }; @@ -162,7 +163,7 @@ "payjoin-cli" = "--features v1,v2"; "payjoin-directory" = ""; "ohttp-relay" = ""; - "payjoin-mailroom" = "--features acme,telemetry"; + "payjoin-mailroom" = "--features access-control,acme,telemetry"; }; # nix2container for building OCI/Docker images diff --git a/payjoin-mailroom/Cargo.toml b/payjoin-mailroom/Cargo.toml index 5ff4c54e0..e65672cad 100644 --- a/payjoin-mailroom/Cargo.toml +++ b/payjoin-mailroom/Cargo.toml @@ -22,6 +22,7 @@ acme = [ "dep:rustls", "dep:tokio-stream", ] +access-control = ["dep:flate2", "dep:maxminddb", "dep:reqwest"] telemetry = ["dep:opentelemetry-otlp"] [dependencies] @@ -32,6 +33,8 @@ axum-server = { version = "0.8", features = [ ], optional = true } clap = { version = "4.5", features = ["derive", "env"] } config = "0.15" +flate2 = { version = "1.1", optional = true } +maxminddb = { version = "0.27", optional = true } ohttp-relay = { path = "../ohttp-relay", features = ["bootstrap"] } opentelemetry = "0.31" opentelemetry-otlp = { version = "0.31", optional = true, features = [ @@ -40,6 +43,9 @@ opentelemetry-otlp = { version = "0.31", optional = true, features = [ opentelemetry_sdk = "0.31" payjoin-directory = { path = "../payjoin-directory" } rand = "0.8" +reqwest = { version = "0.12", default-features = false, features = [ + "rustls-tls", +], optional = true } rustls = { version = "0.23", default-features = false, features = [ "ring", ], optional = true } diff --git a/payjoin-mailroom/config.example.toml b/payjoin-mailroom/config.example.toml new file mode 100644 index 000000000..fe92e5ae8 --- /dev/null +++ b/payjoin-mailroom/config.example.toml @@ -0,0 +1,35 @@ +# Payjoin Mailroom configuration example +# +# Configuration can also be set via environment variables with the `PJ_` +# prefix. Nested values use double underscores as separators, e.g. +# PJ_ACCESS_CONTROL__BLOCKED_REGIONS=CU,IR,KP,SY + +# Address and port to listen on +# listener = "[::]:8080" + +# Directory for persistent storage (OHTTP keys, caches, etc.) +# storage_dir = "./data" + +# Request timeout in seconds +# timeout = 30 + +# --- Access-control (requires `access-control` feature) --- +# [access_control] + +# Optional path to a MaxMind GeoIP2 / DB-IP Country database. +# If omitted but blocked_regions is non-empty, a free DB-IP Lite +# database will be fetched automatically and cached under storage_dir. +# geo_db_path = "/path/to/GeoIP2-Country.mmdb" + +# ISO 3166-1 alpha-2 country codes whose requests should be blocked. +# blocked_regions = ["CU", "IR", "KP", "SY"] + +# Path to a local file containing blocked Bitcoin addresses (one per line). +# Used for V1 PSBT screening. +# blocked_addresses_path = "/path/to/blocked_addresses.txt" + +# URL to periodically fetch an updated blocked-address list from. +# blocked_addresses_url = "https://example.com/blocked_addresses.txt" + +# How often (in seconds) to refresh the remote address list (default: 86400). +# blocked_addresses_refresh_secs = 86400 diff --git a/payjoin-mailroom/src/access_control.rs b/payjoin-mailroom/src/access_control.rs new file mode 100644 index 000000000..726171431 --- /dev/null +++ b/payjoin-mailroom/src/access_control.rs @@ -0,0 +1,191 @@ +use std::collections::HashSet; +use std::net::IpAddr; +use std::path::Path; + +use maxminddb::PathElement; + +use crate::config::AccessControlConfig; + +pub struct GeoIp { + geo_reader: Option>>, + blocked_regions: HashSet, +} + +impl GeoIp { + pub async fn from_config( + config: &AccessControlConfig, + storage_dir: &Path, + ) -> anyhow::Result { + let geo_reader = match &config.geo_db_path { + Some(path) => Some(maxminddb::Reader::open_readfile(path)?), + None if !config.blocked_regions.is_empty() => { + let cached = storage_dir.join("access-control/geoip.mmdb"); + if cached.exists() { + match maxminddb::Reader::open_readfile(&cached) { + Ok(reader) => Some(reader), + Err(e) => { + tracing::warn!( + "Failed to open cached GeoIP database at {}: {e}; attempting refresh", + cached.display() + ); + fetch_geoip_db(&cached).await?; + Some(maxminddb::Reader::open_readfile(&cached)?) + } + } + } else { + fetch_geoip_db(&cached).await?; + Some(maxminddb::Reader::open_readfile(&cached)?) + } + } + None => None, + }; + + let blocked_regions = config.blocked_regions.iter().cloned().collect(); + + Ok(Self { geo_reader, blocked_regions }) + } + + /// Returns `true` if the IP is allowed. Fail-open on lookup errors. + pub fn check_ip(&self, ip: IpAddr) -> bool { + let reader = match &self.geo_reader { + Some(r) => r, + None => return true, + }; + + if self.blocked_regions.is_empty() { + return true; + } + + match reader.lookup(ip) { + Ok(result) => { + match result.decode_path::(&[ + PathElement::Key("country"), + PathElement::Key("iso_code"), + ]) { + Ok(Some(iso_code)) => !self.blocked_regions.contains(&iso_code), + _ => true, // no country info or decode error -> allow + } + } + Err(_) => true, // fail-open + } + } +} + +async fn fetch_geoip_db(dest: &Path) -> anyhow::Result<()> { + use std::io::Read; + + let now = chrono_month_year(); + let url = + format!("https://download.db-ip.com/free/dbip-country-lite-{}-{}.mmdb.gz", now.0, now.1); + tracing::info!("Fetching GeoIP database from {}", url); + + let response = reqwest::get(&url).await?; + if !response.status().is_success() { + anyhow::bail!("Failed to fetch GeoIP database: HTTP {}", response.status()); + } + let compressed = response.bytes().await?; + let mut decoder = flate2::read::GzDecoder::new(&compressed[..]); + let mut decompressed = Vec::new(); + decoder.read_to_end(&mut decompressed)?; + + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(dest, &decompressed)?; + tracing::info!("GeoIP database saved to {}", dest.display()); + Ok(()) +} + +/// Returns (year, month) as strings for the DB-IP download URL. +fn chrono_month_year() -> (String, String) { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time should be after UNIX_EPOCH"); + let days_since_epoch = (now.as_secs() / 86_400) as i64; + let (year, month) = year_month_from_days_since_epoch(days_since_epoch); + (year.to_string(), format!("{month:02}")) +} + +fn year_month_from_days_since_epoch(days_since_epoch: i64) -> (i32, u32) { + // Exact conversion from Unix days to Gregorian year/month in UTC. + // Based on Howard Hinnant's civil calendar algorithm. + let z = days_since_epoch + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let month = (mp + if mp < 10 { 3 } else { -9 }) as u32; + let year = (y + if month <= 2 { 1 } else { 0 }) as i32; + (year, month) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_geo_reader() -> maxminddb::Reader> { + maxminddb::Reader::open_readfile(concat!( + env!("CARGO_MANIFEST_DIR"), + "/test-data/GeoIP2-Country-Test.mmdb" + )) + .unwrap() + } + + #[test] + fn check_ip_allows_when_no_geo_reader() { + let ac = GeoIp { geo_reader: None, blocked_regions: HashSet::new() }; + assert!(ac.check_ip("1.2.3.4".parse().unwrap())); + } + + #[test] + fn check_ip_allows_when_no_blocked_regions() { + let reader = test_geo_reader(); + let ac = GeoIp { geo_reader: Some(reader), blocked_regions: HashSet::new() }; + assert!(ac.check_ip("2.125.160.216".parse().unwrap())); + } + + #[test] + fn check_ip_blocks_blocked_region() { + let reader = test_geo_reader(); + // 2.125.160.216 is GB in the test database + let blocked_regions: HashSet = ["GB"].iter().map(|s| s.to_string()).collect(); + let ac = GeoIp { geo_reader: Some(reader), blocked_regions }; + assert!(!ac.check_ip("2.125.160.216".parse().unwrap())); + } + + #[test] + fn check_ip_allows_non_blocked_region() { + let reader = test_geo_reader(); + // 2.125.160.216 is GB in the test database + let blocked_regions: HashSet = ["US"].iter().map(|s| s.to_string()).collect(); + let ac = GeoIp { geo_reader: Some(reader), blocked_regions }; + assert!(ac.check_ip("2.125.160.216".parse().unwrap())); + } + + #[test] + fn check_ip_fail_open_on_unknown_ip() { + let reader = test_geo_reader(); + let blocked_regions: HashSet = ["US"].iter().map(|s| s.to_string()).collect(); + let ac = GeoIp { geo_reader: Some(reader), blocked_regions }; + // 127.0.0.1 won't be in test DB + assert!(ac.check_ip("127.0.0.1".parse().unwrap())); + } + + #[test] + fn year_month_conversion_handles_leap_day() { + // 2024-02-29 00:00:00 UTC + let days = 19_782; + let (year, month) = year_month_from_days_since_epoch(days); + assert_eq!((year, month), (2024, 2)); + } + + #[test] + fn year_month_conversion_handles_year_start() { + // 2024-01-01 00:00:00 UTC + let days = 19_723; + let (year, month) = year_month_from_days_since_epoch(days); + assert_eq!((year, month), (2024, 1)); + } +} diff --git a/payjoin-mailroom/src/config.rs b/payjoin-mailroom/src/config.rs index 813f22a04..97d527369 100644 --- a/payjoin-mailroom/src/config.rs +++ b/payjoin-mailroom/src/config.rs @@ -17,6 +17,8 @@ pub struct Config { pub telemetry: Option, #[cfg(feature = "acme")] pub acme: Option, + #[cfg(feature = "access-control")] + pub access_control: Option, } #[cfg(feature = "telemetry")] @@ -36,6 +38,14 @@ pub struct AcmeConfig { pub directory_url: Option, } +#[cfg(feature = "access-control")] +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(default)] +pub struct AccessControlConfig { + pub geo_db_path: Option, + pub blocked_regions: Vec, +} + #[cfg(feature = "acme")] impl AcmeConfig { pub fn into_rustls_config( @@ -64,6 +74,8 @@ impl Default for Config { telemetry: None, #[cfg(feature = "acme")] acme: None, + #[cfg(feature = "access-control")] + access_control: None, } } } @@ -92,6 +104,8 @@ impl Config { telemetry: None, #[cfg(feature = "acme")] acme: None, + #[cfg(feature = "access-control")] + access_control: None, } } @@ -109,6 +123,7 @@ impl Config { .list_separator(",") .with_list_parse_key("acme.domains") .with_list_parse_key("acme.contact") + .with_list_parse_key("access_control.blocked_regions") .try_parsing(true), ) .build()? diff --git a/payjoin-mailroom/src/lib.rs b/payjoin-mailroom/src/lib.rs index a95698209..5c19d1c1d 100644 --- a/payjoin-mailroom/src/lib.rs +++ b/payjoin-mailroom/src/lib.rs @@ -1,6 +1,10 @@ +#[cfg(feature = "access-control")] +use axum::extract::connect_info::Connected; use axum::extract::State; use axum::http::Method; use axum::response::{IntoResponse, Response}; +#[cfg(feature = "access-control")] +use axum::serve::IncomingStream; use axum::Router; use config::Config; use ohttp_relay::SentinelTag; @@ -10,6 +14,8 @@ use tokio_listener::{Listener, SystemOptions, UserOptions}; use tower::{Service, ServiceBuilder}; use tracing::info; +#[cfg(feature = "access-control")] +pub mod access_control; pub mod cli; pub mod config; pub mod metrics; @@ -23,18 +29,29 @@ struct Services { directory: payjoin_directory::Service, relay: ohttp_relay::Service, metrics: MetricsService, + #[cfg(feature = "access-control")] + geoip: Option>, } pub async fn serve(config: Config, meter_provider: Option) -> anyhow::Result<()> { let sentinel_tag = generate_sentinel_tag(); + #[cfg(feature = "access-control")] + let geoip = init_geoip(&config).await?; + + let directory = init_directory(&config, sentinel_tag).await?; + let services = Services { - directory: init_directory(&config, sentinel_tag).await?, + directory, relay: ohttp_relay::Service::new(sentinel_tag).await, metrics: MetricsService::new(meter_provider), + #[cfg(feature = "access-control")] + geoip, }; let app = build_app(services); + #[cfg(feature = "access-control")] + let app = app.into_make_service_with_connect_info::(); let listener = Listener::bind(&config.listener, &SystemOptions::default(), &UserOptions::default()) @@ -62,10 +79,17 @@ pub async fn serve_manual_tls( let sentinel_tag = generate_sentinel_tag(); + #[cfg(feature = "access-control")] + let geoip = init_geoip(&config).await?; + + let directory = init_directory(&config, sentinel_tag).await?; + let services = Services { - directory: init_directory(&config, sentinel_tag).await?, + directory, relay: ohttp_relay::Service::new_with_roots(root_store, sentinel_tag).await, metrics: MetricsService::new(None), + #[cfg(feature = "access-control")] + geoip, }; let app = build_app(services); @@ -82,14 +106,18 @@ pub async fn serve_manual_tls( info!("Payjoin service listening on port {} with TLS", port); tokio::spawn(async move { axum_server::from_tcp_rustls(listener.into_std()?, tls)? - .serve(app.into_make_service()) + .serve(app.into_make_service_with_connect_info::()) .await .map_err(Into::into) }) } None => { info!("Payjoin service listening on port {} without TLS", port); - tokio::spawn(async move { axum::serve(listener, app).await.map_err(Into::into) }) + tokio::spawn(async move { + axum::serve(listener, app.into_make_service_with_connect_info::()) + .await + .map_err(Into::into) + }) } }; @@ -115,10 +143,17 @@ pub async fn serve_acme( let sentinel_tag = generate_sentinel_tag(); + #[cfg(feature = "access-control")] + let geoip = init_geoip(&config).await?; + + let directory = init_directory(&config, sentinel_tag).await?; + let services = Services { - directory: init_directory(&config, sentinel_tag).await?, + directory, relay: ohttp_relay::Service::new(sentinel_tag).await, metrics: MetricsService::new(meter_provider), + #[cfg(feature = "access-control")] + geoip, }; let app = build_app(services); @@ -148,7 +183,10 @@ pub async fn serve_acme( }); info!("Payjoin service listening on {} with ACME TLS", addr); - axum_server::bind(addr).acceptor(acceptor).serve(app.into_make_service()).await?; + axum_server::bind(addr) + .acceptor(acceptor) + .serve(app.into_make_service_with_connect_info::()) + .await?; Ok(()) } @@ -157,6 +195,17 @@ pub async fn serve_acme( /// at detecting self loops. fn generate_sentinel_tag() -> SentinelTag { SentinelTag::new(rand::thread_rng().gen()) } +#[cfg(feature = "access-control")] +impl Connected> for middleware::MaybePeerIp { + fn connect_info(stream: IncomingStream<'_, Listener>) -> Self { + let ip = match stream.remote_addr() { + tokio_listener::SomeSocketAddr::Tcp(addr) => Some(addr.ip()), + _ => None, + }; + Self(ip) + } +} + async fn init_directory( config: &Config, sentinel_tag: SentinelTag, @@ -170,6 +219,20 @@ async fn init_directory( Ok(payjoin_directory::Service::new(db, ohttp_config.into(), sentinel_tag, config.enable_v1)) } +#[cfg(feature = "access-control")] +async fn init_geoip( + config: &Config, +) -> anyhow::Result>> { + match &config.access_control { + Some(ac_config) => { + let gi = access_control::GeoIp::from_config(ac_config, &config.storage_dir).await?; + info!("GeoIP access control enabled"); + Ok(Some(std::sync::Arc::new(gi))) + } + None => Ok(None), + } +} + fn init_ohttp_config( ohttp_keys_dir: &std::path::Path, ) -> anyhow::Result { @@ -186,14 +249,28 @@ fn init_ohttp_config( fn build_app(services: Services) -> Router { let metrics = services.metrics.clone(); - Router::new() + + #[cfg(feature = "access-control")] + let geoip = services.geoip.clone(); + + #[allow(unused_mut)] + let mut router = Router::new() .fallback(route_request) .layer( ServiceBuilder::new() .layer(axum::middleware::from_fn_with_state(metrics.clone(), track_metrics)) .layer(axum::middleware::from_fn_with_state(metrics, track_connections)), ) - .with_state(services) + .with_state(services); + + #[cfg(feature = "access-control")] + { + router = router + .layer(axum::middleware::from_fn(middleware::check_geoip)) + .layer(axum::Extension(geoip)); + } + + router } async fn route_request( @@ -363,6 +440,8 @@ mod tests { directory: init_directory(&config, sentinel_tag).await.unwrap(), relay: ohttp_relay::Service::new(sentinel_tag).await, metrics: MetricsService::new(Some(provider.clone())), + #[cfg(feature = "access-control")] + geoip: None, }; let app = build_app(services); diff --git a/payjoin-mailroom/src/middleware.rs b/payjoin-mailroom/src/middleware.rs index 875bc49b4..c876ef489 100644 --- a/payjoin-mailroom/src/middleware.rs +++ b/payjoin-mailroom/src/middleware.rs @@ -4,6 +4,35 @@ use axum::response::Response; use crate::metrics::MetricsService; +#[cfg(feature = "access-control")] +#[derive(Clone, Debug)] +pub struct MaybePeerIp(pub Option); + +#[cfg(feature = "access-control")] +pub async fn check_geoip(req: Request, next: Next) -> Response { + use axum::http::StatusCode; + + let geoip = req.extensions().get::>>(); + + if let Some(Some(geoip)) = geoip { + if let Some(connect_info) = + req.extensions().get::>() + { + if let Some(ip) = connect_info.0 .0 { + if !geoip.check_ip(ip) { + tracing::warn!("Blocked request from {ip} due to GeoIP policy"); + return Response::builder() + .status(StatusCode::FORBIDDEN) + .body(axum::body::Body::empty()) + .expect("valid response"); + } + } + } + } + + next.run(req).await +} + pub async fn track_metrics( metrics: axum::extract::State, req: Request, diff --git a/payjoin-mailroom/test-data/GeoIP2-Country-Test.mmdb b/payjoin-mailroom/test-data/GeoIP2-Country-Test.mmdb new file mode 100644 index 0000000000000000000000000000000000000000..840f89384ec21d35e84ab2a71a2e01cb2c555a00 GIT binary patch literal 19492 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 z zte|{+>0ns4S3~JQE3p**qYg8(+gPj@ZD*?E-$)nNyK&I2ianELhNq#6cEL>qG!+Rgbn+glSt+*wP1$jyu zvdsfFw$wNif9lY^8Ro!Ux46D;qBnO1ro6kk`rzJUZl{}*+bj3L_LYtES7K_8+>U;l z7uw8RZ0|wQvvg^5Gl%eTRzKiUGxFl&Au`T2&*Z6ZDjD1{ti`yhDD;0;kdq=ojV3?4*>sl z0gsP|)#384MPNw-TOU}yd;iV_`?u~mup9AYJFf2PrMiWq7o}L; z;XmQQDJzqQ;GUT=Sac{-W~LaPvPUMau1F6@BBRhvjL}DkBBtW&%1ovn5z#07V4_~! z{6K-1AiR&II#I8N;c@e7bFDr`b9b0$i_Lf_(eq)EaqCMLHBMc4VBSPnUz_t%`fFAG zW%|q6yD{aZOZ|o#NKQp0ibpS}TR-Umxu9mN4 zI9$^4%saGl-l4hX9n`z7vo7<-|F61A2cDc*gcq+V@RztL!8w~F9^L^HPvEBotj~I6 z8sD_l2-f1ahOI`3@Cp|`pvj~Tj-P{H1`bV_dT8z*+Ebg)Qf9-yW}=ymp1L3NGouw@ zx{N%28b{T(g^Bl3ymcchCDh{gkpCBIQObK)H(kWRyYpeGpgUgo@#GxE1uCz5xKh6|3cBO{6rXzd>K<|nn8zRU4qjh}2Y#GG)_LA3 zz3pPN)8?c&B>bXqXuEMpZN2NSIc}UYVgn|jfRj*AP#MFkPfugMg}CR0&HJ-SWtbE$ zBwbEe4I3Xc zefVL`Yv1+^jqVw#EIX&Ps^U<|V2m$1X3&r-yunuwst66QiVnIp9F5_(w59%k=i=wR zrrafkgKHw?WrM;at4fDi{z%=czx6B&Rfh(LV&OsN8Ms$oxT>Jf1zofGt6gDPI93{s zjI2h@`|u0qz^=@9+5I^ofw3cS@jBWt1~tIWZo zsrmVfJ+)$6B-(h`7CF2k4pTe%3ltj2|iL74VNN*42ng@hL@i5e>ia1 A!T Date: Thu, 19 Feb 2026 22:38:04 +0800 Subject: [PATCH 2/8] Add address blocklist screening for V1 PSBTs Screen V1 payjoin payloads against a configurable address blocklist. Addresses are parsed into script pubkeys at load time for canonical comparison, avoiding encoding round-trips and bech32 case issues. Supports loading from a local file, a remote URL with periodic background refresh, and local cache fallback. --- payjoin-directory/src/lib.rs | 209 ++++++++++++++++++++++++- payjoin-mailroom/src/access_control.rs | 30 ++++ payjoin-mailroom/src/config.rs | 3 + payjoin-mailroom/src/lib.rs | 101 +++++++++++- 4 files changed, 339 insertions(+), 4 deletions(-) diff --git a/payjoin-directory/src/lib.rs b/payjoin-directory/src/lib.rs index d295e7e5f..68c8e8e50 100644 --- a/payjoin-directory/src/lib.rs +++ b/payjoin-directory/src/lib.rs @@ -65,12 +65,60 @@ fn init_tls_acceptor(cert_key: (Vec, Vec)) -> Result>>, +); + +impl BlockedAddresses { + pub fn empty() -> Self { + Self(Arc::new(tokio::sync::RwLock::new(std::collections::HashSet::new()))) + } + + pub fn from_address_lines(text: &str) -> Self { + Self(Arc::new(tokio::sync::RwLock::new(parse_address_lines(text)))) + } + + /// Replace the contents with scripts parsed from newline-delimited + /// address text. Returns the number of entries after update. + pub async fn update_from_lines(&self, text: &str) -> usize { + let scripts = parse_address_lines(text); + let count = scripts.len(); + *self.0.write().await = scripts; + count + } +} + +fn parse_address_lines(text: &str) -> std::collections::HashSet { + text.lines() + .filter_map(|l| { + let trimmed = l.trim(); + if trimmed.is_empty() { + return None; + } + match trimmed.parse::>() { + Ok(addr) => Some(addr.assume_checked().script_pubkey()), + Err(e) => { + tracing::warn!("Skipping unparsable blocked address {trimmed:?}: {e}"); + None + } + } + }) + .collect() +} + #[derive(Clone)] pub struct Service { db: D, ohttp: ohttp::Server, sentinel_tag: SentinelTag, enable_v1: bool, + blocked_addresses: Option, } impl tower::Service> for Service @@ -95,7 +143,12 @@ where impl Service { pub fn new(db: D, ohttp: ohttp::Server, sentinel_tag: SentinelTag, enable_v1: bool) -> Self { - Self { db, ohttp, sentinel_tag, enable_v1 } + Self { db, ohttp, sentinel_tag, enable_v1, blocked_addresses: None } + } + + pub fn with_blocked_addresses(mut self, addrs: BlockedAddresses) -> Self { + self.blocked_addresses = Some(addrs); + self } #[cfg(feature = "_manual-tls")] @@ -419,6 +472,24 @@ impl Service { Err(_) => return Ok(bad_request_body_res), }; + if let Some(blocked) = &self.blocked_addresses { + let scripts = blocked.0.read().await; + if !scripts.is_empty() { + match screen_v1_addresses(&body_str, &scripts) { + ScreenResult::Blocked => { + return Ok(Response::builder() + .status(StatusCode::FORBIDDEN) + .body(empty())?); + } + ScreenResult::Clean => {} + ScreenResult::ParseError(e) => { + warn!("Could not screen V1 payload: {e}"); + // fail-open: unparsable PSBTs can't complete transactions + } + } + } + } + let v2_compat_body = format!("{body_str}\n{query}"); let id = ShortId::from_str(id)?; handle_peek( @@ -629,6 +700,57 @@ fn full>(chunk: T) -> BoxBody { Full::new(chunk.into()).map_err(|never| match never {}).boxed() } +enum ScreenResult { + Blocked, + Clean, + ParseError(String), +} + +fn screen_v1_addresses( + body: &str, + blocked: &std::collections::HashSet, +) -> ScreenResult { + use bitcoin::base64::prelude::{Engine, BASE64_STANDARD}; + use bitcoin::psbt::Psbt; + + let psbt_bytes = match BASE64_STANDARD.decode(body) { + Ok(b) => b, + Err(e) => return ScreenResult::ParseError(format!("base64 decode: {e}")), + }; + + let psbt = match Psbt::deserialize(&psbt_bytes) { + Ok(p) => p, + Err(e) => return ScreenResult::ParseError(format!("PSBT deserialize: {e}")), + }; + + // Check output scripts + for txout in &psbt.unsigned_tx.output { + if blocked.contains(&txout.script_pubkey) { + return ScreenResult::Blocked; + } + } + + // Check input scripts from witness_utxo and non_witness_utxo + for (i, input) in psbt.inputs.iter().enumerate() { + if let Some(ref utxo) = input.witness_utxo { + if blocked.contains(&utxo.script_pubkey) { + return ScreenResult::Blocked; + } + } + if let Some(ref tx) = input.non_witness_utxo { + if let Some(prev_out) = psbt.unsigned_tx.input.get(i) { + if let Some(txout) = tx.output.get(prev_out.previous_output.vout as usize) { + if blocked.contains(&txout.script_pubkey) { + return ScreenResult::Blocked; + } + } + } + } + } + + ScreenResult::Clean +} + #[cfg(test)] mod tests { use std::time::Duration; @@ -712,3 +834,88 @@ mod tests { assert_eq!(body, V1_UNAVAILABLE_RES_JSON); } } + +#[cfg(test)] +mod screen_tests { + use super::*; + + fn addr_to_script(address: &str) -> bitcoin::ScriptBuf { + let addr: bitcoin::Address = + address.parse().expect("valid address"); + addr.assume_checked().script_pubkey() + } + + fn make_test_psbt_base64(output_address: &str) -> String { + use bitcoin::base64::prelude::{Engine, BASE64_STANDARD}; + use bitcoin::psbt::Psbt; + use bitcoin::{Amount, Transaction, TxIn, TxOut}; + + let script_pubkey = addr_to_script(output_address); + + let tx = Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: bitcoin::blockdata::locktime::absolute::LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { value: Amount::from_sat(50_000), script_pubkey }], + }; + + let psbt = Psbt::from_unsigned_tx(tx).expect("valid psbt"); + let serialized = psbt.serialize(); + BASE64_STANDARD.encode(&serialized) + } + + #[test] + fn screen_blocks_blocked_output_address() { + let blocked_addr = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"; + let blocked = std::collections::HashSet::from([addr_to_script(blocked_addr)]); + + let psbt_b64 = make_test_psbt_base64(blocked_addr); + assert!(matches!(screen_v1_addresses(&psbt_b64, &blocked), ScreenResult::Blocked)); + } + + #[test] + fn screen_allows_clean_psbt() { + let clean_addr = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"; + let blocked = std::collections::HashSet::new(); // empty + let psbt_b64 = make_test_psbt_base64(clean_addr); + assert!(matches!(screen_v1_addresses(&psbt_b64, &blocked), ScreenResult::Clean)); + } + + #[test] + fn screen_allows_non_blocked_address() { + let addr = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"; + let blocked = + std::collections::HashSet::from([addr_to_script("3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy")]); + + let psbt_b64 = make_test_psbt_base64(addr); + assert!(matches!(screen_v1_addresses(&psbt_b64, &blocked), ScreenResult::Clean)); + } + + #[test] + fn screen_parse_error_on_invalid_base64() { + let blocked = + std::collections::HashSet::from([addr_to_script("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa")]); + assert!(matches!( + screen_v1_addresses("not-valid-base64!!!", &blocked), + ScreenResult::ParseError(_) + )); + } + + #[test] + fn screen_parse_error_on_invalid_psbt() { + use bitcoin::base64::prelude::{Engine, BASE64_STANDARD}; + let blocked = + std::collections::HashSet::from([addr_to_script("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa")]); + let bad_psbt = BASE64_STANDARD.encode(b"not a psbt"); + assert!(matches!(screen_v1_addresses(&bad_psbt, &blocked), ScreenResult::ParseError(_))); + } + + #[test] + fn screen_blocks_bech32_address() { + let addr = "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"; + let blocked = std::collections::HashSet::from([addr_to_script(addr)]); + + let psbt_b64 = make_test_psbt_base64(addr); + assert!(matches!(screen_v1_addresses(&psbt_b64, &blocked), ScreenResult::Blocked)); + } +} diff --git a/payjoin-mailroom/src/access_control.rs b/payjoin-mailroom/src/access_control.rs index 726171431..5a7221e5e 100644 --- a/payjoin-mailroom/src/access_control.rs +++ b/payjoin-mailroom/src/access_control.rs @@ -71,6 +71,36 @@ impl GeoIp { } } +pub fn load_blocked_address_text(path: &Path) -> anyhow::Result { + Ok(std::fs::read_to_string(path)?) +} + +pub fn spawn_address_list_updater( + url: String, + refresh: std::time::Duration, + cache_path: std::path::PathBuf, + blocked: payjoin_directory::BlockedAddresses, +) { + tokio::spawn(async move { + loop { + match reqwest::get(&url).await.and_then(|r| r.error_for_status()) { + Ok(resp) => match resp.text().await { + Ok(body) => { + if let Err(e) = std::fs::write(&cache_path, &body) { + tracing::warn!("Failed to write address cache: {e}"); + } + let count = blocked.update_from_lines(&body).await; + tracing::info!("Updated blocked address list ({count} entries)"); + } + Err(e) => tracing::warn!("Failed to read address list response: {e}"), + }, + Err(e) => tracing::warn!("Failed to fetch address list: {e}"), + } + tokio::time::sleep(refresh).await; + } + }); +} + async fn fetch_geoip_db(dest: &Path) -> anyhow::Result<()> { use std::io::Read; diff --git a/payjoin-mailroom/src/config.rs b/payjoin-mailroom/src/config.rs index 97d527369..53a60795c 100644 --- a/payjoin-mailroom/src/config.rs +++ b/payjoin-mailroom/src/config.rs @@ -44,6 +44,9 @@ pub struct AcmeConfig { pub struct AccessControlConfig { pub geo_db_path: Option, pub blocked_regions: Vec, + pub blocked_addresses_path: Option, + pub blocked_addresses_url: Option, + pub blocked_addresses_refresh_secs: Option, } #[cfg(feature = "acme")] diff --git a/payjoin-mailroom/src/lib.rs b/payjoin-mailroom/src/lib.rs index 5c19d1c1d..7c0c270a3 100644 --- a/payjoin-mailroom/src/lib.rs +++ b/payjoin-mailroom/src/lib.rs @@ -39,7 +39,12 @@ pub async fn serve(config: Config, meter_provider: Option) -> #[cfg(feature = "access-control")] let geoip = init_geoip(&config).await?; - let directory = init_directory(&config, sentinel_tag).await?; + #[allow(unused_mut)] + let mut directory = init_directory(&config, sentinel_tag).await?; + #[cfg(feature = "access-control")] + if let Some(blocked) = init_blocked_addresses(&config).await? { + directory = directory.with_blocked_addresses(blocked); + } let services = Services { directory, @@ -82,7 +87,12 @@ pub async fn serve_manual_tls( #[cfg(feature = "access-control")] let geoip = init_geoip(&config).await?; - let directory = init_directory(&config, sentinel_tag).await?; + #[allow(unused_mut)] + let mut directory = init_directory(&config, sentinel_tag).await?; + #[cfg(feature = "access-control")] + if let Some(blocked) = init_blocked_addresses(&config).await? { + directory = directory.with_blocked_addresses(blocked); + } let services = Services { directory, @@ -146,7 +156,12 @@ pub async fn serve_acme( #[cfg(feature = "access-control")] let geoip = init_geoip(&config).await?; - let directory = init_directory(&config, sentinel_tag).await?; + #[allow(unused_mut)] + let mut directory = init_directory(&config, sentinel_tag).await?; + #[cfg(feature = "access-control")] + if let Some(blocked) = init_blocked_addresses(&config).await? { + directory = directory.with_blocked_addresses(blocked); + } let services = Services { directory, @@ -233,6 +248,86 @@ async fn init_geoip( } } +#[cfg(feature = "access-control")] +async fn init_blocked_addresses( + config: &Config, +) -> anyhow::Result> { + let ac_config = match &config.access_control { + Some(c) => c, + None => return Ok(None), + }; + + // Neither file nor URL configured + if ac_config.blocked_addresses_path.is_none() && ac_config.blocked_addresses_url.is_none() { + return Ok(None); + } + + // Load initial addresses from file if available + let blocked = match &ac_config.blocked_addresses_path { + Some(path) => { + let text = access_control::load_blocked_address_text(path)?; + let ba = payjoin_directory::BlockedAddresses::from_address_lines(&text); + info!("Loaded blocked addresses from {}", path.display()); + ba + } + None => payjoin_directory::BlockedAddresses::empty(), + }; + + // If URL configured, try initial fetch and spawn background updater + if let Some(url) = &ac_config.blocked_addresses_url { + let cache_path = config.storage_dir.join("blocked_addresses_cache.txt"); + let refresh = std::time::Duration::from_secs( + ac_config.blocked_addresses_refresh_secs.unwrap_or(86400), + ); + + // Try initial fetch; fall back to cache on failure + match reqwest::get(url).await.and_then(|r| r.error_for_status()) { + Ok(resp) => match resp.text().await { + Ok(body) => { + if let Err(e) = std::fs::write(&cache_path, &body) { + tracing::warn!("Failed to write address cache: {e}"); + } + let count = blocked.update_from_lines(&body).await; + info!("Fetched {count} blocked addresses from URL"); + } + Err(e) => { + tracing::warn!("Failed to read address list response: {e}"); + load_address_cache(&cache_path, &blocked).await; + } + }, + Err(e) => { + tracing::warn!("Failed to fetch address list: {e}"); + load_address_cache(&cache_path, &blocked).await; + } + } + + access_control::spawn_address_list_updater( + url.clone(), + refresh, + cache_path, + blocked.clone(), + ); + } + + Ok(Some(blocked)) +} + +#[cfg(feature = "access-control")] +async fn load_address_cache( + cache_path: &std::path::Path, + blocked: &payjoin_directory::BlockedAddresses, +) { + if cache_path.exists() { + match access_control::load_blocked_address_text(cache_path) { + Ok(text) => { + let count = blocked.update_from_lines(&text).await; + info!("Loaded {count} blocked addresses from cache"); + } + Err(e) => tracing::warn!("Failed to load address cache: {e}"), + } + } +} + fn init_ohttp_config( ohttp_keys_dir: &std::path::Path, ) -> anyhow::Result { From f92ae86d2e335f4acdfd04f3025b9e9a2b3aa441 Mon Sep 17 00:00:00 2001 From: spacebear Date: Thu, 19 Feb 2026 15:08:08 -0500 Subject: [PATCH 3/8] Nest address screening inside V1 config Since address screening is only relevant when V1 is enabled, it doesn't make much sense to expose the blocked_* config otherwise. This replaces the `enable_v1` bool with a new `v1` config section. The presence / absence of that config section indicates whether to enable v1 or not, and blocked address settings can be configured within that section if the access-control feature is enabled. --- payjoin-directory/src/lib.rs | 41 +++++++++++++++---------- payjoin-directory/src/main.rs | 3 +- payjoin-mailroom/config.example.toml | 24 +++++++++------ payjoin-mailroom/src/config.rs | 26 +++++++++++----- payjoin-mailroom/src/lib.rs | 46 ++++++++++++---------------- payjoin-test-utils/src/lib.rs | 4 +-- 6 files changed, 81 insertions(+), 63 deletions(-) diff --git a/payjoin-directory/src/lib.rs b/payjoin-directory/src/lib.rs index 68c8e8e50..aea5ea86c 100644 --- a/payjoin-directory/src/lib.rs +++ b/payjoin-directory/src/lib.rs @@ -94,6 +94,19 @@ impl BlockedAddresses { } } +/// V1 protocol configuration. +/// +/// Its presence in [`Service`] enables the V1 fallback path; +/// its contents carry optional blocklist screening. +#[derive(Clone, Default)] +pub struct V1 { + blocked_addresses: Option, +} + +impl V1 { + pub fn new(blocked_addresses: Option) -> Self { Self { blocked_addresses } } +} + fn parse_address_lines(text: &str) -> std::collections::HashSet { text.lines() .filter_map(|l| { @@ -117,8 +130,7 @@ pub struct Service { db: D, ohttp: ohttp::Server, sentinel_tag: SentinelTag, - enable_v1: bool, - blocked_addresses: Option, + v1: Option, } impl tower::Service> for Service @@ -142,13 +154,8 @@ where } impl Service { - pub fn new(db: D, ohttp: ohttp::Server, sentinel_tag: SentinelTag, enable_v1: bool) -> Self { - Self { db, ohttp, sentinel_tag, enable_v1, blocked_addresses: None } - } - - pub fn with_blocked_addresses(mut self, addrs: BlockedAddresses) -> Self { - self.blocked_addresses = Some(addrs); - self + pub fn new(db: D, ohttp: ohttp::Server, sentinel_tag: SentinelTag, v1: Option) -> Self { + Self { db, ohttp, sentinel_tag, v1 } } #[cfg(feature = "_manual-tls")] @@ -294,7 +301,7 @@ impl Service { B: Body + Send + 'static, B::Error: Into, { - if self.enable_v1 { + if self.v1.is_some() { self.post_fallback_v1(id, query, body).await } else { let _ = (id, query, body); @@ -382,7 +389,7 @@ impl Service { match (parts.method, path_segments.as_slice()) { (Method::POST, &["", id]) => self.post_mailbox(id, body).await, (Method::GET, &["", id]) => self.get_mailbox(id).await, - (Method::PUT, &["", id]) if self.enable_v1 => self.put_payjoin_v1(id, body).await, + (Method::PUT, &["", id]) if self.v1.is_some() => self.put_payjoin_v1(id, body).await, _ => Ok(not_found()), } } @@ -472,7 +479,7 @@ impl Service { Err(_) => return Ok(bad_request_body_res), }; - if let Some(blocked) = &self.blocked_addresses { + if let Some(blocked) = self.v1.as_ref().and_then(|v| v.blocked_addresses.as_ref()) { let scripts = blocked.0.read().await; if !scripts.is_empty() { match screen_v1_addresses(&body_str, &scripts) { @@ -763,12 +770,12 @@ mod tests { use super::*; - async fn test_service(enable_v1: bool) -> Service { + async fn test_service(v1: Option) -> Service { let dir = tempfile::tempdir().expect("tempdir"); let db = FilesDb::init(Duration::from_millis(100), dir.keep()).await.expect("db init"); let ohttp: ohttp::Server = key_config::gen_ohttp_server_config().expect("ohttp config").into(); - Service::new(db, ohttp, SentinelTag::new([0u8; 32]), enable_v1) + Service::new(db, ohttp, SentinelTag::new([0u8; 32]), v1) } /// A valid ShortId encoded as bech32 for use in URL paths. @@ -785,7 +792,7 @@ mod tests { #[tokio::test] async fn post_v1_when_disabled_returns_version_unsupported() { - let mut svc = test_service(false).await; + let mut svc = test_service(None).await; let id = valid_short_id_path(); let req = Request::builder() .method(Method::POST) @@ -802,7 +809,7 @@ mod tests { #[tokio::test] async fn post_v1_with_invalid_body_returns_reject() { - let mut svc = test_service(true).await; + let mut svc = test_service(Some(V1::new(None))).await; let id = valid_short_id_path(); let req = Request::builder() .method(Method::POST) @@ -819,7 +826,7 @@ mod tests { #[tokio::test] async fn post_v1_with_no_receiver_returns_unavailable() { - let mut svc = test_service(true).await; + let mut svc = test_service(Some(V1::new(None))).await; let id = valid_short_id_path(); let req = Request::builder() .method(Method::POST) diff --git a/payjoin-directory/src/main.rs b/payjoin-directory/src/main.rs index e60483f3d..2d7ae3554 100644 --- a/payjoin-directory/src/main.rs +++ b/payjoin-directory/src/main.rs @@ -29,7 +29,8 @@ async fn main() -> Result<(), BoxError> { .await .expect("Failed to initialize persistent storage"); - let service = Service::new(db, ohttp.into(), SentinelTag::new([0u8; 32]), config.enable_v1); + let v1 = if config.enable_v1 { Some(V1::new(None)) } else { None }; + let service = Service::new(db, ohttp.into(), SentinelTag::new([0u8; 32]), v1); let listener = TcpListener::bind(config.listen_addr).await?; diff --git a/payjoin-mailroom/config.example.toml b/payjoin-mailroom/config.example.toml index fe92e5ae8..7b3451f54 100644 --- a/payjoin-mailroom/config.example.toml +++ b/payjoin-mailroom/config.example.toml @@ -13,6 +13,20 @@ # Request timeout in seconds # timeout = 30 +# --- V1 protocol --- +# Uncomment the [v1] section to enable V1 fallback support. +# (address screening requires `access-control` feature) +# [v1] + +# Path to a local file containing blocked Bitcoin addresses (one per line). +# blocked_addresses_path = "/path/to/blocked_addresses.txt" + +# URL to periodically fetch an updated blocked-address list from. +# blocked_addresses_url = "https://example.com/blocked_addresses.txt" + +# How often (in seconds) to refresh the remote address list (default: 86400). +# blocked_addresses_refresh_secs = 86400 + # --- Access-control (requires `access-control` feature) --- # [access_control] @@ -23,13 +37,3 @@ # ISO 3166-1 alpha-2 country codes whose requests should be blocked. # blocked_regions = ["CU", "IR", "KP", "SY"] - -# Path to a local file containing blocked Bitcoin addresses (one per line). -# Used for V1 PSBT screening. -# blocked_addresses_path = "/path/to/blocked_addresses.txt" - -# URL to periodically fetch an updated blocked-address list from. -# blocked_addresses_url = "https://example.com/blocked_addresses.txt" - -# How often (in seconds) to refresh the remote address list (default: 86400). -# blocked_addresses_refresh_secs = 86400 diff --git a/payjoin-mailroom/src/config.rs b/payjoin-mailroom/src/config.rs index 53a60795c..d8579f050 100644 --- a/payjoin-mailroom/src/config.rs +++ b/payjoin-mailroom/src/config.rs @@ -12,7 +12,7 @@ pub struct Config { pub storage_dir: PathBuf, #[serde(deserialize_with = "deserialize_duration_secs")] pub timeout: Duration, - pub enable_v1: bool, + pub v1: Option, #[cfg(feature = "telemetry")] pub telemetry: Option, #[cfg(feature = "acme")] @@ -21,6 +21,21 @@ pub struct Config { pub access_control: Option, } +/// V1 protocol configuration. +/// +/// Present in [`Config`] to enable the V1 fallback path. +/// Contains optional address-screening settings that only apply to V1. +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(default)] +pub struct V1Config { + #[cfg(feature = "access-control")] + pub blocked_addresses_path: Option, + #[cfg(feature = "access-control")] + pub blocked_addresses_url: Option, + #[cfg(feature = "access-control")] + pub blocked_addresses_refresh_secs: Option, +} + #[cfg(feature = "telemetry")] #[derive(Debug, Clone, Deserialize)] pub struct TelemetryConfig { @@ -44,9 +59,6 @@ pub struct AcmeConfig { pub struct AccessControlConfig { pub geo_db_path: Option, pub blocked_regions: Vec, - pub blocked_addresses_path: Option, - pub blocked_addresses_url: Option, - pub blocked_addresses_refresh_secs: Option, } #[cfg(feature = "acme")] @@ -72,7 +84,7 @@ impl Default for Config { listener: "[::]:8080".parse().expect("valid default listener address"), storage_dir: PathBuf::from("./data"), timeout: Duration::from_secs(30), - enable_v1: false, + v1: None, #[cfg(feature = "telemetry")] telemetry: None, #[cfg(feature = "acme")] @@ -96,13 +108,13 @@ impl Config { listener: ListenerAddress, storage_dir: PathBuf, timeout: Duration, - enable_v1: bool, + v1: Option, ) -> Self { Self { listener, storage_dir, timeout, - enable_v1, + v1, #[cfg(feature = "telemetry")] telemetry: None, #[cfg(feature = "acme")] diff --git a/payjoin-mailroom/src/lib.rs b/payjoin-mailroom/src/lib.rs index 7c0c270a3..62d943a7d 100644 --- a/payjoin-mailroom/src/lib.rs +++ b/payjoin-mailroom/src/lib.rs @@ -39,12 +39,7 @@ pub async fn serve(config: Config, meter_provider: Option) -> #[cfg(feature = "access-control")] let geoip = init_geoip(&config).await?; - #[allow(unused_mut)] - let mut directory = init_directory(&config, sentinel_tag).await?; - #[cfg(feature = "access-control")] - if let Some(blocked) = init_blocked_addresses(&config).await? { - directory = directory.with_blocked_addresses(blocked); - } + let directory = init_directory(&config, sentinel_tag).await?; let services = Services { directory, @@ -87,12 +82,7 @@ pub async fn serve_manual_tls( #[cfg(feature = "access-control")] let geoip = init_geoip(&config).await?; - #[allow(unused_mut)] - let mut directory = init_directory(&config, sentinel_tag).await?; - #[cfg(feature = "access-control")] - if let Some(blocked) = init_blocked_addresses(&config).await? { - directory = directory.with_blocked_addresses(blocked); - } + let directory = init_directory(&config, sentinel_tag).await?; let services = Services { directory, @@ -156,12 +146,7 @@ pub async fn serve_acme( #[cfg(feature = "access-control")] let geoip = init_geoip(&config).await?; - #[allow(unused_mut)] - let mut directory = init_directory(&config, sentinel_tag).await?; - #[cfg(feature = "access-control")] - if let Some(blocked) = init_blocked_addresses(&config).await? { - directory = directory.with_blocked_addresses(blocked); - } + let directory = init_directory(&config, sentinel_tag).await?; let services = Services { directory, @@ -231,7 +216,16 @@ async fn init_directory( let ohttp_keys_dir = config.storage_dir.join("ohttp-keys"); let ohttp_config = init_ohttp_config(&ohttp_keys_dir)?; - Ok(payjoin_directory::Service::new(db, ohttp_config.into(), sentinel_tag, config.enable_v1)) + let v1 = if config.v1.is_some() { + #[cfg(feature = "access-control")] + let blocked = init_blocked_addresses(config).await?; + #[cfg(not(feature = "access-control"))] + let blocked = None; + Some(payjoin_directory::V1::new(blocked)) + } else { + None + }; + Ok(payjoin_directory::Service::new(db, ohttp_config.into(), sentinel_tag, v1)) } #[cfg(feature = "access-control")] @@ -252,18 +246,18 @@ async fn init_geoip( async fn init_blocked_addresses( config: &Config, ) -> anyhow::Result> { - let ac_config = match &config.access_control { + let v1_config = match &config.v1 { Some(c) => c, None => return Ok(None), }; // Neither file nor URL configured - if ac_config.blocked_addresses_path.is_none() && ac_config.blocked_addresses_url.is_none() { + if v1_config.blocked_addresses_path.is_none() && v1_config.blocked_addresses_url.is_none() { return Ok(None); } // Load initial addresses from file if available - let blocked = match &ac_config.blocked_addresses_path { + let blocked = match &v1_config.blocked_addresses_path { Some(path) => { let text = access_control::load_blocked_address_text(path)?; let ba = payjoin_directory::BlockedAddresses::from_address_lines(&text); @@ -274,10 +268,10 @@ async fn init_blocked_addresses( }; // If URL configured, try initial fetch and spawn background updater - if let Some(url) = &ac_config.blocked_addresses_url { + if let Some(url) = &v1_config.blocked_addresses_url { let cache_path = config.storage_dir.join("blocked_addresses_cache.txt"); let refresh = std::time::Duration::from_secs( - ac_config.blocked_addresses_refresh_secs.unwrap_or(86400), + v1_config.blocked_addresses_refresh_secs.unwrap_or(86400), ); // Try initial fetch; fall back to cache on failure @@ -432,7 +426,7 @@ mod tests { "[::]:0".parse().expect("valid listener address"), tempdir.path().to_path_buf(), Duration::from_secs(2), - false, + None, ); let mut root_store = RootCertStore::empty(); @@ -527,7 +521,7 @@ mod tests { "[::]:0".parse().expect("valid listener address"), tempdir.path().to_path_buf(), Duration::from_secs(2), - false, + None, ); let sentinel_tag = generate_sentinel_tag(); diff --git a/payjoin-test-utils/src/lib.rs b/payjoin-test-utils/src/lib.rs index e3b8fcece..9c467ca29 100644 --- a/payjoin-test-utils/src/lib.rs +++ b/payjoin-test-utils/src/lib.rs @@ -121,7 +121,7 @@ pub async fn init_directory( "[::]:0".parse().expect("valid listener address"), tempdir.path().to_path_buf(), Duration::from_secs(2), - true, + Some(payjoin_mailroom::config::V1Config::default()), ); let tls_config = RustlsConfig::from_der(vec![local_cert_key.0], local_cert_key.1).await?; @@ -149,7 +149,7 @@ async fn init_ohttp_relay( "[::]:0".parse().expect("valid listener address"), tempdir.path().to_path_buf(), Duration::from_secs(2), - false, + None, ); let (port, handle) = payjoin_mailroom::serve_manual_tls(config, None, root_store) From 9f366c2e9d55d21652375dd4ba5d3b402dba8357 Mon Sep 17 00:00:00 2001 From: spacebear Date: Thu, 19 Feb 2026 15:33:10 -0500 Subject: [PATCH 4/8] Screen v1 requests in PUT requests Since the receiver's proposal contains new outputs and inputs contributed by the receiver, that PSBT should also be screened. --- payjoin-directory/src/lib.rs | 90 ++++++++++++++++++++++++------------ 1 file changed, 60 insertions(+), 30 deletions(-) diff --git a/payjoin-directory/src/lib.rs b/payjoin-directory/src/lib.rs index aea5ea86c..167cd4e81 100644 --- a/payjoin-directory/src/lib.rs +++ b/payjoin-directory/src/lib.rs @@ -428,6 +428,30 @@ impl Service { let timeout_response = Response::builder().status(StatusCode::ACCEPTED).body(empty())?; handle_peek(self.db.wait_for_v2_payload(&id).await, timeout_response) } + + /// Screen a V1 PSBT body against the address blocklist. + /// + /// Returns `Ok(())` if screening passes or is not configured. + async fn check_v1_blocklist(&self, body_str: &str) -> Result<(), HandlerError> { + if let Some(blocked) = self.v1.as_ref().and_then(|v| v.blocked_addresses.as_ref()) { + let scripts = blocked.0.read().await; + if !scripts.is_empty() { + match screen_v1_addresses(body_str, &scripts) { + ScreenResult::Blocked => { + return Err(HandlerError::Forbidden(anyhow::anyhow!( + "blocked address in V1 PSBT" + ))); + } + ScreenResult::Clean => {} + ScreenResult::ParseError(e) => { + warn!("Could not parse V1 PSBT: {e}"); + } + } + } + } + Ok(()) + } + async fn put_payjoin_v1( &self, id: &str, @@ -446,6 +470,9 @@ impl Service { return Err(HandlerError::PayloadTooLarge); } + let body_str = std::str::from_utf8(&req).map_err(|e| HandlerError::BadRequest(e.into()))?; + self.check_v1_blocklist(body_str).await?; + match self.db.post_v1_response(&id, req.into()).await { Ok(_) => Ok(ok_response), Err(e) => Err(HandlerError::BadRequest(e.into())), @@ -479,23 +506,7 @@ impl Service { Err(_) => return Ok(bad_request_body_res), }; - if let Some(blocked) = self.v1.as_ref().and_then(|v| v.blocked_addresses.as_ref()) { - let scripts = blocked.0.read().await; - if !scripts.is_empty() { - match screen_v1_addresses(&body_str, &scripts) { - ScreenResult::Blocked => { - return Ok(Response::builder() - .status(StatusCode::FORBIDDEN) - .body(empty())?); - } - ScreenResult::Clean => {} - ScreenResult::ParseError(e) => { - warn!("Could not screen V1 payload: {e}"); - // fail-open: unparsable PSBTs can't complete transactions - } - } - } - } + self.check_v1_blocklist(&body_str).await?; let v2_compat_body = format!("{body_str}\n{query}"); let id = ShortId::from_str(id)?; @@ -790,6 +801,8 @@ mod tests { (parts.status, String::from_utf8(bytes.to_vec()).unwrap()) } + // V1 routing + #[tokio::test] async fn post_v1_when_disabled_returns_version_unsupported() { let mut svc = test_service(None).await; @@ -840,24 +853,17 @@ mod tests { assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); assert_eq!(body, V1_UNAVAILABLE_RES_JSON); } -} -#[cfg(test)] -mod screen_tests { - use super::*; - - fn addr_to_script(address: &str) -> bitcoin::ScriptBuf { - let addr: bitcoin::Address = - address.parse().expect("valid address"); - addr.assume_checked().script_pubkey() - } + // Address screening fn make_test_psbt_base64(output_address: &str) -> String { use bitcoin::base64::prelude::{Engine, BASE64_STANDARD}; use bitcoin::psbt::Psbt; use bitcoin::{Amount, Transaction, TxIn, TxOut}; - let script_pubkey = addr_to_script(output_address); + let addr: bitcoin::Address = + output_address.parse().expect("valid address"); + let script_pubkey = addr.assume_checked().script_pubkey(); let tx = Transaction { version: bitcoin::transaction::Version::TWO, @@ -867,8 +873,32 @@ mod screen_tests { }; let psbt = Psbt::from_unsigned_tx(tx).expect("valid psbt"); - let serialized = psbt.serialize(); - BASE64_STANDARD.encode(&serialized) + BASE64_STANDARD.encode(psbt.serialize()) + } + + fn addr_to_script(address: &str) -> bitcoin::ScriptBuf { + let addr: bitcoin::Address = + address.parse().expect("valid address"); + addr.assume_checked().script_pubkey() + } + + #[tokio::test] + async fn post_v1_with_blocked_address_returns_forbidden() { + let blocked_addr = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"; + let blocked = BlockedAddresses::from_address_lines(blocked_addr); + let mut svc = test_service(Some(V1::new(Some(blocked)))).await; + let id = valid_short_id_path(); + let psbt_b64 = make_test_psbt_base64(blocked_addr); + let req = Request::builder() + .method(Method::POST) + .uri(format!("http://localhost/{id}")) + .body(Full::new(Bytes::from(psbt_b64))) + .unwrap(); + + let res = tower::Service::call(&mut svc, req).await.unwrap(); + let (status, _body) = collect_body(res).await; + + assert_eq!(status, StatusCode::FORBIDDEN); } #[test] From 4c450cc138d35606e0187e30aa9aa1428e68e96b Mon Sep 17 00:00:00 2001 From: spacebear Date: Thu, 19 Feb 2026 16:00:45 -0500 Subject: [PATCH 5/8] Add blocked_ips config and CIDR matching Allow operators to block specific IP addresses or CIDR ranges independently of geographic region. Bare IPs ("192.0.2.1") and CIDR notation ("192.0.2.0/24") are both accepted in the config. --- Cargo-minimal.lock | 1 + Cargo-recent.lock | 1 + payjoin-mailroom/Cargo.toml | 3 +- payjoin-mailroom/config.example.toml | 3 + payjoin-mailroom/src/access_control.rs | 76 +++++++++++++++++++++++--- payjoin-mailroom/src/config.rs | 2 + payjoin-mailroom/src/lib.rs | 6 +- payjoin-mailroom/src/middleware.rs | 2 +- 8 files changed, 80 insertions(+), 14 deletions(-) diff --git a/Cargo-minimal.lock b/Cargo-minimal.lock index a6eb29a82..04c6819eb 100644 --- a/Cargo-minimal.lock +++ b/Cargo-minimal.lock @@ -2798,6 +2798,7 @@ dependencies = [ "clap", "config", "flate2", + "ipnet", "maxminddb", "ohttp-relay", "opentelemetry", diff --git a/Cargo-recent.lock b/Cargo-recent.lock index a6eb29a82..04c6819eb 100644 --- a/Cargo-recent.lock +++ b/Cargo-recent.lock @@ -2798,6 +2798,7 @@ dependencies = [ "clap", "config", "flate2", + "ipnet", "maxminddb", "ohttp-relay", "opentelemetry", diff --git a/payjoin-mailroom/Cargo.toml b/payjoin-mailroom/Cargo.toml index e65672cad..4b5ba8448 100644 --- a/payjoin-mailroom/Cargo.toml +++ b/payjoin-mailroom/Cargo.toml @@ -22,7 +22,7 @@ acme = [ "dep:rustls", "dep:tokio-stream", ] -access-control = ["dep:flate2", "dep:maxminddb", "dep:reqwest"] +access-control = ["dep:flate2", "dep:ipnet", "dep:maxminddb", "dep:reqwest"] telemetry = ["dep:opentelemetry-otlp"] [dependencies] @@ -34,6 +34,7 @@ axum-server = { version = "0.8", features = [ clap = { version = "4.5", features = ["derive", "env"] } config = "0.15" flate2 = { version = "1.1", optional = true } +ipnet = { version = "2", optional = true } maxminddb = { version = "0.27", optional = true } ohttp-relay = { path = "../ohttp-relay", features = ["bootstrap"] } opentelemetry = "0.31" diff --git a/payjoin-mailroom/config.example.toml b/payjoin-mailroom/config.example.toml index 7b3451f54..e3e954afa 100644 --- a/payjoin-mailroom/config.example.toml +++ b/payjoin-mailroom/config.example.toml @@ -37,3 +37,6 @@ # ISO 3166-1 alpha-2 country codes whose requests should be blocked. # blocked_regions = ["CU", "IR", "KP", "SY"] + +# IP addresses or CIDR ranges whose requests should be blocked. +# blocked_ips = ["192.0.2.0/24", "2001:db8::1"] diff --git a/payjoin-mailroom/src/access_control.rs b/payjoin-mailroom/src/access_control.rs index 5a7221e5e..64bb9639d 100644 --- a/payjoin-mailroom/src/access_control.rs +++ b/payjoin-mailroom/src/access_control.rs @@ -6,12 +6,13 @@ use maxminddb::PathElement; use crate::config::AccessControlConfig; -pub struct GeoIp { +pub struct IpFilter { geo_reader: Option>>, blocked_regions: HashSet, + blocked_ips: Vec, } -impl GeoIp { +impl IpFilter { pub async fn from_config( config: &AccessControlConfig, storage_dir: &Path, @@ -42,11 +43,30 @@ impl GeoIp { let blocked_regions = config.blocked_regions.iter().cloned().collect(); - Ok(Self { geo_reader, blocked_regions }) + let blocked_ips = config + .blocked_ips + .iter() + .map(|s| { + s.parse::().or_else(|_| { + // Accept bare IP addresses without CIDR prefix length + Ok(ipnet::IpNet::from(s.parse::()?)) + }) + }) + .collect::, anyhow::Error>>()?; + + Ok(Self { geo_reader, blocked_regions, blocked_ips }) } - /// Returns `true` if the IP is allowed. Fail-open on lookup errors. + /// Returns `true` if the IP is allowed. Fail-open on GeoIP lookup errors. pub fn check_ip(&self, ip: IpAddr) -> bool { + if self.blocked_ips.iter().any(|net| net.contains(&ip)) { + return false; + } + + self.check_geoip(ip) + } + + fn check_geoip(&self, ip: IpAddr) -> bool { let reader = match &self.geo_reader { Some(r) => r, None => return true, @@ -165,14 +185,19 @@ mod tests { #[test] fn check_ip_allows_when_no_geo_reader() { - let ac = GeoIp { geo_reader: None, blocked_regions: HashSet::new() }; + let ac = + IpFilter { geo_reader: None, blocked_regions: HashSet::new(), blocked_ips: vec![] }; assert!(ac.check_ip("1.2.3.4".parse().unwrap())); } #[test] fn check_ip_allows_when_no_blocked_regions() { let reader = test_geo_reader(); - let ac = GeoIp { geo_reader: Some(reader), blocked_regions: HashSet::new() }; + let ac = IpFilter { + geo_reader: Some(reader), + blocked_regions: HashSet::new(), + blocked_ips: vec![], + }; assert!(ac.check_ip("2.125.160.216".parse().unwrap())); } @@ -181,7 +206,7 @@ mod tests { let reader = test_geo_reader(); // 2.125.160.216 is GB in the test database let blocked_regions: HashSet = ["GB"].iter().map(|s| s.to_string()).collect(); - let ac = GeoIp { geo_reader: Some(reader), blocked_regions }; + let ac = IpFilter { geo_reader: Some(reader), blocked_regions, blocked_ips: vec![] }; assert!(!ac.check_ip("2.125.160.216".parse().unwrap())); } @@ -190,7 +215,7 @@ mod tests { let reader = test_geo_reader(); // 2.125.160.216 is GB in the test database let blocked_regions: HashSet = ["US"].iter().map(|s| s.to_string()).collect(); - let ac = GeoIp { geo_reader: Some(reader), blocked_regions }; + let ac = IpFilter { geo_reader: Some(reader), blocked_regions, blocked_ips: vec![] }; assert!(ac.check_ip("2.125.160.216".parse().unwrap())); } @@ -198,11 +223,44 @@ mod tests { fn check_ip_fail_open_on_unknown_ip() { let reader = test_geo_reader(); let blocked_regions: HashSet = ["US"].iter().map(|s| s.to_string()).collect(); - let ac = GeoIp { geo_reader: Some(reader), blocked_regions }; + let ac = IpFilter { geo_reader: Some(reader), blocked_regions, blocked_ips: vec![] }; // 127.0.0.1 won't be in test DB assert!(ac.check_ip("127.0.0.1".parse().unwrap())); } + #[test] + fn blocked_ips_blocks_exact_ipv4() { + let blocked_ips = vec!["192.0.2.1/32".parse().unwrap()]; + let ac = IpFilter { geo_reader: None, blocked_regions: HashSet::new(), blocked_ips }; + assert!(!ac.check_ip("192.0.2.1".parse().unwrap())); + assert!(ac.check_ip("192.0.2.2".parse().unwrap())); + } + + #[test] + fn blocked_ips_blocks_exact_ipv6() { + let blocked_ips = vec!["2001:db8::1/128".parse().unwrap()]; + let ac = IpFilter { geo_reader: None, blocked_regions: HashSet::new(), blocked_ips }; + assert!(!ac.check_ip("2001:db8::1".parse().unwrap())); + assert!(ac.check_ip("2001:db8::2".parse().unwrap())); + } + + #[test] + fn blocked_ips_blocks_cidr_range() { + let blocked_ips = vec!["198.51.100.0/24".parse().unwrap()]; + let ac = IpFilter { geo_reader: None, blocked_regions: HashSet::new(), blocked_ips }; + assert!(!ac.check_ip("198.51.100.0".parse().unwrap())); + assert!(!ac.check_ip("198.51.100.255".parse().unwrap())); + assert!(ac.check_ip("198.51.101.0".parse().unwrap())); + } + + #[test] + fn empty_blocked_ips_allows_all() { + let ac = + IpFilter { geo_reader: None, blocked_regions: HashSet::new(), blocked_ips: vec![] }; + assert!(ac.check_ip("192.0.2.1".parse().unwrap())); + assert!(ac.check_ip("2001:db8::1".parse().unwrap())); + } + #[test] fn year_month_conversion_handles_leap_day() { // 2024-02-29 00:00:00 UTC diff --git a/payjoin-mailroom/src/config.rs b/payjoin-mailroom/src/config.rs index d8579f050..a040445ae 100644 --- a/payjoin-mailroom/src/config.rs +++ b/payjoin-mailroom/src/config.rs @@ -59,6 +59,7 @@ pub struct AcmeConfig { pub struct AccessControlConfig { pub geo_db_path: Option, pub blocked_regions: Vec, + pub blocked_ips: Vec, } #[cfg(feature = "acme")] @@ -139,6 +140,7 @@ impl Config { .with_list_parse_key("acme.domains") .with_list_parse_key("acme.contact") .with_list_parse_key("access_control.blocked_regions") + .with_list_parse_key("access_control.blocked_ips") .try_parsing(true), ) .build()? diff --git a/payjoin-mailroom/src/lib.rs b/payjoin-mailroom/src/lib.rs index 62d943a7d..08273bdba 100644 --- a/payjoin-mailroom/src/lib.rs +++ b/payjoin-mailroom/src/lib.rs @@ -30,7 +30,7 @@ struct Services { relay: ohttp_relay::Service, metrics: MetricsService, #[cfg(feature = "access-control")] - geoip: Option>, + geoip: Option>, } pub async fn serve(config: Config, meter_provider: Option) -> anyhow::Result<()> { @@ -231,10 +231,10 @@ async fn init_directory( #[cfg(feature = "access-control")] async fn init_geoip( config: &Config, -) -> anyhow::Result>> { +) -> anyhow::Result>> { match &config.access_control { Some(ac_config) => { - let gi = access_control::GeoIp::from_config(ac_config, &config.storage_dir).await?; + let gi = access_control::IpFilter::from_config(ac_config, &config.storage_dir).await?; info!("GeoIP access control enabled"); Ok(Some(std::sync::Arc::new(gi))) } diff --git a/payjoin-mailroom/src/middleware.rs b/payjoin-mailroom/src/middleware.rs index c876ef489..786337398 100644 --- a/payjoin-mailroom/src/middleware.rs +++ b/payjoin-mailroom/src/middleware.rs @@ -12,7 +12,7 @@ pub struct MaybePeerIp(pub Option); pub async fn check_geoip(req: Request, next: Next) -> Response { use axum::http::StatusCode; - let geoip = req.extensions().get::>>(); + let geoip = req.extensions().get::>>(); if let Some(Some(geoip)) = geoip { if let Some(connect_info) = From 28df26fa80fd09f59aa852c58f62325c979687e4 Mon Sep 17 00:00:00 2001 From: spacebear Date: Thu, 19 Feb 2026 19:20:21 -0500 Subject: [PATCH 6/8] Simplify geoIP DB fetching Use the automatically-updated CDN download link from https://github.com/wp-statistics/GeoLite2-Country?tab=readme-ov-file to avoid manual date shenanigans. --- payjoin-mailroom/config.example.toml | 4 +-- payjoin-mailroom/src/access_control.rs | 47 ++------------------------ 2 files changed, 4 insertions(+), 47 deletions(-) diff --git a/payjoin-mailroom/config.example.toml b/payjoin-mailroom/config.example.toml index e3e954afa..1b256115e 100644 --- a/payjoin-mailroom/config.example.toml +++ b/payjoin-mailroom/config.example.toml @@ -30,8 +30,8 @@ # --- Access-control (requires `access-control` feature) --- # [access_control] -# Optional path to a MaxMind GeoIP2 / DB-IP Country database. -# If omitted but blocked_regions is non-empty, a free DB-IP Lite +# Optional path to a MaxMind GeoIP2 / GeoLite2 Country database. +# If omitted but blocked_regions is non-empty, the free GeoLite2-Country # database will be fetched automatically and cached under storage_dir. # geo_db_path = "/path/to/GeoIP2-Country.mmdb" diff --git a/payjoin-mailroom/src/access_control.rs b/payjoin-mailroom/src/access_control.rs index 64bb9639d..f8bc39594 100644 --- a/payjoin-mailroom/src/access_control.rs +++ b/payjoin-mailroom/src/access_control.rs @@ -124,12 +124,10 @@ pub fn spawn_address_list_updater( async fn fetch_geoip_db(dest: &Path) -> anyhow::Result<()> { use std::io::Read; - let now = chrono_month_year(); - let url = - format!("https://download.db-ip.com/free/dbip-country-lite-{}-{}.mmdb.gz", now.0, now.1); + let url = "https://cdn.jsdelivr.net/npm/geolite2-country/GeoLite2-Country.mmdb.gz"; tracing::info!("Fetching GeoIP database from {}", url); - let response = reqwest::get(&url).await?; + let response = reqwest::get(url).await?; if !response.status().is_success() { anyhow::bail!("Failed to fetch GeoIP database: HTTP {}", response.status()); } @@ -146,31 +144,6 @@ async fn fetch_geoip_db(dest: &Path) -> anyhow::Result<()> { Ok(()) } -/// Returns (year, month) as strings for the DB-IP download URL. -fn chrono_month_year() -> (String, String) { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system time should be after UNIX_EPOCH"); - let days_since_epoch = (now.as_secs() / 86_400) as i64; - let (year, month) = year_month_from_days_since_epoch(days_since_epoch); - (year.to_string(), format!("{month:02}")) -} - -fn year_month_from_days_since_epoch(days_since_epoch: i64) -> (i32, u32) { - // Exact conversion from Unix days to Gregorian year/month in UTC. - // Based on Howard Hinnant's civil calendar algorithm. - let z = days_since_epoch + 719_468; - let era = if z >= 0 { z } else { z - 146_096 } / 146_097; - let doe = z - era * 146_097; - let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; - let y = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let month = (mp + if mp < 10 { 3 } else { -9 }) as u32; - let year = (y + if month <= 2 { 1 } else { 0 }) as i32; - (year, month) -} - #[cfg(test)] mod tests { use super::*; @@ -260,20 +233,4 @@ mod tests { assert!(ac.check_ip("192.0.2.1".parse().unwrap())); assert!(ac.check_ip("2001:db8::1".parse().unwrap())); } - - #[test] - fn year_month_conversion_handles_leap_day() { - // 2024-02-29 00:00:00 UTC - let days = 19_782; - let (year, month) = year_month_from_days_since_epoch(days); - assert_eq!((year, month), (2024, 2)); - } - - #[test] - fn year_month_conversion_handles_year_start() { - // 2024-01-01 00:00:00 UTC - let days = 19_723; - let (year, month) = year_month_from_days_since_epoch(days); - assert_eq!((year, month), (2024, 1)); - } } From d2195a0d14789cd10bc7f4e051b2dc77abd74aaf Mon Sep 17 00:00:00 2001 From: spacebear Date: Thu, 19 Feb 2026 19:38:46 -0500 Subject: [PATCH 7/8] Update mailroom README & config example --- payjoin-mailroom/README.md | 28 +++++++++++----------- payjoin-mailroom/config.example.toml | 35 +++++++++++++++++++--------- 2 files changed, 38 insertions(+), 25 deletions(-) diff --git a/payjoin-mailroom/README.md b/payjoin-mailroom/README.md index 11915626a..19fc60b7a 100644 --- a/payjoin-mailroom/README.md +++ b/payjoin-mailroom/README.md @@ -34,21 +34,21 @@ nix run .#payjoin-mailroom -- --config payjoin-mailroom/config.toml ## Telemetry -payjoin-mailroom supports **optional** OpenTelemetry-based telemetry (metrics, traces, and logs). Build with `--features telemetry` and add a `[telemetry]` section to your config: +payjoin-mailroom supports **optional** OpenTelemetry-based telemetry (metrics). +Build with `--features telemetry` and configure via the [`[telemetry]`](config.example.com) config section. +When no telemetry configuration is present, it falls back to local-only console tracing. -```toml -[telemetry] -endpoint = "https://otlp-gateway-prod-us-west-0.grafana.net/otlp" -auth_token = "" -operator_domain = "your-domain.example.com" -``` +## Access Control -Or set the equivalent environment variables: +Build with `--features access-control` to enable: -```sh -export PJ_TELEMETRY__ENDPOINT="https://otlp-gateway-prod-us-west-0.grafana.net/otlp" -export PJ_TELEMETRY__AUTH_TOKEN="" -export PJ_TELEMETRY__OPERATOR_DOMAIN="your-domain.example.com" -``` +### IP Screening + +Configured via the [`[access_control]`](config.example.toml) config section for IP- and region-based filtering. + +The auto-fetched GeoLite2 database is provided by [MaxMind](https://www.maxmind.com) and distributed under the [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) license. + +### V1 Address Screening -When no `[telemetry]` section is present and no `PJ_TELEMETRY__*` variables are set, it falls back to local-only console tracing. +When the V1 protocol is enabled, payjoin-mailroom can screen PSBTs for blocked Bitcoin addresses. +Configure a local blocklist, a remote URL, or both via the [`[v1]`](config.example.toml) config section. diff --git a/payjoin-mailroom/config.example.toml b/payjoin-mailroom/config.example.toml index 1b256115e..6a9e995fe 100644 --- a/payjoin-mailroom/config.example.toml +++ b/payjoin-mailroom/config.example.toml @@ -2,7 +2,7 @@ # # Configuration can also be set via environment variables with the `PJ_` # prefix. Nested values use double underscores as separators, e.g. -# PJ_ACCESS_CONTROL__BLOCKED_REGIONS=CU,IR,KP,SY +# PJ_TELEMETRY__OPERATOR_DOMAIN="your-domain.example.com" # Address and port to listen on # listener = "[::]:8080" @@ -13,19 +13,18 @@ # Request timeout in seconds # timeout = 30 -# --- V1 protocol --- -# Uncomment the [v1] section to enable V1 fallback support. -# (address screening requires `access-control` feature) -# [v1] +# --- Telemetry (requires `--telemetry` feature) --- +# [telemetry] -# Path to a local file containing blocked Bitcoin addresses (one per line). -# blocked_addresses_path = "/path/to/blocked_addresses.txt" +# OpenTelemetry Protocol (OTLP) endpoint to export telemetry to +# endpoint = "https://otlp-gateway-prod-us-west-0.grafana.net/otlp" -# URL to periodically fetch an updated blocked-address list from. -# blocked_addresses_url = "https://example.com/blocked_addresses.txt" +# Authentication token for the OTLP endpoint +# auth_token = "" -# How often (in seconds) to refresh the remote address list (default: 86400). -# blocked_addresses_refresh_secs = 86400 +# The domain you are running the payjoin-mailroom from. +# This serves as an identifier for metrics collection. +# operator_domain = "your-domain.example.com" # --- Access-control (requires `access-control` feature) --- # [access_control] @@ -40,3 +39,17 @@ # IP addresses or CIDR ranges whose requests should be blocked. # blocked_ips = ["192.0.2.0/24", "2001:db8::1"] + +# --- V1 protocol --- +# Uncomment the [v1] section to enable V1 fallback support. +# (address screening requires `access-control` feature) +# [v1] + +# Path to a local file containing blocked Bitcoin addresses (one per line). +# blocked_addresses_path = "/path/to/blocked_addresses.txt" + +# URL to periodically fetch an updated blocked-address list from. +# blocked_addresses_url = "https://example.com/blocked_addresses.txt" + +# How often (in seconds) to refresh the remote address list (default: 86400). +# blocked_addresses_refresh_secs = 86400 From a76176802d5da536feba7cce4190620b4c23b58c Mon Sep 17 00:00:00 2001 From: DanGould Date: Fri, 20 Feb 2026 14:53:45 +0800 Subject: [PATCH 8/8] Return BIP78 error for blocked V1 PSBTs Use the well-known original-psbt-rejected error code instead of 403 Forbidden so V1 senders get a standard BIP78 response that does not reveal screening details. --- payjoin-directory/src/lib.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/payjoin-directory/src/lib.rs b/payjoin-directory/src/lib.rs index 167cd4e81..1058e0cab 100644 --- a/payjoin-directory/src/lib.rs +++ b/payjoin-directory/src/lib.rs @@ -438,7 +438,7 @@ impl Service { if !scripts.is_empty() { match screen_v1_addresses(body_str, &scripts) { ScreenResult::Blocked => { - return Err(HandlerError::Forbidden(anyhow::anyhow!( + return Err(HandlerError::V1PsbtRejected(anyhow::anyhow!( "blocked address in V1 PSBT" ))); } @@ -651,6 +651,8 @@ enum HandlerError { SenderGone(anyhow::Error), OhttpKeyRejection(anyhow::Error), BadRequest(anyhow::Error), + /// V1 PSBT rejected — returns the BIP78 `original-psbt-rejected` error. + V1PsbtRejected(anyhow::Error), Forbidden(anyhow::Error), } @@ -684,6 +686,11 @@ impl HandlerError { warn!("Bad request: {}", e); *res.status_mut() = StatusCode::BAD_REQUEST } + HandlerError::V1PsbtRejected(e) => { + warn!("PSBT rejected: {}", e); + *res.status_mut() = StatusCode::BAD_REQUEST; + *res.body_mut() = full(V1_REJECT_RES_JSON); + } HandlerError::Forbidden(e) => { warn!("Forbidden: {}", e); *res.status_mut() = StatusCode::FORBIDDEN @@ -883,7 +890,7 @@ mod tests { } #[tokio::test] - async fn post_v1_with_blocked_address_returns_forbidden() { + async fn post_v1_with_blocked_address_returns_bad_request() { let blocked_addr = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"; let blocked = BlockedAddresses::from_address_lines(blocked_addr); let mut svc = test_service(Some(V1::new(Some(blocked)))).await; @@ -896,9 +903,10 @@ mod tests { .unwrap(); let res = tower::Service::call(&mut svc, req).await.unwrap(); - let (status, _body) = collect_body(res).await; + let (status, body) = collect_body(res).await; - assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body, V1_REJECT_RES_JSON); } #[test]