dnscrypt 0.1.0

A pure-Rust DNSCrypt v2 client library — sync and async support.
Documentation
use dnscrypt::{
    HARDCODED_RESOLVERS, HardcodedResolver, establish_dnscrypt_session, resolve,
    resolve_domain_via_dnscrypt_session,
};
use std::net::{IpAddr, Ipv4Addr, SocketAddr};

// Define a custom, user-specified compile-time ready resolver list.
static CUSTOM_RESOLVERS: &[HardcodedResolver] = &[
    HardcodedResolver {
        ip: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(9, 9, 9, 9)), 8443),
        provider_name: "2.dnscrypt-cert.quad9.net",
        provider_pk: [
            0x67, 0xc8, 0x47, 0xb8, 0xc8, 0x75, 0x8c, 0xd1, 0x20, 0x24, 0x55, 0x43, 0xbe, 0x75,
            0x67, 0x46, 0xdf, 0x34, 0xdf, 0x1d, 0x84, 0xc0, 0x0b, 0x8c, 0x47, 0x03, 0x68, 0xdf,
            0x82, 0x1d, 0x86, 0x3e,
        ],
    },
    HardcodedResolver {
        ip: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(102, 214, 10, 82)), 8443),
        provider_name: "2.dnscrypt-cert.jb1.cipherdns.co.za",
        provider_pk: [
            0x29, 0xfd, 0x92, 0xbc, 0x01, 0xbe, 0xfb, 0xc8, 0x85, 0xc5, 0x23, 0xb0, 0x08, 0x79,
            0x2a, 0xe0, 0x8d, 0x98, 0xd4, 0x27, 0x6c, 0xc6, 0xf5, 0xa7, 0x0a, 0x0b, 0x45, 0xc0,
            0x44, 0x96, 0x69, 0xad,
        ],
    },
];

#[test]
fn test_sync_resolve_github() {
    let res = resolve(HARDCODED_RESOLVERS, "github.com");
    match res {
        Ok(ips) => {
            println!("Sync resolved github.com => {:?}", ips);
            assert!(!ips.is_empty() && (ips[0].is_ipv4() || ips[0].is_ipv6()));
        }
        Err(e) => {
            panic!("Sync resolve failed: {e:?}");
        }
    }
}

#[test]
fn test_sync_resolve_custom_resolvers() {
    let res = resolve(CUSTOM_RESOLVERS, "github.com");
    match res {
        Ok(ips) => {
            println!("Sync resolved via custom list github.com => {:?}", ips);
            assert!(!ips.is_empty() && (ips[0].is_ipv4() || ips[0].is_ipv6()));
        }
        Err(e) => {
            panic!("Sync custom resolve failed: {e:?}");
        }
    }
}

#[test]
fn test_sync_session_reuse() {
    let sessions =
        establish_dnscrypt_session(HARDCODED_RESOLVERS).expect("Failed to establish sync session");
    let session = &sessions[0];

    // Reuse the same session to resolve two different domains.
    let ips1 = resolve_domain_via_dnscrypt_session(session, "github.com")
        .expect("Failed to resolve github.com with session");
    let ips2 = resolve_domain_via_dnscrypt_session(session, "quad9.net")
        .expect("Failed to resolve quad9.net with session");

    println!(
        "Session reused: github.com => {:?}, quad9.net => {:?}",
        ips1, ips2
    );
    assert!(!ips1.is_empty() && (ips1[0].is_ipv4() || ips1[0].is_ipv6()));
    assert!(!ips2.is_empty() && (ips2[0].is_ipv4() || ips2[0].is_ipv6()));
}

