Skip to content

Commit

Permalink
Allow updating the root domain
Browse files Browse the repository at this point in the history
  • Loading branch information
alcroito committed Mar 5, 2021
1 parent d48e5a4 commit c27a990
Show file tree
Hide file tree
Showing 10 changed files with 143 additions and 59 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
steps:
- name: Checkout sources
uses: actions/checkout@v2

- uses: actions/cache@v2
with:
path: |
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions do_ddns.sample.toml
Original file line number Diff line number Diff line change
@@ -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"
2 changes: 2 additions & 0 deletions docker/docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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=<token>
- DO_DYNDNS_LOG_LEVEL=info
Expand Down
13 changes: 12 additions & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pub fn get_clap_matches() -> ArgMatches<'static> {
"\
do_dyndns [FLAGS] [OPTIONS]
do_dyndns -c <CONFIG_PATH> -d <DOMAIN> -s <SUBDOMAIN> -t <TOKEN> -p <TOKEN_PATH>
do_dyndns -d <DOMAIN> -r -t <TOKEN>
do_dyndns -c /config/ddns.toml -t <TOKEN>
do_dyndns -vvv -d foo.net -s home -i '10 mins' -p <TOKEN_PATH>
",
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
33 changes: 31 additions & 2 deletions src/config_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ pub struct Builder<'clap> {
toml_table: Option<toml::value::Table>,
domain_root: Option<String>,
subdomain_to_update: Option<String>,
update_domain_root: Option<bool>,
update_interval: Option<UpdateInterval>,
digital_ocean_token: Option<SecretDigitalOceanToken>,
log_level: Option<log::LevelFilter>,
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/config_consts.rs
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
95 changes: 44 additions & 51 deletions src/domain_updater.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -21,7 +21,6 @@ trait DomainRecordUpdater {
&self,
domain_record_id: u64,
domain_root: &str,
subdomain: &str,
new_ip: &IpAddr,
) -> Result<()>;
}
Expand Down Expand Up @@ -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");
}
Expand All @@ -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);
Expand Down Expand Up @@ -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<DomainRecords> {
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")?;

Expand All @@ -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;
Expand All @@ -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()
Expand All @@ -187,12 +194,9 @@ impl DomainRecordUpdater for DigitalOceanUpdater {
.parse::<IpAddr>()
.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(())
}
Expand All @@ -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 {
Expand Down Expand Up @@ -275,7 +275,6 @@ mod tests {
&self,
domain_record_id: u64,
domain_root: &str,
subdomain: &str,
new_ip: &IpAddr,
) -> Result<()> {
if self.return_success {
Expand Down Expand Up @@ -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());
}
}
Loading

0 comments on commit c27a990

Please sign in to comment.