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..4e73e69 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"; @@ -331,8 +333,14 @@ async fn main_result(cfg: &MyConfig, network_params: &NetworkParams) -> Result<( stmt.bind((2, Value::String(txid.clone()))).unwrap(); let _ = stmt.next(); } - let _ = send_stats_report(cfg, bcinfo).await; - let _ = calculate_stats(&db, network_params.db_field.clone()).await; + if let Err(e) = send_stats_report(cfg, bcinfo).await { + // Never discard silently: a failing report is otherwise + // invisible in the logs and can go unnoticed for a long time. + warn!("send_stats_report failed: {e}"); + } + if let Err(e) = calculate_stats(&db, network_params.db_field.clone()).await { + warn!("calculate_stats failed: {e}"); + } } Err(erx) => { error!("impossible to get client: {}, retrying on next block", erx); @@ -422,6 +430,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 +515,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 +802,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); + } +}