use std::{
net::{Ipv4Addr, Ipv6Addr},
time::Duration,
};
use url::Url;
use wasm_bindgen::{JsCast, JsValue};
use wasm_bindgen_futures::JsFuture;
use web_sys::{AbortSignal, Request, RequestInit, Response};
use crate::builder::dns_websys::web_context::WebContext;
pub const CLOUDFLARE: &str = "https://cloudflare-dns.com/dns-query";
pub const GOOGLE: &str = "https://dns.google/resolve";
const TYPE_A: u16 = 1;
const TYPE_AAAA: u16 = 28;
const TYPE_TXT: u16 = 16;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DnsResolution {
#[default]
Auto,
Always,
Never,
}
#[derive(Debug, Clone)]
pub struct Config {
endpoint: String,
dns_resolution: DnsResolution,
timeout: Duration,
}
impl Default for Config {
fn default() -> Self {
Self::cloudflare()
}
}
impl Config {
pub fn new(endpoint: impl Into<String>) -> Self {
Config {
endpoint: endpoint.into(),
dns_resolution: DnsResolution::default(),
timeout: DEFAULT_TIMEOUT,
}
}
pub fn cloudflare() -> Self {
Config::new(CLOUDFLARE)
}
pub fn google() -> Self {
Config::new(GOOGLE)
}
pub fn dns_resolution(mut self, policy: DnsResolution) -> Self {
self.dns_resolution = policy;
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
}
#[derive(Debug, Clone)]
pub(crate) struct Resolver {
config: Config,
}
impl Resolver {
pub(crate) fn new(config: Config) -> Self {
Resolver { config }
}
pub(crate) fn dns_resolution(&self) -> DnsResolution {
self.config.dns_resolution
}
pub(crate) async fn ipv4_lookup(&self, name: &str) -> Result<Vec<Ipv4Addr>, ResolveError> {
Ok(self
.query(name, TYPE_A)
.await?
.iter()
.filter_map(|d| d.parse::<Ipv4Addr>().ok())
.collect())
}
pub(crate) async fn ipv6_lookup(&self, name: &str) -> Result<Vec<Ipv6Addr>, ResolveError> {
Ok(self
.query(name, TYPE_AAAA)
.await?
.iter()
.filter_map(|d| d.parse::<Ipv6Addr>().ok())
.collect())
}
pub(crate) async fn txt_lookup(&self, name: &str) -> Result<Vec<String>, ResolveError> {
Ok(self
.query(name, TYPE_TXT)
.await?
.iter()
.map(|d| unquote_txt(d))
.collect())
}
async fn query(&self, name: &str, qtype: u16) -> Result<Vec<String>, ResolveError> {
let url = build_query_url(&self.config.endpoint, name, qtype)?;
let body = doh_get(url.as_str(), self.config.timeout).await?;
let response: DohResponse =
serde_json::from_str(&body).map_err(|e| ResolveError::Parse(e.to_string()))?;
if response.status != 0 {
return Err(ResolveError::Status(response.status));
}
Ok(response
.answer
.into_iter()
.filter(|a| a.kind == qtype)
.map(|a| a.data)
.collect())
}
}
#[derive(serde::Deserialize)]
struct DohResponse {
#[serde(rename = "Status")]
status: u32,
#[serde(default, rename = "Answer")]
answer: Vec<DohAnswer>,
}
#[derive(serde::Deserialize)]
struct DohAnswer {
#[serde(rename = "type")]
kind: u16,
data: String,
}
fn build_query_url(endpoint: &str, name: &str, qtype: u16) -> Result<Url, ResolveError> {
let mut url = Url::parse(endpoint).map_err(|e| ResolveError::Url(e.to_string()))?;
url.query_pairs_mut()
.append_pair("name", name)
.append_pair("type", &qtype.to_string());
Ok(url)
}
async fn doh_get(url: &str, timeout: Duration) -> Result<String, ResolveError> {
let opts = RequestInit::new();
let timeout_ms = timeout.as_millis().clamp(1, u32::MAX as u128) as u32;
opts.set_signal(Some(&AbortSignal::timeout_with_u32(timeout_ms)));
let request = Request::new_with_str_and_init(url, &opts).map_err(js_error)?;
request
.headers()
.set("accept", "application/dns-json")
.map_err(js_error)?;
let context = WebContext::new()
.ok_or_else(|| ResolveError::Fetch("no browser global scope available".to_owned()))?;
let response = JsFuture::from(context.fetch_with_request(&request))
.await
.map_err(js_error)?;
let response: Response = response
.dyn_into()
.map_err(|_| ResolveError::Fetch("fetch did not return a Response".to_owned()))?;
if !response.ok() {
return Err(ResolveError::Http(response.status()));
}
let text = JsFuture::from(response.text().map_err(js_error)?)
.await
.map_err(js_error)?;
text.as_string()
.ok_or_else(|| ResolveError::Fetch("response body was not a string".to_owned()))
}
fn unquote_txt(s: &str) -> String {
if !s.contains('"') {
return s.to_owned();
}
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c != '"' {
continue;
}
while let Some(c) = chars.next() {
match c {
'"' => break,
'\\' => match chars.next() {
Some(d) if d.is_ascii_digit() => {
let mut code = d.to_digit(10).unwrap();
for _ in 0..2 {
match chars.peek() {
Some(p) if p.is_ascii_digit() => {
code = code * 10 + chars.next().unwrap().to_digit(10).unwrap();
}
_ => break,
}
}
if let Some(ch) = char::from_u32(code) {
out.push(ch);
}
}
Some(other) => out.push(other),
None => {}
},
_ => out.push(c),
}
}
}
out
}
fn js_error(value: JsValue) -> ResolveError {
ResolveError::Fetch(format!("{value:?}"))
}
#[derive(Debug, Clone, thiserror::Error)]
pub enum ResolveError {
#[error("DNS-over-HTTPS request failed: {0}")]
Fetch(String),
#[error("DNS-over-HTTPS request returned HTTP status {0}")]
Http(u16),
#[error("DNS query failed with status {0}")]
Status(u32),
#[error("failed to parse DNS-over-HTTPS response: {0}")]
Parse(String),
#[error("invalid DNS-over-HTTPS endpoint URL: {0}")]
Url(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unquote_txt_single_segment_and_unquoted() {
assert_eq!(unquote_txt("\"dnsaddr=/dns4/foo\""), "dnsaddr=/dns4/foo");
assert_eq!(unquote_txt("dnsaddr=/dns4/foo"), "dnsaddr=/dns4/foo");
assert_eq!(unquote_txt("\"\""), "");
}
#[test]
fn unquote_txt_concatenates_multi_segment() {
assert_eq!(unquote_txt("\"part1\" \"part2\""), "part1part2");
assert_eq!(unquote_txt("\"part1\"\"part2\""), "part1part2");
assert_eq!(
unquote_txt("\"dnsaddr=/dns4/exa\" \"mple.com/tcp/443/wss\""),
"dnsaddr=/dns4/example.com/tcp/443/wss"
);
}
#[test]
fn unquote_txt_handles_escapes() {
assert_eq!(unquote_txt("\"a\\\"b\""), "a\"b"); assert_eq!(unquote_txt("\"a\\\\b\""), "a\\b"); assert_eq!(unquote_txt("\"\\097\""), "a"); }
#[test]
fn parses_doh_json() {
let body = r#"{"Status":0,"Answer":[
{"name":"example.com.","type":1,"TTL":60,"data":"1.2.3.4"},
{"name":"example.com.","type":5,"TTL":60,"data":"cname.example.com."}
]}"#;
let response: DohResponse = serde_json::from_str(body).unwrap();
assert_eq!(response.status, 0);
let a_records: Vec<_> = response
.answer
.into_iter()
.filter(|a| a.kind == TYPE_A)
.map(|a| a.data)
.collect();
assert_eq!(a_records, vec!["1.2.3.4".to_owned()]);
}
}