use std::collections::HashMap;
use std::net::SocketAddr;
use std::time::Duration;
use serde::Deserialize;
#[derive(Clone, Debug)]
pub struct QuicBridge {
pub addresses: Vec<SocketAddr>,
pub sni_host: Option<String>,
pub id_pubkey_base64: String,
}
#[derive(Clone, Debug, Default)]
pub(crate) struct DirEntry {
pub name: Option<String>,
pub country: Option<String>,
pub quic: Option<QuicBridge>,
}
#[derive(Clone, Debug, Default)]
pub(crate) struct DvpnDirectory {
entries: HashMap<String, DirEntry>,
}
impl DvpnDirectory {
pub(crate) async fn fetch(url: &str) -> Result<Self, String> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.map_err(|e| e.to_string())?;
let raw: Vec<RawGateway> = client
.get(url)
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?
.json()
.await
.map_err(|e| e.to_string())?;
let mut entries = HashMap::with_capacity(raw.len());
for gw in raw {
entries.insert(
gw.identity_key.clone(),
DirEntry {
name: gw.name.filter(|n| !n.is_empty() && n != "N/A"),
country: gw
.location
.and_then(|l| l.two_letter_iso_country_code)
.filter(|c| !c.is_empty()),
quic: gw.bridges.and_then(|b| b.into_quic()),
},
);
}
Ok(Self { entries })
}
pub(crate) fn entry(&self, identity_base58: &str) -> Option<&DirEntry> {
self.entries.get(identity_base58)
}
pub(crate) fn has_quic(&self, identity_base58: &str) -> bool {
self.entries
.get(identity_base58)
.is_some_and(|e| e.quic.is_some())
}
}
#[derive(Deserialize)]
struct RawGateway {
identity_key: String,
#[serde(default)]
name: Option<String>,
#[serde(default)]
location: Option<RawLocation>,
#[serde(default)]
bridges: Option<RawBridges>,
}
#[derive(Deserialize)]
struct RawLocation {
#[serde(default)]
two_letter_iso_country_code: Option<String>,
}
#[derive(Deserialize)]
struct RawBridges {
#[serde(default)]
transports: Vec<RawTransport>,
}
impl RawBridges {
fn into_quic(self) -> Option<QuicBridge> {
self.transports
.into_iter()
.filter(|t| t.transport_type == "quic_plain")
.find_map(|t| {
let args = t.args?;
let addresses = args
.addresses
.iter()
.filter_map(|a| a.parse::<SocketAddr>().ok())
.collect::<Vec<_>>();
if addresses.is_empty() {
return None;
}
let id_pubkey_base64 = args.id_pubkey.trim().to_string();
if id_pubkey_base64.is_empty() {
return None;
}
let sni_host = args
.host
.map(|h| h.trim().to_string())
.filter(|h| !h.is_empty());
Some(QuicBridge {
addresses,
sni_host,
id_pubkey_base64,
})
})
}
}
#[derive(Deserialize)]
struct RawTransport {
transport_type: String,
#[serde(default)]
args: Option<RawQuicArgs>,
}
#[derive(Deserialize)]
struct RawQuicArgs {
#[serde(default)]
addresses: Vec<String>,
#[serde(default)]
host: Option<String>,
id_pubkey: String,
}
#[cfg(test)]
mod tests {
use super::*;
fn bridges(json: &str) -> RawBridges {
serde_json::from_str(json).expect("valid RawBridges json")
}
#[test]
fn into_quic_skips_broken_first_transport() {
let quic = bridges(
r#"{"transports":[
{"transport_type":"quic_plain","args":{"addresses":["1.2.3.4:443"],"host":"a","id_pubkey":""}},
{"transport_type":"quic_plain","args":{"addresses":["5.6.7.8:443"],"host":"b","id_pubkey":"PINKEY"}}
]}"#,
)
.into_quic()
.expect("a usable quic bridge exists");
assert_eq!(quic.id_pubkey_base64, "PINKEY");
assert_eq!(quic.addresses, vec!["5.6.7.8:443".parse().unwrap()]);
}
#[test]
fn into_quic_trims_id_pubkey_and_host() {
let quic = bridges(
r#"{"transports":[
{"transport_type":"quic_plain","args":{"addresses":["1.2.3.4:443"],"host":" sni.example ","id_pubkey":" PADDED "}}
]}"#,
)
.into_quic()
.expect("a usable quic bridge exists");
assert_eq!(quic.id_pubkey_base64, "PADDED");
assert_eq!(quic.sni_host.as_deref(), Some("sni.example"));
}
#[test]
fn into_quic_none_when_all_unusable() {
assert!(bridges(
r#"{"transports":[
{"transport_type":"quic_plain","args":{"addresses":[],"id_pubkey":"X"}},
{"transport_type":"quic_plain","args":{"addresses":["1.2.3.4:443"],"id_pubkey":" "}}
]}"#,
)
.into_quic()
.is_none());
}
#[test]
fn into_quic_ignores_non_quic_transports() {
assert!(bridges(
r#"{"transports":[{"transport_type":"other","args":{"addresses":["1.2.3.4:443"],"id_pubkey":"K"}}]}"#,
)
.into_quic()
.is_none());
}
}