//! TCP, TLS, WebSocket and SSH identification probes (port of `socketDiagnostics.js`). //! //! The TLS probe is inspection-only: it records the presented chain even when it is invalid, //! but a `valid` result requires both a trusted chain (Mozilla roots via webpki-roots, as the //! Node runtime of 0.1.0 used its bundled Mozilla store) and hostname/IP SAN verification. //! No application data is sent on that socket. use crate::core::diagnostics::common::{error_result, map_limit, now_ms, service_name, DiagError, Options, Target}; use base64::Engine; use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; use rustls::{DigitallySignedStruct, SignatureScheme}; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; use std::net::{IpAddr, SocketAddr}; use std::sync::Arc; use std::time::Duration; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio_util::sync::CancellationToken; pub fn root_store(extra_ca: Option<&[u8]>) -> rustls::RootCertStore { let mut roots = rustls::RootCertStore::empty(); roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); if let Some(pem) = extra_ca { for cert in rustls_pemfile::certs(&mut &pem[..]).flatten() { let _ = roots.add(cert); } } roots } pub fn provider() -> Arc { Arc::new(rustls::crypto::ring::default_provider()) } /// Records the chain and the strict verdict, but lets the handshake finish for inspection. #[derive(Debug)] struct InspectingVerifier { inner: Arc, chain: parking_lot::Mutex>>, verdict: parking_lot::Mutex>>, } impl ServerCertVerifier for InspectingVerifier { fn verify_server_cert(&self, end_entity: &CertificateDer<'_>, intermediates: &[CertificateDer<'_>], server_name: &ServerName<'_>, ocsp: &[u8], now: UnixTime) -> Result { let mut chain = vec![end_entity.to_vec()]; chain.extend(intermediates.iter().map(|c| c.to_vec())); *self.chain.lock() = chain; *self.verdict.lock() = Some(self.inner.verify_server_cert(end_entity, intermediates, server_name, ocsp, now).map(|_| ())); Ok(ServerCertVerified::assertion()) } fn verify_tls12_signature(&self, message: &[u8], cert: &CertificateDer<'_>, dss: &DigitallySignedStruct) -> Result { self.inner.verify_tls12_signature(message, cert, dss) } fn verify_tls13_signature(&self, message: &[u8], cert: &CertificateDer<'_>, dss: &DigitallySignedStruct) -> Result { self.inner.verify_tls13_signature(message, cert, dss) } fn supported_verify_schemes(&self) -> Vec { self.inner.supported_verify_schemes() } } fn node_error_code(error: &rustls::Error) -> String { use rustls::CertificateError as C; match error { rustls::Error::InvalidCertificate(certificate) => match certificate { C::Expired | C::ExpiredContext { .. } => "CERT_HAS_EXPIRED", C::NotValidYet | C::NotValidYetContext { .. } => "CERT_NOT_YET_VALID", C::UnknownIssuer => "UNABLE_TO_VERIFY_LEAF_SIGNATURE", C::NotValidForName | C::NotValidForNameContext { .. } => "ERR_TLS_CERT_ALTNAME_INVALID", C::BadSignature => "CERT_SIGNATURE_FAILURE", C::Revoked => "CERT_REVOKED", _ => "UNTRUSTED_CHAIN", } .to_string(), _ => "UNTRUSTED_CHAIN".to_string(), } } fn openssl_name(oid: &str) -> String { match oid { "1.2.840.113549.1.1.11" => "sha256WithRSAEncryption", "1.2.840.113549.1.1.12" => "sha384WithRSAEncryption", "1.2.840.113549.1.1.13" => "sha512WithRSAEncryption", "1.2.840.113549.1.1.5" => "sha1WithRSAEncryption", "1.2.840.113549.1.1.10" => "rsassaPss", "1.2.840.10045.4.3.2" => "ecdsa-with-SHA256", "1.2.840.10045.4.3.3" => "ecdsa-with-SHA384", "1.2.840.10045.4.3.4" => "ecdsa-with-SHA512", "1.3.101.112" => "ED25519", "1.3.101.113" => "ED448", other => other, } .to_string() } fn node_name(name: &x509_parser::x509::X509Name<'_>) -> String { let mut parts = Vec::new(); for rdn in name.iter() { for attribute in rdn.iter() { let key = x509_parser::objects::oid2abbrev(attribute.attr_type(), x509_parser::objects::oid_registry()).map(str::to_string).unwrap_or_else(|_| attribute.attr_type().to_id_string()); let value = attribute.as_str().map(str::to_string).unwrap_or_else(|_| hex::encode(attribute.as_slice())); parts.push(format!("{key}={value}")); } } parts.join("\n") } fn node_date(time: x509_parser::time::ASN1Time) -> String { let date = chrono::DateTime::from_timestamp(time.timestamp(), 0).unwrap_or_default(); date.format("%b %e %H:%M:%S %Y GMT").to_string() } pub fn certificate_info(der: &[u8], host: &str) -> Value { use x509_parser::prelude::*; let fingerprint = Sha256::digest(der).iter().map(|b| format!("{b:02X}")).collect::>().join(":"); let Ok((_, cert)) = X509Certificate::from_der(der) else { return json!({ "fingerprint256": fingerprint, "error": "CERTIFICATE_UNPARSEABLE" }); }; let mut san: Vec = Vec::new(); let mut dns_names: Vec = Vec::new(); let mut ip_names: Vec = Vec::new(); if let Ok(Some(extension)) = cert.subject_alternative_name() { for name in &extension.value.general_names { match name { GeneralName::DNSName(dns) => { san.push(format!("DNS:{dns}")); dns_names.push(dns.to_lowercase()); } GeneralName::IPAddress(bytes) => { let ip = match bytes.len() { 4 => Some(IpAddr::from(<[u8; 4]>::try_from(*bytes).unwrap())), 16 => Some(IpAddr::from(<[u8; 16]>::try_from(*bytes).unwrap())), _ => None, }; if let Some(ip) = ip { san.push(format!("IP Address:{}", if ip.is_ipv6() { ip.to_string().to_uppercase() } else { ip.to_string() })); ip_names.push(ip); } } GeneralName::RFC822Name(email) => san.push(format!("email:{email}")), GeneralName::URI(uri) => san.push(format!("URI:{uri}")), _ => {} } } } let host_matches = match host.parse::() { Ok(ip) => ip_names.contains(&ip), Err(_) => dns_names.iter().any(|pattern| { let host = host.to_lowercase(); if let Some(suffix) = pattern.strip_prefix("*.") { host.split_once('.').map(|(label, rest)| !label.is_empty() && rest == suffix).unwrap_or(false) } else { *pattern == host } }), }; let (key_type, bits, curve) = match cert.public_key().parsed() { Ok(x509_parser::public_key::PublicKey::RSA(rsa)) => ("rsa", json!(rsa.key_size()), Value::Null), Ok(x509_parser::public_key::PublicKey::EC(point)) => { let curve_oid = cert.public_key().algorithm.parameters.as_ref().and_then(|p| p.as_oid().ok()).map(|o| o.to_id_string()).unwrap_or_default(); let (name, bits) = match curve_oid.as_str() { "1.2.840.10045.3.1.7" => ("prime256v1", 256), "1.3.132.0.34" => ("secp384r1", 384), "1.3.132.0.35" => ("secp521r1", 521), _ => ("", point.key_size()), }; ("ec", json!(bits), if name.is_empty() { Value::Null } else { json!(name) }) } Ok(x509_parser::public_key::PublicKey::Unknown(_)) if cert.public_key().algorithm.algorithm.to_id_string() == "1.3.101.112" => ("ed25519", Value::Null, Value::Null), _ => ("unknown", Value::Null, Value::Null), }; let not_after = cert.validity().not_after.timestamp(); let days = ((not_after * 1000 - chrono::Utc::now().timestamp_millis()) as f64 / 86_400_000.0).floor(); let self_signed = cert.subject() == cert.issuer() && cert.verify_signature(None).is_ok(); json!({ "subject": node_name(cert.subject()), "issuer": node_name(cert.issuer()), "san": if san.is_empty() { Value::Null } else { json!(san.join(", ")) }, "validFrom": node_date(cert.validity().not_before), "validUntil": node_date(cert.validity().not_after), "daysUntilExpiration": days, "fingerprint256": fingerprint, "publicKeyType": key_type, "publicKeyBits": bits, "publicKeyCurve": curve, "signatureAlgorithm": openssl_name(&cert.signature_algorithm.algorithm.to_id_string()), "hostnameMatches": host_matches, "selfSigned": self_signed, "serialNumber": cert.raw_serial().iter().map(|b| format!("{b:02X}")).collect::() }) } fn server_name(host: &str) -> ServerName<'static> { match host.parse::() { Ok(ip) => ServerName::IpAddress(ip.into()), Err(_) => ServerName::try_from(host.to_string()).unwrap_or_else(|_| ServerName::IpAddress(IpAddr::from([0, 0, 0, 0]).into())), } } async fn tls_probe(target: &Target, address: &str, options: &Options, cancel: &CancellationToken, version: &'static str, ca: Option<&[u8]>) -> Result { let started = now_ms(); let roots = Arc::new(root_store(ca)); let inner = rustls::client::WebPkiServerVerifier::builder_with_provider(roots, provider()).build().map_err(|e| DiagError::with_message("TLS_SETUP_FAILED", e.to_string()))?; let verifier = Arc::new(InspectingVerifier { inner, chain: Default::default(), verdict: Default::default() }); let versions: &[&'static rustls::SupportedProtocolVersion] = if version == "TLSv1.2" { &[&rustls::version::TLS12] } else { &[&rustls::version::TLS13] }; let mut config = rustls::ClientConfig::builder_with_provider(provider()) .with_protocol_versions(versions) .map_err(|e| DiagError::with_message("TLS_SETUP_FAILED", e.to_string()))? .dangerous() .with_custom_certificate_verifier(verifier.clone()) .with_no_client_auth(); config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; let ip: IpAddr = address.parse().map_err(|_| DiagError::new("INVALID_IP"))?; let connector = tokio_rustls::TlsConnector::from(Arc::new(config)); let work = async { let tcp = tokio::net::TcpStream::connect(SocketAddr::new(ip, target.port)).await.map_err(DiagError::from_io)?; let name = if target.host.parse::().is_ok() { server_name(&target.host) } else { server_name(&target.host) }; connector.connect(name, tcp).await.map_err(|e| { let text = e.to_string(); if text.contains("PeerIncompatible") || text.contains("protocol version") || text.contains("ProtocolVersion") { DiagError::with_message("ERR_SSL_UNSUPPORTED_PROTOCOL", format!("unsupported protocol: {text}")) } else { DiagError::with_message("TLS_HANDSHAKE_FAILED", text) } }) }; let stream = tokio::select! { result = tokio::time::timeout(Duration::from_millis(options.timeout_ms), work) => result.map_err(|_| DiagError::new("TIMEOUT"))??, _ = cancel.cancelled() => return Err(DiagError::new("CANCELLED")), }; let (_, session) = stream.get_ref(); let negotiated = match session.protocol_version() { Some(rustls::ProtocolVersion::TLSv1_2) => "TLSv1.2", Some(rustls::ProtocolVersion::TLSv1_3) => "TLSv1.3", _ => "unknown", }; let cipher = session.negotiated_cipher_suite().map(|s| { let name = format!("{:?}", s.suite()); json!({ "name": name, "standardName": name, "version": negotiated }) }); let alpn = session.alpn_protocol().map(|p| String::from_utf8_lossy(p).to_string()); let chain_der = verifier.chain.lock().clone(); let chain: Vec = chain_der.iter().take(12).map(|der| certificate_info(der, &target.host)).collect(); let mut errors: Vec = Vec::new(); match verifier.verdict.lock().clone() { Some(Ok(())) => {} Some(Err(error)) => { let code = node_error_code(&error); errors.push(code); } None => errors.push("CERTIFICATE_MISSING".into()), } if chain.is_empty() && !errors.iter().any(|e| e == "CERTIFICATE_MISSING") { errors.push("CERTIFICATE_MISSING".into()); } let days = chain.first().and_then(|c| c["daysUntilExpiration"].as_f64()).unwrap_or(0.0); Ok(json!({ "status": if !errors.is_empty() { "invalid" } else if days < 30.0 { "warning" } else { "valid" }, "valid": errors.is_empty(), "requestedVersion": version, "negotiatedVersion": negotiated, "address": address, "port": target.port, "sni": if target.host.parse::().is_ok() { Value::Null } else { json!(target.host) }, "durationMs": now_ms() - started, "cipher": cipher, "alpn": alpn, "chain": chain, "errors": errors, "validation": "rustls/webpki trust/date/signature verification (Mozilla root store) plus explicit hostname verification; revocation/CT not independently audited." })) } pub async fn run_tls(target: &Target, address: &str, options: &Options, cancel: &CancellationToken, progress: Arc, ca: Option<&[u8]>) -> Result { let mut results = Vec::new(); for version in ["TLSv1.2", "TLSv1.3"] { let result = match tls_probe(target, address, options, cancel, version, ca).await { Ok(result) => result, Err(error) => { if cancel.is_cancelled() { return Err(error); } let mut result = error_result(&error); result["requestedVersion"] = json!(version); if regex::Regex::new(r"(?i)protocol version|unsupported protocol").unwrap().is_match(&error.message) { result["status"] = json!("unsupported"); } result } }; let mut event = json!({ "kind": "tls", "address": address }); if let (Some(e), Some(r)) = (event.as_object_mut(), result.as_object()) { e.extend(r.clone()); } progress(event); results.push(result); } let any_invalid = results.iter().any(|r| r["status"] == "invalid"); let any_valid = results.iter().any(|r| r["valid"] == true); Ok(json!({ "status": if any_invalid { if any_valid { "warning" } else { "failed" } } else if any_valid { "success" } else { "warning" }, "results": results })) } pub async fn tcp_probe(address: &str, port: u16, options: &Options, cancel: &CancellationToken) -> Result { let ip: IpAddr = address.parse().map_err(|_| DiagError::new("INVALID_IP"))?; let started = now_ms(); let mut connected_ms: Option = None; let mut banner: Vec = Vec::new(); let work = async { let mut stream = tokio::net::TcpStream::connect(SocketAddr::new(ip, port)).await.map_err(DiagError::from_io)?; connected_ms = Some(now_ms() - started); if [21u16, 22, 25, 110, 143, 587].contains(&port) { let mut buffer = [0u8; 512]; if let Ok(Ok(n)) = tokio::time::timeout(Duration::from_millis(500.min(options.timeout_ms)), stream.read(&mut buffer)).await { banner.extend_from_slice(&buffer[..n]); } } Ok::<_, DiagError>(()) }; let outcome = tokio::select! { result = tokio::time::timeout(Duration::from_millis(options.timeout_ms), work) => match result { Ok(r) => r, Err(_) => Err(DiagError::new("TIMEOUT")) }, _ = cancel.cancelled() => return Err(DiagError::new("CANCELLED")), }; let (status, code) = match &outcome { Ok(()) => ("connected", Value::Null), Err(e) if e.code == "ECONNREFUSED" => ("refused", json!(e.code)), Err(e) if e.code == "TIMEOUT" || e.code == "ETIMEDOUT" => ("timeout", json!(e.code)), Err(e) => ("error", json!(e.code)), }; let banner_text: String = String::from_utf8_lossy(&banner).chars().filter(|c| !matches!(*c as u32, 0..=8 | 11..=31 | 127)).take(512).collect(); Ok(json!({ "address": address, "family": if ip.is_ipv4() { 4 } else { 6 }, "port": port, "standardService": service_name(port), "status": status, "code": code, "connectionMs": connected_ms, "banner": if banner_text.is_empty() { Value::Null } else { json!(banner_text) }, "serviceNote": "Standard port assignment is a label, not service verification." })) } struct ObserveKey { fingerprint: Arc>>, } impl russh::client::Handler for ObserveKey { type Error = russh::Error; async fn check_server_key(&mut self, key: &russh::keys::PublicKeyOrCertificate) -> Result { let blob = match key { russh::keys::PublicKeyOrCertificate::PublicKey { key, .. } => key.to_bytes()?, russh::keys::PublicKeyOrCertificate::Certificate(c) => c.to_bytes()?, }; *self.fingerprint.lock() = Some(format!("SHA256:{}", base64::engine::general_purpose::STANDARD_NO_PAD.encode(Sha256::digest(&blob)))); Ok(false) } } /// Observes the SSH handshake key only; nothing is authenticated, trusted or stored. pub async fn ssh_identification(address: &str, options: &Options, cancel: &CancellationToken) -> Result { let ip: IpAddr = address.parse().map_err(|_| DiagError::new("INVALID_IP"))?; let fingerprint = Arc::new(parking_lot::Mutex::new(None)); let handler = ObserveKey { fingerprint: fingerprint.clone() }; let config = Arc::new(russh::client::Config::default()); let attempt = russh::client::connect(config, SocketAddr::new(ip, 22), handler); let _ = tokio::select! { result = tokio::time::timeout(Duration::from_millis(options.timeout_ms), attempt) => result.map_err(|_| DiagError::new("TIMEOUT")), _ = cancel.cancelled() => return Err(DiagError::new("CANCELLED")), }; let observed = fingerprint.lock().clone(); match observed { Some(fp) => Ok(json!({ "status": "success", "fingerprint": fp, "identification": null, "authenticated": false, "note": "Observed handshake key; not trusted or added to SSH known hosts." })), None => Err(DiagError::with_message("SSH_HANDSHAKE_FAILED", "The SSH handshake did not present a host key.")), } } pub async fn run_tcp(address: &str, options: &Options, cancel: &CancellationToken, progress: Arc) -> Result { let ports = map_limit(options.ports.clone(), 4, |port| { let progress = progress.clone(); async move { let result = tcp_probe(address, port, options, cancel).await?; let mut event = json!({ "kind": "tcp" }); if let (Some(e), Some(r)) = (event.as_object_mut(), result.as_object()) { e.extend(r.clone()); } progress(event); Ok(result) } }) .await?; let mut ssh = Value::Null; if ports.iter().any(|p| p["port"] == 22 && p["status"] == "connected") { ssh = match ssh_identification(address, options, cancel).await { Ok(mut value) => { if value["identification"].is_null() { value["identification"] = ports.iter().find(|p| p["port"] == 22).map(|p| p["banner"].clone()).unwrap_or(Value::Null); } value } Err(error) => { if cancel.is_cancelled() { return Err(error); } error_result(&error) } }; } Ok(json!({ "status": if ports.iter().any(|p| p["status"] == "connected") { "success" } else { "warning" }, "ports": ports, "ssh": ssh })) } /// Optional WebSocket upgrade handshake (status 101, Upgrade/Connection, accept hash). pub async fn websocket_probe(target: &Target, address: &str, options: &Options, cancel: &CancellationToken, ca: Option<&[u8]>) -> Result { let url = url::Url::parse(&target.url).map_err(|_| DiagError::new("INVALID_URL"))?; let secure = matches!(url.scheme(), "wss" | "https"); let ip: IpAddr = address.parse().map_err(|_| DiagError::new("INVALID_IP"))?; let mut key_bytes = [0u8; 16]; rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut key_bytes); let key = base64::engine::general_purpose::STANDARD.encode(key_bytes); let started = now_ms(); let host_header = match url.port() { Some(port) => format!("{}:{port}", url.host_str().unwrap_or("")), None => url.host_str().unwrap_or("").to_string(), }; let path = format!("{}{}", url.path(), url.query().map(|q| format!("?{q}")).unwrap_or_default()); let request = format!("GET {path} HTTP/1.1\r\nHost: {host_header}\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: {key}\r\n\r\n"); async fn exchange(mut stream: S, request: &str) -> Result { stream.write_all(request.as_bytes()).await.map_err(DiagError::from_io)?; let mut head = Vec::new(); let mut buffer = [0u8; 1024]; while !head.windows(4).any(|w| w == b"\r\n\r\n") { let n = stream.read(&mut buffer).await.map_err(DiagError::from_io)?; if n == 0 { break; } head.extend_from_slice(&buffer[..n]); if head.len() > 16384 { return Err(DiagError::new("HEADER_LIMIT")); } } Ok(String::from_utf8_lossy(&head).to_string()) } let work = async { let tcp = tokio::net::TcpStream::connect(SocketAddr::new(ip, target.port)).await.map_err(DiagError::from_io)?; if secure { let mut config = rustls::ClientConfig::builder_with_provider(provider()) .with_safe_default_protocol_versions() .map_err(|e| DiagError::with_message("TLS_SETUP_FAILED", e.to_string()))? .with_root_certificates(root_store(ca)) .with_no_client_auth(); config.alpn_protocols = vec![b"http/1.1".to_vec()]; let stream = tokio_rustls::TlsConnector::from(Arc::new(config)).connect(server_name(&target.host), tcp).await.map_err(|e| DiagError::with_message("TLS_CERT_INVALID", e.to_string()))?; exchange(stream, &request).await } else { exchange(tcp, &request).await } }; let head = tokio::select! { result = tokio::time::timeout(Duration::from_millis(options.timeout_ms), work) => result.map_err(|_| DiagError::new("TIMEOUT"))??, _ = cancel.cancelled() => return Err(DiagError::new("CANCELLED")), }; let mut lines = head.split("\r\n"); let status_code: u16 = lines.next().and_then(|l| l.split_whitespace().nth(1)).and_then(|s| s.parse().ok()).unwrap_or(0); let mut headers = std::collections::HashMap::new(); for line in lines { if let Some((k, v)) = line.split_once(':') { headers.insert(k.trim().to_lowercase(), v.trim().to_string()); } } if status_code != 101 { return Ok(json!({ "status": "unsupported", "statusCode": status_code, "durationMs": now_ms() - started, "address": address })); } let mut hasher = sha1::Sha1::new(); sha1::Digest::update(&mut hasher, format!("{key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11").as_bytes()); let expected = base64::engine::general_purpose::STANDARD.encode(sha1::Digest::finalize(hasher)); let valid = headers.get("upgrade").map(|v| v.to_lowercase() == "websocket").unwrap_or(false) && regex::Regex::new(r"(?i)\bupgrade\b").unwrap().is_match(headers.get("connection").map(String::as_str).unwrap_or("")) && headers.get("sec-websocket-accept") == Some(&expected); Ok(json!({ "status": if valid { "success" } else { "failed" }, "valid": valid, "statusCode": status_code, "durationMs": now_ms() - started, "address": address, "secure": secure })) } #[cfg(test)] mod tests { use super::*; const SERVER_PEM: &str = include_str!("../../../../tools/test-fixtures/diagnostics/server.pem"); const EXPIRED_PEM: &str = include_str!("../../../../tools/test-fixtures/diagnostics/expired.pem"); fn first_der(pem: &str) -> Vec { rustls_pemfile::certs(&mut pem.as_bytes()).next().unwrap().unwrap().to_vec() } #[test] fn certificate_details() { let info = certificate_info(&first_der(SERVER_PEM), "localhost"); assert_eq!(info["hostnameMatches"], true); assert!(info["san"].as_str().unwrap().contains("DNS:localhost")); assert!(info["daysUntilExpiration"].as_f64().unwrap() > 1000.0); assert_eq!(certificate_info(&first_der(SERVER_PEM), "127.0.0.1")["hostnameMatches"], true); assert_eq!(certificate_info(&first_der(SERVER_PEM), "example.com")["hostnameMatches"], false); assert!(certificate_info(&first_der(EXPIRED_PEM), "localhost")["daysUntilExpiration"].as_f64().unwrap() < 0.0); } #[tokio::test] async fn tcp_refused_and_connected() { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); tokio::spawn(async move { let _ = listener.accept().await; }); // Windows retries SYN to a closed local port for about 2 s before reporting the refusal. let options = Options { timeout_ms: 10_000, ..Default::default() }; let cancel = CancellationToken::new(); assert_eq!(tcp_probe("127.0.0.1", port, &options, &cancel).await.unwrap()["status"], "connected"); let closed = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let closed_port = closed.local_addr().unwrap().port(); drop(closed); assert_eq!(tcp_probe("127.0.0.1", closed_port, &options, &cancel).await.unwrap()["status"], "refused"); } }