From 84ac0a23737fa9cd14d86326aed7ace2cd696996 Mon Sep 17 00:00:00 2001 From: Evelyn Alicke Date: Mon, 13 Nov 2023 09:42:18 +0100 Subject: [PATCH] feat: basic challange implementation --- src/cli.rs | 31 ++++++++++ src/config.rs | 33 +++++++--- src/happy.rs | 38 ++++++++++++ src/main.rs | 163 ++++++++++++++++++++++++++++++++++++-------------- 4 files changed, 212 insertions(+), 53 deletions(-) create mode 100644 src/cli.rs create mode 100644 src/happy.rs diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..306e004 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,31 @@ +use clap::{Parser, Subcommand}; + +#[derive(Parser)] +#[command(author, long_about = None)] +pub (crate) struct CliArgs { + #[arg(short, long)] + pub config: std::path::PathBuf, + + #[arg(short, long)] + pub identifier: hickory_client::rr::Name, + + #[arg(short, long)] + pub proof: String, + + #[arg(short, long)] + pub debug: Option, + + #[command(subcommand)] + pub command: Option, +} +#[derive(Subcommand)] +pub enum Commands { + /// sets the challenge record + Set, + /// removes the challenge record + Cleanup, +} + +pub(crate) fn parse() -> CliArgs { + CliArgs::parse() +} diff --git a/src/config.rs b/src/config.rs index d343da3..3f651b1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,13 +1,16 @@ -use serde::{Deserialize, Deserializer}; -use serde_with::{serde_as, MapPreventDuplicates, DisplayFromStr, base64::Base64}; use anyhow::{Context, Result}; +use hickory_client::client::SyncClient; +use hickory_client::rr::rdata::tsig::TsigAlgorithm; +use hickory_client::rr::Name; +use hickory_client::udp::UdpClientConnection; +use hickory_proto::rr::dnssec::tsig::TSigner; +use serde::{Deserialize, Deserializer}; +use serde_with::{base64::Base64, serde_as, DisplayFromStr, MapPreventDuplicates}; use std::collections::HashMap; use std::str::FromStr; -use trust_dns_client::rr::Name; -use trust_dns_client::rr::rdata::tsig::{TsigAlgorithm}; #[serde_as] -#[derive(Deserialize)] +#[derive(Deserialize, Debug)] pub(crate) struct Config { pub(crate) resolver: std::net::SocketAddr, #[serde_as(as = "MapPreventDuplicates")] @@ -15,7 +18,7 @@ pub(crate) struct Config { } #[serde_as] -#[derive(Deserialize)] +#[derive(Deserialize, Debug)] pub(crate) struct ZoneConfig { pub(crate) primary_ns: std::net::SocketAddr, #[serde_as(as = "DisplayFromStr")] @@ -26,15 +29,27 @@ pub(crate) struct ZoneConfig { pub(crate) tsig_algorithm: TsigAlgorithm, } +impl ZoneConfig { + pub fn create_client(&self) -> SyncClient { + let signer = TSigner::new( + self.tsig_key.clone(), + self.tsig_algorithm.clone(), + self.tsig_name.clone(), + 300, + ) + .unwrap(); + let conn = UdpClientConnection::new(self.primary_ns.clone()).unwrap(); + SyncClient::with_tsigner(conn, signer) + } +} + pub(crate) fn read_config(path: &std::path::Path) -> Result { // TODO: error message on config not found let config_file_content = std::fs::read_to_string(path).context("Couldn't read config file")?; toml::from_str(&config_file_content).context("Couldn't parse config file") } -fn deserialize_tsig_algorithm<'de, D>( - deserializer: D -) -> Result +fn deserialize_tsig_algorithm<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, { diff --git a/src/happy.rs b/src/happy.rs new file mode 100644 index 0000000..d459fd1 --- /dev/null +++ b/src/happy.rs @@ -0,0 +1,38 @@ +use std::str::FromStr; + +use hickory_client::{ + client::{Client, SyncClient}, + error::ClientResult, + op::DnsResponse, + rr::{ + Name, + RecordType, + DNSClass + }, + udp::UdpClientConnection, +}; + +/* +TODO: +This needs to be TCP and have Timeout Retries + +*/ +pub fn search( + identifier: Name, + rtype: RecordType, +) -> anyhow::Result { + debug!("searching {:?} for {}", rtype, identifier); + let socket_addr = std::net::SocketAddr::new(std::net::IpAddr::from_str("1.1.1.1")?, 53); + let client = happy_eyeballs::connect(socket_addr)?; + let questionable_addr = client.peer_addr()?; + let conn = UdpClientConnection::new(socket_addr).unwrap(); + let client: SyncClient = SyncClient::new(conn); + + let q: ClientResult = client.query(&identifier, DNSClass::IN, rtype); + let response = match q { + Ok(response) => response, + Err(e) => panic!("{}", e), + }; + trace!("found {:?}", response); + Ok(response) +} diff --git a/src/main.rs b/src/main.rs index 8eca046..db557c7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,61 +1,136 @@ -use anyhow::{Context}; -use clap::Parser; + +use crate::config::read_config; +use anyhow::Context; +use hickory_proto::xfer::DnsResponse; + use std::str::FromStr; use time::Duration; -use trust_dns_client::client::{Client, SyncClient}; -use trust_dns_client::udp::UdpClientConnection; -use trust_dns_client::op::DnsResponse; -use trust_dns_client::rr::dnssec::tsig::{TSigner}; -use trust_dns_client::rr::rdata::{TXT}; -use trust_dns_client::rr::{Name, RData, Record, RecordType}; -use crate::config::{read_config}; -mod config; +use hickory_client::{ + client::Client, + op::ResponseCode, + rr::{Name, RData, Record, RecordType, rdata::TXT}, +}; -#[derive(Parser)] -#[command(author, version, about, long_about = None)] -struct Cli { - #[arg(long)] - config: std::path::PathBuf, - #[arg(long)] - identifier: String, - #[arg(long)] - proof: String, -} +use log::{debug, info, trace}; +extern crate pretty_env_logger; +#[macro_use] +extern crate log; + +mod happy; +mod config; +mod cli; fn main() -> anyhow::Result<()> { - let cli = Cli::parse(); + + pretty_env_logger::init_timed(); + + let cli = cli::parse(); + let config = read_config(&cli.config)?; - let mut record = find_record(&cli.identifier)?; - let origin = record.name().clone(); // TODO: find out zone via SOA + debug!("initializing with zones {:?}", config.zones.keys()); - let zone_config = &config.zones.get(record.name()).unwrap(); // TODO: Handle None - let tsigner = TSigner::new(zone_config.tsig_key.clone(), - zone_config.tsig_algorithm.clone(), - zone_config.tsig_name.clone(), 300).unwrap(); + let challenge_name: Name = find_record(cli.identifier)?; + info!("challenge name is {}", challenge_name); - let conn = UdpClientConnection::new(zone_config.primary_ns).unwrap(); - let client = SyncClient::with_tsigner(conn, tsigner); + let zone: Name = find_zone(challenge_name.clone())?; + info!("challenge zone is {}", zone); - record.set_data(Some(RData::TXT(TXT::new(Vec::from([cli.proof]))))); - let result = client.create(record, origin).context("Couldn't set record")?; + let primary_ns_client = &config.zones.get(&zone) + .context("Couldn't find challenge zone in config")? + .create_client(); - Ok(()) + let challenge_record = Record::from_rdata( + challenge_name.clone(), + Duration::minutes(1).whole_seconds() as u32, + RData::TXT( + TXT::new( + Vec::from([cli.proof]) + ) + ), + ); + + match &cli.command.unwrap() { + cli::Commands::Set => { + let result = primary_ns_client + .create(challenge_record.clone(), zone) + .context("Couldn't set record")?; + match result.response_code() { + ResponseCode::NoError => { + info!("record was successfully set"); + Ok(()) + }, + ResponseCode::YXRRSet => { + warn!("{:?} already exists", challenge_record.clone()); + Ok(()) + }, + _ => todo!() + } + }, + cli::Commands::Cleanup => { + let result = primary_ns_client + .delete_rrset(challenge_record.clone(), zone) + .context("Couldn't remove record")?; + match result.response_code() { + ResponseCode::NoError => { + info!("record was removed"); + Ok(()) + }, + _ => { + debug!("{:?}",result); + Ok(()) + }, + } + } + } } -fn find_record(identifier: &String) -> anyhow::Result { - let name = Name::from_str(&("_acme-challenge.".to_owned() + identifier)).context("Invalid input for identifier")?; +fn find_record(identifier: Name) -> anyhow::Result { + let prefix: Name = Name::from_str("_acme-challenge").unwrap(); + let q = happy::search( + prefix.append_name(&identifier)?, // this should be called prepend because that's what it actualyl does + RecordType::TXT, + )?; + let last_record = q.answers().last().unwrap().clone().into_parts(); + let challenge_name = match last_record.rdata.unwrap() { + RData::CNAME(name) => name.0, + RData::TXT(_) => { + info!( + "found existing TXT record for {:?}", + last_record.name_labels + ); + last_record.name_labels + } + _ => panic!("unexpected record response"), + }; + Ok(challenge_name) +} - // TODO: traverse CNAMEs, find last CNAME - // let address = "1.1.1.1:53".parse().unwrap(); - // let conn = UdpClientConnection::new(address).unwrap(); - // let client = SyncClient::new(conn); - // let response: DnsResponse = client.query(&name, DNSClass::IN, RecordType::TXT).unwrap(); - // let answers: &[Record] = response.answers(); - // println!("answer: {:?}", answers); +fn find_zone(identifier: Name) -> anyhow::Result { - return Ok(Record::with(name, - RecordType::TXT, - Duration::minutes(1).whole_seconds() as u32)); + fn from_name_labels(response: DnsResponse) -> Option { + response + .name_servers() + .iter() + .map(|record| record.clone().into_parts().name_labels) + .last() + } + let q = happy::search(identifier.clone(), RecordType::SOA)?; + let name = match q.response_code() { + ResponseCode::NoError => from_name_labels(q), + ResponseCode::NXDomain => from_name_labels(q), + /* + ResponseCode::NoError => { + let cut_id = identifier.base_name(); + trace!("new search id {:?}", cut_id); + Some(find_zone(cut_id)?) + } */ + _ => { + error!("unhandled response type, body was: {:?}", q); + None + } + } + .unwrap(); + Ok(name) }