pharos-plugins 0.1.0

CoreDNS-compatible Plugins for the Pharos DNS server
Documentation
#![forbid(unsafe_code)]
#![cfg(feature = "plugin-forward")]
#![recursion_limit = "512"]

use pharos_corefile::{configure, parse};
use pharos_plugins::{Chain, directives};
use pharos_proto::{Message, Name};
use pharos_server::{Context, Metadata, Outcome, Protocol, Transport};
use tokio::net::UdpSocket;

const QUERY: &[u8] = b"\x12\x34\x01\x00\x00\x01\0\0\0\0\0\0\x03www\x07example\x03com\0\0\x01\0\x01";

async fn run(chain: &Chain) -> (Outcome, Vec<u8>, Option<String>) {
    let domain: Name = ".".parse().unwrap();
    let mut response = Vec::with_capacity(65535);
    let mut slots = [];
    let mut context = Context {
        query: Message::parse(QUERY).unwrap(),
        client: "127.0.0.1:1000".parse().unwrap(),
        local: "127.0.0.1:53".parse().unwrap(),
        transport: Transport::Dns,
        protocol: Protocol::Udp,
        domain: &domain,
        metadata: Metadata::new(&mut slots),
        response: &mut response,
        max_response_size: 512,
    };
    let outcome = chain.serve(&mut context).await;
    let upstream = context.metadata.get("forward/upstream").map(str::to_owned);
    (outcome, response, upstream)
}

fn chain(text: &str) -> Chain {
    let blocks = parse("Corefile", text).unwrap();
    configure::<Chain>(&blocks, directives())
        .unwrap()
        .remove(0)
        .config
}

async fn fake(
    rcode: u8,
    wrong_question: bool,
) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) {
    let socket = UdpSocket::bind("127.0.0.1:0").await.unwrap();
    let address = socket.local_addr().unwrap();
    let task = tokio::spawn(async move {
        let mut wire = [0; 512];
        let (len, peer) = socket.recv_from(&mut wire).await.unwrap();
        wire[2] |= 0x80;
        wire[3] = rcode;
        if wrong_question {
            wire[len - 4] = 0;
            wire[len - 3] = 28;
        }
        socket.send_to(&wire[..len], peer).await.unwrap();
    });
    (address, task)
}

#[tokio::test]
async fn forwards_query_and_restores_client_id() {
    let upstream = UdpSocket::bind("127.0.0.1:0").await.unwrap();
    let address = upstream.local_addr().unwrap();
    let task = tokio::spawn(async move {
        let mut wire = [0; 512];
        let (len, peer) = upstream.recv_from(&mut wire).await.unwrap();
        wire[2] |= 0x80;
        upstream.send_to(&wire[..len], peer).await.unwrap();
    });
    let text = format!(".:0 {{\n forward . {address}\n}}");
    let blocks = parse("Corefile", &text).unwrap();
    let configured = configure::<Chain>(&blocks, directives()).unwrap();
    let wire = QUERY;
    let domain: Name = ".".parse().unwrap();
    let mut response = Vec::with_capacity(65535);
    let mut slots = [];
    let mut context = Context {
        query: Message::parse(wire).unwrap(),
        client: "127.0.0.1:1000".parse().unwrap(),
        local: "127.0.0.1:53".parse().unwrap(),
        transport: Transport::Dns,
        protocol: Protocol::Udp,
        domain: &domain,
        metadata: Metadata::new(&mut slots),
        response: &mut response,
        max_response_size: 512,
    };
    assert_eq!(
        configured[0].config.serve(&mut context).await,
        Outcome::Response
    );
    let answer = Message::parse(context.response).unwrap();
    assert_eq!(answer.id(), 0x1234);
    assert!(answer.qr());
    task.await.unwrap();
}

#[tokio::test]
async fn refuses_unsafe_modified_query_view() {
    let chain = chain(".:0 {\n forward . 127.0.0.1:53\n}");
    let wire = b"\0\x01\x01\0\0\x01\0\0\0\0\0\0\xc0\x03\0\x01\0\x01";
    let domain: Name = ".".parse().unwrap();
    let mut response = Vec::with_capacity(65535);
    let mut slots = [];
    let mut cx = Context {
        query: Message::parse(wire).unwrap().with_ad(),
        client: "127.0.0.1:1000".parse().unwrap(),
        local: "127.0.0.1:53".parse().unwrap(),
        transport: Transport::Dns,
        protocol: Protocol::Udp,
        domain: &domain,
        metadata: Metadata::new(&mut slots),
        response: &mut response,
        max_response_size: 512,
    };
    assert_eq!(
        tokio::time::timeout(std::time::Duration::from_millis(200), chain.serve(&mut cx))
            .await
            .unwrap(),
        Outcome::Unanswered(pharos_proto::Rcode::FORMERR)
    );
}

#[tokio::test]
async fn forwards_with_more_than_fifteen_expanded_upstreams() {
    let (address, task) = fake(0, false).await;
    let path = std::env::temp_dir().join(format!("pharos-forward-expanded-{}", std::process::id()));
    let nameservers = (1..=16)
        .map(|i| format!("nameserver 192.0.2.{i}\n"))
        .collect::<String>();
    std::fs::write(&path, nameservers).unwrap();
    let chain = chain(&format!(
        ".:0 {{\n forward . {address} {} {{\n policy sequential\n }}\n}}",
        path.display()
    ));
    assert_eq!(run(&chain).await.0, Outcome::Response);
    task.await.unwrap();
    std::fs::remove_file(path).unwrap();
}

