#![forbid(unsafe_code)]
#![warn(
clippy::pedantic,
missing_docs,
missing_debug_implementations,
missing_copy_implementations,
trivial_casts,
trivial_numeric_casts,
unused_extern_crates,
unused_import_braces,
unused_qualifications,
variant_size_differences
)]
mod api;
mod config;
use clap::command;
use clap::Parser;
use getip::{get_ip, IpScope, IpType};
use log::{debug, error, info};
use simplelog::{ColorChoice, ConfigBuilder, LevelFilter, TermLogger, TerminalMode};
use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::process::exit;
use std::str::FromStr;
use std::sync::Arc;
use tokio::process::Command;
use tokio::{join, sync::RwLock, time};
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Args {
#[arg(short='f', long, default_value_t=String::from("namecom_ddns.toml"))]
config_file: String,
#[arg(short = 's', long)]
oneshot: bool,
#[arg(short, long, default_value_t=LevelFilter::Info)]
log_level: LevelFilter,
}
#[tokio::main]
async fn main() {
let args = Args::parse();
let log_format = ConfigBuilder::new()
.set_time_offset_to_local()
.or_else::<&mut ConfigBuilder, _>(Ok)
.unwrap()
.set_time_format_rfc3339()
.build();
if let Err(e) = TermLogger::init(
args.log_level,
log_format,
TerminalMode::Mixed,
ColorChoice::Auto,
) {
panic!("Cannot create logger: {e:?}");
}
let configuration =
config::NameComDdnsConfig::from_file(args.config_file).expect("Cannot open configuration");
let mut interval = time::interval(time::Duration::from_secs(configuration.core.interval * 60));
debug!("Configuration: {configuration:?}");
let url = configuration.core.url;
let client = api::NameComDnsApi::create(
&configuration.core.username,
&configuration.core.key,
&url,
configuration.core.timeout,
)
.unwrap();
let app = DdnsApp::new(&configuration.records, &client);
if args.oneshot {
exit(i32::from(!(app.update_once().await)));
} else {
app.updater_loop(&mut interval).await;
}
}
async fn get_ip_from_command(ip_type: IpType, command: &[String]) -> Result<IpAddr, getip::Error> {
match Command::new(&command[0]).args(&command[1..]).output().await {
Ok(output) => {
if output.status.success() {
let ip_str = String::from_utf8(output.stdout)?;
let ip = match ip_type {
IpType::Ipv4 => IpAddr::V4(Ipv4Addr::from_str(ip_str.trim())?),
IpType::Ipv6 => IpAddr::V6(Ipv6Addr::from_str(ip_str.trim())?),
};
Ok(ip)
} else {
error!(
"Command {:?} failed with status: {}",
command, output.status
);
Err(getip::Error::NonZeroExit(output.status))
}
}
Err(error) => {
error!("Command {command:?} failed to be executed: {error}");
Err(getip::Error::IoError(error))
}
}
}
struct DdnsApp<'a> {
client: &'a api::NameComDnsApi,
records: &'a [config::NameComConfigRecord],
id_cache: Arc<RwLock<HashMap<config::NameComConfigRecord, i32>>>,
}
impl<'a> DdnsApp<'a> {
fn new(records: &'a [config::NameComConfigRecord], client: &'a api::NameComDnsApi) -> Self {
Self {
client,
records,
id_cache: Arc::new(RwLock::new(HashMap::with_capacity(records.len()))),
}
}
async fn update_once(&self) -> bool {
futures::future::join_all(
self.records
.iter()
.map(|item| self.update_single_item(item)),
)
.await
.iter()
.all(|a| *a)
}
async fn updater_loop(&self, interval: &mut time::Interval) {
loop {
interval.tick().await;
info!("Checking and updating addresses");
self.update_once().await;
info!("Finished checking and updating addresses");
}
}
async fn get_id(&self, item: &config::NameComConfigRecord) -> reqwest::Result<Option<i32>> {
let id = {
let cache = self.id_cache.read().await;
(|| Some(*cache.get(item)?))()
};
Ok(if id.is_none() {
let matches = self
.client
.search_records(&item.zone, item.rec_type, Some(&item.host))
.await?;
if matches.is_empty() {
None
} else {
self.id_cache.write().await.insert(item.clone(), matches[0]);
Some(matches[0])
}
} else {
id
})
}
async fn update_single_item(&self, item: &config::NameComConfigRecord) -> bool {
let (answer, old) = join!(self.get_ip_by_item(item), self.get_id(item));
if let Ok(addr) = answer {
info!("Received answer for {} is {}", item.host, addr);
let new_record = api::NameComNewRecord {
host: Some(item.host.clone()),
rec_type: item.rec_type,
answer: addr.to_string(),
ttl: item.ttl,
priority: None,
};
match old {
Ok(maybe_id) => {
let update_result = if let Some(id) = maybe_id {
self.client.update_record(&item.zone, id, &new_record).await
} else {
self.client.create_record(&item.zone, &new_record).await
};
if let Err(error) = update_result {
error!(
"Failed to update the record for {} via API: {:?}",
item.host, error
);
false
} else {
true
}
}
Err(error) => {
error!(
"Failed to query records of {} via API: {:?}",
item.host, error
);
false
}
}
} else {
error!(
"Failed to receive the IP for {}: {:?}",
item.host,
answer.unwrap_err()
);
false
}
}
async fn get_ip_by_item(
&self,
item: &config::NameComConfigRecord,
) -> Result<IpAddr, getip::Error> {
match (item.rec_type, item.method) {
(api::RecordType::A, config::NameComConfigMethod::Global) => {
get_ip(IpType::Ipv4, IpScope::Global, None).await
}
(api::RecordType::Aaaa, config::NameComConfigMethod::Global) => {
get_ip(IpType::Ipv6, IpScope::Global, None).await
}
(api::RecordType::A, config::NameComConfigMethod::Local) => {
if item.interface.is_some() {
get_ip(
IpType::Ipv4,
IpScope::Local,
Some(item.interface.as_ref().unwrap()),
)
.await
} else {
error!(
"Record {} is local but has no interface specified",
item.host
);
Err(getip::Error::NoAddress)
}
}
(api::RecordType::Aaaa, config::NameComConfigMethod::Local) => {
if item.interface.is_some() {
get_ip(
IpType::Ipv6,
IpScope::Local,
Some(item.interface.as_ref().unwrap()),
)
.await
} else {
error!(
"Record {} is local but has no interface specified",
item.host
);
Err(getip::Error::NoAddress)
}
}
(api::RecordType::A, config::NameComConfigMethod::Script) => {
if item.command.is_some() {
get_ip_from_command(IpType::Ipv4, item.command.as_ref().unwrap()).await
} else {
error!(
"Record {} is script but has no command specified",
item.host
);
Err(getip::Error::NoAddress)
}
}
(api::RecordType::Aaaa, config::NameComConfigMethod::Script) => {
if item.command.is_some() {
get_ip_from_command(IpType::Ipv6, item.command.as_ref().unwrap()).await
} else {
error!(
"Record {} is script but has no command specified",
item.host
);
Err(getip::Error::NoAddress)
}
}
_ => panic!(
"Record type {} is not one of \"A\" and \"AAAA\"",
item.rec_type
),
}
}
}