lab-ops 0.1.19

Personal utility tools for my homelab
Documentation
//! Converts BIND DNS zone files to Ansible Cloudflare DNS tasks.
//!
//! Parses standard BIND zone file format and emits Ansible YAML tasks using
//! the `community.general.cloudflare_dns` module. Supports A, AAAA, CNAME, MX,
//! TXT, SRV, TLSA, and NS record types. Cloudflare proxied status can be
//! annotated via `; cf_tags=cf-proxied:true|false` inline comments.

use std::fs;
use std::io;
use std::path::Path;

use color_eyre::Result;
use color_eyre::eyre::Context;

use crate::cmd::dns_parser;

/// Parses a BIND zone file and prints Ansible Cloudflare DNS tasks to stdout.
///
/// # Examples
///
/// ```no_run
/// use lab_ops::cmd::cf2ansible;
/// cf2ansible::run("zone.txt", Some("example.com")).unwrap();
/// ```
pub fn run(zone_file: impl AsRef<Path>, zone_name: Option<impl AsRef<str>>) -> Result<()> {
    let content = fs::read_to_string(&zone_file)
        .with_context(|| format!("failed to read file {}", zone_file.as_ref().display()))?;

    let zone = zone_name
        .map(|s| s.as_ref().to_string())
        .unwrap_or_else(|| extract_zone(&content));

    let records = dns_parser::parse_zone(&content);
    print_ansible_tasks(&records, &zone)?;

    Ok(())
}

/// Extracts the zone name from the SOA record.
///
/// Returns "example.com" as a fallback if no SOA record is found.
fn extract_zone(content: &str) -> String {
    for line in content.lines() {
        if dns_parser::SOA.is_match(line) {
            let parts: Vec<&str> = line.split_whitespace().collect();
            let name = parts[0].trim_end_matches('.');
            return name.to_string();
        }
    }
    "example.com".to_string()
}

/// Escapes a string for safe inclusion in a YAML double-quoted value.
fn yaml_escape(s: &str) -> String {
    const SPECIAL_CHARS: &[char] = &[
        ':', '#', '"', '\'', '{', '}', '[', ']', ',', '&', '*', '?', '|', '<', '>', '=', '!', '%',
        '@', '`', '\n', '\t', '\r',
    ];

    let needs_quoting = s.is_empty()
        || s.starts_with(' ')
        || s.ends_with(' ')
        || matches!(s.chars().next(), Some('-' | '~' | ':'))
        || s.contains(SPECIAL_CHARS)
        || s.chars().next().is_some_and(|c| c.is_ascii_digit())
        || matches!(s, "true" | "false" | "null" | "yes" | "no" | "on" | "off");

    if needs_quoting {
        let mut out = String::with_capacity(s.len() + 2);
        out.push('"');
        for c in s.chars() {
            match c {
                '\\' => out.push_str("\\\\"),
                '"' => out.push_str("\\\""),
                '\n' => out.push_str("\\n"),
                '\r' => out.push_str("\\r"),
                '\t' => out.push_str("\\t"),
                c => out.push(c),
            }
        }
        out.push('"');
        out
    } else {
        s.to_string()
    }
}

/// Prints the full Ansible playbook (YAML) for all parsed DNS records to stdout.
fn print_ansible_tasks(records: &[dns_parser::DnsRecord], zone: &str) -> Result<()> {
    print_ansible_tasks_to(records, zone, io::stdout().lock())
}

