pharos-plugins 0.1.0

CoreDNS-compatible Plugins for the Pharos DNS server
Documentation
use std::{
    collections::HashMap,
    io::{BufReader, Read},
    net::IpAddr,
};

use pharos_proto::Name;

#[derive(Debug, Default)]
pub(super) struct Map {
    pub(super) v4: HashMap<Vec<u8>, Vec<IpAddr>>,
    pub(super) v6: HashMap<Vec<u8>, Vec<IpAddr>>,
    pub(super) reverse: HashMap<IpAddr, Vec<Name>>,
}

impl Map {
    pub(super) fn len(&self) -> usize {
        self.v4
            .values()
            .chain(self.v6.values())
            .map(Vec::len)
            .sum::<usize>()
            + self.reverse.values().map(Vec::len).sum::<usize>()
    }
    pub(super) fn parse(
        input: impl Read,
        domains: &[Name],
        auto_reverse: bool,
        source: &str,
    ) -> Self {
        let mut map = Self::default();
        let mut field = Vec::new();
        let mut oversized = false;
        let mut index = 0;
        let mut line = 1;
        let mut comment = false;
        let mut address = None;
        for result in BufReader::new(input)
            .bytes()
            .chain(std::iter::once(Ok(b'\n')))
        {
            let byte = match result {
                Ok(byte) => byte,
                Err(error) => {
                    pharos_server::logging::log(
                        "ERROR",
                        "hosts",
                        &format!("Failed to parse {source}: {error}"),
                    );
                    break;
                }
            };
            if !comment && byte == b'#' {
                comment = true;
            }
            if matches!(byte, b' ' | b'\t' | b'\n' | b'\x0b' | b'\x0c' | b'\r') || comment {
                if !field.is_empty() || oversized {
                    index += 1;
                    if oversized {
                        pharos_server::logging::log(
                            "ERROR",
                            "hosts",
                            &format!(
                                "{source}, line {line}: {} longer than 1024 bytes, dropping {}",
                                if index == 1 { "address" } else { "name" },
                                if index == 1 { "the line" } else { "the name" }
                            ),
                        );
                    } else if index == 1 {
                        address = std::str::from_utf8(&field)
                            .ok()
                            .and_then(|s| s.split('%').next())
                            .and_then(|s| s.parse::<IpAddr>().ok())
                            .map(|ip| match ip {
                                IpAddr::V6(ip) => {
                                    ip.to_ipv4_mapped().map_or(IpAddr::V6(ip), IpAddr::V4)
                                }
                                ip => ip,
                            });
                    } else if let Some(ip) = address {
                        map.add(&field, ip, domains, auto_reverse);
                    }
                    field.clear();
                    oversized = false;
                }
                if byte == b'\n' {
                    index = 0;
                    address = None;
                    comment = false;
                    line += 1;
                }
            } else if !oversized {
                if field.len() == 1024 {
                    field.clear();
                    oversized = true;
                } else {
                    field.push(byte);
                }
            }
        }
        map
    }

    fn add(&mut self, field: &[u8], ip: IpAddr, domains: &[Name], auto_reverse: bool) {
        let Ok(text) = std::str::from_utf8(field) else {
            return;
        };
        // RFC 4343 §3: DNS name lookup compares ASCII letters case-insensitively.
        let text = text.to_ascii_lowercase();
        let text = if text.ends_with('.') {
            text
        } else {
            format!("{text}.")
        };
        let Ok(name) = text.parse::<Name>() else {
            return;
        };
        // RFC 1034 §3.1: a subdomain is contained within its parent domain.
        if !domains.iter().any(|domain| name.is_subdomain_of(domain)) {
            return;
        }
        let wildcard = text.starts_with("*.");
        let key = name.as_wire().to_ascii_lowercase();
        match ip {
            IpAddr::V4(_) => self.v4.entry(key).or_default().push(ip),
            IpAddr::V6(_) => self.v6.entry(key).or_default().push(ip),
        }
        if auto_reverse && !wildcard {
            self.reverse.entry(ip).or_default().push(name);
        }
    }

    pub(super) fn lookup_key(&self, key: &[u8], v4: bool) -> &[IpAddr] {
        let map = if v4 { &self.v4 } else { &self.v6 };
        if let Some(ips) = map.get(key) {
            return ips;
        }
        let Some(&len) = key.first() else { return &[] };
        if len == 0 {
            return &[];
        }
        let mut wildcard = [0u8; 255];
        let suffix = &key[1 + usize::from(len)..];
        // RFC 4592 §2.1.1: an asterisk label has wire encoding 0x01 0x2a.
        wildcard[0] = 1;
        wildcard[1] = b'*';
        wildcard[2..2 + suffix.len()].copy_from_slice(suffix);
        map.get(&wildcard[..2 + suffix.len()])
            .map_or(&[], Vec::as_slice)
    }

