use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use hickory_proto::op::{Message, ResponseCode};
use ipstack::IpStackUdpStream;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::UdpSocket;
use tokio::time::timeout;
use tracing::{debug, info, warn};
use super::policy::Policy;
const DNS_BUF: usize = 4096;
const UPSTREAM_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug, PartialEq, Eq)]
enum Verdict {
Resolve(String),
Refuse(String),
Unparsable,
}
pub async fn serve(mut guest: IpStackUdpStream, resolver: SocketAddr, policy: Arc<Policy>) {
let upstream = match UdpSocket::bind(unspecified_for(resolver)).await {
Ok(sock) => sock,
Err(e) => {
debug!(?e, "dns: failed to bind upstream socket");
return;
}
};
if upstream.connect(resolver).await.is_err() {
return;
}
let mut query = [0u8; DNS_BUF];
let mut answer = [0u8; DNS_BUF];
loop {
let n = match guest.read(&mut query).await {
Ok(0) | Err(_) => break,
Ok(n) => n,
};
let datagram = &query[..n];
match decide(datagram, &policy) {
Verdict::Resolve(name) => {
info!(target: "npxc::egress", proto = "dns", %name, "allow");
if upstream.send(datagram).await.is_err() {
break;
}
if let Ok(Ok(len)) = timeout(UPSTREAM_TIMEOUT, upstream.recv(&mut answer)).await {
if guest.write_all(&answer[..len]).await.is_err() {
break;
}
} else {
debug!(%name, "dns: upstream did not answer");
}
}
Verdict::Refuse(name) => {
warn!(target: "npxc::egress", proto = "dns", %name, "deny (nxdomain)");
if let Some(response) = nxdomain(datagram)
&& guest.write_all(&response).await.is_err()
{
break;
}
}
Verdict::Unparsable => debug!("dns: dropping unparseable query"),
}
}
}
fn unspecified_for(resolver: SocketAddr) -> SocketAddr {
if resolver.is_ipv4() {
(std::net::Ipv4Addr::UNSPECIFIED, 0).into()
} else {
(std::net::Ipv6Addr::UNSPECIFIED, 0).into()
}
}
fn decide(datagram: &[u8], policy: &Policy) -> Verdict {
let Ok(message) = Message::from_vec(datagram) else {
return Verdict::Unparsable;
};
let Some(query) = message.queries.first() else {
return Verdict::Unparsable;
};
let name = query.name().to_ascii();
let name = name.trim_end_matches('.').to_ascii_lowercase();
if policy.allows_name(&name) {
Verdict::Resolve(name)
} else {
Verdict::Refuse(name)
}
}
fn nxdomain(query: &[u8]) -> Option<Vec<u8>> {
let request = Message::from_vec(query).ok()?;
let mut response = Message::error_msg(
request.metadata.id,
request.metadata.op_code,
ResponseCode::NXDomain,
);
response.metadata.recursion_desired = request.metadata.recursion_desired;
response.metadata.recursion_available = true;
response.add_queries(request.queries);
response.to_vec().ok()
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::{IpAddr, Ipv4Addr};
use hickory_proto::op::{MessageType, OpCode, Query};
use hickory_proto::rr::{Name, RecordType};
const DNS: IpAddr = IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1));
fn policy(allow: &[&str]) -> Policy {
let owned: Vec<String> = allow.iter().map(|s| (*s).to_string()).collect();
Policy::build(&owned, DNS).unwrap()
}
fn query_for(name: &str, id: u16) -> Vec<u8> {
let mut message = Message::new(id, MessageType::Query, OpCode::Query);
message.metadata.recursion_desired = true;
let name = Name::from_ascii(name).unwrap();
message.add_query(Query::query(name, RecordType::A));
message.to_vec().unwrap()
}
#[test]
fn allowed_name_is_resolved() {
let p = policy(&["api.anthropic.com:443"]);
assert_eq!(
decide(&query_for("api.anthropic.com.", 1), &p),
Verdict::Resolve("api.anthropic.com".to_string())
);
}
#[test]
fn denied_name_is_refused() {
let p = policy(&["api.anthropic.com:443"]);
assert_eq!(
decide(&query_for("evil.example.", 2), &p),
Verdict::Refuse("evil.example".to_string())
);
}
#[test]
fn garbage_is_unparsable() {
let p = policy(&[]);
assert_eq!(decide(&[0xde, 0xad, 0xbe, 0xef], &p), Verdict::Unparsable);
}
#[test]
fn nxdomain_echoes_id_and_question() {
let query = query_for("denied.example.", 0x1234);
let response = nxdomain(&query).expect("build NXDOMAIN");
let parsed = Message::from_vec(&response).unwrap();
assert_eq!(parsed.metadata.id, 0x1234);
assert_eq!(parsed.metadata.message_type, MessageType::Response);
assert_eq!(parsed.metadata.response_code, ResponseCode::NXDomain);
assert_eq!(parsed.queries.len(), 1);
assert_eq!(
parsed.queries[0].name().to_ascii(),
"denied.example.",
"the original question must be echoed back"
);
}
}