chia_query/peer/connect.rs
1use std::net::SocketAddr;
2use std::path::Path;
3use std::time::Duration;
4
5use futures_util::stream::{FuturesUnordered, StreamExt};
6use rand::seq::SliceRandom;
7
8use chia_ssl::ChiaCertificate;
9use chia_wallet_sdk::client::{
10 connect_peer, create_native_tls_connector, load_ssl_cert, Network, Peer, PeerOptions,
11};
12use tokio_tungstenite::Connector;
13
14use chia::protocol::Message;
15use tokio::sync::mpsc;
16
17use crate::types::ChiaQueryError;
18use crate::NetworkType;
19
20const BATCH_SIZE: usize = 10;
21const MAINNET_PORT: u16 = 8444;
22const TESTNET11_PORT: u16 = 58444;
23
24// ---------------------------------------------------------------------------
25// TLS helpers
26// ---------------------------------------------------------------------------
27
28/// Build a TLS connector from a FRESHLY GENERATED, in-memory Chia certificate.
29///
30/// The Chia peer protocol does not treat the client certificate as a credential — a
31/// full node accepts any well-formed certificate — so a query client has no reason to
32/// require a Chia installation just to speak to peers. Generating avoids the whole
33/// class of "the certificate lives in a home directory this process cannot read or
34/// write" failures (dig_ecosystem#2210).
35///
36/// Nothing is written to disk. The certificate lives for the life of the process, so
37/// the peer-visible client identity changes on restart; that is harmless because peers
38/// are tracked by address, not by certificate, and persisting one would reintroduce
39/// the dependency on a writable well-known directory that this exists to remove.
40pub fn create_generated_tls() -> Result<Connector, ChiaQueryError> {
41 let cert = ChiaCertificate::generate().map_err(|e| ChiaQueryError::TlsError(e.to_string()))?;
42 create_native_tls_connector(&cert).map_err(|e| ChiaQueryError::TlsError(e.to_string()))
43}
44
45/// Build a TLS connector from an EXISTING certificate/key pair on disk.
46///
47/// Used only when a caller explicitly supplies [`TlsIdentity::Files`](crate::TlsIdentity::Files),
48/// e.g. to present a real Chia node's wallet certificate.
49pub fn create_tls(cert_path: &Path, key_path: &Path) -> Result<Connector, ChiaQueryError> {
50 let cert_str = cert_path
51 .to_str()
52 .ok_or_else(|| ChiaQueryError::TlsError("cert path is not valid UTF-8".into()))?;
53 let key_str = key_path
54 .to_str()
55 .ok_or_else(|| ChiaQueryError::TlsError("key path is not valid UTF-8".into()))?;
56 let cert =
57 load_ssl_cert(cert_str, key_str).map_err(|e| ChiaQueryError::TlsError(e.to_string()))?;
58 create_native_tls_connector(&cert).map_err(|e| ChiaQueryError::TlsError(e.to_string()))
59}
60
61// ---------------------------------------------------------------------------
62// Peer discovery + connection
63// ---------------------------------------------------------------------------
64
65/// HOW a peer was reached, which is not the same question as whether it is reachable.
66///
67/// A priority peer — an operator's `TRUSTED_FULLNODE`, or a full node on this machine — is
68/// *preferred* because it is fast and under the operator's own control. That makes it a good peer
69/// to ASK. It does not make it an independent voice: corroboration is only worth anything when the
70/// peers agreeing came from sources an attacker cannot all supply, and a co-resident process is
71/// precisely a source a local attacker CAN supply. Priority orders discovery; it never confers
72/// authority.
73///
74/// So this is reported rather than decided here. A caller doing round-robin reads wants the fast
75/// peer; a caller counting agreeing opinions must count [`PeerOrigin::Discovered`] peers only. A
76/// single return value cannot serve both, and conflating them is what let one local process stand
77/// in for an entire "independent" peer set (dig_ecosystem#2648).
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum PeerOrigin {
80 /// Reached from a configured or co-resident address tried ahead of discovery.
81 Priority,
82 /// Reached from a DNS introducer's address set.
83 Discovered,
84}
85
86/// Resolve DNS introducers, shuffle, and return the first peer that connects
87/// within `timeout`. Returns the connected [`Peer`] together with its address
88/// so the pool can track it.
89///
90/// Equivalent to [`connect_random_peer_excluding`] with nothing excluded; prefer that when the
91/// caller already holds addresses, so a dial cannot return an address the caller must then discard.
92pub async fn connect_random_peer(
93 network: NetworkType,
94 tls: &Connector,
95 timeout: Duration,
96) -> Result<(Peer, SocketAddr, mpsc::Receiver<Message>), ChiaQueryError> {
97 let (peer, addr, receiver, _origin) =
98 connect_random_peer_excluding(network, tls, timeout, &[]).await?;
99 Ok((peer, addr, receiver))
100}
101
102/// Connect one peer whose address is NOT in `exclude`, reporting how it was reached.
103///
104/// Discovery order (matching dig-chia-sdk FullNodePeer.ts):
105/// 1. `TRUSTED_FULLNODE` env var (if set and valid IP)
106/// 2. localhost (127.0.0.1)
107/// 3. DNS introducers (same 4 hosts used by dig-chia-sdk):
108/// - dns-introducer.chia.net
109/// - chia.ctrlaltdel.ch
110/// - seeder.dexie.space
111/// - chia.hoffmang.com
112///
113/// `exclude` applies to the priority addresses as well as the discovered ones. That is the whole
114/// point of it: the localhost dial is tried on EVERY call, so a caller filling a pool would
115/// otherwise be handed the same local address as many times as it asks, and a local node that
116/// answers would occupy every slot (dig_ecosystem#2648). Excluding what the caller already holds
117/// makes the preference "try the local node first" instead of "try only the local node".
118pub async fn connect_random_peer_excluding(
119 network: NetworkType,
120 tls: &Connector,
121 timeout: Duration,
122 exclude: &[SocketAddr],
123) -> Result<(Peer, SocketAddr, mpsc::Receiver<Message>, PeerOrigin), ChiaQueryError> {
124 let default_port = match network {
125 NetworkType::Mainnet => MAINNET_PORT,
126 NetworkType::Testnet11 => TESTNET11_PORT,
127 };
128 let network_id = network.network_id().to_string();
129
130 let priority_addrs = priority_addresses(default_port, exclude);
131
132 // Try priority peers first (sequentially, fast timeout).
133 for addr in &priority_addrs {
134 match try_connect(&network_id, tls, *addr, timeout).await {
135 Ok((peer, receiver)) => return Ok((peer, *addr, receiver, PeerOrigin::Priority)),
136 Err(e) => log::debug!("priority peer {addr} unavailable: {e}"),
137 }
138 }
139
140 // -- DNS introducer discovery -------------------------------------------
141 let net = match network {
142 NetworkType::Mainnet => Network::default_mainnet(),
143 NetworkType::Testnet11 => Network::default_testnet11(),
144 };
145
146 let mut addrs = net.lookup_all(timeout, BATCH_SIZE).await;
147
148 if addrs.is_empty() {
149 return Err(ChiaQueryError::PeerDiscoveryFailed);
150 }
151
152 // Randomise so we don't always hammer the same peer.
153 addrs.shuffle(&mut rand::thread_rng());
154 addrs.dedup();
155
156 // Remove any addresses we already tried above, and any the caller already holds.
157 addrs.retain(|a| !priority_addrs.contains(a) && !exclude.contains(a));
158
159 if addrs.is_empty() {
160 return Err(ChiaQueryError::PeerDiscoveryFailed);
161 }
162
163 // Try batches of concurrent connection attempts.
164 for chunk in addrs.chunks(BATCH_SIZE) {
165 let mut futures = FuturesUnordered::new();
166
167 for &addr in chunk {
168 let tls_clone = tls.clone();
169 let nid = network_id.clone();
170 futures.push(async move {
171 let res = tokio::time::timeout(
172 timeout,
173 connect_peer(nid, tls_clone, addr, PeerOptions::default()),
174 )
175 .await;
176 (addr, res)
177 });
178 }
179
180 while let Some((addr, result)) = futures.next().await {
181 match result {
182 Ok(Ok((peer, receiver))) => {
183 return Ok((peer, addr, receiver, PeerOrigin::Discovered))
184 }
185 Ok(Err(e)) => log::debug!("connect to {addr} failed: {e}"),
186 Err(_) => log::debug!("connect to {addr} timed out"),
187 }
188 }
189 }
190
191 Err(ChiaQueryError::PeerDiscoveryFailed)
192}
193
194// ---------------------------------------------------------------------------
195// Helpers
196// ---------------------------------------------------------------------------
197
198/// The addresses tried ahead of DNS discovery, minus anything `exclude` already holds.
199///
200/// Mirrors dig-chia-sdk `FullNodePeer.ts`: an operator's `TRUSTED_FULLNODE`, then a full node on
201/// this machine. Both are preferences about SPEED and operator control.
202///
203/// The exclusion is what keeps a preference from becoming a monopoly. These addresses are computed
204/// on every dial, so without it a caller filling N slots is offered the same local address N times
205/// — and any unprivileged local process that binds the port becomes the whole peer set
206/// (dig_ecosystem#2648).
207fn priority_addresses(default_port: u16, exclude: &[SocketAddr]) -> Vec<SocketAddr> {
208 let mut addrs: Vec<SocketAddr> = Vec::new();
209
210 if let Ok(trusted) = std::env::var("TRUSTED_FULLNODE") {
211 if let Ok(ip) = trusted.parse::<std::net::IpAddr>() {
212 addrs.push(SocketAddr::new(ip, default_port));
213 } else {
214 log::debug!("TRUSTED_FULLNODE value is not a valid IP: {trusted}");
215 }
216 }
217
218 addrs.push(SocketAddr::new(
219 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
220 default_port,
221 ));
222
223 addrs.retain(|addr| !exclude.contains(addr));
224 addrs
225}
226
227async fn try_connect(
228 network_id: &str,
229 tls: &Connector,
230 addr: SocketAddr,
231 timeout: Duration,
232) -> Result<(Peer, mpsc::Receiver<Message>), ChiaQueryError> {
233 let result = tokio::time::timeout(
234 timeout,
235 connect_peer(
236 network_id.to_string(),
237 tls.clone(),
238 addr,
239 PeerOptions::default(),
240 ),
241 )
242 .await;
243
244 match result {
245 Ok(Ok((peer, receiver))) => Ok((peer, receiver)),
246 Ok(Err(e)) => Err(ChiaQueryError::PeerConnection(e.to_string())),
247 Err(_) => Err(ChiaQueryError::PeerConnection("timed out".into())),
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 const MAINNET: u16 = MAINNET_PORT;
256
257 fn localhost(port: u16) -> SocketAddr {
258 SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), port)
259 }
260
261 /// The control: with nothing held, the local node is still preferred.
262 ///
263 /// Without this, an exclusion that dropped the local address unconditionally — the tempting
264 /// "just delete the prepend" fix — would look identical to the correct behaviour.
265 #[test]
266 fn the_local_node_is_offered_when_it_is_not_already_held() {
267 assert!(priority_addresses(MAINNET, &[]).contains(&localhost(MAINNET)));
268 }
269
270 /// The fix: it is offered AT MOST ONCE, because a caller that already holds it excludes it.
271 #[test]
272 fn the_local_node_is_not_offered_again_once_it_is_held() {
273 let held = [localhost(MAINNET)];
274 assert!(
275 !priority_addresses(MAINNET, &held).contains(&localhost(MAINNET)),
276 "a held local node must not be offered again, or one address fills the pool"
277 );
278 }
279
280 /// The exclusion is by exact socket address, not by host: the same host on another port is a
281 /// different peer, and refusing it would silently narrow discovery.
282 #[test]
283 fn exclusion_is_per_socket_address_not_per_host() {
284 let held = [localhost(TESTNET11_PORT)];
285 assert!(priority_addresses(MAINNET, &held).contains(&localhost(MAINNET)));
286 }
287}