    #[cfg(test)]
    fn lookup(&self, text: &str, v4: bool) -> &[IpAddr] {
        let name: Name = text.parse().unwrap();
        self.lookup_key(&name.as_wire().to_ascii_lowercase(), v4)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_scoped_addresses_aliases_and_wildcards() {
        let map = Map::parse(
            &b"127.0.0.2 Odin odinalias # comment\n127.0.0.3 odin\n::2 odin\nfe80::1%lo0 localhost\n127.1.1.2 *.example.org\ninvalid invalid\n"[..],
            &[".".parse().unwrap()], true, "Inline hosts entries");
        assert_eq!(
            map.lookup("odin.", true),
            [
                "127.0.0.2".parse::<IpAddr>().unwrap(),
                "127.0.0.3".parse().unwrap()
            ]
        );
        assert_eq!(
            map.lookup("ODIN.", false),
            ["::2".parse::<IpAddr>().unwrap()]
        );
        assert_eq!(
            map.lookup("localhost.", false),
            ["fe80::1".parse::<IpAddr>().unwrap()]
        );
        assert_eq!(
            map.lookup("a.example.org.", true),
            ["127.1.1.2".parse::<IpAddr>().unwrap()]
        );
        assert!(map.lookup("a.b.example.org.", true).is_empty());
        assert_eq!(
            map.reverse
                .get(&"127.0.0.2".parse::<IpAddr>().unwrap())
                .unwrap()
                .iter()
                .map(ToString::to_string)
                .collect::<Vec<_>>(),
            ["odin.", "odinalias."]
        );
        assert!(
            !map.reverse
                .contains_key(&"127.1.1.2".parse::<IpAddr>().unwrap())
        );
    }

    #[test]
    fn long_fields_do_not_discard_following_names_or_lines() {
        let input = format!(
            "127.0.0.1 {} one.example.org\n127.0.0.2 two.example.org\n",
            "x".repeat(70_000)
        );
        let map = Map::parse(
            input.as_bytes(),
            &["example.org.".parse().unwrap()],
            false,
            "Inline hosts entries",
        );
        assert_eq!(
            map.lookup("one.example.org.", true),
            ["127.0.0.1".parse::<IpAddr>().unwrap()]
        );
        assert_eq!(
            map.lookup("two.example.org.", true),
            ["127.0.0.2".parse::<IpAddr>().unwrap()]
        );
        assert!(map.reverse.is_empty());
    }

    #[test]
    fn vertical_tab_and_form_feed_separate_fields() {
        let map = Map::parse(
            &b"127.0.0.1\x0bfoo\x0cbar\n"[..],
            &[".".parse().unwrap()],
            true,
            "Inline hosts entries",
        );
        assert_eq!(
            map.lookup("foo.", true),
            ["127.0.0.1".parse::<IpAddr>().unwrap()]
        );
        assert_eq!(
            map.lookup("bar.", true),
            ["127.0.0.1".parse::<IpAddr>().unwrap()]
        );
    }

    #[test]
    fn exact_and_wildcard_lookup_is_separate_per_family() {
        let input = &b"192.0.2.1 *.example.org\n192.0.2.2 x.example.org\n2001:db8::1 *.example.org\n192.0.2.3 *.deep.example.org\n"[..];
        let map = Map::parse(input, &["example.org.".parse().unwrap()], true, "test");
        assert_eq!(
            map.lookup("x.example.org.", true),
            ["192.0.2.2".parse::<IpAddr>().unwrap()]
        );
        assert_eq!(
            map.lookup("x.example.org.", false),
            ["2001:db8::1".parse::<IpAddr>().unwrap()]
        );
        assert_eq!(
            map.lookup("y.example.org.", true),
            ["192.0.2.1".parse::<IpAddr>().unwrap()]
        );
        assert_eq!(
            map.lookup("y.deep.example.org.", true),
            ["192.0.2.3".parse::<IpAddr>().unwrap()]
        );
        assert!(map.lookup("deep.example.org.", false).len() == 1);
        assert!(map.lookup("a.b.deep.example.org.", true).is_empty());
    }

    #[test]
    fn oversized_address_discards_only_its_line() {
        let input = format!(
            "{} ignored.example.org\n192.0.2.1 valid.example.org\n",
            "1".repeat(1025)
        );
        let map = Map::parse(input.as_bytes(), &[".".parse().unwrap()], true, "test");
        assert!(map.lookup("ignored.example.org.", true).is_empty());
        assert_eq!(
            map.lookup("valid.example.org.", true),
            ["192.0.2.1".parse::<IpAddr>().unwrap()]
        );
    }
}