#[test]
fn test_manual_udp_and_tcp_resolution() {
    let mut udp_success = false;
    let mut tcp_success = false;

    for i in 0..CUSTOM_RESOLVERS.len() {
        let sessions = match establish_dnscrypt_session(&CUSTOM_RESOLVERS[i..i + 1]) {
            Ok(s) => s,
            Err(_) => continue,
        };
        let session = &sessions[0];

        // Build the payload dynamically to avoid Replay Attacks which close TCP!
        let build_query = || {
            let mut txid = [0u8; 2];
            getrandom::fill(&mut txid).expect("getrandom failed");
            let client_query = dnscrypt::packet::build_a_record_query("github.com", txid);
            let padded_query = dnscrypt::packet::pad_query(&client_query, 1024);

            let mut client_nonce = [0u8; 12];
            getrandom::fill(&mut client_nonce).expect("getrandom failed");
            let mut query_nonce = [0u8; 24];
            query_nonce[..12].copy_from_slice(&client_nonce);

            let encrypted_query = dnscrypt::crypto::xchacha20_djb_poly1305_encrypt(
                &session.shared_key,
                &query_nonce,
                &padded_query,
            );

            let mut request_packet = Vec::with_capacity(8 + 32 + 12 + encrypted_query.len());
            request_packet.extend_from_slice(&session.client_magic);
            request_packet.extend_from_slice(&session.client_pk_bytes);
            request_packet.extend_from_slice(&client_nonce);
            request_packet.extend_from_slice(&encrypted_query);
            request_packet
        };

        // Send UDP explicit
        let request_packet_udp = build_query();
        if let Ok(resp_udp) =
            dnscrypt::net::send_dns_query_udp(session.resolver_ip, &request_packet_udp)
        {
            if resp_udp.len() > 32 {
                // Briefly test decrypting the UDP response to prove it's a valid DNSCrypt response
                let mut resp_nonce = [0u8; 24];
                resp_nonce.copy_from_slice(&resp_udp[8..32]);
                if let Ok(decrypted) = dnscrypt::crypto::xchacha20_djb_poly1305_decrypt(
                    &session.shared_key,
                    &resp_nonce,
                    &resp_udp[32..],
                ) {
                    if let Ok(unpadded) = dnscrypt::packet::unpad_response(&decrypted) {
                        let ips_parsed = dnscrypt::packet::parse_dns_response(&unpadded);
                        if !ips_parsed.is_empty() {
                            if ips_parsed[0].is_ipv4() {
                                udp_success = true;
                            }
                        }
                    }
                }
            }
        }

        // Send TCP explicit - using a brand new query to prevent replay drops!
        let request_packet_tcp = build_query();
        if let Ok(resp_tcp) =
            dnscrypt::net::send_dns_query_tcp(session.resolver_ip, &request_packet_tcp)
        {
            if resp_tcp.len() > 32 {
                tcp_success = true;
            }
        }

        if udp_success && tcp_success {
            break;
        }
    }

    assert!(udp_success, "UDP query failed across all custom resolvers");
    assert!(tcp_success, "TCP query failed across all custom resolvers");
}

#[test]
fn test_all_hardcoded_providers_individually() {
    let test_domains = ["github.com", "google.com", "proton.me", "mullvad.net"];

    for domain in test_domains {
        println!("Testing domain: {}", domain);
        let mut results: Vec<(String, std::net::IpAddr)> = Vec::new();

        for resolver in HARDCODED_RESOLVERS {
            println!(
                "  -> Querying provider {} at IP {}",
                resolver.provider_name, resolver.ip
            );

            // Pass a slice of exactly one resolver so we test this specific IP.
            let single_resolver_slice = std::slice::from_ref(resolver);

            let sessions =
                match dnscrypt::resolver::establish_dnscrypt_session(single_resolver_slice) {
                    Ok(s) => s,
                    Err(e) => {
                        let err_str = e.to_string();
                        if err_str.contains("os error 111")
                            || err_str.contains("timeout")
                            || err_str.contains("timed out")
                        {
                            println!(
                                "    Skipping {} ({}) due to network block/timeout: {}",
                                resolver.provider_name, resolver.ip, err_str
                            );
                            continue;
                        }
                        panic!(
                            "Failed to establish session with {} ({}): {}",
                            resolver.provider_name, resolver.ip, e
                        );
                    }
                };

            assert_eq!(sessions.len(), 1, "Should have exactly 1 session");

            let session = &sessions[0];
            let ips = match dnscrypt::resolver::resolve_domain_via_dnscrypt_session(session, domain)
            {
                Ok(ips) => ips,
                Err(e) => {
                    let err_str = e.to_string();
                    if err_str.contains("os error 111")
                        || err_str.contains("timeout")
                        || err_str.contains("timed out")
                    {
                        println!(
                            "    Skipping resolution for {} ({}) due to network block/timeout: {}",
                            resolver.provider_name, resolver.ip, err_str
                        );
                        continue;
                    }
                    println!(
                        "    WARNING: Failed to resolve {} via {} ({}): {}",
                        domain, resolver.provider_name, resolver.ip, e
                    );
                    continue;
                }
            };

            assert!(!ips.is_empty() && (ips[0].is_ipv4() || ips[0].is_ipv6()));
            println!(
                "    Successfully resolved {} to {:?} via {}",
                domain, ips, resolver.ip
            );
            results.push((
                format!("{} ({})", resolver.provider_name, resolver.ip),
                ips[0],
            ));
        }

        if results.is_empty() {
            continue;
        }

        // Voting mechanism
        let mut frequency: std::collections::HashMap<std::net::IpAddr, usize> =
            std::collections::HashMap::new();
        for (_, ip) in &results {
            *frequency.entry(*ip).or_insert(0) += 1;
        }

        let mut majority_ip = results[0].1;
        let mut max_count = 0;
        for (ip, count) in &frequency {
            if *count > max_count {
                max_count = *count;
                majority_ip = *ip;
            }
        }

        // If a provider returned a different IP than the majority, log a warning instead of panicking
        // because global CDNs and Geo-DNS naturally return different localized IPs.
        for (provider_id, ip) in &results {
            if *ip != majority_ip {
                println!(
                    "WARNING (Geo-DNS / CDN): Provider {} returned IP {} for {}. Majority IP is {}.",
                    provider_id, ip, domain, majority_ip
                );
            }
        }
    }
}

