+ feat ipv6; + systemd integ
This commit is contained in:
52
src/dns.rs
Normal file
52
src/dns.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
use std::error::Error;
|
||||
use std::net::IpAddr;
|
||||
|
||||
use log::info;
|
||||
use trust_dns_proto::rr::record_type::RecordType;
|
||||
use trust_dns_proto::rr::RecordData;
|
||||
use trust_dns_resolver::config::{ResolverConfig, ResolverOpts};
|
||||
use trust_dns_resolver::TokioAsyncResolver;
|
||||
|
||||
pub async fn is_addr_current(domain: &str, ip_addr: IpAddr) -> Result<bool, Box<dyn Error>> {
|
||||
let response = TokioAsyncResolver::tokio(ResolverConfig::cloudflare(), ResolverOpts::default())
|
||||
.lookup(
|
||||
domain,
|
||||
match ip_addr {
|
||||
IpAddr::V4(_) => RecordType::A,
|
||||
IpAddr::V6(_) => RecordType::AAAA,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut record_ip: Option<IpAddr> = None;
|
||||
for record in response.into_iter() {
|
||||
record_ip = record.into_rdata().ip_addr();
|
||||
if !record_ip.is_none() && record_ip == Some(ip_addr) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"dynamic ip drift detected: {} -> {}",
|
||||
record_ip.unwrap(),
|
||||
ip_addr
|
||||
);
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod unit {
|
||||
use std::net::IpAddr;
|
||||
use std::str::FromStr;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_is_addr_current() {
|
||||
let domain = "rskio.com";
|
||||
let ip_addr = IpAddr::from_str("0.0.0.0").unwrap();
|
||||
assert_eq!(
|
||||
super::is_addr_current(domain, ip_addr).await.unwrap(),
|
||||
false
|
||||
);
|
||||
}
|
||||
}
|
||||
168
src/main.rs
168
src/main.rs
@@ -1,12 +1,12 @@
|
||||
use aws_config::meta::region::RegionProviderChain;
|
||||
use aws_sdk_route53 as route53;
|
||||
use aws_sdk_route53::types::{
|
||||
Change, ChangeAction, ChangeBatch, ResourceRecord, ResourceRecordSet,
|
||||
};
|
||||
mod dns;
|
||||
mod route53;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
use std::net::Ipv4Addr;
|
||||
use env_logger::Builder;
|
||||
use log::info;
|
||||
use reqwest::get;
|
||||
use std::net::IpAddr;
|
||||
use tokio::time::{sleep, Duration};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[clap(
|
||||
@@ -14,166 +14,48 @@ use std::net::Ipv4Addr;
|
||||
about = "A CLI tool for correcting drift between your PublicIP and Route53 DNS A RECORD"
|
||||
)]
|
||||
struct Args {
|
||||
#[clap(short, long, help = "DNS ZONE ID\t(see AWS Console Route53)")]
|
||||
zone_id: String,
|
||||
#[clap(short = 'z', long, help = "DNS ZONE ID\t(see AWS Console Route53)")]
|
||||
dns_zone_id: String,
|
||||
|
||||
#[clap(short, long, help = "DOMAIN NAME\t(ex. 'docs.rskio.com.')")]
|
||||
domain_name: String,
|
||||
}
|
||||
|
||||
const RECORD_TYPE: &'static str = "A";
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args = Args::parse();
|
||||
Builder::new().filter(None, log::LevelFilter::Info).init();
|
||||
|
||||
// get aws r53 client
|
||||
let region_provider = RegionProviderChain::default_provider();
|
||||
let config = aws_config::from_env().region(region_provider).load().await;
|
||||
let client = route53::Client::new(&config);
|
||||
info!(
|
||||
"starting with options: -z {} -d {}",
|
||||
&args.dns_zone_id, &args.domain_name,
|
||||
);
|
||||
|
||||
// get a list of resource_record_sets
|
||||
let list_resource_record_sets = client
|
||||
.list_resource_record_sets()
|
||||
.hosted_zone_id(&args.zone_id)
|
||||
.start_record_name(&args.domain_name)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
// match a single resource record_set
|
||||
let mut resource_record_set: Option<ResourceRecordSet> = None;
|
||||
|
||||
for rrs in list_resource_record_sets.resource_record_sets {
|
||||
if rrs.name.as_str() == &args.domain_name && rrs.r#type.as_str() == "A" {
|
||||
resource_record_set = Some(rrs);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// if record is none: exit
|
||||
// else shadow resource_record_set with a safe unwrap
|
||||
let resource_record_set = match resource_record_set.is_none() {
|
||||
true => {
|
||||
println!(
|
||||
"No ResourceRecordSet found in Zone: {} for Record like: {} {}",
|
||||
&args.zone_id, RECORD_TYPE, &args.domain_name,
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
false => resource_record_set.unwrap(),
|
||||
};
|
||||
|
||||
// if record contains empty resource_records, exit
|
||||
// else shadow resource_records with a safe unwrap
|
||||
let resource_records = match resource_record_set.resource_records.is_none() {
|
||||
true => {
|
||||
println!(
|
||||
"No ResourceRecord found Zone: {} for Record like: {} {}",
|
||||
&args.zone_id, RECORD_TYPE, &args.domain_name,
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
false => &resource_record_set.resource_records.unwrap(),
|
||||
};
|
||||
|
||||
// get the first ip in the DNS record
|
||||
let record_ip = resource_records[0]
|
||||
.value
|
||||
.parse::<Ipv4Addr>()
|
||||
.expect("Failed to parse IP address");
|
||||
|
||||
let public_ip: Ipv4Addr = get_public_ip().await?;
|
||||
|
||||
// if no drift detected, exit
|
||||
if record_ip == public_ip {
|
||||
println!(
|
||||
"The DNS record is currently up to date with the public IP: {}",
|
||||
record_ip
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let msg: String = format!("The dynamic IP provided by the ISP has drifted.",);
|
||||
|
||||
println!("{} {} -> {}", msg, record_ip, public_ip);
|
||||
|
||||
// prepare aws r53 change request
|
||||
let change = Change::builder()
|
||||
.action(ChangeAction::Upsert)
|
||||
.resource_record_set(
|
||||
ResourceRecordSet::builder()
|
||||
.name(resource_record_set.name.clone())
|
||||
.r#type(resource_record_set.r#type.clone())
|
||||
.ttl(resource_record_set.ttl.unwrap())
|
||||
.resource_records(
|
||||
ResourceRecord::builder()
|
||||
.set_value(Some(public_ip.to_string()))
|
||||
.build()
|
||||
.unwrap(),
|
||||
)
|
||||
.build()
|
||||
.unwrap(),
|
||||
) // Build the ResourceRecordSet
|
||||
.build()
|
||||
.unwrap(); // Build the Change
|
||||
|
||||
// Change the resource record set
|
||||
let response = client
|
||||
.change_resource_record_sets()
|
||||
.hosted_zone_id(&args.zone_id)
|
||||
.change_batch(
|
||||
ChangeBatch::builder()
|
||||
.set_changes(Some(vec![change]))
|
||||
.set_comment(Some(msg))
|
||||
.build()
|
||||
.unwrap(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
println!("Requested DNS record update to PublicIP: {}", public_ip);
|
||||
|
||||
// Get the change ID from the response
|
||||
let change_id = response.change_info.unwrap().id;
|
||||
|
||||
// Check the status of the change request every 60 seconds
|
||||
loop {
|
||||
let change_response = client.get_change().id(&change_id).send().await?;
|
||||
let public_ip = get_public_ip().await?;
|
||||
|
||||
// check the status
|
||||
if let Some(change_info) = change_response.change_info {
|
||||
println!("Change ID: {}, Status: {:?}", change_id, change_info.status);
|
||||
if !dns::is_addr_current(&args.domain_name, public_ip).await? {
|
||||
route53::update_record(&args.dns_zone_id, &args.domain_name, public_ip).await?;
|
||||
};
|
||||
|
||||
// break loop if the change is insync
|
||||
if change_info.status == route53::types::ChangeStatus::Insync {
|
||||
println!("The change request has been completed.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// sleep for 60 seconds before checking again...
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(60)).await;
|
||||
sleep(Duration::from_secs(60)).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_public_ip() -> Result<Ipv4Addr, Box<dyn std::error::Error>> {
|
||||
Ok(reqwest::get("http://ipv4.icanhazip.com")
|
||||
async fn get_public_ip() -> Result<IpAddr, Box<dyn std::error::Error>> {
|
||||
Ok(get("http://icanhazip.com")
|
||||
.await?
|
||||
.text()
|
||||
.await?
|
||||
.trim()
|
||||
.to_string()
|
||||
.parse::<Ipv4Addr>()?)
|
||||
.parse::<IpAddr>()?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::get_public_ip;
|
||||
|
||||
mod unit {
|
||||
#[tokio::test]
|
||||
async fn get_public_ip_works() {
|
||||
dbg!(get_public_ip().await.unwrap());
|
||||
async fn test_get_public_ip() {
|
||||
dbg!(super::get_public_ip().await.unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
163
src/route53.rs
Normal file
163
src/route53.rs
Normal file
@@ -0,0 +1,163 @@
|
||||
use std::error::Error;
|
||||
use std::fmt;
|
||||
use std::net::IpAddr;
|
||||
|
||||
use aws_config::meta::region::RegionProviderChain;
|
||||
use aws_sdk_route53 as r53;
|
||||
use aws_sdk_route53::types::{
|
||||
Change, ChangeAction, ChangeBatch, ResourceRecord, ResourceRecordSet,
|
||||
};
|
||||
use log::info;
|
||||
use thiserror::Error;
|
||||
use tokio::time::{sleep, Duration};
|
||||
|
||||
pub async fn update_record(
|
||||
dns_zone_id: &str,
|
||||
domain_name: &str,
|
||||
public_ip: IpAddr,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let record_type: RecordType = match public_ip {
|
||||
IpAddr::V4(_) => RecordType::A,
|
||||
IpAddr::V6(_) => RecordType::AAAA,
|
||||
};
|
||||
let client: r53::Client = get_client().await?;
|
||||
let resource_record_set: Option<ResourceRecordSet> =
|
||||
get_single_record_set(&client, &dns_zone_id, &domain_name, &record_type).await?;
|
||||
|
||||
match resource_record_set.is_none() {
|
||||
true => return Err(Box::new(Route53UpdateError::NoRecordAvailable)),
|
||||
false => {
|
||||
info!(
|
||||
"requesting update to route53 record for {} {} -> {}",
|
||||
record_type, domain_name, public_ip
|
||||
);
|
||||
return Ok(submit_single_change_request(
|
||||
&client,
|
||||
resource_record_set.unwrap(),
|
||||
&public_ip,
|
||||
&dns_zone_id,
|
||||
)
|
||||
.await?);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_client() -> Result<aws_sdk_route53::Client, Box<dyn Error>> {
|
||||
// get aws r53 client
|
||||
Ok(r53::Client::new(
|
||||
&aws_config::from_env()
|
||||
.region(RegionProviderChain::default_provider())
|
||||
.load()
|
||||
.await,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_single_record_set(
|
||||
client: &r53::Client,
|
||||
dns_zone_id: &str,
|
||||
domain_name: &str,
|
||||
record_type: &RecordType,
|
||||
) -> Result<Option<ResourceRecordSet>, Box<dyn Error>> {
|
||||
// get a list of resource_record_sets
|
||||
let list_resource_record_sets = client
|
||||
.list_resource_record_sets()
|
||||
.hosted_zone_id(dns_zone_id.to_string())
|
||||
.start_record_name(domain_name.to_string())
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
// match a single resource record_set
|
||||
let mut resource_record_set: Option<ResourceRecordSet> = None;
|
||||
for rrs in list_resource_record_sets.resource_record_sets {
|
||||
if rrs.name.as_str() == domain_name && rrs.r#type.as_str() == record_type.to_string() {
|
||||
resource_record_set = Some(rrs);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(resource_record_set)
|
||||
}
|
||||
|
||||
pub async fn submit_single_change_request(
|
||||
client: &r53::Client,
|
||||
resource_record_set: ResourceRecordSet,
|
||||
public_ip: &IpAddr,
|
||||
dns_zone_id: &str,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let change = Change::builder()
|
||||
.action(ChangeAction::Upsert)
|
||||
.resource_record_set(
|
||||
ResourceRecordSet::builder()
|
||||
.name(resource_record_set.name.clone())
|
||||
.r#type(resource_record_set.r#type.clone())
|
||||
.ttl(resource_record_set.ttl.clone().unwrap())
|
||||
.resource_records(
|
||||
ResourceRecord::builder()
|
||||
.set_value(Some(public_ip.to_string()))
|
||||
.build()
|
||||
.unwrap(),
|
||||
)
|
||||
.build()
|
||||
.unwrap(),
|
||||
)
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let msg: String = format!("ISP provided dynamic IP has drifted.");
|
||||
|
||||
// submit batch change request of the resource record set
|
||||
let response = client
|
||||
.change_resource_record_sets()
|
||||
.hosted_zone_id(dns_zone_id)
|
||||
.change_batch(
|
||||
ChangeBatch::builder()
|
||||
.set_changes(Some(vec![change]))
|
||||
.set_comment(Some(msg))
|
||||
.build()
|
||||
.unwrap(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let change_id = response.change_info.unwrap().id;
|
||||
|
||||
// check change request status every 60 seconds
|
||||
loop {
|
||||
let change_response = client.get_change().id(&change_id).send().await?;
|
||||
|
||||
if let Some(change_info) = change_response.change_info {
|
||||
info!(
|
||||
"change_id: {} has status: {:?}",
|
||||
change_id, change_info.status
|
||||
);
|
||||
|
||||
// break loop if the change is insync
|
||||
if change_info.status == r53::types::ChangeStatus::Insync {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
sleep(Duration::from_secs(180)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum Route53UpdateError {
|
||||
#[error("zone does not contain requested name record")]
|
||||
NoRecordAvailable,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum RecordType {
|
||||
A,
|
||||
AAAA,
|
||||
}
|
||||
|
||||
impl fmt::Display for RecordType {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
RecordType::A => write!(f, "A"),
|
||||
RecordType::AAAA => write!(f, "AAAA"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user