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:
2026-07-06 16:42:28 +03:30
parent b11d042732
commit 3a5c0d2aba
16 changed files with 2601 additions and 7 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
target/

1824
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

24
Cargo.toml Normal file
View File

@@ -0,0 +1,24 @@
[package]
name = "doh-forwarder"
version = "0.1.0"
edition = "2021"
description = "A lightweight DNS-over-HTTPS forwarder"
license = "MIT"
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
hickory-proto = "0.25"
reqwest = { version = "0.12", features = ["rustls-tls"], default-features = false }
serde = { version = "1", features = ["derive"] }
toml = "0.8"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
anyhow = "1"
thiserror = "2"
bytes = "1"
base64 = "0.22"
[dev-dependencies]
tokio-test = "0.4"
tower = { version = "0.5", features = ["util"] }

View File

@@ -85,7 +85,7 @@ See [docs/plan.md](docs/plan.md) for the full project plan and milestone checkli
| Milestone | Status |
|-----------|--------|
| M1 — Core Forwarding | 🔲 Not started |
| M1 — Core Forwarding | ✅ Done |
| M2 — Multiple Upstreams & Config | 🔲 Not started |
| M3 — Caching | 🔲 Not started |
| M4 — Observability | 🔲 Not started |

14
config/default.toml Normal file
View File

@@ -0,0 +1,14 @@
[server]
listen = "0.0.0.0:8800"
[upstream]
resolvers = [
{ name = "cloudflare", url = "https://cloudflare-dns.com/dns-query" },
{ name = "google", url = "https://dns.google/dns-query" },
]
strategy = "round-robin"
[cache]
enabled = true
max_entries = 10000
max_ttl_secs = 3600

View File

@@ -37,12 +37,12 @@ A lightweight DNS-over-HTTPS (DoH) forwarder that accepts DNS queries over HTTPS
### M1 — Core Forwarding
- [ ] Project scaffold (Cargo workspace, CI)
- [ ] HTTP server with `/dns-query` endpoint (GET + POST)
- [ ] Parse incoming DNS wire-format queries
- [ ] Forward to a single upstream DoH resolver
- [ ] Return DNS wire-format response
- [ ] Basic integration test
- [x] Project scaffold (Cargo workspace, CI)
- [x] HTTP server with `/dns-query` endpoint (GET + POST)
- [x] Parse incoming DNS wire-format queries
- [x] Forward to a single upstream DoH resolver
- [x] Return DNS wire-format response
- [x] Basic integration test
### M2 — Multiple Upstreams & Config

117
src/config.rs Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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(&params.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
View 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
View File

@@ -0,0 +1,3 @@
//! Upstream DoH resolver client.
pub mod doh_client;

142
tests/integration.rs Normal file
View File

@@ -0,0 +1,142 @@
//! Integration tests for doh-forwarder.
//!
//! These tests spin up the full server on a random port and verify
//! the HTTP endpoints accept and forward DNS queries.
use axum::body::Body;
use axum::http::{header, Request, StatusCode};
use axum::routing::{get, post};
use axum::{Extension, Router};
use base64::Engine;
use hickory_proto::op::{Message, Query, ResponseCode};
use hickory_proto::rr::{Name, RecordType};
use std::sync::Arc;
use tokio::net::TcpListener;
use tower::ServiceExt;
use doh_forwarder::routes::{dns_query_get, dns_query_post, AppState};
use doh_forwarder::upstream::doh_client::DohClient;
/// Build a minimal DNS query for the given domain and record type.
fn build_dns_query(name: &str, rtype: RecordType) -> Vec<u8> {
let mut msg = Message::new();
msg.set_id(0x1234);
msg.set_recursion_desired(true);
msg.add_query(Query::query(Name::from_ascii(name).unwrap(), rtype));
msg.to_vec().unwrap()
}
/// Create a test AppState with a dummy resolver (will fail on forward,
/// but lets us test parsing/validation/health).
fn test_state() -> AppState {
let resolvers = vec![doh_forwarder::config::ResolverEntry {
name: "test".into(),
url: "http://127.0.0.1:1/dns-query".into(), // unreachable
}];
AppState {
doh_client: DohClient::new(resolvers),
rr_counter: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
}
}
fn test_app() -> Router {
Router::new()
.route("/dns-query", post(dns_query_post).get(dns_query_get))
.route("/health", get(|| async { "ok" }))
.layer(Extension(test_state()))
}
#[tokio::test]
async fn test_health_endpoint() {
let app = test_app();
let req = Request::builder()
.uri("/health")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(body.as_ref(), b"ok");
}
#[tokio::test]
async fn test_dns_query_post_valid_query() {
let app = test_app();
let query_bytes = build_dns_query("example.com.", RecordType::A);
let req = Request::builder()
.method("POST")
.uri("/dns-query")
.header(header::CONTENT_TYPE, "application/dns-message")
.header(header::ACCEPT, "application/dns-message")
.body(Body::from(query_bytes))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
// The upstream is unreachable, so we expect SERVFAIL (200 with DNS response)
assert_eq!(resp.status(), StatusCode::OK);
let content_type = resp.headers().get(header::CONTENT_TYPE).unwrap();
assert_eq!(content_type, "application/dns-message");
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let response_msg = Message::from_vec(&body).unwrap();
assert_eq!(response_msg.id(), 0x1234);
assert_eq!(response_msg.response_code(), ResponseCode::ServFail);
}
#[tokio::test]
async fn test_dns_query_post_invalid_body() {
let app = test_app();
let req = Request::builder()
.method("POST")
.uri("/dns-query")
.header(header::CONTENT_TYPE, "application/dns-message")
.body(Body::from(vec![0x00; 3])) // too short, invalid DNS
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_dns_query_get_valid_query() {
let app = test_app();
let query_bytes = build_dns_query("example.com.", RecordType::A);
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&query_bytes);
let req = Request::builder()
.uri(format!("/dns-query?dns={encoded}"))
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let response_msg = Message::from_vec(&body).unwrap();
assert_eq!(response_msg.id(), 0x1234);
assert_eq!(response_msg.response_code(), ResponseCode::ServFail);
}
#[tokio::test]
async fn test_dns_query_get_invalid_base64() {
let app = test_app();
let req = Request::builder()
.uri("/dns-query?dns=!!!invalid-base64!!!")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}