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:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
target/
|
||||||
1824
Cargo.lock
generated
Normal file
1824
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
Cargo.toml
Normal file
24
Cargo.toml
Normal 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"] }
|
||||||
@@ -85,7 +85,7 @@ See [docs/plan.md](docs/plan.md) for the full project plan and milestone checkli
|
|||||||
|
|
||||||
| Milestone | Status |
|
| Milestone | Status |
|
||||||
|-----------|--------|
|
|-----------|--------|
|
||||||
| M1 — Core Forwarding | 🔲 Not started |
|
| M1 — Core Forwarding | ✅ Done |
|
||||||
| M2 — Multiple Upstreams & Config | 🔲 Not started |
|
| M2 — Multiple Upstreams & Config | 🔲 Not started |
|
||||||
| M3 — Caching | 🔲 Not started |
|
| M3 — Caching | 🔲 Not started |
|
||||||
| M4 — Observability | 🔲 Not started |
|
| M4 — Observability | 🔲 Not started |
|
||||||
|
|||||||
14
config/default.toml
Normal file
14
config/default.toml
Normal 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
|
||||||
12
docs/plan.md
12
docs/plan.md
@@ -37,12 +37,12 @@ A lightweight DNS-over-HTTPS (DoH) forwarder that accepts DNS queries over HTTPS
|
|||||||
|
|
||||||
### M1 — Core Forwarding
|
### M1 — Core Forwarding
|
||||||
|
|
||||||
- [ ] Project scaffold (Cargo workspace, CI)
|
- [x] Project scaffold (Cargo workspace, CI)
|
||||||
- [ ] HTTP server with `/dns-query` endpoint (GET + POST)
|
- [x] HTTP server with `/dns-query` endpoint (GET + POST)
|
||||||
- [ ] Parse incoming DNS wire-format queries
|
- [x] Parse incoming DNS wire-format queries
|
||||||
- [ ] Forward to a single upstream DoH resolver
|
- [x] Forward to a single upstream DoH resolver
|
||||||
- [ ] Return DNS wire-format response
|
- [x] Return DNS wire-format response
|
||||||
- [ ] Basic integration test
|
- [x] Basic integration test
|
||||||
|
|
||||||
### M2 — Multiple Upstreams & Config
|
### M2 — Multiple Upstreams & Config
|
||||||
|
|
||||||
|
|||||||
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;
|
||||||
142
tests/integration.rs
Normal file
142
tests/integration.rs
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user