diff --git a/Cargo.toml b/Cargo.toml index a52e33c..2b536db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ readme = "README.md" [features] default = ["tls"] tls = ["libdoh/tls"] +odoh-proxy = ["libdoh/odoh-proxy"] [dependencies] libdoh = { path = "src/libdoh", version = "0.9.0", default-features = false } diff --git a/README.md b/README.md index bb8cd5b..ae58279 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,12 @@ cargo install doh-proxy cargo install doh-proxy --no-default-features ``` +* With Oblivious DoH Proxy function (**for testing pupose**): + +```sh +cargo install doh-proxy --features=doh-proxy +``` + ## Usage ```text @@ -33,7 +39,7 @@ USAGE: doh-proxy [FLAGS] [OPTIONS] FLAGS: - -O, --allow-odoh-post Allow POST queries over ODoH even if they have been disabed for DoH + -O, --allow-odoh-post Allow POST queries over ODoH even if they have been disabled for DoH -K, --disable-keepalive Disable keepalive -P, --disable-post Disable POST queries -h, --help Prints help information @@ -48,6 +54,7 @@ OPTIONS: -C, --max-concurrent Maximum number of concurrent requests per client [default: 16] -X, --max-ttl Maximum TTL, in seconds [default: 604800] -T, --min-ttl Minimum TTL, in seconds [default: 10] + -q, --odoh-proxy-path ODoH proxy URI path [default: /proxy] -p, --path URI path [default: /dns-query] -g, --public-address External IP address DoH clients will connect to -j, --public-port External port DoH clients will connect to, if not 443 @@ -113,11 +120,13 @@ Unless the front-end is a CDN, an ideal setup is to use `doh-proxy` behind `Encr Oblivious DoH is similar to Anonymized DNSCrypt, but for DoH. It requires relays, but also upstream DoH servers that support the protocol. -This proxy supports ODoH termination (not relaying) out of the box. +This proxy supports ODoH termination out of the box. However, ephemeral keys are currently only stored in memory. In a load-balanced configuration, sticky sessions must be used. -Currently available ODoH relays only use `POST` queries. +This also also provides ODoH relaying (Oblivious Proxy) of naive implementation, which is **for testing purposes only**. Please do not deploy the relaying function AS-IS. You need to carefully consider the performance and security issues when you deploy ODoH relays. Further, the relaying protocol is not fully fixed yet in the IETF draft. + +As currently available ODoH relays only use `POST` queries, this proxy accepts and issues `POST` queries both in ODoH target and relay functions. So, `POST` queries have been disabled for regular DoH queries, accepting them is required to be compatible with ODoH relays. This can be achieved with the `--allow-odoh-post` command-line switch. diff --git a/src/config.rs b/src/config.rs index a65bd19..412e712 100644 --- a/src/config.rs +++ b/src/config.rs @@ -140,7 +140,7 @@ pub fn parse_opts(globals: &mut Globals) { Arg::with_name("allow_odoh_post") .short("O") .long("allow-odoh-post") - .help("Allow POST queries over ODoH even if they have been disabed for DoH"), + .help("Allow POST queries over ODoH even if they have been disabled for DoH"), ); #[cfg(feature = "tls")] @@ -162,6 +162,17 @@ pub fn parse_opts(globals: &mut Globals) { .help("Path to the PEM-encoded secret keys (only required for built-in TLS)"), ); + #[cfg(feature = "odoh-proxy")] + let options = options + .arg( + Arg::with_name("odoh_proxy_path") + .short("q") + .long("odoh-proxy-path") + .takes_value(true) + .default_value(ODOH_PROXY_PATH) + .help("ODoH proxy URI path"), + ); + let matches = options.get_matches(); globals.listen_address = matches.value_of("listen_address").unwrap().parse().unwrap(); @@ -207,6 +218,15 @@ pub fn parse_opts(globals: &mut Globals) { .or_else(|| globals.tls_cert_path.clone()); } + #[cfg(feature = "odoh-proxy")] + { + globals.odoh_proxy_path = matches.value_of("odoh_proxy_path").unwrap().to_string(); + if !globals.odoh_proxy_path.starts_with('/') { + globals.odoh_proxy_path = format!("/{}", globals.odoh_proxy_path); + } + globals.odoh_proxy = libdoh::odoh_proxy::ODoHProxy::new(globals.timeout).unwrap(); + } + if let Some(hostname) = matches.value_of("hostname") { let mut builder = dnsstamps::DoHBuilder::new(hostname.to_string(), globals.path.to_string()); @@ -230,11 +250,22 @@ pub fn parse_opts(globals: &mut Globals) { builder = builder.with_port(public_port); } println!( - "Test DNS stamp to reach [{}] over Oblivious DoH: [{}]\n", + "Test DNS stamp to reach [{}] over Oblivious DoH Target: [{}]\n", hostname, builder.serialize().unwrap() ); + #[cfg(feature = "odoh-proxy")] + { + let builder = + dnsstamps::ODoHRelayBuilder::new(hostname.to_string(), globals.odoh_proxy_path.to_string()); + println!( + "Test DNS stamp to reach [{}] over Oblivious DoH Proxy: [{}]\n", + hostname, + builder.serialize().unwrap() + ); + } + println!("Check out https://dnscrypt.info/stamps/ to compute the actual stamps.\n") } else { println!("Please provide a fully qualified hostname (-H command-line option) to get test DNS stamps for your server.\n"); diff --git a/src/constants.rs b/src/constants.rs index e7e549c..98bfdbc 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -2,6 +2,8 @@ pub const LISTEN_ADDRESS: &str = "127.0.0.1:3000"; pub const MAX_CLIENTS: usize = 512; pub const MAX_CONCURRENT_STREAMS: u32 = 16; pub const PATH: &str = "/dns-query"; +#[cfg(feature = "odoh-proxy")] +pub const ODOH_PROXY_PATH: &str = "/proxy"; pub const ODOH_CONFIGS_PATH: &str = "/.well-known/odohconfigs"; pub const SERVER_ADDRESS: &str = "9.9.9.9:53"; pub const TIMEOUT_SEC: u64 = 10; diff --git a/src/libdoh/Cargo.toml b/src/libdoh/Cargo.toml index 9318afa..aae4e6a 100644 --- a/src/libdoh/Cargo.toml +++ b/src/libdoh/Cargo.toml @@ -13,6 +13,7 @@ edition = "2018" [features] default = ["tls"] tls = ["tokio-rustls"] +odoh-proxy = ["reqwest", "urlencoding"] [dependencies] anyhow = "1.0.44" @@ -25,9 +26,11 @@ hpke = "0.5.1" hyper = { version = "0.14.14", default-features = false, features = ["server", "http1", "http2", "stream"] } odoh-rs = "1.0.0-alpha.1" rand = "0.8.4" +reqwest = { version = "0.11.4", features = ["trust-dns"], optional = true} tokio = { version = "1.13.0", features = ["net", "rt-multi-thread", "parking_lot", "time", "sync"] } tokio-rustls = { version = "0.23.0", features = ["early-data"], optional = true } rustls-pemfile = "0.2.1" +urlencoding = { version = "2.1.0", optional = true } [profile.release] codegen-units = 1 diff --git a/src/libdoh/src/constants.rs b/src/libdoh/src/constants.rs index 2ba0361..0085ad6 100644 --- a/src/libdoh/src/constants.rs +++ b/src/libdoh/src/constants.rs @@ -1,4 +1,8 @@ pub const DNS_QUERY_PARAM: &str = "dns"; +#[cfg(feature = "odoh-proxy")] +pub const ODOH_TARGET_HOST_QUERY_PARAM: &str = "targethost"; +#[cfg(feature = "odoh-proxy")] +pub const ODOH_TARGET_PATH_QUERY_PARAM: &str = "targetpath"; pub const MAX_DNS_QUESTION_LEN: usize = 512; pub const MAX_DNS_RESPONSE_LEN: usize = 4096; pub const MIN_DNS_PACKET_LEN: usize = 17; diff --git a/src/libdoh/src/errors.rs b/src/libdoh/src/errors.rs index 82bae1a..79d1bfd 100644 --- a/src/libdoh/src/errors.rs +++ b/src/libdoh/src/errors.rs @@ -1,5 +1,7 @@ use hyper::StatusCode; use std::io; +#[cfg(feature = "odoh-proxy")] +use reqwest; #[derive(Debug)] pub enum DoHError { @@ -10,6 +12,8 @@ pub enum DoHError { UpstreamTimeout, StaleKey, Hyper(hyper::Error), + #[cfg(feature = "odoh-proxy")] + Reqwest(reqwest::Error), Io(io::Error), ODoHConfigError(anyhow::Error), TooManyTcpSessions, @@ -27,6 +31,8 @@ impl std::fmt::Display for DoHError { DoHError::UpstreamTimeout => write!(fmt, "Upstream timeout"), DoHError::StaleKey => write!(fmt, "Stale key material"), DoHError::Hyper(e) => write!(fmt, "HTTP error: {}", e), + #[cfg(feature = "odoh-proxy")] + DoHError::Reqwest(e) => write!(fmt, "HTTP Proxy error: {}", e), DoHError::Io(e) => write!(fmt, "IO error: {}", e), DoHError::ODoHConfigError(e) => write!(fmt, "ODoH config error: {}", e), DoHError::TooManyTcpSessions => write!(fmt, "Too many TCP sessions"), @@ -44,6 +50,8 @@ impl From for StatusCode { DoHError::UpstreamTimeout => StatusCode::BAD_GATEWAY, DoHError::StaleKey => StatusCode::UNAUTHORIZED, DoHError::Hyper(_) => StatusCode::SERVICE_UNAVAILABLE, + #[cfg(feature = "odoh-proxy")] + DoHError::Reqwest(_) => StatusCode::SERVICE_UNAVAILABLE, DoHError::Io(_) => StatusCode::INTERNAL_SERVER_ERROR, DoHError::ODoHConfigError(_) => StatusCode::INTERNAL_SERVER_ERROR, DoHError::TooManyTcpSessions => StatusCode::SERVICE_UNAVAILABLE, diff --git a/src/libdoh/src/globals.rs b/src/libdoh/src/globals.rs index 85b9808..be89592 100644 --- a/src/libdoh/src/globals.rs +++ b/src/libdoh/src/globals.rs @@ -1,4 +1,6 @@ use crate::odoh::ODoHRotator; +#[cfg(feature = "odoh-proxy")] +use crate::odoh_proxy::ODoHProxy; use std::net::SocketAddr; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; @@ -33,6 +35,12 @@ pub struct Globals { pub odoh_configs_path: String, pub odoh_rotator: Arc, + #[cfg(feature = "odoh-proxy")] + pub odoh_proxy_path: String, + + #[cfg(feature = "odoh-proxy")] + pub odoh_proxy: ODoHProxy, + pub runtime_handle: runtime::Handle, } diff --git a/src/libdoh/src/lib.rs b/src/libdoh/src/lib.rs index 427acec..5d1d02b 100644 --- a/src/libdoh/src/lib.rs +++ b/src/libdoh/src/lib.rs @@ -3,6 +3,8 @@ pub mod dns; mod errors; mod globals; pub mod odoh; +#[cfg(feature = "odoh-proxy")] +pub mod odoh_proxy; #[cfg(feature = "tls")] mod tls; @@ -109,7 +111,27 @@ impl hyper::service::Service> for DoH { _ => Box::pin(async { http_error(StatusCode::METHOD_NOT_ALLOWED) }), } } else { - Box::pin(async { http_error(StatusCode::NOT_FOUND) }) + #[cfg(not(feature = "odoh-proxy"))] + { + Box::pin(async { http_error(StatusCode::NOT_FOUND) }) + } + #[cfg(feature = "odoh-proxy")] + { + if req.uri().path() == globals.odoh_proxy_path { + // Draft: https://datatracker.ietf.org/doc/html/draft-pauly-dprive-oblivious-doh-06 + // Golang impl.: https://github.com/cloudflare/odoh-server-go + // Based on the draft and Golang implementation, only post method is allowed. + match *req.method() { + Method::POST => { + Box::pin(async move { self_inner.serve_odoh_proxy_post(req).await }) + } + _ => Box::pin(async { http_error(StatusCode::METHOD_NOT_ALLOWED) }), + } + } + else { + Box::pin(async { http_error(StatusCode::NOT_FOUND) }) + } + } } } } @@ -228,6 +250,66 @@ impl DoH { self.serve_odoh(encrypted_query).await } + #[cfg(feature = "odoh-proxy")] + async fn serve_odoh_proxy( + &self, + encrypted_query: Vec, + target_uri: &str, + ) -> Result, http::Error> { + let encrypted_response = match self + .globals + .odoh_proxy + .forward_to_target(&encrypted_query, target_uri) + .await + { + Ok(resp) => self.build_response(resp, 0u32, DoHType::Oblivious.as_str(), true), + Err(e) => return http_error(e), + }; + + match encrypted_response { + Ok(resp) => Ok(resp), + Err(e) => http_error(StatusCode::from(e)), + } + } + + #[cfg(feature = "odoh-proxy")] + async fn serve_odoh_proxy_post( + &self, + req: Request, + ) -> Result, http::Error> { + if self.globals.disable_post && !self.globals.allow_odoh_post { + return http_error(StatusCode::METHOD_NOT_ALLOWED); + } + // Draft: https://datatracker.ietf.org/doc/html/draft-pauly-dprive-oblivious-doh-06 + // Golang impl.: https://github.com/cloudflare/odoh-server-go + // The following follows the Golang implementation, which is different from the draft. + // In the draft, single endpoint, i.e., /dns-query, can accept proxy and target messages, + // and works as a proxy only when '?targethost' and '?tagetpath' exist in given uri query. + // However, in Golang implementation, proxy and target endpoints are separated. + match Self::parse_content_type(&req) { + Ok(DoHType::Oblivious) => { + let http_query = req.uri().query().unwrap_or(""); + let target_uri = match odoh_proxy::target_uri_from_query_string(http_query) { + Some(uri) => uri, + _ => return http_error(StatusCode::BAD_REQUEST), + }; + let encrypted_query = match self.read_body(req.into_body()).await { + Ok(q) => { + if q.len() == 0 { + return http_error(StatusCode::BAD_REQUEST); + } + q + }, + Err(e) => return http_error(StatusCode::from(e)), + }; + + self.serve_odoh_proxy(encrypted_query, &target_uri).await + }, + Ok(_) => http_error(StatusCode::UNSUPPORTED_MEDIA_TYPE), + Err(err_response) => Ok(err_response) + } + } + async fn serve_odoh_configs(&self) -> Result, http::Error> { let odoh_public_key = (*self.globals.odoh_rotator).clone().current_public_key(); let configs = (*odoh_public_key).clone().into_config(); @@ -483,6 +565,9 @@ impl DoH { .map_err(DoHError::Io)?; let path = &self.globals.path; + #[cfg(feature = "odoh-proxy")] + let odoh_proxy_path = &self.globals.odoh_proxy_path; + let tls_enabled: bool; #[cfg(not(feature = "tls"))] { @@ -494,9 +579,13 @@ impl DoH { self.globals.tls_cert_path.is_some() && self.globals.tls_cert_key_path.is_some(); } if tls_enabled { - println!("Listening on https://{}{}", listen_address, path); + println!("ODoH/DoH Server: Listening on https://{}{}", listen_address, path); + #[cfg(feature = "odoh-proxy")] + println!("ODoH Proxy : Listening on https://{}{}", listen_address, odoh_proxy_path); } else { - println!("Listening on http://{}{}", listen_address, path); + println!("ODoH/DoH Server: Listening on http://{}{}", listen_address, path); + #[cfg(feature = "odoh-proxy")] + println!("ODoH Proxy : Listening on http://{}{}", listen_address, odoh_proxy_path); } let mut server = Http::new(); diff --git a/src/libdoh/src/odoh_proxy.rs b/src/libdoh/src/odoh_proxy.rs new file mode 100644 index 0000000..ae9cabf --- /dev/null +++ b/src/libdoh/src/odoh_proxy.rs @@ -0,0 +1,89 @@ +use crate::constants::*; +use crate::errors::DoHError; +use hyper::http::StatusCode; +use reqwest::header; +use urlencoding::decode; + +pub fn target_uri_from_query_string(http_query: &str) -> Option { + let mut targethost = None; + let mut targetpath = None; + for parts in http_query.split('&') { + let mut kv = parts.split('='); + if let Some(k) = kv.next() { + match k { + ODOH_TARGET_HOST_QUERY_PARAM => { + targethost = kv.next().map(str::to_string); + } + ODOH_TARGET_PATH_QUERY_PARAM => { + targetpath = kv.next().map(str::to_string); + } + _ => (), + } + } + } + if let (Some(host), Some(path)) = (targethost, targetpath) { + // remove percent encoding + Some( + decode(&format!("https://{}{}", host, path)) + .unwrap_or(std::borrow::Cow::Borrowed("")) + .to_string(), + ) + } else { + None + } +} + +#[derive(Debug, Clone)] +pub struct ODoHProxy { + client: reqwest::Client, +} + +impl ODoHProxy { + pub fn new(timeout: std::time::Duration) -> Result { + // build client + let mut headers = header::HeaderMap::new(); + let ct = "application/oblivious-dns-message"; + headers.insert("Accept", header::HeaderValue::from_str(&ct).unwrap()); + headers.insert("Content-Type", header::HeaderValue::from_str(&ct).unwrap()); + headers.insert( + "Cache-Control", + header::HeaderValue::from_str("no-cache, no-store").unwrap(), + ); + + let client = reqwest::Client::builder() + .user_agent(format!("odoh-proxy/{}", env!("CARGO_PKG_VERSION"))) + .timeout(timeout) + .trust_dns(true) + .default_headers(headers) + .build() + .map_err(|e| DoHError::Reqwest(e))?; + + Ok(ODoHProxy { client }) + } + + pub async fn forward_to_target( + &self, + encrypted_query: &Vec, + target_uri: &str, + ) -> Result, StatusCode> { + // Only post method is allowed in ODoH + let response = self + .client + .post(target_uri) + .body(encrypted_query.clone()) + .send() + .await + .map_err(|e| { + eprintln!("[ODoH Proxy] Upstream query error: {}", e); + DoHError::Reqwest(e) + })?; + + if response.status() != reqwest::StatusCode::OK { + eprintln!("[ODoH Proxy] Response not ok: {:?}", response.status()); + return Err(response.status()); + } + + let body = response.bytes().await.map_err(|e| DoHError::Reqwest(e))?; + Ok(body.to_vec()) + } +} diff --git a/src/main.rs b/src/main.rs index 582724c..b2eb038 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,9 @@ use libdoh::*; use crate::config::*; use crate::constants::*; +#[cfg(feature = "odoh-proxy")] +use libdoh::odoh_proxy::ODoHProxy; + use libdoh::odoh::ODoHRotator; use libdoh::reexports::tokio; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; @@ -53,6 +56,11 @@ fn main() { odoh_configs_path: ODOH_CONFIGS_PATH.to_string(), odoh_rotator: Arc::new(rotator), + #[cfg(feature = "odoh-proxy")] + odoh_proxy_path: ODOH_PROXY_PATH.to_string(), + #[cfg(feature = "odoh-proxy")] + odoh_proxy: ODoHProxy::new(Duration::from_secs(TIMEOUT_SEC)).unwrap(), + runtime_handle: runtime.handle().clone(), }; parse_opts(&mut globals);