pusher: optional IPv6 preference for welist reports + log report failures #1
@@ -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.
|
- `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.
|
- `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).
|
- `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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ use openssl::sign::Signer;
|
|||||||
use openssl::sign::Verifier;
|
use openssl::sign::Verifier;
|
||||||
use reqwest::Client as rClient;
|
use reqwest::Client as rClient;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
use std::net::SocketAddr;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
const LOCKTIME_THRESHOLD: i64 = 5000000;
|
const LOCKTIME_THRESHOLD: i64 = 5000000;
|
||||||
const VERSION: &str = "0.0.2";
|
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();
|
stmt.bind((2, Value::String(txid.clone()))).unwrap();
|
||||||
let _ = stmt.next();
|
let _ = stmt.next();
|
||||||
}
|
}
|
||||||
let _ = send_stats_report(cfg, bcinfo).await;
|
if let Err(e) = send_stats_report(cfg, bcinfo).await {
|
||||||
let _ = calculate_stats(&db, network_params.db_field.clone()).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) => {
|
Err(erx) => {
|
||||||
error!("impossible to get client: {}, retrying on next block", erx);
|
error!("impossible to get client: {}, retrying on next block", erx);
|
||||||
@@ -422,6 +430,76 @@ ON CONFLICT(chain) DO UPDATE SET
|
|||||||
}
|
}
|
||||||
Ok(())
|
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<SocketAddr> {
|
||||||
|
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::<bool>()
|
||||||
|
.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(
|
async fn send_stats_report(
|
||||||
cfg: &MyConfig,
|
cfg: &MyConfig,
|
||||||
bcinfo: GetBlockchainInfoResult,
|
bcinfo: GetBlockchainInfoResult,
|
||||||
@@ -437,7 +515,7 @@ async fn send_stats_report(
|
|||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let client = rClient::new();
|
let client = welist_http_client(&welist_url).await;
|
||||||
let url = format!("{}/ping", welist_url);
|
let url = format!("{}/ping", welist_url);
|
||||||
debug!("welist url: {}", url);
|
debug!("welist url: {}", url);
|
||||||
let chain = bcinfo.chain.to_string().to_lowercase();
|
let chain = bcinfo.chain.to_string().to_lowercase();
|
||||||
@@ -724,3 +802,45 @@ fn seq_to_str(seq: &Vec<u8>) -> String {
|
|||||||
}
|
}
|
||||||
"Unknown".to_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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user