From b46f85f436553bf85c2fe429d079584bb69a9963 Mon Sep 17 00:00:00 2001 From: SAFE21 Date: Sun, 19 Jul 2026 14:09:01 +0200 Subject: [PATCH] feat(pusher): optional IPv6 preference for welist reports (BAL_PUSHER_PREFER_IPV6) The welist host publishes both A and AAAA records. On networks where the IPv4 route is broken (connection stalls after the TCP handshake) while IPv6 works, the default connector may pick the broken family and the report request hangs. When BAL_PUSHER_PREFER_IPV6 is truthy, the pusher now resolves the welist host itself and pins the reqwest client to its first IPv6 address; the original hostname is still used for the Host header and TLS SNI. When the variable is unset (default) or no AAAA record exists, behavior is completely unchanged. Includes unit tests for the URL host/port parsing and documentation in docs/07_deployment_and_ops.md. --- docs/07_deployment_and_ops.md | 1 + src/bin/bal-pusher.rs | 116 +++++++++++++++++++++++++++++++++- 2 files changed, 116 insertions(+), 1 deletion(-) diff --git a/docs/07_deployment_and_ops.md b/docs/07_deployment_and_ops.md index 0da8ac7..4187881 100644 --- a/docs/07_deployment_and_ops.md +++ b/docs/07_deployment_and_ops.md @@ -45,6 +45,7 @@ WELIST_URL=https://welist.example.com/api/stats - `BAL_SSL_KEY_PATH`: The path to the Ed25519 private key (`private_key.pem`) used to sign the statistics payload before sending it to the `welist` server. This is a critical secret. - `SEND_STATS`: A boolean flag to enable the reporting of statistics to the remote `welist` server. - `WELIST_URL`: The URL to which the statistics are sent. If `SEND_STATS` is `true`, this URL must be reachable. If the server is unreachable, the pusher will log an error but might not crash (see `08_security_audit.md` for DoS analysis). +- `BAL_PUSHER_PREFER_IPV6`: Optional boolean flag (default `false`). When set to `true`, the pusher resolves the `welist` host itself and pins the HTTP connection to its first IPv6 (AAAA) address, still using the hostname for the `Host` header and TLS SNI. This works around networks where the IPv4 route to the `welist` host is broken while IPv6 works — the default connector may otherwise pick the unreachable family and the request would stall. Leave unset unless you hit this specific connectivity problem. --- diff --git a/src/bin/bal-pusher.rs b/src/bin/bal-pusher.rs index 98e5154..61456cc 100644 --- a/src/bin/bal-pusher.rs +++ b/src/bin/bal-pusher.rs @@ -29,7 +29,9 @@ use openssl::sign::Signer; use openssl::sign::Verifier; use reqwest::Client as rClient; use std::fs; +use std::net::SocketAddr; use std::time::Instant; +use url::Url; const LOCKTIME_THRESHOLD: i64 = 5000000; const VERSION: &str = "0.0.2"; @@ -422,6 +424,76 @@ ON CONFLICT(chain) DO UPDATE SET } Ok(()) } +/// Parse the `(host, port)` pair from a base URL like `https://host[:port]`. +/// +/// Falls back to the scheme's well-known default port (443 for `https`, +/// 80 for plain `http`), or to 443 when the scheme is unknown. +fn parse_host_port(base_url: &str) -> Option<(String, u16)> { + let url = Url::parse(base_url).ok()?; + let host = url + .host_str()? + .trim_start_matches('[') + .trim_end_matches(']') + .to_string(); + let port = url.port_or_known_default().unwrap_or(443); + Some((host, port)) +} + +/// Resolve `host:port` and return the first IPv6 (AAAA) address, if any. +/// +/// Returns `None` when the host has no IPv6 address. +async fn resolve_first_ipv6(host: &str, port: u16) -> Option { + use std::net::ToSocketAddrs; + let host = host.to_string(); + tokio::task::spawn_blocking(move || { + format!("{}:{}", host, port) + .to_socket_addrs() + .ok() + .and_then(|mut addrs| addrs.find(|a| a.is_ipv6())) + }) + .await + .ok() + .flatten() +} + +/// Build the HTTP client used for welist reports. +/// +/// When `BAL_PUSHER_PREFER_IPV6` is truthy, the welist host is resolved and +/// the client is pinned to its first IPv6 address (the original hostname is +/// still used for the `Host` header and TLS SNI). This works around networks +/// where the IPv4 route to the welist host is broken while IPv6 works: the +/// default connector may otherwise pick the broken family and the request +/// stalls. When the variable is unset (the default), behavior is unchanged. +async fn welist_http_client(welist_url: &str) -> rClient { + let prefer_ipv6 = env::var("BAL_PUSHER_PREFER_IPV6") + .unwrap_or("false".to_string()) + .parse::() + .unwrap_or(false); + if !prefer_ipv6 { + return rClient::new(); + } + let (host, port) = match parse_host_port(welist_url) { + Some(hp) => hp, + None => { + warn!("BAL_PUSHER_PREFER_IPV6: cannot parse '{welist_url}', using default resolver"); + return rClient::new(); + } + }; + match resolve_first_ipv6(&host, port).await { + Some(addr) => { + debug!("BAL_PUSHER_PREFER_IPV6: pinning {host} to {addr}"); + rClient::builder() + .resolve(&host, addr) + .build() + .unwrap_or_else(|_| rClient::new()) + } + None => { + debug!("BAL_PUSHER_PREFER_IPV6: no IPv6 address for {host}, using default resolver"); + rClient::new() + } + } +} + async fn send_stats_report( cfg: &MyConfig, bcinfo: GetBlockchainInfoResult, @@ -437,7 +509,7 @@ async fn send_stats_report( ); return Ok(()); } - let client = rClient::new(); + let client = welist_http_client(&welist_url).await; let url = format!("{}/ping", welist_url); debug!("welist url: {}", url); let chain = bcinfo.chain.to_string().to_lowercase(); @@ -724,3 +796,45 @@ fn seq_to_str(seq: &Vec) -> String { } "Unknown".to_string() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_host_port_https_default_port() { + assert_eq!( + parse_host_port("https://welist.bitcoin-after.life"), + Some(("welist.bitcoin-after.life".to_string(), 443)) + ); + } + + #[test] + fn parse_host_port_explicit_port_and_path() { + assert_eq!( + parse_host_port("https://example.com:8443/ping"), + Some(("example.com".to_string(), 8443)) + ); + } + + #[test] + fn parse_host_port_http_default_port() { + assert_eq!( + parse_host_port("http://example.com"), + Some(("example.com".to_string(), 80)) + ); + } + + #[test] + fn parse_host_port_ipv6_literal_brackets_stripped() { + assert_eq!( + parse_host_port("https://[2a13:2c0::1]:443"), + Some(("2a13:2c0::1".to_string(), 443)) + ); + } + + #[test] + fn parse_host_port_invalid_url() { + assert_eq!(parse_host_port("not a url"), None); + } +}