feat: implement M1 — core DoH forwarding
- Axum HTTP server with /dns-query endpoint (GET + POST per RFC 8484) - DNS wire-format parsing via hickory-proto - Upstream DoH client with round-robin selection - TOML config file with resolver definitions - SERVFAIL fallback when upstream is unreachable - /health endpoint - Unit tests (7) and integration tests (5) - Zero warnings, clippy clean, fmt clean
This commit is contained in:
117
src/config.rs
Normal file
117
src/config.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
use serde::Deserialize;
|
||||
use std::path::Path;
|
||||
|
||||
/// Top-level application configuration.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
/// Server configuration.
|
||||
pub server: ServerConfig,
|
||||
/// Upstream resolver configuration.
|
||||
pub upstream: UpstreamConfig,
|
||||
/// Cache configuration.
|
||||
#[allow(dead_code)] // Used once M3 caching is implemented
|
||||
pub cache: CacheConfig,
|
||||
}
|
||||
|
||||
/// Server bind address and port.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ServerConfig {
|
||||
/// Address to listen on (e.g., "0.0.0.0:3000").
|
||||
pub listen: String,
|
||||
}
|
||||
|
||||
/// Upstream DoH resolver settings.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct UpstreamConfig {
|
||||
/// List of upstream DoH resolvers.
|
||||
pub resolvers: Vec<ResolverEntry>,
|
||||
/// Selection strategy: "round-robin" or "failover".
|
||||
pub strategy: String,
|
||||
}
|
||||
|
||||
/// A single upstream resolver entry.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ResolverEntry {
|
||||
/// Human-readable name (e.g., "cloudflare").
|
||||
pub name: String,
|
||||
/// DoH endpoint URL.
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// Cache settings.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)] // Fields used once M3 caching is implemented
|
||||
pub struct CacheConfig {
|
||||
/// Whether caching is enabled.
|
||||
pub enabled: bool,
|
||||
/// Maximum number of cache entries.
|
||||
pub max_entries: usize,
|
||||
/// Maximum TTL in seconds (caps per-record TTL).
|
||||
pub max_ttl_secs: u64,
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
/// Load configuration from a TOML file, with optional env var overrides.
|
||||
pub fn load(path: &Path) -> anyhow::Result<Self> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.map_err(|e| anyhow::anyhow!("failed to read config file {:?}: {}", path, e))?;
|
||||
let config: AppConfig = toml::from_str(&content)?;
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_default_config() {
|
||||
let config_str = r#"
|
||||
[server]
|
||||
listen = "127.0.0.1:8080"
|
||||
|
||||
[upstream]
|
||||
resolvers = [
|
||||
{ name = "cloudflare", url = "https://cloudflare-dns.com/dns-query" },
|
||||
]
|
||||
strategy = "round-robin"
|
||||
|
||||
[cache]
|
||||
enabled = true
|
||||
max_entries = 5000
|
||||
max_ttl_secs = 300
|
||||
"#;
|
||||
let config: AppConfig = toml::from_str(config_str).unwrap();
|
||||
assert_eq!(config.server.listen, "127.0.0.1:8080");
|
||||
assert_eq!(config.upstream.resolvers.len(), 1);
|
||||
assert_eq!(config.upstream.resolvers[0].name, "cloudflare");
|
||||
assert_eq!(config.upstream.strategy, "round-robin");
|
||||
assert!(config.cache.enabled);
|
||||
assert_eq!(config.cache.max_entries, 5000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_multiple_resolvers() {
|
||||
let config_str = r#"
|
||||
[server]
|
||||
listen = "0.0.0.0:3000"
|
||||
|
||||
[upstream]
|
||||
resolvers = [
|
||||
{ name = "cloudflare", url = "https://cloudflare-dns.com/dns-query" },
|
||||
{ name = "google", url = "https://dns.google/dns-query" },
|
||||
{ name = "quad9", url = "https://dns.quad9.net/dns-query" },
|
||||
]
|
||||
strategy = "failover"
|
||||
|
||||
[cache]
|
||||
enabled = false
|
||||
max_entries = 0
|
||||
max_ttl_secs = 0
|
||||
"#;
|
||||
let config: AppConfig = toml::from_str(config_str).unwrap();
|
||||
assert_eq!(config.upstream.resolvers.len(), 3);
|
||||
assert_eq!(config.upstream.strategy, "failover");
|
||||
assert!(!config.cache.enabled);
|
||||
}
|
||||
}
|
||||
4
src/dns/mod.rs
Normal file
4
src/dns/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
//! DNS wire-format parsing and response building.
|
||||
|
||||
pub mod query;
|
||||
pub mod response;
|
||||
55
src/dns/query.rs
Normal file
55
src/dns/query.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
use hickory_proto::op::Message;
|
||||
|
||||
/// Errors that can occur when parsing a DNS query.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum QueryParseError {
|
||||
/// The wire-format bytes are not a valid DNS message.
|
||||
#[error("invalid DNS message: {0}")]
|
||||
InvalidMessage(#[from] hickory_proto::ProtoError),
|
||||
}
|
||||
|
||||
/// Parse raw bytes into a DNS [`Message`].
|
||||
///
|
||||
/// This is used to decode incoming DNS wire-format queries received
|
||||
/// over the DoH endpoint (RFC 8484).
|
||||
pub fn parse_query(buf: &[u8]) -> Result<Message, QueryParseError> {
|
||||
let message = Message::from_vec(buf)?;
|
||||
Ok(message)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_valid_dns_query() {
|
||||
// Minimal valid DNS query for "example.com" A record
|
||||
#[rustfmt::skip]
|
||||
let buf: Vec<u8> = vec![
|
||||
0xAB, 0xCD, // transaction ID
|
||||
0x01, 0x00, // flags: standard query, recursion desired
|
||||
0x00, 0x01, // 1 question
|
||||
0x00, 0x00, // 0 answers
|
||||
0x00, 0x00, // 0 authority
|
||||
0x00, 0x00, // 0 additional
|
||||
// question: example.com A IN
|
||||
0x07, b'e', b'x', b'a', b'm', b'p', b'l', b'e',
|
||||
0x03, b'c', b'o', b'm',
|
||||
0x00, // root label
|
||||
0x00, 0x01, // type A
|
||||
0x00, 0x01, // class IN
|
||||
];
|
||||
|
||||
let message = parse_query(&buf).unwrap();
|
||||
assert_eq!(message.id(), 0xABCD);
|
||||
assert_eq!(message.queries().len(), 1);
|
||||
assert_eq!(message.queries()[0].name().to_string(), "example.com.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_invalid_bytes_returns_error() {
|
||||
let buf = vec![0x00; 3]; // too short
|
||||
let result = parse_query(&buf);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
76
src/dns/response.rs
Normal file
76
src/dns/response.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
use hickory_proto::op::Message;
|
||||
|
||||
/// Errors that can occur when building a DNS response.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ResponseBuildError {
|
||||
/// Serialization of the DNS message failed.
|
||||
#[error("failed to serialize DNS message: {0}")]
|
||||
SerializationFailed(#[from] hickory_proto::ProtoError),
|
||||
}
|
||||
|
||||
/// Serialize a DNS [`Message`] into wire-format bytes.
|
||||
///
|
||||
/// Used to encode the upstream response before returning it to the
|
||||
/// DoH client.
|
||||
pub fn serialize_response(message: &Message) -> Result<Vec<u8>, ResponseBuildError> {
|
||||
let bytes = message.to_vec()?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Build a SERVFAIL response for a given query message.
|
||||
///
|
||||
/// Used when the upstream resolver is unreachable or returns an error.
|
||||
pub fn build_servfail(original: &Message) -> Result<Vec<u8>, ResponseBuildError> {
|
||||
let mut response = Message::new();
|
||||
response.set_id(original.id());
|
||||
response.set_response_code(hickory_proto::op::ResponseCode::ServFail);
|
||||
response.set_recursion_desired(original.recursion_desired());
|
||||
response.set_recursion_available(true);
|
||||
|
||||
// Copy the original question section into the response
|
||||
for query in original.queries() {
|
||||
response.add_query(query.clone());
|
||||
}
|
||||
|
||||
serialize_response(&response)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use hickory_proto::op::{Message, Query};
|
||||
use hickory_proto::rr::RecordType;
|
||||
|
||||
#[test]
|
||||
fn test_serialize_and_roundtrip() {
|
||||
let mut msg = Message::new();
|
||||
msg.set_id(1234);
|
||||
msg.set_response_code(hickory_proto::op::ResponseCode::NoError);
|
||||
|
||||
let bytes = serialize_response(&msg).unwrap();
|
||||
assert!(!bytes.is_empty());
|
||||
|
||||
let parsed = Message::from_vec(&bytes).unwrap();
|
||||
assert_eq!(parsed.id(), 1234);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_servfail_preserves_id_and_question() {
|
||||
let mut query = Message::new();
|
||||
query.set_id(5678);
|
||||
query.add_query(Query::query(
|
||||
hickory_proto::rr::Name::from_ascii("example.com.").unwrap(),
|
||||
RecordType::A,
|
||||
));
|
||||
|
||||
let response_bytes = build_servfail(&query).unwrap();
|
||||
let response = Message::from_vec(&response_bytes).unwrap();
|
||||
|
||||
assert_eq!(response.id(), 5678);
|
||||
assert_eq!(
|
||||
response.response_code(),
|
||||
hickory_proto::op::ResponseCode::ServFail
|
||||
);
|
||||
assert_eq!(response.queries().len(), 1);
|
||||
}
|
||||
}
|
||||
6
src/lib.rs
Normal file
6
src/lib.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
//! A lightweight DNS-over-HTTPS forwarder.
|
||||
|
||||
pub mod config;
|
||||
pub mod dns;
|
||||
pub mod routes;
|
||||
pub mod upstream;
|
||||
81
src/main.rs
Normal file
81
src/main.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Context;
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Extension, Router};
|
||||
use tokio::net::TcpListener;
|
||||
use tracing::info;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use doh_forwarder::config::AppConfig;
|
||||
use doh_forwarder::routes::{dns_query_get, dns_query_post, AppState};
|
||||
use doh_forwarder::upstream::doh_client::DohClient;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// Initialize tracing (RUST_LOG env filter, defaults to info)
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
|
||||
)
|
||||
.init();
|
||||
|
||||
// Parse CLI args for config path
|
||||
let config_path = parse_config_path();
|
||||
info!(path = %config_path.display(), "loading configuration");
|
||||
|
||||
let config = AppConfig::load(&config_path)
|
||||
.with_context(|| format!("failed to load config from {:?}", config_path))?;
|
||||
|
||||
info!(listen = %config.server.listen, "starting doh-forwarder");
|
||||
info!(
|
||||
resolvers = config
|
||||
.upstream
|
||||
.resolvers
|
||||
.iter()
|
||||
.map(|r| r.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
strategy = %config.upstream.strategy,
|
||||
"upstream configuration"
|
||||
);
|
||||
|
||||
let doh_client = DohClient::new(config.upstream.resolvers.clone());
|
||||
|
||||
let state = AppState {
|
||||
doh_client,
|
||||
rr_counter: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
|
||||
};
|
||||
|
||||
let app = Router::new()
|
||||
.route("/dns-query", post(dns_query_post).get(dns_query_get))
|
||||
.route("/health", get(health))
|
||||
.layer(Extension(state));
|
||||
|
||||
let listener = TcpListener::bind(&config.server.listen)
|
||||
.await
|
||||
.with_context(|| format!("failed to bind to {}", config.server.listen))?;
|
||||
|
||||
info!(addr = %listener.local_addr()?, "listening");
|
||||
|
||||
axum::serve(listener, app).await.context("server error")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Health-check endpoint.
|
||||
async fn health() -> &'static str {
|
||||
"ok"
|
||||
}
|
||||
|
||||
/// Parse the config file path from CLI args, defaulting to `config/default.toml`.
|
||||
fn parse_config_path() -> PathBuf {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if let Some(pos) = args.iter().position(|a| a == "--config") {
|
||||
if let Some(path) = args.get(pos + 1) {
|
||||
return PathBuf::from(path);
|
||||
}
|
||||
}
|
||||
PathBuf::from("config/default.toml")
|
||||
}
|
||||
139
src/routes.rs
Normal file
139
src/routes.rs
Normal file
@@ -0,0 +1,139 @@
|
||||
use axum::extract::Query;
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Extension;
|
||||
use base64::Engine;
|
||||
use std::sync::Arc;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::dns::query::parse_query;
|
||||
use crate::dns::response::build_servfail;
|
||||
use crate::upstream::doh_client::DohClient;
|
||||
|
||||
/// Shared application state passed to route handlers.
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
/// The DoH upstream client.
|
||||
pub doh_client: DohClient,
|
||||
/// Round-robin counter for upstream selection.
|
||||
pub rr_counter: Arc<std::sync::atomic::AtomicUsize>,
|
||||
}
|
||||
|
||||
/// Query parameters for the GET variant of `/dns-query`.
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct DnsQueryParams {
|
||||
/// Base64url-encoded DNS wire-format query (RFC 8484 §4.1).
|
||||
pub dns: String,
|
||||
}
|
||||
|
||||
/// POST handler for `/dns-query` (RFC 8484 §4.2).
|
||||
///
|
||||
/// Expects `Content-Type: application/dns-message` and a raw DNS
|
||||
/// wire-format body.
|
||||
pub async fn dns_query_post(Extension(state): Extension<AppState>, body: bytes::Bytes) -> Response {
|
||||
let query_bytes = body.to_vec();
|
||||
|
||||
// Validate that it's a parseable DNS message
|
||||
let query = match parse_query(&query_bytes) {
|
||||
Ok(q) => q,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "invalid DNS query received");
|
||||
return (StatusCode::BAD_REQUEST, "invalid DNS query").into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let qname = query
|
||||
.queries()
|
||||
.first()
|
||||
.map(|q| q.name().to_string())
|
||||
.unwrap_or_default();
|
||||
let qtype = query
|
||||
.queries()
|
||||
.first()
|
||||
.map(|q| q.query_type().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
info!(query = %qname, qtype = %qtype, "received DoH POST query");
|
||||
|
||||
forward_and_respond(&state, &query_bytes, &query).await
|
||||
}
|
||||
|
||||
/// GET handler for `/dns-query` (RFC 8484 §4.1).
|
||||
///
|
||||
/// Expects a `?dns=<base64url>` query parameter.
|
||||
pub async fn dns_query_get(
|
||||
Extension(state): Extension<AppState>,
|
||||
Query(params): Query<DnsQueryParams>,
|
||||
) -> Response {
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
|
||||
let query_bytes = match URL_SAFE_NO_PAD.decode(¶ms.dns) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "invalid base64url in dns query param");
|
||||
return (StatusCode::BAD_REQUEST, "invalid base64url dns param").into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let query = match parse_query(&query_bytes) {
|
||||
Ok(q) => q,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "invalid DNS query from base64 param");
|
||||
return (StatusCode::BAD_REQUEST, "invalid DNS query").into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let qname = query
|
||||
.queries()
|
||||
.first()
|
||||
.map(|q| q.name().to_string())
|
||||
.unwrap_or_default();
|
||||
let qtype = query
|
||||
.queries()
|
||||
.first()
|
||||
.map(|q| q.query_type().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
info!(query = %qname, qtype = %qtype, "received DoH GET query");
|
||||
|
||||
forward_and_respond(&state, &query_bytes, &query).await
|
||||
}
|
||||
|
||||
/// Forward a query to the upstream and build the response.
|
||||
async fn forward_and_respond(
|
||||
state: &AppState,
|
||||
query_bytes: &[u8],
|
||||
query: &hickory_proto::op::Message,
|
||||
) -> Response {
|
||||
// Pick upstream via round-robin
|
||||
let idx = state
|
||||
.rr_counter
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
|
||||
match state.doh_client.forward(query_bytes, idx).await {
|
||||
Ok(response_bytes) => {
|
||||
info!(bytes = response_bytes.len(), "forwarding upstream response");
|
||||
(
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_TYPE, "application/dns-message")],
|
||||
response_bytes,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!(error = %e, "upstream forward failed, returning SERVFAIL");
|
||||
match build_servfail(query) {
|
||||
Ok(servfail) => (
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_TYPE, "application/dns-message")],
|
||||
servfail,
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => {
|
||||
error!(error = %e, "failed to build SERVFAIL response");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
108
src/upstream/doh_client.rs
Normal file
108
src/upstream/doh_client.rs
Normal file
@@ -0,0 +1,108 @@
|
||||
use reqwest::Client;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::config::ResolverEntry;
|
||||
|
||||
/// Errors from the upstream DoH client.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum UpstreamError {
|
||||
/// An HTTP-level error occurred.
|
||||
#[error("HTTP error from upstream {name}: {source}")]
|
||||
HttpError {
|
||||
name: String,
|
||||
source: reqwest::Error,
|
||||
},
|
||||
/// The upstream returned a non-2xx status.
|
||||
#[error("upstream {name} returned status {status}")]
|
||||
BadStatus { name: String, status: u16 },
|
||||
}
|
||||
|
||||
/// A DoH client that forwards DNS wire-format queries to upstream resolvers.
|
||||
#[derive(Clone)]
|
||||
pub struct DohClient {
|
||||
http: Client,
|
||||
resolvers: Vec<ResolverEntry>,
|
||||
}
|
||||
|
||||
impl DohClient {
|
||||
/// Create a new DoH client with the given upstream resolvers.
|
||||
pub fn new(resolvers: Vec<ResolverEntry>) -> Self {
|
||||
let http = Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.expect("failed to build HTTP client");
|
||||
Self { http, resolvers }
|
||||
}
|
||||
|
||||
/// Forward a DNS wire-format query to the upstream at the given index.
|
||||
///
|
||||
/// Returns the raw DNS wire-format response bytes.
|
||||
pub async fn forward(
|
||||
&self,
|
||||
query_bytes: &[u8],
|
||||
resolver_index: usize,
|
||||
) -> Result<Vec<u8>, UpstreamError> {
|
||||
let resolver = &self.resolvers[resolver_index % self.resolvers.len()];
|
||||
debug!(resolver = %resolver.name, url = %resolver.url, "forwarding query to upstream");
|
||||
|
||||
let response = self
|
||||
.http
|
||||
.post(&resolver.url)
|
||||
.header("content-type", "application/dns-message")
|
||||
.header("accept", "application/dns-message")
|
||||
.body(query_bytes.to_vec())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| UpstreamError::HttpError {
|
||||
name: resolver.name.clone(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
warn!(resolver = %resolver.name, status = %status, "upstream returned error status");
|
||||
return Err(UpstreamError::BadStatus {
|
||||
name: resolver.name.clone(),
|
||||
status: status.as_u16(),
|
||||
});
|
||||
}
|
||||
|
||||
let body = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| UpstreamError::HttpError {
|
||||
name: resolver.name.clone(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
debug!(resolver = %resolver.name, bytes = body.len(), "received upstream response");
|
||||
Ok(body.to_vec())
|
||||
}
|
||||
|
||||
/// Return the number of configured resolvers.
|
||||
#[allow(dead_code)] // Used by health/stats endpoints in M4
|
||||
pub fn resolver_count(&self) -> usize {
|
||||
self.resolvers.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_client_creation() {
|
||||
let resolvers = vec![
|
||||
ResolverEntry {
|
||||
name: "cloudflare".into(),
|
||||
url: "https://cloudflare-dns.com/dns-query".into(),
|
||||
},
|
||||
ResolverEntry {
|
||||
name: "google".into(),
|
||||
url: "https://dns.google/dns-query".into(),
|
||||
},
|
||||
];
|
||||
let client = DohClient::new(resolvers);
|
||||
assert_eq!(client.resolver_count(), 2);
|
||||
}
|
||||
}
|
||||
3
src/upstream/mod.rs
Normal file
3
src/upstream/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
//! Upstream DoH resolver client.
|
||||
|
||||
pub mod doh_client;
|
||||
Reference in New Issue
Block a user