#[tokio::test]
async fn invalid_file_nameserver_falls_back_to_next_upstream() {
    let (address, task) = fake(0, false).await;
    let path = std::env::temp_dir().join(format!("pharos-forward-invalid-{}", std::process::id()));
    std::fs::write(&path, "nameserver #\n").unwrap();
    let chain = chain(&format!(
        ".:0 {{\n forward . {} {address} {{\n policy sequential\n }}\n}}",
        path.display()
    ));
    assert_eq!(run(&chain).await.0, Outcome::Response);
    task.await.unwrap();
    std::fs::remove_file(path).unwrap();
}

#[tokio::test(flavor = "current_thread")]
async fn unlimited_invalid_upstream_does_not_block_runtime() {
    let path = std::env::temp_dir().join(format!("pharos-forward-busy-{}", std::process::id()));
    std::fs::write(&path, "nameserver #\n").unwrap();
    let chain = chain(&format!(
        ".:0 {{\n forward . {} {{\n max_connect_attempts 0\n }}\n}}",
        path.display()
    ));
    let result = tokio::time::timeout(std::time::Duration::from_millis(50), run(&chain)).await;
    assert!(result.is_err(), "invalid upstream blocked the runtime");
    std::fs::remove_file(path).unwrap();
}

#[tokio::test]
async fn next_rcode_passes_to_following_forward_and_records_upstream() {
    let (first, task1) = fake(3, false).await;
    let (second, task2) = fake(0, false).await;
    let chain = chain(&format!(
        ".:0 {{\n {}\n forward . {first} {{\n next NXDOMAIN\n }}\n forward . {second}\n}}",
        if cfg!(feature = "plugin-metadata") {
            "metadata"
        } else {
            ""
        }
    ));
    let (outcome, response, upstream) = run(&chain).await;
    assert_eq!(outcome, Outcome::Response);
    assert_eq!(Message::parse(&response).unwrap().rcode().0, 0);
    assert_eq!(
        upstream.as_deref(),
        cfg!(feature = "plugin-metadata")
            .then_some(second.to_string())
            .as_deref()
    );
    task1.await.unwrap();
    task2.await.unwrap();
}

#[tokio::test]
async fn failover_tries_next_upstream_and_question_mismatch_is_formerr() {
    let (first, task1) = fake(2, false).await;
    let (second, task2) = fake(0, true).await;
    let chain = chain(&format!(
        ".:0 {{\n forward . {first} {second} {{\n policy sequential\n failover SERVFAIL\n }}\n}}"
    ));
    assert_eq!(
        run(&chain).await.0,
        Outcome::Unanswered(pharos_proto::Rcode::FORMERR)
    );
    task1.await.unwrap();
    task2.await.unwrap();
}

#[tokio::test]
#[cfg(feature = "plugin-whoami")]
async fn except_and_nonmatching_domains_reach_next_plugin() {
    let first = chain(".:0 {\n forward other.org 127.0.0.1\n whoami\n}");
    assert_eq!(run(&first).await.0, Outcome::Response);
    let second = chain(".:0 {\n forward . 127.0.0.1 {\n except example.com\n }\n whoami\n}");
    assert_eq!(run(&second).await.0, Outcome::Response);
}

#[tokio::test]
async fn max_concurrent_rejects_without_leaking_a_slot() {
    let socket = UdpSocket::bind("127.0.0.1:0").await.unwrap();
    let address = socket.local_addr().unwrap();
    let (received, wait) = tokio::sync::oneshot::channel();
    let (release, released) = tokio::sync::oneshot::channel();
    let server = tokio::spawn(async move {
        let mut wire = [0; 512];
        let (len, peer) = socket.recv_from(&mut wire).await.unwrap();
        received.send(()).unwrap();
        released.await.unwrap();
        wire[2] |= 0x80;
        socket.send_to(&wire[..len], peer).await.unwrap();
    });
    let chain = std::sync::Arc::new(chain(&format!(
        ".:0 {{\n forward . {address} {{\n max_concurrent 1\n }}\n}}"
    )));
    let first_chain = chain.clone();
    let first = tokio::spawn(async move { run(&first_chain).await });
    wait.await.unwrap();
    assert_eq!(
        run(&chain).await.0,
        Outcome::Unanswered(pharos_proto::Rcode::REFUSED)
    );
    release.send(()).unwrap();
    assert_eq!(first.await.unwrap().0, Outcome::Response);
    server.await.unwrap();
}

#[tokio::test]
async fn shutdown_stops_upstream_proxy() {
    use pharos_server::{Action, Handler, Phase};
    let (upstream, task) = fake(0, false).await;
    let text = format!(".:0 {{\n forward . {upstream}\n}}");
    let blocks = parse("Corefile", &text).unwrap();
    let (mut servers, lifecycle) =
        pharos_plugins::servers(configure::<Chain>(&blocks, directives()).unwrap()).unwrap();
    let (_, server) = servers.drain().next().unwrap();
    let mut response = Vec::with_capacity(65535);
    let peer = "127.0.0.1:1000".parse().unwrap();
    assert_eq!(
        server
            .handle(QUERY, peer, Protocol::Udp, &mut response)
            .await,
        Action::Respond
    );
    assert_eq!(Message::parse(&response).unwrap().rcode().0, 0);
    task.await.unwrap();
    lifecycle.run(Phase::Shutdown).await.unwrap();
    assert_eq!(
        server
            .handle(QUERY, peer, Protocol::Udp, &mut response)
            .await,
        Action::Respond
    );
    assert_eq!(
        Message::parse(&response).unwrap().rcode(),
        pharos_proto::Rcode::SERVFAIL
    );
}