geph5_client/
get_dialer.rs

1use std::time::{Duration, Instant, SystemTime};
2
3use anyctx::AnyCtx;
4use anyhow::Context;
5
6use arrayref::array_ref;
7use async_native_tls::TlsConnector;
8use ed25519_dalek::VerifyingKey;
9
10use geph5_broker_protocol::{
11    AccountLevel, ExitDescriptor, ExitList, GetRoutesArgs, RouteDescriptor, DOMAIN_EXIT_DESCRIPTOR,
12};
13use isocountry::CountryCode;
14use ordered_float::OrderedFloat;
15use rand::seq::SliceRandom;
16use serde::{Deserialize, Serialize};
17use sillad::{
18    dialer::{DialerExt, DynDialer, FailingDialer},
19    tcp::TcpDialer,
20};
21use sillad_conntest::ConnTestDialer;
22use sillad_sosistab3::{dialer::SosistabDialer, Cookie};
23
24use smol_timeout2::TimeoutExt as _;
25
26use crate::{
27    auth::get_connect_token,
28    broker::broker_client,
29    client::{Config, CtxField},
30    device_metadata::get_device_metadata,
31    vpn::smart_vpn_whitelist,
32};
33
34#[derive(Serialize, Deserialize, Clone, Debug)]
35#[serde(rename_all = "snake_case")]
36pub enum ExitConstraint {
37    Auto,
38    Direct(String),
39    Hostname(String),
40    Country(CountryCode),
41    CountryCity(CountryCode, String),
42}
43
44/// Gets a sillad Dialer that produces a single, pre-authentication pipe, as well as the public key.
45pub async fn get_dialer(
46    ctx: &AnyCtx<Config>,
47) -> anyhow::Result<(VerifyingKey, ExitDescriptor, DynDialer)> {
48    static SEMAPH: CtxField<
49        smol::lock::Mutex<Option<(VerifyingKey, ExitDescriptor, DynDialer, SystemTime)>>,
50    > = |_| smol::lock::Mutex::new(None);
51    let mut cached_value = ctx.get(SEMAPH).lock().await;
52
53    if let Some(inner) = cached_value.clone() {
54        if inner.3.elapsed()? < Duration::from_secs(10) {
55            tracing::debug!("returning very recently cached dialer");
56            return Ok((inner.0, inner.1, inner.2));
57        }
58    }
59
60    let res = get_dialer_inner(ctx)
61        .timeout(Duration::from_secs(15))
62        .await
63        .ok_or_else(|| anyhow::anyhow!("get_dialer_inner timed out"))
64        .and_then(|x| x);
65    match res {
66        Ok(val) => {
67            *cached_value = Some((val.0, val.1.clone(), val.2.clone(), SystemTime::now()));
68            Ok((val.0, val.1, val.2))
69        }
70        Err(err) => {
71            tracing::warn!("failed to get dialer: {:?}", err);
72            if let Some(val) = cached_value.clone() {
73                tracing::warn!("returning stale value instead");
74                Ok((val.0, val.1, val.2))
75            } else {
76                Err(err)
77            }
78        }
79    }
80}
81
82async fn get_dialer_inner(
83    ctx: &AnyCtx<Config>,
84) -> anyhow::Result<(VerifyingKey, ExitDescriptor, DynDialer)> {
85    // If the user specified a direct constraint, handle that path immediately:
86    if let ExitConstraint::Direct(dir) = &ctx.init().exit_constraint {
87        let (dir, pubkey_hex) = dir
88            .split_once('/')
89            .context("did not find / in a direct constraint")?;
90        let pubkey = VerifyingKey::from_bytes(
91            hex::decode(pubkey_hex)
92                .context("cannot decode pubkey as hex")?
93                .as_slice()
94                .try_into()
95                .context("pubkey wrong length")?,
96        )?;
97        let dest_addr = *smol::net::resolve(dir)
98            .await?
99            .choose(&mut rand::thread_rng())
100            .context("could not resolve destination for direct exit connection")?;
101        smart_vpn_whitelist(ctx, dest_addr.ip());
102        return Ok((
103            pubkey,
104            ExitDescriptor {
105                c2e_listen: "0.0.0.0:0".parse()?,
106                b2e_listen: "0.0.0.0:0".parse()?,
107                country: CountryCode::ABW,
108                city: "".to_string(),
109                load: 0.0,
110                expiry: 0,
111            },
112            ConnTestDialer {
113                ping_count: 1,
114                inner: TcpDialer { dest_addr },
115            }
116            .dynamic(),
117        ));
118    }
119
120    // Otherwise, we need to pick an exit from the broker based on user constraints.
121    let (level, conn_token, sig) = get_connect_token(ctx)
122        .await
123        .context("could not get connect token")?;
124
125    let broker = broker_client(ctx).context("could not get broker client")?;
126    let exits_response = match level {
127        AccountLevel::Plus => broker.get_exits().await,
128        AccountLevel::Free => broker.get_free_exits().await,
129    }?
130    .map_err(|e| anyhow::anyhow!("broker refused to serve exits: {e}"))?;
131
132    // Verify the broker's signature over the exit list:
133    let exits_verified = exits_response
134        .verify(DOMAIN_EXIT_DESCRIPTOR, |their_pk| {
135            if let Some(broker_pk) = &ctx.init().broker_keys {
136                hex::encode(their_pk.as_bytes()) == broker_pk.master
137            } else {
138                true
139            }
140        })
141        .context("could not verify exits")?;
142
143    // Use our new helper function to pick the best exit:
144    let rendezvous_key = blake3::hash(serde_json::to_string(&ctx.init().credentials)?.as_bytes());
145    let (pubkey, exit) =
146        pick_exit_with_constraint(rendezvous_key, &ctx.init().exit_constraint, &exits_verified)?;
147
148    tracing::debug!(exit = ?exit, "narrowed down choice of exit");
149    smart_vpn_whitelist(ctx, exit.c2e_listen.ip());
150
151    tracing::debug!(token = %conn_token, "CONN TOKEN");
152
153    let start = Instant::now();
154    let metadata = if let Ok(metadata) = get_device_metadata(ctx).await {
155        tracing::info!(
156            metadata = debug(&metadata),
157            elapsed = debug(start.elapsed()),
158            "DEVICE METADATA OBTAINED"
159        );
160        serde_json::to_value(&metadata)?
161    } else {
162        tracing::warn!("CANNOT GET DEVICE METADATA, PROCEEDING NONETHELESS");
163        serde_json::Value::Null
164    };
165
166    // Also get potential “bridge routes”:
167    let bridge_routes = broker
168        .get_routes_v2(GetRoutesArgs {
169            token: conn_token,
170            sig,
171            exit_b2e: exit.b2e_listen,
172            client_metadata: metadata,
173        })
174        .await?
175        .map_err(|e| anyhow::anyhow!("broker refused to serve bridge routes: {e}"))?;
176    tracing::debug!(
177        "bridge routes obtained: {}",
178        serde_json::to_string(&bridge_routes)?
179    );
180
181    let bridge_dialer = route_to_dialer(ctx, &bridge_routes);
182
183    Ok((*pubkey, exit.clone(), bridge_dialer))
184}
185
186/// A helper that filters the verified exits by the user’s `ExitConstraint`,
187/// then picks the exit with the lowest load.
188fn pick_exit_with_constraint<'a>(
189    rendezvous_key: blake3::Hash,
190    constraint: &ExitConstraint,
191    exits_verified: &'a ExitList,
192) -> anyhow::Result<(&'a VerifyingKey, &'a ExitDescriptor)> {
193    // Extract the underlying HashMap from your verification struct
194    let all_exits = &exits_verified.all_exits;
195
196    // Figure out which fields we need to match
197    let mut country_constraint = None;
198    let mut city_constraint = None;
199    let mut hostname_constraint = None;
200
201    match constraint {
202        ExitConstraint::Hostname(host) => {
203            hostname_constraint = Some(host.clone());
204        }
205        ExitConstraint::Country(country) => {
206            country_constraint = Some(*country);
207        }
208        ExitConstraint::CountryCity(country, city) => {
209            country_constraint = Some(*country);
210            city_constraint = Some(city.clone());
211        }
212        ExitConstraint::Auto => {}
213        ExitConstraint::Direct(_) => panic!("should not reach here"),
214    }
215
216    // Filter down to those that match. If none match, we pick the global minimum load.
217    let filtered = all_exits
218        .iter()
219        .filter(|(_, exit)| {
220            let country_pass = match country_constraint {
221                Some(c) => exit.country == c,
222                None => true,
223            };
224            let city_pass = match &city_constraint {
225                Some(city) => exit.city == *city,
226                None => true,
227            };
228            let hostname_pass = match &hostname_constraint {
229                Some(hn) => exit.b2e_listen.ip().to_string() == *hn,
230                None => true,
231            };
232            country_pass && city_pass && hostname_pass
233        })
234        .collect::<Vec<_>>();
235
236    if filtered.is_empty() {
237        anyhow::bail!("no exits match the constraints")
238    }
239
240    // If any matched, we use load-sensitive rendezvous hashing
241    let first = filtered
242        .iter()
243        .min_by_key(|rh| {
244            let hash = blake3::keyed_hash(rendezvous_key.as_bytes(), &rh.0.as_bytes()[..]);
245            let hash = &hash.as_bytes()[..];
246            let hash = u64::from_be_bytes(*array_ref![hash, 0, 8]) as f64 / u64::MAX as f64;
247            let weight = (1.0 - (rh.1.load as f64)).powi(2);
248            let picker = -hash.ln() / weight;
249            tracing::debug!(
250                "picking exit, {}/{}/{} => {:.5}",
251                rh.1.country,
252                rh.1.city,
253                rh.1.b2e_listen.ip(),
254                picker
255            );
256            OrderedFloat(picker)
257        })
258        .unwrap();
259    Ok((&first.0, &first.1))
260}
261
262fn route_to_dialer(ctx: &AnyCtx<Config>, route: &RouteDescriptor) -> DynDialer {
263    use sillad_native_tls::TlsDialer;
264
265    match route {
266        RouteDescriptor::Tcp(addr) => {
267            smart_vpn_whitelist(ctx, addr.ip());
268            let addr = *addr;
269            TcpDialer { dest_addr: addr }.dynamic()
270        }
271        RouteDescriptor::Sosistab3 { cookie, lower } => {
272            let inner = route_to_dialer(ctx, lower);
273            SosistabDialer {
274                inner,
275                cookie: Cookie::new(cookie),
276            }
277            .dynamic()
278        }
279        RouteDescriptor::Race(inside) => inside
280            .iter()
281            .map(|s| route_to_dialer(ctx, s))
282            .reduce(|a, b| a.race(b).dynamic())
283            .unwrap_or_else(|| FailingDialer.dynamic()),
284        RouteDescriptor::Fallback(a) => a
285            .iter()
286            .map(|s| route_to_dialer(ctx, s))
287            .reduce(|a, b| a.fallback(b).dynamic())
288            .unwrap_or_else(|| FailingDialer.dynamic()),
289        RouteDescriptor::Timeout {
290            milliseconds,
291            lower,
292        } => route_to_dialer(ctx, lower)
293            .timeout(Duration::from_millis(*milliseconds as _))
294            .dynamic(),
295        RouteDescriptor::Delay {
296            milliseconds,
297            lower,
298        } => route_to_dialer(ctx, lower)
299            .delay(Duration::from_millis((*milliseconds).into()))
300            .dynamic(),
301        RouteDescriptor::ConnTest { ping_count, lower } => {
302            let lower = route_to_dialer(ctx, lower);
303            ConnTestDialer {
304                inner: lower,
305                ping_count: *ping_count as _,
306            }
307            .dynamic()
308        }
309
310        RouteDescriptor::Other(_) => FailingDialer.dynamic(),
311        RouteDescriptor::PlainTls { sni_domain, lower } => {
312            let lower = route_to_dialer(ctx, lower);
313            TlsDialer::new(
314                lower,
315                TlsConnector::new()
316                    .use_sni(sni_domain.is_some())
317                    .danger_accept_invalid_certs(true)
318                    .danger_accept_invalid_hostnames(true)
319                    .min_protocol_version(None)
320                    .max_protocol_version(None),
321                sni_domain
322                    .clone()
323                    .unwrap_or_else(|| "example.com".to_string()),
324            )
325            .dynamic()
326        }
327    }
328}