use std::fs;
use std::io;
use std::path::Path;
use color_eyre::Result;
use color_eyre::eyre::Context;
use crate::cmd::dns_parser;
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(())
}
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()
}
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()
}
}
fn print_ansible_tasks(records: &[dns_parser::DnsRecord], zone: &str) -> Result<()> {
print_ansible_tasks_to(records, zone, io::stdout().lock())
}
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 }}\""));
}
}