/// Writes the Ansible playbook for all parsed DNS records to the given writer.
fn print_ansible_tasks_to<W: io::Write>(
    records: &[dns_parser::DnsRecord],
    zone: &str,
    mut out: W,
) -> Result<()> {
    writeln!(out, "---")?;
    writeln!(out, "# DNS records for zone: {zone}")?;
    writeln!(out, "# Generated by {}", crate::consts::CMD_CF2ANSIBLE)?;
    writeln!(out)?;

    for rec in records {
        let record_name = dns_parser::strip_zone(&rec.name, zone);

        let task_name = match rec.rtype.as_str() {
            "SRV" => {
                let (_, service, proto) = dns_parser::parse_srv_name(&rec.name, zone);
                let full = dns_parser::strip_zone(&rec.name, zone);
                format!(
                    "Create {} {} SRV record",
                    zone,
                    if full == "@" {
                        format!("_{service}._{proto}")
                    } else {
                        full
                    }
                )
            }
            "TLSA" => {
                let full = dns_parser::strip_zone(&rec.name, zone);
                format!("Create {zone} {full} TLSA record")
            }
            _ => format!(
                "Create {} {} {} record",
                zone,
                if record_name == "@" {
                    "@ (root)"
                } else {
                    record_name.as_str()
                },
                rec.rtype
            ),
        };

        writeln!(out, "- name: {}", yaml_escape(&task_name))?;
        writeln!(out, "  community.general.cloudflare_dns:")?;
        writeln!(out, "    zone: {}", yaml_escape(zone))?;

        match rec.rtype.as_str() {
            "SRV" => {
                let (record, service, proto) = dns_parser::parse_srv_name(&rec.name, zone);
                let parts: Vec<&str> = rec.data.split_whitespace().collect();
                let priority: u32 = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
                let weight: u32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(1);
                let port: u32 = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
                let target = parts.get(3).map(|s| s.trim_end_matches('.')).unwrap_or("");

                writeln!(out, "    record: {}", yaml_escape(&record))?;
                writeln!(out, "    type: {}", yaml_escape(&rec.rtype))?;
                writeln!(out, "    service: {}", yaml_escape(&service))?;
                writeln!(out, "    proto: {}", yaml_escape(&proto))?;
                if port != 0 {
                    writeln!(out, "    port: {port}")?;
                }
                if priority != 0 {
                    writeln!(out, "    priority: {priority}")?;
                }
                if weight != 1 {
                    writeln!(out, "    weight: {weight}")?;
                }
                writeln!(out, "    value: {}", yaml_escape(target))?;
            }
            "TLSA" => {
                let (record, port, proto) = dns_parser::parse_tlsa_name(&rec.name, zone);
                let parts: Vec<&str> = rec.data.split_whitespace().collect();
                let cert_usage: u32 = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
                let selector: u32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
                let hash_type: u32 = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
                let value = parts.get(3..).map(|p| p.join(" ")).unwrap_or_default();

                writeln!(out, "    record: {}", yaml_escape(&record))?;
                writeln!(out, "    type: {}", yaml_escape(&rec.rtype))?;
                writeln!(out, "    port: {port}")?;
                writeln!(out, "    proto: {}", yaml_escape(&proto))?;
                writeln!(out, "    cert_usage: {cert_usage}")?;
                writeln!(out, "    selector: {selector}")?;
                writeln!(out, "    hash_type: {hash_type}")?;
                writeln!(out, "    value: {}", yaml_escape(&value))?;
            }
            "MX" => {
                let parts: Vec<&str> = rec.data.splitn(2, ' ').collect();
                let priority = parts.first().map(|s| s.trim()).unwrap_or("0");
                let value = parts
                    .get(1)
                    .map(|s| s.trim_end_matches('.').trim())
                    .unwrap_or("");

                writeln!(out, "    record: {}", yaml_escape(&record_name))?;
                writeln!(out, "    type: {}", yaml_escape(&rec.rtype))?;
                writeln!(out, "    value: {}", yaml_escape(value))?;
                writeln!(out, "    priority: {priority}")?;
            }
            "TXT" => {
                let txt = dns_parser::parse_txt_data(&rec.data);
                writeln!(out, "    record: {}", yaml_escape(&record_name))?;
                writeln!(out, "    type: {}", yaml_escape(&rec.rtype))?;
                writeln!(out, "    value: {}", yaml_escape(&txt))?;
            }
            _ => {
                let value = rec.data.trim_end_matches('.');
                writeln!(out, "    record: {}", yaml_escape(&record_name))?;
                writeln!(out, "    type: {}", yaml_escape(&rec.rtype))?;
                writeln!(out, "    value: {}", yaml_escape(value))?;
            }
        }

        if rec.ttl != 1 {
            writeln!(out, "    ttl: {}", rec.ttl)?;
        }

        if dns_parser::can_proxy(&rec.rtype)
            && let Some(proxied) = rec.proxied
        {
            writeln!(
                out,
                "    proxied: {}",
                if proxied { "true" } else { "false" }
            )?;
        }

        writeln!(out, "    api_token: \"{{{{ cloudflare_api_token }}}}\"")?;
        writeln!(out, "    state: present")?;
        writeln!(out, "  tags: [\"dns\"]")?;
        writeln!(out)?;
    }

    Ok(())
}

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

    const SAMPLE_ZONE: &str = r#";;
