Skip to main content

nym_sdk_session/
dvpn.rs

1// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
2// SPDX-License-Identifier: Apache-2.0
3
4//! Minimal client for the dVPN gateway directory.
5//!
6//! QUIC bridge parameters (and human monikers) are not carried by the nym-api
7//! described-nodes the session selects from; they live in a separate dVPN
8//! directory HTTP endpoint. This module fetches that directory once and indexes
9//! it by gateway identity so selection can enrich a gateway's name/country and
10//! require a QUIC-bridge-capable entry.
11
12use std::collections::HashMap;
13use std::net::SocketAddr;
14use std::time::Duration;
15
16use serde::Deserialize;
17
18/// QUIC bridge connection parameters for a gateway, sourced from the dVPN
19/// directory. The datapath consumes these to front the WireGuard entry leg with
20/// a QUIC bridge.
21#[derive(Clone, Debug)]
22pub struct QuicBridge {
23    /// Candidate bridge socket addresses.
24    pub addresses: Vec<SocketAddr>,
25    /// SNI host to present to the bridge (if advertised).
26    pub sni_host: Option<String>,
27    /// Base64-encoded ed25519 identity public key the bridge cert is pinned to.
28    pub id_pubkey_base64: String,
29}
30
31/// Per-gateway directory metadata indexed by base58 identity.
32#[derive(Clone, Debug, Default)]
33pub(crate) struct DirEntry {
34    pub name: Option<String>,
35    pub country: Option<String>,
36    pub quic: Option<QuicBridge>,
37}
38
39/// The fetched dVPN directory, indexed by base58 gateway identity.
40#[derive(Clone, Debug, Default)]
41pub(crate) struct DvpnDirectory {
42    entries: HashMap<String, DirEntry>,
43}
44
45impl DvpnDirectory {
46    /// Fetch and index the directory at `url`. Errors are the caller's to treat
47    /// as best-effort (an empty directory is a valid fallback).
48    pub(crate) async fn fetch(url: &str) -> Result<Self, String> {
49        let client = reqwest::Client::builder()
50            .timeout(Duration::from_secs(30))
51            .build()
52            .map_err(|e| e.to_string())?;
53        let raw: Vec<RawGateway> = client
54            .get(url)
55            .send()
56            .await
57            .map_err(|e| e.to_string())?
58            .error_for_status()
59            .map_err(|e| e.to_string())?
60            .json()
61            .await
62            .map_err(|e| e.to_string())?;
63
64        let mut entries = HashMap::with_capacity(raw.len());
65        for gw in raw {
66            entries.insert(
67                gw.identity_key.clone(),
68                DirEntry {
69                    name: gw.name.filter(|n| !n.is_empty() && n != "N/A"),
70                    country: gw
71                        .location
72                        .and_then(|l| l.two_letter_iso_country_code)
73                        .filter(|c| !c.is_empty()),
74                    quic: gw.bridges.and_then(|b| b.into_quic()),
75                },
76            );
77        }
78        Ok(Self { entries })
79    }
80
81    /// Directory metadata for a gateway by base58 identity.
82    pub(crate) fn entry(&self, identity_base58: &str) -> Option<&DirEntry> {
83        self.entries.get(identity_base58)
84    }
85
86    /// Whether the gateway advertises a QUIC bridge.
87    pub(crate) fn has_quic(&self, identity_base58: &str) -> bool {
88        self.entries
89            .get(identity_base58)
90            .is_some_and(|e| e.quic.is_some())
91    }
92}
93
94// --- Wire types (only the fields we consume). ---
95
96#[derive(Deserialize)]
97struct RawGateway {
98    identity_key: String,
99    #[serde(default)]
100    name: Option<String>,
101    #[serde(default)]
102    location: Option<RawLocation>,
103    #[serde(default)]
104    bridges: Option<RawBridges>,
105}
106
107#[derive(Deserialize)]
108struct RawLocation {
109    #[serde(default)]
110    two_letter_iso_country_code: Option<String>,
111}
112
113#[derive(Deserialize)]
114struct RawBridges {
115    #[serde(default)]
116    transports: Vec<RawTransport>,
117}
118
119impl RawBridges {
120    /// The first *usable* `quic_plain` transport, parsed into a [`QuicBridge`].
121    fn into_quic(self) -> Option<QuicBridge> {
122        // Consider every `quic_plain` transport and return the first that yields a usable bridge — a
123        // malformed earlier entry (no routable address, or no identity pin) must not shadow a valid
124        // later one.
125        self.transports
126            .into_iter()
127            .filter(|t| t.transport_type == "quic_plain")
128            .find_map(|t| {
129                let args = t.args?;
130                let addresses = args
131                    .addresses
132                    .iter()
133                    .filter_map(|a| a.parse::<SocketAddr>().ok())
134                    .collect::<Vec<_>>();
135                if addresses.is_empty() {
136                    return None;
137                }
138                // The identity pin is what the bridge's certificate is verified against, so a bridge
139                // without one is unusable — don't advertise it as QUIC-capable. Trim surrounding
140                // whitespace so a padded directory value still base64-decodes at connect time.
141                let id_pubkey_base64 = args.id_pubkey.trim().to_string();
142                if id_pubkey_base64.is_empty() {
143                    return None;
144                }
145                let sni_host = args
146                    .host
147                    .map(|h| h.trim().to_string())
148                    .filter(|h| !h.is_empty());
149                Some(QuicBridge {
150                    addresses,
151                    sni_host,
152                    id_pubkey_base64,
153                })
154            })
155    }
156}
157
158#[derive(Deserialize)]
159struct RawTransport {
160    transport_type: String,
161    #[serde(default)]
162    args: Option<RawQuicArgs>,
163}
164
165#[derive(Deserialize)]
166struct RawQuicArgs {
167    #[serde(default)]
168    addresses: Vec<String>,
169    #[serde(default)]
170    host: Option<String>,
171    id_pubkey: String,
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    fn bridges(json: &str) -> RawBridges {
179        serde_json::from_str(json).expect("valid RawBridges json")
180    }
181
182    #[test]
183    fn into_quic_skips_broken_first_transport() {
184        // The first quic_plain has a blank id_pubkey (unusable); a valid later transport must still
185        // be selected rather than shadowed.
186        let quic = bridges(
187            r#"{"transports":[
188                {"transport_type":"quic_plain","args":{"addresses":["1.2.3.4:443"],"host":"a","id_pubkey":""}},
189                {"transport_type":"quic_plain","args":{"addresses":["5.6.7.8:443"],"host":"b","id_pubkey":"PINKEY"}}
190            ]}"#,
191        )
192        .into_quic()
193        .expect("a usable quic bridge exists");
194        assert_eq!(quic.id_pubkey_base64, "PINKEY");
195        assert_eq!(quic.addresses, vec!["5.6.7.8:443".parse().unwrap()]);
196    }
197
198    #[test]
199    fn into_quic_trims_id_pubkey_and_host() {
200        let quic = bridges(
201            r#"{"transports":[
202                {"transport_type":"quic_plain","args":{"addresses":["1.2.3.4:443"],"host":"  sni.example  ","id_pubkey":"  PADDED  "}}
203            ]}"#,
204        )
205        .into_quic()
206        .expect("a usable quic bridge exists");
207        assert_eq!(quic.id_pubkey_base64, "PADDED");
208        assert_eq!(quic.sni_host.as_deref(), Some("sni.example"));
209    }
210
211    #[test]
212    fn into_quic_none_when_all_unusable() {
213        // No routable address, then a whitespace-only pin — neither is usable.
214        assert!(bridges(
215            r#"{"transports":[
216                {"transport_type":"quic_plain","args":{"addresses":[],"id_pubkey":"X"}},
217                {"transport_type":"quic_plain","args":{"addresses":["1.2.3.4:443"],"id_pubkey":"   "}}
218            ]}"#,
219        )
220        .into_quic()
221        .is_none());
222    }
223
224    #[test]
225    fn into_quic_ignores_non_quic_transports() {
226        assert!(bridges(
227            r#"{"transports":[{"transport_type":"other","args":{"addresses":["1.2.3.4:443"],"id_pubkey":"K"}}]}"#,
228        )
229        .into_quic()
230        .is_none());
231    }
232}