diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4b1fe18..f8e1427 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -19,7 +19,7 @@ jobs: steps: - name: Checkout sources uses: actions/checkout@v2 - + - uses: actions/cache@v2 with: path: | diff --git a/README.md b/README.md index 10bef12..73e6de5 100644 --- a/README.md +++ b/README.md @@ -15,14 +15,14 @@ A Unix daemon that periodically updates a DigitalOcean domain record with the cu The daemon periodically runs the following steps: * finds the current machine's public IPv4 by sending a DNS request to an OpenDNS resolver -* queries the domain records using DO's API to find the configured subdomain. If the subdomain IP - is different from the current public API, it updates the subdomain record to point to the new IP +* queries the domain records using DO's API to find the hostname to update. If the queried IP + is different from the current public API, it updates the domain record to point to the new IP ## Setup * A Unix (Linux / macOS) server to run the daemon * A DigitalOcean account with your domain associated to it -* An existing `A` record for the subdomain to be updated +* An existing `A` record for the subdomain or domain root to be updated ## Usage diff --git a/do_ddns.sample.toml b/do_ddns.sample.toml index de9aaab..a34f0aa 100644 --- a/do_ddns.sample.toml +++ b/do_ddns.sample.toml @@ -1,5 +1,7 @@ domain_root = "foo.net" subdomain_to_update = "home" +# to update the root A record +#update_domain_root = "true" update_interval = "30mins" digital_ocean_token = "aabbccddeeffgghhiijjkkllmmnnooppqqrrssttuuvvwwxxyyzz" dry_run = "false" diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 7712712..4eabb52 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -9,6 +9,8 @@ services: environment: - DO_DYNDNS_DOMAIN_ROOT=mydomain.com - DO_DYNDNS_SUBDOMAIN_TO_UPDATE=home + # to update the domain root 'A' record + # - DO_DYNDNS_UPDATE_DOMAIN_ROOT=true - DO_DYNDNS_UPDATE_INTERVAL=30mins - DO_DYNDNS_DIGITAL_OCEAN_TOKEN= - DO_DYNDNS_LOG_LEVEL=info diff --git a/src/cli.rs b/src/cli.rs index ce535d7..b9b636b 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -11,6 +11,7 @@ pub fn get_clap_matches() -> ArgMatches<'static> { "\ do_dyndns [FLAGS] [OPTIONS] do_dyndns -c -d -s -t -p + do_dyndns -d -r -t do_dyndns -c /config/ddns.toml -t do_dyndns -vvv -d foo.net -s home -i '10 mins' -p ", @@ -66,6 +67,16 @@ Env var: DO_DYNDNS_SUBDOMAIN_TO_UPDATE=home", ) .takes_value(true), ) + .arg( + Arg::with_name(UPDATE_DOMAIN_ROOT) + .short("r") + .long("update-domain-root") + .help( + "\ +If true, the provided domain root 'A' record will be updated (instead of a subdomain). +Env var: DO_DYNDNS_UPDATE_DOMAIN_ROOT=true", + ), + ) .arg( Arg::with_name(DIGITAL_OCEAN_TOKEN) .short("t") @@ -110,7 +121,7 @@ Env var: DO_DYNDNS_UPDATE_INTERVAL=2hours 30mins", .arg(Arg::with_name(DRY_RUN).short("n").long("dry-run").help( "\ Show what would have been updated. -Env var: DO_DYNDNS_DRY_RUN", +Env var: DO_DYNDNS_DRY_RUN=true", )) .get_matches(); matches diff --git a/src/config.rs b/src/config.rs index ff3a910..0f81327 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,7 +7,7 @@ use std::time::Duration as StdDuration; #[derive(Debug)] pub struct Config { pub domain_root: String, - pub subdomain_to_update: String, + pub hostname_part: String, pub update_interval: UpdateInterval, pub digital_ocean_token: SecretDigitalOceanToken, pub log_level: log::LevelFilter, diff --git a/src/config_builder.rs b/src/config_builder.rs index 9143e3c..ef8977d 100644 --- a/src/config_builder.rs +++ b/src/config_builder.rs @@ -257,6 +257,7 @@ pub struct Builder<'clap> { toml_table: Option, domain_root: Option, subdomain_to_update: Option, + update_domain_root: Option, update_interval: Option, digital_ocean_token: Option, log_level: Option, @@ -293,6 +294,7 @@ impl<'clap> Builder<'clap> { clap_matches, toml_table, domain_root: None, + update_domain_root: None, subdomain_to_update: None, update_interval: None, digital_ocean_token: None, @@ -311,6 +313,11 @@ impl<'clap> Builder<'clap> { self } + pub fn set_update_domain_root(&mut self, value: bool) -> &mut Self { + self.update_domain_root = Some(value); + self + } + pub fn set_update_interval(&mut self, value: UpdateInterval) -> &mut Self { self.update_interval = Some(value); self @@ -344,7 +351,29 @@ impl<'clap> Builder<'clap> { .with_env_var_name() .with_clap(self.clap_matches) .with_config_value(self.toml_table.as_ref()) - .build()?; + .build(); + + let update_domain_root = ValueBuilder::new(UPDATE_DOMAIN_ROOT) + .with_value(self.update_domain_root) + .with_env_var_name() + .with_clap(self.clap_matches) + .with_config_value(self.toml_table.as_ref()) + .build(); + + let hostname_part = match (subdomain_to_update, update_domain_root) { + (Ok(subdomain_to_update), Err(_)) => subdomain_to_update, + (Err(_), Ok(update_domain_root)) => { + if update_domain_root { + "@".to_owned() + } else { + bail!("Please provide a subdomain to update.") + } + } + (Err(e), Err(_)) => return Err(e), + (Ok(_), Ok(_)) => { + bail!("Both 'subdomain to update' and 'update domain root' options were set. Please provide only one of them.") + } + }; let update_interval = ValueBuilder::new(UPDATE_INTERVAL) .with_value(self.update_interval.clone()) @@ -396,7 +425,7 @@ impl<'clap> Builder<'clap> { let config = Config { domain_root, - subdomain_to_update, + hostname_part, update_interval, digital_ocean_token, log_level, diff --git a/src/config_consts.rs b/src/config_consts.rs index 3f0a08f..e80b204 100644 --- a/src/config_consts.rs +++ b/src/config_consts.rs @@ -1,6 +1,7 @@ pub static CONFIG_KEY: &str = "config"; pub static DOMAIN_ROOT: &str = "domain_root"; pub static SUBDOMAIN_TO_UPDATE: &str = "subdomain_to_update"; +pub static UPDATE_DOMAIN_ROOT: &str = "update_domain_root"; pub static UPDATE_INTERVAL: &str = "update_interval"; pub static DIGITAL_OCEAN_TOKEN: &str = "digital_ocean_token"; pub static DIGITAL_OCEAN_TOKEN_PATH: &str = "token_file_path"; diff --git a/src/domain_updater.rs b/src/domain_updater.rs index 19f155b..3dca405 100644 --- a/src/domain_updater.rs +++ b/src/domain_updater.rs @@ -11,7 +11,7 @@ use std::time::Instant; use crate::config::Config; use crate::ip_fetcher::{DnsIpFetcher, PublicIpFetcher}; -use crate::types::{DomainRecord, DomainRecords, UpdateDomainRecordResponse}; +use crate::types::{DomainFilter, DomainRecord, DomainRecords, UpdateDomainRecordResponse}; const DIGITAL_OCEAN_API_HOST_NAME: &str = "https://api.digitalocean.com"; @@ -21,7 +21,6 @@ trait DomainRecordUpdater { &self, domain_record_id: u64, domain_root: &str, - subdomain: &str, new_ip: &IpAddr, ) -> Result<()>; } @@ -52,16 +51,18 @@ impl DigitalOceanUpdater { info!("Attempting domain record update"); let records = self.get_domain_records()?; - let domain_record = - get_subdomain_record_to_update(&records, &self.config.subdomain_to_update)?; + let domain_record = get_record_to_update(&records, &self.config.hostname_part)?; if should_update_domain_ip(&public_ip, &domain_record) { info!( "Existing domain record IP does not match current public IP: '{}'; domain record IP: '{}'. Updating record", public_ip, domain_record.data ); let domain_root = &self.config.domain_root; - let subdomain = &self.config.subdomain_to_update; - self.update_domain_ip(domain_record.id, domain_root, subdomain, &public_ip)?; + if !self.config.dry_run { + self.update_domain_ip(domain_record.id, domain_root, &public_ip)?; + } else { + info!("Skipping updating IP due to dry run") + } } else { info!("Correct IP already set, nothing to do"); } @@ -75,24 +76,25 @@ impl DigitalOceanUpdater { pub fn start_update_loop(&mut self) -> Result<()> { let ip_fetcher = DnsIpFetcher::default(); - let fqdn = build_subdomain_fqdn(&self.config.domain_root, &self.config.subdomain_to_update); + let domain_filter = DomainFilter::new(&self.config.domain_root, &self.config.hostname_part); + let fqdn = domain_filter.fqdn(); let duration_formatted = format_duration(*self.config.update_interval.0); info!( - "Starting updater: domain record '{}' will be updated every {}", - fqdn, duration_formatted + "Starting updater: domain record '{}' of type '{}' will be updated every {}", + fqdn, + domain_filter.record_type(), + duration_formatted ); loop { - if !self.config.dry_run { - let attempt_result = self.attempt_update(&ip_fetcher); - if let Err(e) = attempt_result { - error!("Domain record update attempt failed: {}", e); - self.failed_attempts += 1; - } - if self.failed_attempts > 10 { - warn!("Too many failed domain record update attempts. Shutting down updater"); - break; - } + let attempt_result = self.attempt_update(&ip_fetcher); + if let Err(e) = attempt_result { + error!("Domain record update attempt failed: {}", e); + self.failed_attempts += 1; + } + if self.failed_attempts > 10 { + warn!("Too many failed domain record update attempts. Shutting down updater"); + break; } let duration_formatted = format_duration(*self.config.update_interval.0); @@ -130,21 +132,29 @@ impl DigitalOceanUpdater { fn should_exit(&self) -> bool { self.should_exit.load(Ordering::SeqCst) } + + fn build_query_filter(&self) -> Vec<(&'static str, String)> { + let domain_filter = DomainFilter::new(&self.config.domain_root, &self.config.hostname_part); + let record_type = match domain_filter { + DomainFilter::Root(_) => domain_filter.record_type().to_owned(), + DomainFilter::Subdomain(_) => domain_filter.record_type().to_owned(), + }; + vec![("type", record_type), ("name", domain_filter.fqdn())] + } } impl DomainRecordUpdater for DigitalOceanUpdater { fn get_domain_records(&self) -> Result { let domain_root = &self.config.domain_root; - let subdomain = &self.config.subdomain_to_update; let endpoint = format!("/v2/domains/{}/records", domain_root); let request_url = format!("{}{}", DIGITAL_OCEAN_API_HOST_NAME, endpoint); let access_token = &self.config.digital_ocean_token; - let subdomain_filter = build_subdomain_fqdn(&domain_root, &subdomain); + let query_filter = self.build_query_filter(); let response = self .request_client .get(&request_url) .bearer_auth(access_token.expose_secret().as_str()) - .query(&[("name", &subdomain_filter)]) + .query(&query_filter) .send() .context("Failed to query DO for domain records")?; @@ -158,10 +168,10 @@ impl DomainRecordUpdater for DigitalOceanUpdater { &self, domain_record_id: u64, domain_root: &str, - subdomain: &str, new_ip: &IpAddr, ) -> Result<()> { - let subdomain = build_subdomain_fqdn(&domain_root, &subdomain); + let domain_filter = DomainFilter::new(&self.config.domain_root, &self.config.hostname_part); + let fqdn = domain_filter.fqdn(); let endpoint = format!("/v2/domains/{}/records/{}", domain_root, domain_record_id); let request_url = format!("{}{}", DIGITAL_OCEAN_API_HOST_NAME, endpoint); let access_token = &self.config.digital_ocean_token; @@ -173,10 +183,7 @@ impl DomainRecordUpdater for DigitalOceanUpdater { .bearer_auth(access_token.expose_secret().as_str()) .json(&body) .send() - .context(format!( - "Failed to update domain record for subdomain: {}", - subdomain - ))?; + .context(format!("Failed to update domain record for: {}", fqdn))?; let record: UpdateDomainRecordResponse = response .json() @@ -187,12 +194,9 @@ impl DomainRecordUpdater for DigitalOceanUpdater { .parse::() .expect("Failed to parse IP from response"); if &response_ip != new_ip { - bail!(format!("Failed to update IP for subdomain: {}", subdomain)) + bail!(format!("Failed to update IP for: {}", fqdn)) } else { - info!( - "Successfully updated public IP for subdomain: {}", - subdomain - ); + info!("Successfully updated public IP for: {}", fqdn); } Ok(()) } @@ -204,22 +208,18 @@ impl Drop for DigitalOceanUpdater { } } -fn build_subdomain_fqdn(domain_root: &str, subdomain: &str) -> String { - format!("{}.{}", subdomain, domain_root) -} - -fn get_subdomain_record_to_update<'a>( +fn get_record_to_update<'a>( records: &'a DomainRecords, - subdomain: &str, + hostname_part: &str, ) -> Result<&'a DomainRecord> { if records.domain_records.is_empty() { - bail!("Failed to find subdomain update, domain records are empty"); + bail!("Failed to find hostname to update, retreived domain records are empty"); } records .domain_records .iter() - .find(|&record| record.name.eq(subdomain)) - .ok_or_else(|| anyhow!("Failed to find subdomain to update")) + .find(|&record| record.name.eq(hostname_part)) + .ok_or_else(|| anyhow!("Failed to find hostname to update in the retrieved domain records")) } fn should_update_domain_ip(public_ip: &IpAddr, domain_record: &DomainRecord) -> bool { @@ -275,7 +275,6 @@ mod tests { &self, domain_record_id: u64, domain_root: &str, - subdomain: &str, new_ip: &IpAddr, ) -> Result<()> { if self.return_success { @@ -305,18 +304,12 @@ mod tests { let public_ip = ip_fetcher.fetch_public_ip().unwrap(); let updater = MockUpdater::new(); let records = updater.get_domain_records().unwrap(); - let domain_record = - get_subdomain_record_to_update(&records, &config.subdomain_to_update).unwrap(); + let domain_record = get_record_to_update(&records, &config.hostname_part).unwrap(); let should_update = should_update_domain_ip(&public_ip, domain_record); assert_eq!(should_update, true); - let result = updater.update_domain_ip( - domain_record.id, - &config.domain_root, - &config.subdomain_to_update, - &public_ip, - ); + let result = updater.update_domain_ip(domain_record.id, &config.domain_root, &public_ip); assert!(result.is_err()); } } diff --git a/src/types.rs b/src/types.rs index 259e053..dc1bcd6 100644 --- a/src/types.rs +++ b/src/types.rs @@ -18,6 +18,52 @@ pub struct DomainRecords { pub struct UpdateDomainRecordResponse { pub domain_record: DomainRecord, } + +pub struct SubdomainFilter { + domain_root: String, + subdomain: String, +} + +impl SubdomainFilter { + pub fn new(domain_root: &str, subdomain: &str) -> Self { + SubdomainFilter { + domain_root: domain_root.to_owned(), + subdomain: subdomain.to_owned(), + } + } +} + +pub enum DomainFilter { + Root(String), + Subdomain(SubdomainFilter), +} + +impl DomainFilter { + pub fn new(domain_root: &str, hostname_part: &str) -> Self { + if hostname_part == "@" { + DomainFilter::Root(domain_root.to_owned()) + } else { + DomainFilter::Subdomain(SubdomainFilter::new(domain_root, hostname_part)) + } + } + + pub fn fqdn(&self) -> String { + match self { + DomainFilter::Root(domain_root) => domain_root.to_string(), + DomainFilter::Subdomain(filter) => { + format!("{}.{}", filter.subdomain, filter.domain_root) + } + } + } + + pub fn record_type(&self) -> &str { + match self { + DomainFilter::Root(_) => "A", + DomainFilter::Subdomain(_) => "A", + } + } +} + pub trait ValueFromStr: Sized { type Err; fn from_str(s: &str) -> Result;