;; Domain:     example.com.
;;
;; NS Records
example.com.	86400	IN	NS	ns1.example.com.

;; A Records
example.com.	1	IN	A	203.0.113.1 ; cf_tags=cf-proxied:true
mail.example.com.	1	IN	A	192.0.2.1 ; cf_tags=cf-proxied:false
api.example.com.	3600	IN	A	10.0.0.1

;; AAAA Records
example.com.	1	IN	AAAA	2001:db8::1

;; CNAME Records
www.example.com.	1	IN	CNAME	example.com.

;; MX Records
example.com.	1	IN	MX	5 mail.example.com.

;; TXT Records
example.com.	1	IN	TXT	"v=spf1 include:_spf.example.com ~all"
dkim._domainkey.example.com.	1	IN	TXT	"p=one" "two"

;; SRV Records (apex)
_autodiscover._tcp.example.com.	1	IN	SRV	0 1 443 mail.example.com.

;; SRV Records (subdomain)
_minecraft._tcp.mc.example.com.	1	IN	SRV	0 5 25565 mc.example.com.

;; TLSA Records
_25._tcp.mail.example.com.	1	IN	TLSA	3 1 1 CERTDATA
"#;

    #[test]
    fn output_produces_yaml() {
        let records = dns_parser::parse_zone(SAMPLE_ZONE);
        let types: Vec<&str> = records.iter().map(|r| r.rtype.as_str()).collect();
        assert!(types.contains(&"A"), "Missing A");
        assert!(types.contains(&"AAAA"), "Missing AAAA");
        assert!(types.contains(&"NS"), "Missing NS");
        assert!(types.contains(&"CNAME"), "Missing CNAME");
        assert!(types.contains(&"MX"), "Missing MX");
        assert!(types.contains(&"TXT"), "Missing TXT");
        assert!(types.contains(&"SRV"), "Missing SRV");
        assert!(types.contains(&"TLSA"), "Missing TLSA");
    }

    #[test]
    fn a_record_proxied() {
        let records = dns_parser::parse_zone(SAMPLE_ZONE);
        let apex_a = records
            .iter()
            .find(|r| r.name == "example.com." && r.rtype == "A")
            .unwrap();
        assert_eq!(apex_a.proxied, Some(true));

        let mail_a = records
            .iter()
            .find(|r| r.name == "mail.example.com." && r.rtype == "A")
            .unwrap();
        assert_eq!(mail_a.proxied, Some(false));
    }

    #[test]
    fn ns_records_not_proxied() {
        let records = dns_parser::parse_zone(SAMPLE_ZONE);
        for rec in records.iter().filter(|r| r.rtype == "NS") {
            assert_eq!(rec.proxied, None, "NS records should not have proxied flag");
        }
    }

    #[test]
    fn output_with_api_token() {
        let records = dns_parser::parse_zone(SAMPLE_ZONE);
        let zone = "example.com";
        let mut buf = Vec::new();
        let result = print_ansible_tasks_to(&records, zone, &mut buf);
        assert!(result.is_ok(), "print_ansible_tasks should succeed");
        let output = String::from_utf8(buf).unwrap();
        assert!(output.contains("api_token: \"{{ cloudflare_api_token }}\""));
    }
}