Skip to main content

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