feat(pusher): add opt-in v2 WeTime ping signing

- Add WELIST_PING_SIG_VERSION (1 legacy, 2 canonical JSON); default 1
- Sign WELIST-PING-V2\n + compact sorted JSON covering url, chain, height,
  median_time, last_block_hash, info, base_fee, version and sig_version
- Fetch info/base_fee/version from the local /{chain}/info endpoint and sign them
- Fall back to null metadata on fetch failure; skip the fetch for onion URLs
- Normalize the advertised URL and bound metadata to match the WeTime service
- Activate v2 in bal-pusher.sh and document the TXT record setup
- Add unit tests for canonical payloads, URL normalization, metadata and onion detection

Includes previously uncommitted local deployment hardening:
- pg-server.sh configurable loopback bind address, volume and recreate handling
- .env loading in bal-server.sh and bal-pusher.sh
- second bal-server/bal-pusher development scripts
This commit is contained in:
2026-11-07 20:37:11 -04:00
parent b8d87ecabd
commit 0eff7a649a
12 changed files with 614 additions and 32 deletions

View File

@@ -10,7 +10,7 @@ use log::{debug, error, info, trace, warn};
use serde::Deserialize;
use serde::Serialize;
use serde_json::json;
use std::collections::HashMap;
use std::collections::{BTreeMap, HashMap};
use std::env;
use std::error::Error as StdError;
use std::str;
@@ -27,6 +27,19 @@ use url::Url;
// BIP-65: locktime values below this are block heights, at or above are UNIX timestamps.
const LOCKTIME_THRESHOLD: i64 = 500_000_000;
const VERSION: &str = env!("CARGO_PKG_VERSION");
/// Legacy ping signature: concatenation of URL, chain, height, median time and
/// block hash, verified against `/.pub_key.pem`.
const PING_SIG_VERSION_LEGACY: u8 = 1;
/// Canonical JSON ping signature (`WELIST-PING-V2\n` + sorted compact JSON).
const PING_SIG_VERSION_CANONICAL: u8 = 2;
/// Domain-separation prefix signed before a v2 canonical JSON payload.
const PING_SIGNATURE_PREFIX: &[u8] = b"WELIST-PING-V2\n";
/// Maximum accepted `info` length, matching the WeTime `/ping` validation.
const MAX_INFO_BYTES: usize = 4096;
/// Maximum accepted `version` length, matching the WeTime `/ping` validation.
const MAX_VERSION_BYTES: usize = 64;
#[derive(Debug, Clone, Serialize, Deserialize)]
struct MyConfig {
db_backend: String,
@@ -41,6 +54,9 @@ struct MyConfig {
send_stats: bool,
url: String,
ssl_key_path: String,
/// WeTime ping signature version. Defaults to the legacy scheme so an
/// unmodified deployment keeps working.
ping_sig_version: u8,
}
impl Default for MyConfig {
@@ -61,10 +77,42 @@ impl Default for MyConfig {
.unwrap_or(false),
url: env::var("BAL_SERVER_URL").unwrap_or("http://127.0.0.1:9133".to_string()),
ssl_key_path: env::var("SSL_KEY_PATH").unwrap_or("privkey.pem".to_string()),
ping_sig_version: parse_ping_sig_version(),
}
}
}
/// Parse `WELIST_PING_SIG_VERSION`, falling back to the legacy scheme.
///
/// Unsupported or malformed values are reported and treated as `1` so a typo
/// cannot silently break stats reporting.
fn parse_ping_sig_version() -> u8 {
match env::var("WELIST_PING_SIG_VERSION") {
Ok(value) => match value.trim().parse::<u8>() {
Ok(version)
if version == PING_SIG_VERSION_LEGACY || version == PING_SIG_VERSION_CANONICAL =>
{
version
}
Ok(version) => {
warn!(
"Unsupported WELIST_PING_SIG_VERSION={}, using legacy scheme",
version
);
PING_SIG_VERSION_LEGACY
}
Err(err) => {
warn!(
"Invalid WELIST_PING_SIG_VERSION={:?} ({}), using legacy scheme",
value, err
);
PING_SIG_VERSION_LEGACY
}
},
Err(_) => PING_SIG_VERSION_LEGACY,
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct NetworkParams {
host: String,
@@ -360,6 +408,141 @@ fn new_welist_client() -> rClient {
.unwrap_or_else(|_| rClient::new())
}
/// Metadata advertised by the local bal-server `/{chain}/info` endpoint.
///
/// These values are included in a v2 ping so the executor can publish signed
/// `info`, `base_fee` and `version` values to the WeTime service.
#[derive(Debug, Clone, Default, Deserialize)]
struct ExecutorMetadata {
#[serde(default)]
info: Option<String>,
#[serde(default)]
base_fee: Option<i64>,
#[serde(default)]
version: Option<String>,
}
/// Normalize an executor URL exactly like the WeTime `/ping` handler.
fn normalize_executor_url(url: &str) -> String {
url.trim().trim_end_matches('/').to_string()
}
/// Drop metadata that the WeTime service would reject.
fn sanitize_metadata(metadata: ExecutorMetadata) -> ExecutorMetadata {
ExecutorMetadata {
info: metadata
.info
.filter(|value| !value.is_empty() && value.len() <= MAX_INFO_BYTES),
base_fee: metadata.base_fee.filter(|value| *value >= 0),
version: metadata
.version
.filter(|value| !value.is_empty() && value.len() <= MAX_VERSION_BYTES),
}
}
/// Return `true` when the URL host is a v3 `.onion` address.
///
/// The pusher cannot reach its own onion address without a Tor proxy, so v2
/// pings for onion URLs are signed with `null` metadata instead.
fn is_onion_url(url: &str) -> bool {
Url::parse(url)
.ok()
.and_then(|parsed| {
parsed
.host_str()
.map(|host| host.trim_end_matches('.').to_ascii_lowercase())
})
.is_some_and(|host| host.ends_with(".onion"))
}
/// Fetch the signed metadata published by the local bal-server.
///
/// Failure is not fatal: a v2 ping is still valid with `null` metadata, it just
/// cannot refresh those columns on the WeTime service.
async fn fetch_executor_metadata(cfg: &MyConfig, chain: &str) -> ExecutorMetadata {
let base_url = normalize_executor_url(&cfg.url);
if is_onion_url(&base_url) {
debug!("skipping executor metadata fetch for onion URL");
return ExecutorMetadata::default();
}
let endpoint = format!("{}/{}/info", base_url, chain);
let client = new_welist_client();
match client.get(&endpoint).send().await {
Ok(response) if response.status().is_success() => {
match response.json::<ExecutorMetadata>().await {
Ok(metadata) => {
debug!("executor metadata: {:?}", metadata);
sanitize_metadata(metadata)
}
Err(err) => {
warn!("cannot parse executor metadata from {}: {}", endpoint, err);
ExecutorMetadata::default()
}
}
}
Ok(response) => {
warn!(
"executor metadata endpoint {} returned {}",
endpoint,
response.status()
);
ExecutorMetadata::default()
}
Err(err) => {
warn!("cannot fetch executor metadata from {}: {}", endpoint, err);
ExecutorMetadata::default()
}
}
}
/// Fields covered by a v2 WeTime ping signature.
#[derive(Clone, Copy, Debug)]
struct PingPayload<'a> {
/// Normalized executor URL.
url: &'a str,
/// Canonical lowercase chain name.
chain: &'a str,
/// Self-reported block height.
height: u64,
/// Self-reported median time.
median_time: u64,
/// Self-reported block hash.
last_block_hash: &'a str,
/// Optional executor description.
info: Option<&'a str>,
/// Optional executor base fee.
base_fee: Option<i64>,
/// Optional executor version.
version: Option<&'a str>,
}
impl PingPayload<'_> {
/// Build the exact bytes covered by a v2 ping signature.
///
/// The output is `WELIST-PING-V2\n` followed by compact JSON with sorted
/// keys, every field present, and `null` for absent optional values. This
/// must match the canonicalization performed by the WeTime `/ping` handler
/// byte for byte.
fn canonical_bytes(&self) -> Result<Vec<u8>, serde_json::Error> {
let mut fields: BTreeMap<&str, serde_json::Value> = BTreeMap::new();
fields.insert("base_fee", json!(self.base_fee));
fields.insert("chain", json!(self.chain));
fields.insert("height", json!(self.height));
fields.insert("info", json!(self.info));
fields.insert("last_block_hash", json!(self.last_block_hash));
fields.insert("median_time", json!(self.median_time));
fields.insert("sig_version", json!(PING_SIG_VERSION_CANONICAL));
fields.insert("url", json!(self.url));
fields.insert("version", json!(self.version));
let body = serde_json::to_vec(&fields)?;
let mut message = Vec::with_capacity(PING_SIGNATURE_PREFIX.len() + body.len());
message.extend_from_slice(PING_SIGNATURE_PREFIX);
message.extend_from_slice(&body);
Ok(message)
}
}
async fn send_stats_report(
cfg: &MyConfig,
bcinfo: GetBlockchainInfoResult,
@@ -383,32 +566,80 @@ async fn send_stats_report(
let url = format!("{}/ping", welist_url);
debug!("welist url: {}", url);
let chain = bcinfo.chain.to_string().to_lowercase();
let message = format!(
"{0}{1}{2}{3}{4}",
cfg.url, chain, bcinfo.blocks, bcinfo.median_time, bcinfo.best_block_hash
);
trace!("message to be sent: {}", message);
let sign = match sign_message(cfg.ssl_key_path.as_str(), message.as_str()) {
Ok(signature) => signature,
Err(e) => {
error!("Cannot sign stats report, skipping: {}", e);
return Ok(());
}
};
let response = client
let last_block_hash = bcinfo.best_block_hash.to_string();
let request = client
.post(url)
.header("User-Agent", format!("bal-pusher/{}", VERSION))
.json(&json!(
.header("User-Agent", format!("bal-pusher/{}", VERSION));
let request = if cfg.ping_sig_version == PING_SIG_VERSION_CANONICAL {
let executor_url = normalize_executor_url(&cfg.url);
let metadata = fetch_executor_metadata(cfg, &chain).await;
let payload = PingPayload {
url: &executor_url,
chain: &chain,
height: bcinfo.blocks,
median_time: bcinfo.median_time,
last_block_hash: &last_block_hash,
info: metadata.info.as_deref(),
base_fee: metadata.base_fee,
version: metadata.version.as_deref(),
};
let message = match payload.canonical_bytes() {
Ok(message) => message,
Err(err) => {
error!("Cannot build canonical ping payload, skipping: {}", err);
return Ok(());
}
};
trace!(
"canonical message to be sent: {}",
String::from_utf8_lossy(&message)
);
let sign = match sign_bytes(cfg.ssl_key_path.as_str(), &message) {
Ok(signature) => signature,
Err(e) => {
error!("Cannot sign stats report, skipping: {}", e);
return Ok(());
}
};
request.json(&json!({
"url": executor_url,
"chain": chain,
"height": bcinfo.blocks,
"median_time": bcinfo.median_time,
"last_block_hash": last_block_hash,
"signature": sign,
"sig_version": PING_SIG_VERSION_CANONICAL,
"info": metadata.info,
"base_fee": metadata.base_fee,
"version": metadata.version,
}))
} else {
let message = format!(
"{0}{1}{2}{3}{4}",
cfg.url, chain, bcinfo.blocks, bcinfo.median_time, last_block_hash
);
trace!("message to be sent: {}", message);
let sign = match sign_message(cfg.ssl_key_path.as_str(), message.as_str()) {
Ok(signature) => signature,
Err(e) => {
error!("Cannot sign stats report, skipping: {}", e);
return Ok(());
}
};
request.json(&json!(
{
"url": cfg.url,
"chain": chain,
"height": bcinfo.blocks,
"median_time": bcinfo.median_time,
"last_block_hash": bcinfo.best_block_hash,
"last_block_hash": last_block_hash,
"signature": sign,
}))
.send()
.await?;
};
let response = request.send().await?;
let status = response.status();
let body = response.text().await?;
info!(
@@ -420,14 +651,19 @@ async fn send_stats_report(
}
Ok(())
}
fn sign_message(private_key_path: &str, message: &str) -> Result<String, Box<dyn StdError>> {
fn sign_bytes(private_key_path: &str, message: &[u8]) -> Result<String, Box<dyn StdError>> {
let signing_key = SigningKey::read_pkcs8_pem_file(private_key_path)
.map_err(|e| format!("failed to parse private key PEM at {private_key_path}: {e}"))?;
let signature = signing_key.sign(message.as_bytes());
let signature = signing_key.sign(message);
Ok(general_purpose::STANDARD.encode(signature.to_bytes()))
}
fn sign_message(private_key_path: &str, message: &str) -> Result<String, Box<dyn StdError>> {
sign_bytes(private_key_path, message.as_bytes())
}
fn parse_env(cfg: &mut MyConfig) {
if let Ok(value) = env::var("BAL_SERVER_DB_BACKEND") {
cfg.db_backend = value;
@@ -624,4 +860,84 @@ mod tests {
fn parse_host_port_invalid_url() {
assert_eq!(parse_host_port("not a url"), None);
}
#[test]
fn normalize_executor_url_trims_and_strips_trailing_slashes() {
assert_eq!(
normalize_executor_url(" https://we.example.com/ "),
"https://we.example.com"
);
assert_eq!(
normalize_executor_url("https://we.example.com"),
"https://we.example.com"
);
}
#[test]
fn onion_urls_skip_metadata_fetch() {
assert!(is_onion_url(
"http://abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuv.onion"
));
assert!(!is_onion_url("https://we.example.com"));
assert!(!is_onion_url("not a url"));
}
#[test]
fn canonical_ping_payload_is_sorted_compact_and_complete() {
let payload = PingPayload {
url: "https://example.com",
chain: "bitcoin",
height: 100,
median_time: 1000,
last_block_hash: "abc",
info: None,
base_fee: None,
version: None,
};
let payload = payload.canonical_bytes().unwrap();
assert_eq!(
String::from_utf8(payload).unwrap(),
"WELIST-PING-V2\n{\"base_fee\":null,\"chain\":\"bitcoin\",\"height\":100,\"info\":null,\"last_block_hash\":\"abc\",\"median_time\":1000,\"sig_version\":2,\"url\":\"https://example.com\",\"version\":null}"
);
}
#[test]
fn canonical_ping_payload_includes_metadata_and_escapes_strings() {
let payload = PingPayload {
url: "https://example.com",
chain: "bitcoin",
height: 1,
median_time: 2,
last_block_hash: "abc",
info: Some("line\n\"quoted\""),
base_fee: Some(1500),
version: Some("0.3.0"),
};
let payload = payload.canonical_bytes().unwrap();
let message = String::from_utf8(payload).unwrap();
assert!(message.contains("\"base_fee\":1500"));
assert!(message.contains("\"info\":\"line\\n\\\"quoted\\\"\""));
assert!(message.contains("\"version\":\"0.3.0\""));
}
#[test]
fn sanitize_metadata_drops_values_the_service_rejects() {
let metadata = sanitize_metadata(ExecutorMetadata {
info: Some("x".repeat(MAX_INFO_BYTES + 1)),
base_fee: Some(-1),
version: Some(String::new()),
});
assert_eq!(metadata.info, None);
assert_eq!(metadata.base_fee, None);
assert_eq!(metadata.version, None);
let metadata = sanitize_metadata(ExecutorMetadata {
info: Some("ok".to_string()),
base_fee: Some(0),
version: Some(VERSION.to_string()),
});
assert_eq!(metadata.info.as_deref(), Some("ok"));
assert_eq!(metadata.base_fee, Some(0));
assert_eq!(metadata.version.as_deref(), Some(VERSION));
}
}