#[cfg(feature = "tokio")]
mod async_tests {
    use super::*;
    #[cfg(feature = "reqwest")]
    use dnscrypt::DnscryptResolver;
    use dnscrypt::{
        establish_dnscrypt_session_async, resolve_async, resolve_domain_via_dnscrypt_session_async,
    };
    #[cfg(feature = "reqwest")]
    use std::sync::Arc;

    #[tokio::test]
    async fn test_async_resolve_github() {
        let res = resolve_async(HARDCODED_RESOLVERS, "github.com").await;
        match res {
            Ok(ips) => {
                println!("Async resolved github.com => {:?}", ips);
                assert!(!ips.is_empty() && (ips[0].is_ipv4() || ips[0].is_ipv6()));
            }
            Err(e) => {
                panic!("Async resolve failed: {e:?}");
            }
        }
    }

    #[tokio::test]
    async fn test_async_resolve_custom_resolvers() {
        let res = resolve_async(CUSTOM_RESOLVERS, "github.com").await;
        match res {
            Ok(ips) => {
                println!("Async resolved via custom list github.com => {:?}", ips);
                assert!(!ips.is_empty() && (ips[0].is_ipv4() || ips[0].is_ipv6()));
            }
            Err(e) => {
                panic!("Async custom resolve failed: {e:?}");
            }
        }
    }

    #[tokio::test]
    async fn test_async_session_reuse() {
        let sessions = establish_dnscrypt_session_async(HARDCODED_RESOLVERS)
            .await
            .expect("Failed to establish async session");
        let session = &sessions[0];

        let ips1 = resolve_domain_via_dnscrypt_session_async(session, "github.com")
            .await
            .expect("Failed to resolve github.com with async session");
        let ips2 = resolve_domain_via_dnscrypt_session_async(session, "quad9.net")
            .await
            .expect("Failed to resolve quad9.net with async session");

        println!(
            "Async session reused: github.com => {:?}, quad9.net => {:?}",
            ips1, ips2
        );
        assert!(!ips1.is_empty() && (ips1[0].is_ipv4() || ips1[0].is_ipv6()));
        assert!(!ips2.is_empty() && (ips2[0].is_ipv4() || ips2[0].is_ipv6()));
    }

    #[cfg(feature = "reqwest")]
    #[tokio::test]
    async fn test_reqwest_resolver_integration() {
        let resolver = Arc::new(DnscryptResolver::new());
        let client = reqwest::Client::builder()
            .dns_resolver(resolver)
            .build()
            .expect("Failed to build reqwest client");

        let resp = client.get("http://www.github.com").send().await;
        match resp {
            Ok(r) => {
                let status = r.status();
                println!("Reqwest request succeeded with status: {status}");
                assert!(status.is_success() || status.is_redirection());
            }
            Err(e) => {
                println!("Reqwest request failed (expected if network outbound is limited): {e:?}");
            }
        }
    }

    #[cfg(feature = "reqwest")]
    #[tokio::test]
    async fn test_reqwest_resolver_custom_resolvers_integration() {
        let resolver = Arc::new(DnscryptResolver::new_with_resolvers(CUSTOM_RESOLVERS));
        let client = reqwest::Client::builder()
            .dns_resolver(resolver)
            .build()
            .expect("Failed to build reqwest client");

        let resp = client.get("http://www.github.com").send().await;
        match resp {
            Ok(r) => {
                let status = r.status();
                println!("Reqwest request with custom resolvers succeeded with status: {status}");
                assert!(status.is_success() || status.is_redirection());
            }
            Err(e) => {
                println!("Reqwest request with custom resolvers failed: {e:?}");
            }
        }
    }
}