diff --git a/Cargo.lock b/Cargo.lock index d599013..279742c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -180,6 +180,12 @@ dependencies = [ "syn 2.0.16", ] +[[package]] +name = "atomic" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" + [[package]] name = "autocfg" version = "1.1.0" @@ -426,6 +432,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34d21f9bf1b425d2968943631ec91202fe5e837264063503708b83013f8fc938" dependencies = [ "clap_builder", + "clap_derive", + "once_cell", ] [[package]] @@ -442,6 +450,18 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9644cd56d6b87dbe899ef8b053e331c0637664e9e21a33dfcdc36093f5c5c4" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.16", +] + [[package]] name = "clap_lex" version = "0.4.1" @@ -626,8 +646,11 @@ dependencies = [ "diesel", "diesel_migrations", "directories", + "figment", + "figment_file_provider_adapter", "http", "humantime", + "humantime-serde", "hyper", "itertools", "libsqlite3-sys", @@ -645,7 +668,6 @@ dependencies = [ "tailsome", "tempfile", "tokio", - "toml 0.7.3", "tower", "tower-http", "tracing", @@ -759,6 +781,31 @@ dependencies = [ "instant", ] +[[package]] +name = "figment" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e56602b469b2201400dec66a66aec5a9b8761ee97cd1b8c96ab2483fcc16cc9" +dependencies = [ + "atomic", + "parking_lot", + "pear", + "serde", + "tempfile", + "toml", + "uncased", + "version_check", +] + +[[package]] +name = "figment_file_provider_adapter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33106424fdbb9b1fd89c18072ba94666496a8a468178911b832a3e406988500" +dependencies = [ + "figment", +] + [[package]] name = "flate2" version = "1.0.26" @@ -1046,6 +1093,16 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" +[[package]] +name = "humantime-serde" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57a3db5ea5923d99402c94e9feb261dc5ee9b4efa158b0315f788cf549cc200c" +dependencies = [ + "humantime", + "serde", +] + [[package]] name = "hyper" version = "0.14.26" @@ -1150,6 +1207,12 @@ dependencies = [ "serde", ] +[[package]] +name = "inlinable_string" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" + [[package]] name = "instant" version = "0.1.12" @@ -1374,7 +1437,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c493c09323068c01e54c685f7da41a9ccf9219735c3766fbfd6099806ea08fbc" dependencies = [ "serde", - "toml 0.5.11", + "toml", ] [[package]] @@ -1689,6 +1752,29 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "pear" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec95680a7087503575284e5063e14b694b7a9c0b065e5dceec661e0497127e8" +dependencies = [ + "inlinable_string", + "pear_codegen", + "yansi", +] + +[[package]] +name = "pear_codegen" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9661a3a53f93f09f2ea882018e4d7c88f6ff2956d809a276060476fd8c879d3c" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.16", +] + [[package]] name = "percent-encoding" version = "2.2.0" @@ -1772,6 +1858,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "606c4ba35817e2922a308af55ad51bab3645b59eae5c570d4a6cf07e36bd493b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.16", + "version_check", + "yansi", +] + [[package]] name = "quick-error" version = "1.2.3" @@ -2041,6 +2140,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9bd1c54ea06cfd2f6b63219704de0b9b4f72dcc2b8fdef820be6cd799780e91e" dependencies = [ + "serde", "zeroize", ] @@ -2137,15 +2237,6 @@ dependencies = [ "thiserror", ] -[[package]] -name = "serde_spanned" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0efd8caf556a6cebd3b285caf480045fcc1ac04f6bd786b09a6f11af30c4fcf4" -dependencies = [ - "serde", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2453,40 +2544,6 @@ dependencies = [ "serde", ] -[[package]] -name = "toml" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b403acf6f2bb0859c93c7f0d967cb4a75a7ac552100f9322faf64dc047669b21" -dependencies = [ - "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", -] - -[[package]] -name = "toml_datetime" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ab8ed2edee10b50132aed5f331333428b011c99402b5a534154ed15746f9622" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_edit" -version = "0.19.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "239410c8609e8125456927e6707163a3b1fdb40561e4b803bc041f466ccfdc13" -dependencies = [ - "indexmap", - "serde", - "serde_spanned", - "toml_datetime", - "winnow", -] - [[package]] name = "tower" version = "0.4.13" @@ -2676,6 +2733,15 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba" +[[package]] +name = "uncased" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b9bc53168a4be7402ab86c3aad243a84dd7381d09be0eddc81280c1da95ca68" +dependencies = [ + "version_check", +] + [[package]] name = "unicase" version = "2.6.0" @@ -3058,15 +3124,6 @@ version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" -[[package]] -name = "winnow" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61de7bac303dc551fe038e2b3cef0f571087a47571ea6e79a87692ac99b99699" -dependencies = [ - "memchr", -] - [[package]] name = "winreg" version = "0.10.1" @@ -3076,6 +3133,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "yansi" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" + [[package]] name = "zeroize" version = "1.6.0" diff --git a/config/do_ddns.sample.toml b/config/do_ddns.sample.toml index c03d8b9..dceec40 100644 --- a/config/do_ddns.sample.toml +++ b/config/do_ddns.sample.toml @@ -7,16 +7,16 @@ update_interval = "30mins" digital_ocean_token = "aabbccddeeffgghhiijjkkllmmnnooppqqrrssttuuvvwwxxyyzz" # Setting this option to true will cause the final IP updates to be skipped. -dry_run = "false" +dry_run = false # Setting this option to true will enable resolving of ipv6 addresses and # storing them in AAAA records. -# ipv6 = "true" +# ipv6 = true # Enable collection of statistics (how often does the public IP change) in # a local sqlite database. # Disabled by default. -# collect_stats = "true" +# collect_stats = true # File path where the sqlite database with statistics will be stored. # By default stored in one of the following locations: @@ -26,16 +26,16 @@ dry_run = "false" # Enable web server to visualize collected statistics. # Disabled by default. -# enable_web = "true" +# enable_web = true # An IPv4 / IPv6 address or host name where to serve HTTP pages on. # In case of host that has a dual IP stack, both will be used. # Default is localhost. -# listen_hostname = "true" +# listen_hostname = true # Port number where to serve HTTP pages on. # Default is 8095. -# listen_port = "8095" +# listen_port = 8095 ## Simple config mode sample @@ -48,7 +48,7 @@ subdomain_to_update = "home" # Updates the IP of the 'mysite.com' A record. # domain_root = "mysite.com" -# update_domain_root = "true" +# update_domain_root = true ## Advanced config mode sample diff --git a/crates/dyndns/Cargo.toml b/crates/dyndns/Cargo.toml index f3c6876..7435ac3 100644 --- a/crates/dyndns/Cargo.toml +++ b/crates/dyndns/Cargo.toml @@ -40,20 +40,22 @@ web = [ [dependencies] chrono = { version = "0.4", default-features = false, features = ["alloc", "serde", "clock"] } -clap = { version = "4", features = ["cargo"] } +clap = { version = "4", features = ["cargo", "derive"] } color-eyre = "0.6" +figment = { version = "0.10", features = ["env", "toml", "test"] } +figment_file_provider_adapter = "0.1" humantime = "2" +humantime-serde = "1" itertools = "0.10" native-tls = { version = "0.2", features = ["vendored"] } once_cell = "1" reqwest = { version = "0.11", features = ["blocking", "json"] } -secrecy = "0.8" +secrecy = { version = "0.8", features = ["serde"] } serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" serde_with = "3" signal-hook = { version = "0.3", features = ["extended-siginfo"] } tailsome = "1" -toml = "0.7" tracing = "0.1" tracing-log = "0.1" tracing-subscriber = "0.3" diff --git a/crates/dyndns/src/cli.rs b/crates/dyndns/src/cli.rs index 69792f5..271cddf 100644 --- a/crates/dyndns/src/cli.rs +++ b/crates/dyndns/src/cli.rs @@ -1,11 +1,162 @@ -use clap::{crate_version, Arg, ArgMatches, Command}; +use clap::{crate_version, ArgMatches, Args, Command}; +use serde::Serialize; +use serde_with::skip_serializing_none; -use crate::config::consts::*; +use crate::{config::app_config::UpdateInterval, token::SecretDigitalOceanToken}; pub fn get_cli_args() -> ArgMatches { get_cli_command_definition().get_matches() } +#[skip_serializing_none] +#[derive(Args, Debug, Serialize)] +pub struct CommonArgs { + /// Path to TOML config file. + /// + /// Default config path when none specified: '$PWD/config/do_ddns.toml' + /// Env var: DO_DYNDNS_CONFIG=/config/do_ddns.toml", + #[arg(short = 'c', long = "config", id = "config")] + pub config_file_path: Option, + + /// Increases the level of verbosity. Repeat for more verbosity. + /// + /// Env var: DO_DYNDNS_LOG_LEVEL=info [error|warn|info|debug|trace] + #[arg(short = 'v', action = clap::ArgAction::Count, id = "v")] + pub log_level: Option, + + /// The domain root for which the domain record will be changed. + /// + /// Example: 'foo.net' + /// Env var: DO_DYNDNS_DOMAIN_ROOT=foo.net" + #[arg(short = 'd', long)] + pub domain_root: Option, + + /// The subdomain for which the public IP will be updated. + /// + /// Example: 'home' + /// Env var: DO_DYNDNS_SUBDOMAIN_TO_UPDATE=home + #[arg(short = 's', long)] + pub subdomain_to_update: Option, + + /// If true, the provided domain root 'A' record will be updated (instead of a subdomain). + /// + /// Env var: DO_DYNDNS_UPDATE_DOMAIN_ROOT=true + #[arg(short = 'r', long, conflicts_with = "subdomain_to_update", default_missing_value = "true", num_args = 0..=1)] + pub update_domain_root: Option, + + /// The digital ocean access token. + /// + /// Example: 'abcdefghijklmnopqrstuvwxyz' + /// Env var: DO_DYNDNS_DIGITAL_OCEAN_TOKEN=abcdefghijklmnopqrstuvwxyz" + #[arg(short = 't', long, value_parser = crate::token::parse_secret_token)] + pub digital_ocean_token: Option, + + /// Path to file containing the digital ocean token on its first line. + /// + /// Example: '/config/secret_token.txt' + #[arg( + short = 'p', + long = "token-file-path", + conflicts_with = "digital_ocean_token", + id = "token_file_path" + )] + pub digital_ocean_token_path: Option, + + /// How often should the domain be updated. + /// + /// Default is every 10 minutes. + /// Uses rust's humantime format. + /// Example: '15 mins 30 secs' + /// Env var: DO_DYNDNS_UPDATE_INTERVAL=2hours 30mins + #[arg(short = 'i', long)] + pub update_interval: Option, + + /// Show what would have been updated. + /// + /// Env var: DO_DYNDNS_DRY_RUN=true + #[arg(short = 'n', long, default_missing_value = "true", num_args = 0..=1)] + pub dry_run: Option, + + /// Enable ipv6 support (disabled by default). + /// + /// Env var: DO_DYNDNS_IPV6_SUPPORT=true" + // num_args + default_missing_value emulates a flag action::SetTrue, which + // preserves None when nothing is passed + #[arg(long = "enable-ipv6", id = "ipv6", default_missing_value = "true", num_args = 0..=1)] + #[serde(rename = "ipv6")] + pub ipv6_support: Option, + + /// Output build info like git commit sha, rustc version, etc + #[arg(long = "build-info")] + pub build_info: bool, +} + +#[skip_serializing_none] +#[derive(Args, Debug, Serialize)] +pub struct ConditionalArgs { + /// Enable collection of statistics (how often does the public IP change). + /// + /// Env var: DO_DYNDNS_COLLECT_STATS=true" + #[arg(long, default_missing_value = "true", num_args = 0..=1)] + #[cfg_attr(not(feature = "stats"), arg(hide = true))] + pub collect_stats: Option, + + /// File path where a sqlite database with statistics will be stored. + /// + /// Env var: DO_DYNDNS_DATABASE_PATH=/tmp/dyndns_stats_db.sqlite + #[arg(long = "database-path", id = "database_path")] + #[cfg_attr(not(feature = "stats"), arg(hide = true))] + #[serde(rename = "database_path")] + pub db_path: Option, + + /// Enable web server to visualize collected statistics. + /// + /// Env var: DO_DYNDNS_ENABLE_WEB=true + #[arg(long, default_missing_value = "true", num_args = 0..=1)] + #[cfg_attr(not(feature = "web"), arg(hide = true))] + pub enable_web: Option, + + /// An IP address or host name where to serve HTTP pages on. + /// + /// Env var: DO_DYNDNS_LISTEN_HOSTNAME=192.168.0.1 + #[arg(long)] + #[cfg_attr(not(feature = "web"), arg(hide = true))] + pub listen_hostname: Option, + + /// Port numbere where to serve HTTP pages on. + /// + /// Env var: DO_DYNDNS_LISTEN_PORT=8080 + #[arg(long)] + #[cfg_attr(not(feature = "web"), arg(hide = true))] + pub listen_port: Option, +} + +#[skip_serializing_none] +#[derive(Args, Debug, Serialize)] +pub struct ClapAllArgs { + #[command(flatten)] + #[serde(flatten)] + common_args: CommonArgs, + + #[command(flatten)] + #[serde(flatten)] + conditional_args: ConditionalArgs, +} + +impl ClapAllArgs { + pub fn parse_and_process(clap_matches: &ArgMatches) -> Result { + use clap::FromArgMatches; + let mut args = Self::from_arg_matches(clap_matches)?; + // clap doesn't support generic mapping of argument values when using ArgAction::Count + // So we manually reset the log level to None if count was 0 (aka none was specified). + // This ensures the value is not serialized and used the by the configuration merging. + if let Some(0) = args.common_args.log_level { + args.common_args.log_level = None; + } + Ok(args) + } +} + fn get_cli_command_definition_base() -> Command { Command::new("DigitalOcean dynamic dns updater") .version(crate_version!()) @@ -65,196 +216,13 @@ type = \"A\" name = \"crib\" ", ) - .arg( - Arg::new(CONFIG_KEY) - .short('c') - .long(CONFIG_KEY) - .value_name("FILE") - .help( - "\ -Path to TOML config file. -Default config path when none specified: '$PWD/config/do_ddns.toml' -Env var: DO_DYNDNS_CONFIG=/config/do_ddns.toml", - ), - ) - .arg( - Arg::new(LOG_LEVEL_VERBOSITY_SHORT) - .short(LOG_LEVEL_VERBOSITY_SHORT_CHAR) - .action(clap::ArgAction::Count) - .help( - "\ -Increases the level of verbosity. Repeat for more verbosity. -Env var: DO_DYNDNS_LOG_LEVEL=info [error|warn|info|debug|trace] -", - ), - ) - .arg( - Arg::new(DOMAIN_ROOT) - .short('d') - .long("domain-root") - .value_name("DOMAIN") - .help( - "\ -The domain root for which the domain record will be changed. -Example: 'foo.net' -Env var: DO_DYNDNS_DOMAIN_ROOT=foo.net", - ), - ) - .arg( - Arg::new(SUBDOMAIN_TO_UPDATE) - .short('s') - .long("subdomain-to-update") - .value_name("SUBDOMAIN") - .help( - "\ -The subdomain for which the public IP will be updated. -Example: 'home' -Env var: DO_DYNDNS_SUBDOMAIN_TO_UPDATE=home", - ), - ) - .arg( - Arg::new(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", - ) - .action(clap::ArgAction::SetTrue) - .conflicts_with(SUBDOMAIN_TO_UPDATE), - ) - .arg( - Arg::new(DIGITAL_OCEAN_TOKEN) - .short('t') - .long("token") - .value_name("TOKEN") - .help( - "\ -The digital ocean access token. -Example: 'abcdefghijklmnopqrstuvwxyz' -Env var: DO_DYNDNS_DIGITAL_OCEAN_TOKEN=abcdefghijklmnopqrstuvwxyz", - ), - ) - .arg( - Arg::new(DIGITAL_OCEAN_TOKEN_PATH) - .short('p') - .long("token-file-path") - .value_name("FILE_PATH") - .help( - "\ -Path to file containing the digital ocean token on its first line. -Example: '/config/secret_token.txt'", - ) - .conflicts_with(DIGITAL_OCEAN_TOKEN), - ) - .arg( - Arg::new(UPDATE_INTERVAL) - .short('i') - .long("update-interval") - .value_name("INTERVAL") - .help( - "\ -How often should the domain be updated. -Default is every 10 minutes. -Uses rust's humantime format. -Example: '15 mins 30 secs' -Env var: DO_DYNDNS_UPDATE_INTERVAL=2hours 30mins", - ), - ) - .arg( - Arg::new(DRY_RUN) - .short('n') - .long("dry-run") - .action(clap::ArgAction::SetTrue) - .help( - "\ -Show what would have been updated. -Env var: DO_DYNDNS_DRY_RUN=true", - ), - ) - .arg( - Arg::new(IPV6_SUPPORT) - .long("enable-ipv6") - .action(clap::ArgAction::SetTrue) - .help( - "\ -Enable ipv6 support (disabled by default). -Env var: DO_DYNDNS_IPV6_SUPPORT=true", - ), - ) - .arg( - Arg::new(BUILD_INFO) - .long("build-info") - .help( - "\ -Output build info like git commit sha, rustc version, etc", - ) - .action(clap::ArgAction::SetTrue), - ) } pub fn get_cli_command_definition() -> Command { let mut command = get_cli_command_definition_base(); - // Don't show stats related options when building with the feature disabled. - let mut arg = Arg::new(COLLECT_STATS) - .long("collect-stats") - .action(clap::ArgAction::SetTrue) - .help( - "\ -Enable collection of statistics (how often does the public IP change). -Env var: DO_DYNDNS_COLLECT_STATS=true", - ); - if cfg!(not(feature = "stats")) { - arg = arg.hide(true); - } - command = command.arg(arg); - - let mut arg = Arg::new(DB_PATH).long("database-path").help( - "\ -File path where a sqlite database with statistics will be stored. -Env var: DO_DYNDNS_DATABASE_PATH=/tmp/dyndns_stats_db.sqlite", - ); - - if cfg!(not(feature = "stats")) { - arg = arg.hide(true); - } - command = command.arg(arg); - - // Don't show web related options when building with the feature disabled. - let mut arg = Arg::new(ENABLE_WEB) - .long("enable-web") - .action(clap::ArgAction::SetTrue) - .help( - "\ -Enable web server to visualize collected statistics. -Env var: DO_DYNDNS_ENABLE_WEB=true", - ); - if cfg!(not(feature = "web")) { - arg = arg.hide(true); - } - command = command.arg(arg); - - let mut arg = Arg::new(LISTEN_HOSTNAME).long("listen-hostname").help( - "\ -An IP address or host name where to serve HTTP pages on. -Env var: DO_DYNDNS_LISTEN_HOSTNAME=192.168.0.1", - ); - if cfg!(not(feature = "web")) { - arg = arg.hide(true); - } - command = command.arg(arg); - - let mut arg = Arg::new(LISTEN_PORT).long("listen-port").help( - "\ -Port numbere where to serve HTTP pages on. -Env var: DO_DYNDNS_LISTEN_PORT=8080", - ); - if cfg!(not(feature = "web")) { - arg = arg.hide(true); - } - command = command.arg(arg); + command = CommonArgs::augment_args(command); + command = ConditionalArgs::augment_args(command); command } diff --git a/crates/dyndns/src/config/app_config.rs b/crates/dyndns/src/config/app_config.rs index 1afc9de..1a02d87 100644 --- a/crates/dyndns/src/config/app_config.rs +++ b/crates/dyndns/src/config/app_config.rs @@ -1,8 +1,8 @@ use crate::token::SecretDigitalOceanToken; use color_eyre::eyre::Result; -use humantime::Duration; -use serde::Deserialize; -use std::{ops::Deref, sync::Arc, time::Duration as StdDuration}; +use humantime::parse_duration; +use serde::{Deserialize, Serialize}; +use std::{ops::Deref, sync::Arc, time::Duration}; #[derive(Debug, Clone)] pub struct AppConfig { @@ -33,21 +33,66 @@ pub struct AppConfigInner { } #[non_exhaustive] -#[derive(Debug)] +#[derive(Debug, Deserialize)] pub struct GeneralOptions { pub update_interval: UpdateInterval, pub digital_ocean_token: SecretDigitalOceanToken, + #[serde(deserialize_with = "deserialize_log_level_from_u8_or_string")] + pub log_level: tracing::Level, + pub dry_run: bool, + pub ipv4: bool, + pub ipv6: bool, + pub collect_stats: bool, + #[serde(rename = "database_path")] + pub db_path: Option, + pub enable_web: bool, + pub listen_hostname: String, + pub listen_port: u16, +} + +#[non_exhaustive] +#[derive(Debug, Serialize)] +pub struct GeneralOptionsDefaults { + pub update_interval: UpdateInterval, + pub digital_ocean_token: Option, + #[serde(serialize_with = "serialize_to_u8_from_log_level")] pub log_level: tracing::Level, pub dry_run: bool, pub ipv4: bool, pub ipv6: bool, pub collect_stats: bool, + #[serde(rename = "database_path")] pub db_path: Option, pub enable_web: bool, pub listen_hostname: String, pub listen_port: u16, } +impl Default for GeneralOptionsDefaults { + fn default() -> Self { + Self { + update_interval: Default::default(), + digital_ocean_token: None, + log_level: tracing::Level::INFO, + dry_run: Default::default(), + ipv4: true, + ipv6: Default::default(), + collect_stats: Default::default(), + db_path: Default::default(), + enable_web: Default::default(), + listen_hostname: "localhost".to_owned(), + listen_port: 8095, + } + } +} + +#[derive(Debug, Deserialize)] +pub struct SimpleModeDomainConfig { + pub domain_root: String, + pub subdomain_to_update: Option, + pub update_domain_root: Option, +} + #[non_exhaustive] #[derive(Debug, Deserialize)] pub struct DomainRecord { @@ -69,12 +114,12 @@ pub struct Domains { pub domains: Vec, } -#[derive(Clone, Debug)] -pub struct UpdateInterval(pub Duration); +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct UpdateInterval(#[serde(with = "humantime_serde")] pub Duration); impl Default for UpdateInterval { fn default() -> Self { - UpdateInterval(StdDuration::from_secs(60 * 10).into()) + UpdateInterval(Duration::from_secs(60 * 10)) } } @@ -82,6 +127,74 @@ impl std::str::FromStr for UpdateInterval { type Err = humantime::DurationError; fn from_str(s: &str) -> Result { - s.parse::().map(UpdateInterval) + parse_duration(s).map(UpdateInterval) + } +} + +impl std::fmt::Display for UpdateInterval { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", humantime::format_duration(self.0)) + } +} + +pub fn deserialize_log_level_from_u8_or_string<'de, D>( + deserializer: D, +) -> Result +where + D: serde::Deserializer<'de>, +{ + use serde::de::{self, Visitor}; + + struct LogLevelVisitor; + impl<'de> Visitor<'de> for LogLevelVisitor { + type Value = tracing::Level; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a number between 0 and 3 or one of the following strings: error, warn, info, debug, trace") + } + + fn visit_u8(self, value: u8) -> Result + where + E: de::Error, + { + let level = match value { + 0 => tracing::Level::INFO, + 1 => tracing::Level::DEBUG, + 2 => tracing::Level::TRACE, + _ => tracing::Level::TRACE, + }; + Ok(level) + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + value.parse::().map_err(|_| { + let msg = "error parsing log level: expected one of \"error\", \"warn\", \ + \"info\", \"debug\", \"trace\""; + E::custom(msg) + }) + } } + + // deserialize_u8 is just a hint, the deserializer can handle strings too. + deserializer.deserialize_u8(LogLevelVisitor) +} + +pub fn serialize_to_u8_from_log_level( + level: &tracing::Level, + serializer: S, +) -> Result +where + S: serde::Serializer, +{ + let level_u8 = match *level { + tracing::Level::INFO => 0, + tracing::Level::DEBUG => 1, + tracing::Level::TRACE => 2, + tracing::Level::ERROR => 0, + tracing::Level::WARN => 0, + }; + serializer.serialize_u8(level_u8) } diff --git a/crates/dyndns/src/config/app_config_builder.rs b/crates/dyndns/src/config/app_config_builder.rs index 6245dba..32e03bd 100644 --- a/crates/dyndns/src/config/app_config_builder.rs +++ b/crates/dyndns/src/config/app_config_builder.rs @@ -1,31 +1,29 @@ +use crate::cli::ClapAllArgs; + use super::app_config::{ - AppConfig, AppConfigInner, Domain, DomainRecord, Domains, GeneralOptions, UpdateInterval, + AppConfig, AppConfigInner, Domain, DomainRecord, Domains, GeneralOptions, + GeneralOptionsDefaults, SimpleModeDomainConfig, }; -use super::config_builder::{make_env_var_from_key, ValueBuilder}; use super::consts::*; use super::early::EarlyConfig; -use crate::token::SecretDigitalOceanToken; -use color_eyre::eyre::{bail, eyre, Result, WrapErr}; use clap::ArgMatches; +use color_eyre::eyre::{bail, eyre, Result, WrapErr}; +use figment::Figment; use tracing::trace; fn get_default_config_path() -> &'static str { "./config/do_ddns.toml" } -fn read_config_map(config_path: &str) -> Result { - let config = std::fs::read_to_string(config_path) - .wrap_err(format!("Failed to read config file: {config_path}"))?; - let config = - toml::from_str(&config).wrap_err(format!("Failed to parse config file: {config_path}"))?; - Ok(config) -} - fn file_is_readable(path: &str) -> bool { std::fs::File::open(path).is_ok() } +fn make_env_var_from_key(key: &str) -> String { + format!("{}{}", ENV_VAR_PREFIX, key.to_ascii_uppercase()) +} + fn get_config_path_candidates(clap_matches: &ArgMatches) -> Vec { let mut candidates = vec![]; @@ -88,6 +86,12 @@ pub fn config_with_args(early_config: &EarlyConfig) -> Result { let config_file_path = get_config_path(clap_matches); let config_builder = AppConfigBuilder::new(Some(clap_matches), config_file_path); let config = config_builder + .map_err(|e| { + tracing::error!( + "Failed to initialize configuration system. Will exit shortly with error details." + ); + e + })? .build() .map_err(|e| { tracing::error!( @@ -99,144 +103,77 @@ pub fn config_with_args(early_config: &EarlyConfig) -> Result { Ok(config) } -fn get_advanced_mode_domains(table: Option<&toml::value::Table>) -> Result { - let domains = table - .ok_or_else(|| { - eyre!("No config contents found while retrieving 'advanced mode' domains section") - })? - .get(DOMAINS_CONFIG_KEY) - .ok_or_else(|| eyre!("No 'advanced mode' domains section found in config"))? - .clone() - .try_into::() +fn get_advanced_mode_domains(builder: &AppConfigBuilder) -> Result { + let domains: Domains = builder + .figment + .extract_inner(DOMAINS_CONFIG_KEY) .map_err(|e| eyre!(e).wrap_err("Failed to parse 'advanced mode' domain section"))?; Ok(domains) } -pub struct AppConfigBuilder<'clap> { - clap_matches: Option<&'clap ArgMatches>, - toml_table: Option, - domain_root: Option, - subdomain_to_update: Option, - update_domain_root: Option, - update_interval: Option, - digital_ocean_token: Option, - log_level: Option, - dry_run: Option, - ipv6: Option, - db_path: Option, +pub struct AppConfigBuilder { + figment: Figment, } -impl<'clap> AppConfigBuilder<'clap> { - pub fn new(clap_matches: Option<&'clap ArgMatches>, config_file_path: Option) -> Self { - fn get_config(config_file_path: &str) -> Result { - let toml_value = read_config_map(config_file_path)?; - let toml_table = match toml_value { - toml::value::Value::Table(table) => table, - _ => bail!("Failed to deserialize config file"), - }; - Ok(toml_table) - } - - let mut toml_table = None; - if let Some(config_file_path) = config_file_path { - toml_table = get_config(&config_file_path) - .map_err(|e| { - tracing::error!("{:#}", e); - e - }) - .ok(); - } +impl AppConfigBuilder { + pub fn new( + clap_matches: Option<&ArgMatches>, + config_file_path: Option, + ) -> Result { + let figment = Self::prepare_figmment(clap_matches, config_file_path.as_deref())?; - AppConfigBuilder { - clap_matches, - toml_table, - domain_root: None, - update_domain_root: None, - subdomain_to_update: None, - update_interval: None, - digital_ocean_token: None, - log_level: None, - dry_run: None, - ipv6: None, - db_path: None, - } - } + let builder = AppConfigBuilder { figment }; - pub fn set_domain_root(&mut self, value: String) -> &mut Self { - self.domain_root = Some(value); - self + Ok(builder) } - pub fn set_subdomain_to_update(&mut self, value: String) -> &mut Self { - self.subdomain_to_update = Some(value); - self - } + fn prepare_figmment( + clap_matches: Option<&ArgMatches>, + config_file_path: Option<&str>, + ) -> Result { + use figment::providers::{Env, Format, Serialized, Toml}; + use figment_file_provider_adapter::FileAdapter; - pub fn set_update_domain_root(&mut self, value: bool) -> &mut Self { - self.update_domain_root = Some(value); - self - } + let mut figment = + Figment::new().merge(Serialized::defaults(GeneralOptionsDefaults::default())); - pub fn set_update_interval(&mut self, value: UpdateInterval) -> &mut Self { - self.update_interval = Some(value); - self - } + if let Some(config_file_path) = config_file_path { + figment = figment.merge(Toml::file(config_file_path)); + } - pub fn set_digital_ocean_token(&mut self, value: SecretDigitalOceanToken) -> &mut Self { - self.digital_ocean_token = Some(value); - self - } + if let Some(clap_matches) = clap_matches { + let clap_args = ClapAllArgs::parse_and_process(clap_matches)?; + let wrapped_clap_figment = FileAdapter::wrap(Serialized::defaults(clap_args)) + .with_suffix("_path") + .only(&[DIGITAL_OCEAN_TOKEN_PATH]); + figment = figment.merge(wrapped_clap_figment); + } - pub fn set_log_level(&mut self, value: tracing::Level) -> &mut Self { - self.log_level = Some(value); - self - } + figment = figment.merge(Env::prefixed(ENV_VAR_PREFIX)); - pub fn set_dry_run(&mut self, value: bool) -> &mut Self { - self.dry_run = Some(value); - self + Ok(figment) } fn build_simple_mode_domain_config_values(&self) -> Result<(String, String)> { - let domain_root = ValueBuilder::new(DOMAIN_ROOT) - .with_value(self.domain_root.clone()) - .with_env_var_name() - .with_clap(self.clap_matches) - .with_config_value(self.toml_table.as_ref()) - .build()?; - - let subdomain_to_update = ValueBuilder::new(SUBDOMAIN_TO_UPDATE) - .with_value(self.subdomain_to_update.clone()) - .with_env_var_name() - .with_clap(self.clap_matches) - .with_config_value(self.toml_table.as_ref()) - .build(); - - let update_domain_root = ValueBuilder::new(UPDATE_DOMAIN_ROOT) - .with_value(self.update_domain_root) - .with_env_var_name() - .with_clap_bool(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)) => { + let config: SimpleModeDomainConfig = self.figment.extract()?; + + let hostname_part = match (config.subdomain_to_update, config.update_domain_root) { + (Some(subdomain_to_update), None) => subdomain_to_update, + (None, Some(update_domain_root)) => { if update_domain_root { "@".to_owned() } else { bail!("Please provide a subdomain to update") } } - (Err(e1), Err(e2)) => { - let e = format!("{e1:#}\n{e2:#}"); - return Err(eyre!(e).wrap_err("No valid domain to update found")); + (None, None) => { + bail!("Neither 'subdomain to update' nor 'update domain root' options were set. Please provide one.") } - (Ok(_), Ok(_)) => { + (Some(_), Some(_)) => { bail!("Both 'subdomain to update' and 'update domain root' options were set. Please provide only one option") } }; - Ok((domain_root, hostname_part)) + Ok((config.domain_root, hostname_part)) } fn simple_mode_domains_as_records(config: Result<(String, String)>) -> Result { @@ -257,7 +194,7 @@ impl<'clap> AppConfigBuilder<'clap> { let simple_mode_domains = AppConfigBuilder::simple_mode_domains_as_records( self.build_simple_mode_domain_config_values(), ); - let advanced_mode_domains = get_advanced_mode_domains(self.toml_table.as_ref()); + let advanced_mode_domains = get_advanced_mode_domains(self); let domains = match (simple_mode_domains, advanced_mode_domains) { (Ok(simple_mode_domains), Err(_)) => simple_mode_domains, (Err(_), Ok(advanced_mode_domains)) => advanced_mode_domains, @@ -276,122 +213,12 @@ impl<'clap> AppConfigBuilder<'clap> { } fn build_general_options(&self) -> Result { - let update_interval = ValueBuilder::new(UPDATE_INTERVAL) - .with_value(self.update_interval.clone()) - .with_env_var_name() - .with_clap(self.clap_matches) - .with_config_value(self.toml_table.as_ref()) - .with_default(UpdateInterval::default()) - .build()?; - - let mut builder = ValueBuilder::new(DIGITAL_OCEAN_TOKEN); - builder - .with_value(self.digital_ocean_token.clone()) - .with_env_var_name() - .with_clap(self.clap_matches) - .with_config_value(self.toml_table.as_ref()); - if let Some(clap_matches) = self.clap_matches { - let from_file = clap_matches - .get_one::(DIGITAL_OCEAN_TOKEN_PATH) - .map(|s| s.as_str()); - if let Some(from_file) = from_file { - builder.with_single_line_from_file(from_file); - } - } + let general_options: GeneralOptions = self.figment.extract()?; - let digital_ocean_token: SecretDigitalOceanToken = builder.build()?; - - let log_level = ValueBuilder::new(SERVICE_LOG_LEVEL) - .with_value(self.log_level) - .with_env_var_name() - .with_clap_occurences( - self.clap_matches, - LOG_LEVEL_VERBOSITY_SHORT, - Box::new(|count| match count { - 0 => None, - 1 => Some(tracing::Level::DEBUG), - 2 => Some(tracing::Level::TRACE), - _ => Some(tracing::Level::TRACE), - }), - ) - .with_config_value(self.toml_table.as_ref()) - .with_default(tracing::Level::INFO) - .build()?; - - let dry_run = ValueBuilder::new(DRY_RUN) - .with_value(self.dry_run) - .with_env_var_name() - .with_clap_bool(self.clap_matches) - .with_config_value(self.toml_table.as_ref()) - .with_default(false) - .build()?; - - // TODO: Figure out the bool clap cli issue where it is always true even if it's - // specified as false. - let ipv4 = true; - - let ipv6 = ValueBuilder::new(IPV6_SUPPORT) - .with_value(self.ipv6) - .with_env_var_name() - .with_clap_bool(self.clap_matches) - .with_config_value(self.toml_table.as_ref()) - .with_default(false) - .build()?; - - if !ipv4 && !ipv6 { + if !general_options.ipv4 && !general_options.ipv6 { bail!("At least one kind of ip family support needs to be enabled, both are disabled."); } - let collect_stats = ValueBuilder::new(COLLECT_STATS) - .with_env_var_name() - .with_clap_bool(self.clap_matches) - .with_config_value(self.toml_table.as_ref()) - .with_default(false) - .build()?; - - let db_path = ValueBuilder::new(DB_PATH) - .with_value(self.db_path.clone()) - .with_env_var_name() - .with_clap(self.clap_matches) - .with_config_value(self.toml_table.as_ref()) - .build() - .ok() - .map(|db_path| std::path::PathBuf::from(&db_path)); - - let enable_web = ValueBuilder::new(ENABLE_WEB) - .with_env_var_name() - .with_clap_bool(self.clap_matches) - .with_config_value(self.toml_table.as_ref()) - .with_default(false) - .build()?; - - let listen_hostname: String = ValueBuilder::new(LISTEN_HOSTNAME) - .with_env_var_name() - .with_clap(self.clap_matches) - .with_config_value(self.toml_table.as_ref()) - .with_default("localhost".to_owned()) - .build()?; - - let listen_port = ValueBuilder::new(LISTEN_PORT) - .with_env_var_name() - .with_clap(self.clap_matches) - .with_config_value(self.toml_table.as_ref()) - .with_default(8095_u16) - .build()?; - - let general_options = GeneralOptions { - update_interval, - digital_ocean_token, - log_level, - dry_run, - ipv4, - ipv6, - collect_stats, - db_path, - enable_web, - listen_hostname, - listen_port, - }; Ok(general_options) } diff --git a/crates/dyndns/src/config/config_builder.rs b/crates/dyndns/src/config/config_builder.rs deleted file mode 100644 index ef8665f..0000000 --- a/crates/dyndns/src/config/config_builder.rs +++ /dev/null @@ -1,477 +0,0 @@ -use super::consts::*; -use crate::types::{ValueFromBool, ValueFromStr}; -use clap::ArgMatches; -use color_eyre::eyre::{eyre, Result}; - -pub fn make_env_var_from_key(key: &str) -> String { - format!("{}{}", ENV_VAR_PREFIX, key.to_ascii_uppercase()) -} - -type OccurencesFn = Box Option>; -pub struct ValueBuilder<'clap, 'toml, T> { - key: String, - value: Option, - env_var_name: Option, - clap_option: Option<(&'clap ArgMatches, String)>, - clap_option_bool: Option<(&'clap ArgMatches, String)>, - clap_occurrences_option: Option<(&'clap ArgMatches, String, OccurencesFn)>, - file_path: Option, - config_value: Option<(&'toml toml::value::Table, String)>, - default_value: Option, -} - -impl<'clap, 'toml, T: ValueFromStr + ValueFromBool> ValueBuilder<'clap, 'toml, T> { - pub fn new(key: &str) -> Self { - ValueBuilder { - key: key.to_owned(), - value: None, - env_var_name: None, - clap_option: None, - clap_option_bool: None, - clap_occurrences_option: None, - file_path: None, - config_value: None, - default_value: None, - } - } - - pub fn with_env_var_name(&mut self) -> &mut Self { - let env_var_name = make_env_var_from_key(&self.key.to_ascii_uppercase()); - self.env_var_name = Some(env_var_name); - self - } - - pub fn with_clap(&mut self, arg_matches: Option<&'clap ArgMatches>) -> &mut Self { - if let Some(arg_matches) = arg_matches { - self.clap_option = Some((arg_matches, self.key.to_owned())); - } - self - } - - pub fn with_clap_bool(&mut self, arg_matches: Option<&'clap ArgMatches>) -> &mut Self { - if let Some(arg_matches) = arg_matches { - self.clap_option_bool = Some((arg_matches, self.key.to_owned())); - } - self - } - - pub fn with_clap_occurences( - &mut self, - arg_matches: Option<&'clap ArgMatches>, - key: &str, - clap_fn: OccurencesFn, - ) -> &mut Self { - if let Some(arg_matches) = arg_matches { - self.clap_occurrences_option = Some((arg_matches, key.to_owned(), clap_fn)); - } - self - } - - pub fn with_single_line_from_file(&mut self, file_path: &str) -> &mut Self { - self.file_path = Some(file_path.to_owned()); - self - } - - pub fn with_config_value(&mut self, toml_map: Option<&'toml toml::value::Table>) -> &mut Self { - if let Some(toml_map) = toml_map { - self.config_value = Some((toml_map, self.key.to_owned())); - } - self - } - - pub fn with_default(&mut self, default_value: T) -> &mut Self { - self.default_value = Some(default_value); - self - } - - pub fn with_value(&mut self, value: Option) -> &mut Self { - self.value = value; - self - } - - fn try_from_env(&mut self) -> &mut Self { - if self.value.is_some() { - return self; - } - if let Some(ref env_var_name) = self.env_var_name { - let env_res = std::env::var(env_var_name); - if let Ok(value) = env_res { - let parsed_res = ValueFromStr::from_str(value.as_ref()); - if let Ok(value) = parsed_res { - self.value = Some(value); - } - } - } - - self - } - - fn try_from_clap(&mut self) -> &mut Self { - if self.value.is_some() { - return self; - } - - if let Some((arg_matches, ref option_name)) = self.clap_option { - let clap_value = arg_matches - .get_one::(option_name) - .map(|s| s.as_str()); - if let Some(value) = clap_value { - let parsed_res = ValueFromStr::from_str(value); - if let Ok(value) = parsed_res { - self.value = Some(value); - } - } - } - - self - } - - fn try_from_clap_bool(&mut self) -> &mut Self { - if self.value.is_some() { - return self; - } - - if let Some((arg_matches, ref option_name)) = self.clap_option_bool { - // We only care about values that come from the command line, not default ones set by - // clap. It's not possible to configure a clap argument to return None by default when - // .action(clap::ArgAction::SetTrue/SetFalse) is used and no argument is specified - // on the command line. So we only retrieve the value if it's a non-default one. - if arg_matches.contains_id(option_name) - && arg_matches - .value_source(option_name) - .expect("checked contains_id") - == clap::parser::ValueSource::CommandLine - { - let value = arg_matches.get_flag(option_name); - let parsed_res = ValueFromBool::from_bool(value); - self.value = parsed_res.ok(); - } - } - - self - } - - fn try_from_clap_occurences(&mut self) -> &mut Self { - if self.value.is_some() { - return self; - } - - if let Some((arg_matches, ref option_name, ref mut clap_fn)) = self.clap_occurrences_option - { - let occurences_value = arg_matches.get_count(option_name); - self.value = clap_fn(occurences_value as u64); - } - - self - } - - fn try_from_file_line(&mut self) -> &mut Self { - if self.value.is_some() { - return self; - } - - if let Some(ref file_path) = self.file_path { - let line = std::fs::read_to_string(file_path); - if let Ok(line) = line { - let value = line.trim_end(); - if !value.is_empty() { - let parsed_res = ValueFromStr::from_str(value); - if let Ok(value) = parsed_res { - self.value = Some(value); - } - } - } - } - - self - } - - fn try_from_config_value(&mut self) -> &mut Self { - if self.value.is_some() { - return self; - } - - if let Some((toml_table, ref key)) = self.config_value { - let toml_value = toml_table.get(key); - if let Some(toml_value) = toml_value { - if let Some(toml_str) = toml_value.as_str() { - let value = toml_str; - let parsed_res = ValueFromStr::from_str(value); - if let Ok(value) = parsed_res { - self.value = Some(value); - } - } - } - } - - self - } - - fn try_from_default_value(&mut self) -> &mut Self { - if self.value.is_some() { - return self; - } - - if self.default_value.is_some() { - self.value = self.default_value.take(); - } - - self - } - - pub fn build(&mut self) -> Result { - self.try_from_env(); - self.try_from_clap(); - self.try_from_clap_bool(); - self.try_from_clap_occurences(); - self.try_from_file_line(); - self.try_from_config_value(); - self.try_from_default_value(); - self.value - .take() - .ok_or_else(|| eyre!(format!("Missing value for config option: '{}'", self.key))) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::cli::get_cli_command_definition; - use tempfile::NamedTempFile; - - #[test] - fn test_env_var() { - // Happy path - let key = "valid_env_var"; - std::env::set_var(make_env_var_from_key(key), "some_val"); - let mut builder = ValueBuilder::::new(key); - builder.with_env_var_name(); - let value = builder.build().unwrap(); - assert_eq!(value, "some_val"); - - // Empty value - let key = "empty_env_var"; - std::env::set_var(make_env_var_from_key(key), ""); - let mut builder = ValueBuilder::::new(key); - builder.with_env_var_name(); - let value = builder.build().unwrap(); - assert_eq!(value, ""); - - // Env does not exist - let key = "non_existent_env_var"; - let mut builder = ValueBuilder::::new(key); - builder.with_env_var_name(); - let value = builder.build(); - assert!(value.is_err()); - } - - #[test] - fn test_clap_string_value() { - let command = clap::Command::new("test").arg(clap::Arg::new("foo").short('f').long("foo")); - - // Happy path - let arg_vec = vec!["my_prog", "--foo", "some_val"]; - let matches = command.clone().get_matches_from(arg_vec); - let mut builder = ValueBuilder::::new("foo"); - builder.with_clap(Some(&matches)); - let value = builder.build().unwrap(); - assert_eq!(value, "some_val"); - - // Empty value - let arg_vec = vec!["my_prog", "--foo", ""]; - let matches = command.clone().get_matches_from(arg_vec); - let mut builder = ValueBuilder::::new("foo"); - builder.with_clap(Some(&matches)); - let value = builder.build().unwrap(); - assert_eq!(value, ""); - - // Key not given - let arg_vec = vec!["my_prog"]; - let matches = command.get_matches_from(arg_vec); - let mut builder = ValueBuilder::::new("foo"); - builder.with_clap(Some(&matches)); - let value = builder.build(); - assert!(value.is_err()); - - // Value not given - let command = clap::Command::new("test") - .arg(clap::Arg::new("foo").short('f').long("foo").num_args(0..=1)); - let arg_vec = vec!["my_prog", "--foo"]; - let matches = command.get_matches_from(arg_vec); - let mut builder = ValueBuilder::::new("foo"); - builder.with_clap(Some(&matches)); - let value = builder.build(); - assert!(value.is_err()); - } - - #[test] - fn test_clap_bool_value() { - let command = clap::Command::new("test").arg( - clap::Arg::new("foo") - .short('f') - .action(clap::ArgAction::SetTrue), - ); - - // clap option is false by default when unset, option set, thus result is Some(true) - let arg_vec = vec!["my_prog", "-f"]; - let matches = command.clone().get_matches_from(arg_vec); - let mut builder = ValueBuilder::::new("foo"); - builder.with_clap_bool(Some(&matches)); - let value = builder.build().unwrap(); - assert!(value); - - // clap option is false by default when unset, option unset, thus result is None - let arg_vec = vec!["my_prog"]; - let matches = command.get_matches_from(arg_vec); - let mut builder = ValueBuilder::::new("foo"); - builder.with_clap_bool(Some(&matches)); - let value = builder.build(); - assert!(value.is_err()); - - let command = clap::Command::new("test").arg( - clap::Arg::new("foo") - .short('f') - .action(clap::ArgAction::SetFalse), - ); - - // clap option is true by default when unset, option set, thus result is Some(false) - let arg_vec = vec!["my_prog", "-f"]; - let matches = command.clone().get_matches_from(arg_vec); - let mut builder = ValueBuilder::::new("foo"); - builder.with_clap_bool(Some(&matches)); - let value = builder.build().unwrap(); - assert!(!value); - - // clap option is true by default when unset, option unset, thus result is None - let arg_vec = vec!["my_prog"]; - let matches = command.get_matches_from(arg_vec); - let mut builder = ValueBuilder::::new("foo"); - builder.with_clap_bool(Some(&matches)); - let value = builder.build(); - assert!(value.is_err()); - } - - #[test] - fn test_clap_occurrences_value() { - let command = clap::Command::new("test").arg( - clap::Arg::new("v") - .short('v') - .action(clap::ArgAction::Count), - ); - - // Happy path 2 values - let arg_vec = vec!["my_prog", "-vv"]; - let matches = command.clone().get_matches_from(arg_vec); - let mut builder = ValueBuilder::::new("v"); - builder.with_clap_occurences(Some(&matches), "v", Box::new(|v| Some(v.to_string()))); - let value = builder.build().unwrap(); - assert_eq!(value, "2"); - - // Happy path 1 value - let arg_vec = vec!["my_prog", "-v"]; - let matches = command.clone().get_matches_from(arg_vec); - let mut builder = ValueBuilder::::new("v"); - builder.with_clap_occurences(Some(&matches), "v", Box::new(|v| Some(v.to_string()))); - let value = builder.build().unwrap(); - assert_eq!(value, "1"); - - // Happy path 0 values - let arg_vec = vec!["my_prog"]; - let matches = command.get_matches_from(arg_vec); - let mut builder = ValueBuilder::::new("v"); - builder.with_clap_occurences(Some(&matches), "v", Box::new(|v| Some(v.to_string()))); - let value = builder.build().unwrap(); - assert_eq!(value, "0"); - } - - #[test] - fn test_file_line() { - use std::io::Write; - // Happy path - { - let mut file = NamedTempFile::new().unwrap(); - writeln!(file, "some_val").unwrap(); - let temp_file_path = file.path(); - let mut builder = ValueBuilder::::new("some_file"); - builder.with_single_line_from_file(temp_file_path.to_str().unwrap()); - let value = builder.build().unwrap(); - assert_eq!(value, "some_val"); - } - - // Empty file - { - let file = NamedTempFile::new().unwrap(); - let temp_file_path = file.path(); - let mut builder = ValueBuilder::::new("some_file"); - builder.with_single_line_from_file(temp_file_path.to_str().unwrap()); - let value = builder.build(); - assert!(value.is_err()); - } - - // Missing file - { - let temp_file_path = "/definitely_should_not_exist"; - let mut builder = ValueBuilder::::new("some_file"); - builder.with_single_line_from_file(temp_file_path); - let value = builder.build(); - assert!(value.is_err()); - } - } - - #[test] - fn test_toml_value() { - let toml_value: toml::Value = toml::from_str( - r#" - some_field = 'some_val' - empty_field = '' - "#, - ) - .unwrap(); - - // Happy path - let toml_map = toml_value.as_table().unwrap(); - let mut builder = ValueBuilder::::new("some_field"); - builder.with_config_value(Some(toml_map)); - let value = builder.build().unwrap(); - assert_eq!(value, "some_val"); - - // Empty field - let toml_map = toml_value.as_table().unwrap(); - let mut builder = ValueBuilder::::new("empty_field"); - builder.with_config_value(Some(toml_map)); - let value = builder.build().unwrap(); - assert_eq!(value, ""); - - // Missing key - let toml_map = toml_value.as_table().unwrap(); - let mut builder = ValueBuilder::::new("missing_field"); - builder.with_config_value(Some(toml_map)); - let value = builder.build(); - assert!(value.is_err()); - } - - #[test] - fn test_default_value() { - // Happy path - let mut builder = ValueBuilder::::new("foo"); - builder.with_default("some_val".to_owned()); - let value = builder.build().unwrap(); - assert_eq!(value, "some_val"); - - // Empty string - let mut builder = ValueBuilder::::new("foo"); - builder.with_default("".to_owned()); - let value = builder.build().unwrap(); - assert_eq!(value, ""); - - // Missing key - let mut builder = ValueBuilder::::new("foo"); - let value = builder.build(); - assert!(value.is_err()); - } - - #[test] - fn verify_cli() { - get_cli_command_definition().debug_assert() - } -} diff --git a/crates/dyndns/src/config/consts.rs b/crates/dyndns/src/config/consts.rs index 9c37fe9..4ef23a4 100644 --- a/crates/dyndns/src/config/consts.rs +++ b/crates/dyndns/src/config/consts.rs @@ -1,21 +1,5 @@ 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"; -pub static SERVICE_LOG_LEVEL: &str = "log_level"; -pub static DRY_RUN: &str = "dry_run"; -pub static IPV4_SUPPORT: &str = "ipv4"; -pub static IPV6_SUPPORT: &str = "ipv6"; -pub static COLLECT_STATS: &str = "collect_stats"; -pub static DB_PATH: &str = "database_path"; -pub static ENABLE_WEB: &str = "enable_web"; -pub static LISTEN_HOSTNAME: &str = "listen_hostname"; -pub static LISTEN_PORT: &str = "listen_port"; -pub static LOG_LEVEL_VERBOSITY_SHORT: &str = "v"; -pub static LOG_LEVEL_VERBOSITY_SHORT_CHAR: char = 'v'; +pub static DIGITAL_OCEAN_TOKEN_PATH: &str = "digital_ocean_token_path"; pub static ENV_VAR_PREFIX: &str = "DO_DYNDNS_"; pub static BUILD_INFO: &str = "build_info"; diff --git a/crates/dyndns/src/config/mod.rs b/crates/dyndns/src/config/mod.rs index 670ab3a..a72d531 100644 --- a/crates/dyndns/src/config/mod.rs +++ b/crates/dyndns/src/config/mod.rs @@ -1,5 +1,4 @@ pub mod app_config; pub mod app_config_builder; -pub mod config_builder; pub mod consts; pub mod early; diff --git a/crates/dyndns/src/db/logic.rs b/crates/dyndns/src/db/logic.rs index 614511a..8b529ff 100644 --- a/crates/dyndns/src/db/logic.rs +++ b/crates/dyndns/src/db/logic.rs @@ -312,6 +312,8 @@ mod tests { #[test] fn test_do_ops_with_db() -> Result<()> { + crate::logger::init_color_eyre(); + let maybe_db_path = None; let conn = &mut setup_db(maybe_db_path)?; let domain_record = create_domain_record( diff --git a/crates/dyndns/src/db/setup.rs b/crates/dyndns/src/db/setup.rs index 766d9f8..3f547e3 100644 --- a/crates/dyndns/src/db/setup.rs +++ b/crates/dyndns/src/db/setup.rs @@ -20,7 +20,20 @@ pub fn setup_db(maybe_db_path: Option) -> Result) -> Result { if cfg!(debug_assertions) { + // Make the path absolute relative to the current directory to avoid weird + // flaky failures in test_do_ops_with_db because the current directory is + // modified by figment::Jail in other tests. let db_path = "./config/do_ddns_test.sqlite"; + let cur_dir = std::env::current_dir()?; + let db_path = [cur_dir, db_path.into()] + .iter() + .collect::(); + let db_path = db_path.to_str().ok_or_else(|| { + eyre!( + "Failed to convert db_path: {:#?} PathBuf to a String", + db_path + ) + })?; trace!("Using debug database path: {}", db_path); return Ok(db_path.to_owned()); } diff --git a/crates/dyndns/src/domain_record_api/digital_ocean.rs b/crates/dyndns/src/domain_record_api/digital_ocean.rs index 4173fdb..15df42c 100644 --- a/crates/dyndns/src/domain_record_api/digital_ocean.rs +++ b/crates/dyndns/src/domain_record_api/digital_ocean.rs @@ -116,7 +116,13 @@ mod tests { } fn get_mock_domain_records_response() -> String { - let path = format!("tests/data/{}", "sample_list_domain_records_response.json"); + let path = [ + env!("CARGO_MANIFEST_DIR"), + "tests/data/", + "sample_list_domain_records_response.json", + ] + .iter() + .collect::(); std::fs::read_to_string(path).expect("Mock domain records not found") } @@ -150,36 +156,46 @@ mod tests { #[test] fn test_basic() { - use crate::types::ValueFromStr; use crate::updater::{get_record_to_update, should_update_domain_ip}; - let mut config_builder = - crate::config::app_config_builder::AppConfigBuilder::new(None, None); - config_builder - .set_subdomain_to_update("home".to_owned()) - .set_domain_root("site.com".to_owned()) - .set_digital_ocean_token(ValueFromStr::from_str("123").unwrap()) - .set_log_level(tracing::Level::INFO) - .set_update_interval(crate::config::app_config::UpdateInterval( - std::time::Duration::from_secs(5).into(), - )); - let config = config_builder.build().unwrap(); - let ip_fetcher = MockIpFetcher::default(); - let public_ips = ip_fetcher.fetch_public_ips(true, true).unwrap(); - let updater = MockApi::new(); - let domain_name = &config.domains.domains[0].name; - let hostname_part = &config.domains.domains[0].records[0].name; - let record_type = "A"; - let record_to_update = DomainRecordToUpdate::new(domain_name, hostname_part, record_type); - - let records = updater.get_domain_records(domain_name).unwrap(); - let domain_record = get_record_to_update(&records, &record_to_update).unwrap(); - let (ip_addr, _ip_kind) = public_ips.to_ip_addr_from_any(); - let should_update = should_update_domain_ip(&ip_addr, domain_record); - - assert!(should_update); - - let result = updater.update_domain_ip(domain_record.id, &record_to_update, &ip_addr); - assert!(result.is_err()); + crate::logger::init_color_eyre(); + + figment::Jail::expect_with(|jail| { + jail.create_file( + "config.toml", + r#" +domain_root = "site.com" +subdomain_to_update = "home" +digital_ocean_token = "123" + "#, + )?; + + let config_builder = crate::config::app_config_builder::AppConfigBuilder::new( + None, + Some("config.toml".to_owned()), + ) + .expect("Failed to create config builder"); + let config = config_builder.build().expect("failed to parse config"); + let ip_fetcher = MockIpFetcher::default(); + let public_ips = ip_fetcher.fetch_public_ips(true, true).unwrap(); + let updater = MockApi::new(); + let domain_name = &config.domains.domains[0].name; + let hostname_part = &config.domains.domains[0].records[0].name; + let record_type = "A"; + let record_to_update = + DomainRecordToUpdate::new(domain_name, hostname_part, record_type); + + let records = updater.get_domain_records(domain_name).unwrap(); + let domain_record = get_record_to_update(&records, &record_to_update).unwrap(); + let (ip_addr, _ip_kind) = public_ips.to_ip_addr_from_any(); + let should_update = should_update_domain_ip(&ip_addr, domain_record); + + assert!(should_update); + + let result = updater.update_domain_ip(domain_record.id, &record_to_update, &ip_addr); + assert!(result.is_err()); + + Ok(()) + }); } } diff --git a/crates/dyndns/src/humantime_wrapper_serde.rs b/crates/dyndns/src/humantime_wrapper_serde.rs deleted file mode 100644 index 72d88ec..0000000 --- a/crates/dyndns/src/humantime_wrapper_serde.rs +++ /dev/null @@ -1,83 +0,0 @@ -use humantime::Duration; -use serde::{de, Deserialize, Deserializer}; -use std::fmt; -use std::ops::{Deref, DerefMut}; - -/// This is an adjusted copy-paste of humantime_serde to work with -/// humantime::Duration that wraps an std::time::Duration. - -/// A wrapper type which implements `Deserialize`. -#[derive(Copy, Clone, Eq, Hash, PartialEq)] -pub struct Serde(T); - -impl fmt::Debug for Serde -where - T: fmt::Debug, -{ - fn fmt(&self, formatter: &mut fmt::Formatter) -> Result<(), fmt::Error> { - self.0.fmt(formatter) - } -} - -impl Deref for Serde { - type Target = T; - - fn deref(&self) -> &T { - &self.0 - } -} - -impl DerefMut for Serde { - fn deref_mut(&mut self) -> &mut T { - &mut self.0 - } -} - -impl Serde { - /// Consumes the `De`, returning the inner value. - pub fn into_inner(self) -> T { - self.0 - } -} - -impl From for Serde { - fn from(val: T) -> Serde { - Serde(val) - } -} - -pub fn deserialize<'a, T, D>(d: D) -> Result -where - Serde: Deserialize<'a>, - D: Deserializer<'a>, -{ - Serde::deserialize(d).map(Serde::into_inner) -} - -impl<'de> Deserialize<'de> for Serde { - fn deserialize(d: D) -> Result, D::Error> - where - D: Deserializer<'de>, - { - struct V; - - impl<'de2> de::Visitor<'de2> for V { - type Value = Duration; - - fn expecting(&self, fmt: &mut fmt::Formatter) -> fmt::Result { - fmt.write_str("a duration") - } - - fn visit_str(self, v: &str) -> Result - where - E: de::Error, - { - humantime::parse_duration(v) - .map(|v| v.into()) - .map_err(|_| E::invalid_value(de::Unexpected::Str(v), &self)) - } - } - - d.deserialize_str(V).map(Serde) - } -} diff --git a/crates/dyndns/src/lib.rs b/crates/dyndns/src/lib.rs index b3fdc9d..c334ef9 100644 --- a/crates/dyndns/src/lib.rs +++ b/crates/dyndns/src/lib.rs @@ -10,7 +10,6 @@ extern crate diesel; pub mod db; pub mod domain_record_api; pub mod global_state; -pub mod humantime_wrapper_serde; pub mod ip_fetcher; pub mod logger; pub mod signal_handlers; diff --git a/crates/dyndns/src/logger.rs b/crates/dyndns/src/logger.rs index 48d116d..ef3b413 100644 --- a/crates/dyndns/src/logger.rs +++ b/crates/dyndns/src/logger.rs @@ -52,3 +52,13 @@ impl Drop for EyreSpanTraceWorkaroundGuard { std::env::set_var(RUST_SPANTRACE_KEY, "1"); } } + +struct ColorEyreGuard(()); +static INIT_COLOR_EYRE: OnceCell = OnceCell::new(); + +pub fn init_color_eyre() { + INIT_COLOR_EYRE.get_or_init(|| { + color_eyre::install().expect("Failed to initialize color_eyre"); + ColorEyreGuard(()) + }); +} diff --git a/crates/dyndns/src/token.rs b/crates/dyndns/src/token.rs index 331bc79..ff538a4 100644 --- a/crates/dyndns/src/token.rs +++ b/crates/dyndns/src/token.rs @@ -1,8 +1,9 @@ use crate::types::ValueFromStr; use color_eyre::eyre::Error; use secrecy::Secret; +use serde::{Deserialize, Serialize}; -#[derive(Clone)] +#[derive(Clone, Serialize, Deserialize)] pub struct DigitalOceanToken(String); pub type SecretDigitalOceanToken = Secret; @@ -21,6 +22,7 @@ impl secrecy::Zeroize for DigitalOceanToken { impl secrecy::CloneableSecret for DigitalOceanToken {} impl secrecy::DebugSecret for DigitalOceanToken {} +impl secrecy::SerializableSecret for DigitalOceanToken {} impl std::str::FromStr for DigitalOceanToken { type Err = Error; @@ -37,3 +39,7 @@ impl ValueFromStr for Secret { Ok(Secret::new(s.parse::()?)) } } + +pub fn parse_secret_token(s: &str) -> Result, Error> { + Ok(Secret::new(s.parse::()?)) +} diff --git a/crates/dyndns/src/updater.rs b/crates/dyndns/src/updater.rs index 05a7de6..0d41c3a 100644 --- a/crates/dyndns/src/updater.rs +++ b/crates/dyndns/src/updater.rs @@ -152,7 +152,7 @@ impl Updater { interval: &app_config::UpdateInterval, records_to_update: &[DomainRecordToUpdate], ) -> String { - let duration_formatted = format_duration(*interval.0); + let duration_formatted = format_duration(interval.0); let m = format!( "Starting updater with update interval: {duration_formatted}. The following domain records will be updated:", ); @@ -209,7 +209,7 @@ impl Updater { } let duration_formatted = - format_duration(*self.global_state.config.general_options.update_interval.0); + format_duration(self.global_state.config.general_options.update_interval.0); trace!("Sleeping for {}", duration_formatted); // Exit if interrupted. @@ -231,17 +231,17 @@ impl Updater { let timeout = self.global_state.config.general_options.update_interval.0; let mut sleep_time_left = timeout; loop { - park_timeout(*sleep_time_left); + park_timeout(sleep_time_left); let elapsed = beginning_park.elapsed(); trace!("Interrupted, elapsed {:?}", elapsed); if self.should_exit() { return true; } - if elapsed >= *timeout { + if elapsed >= timeout { break; } trace!("restarting park_timeout after {:?}", elapsed); - sleep_time_left = (*timeout - elapsed).into(); + sleep_time_left = timeout - elapsed; } false } diff --git a/crates/dyndns/src/web/routes.rs b/crates/dyndns/src/web/routes.rs index 97664ca..dac8249 100644 --- a/crates/dyndns/src/web/routes.rs +++ b/crates/dyndns/src/web/routes.rs @@ -119,6 +119,8 @@ mod tests { #[test] fn generate_open_api_schema() { + crate::logger::init_color_eyre(); + let (_, api) = get_pure_router_and_open_api(); let mut buf = Vec::with_capacity(128);