geph5_client/
get_dialer.rs

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