#![forbid(unsafe_code)]
use std::time::{Duration, Instant};
use http::Uri;
use zingo_net_diag::{NetOpFailure, NetOpStage};
use zingo_netutils::{GetClientError, GrpcIndexer, Indexer as _};
use crate::lightclient::indexer_history::{
AttemptKind, AttemptRoute, FailureKind, IndexerAttempt, IndexerHistoryHandle, now_unix_secs,
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ProbeSuccess {
pub chain: String,
pub height: u64,
}
impl ProbeSuccess {
fn of(info: &zingo_netutils::lightwallet_protocol::LightdInfo) -> Self {
ProbeSuccess {
chain: info.chain_name.clone(),
height: info.block_height,
}
}
}
#[derive(Clone, Debug)]
pub struct ProbeLeg {
pub outcome: Result<ProbeSuccess, NetOpFailure>,
pub millis: u64,
}
#[derive(Clone, Debug)]
pub struct MixnetProbe {
pub host: crate::destination::Host,
pub leg: ProbeLeg,
}
fn get_client_stage(error: &GetClientError) -> NetOpStage {
match error {
GetClientError::InvalidScheme | GetClientError::InvalidAuthority => {
NetOpStage::RouteResolution
}
GetClientError::Transport(_) => NetOpStage::RemoteConnect,
}
}
async fn mixnet_leg(
socks5_addr: std::net::SocketAddr,
indexer: &Uri,
timeout: Duration,
history: &IndexerHistoryHandle,
host: &crate::destination::Host,
) -> ProbeLeg {
let started = Instant::now();
let outcome = zingo_netutils::Socks5Indexer::new(socks5_addr, indexer.clone(), timeout)
.get_lightd_info()
.await
.map(|info| ProbeSuccess::of(&info))
.map_err(|e| super::socks5_transmit_failure(&e, host));
let leg = ProbeLeg {
millis: started.elapsed().as_millis().try_into().unwrap_or(u64::MAX),
outcome,
};
record_probe(history, host, AttemptRoute::Mixnet, &leg);
leg
}
fn record_probe(
history: &IndexerHistoryHandle,
host: &crate::destination::Host,
route: AttemptRoute,
leg: &ProbeLeg,
) {
history.record(&IndexerAttempt {
unix_secs: now_unix_secs(),
host: host.clone(),
route,
kind: AttemptKind::Probe,
millis: leg.millis,
fault_domain: leg
.outcome
.as_ref()
.err()
.map(|failure| crate::mixnet::fault_domain(&failure.stage)),
outcome: match &leg.outcome {
Ok(_) => Ok(()),
Err(failure) => Err(FailureKind::classify(&failure.to_string())),
},
});
}
pub fn probe_eligible(indexer: &Uri) -> bool {
indexer.scheme_str() == Some("https") && indexer.port_u16().unwrap_or(443) == 443
}
pub(crate) async fn probe_indexer(
indexer: &Uri,
socks5_addr: std::net::SocketAddr,
timeout: Duration,
history: &IndexerHistoryHandle,
) -> MixnetProbe {
let host = crate::destination::Host::of_uri(indexer);
let leg = mixnet_leg(socks5_addr, indexer, timeout, history, &host).await;
MixnetProbe { host, leg }
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SyncProbeStep {
TcpConnect,
TlsChannel,
GrpcInfo,
}
impl std::fmt::Display for SyncProbeStep {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SyncProbeStep::TcpConnect => write!(f, "tcp-connect"),
SyncProbeStep::TlsChannel => write!(f, "tls-channel"),
SyncProbeStep::GrpcInfo => write!(f, "grpc-info"),
}
}
}
#[derive(Clone, Debug)]
pub struct SyncProbeStage {
pub step: SyncProbeStep,
pub millis: u64,
pub failure: Option<NetOpFailure>,
}
#[derive(Clone, Debug)]
pub struct SyncServerProbe {
pub server: String,
pub stages: Vec<SyncProbeStage>,
pub info: Option<ProbeSuccess>,
}
fn probe_port(server: &Uri) -> u16 {
server.port_u16().unwrap_or_else(|| {
if server.scheme_str() == Some("http") {
80
} else {
443
}
})
}
pub async fn probe_sync_server(server: &Uri, stage_timeout: Duration) -> SyncServerProbe {
let host = server
.host()
.map_or_else(|| server.to_string(), str::to_string);
let target = format!("{host}:{}", probe_port(server));
let mut stages = Vec::new();
let timed_out = || {
NetOpFailure::message(
NetOpStage::TimedOut {
after_ms: stage_timeout.as_millis().try_into().unwrap_or(u64::MAX),
},
&target,
format!("no answer within {stage_timeout:.0?}"),
)
};
let started = Instant::now();
let tcp = tokio::time::timeout(
stage_timeout,
tokio::net::TcpStream::connect((host.as_str(), probe_port(server))),
)
.await;
let failure = match &tcp {
Ok(Ok(_)) => None,
Ok(Err(io)) => Some(NetOpFailure::from_error(
NetOpStage::RemoteConnect,
&target,
io,
)),
Err(_) => Some(timed_out()),
};
let failed = failure.is_some();
stages.push(SyncProbeStage {
step: SyncProbeStep::TcpConnect,
millis: started.elapsed().as_millis().try_into().unwrap_or(u64::MAX),
failure,
});
if failed {
return SyncServerProbe {
server: host,
stages,
info: None,
};
}
let started = Instant::now();
let channel = tokio::time::timeout(stage_timeout, GrpcIndexer::new(server.clone())).await;
let (failure, grpc) = match channel {
Ok(Ok(grpc)) => (None, Some(grpc)),
Ok(Err(e)) => {
let stage = match get_client_stage(&e) {
NetOpStage::RemoteConnect => NetOpStage::RemoteTls,
other => other,
};
(Some(NetOpFailure::from_error(stage, &target, &e)), None)
}
Err(_) => (Some(timed_out()), None),
};
stages.push(SyncProbeStage {
step: SyncProbeStep::TlsChannel,
millis: started.elapsed().as_millis().try_into().unwrap_or(u64::MAX),
failure,
});
let Some(mut grpc) = grpc else {
return SyncServerProbe {
server: host,
stages,
info: None,
};
};
let started = Instant::now();
let rpc = tokio::time::timeout(stage_timeout, grpc.get_lightd_info(stage_timeout)).await;
let (failure, info) = match rpc {
Ok(Ok(info)) => (None, Some(ProbeSuccess::of(&info))),
Ok(Err(status)) => (
Some(NetOpFailure::from_error(
NetOpStage::RemoteHttp,
&target,
&status,
)),
None,
),
Err(_) => (Some(timed_out()), None),
};
stages.push(SyncProbeStage {
step: SyncProbeStep::GrpcInfo,
millis: started.elapsed().as_millis().try_into().unwrap_or(u64::MAX),
failure,
});
SyncServerProbe {
server: host,
stages,
info,
}
}
#[cfg(test)]
mod tests {
use super::*;
use zingo_netutils::time::test::FAST_STAGE_BOUND;
fn uri(text: &str) -> Uri {
text.parse().expect("static uri")
}
#[tokio::test]
async fn a_closed_port_fails_the_tcp_stage_and_stops() {
let closed = {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.local_addr().unwrap()
};
let probe = probe_sync_server(&uri(&format!("https://{closed}")), FAST_STAGE_BOUND).await;
assert_eq!(probe.stages.len(), 1, "the run stops at the first failure");
assert_eq!(probe.stages[0].step, SyncProbeStep::TcpConnect);
let failure = probe.stages[0].failure.as_ref().expect("stage failed");
assert_eq!(failure.stage, NetOpStage::RemoteConnect);
assert!(probe.info.is_none());
}
#[tokio::test]
async fn a_silent_listener_passes_tcp_and_fails_the_channel_stage() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let hold = tokio::spawn(async move {
loop {
let Ok((_sock, _)) = listener.accept().await else {
return;
};
}
});
let probe = probe_sync_server(&uri(&format!("https://{addr}")), FAST_STAGE_BOUND).await;
assert_eq!(probe.stages.len(), 2);
assert!(probe.stages[0].failure.is_none(), "TCP is reachable");
let failure = probe.stages[1].failure.as_ref().expect("channel must fail");
assert!(
matches!(
failure.stage,
NetOpStage::RemoteTls | NetOpStage::TimedOut { .. }
),
"a non-TLS listener fails the channel stage as TLS or a bound: {failure}"
);
assert!(probe.info.is_none());
hold.abort();
}
#[test]
fn the_probe_port_falls_back_to_the_scheme_default() {
assert_eq!(probe_port(&uri("https://zec.rocks")), 443);
assert_eq!(probe_port(&uri("http://localhost")), 80);
assert_eq!(probe_port(&uri("https://zec.rocks:9067")), 9067);
}
#[tokio::test]
#[ignore = "contacts a live public indexer over nakednet"]
async fn live_staged_probe_smoke() {
let probe = probe_sync_server(
&uri("https://zec.rocks:443"),
zingo_netutils::time::test::LIVE_STAGE_BOUND,
)
.await;
assert_eq!(probe.stages.len(), 3, "all stages ran: {probe:?}");
assert!(probe.info.is_some(), "a live indexer answers: {probe:?}");
}
}