aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Cargo.lock100
-rw-r--r--Cargo.toml5
-rw-r--r--src/config.rs183
-rw-r--r--src/dyndns_service.rs2
-rw-r--r--src/error.rs17
-rw-r--r--src/ip_service.rs7
-rw-r--r--src/main.rs102
-rw-r--r--src/test_macros.rs17
-rw-r--r--test/noop.json11
9 files changed, 346 insertions, 98 deletions
diff --git a/Cargo.lock b/Cargo.lock
index d8cafaf..ebb67ab 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -179,6 +179,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
+name = "cfg_aliases"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
+
+[[package]]
name = "chrono"
version = "0.4.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -588,6 +594,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
+name = "homedir"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5bdbbd5bc8c5749697ccaa352fa45aff8730cf21c68029c0eef1ffed7c3d6ba2"
+dependencies = [
+ "cfg-if",
+ "nix",
+ "widestring",
+ "windows",
+]
+
+[[package]]
name = "http"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -719,7 +737,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
- "windows-core",
+ "windows-core 0.61.1",
]
[[package]]
@@ -981,6 +999,7 @@ dependencies = [
"clap",
"env_logger",
"fqdn",
+ "homedir",
"http",
"log",
"reqwest",
@@ -1010,6 +1029,18 @@ dependencies = [
]
[[package]]
+name = "nix"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
+dependencies = [
+ "bitflags",
+ "cfg-if",
+ "cfg_aliases",
+ "libc",
+]
+
+[[package]]
name = "num-conv"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1873,20 +1904,59 @@ dependencies = [
]
[[package]]
+name = "widestring"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd7cf3379ca1aac9eea11fba24fd7e315d621f8dfe35c8d7d2be8b793726e07d"
+
+[[package]]
+name = "windows"
+version = "0.57.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143"
+dependencies = [
+ "windows-core 0.57.0",
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.57.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d"
+dependencies = [
+ "windows-implement 0.57.0",
+ "windows-interface 0.57.0",
+ "windows-result 0.1.2",
+ "windows-targets 0.52.6",
+]
+
+[[package]]
name = "windows-core"
version = "0.61.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46ec44dc15085cea82cf9c78f85a9114c463a369786585ad2882d1ff0b0acf40"
dependencies = [
- "windows-implement",
- "windows-interface",
+ "windows-implement 0.60.0",
+ "windows-interface 0.59.1",
"windows-link",
- "windows-result",
+ "windows-result 0.3.3",
"windows-strings 0.4.1",
]
[[package]]
name = "windows-implement"
+version = "0.57.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-implement"
version = "0.60.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836"
@@ -1898,6 +1968,17 @@ dependencies = [
[[package]]
name = "windows-interface"
+version = "0.57.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-interface"
version = "0.59.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8"
@@ -1919,13 +2000,22 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3"
dependencies = [
- "windows-result",
+ "windows-result 0.3.3",
"windows-strings 0.3.1",
"windows-targets 0.53.0",
]
[[package]]
name = "windows-result"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-result"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b895b5356fc36103d0f64dd1e94dfa7ac5633f1c9dd6e80fe9ec4adef69e09d"
diff --git a/Cargo.toml b/Cargo.toml
index 4b0da6c..de42a63 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -8,7 +8,7 @@ license = "MIT OR Apache-2.0"
[dependencies]
reqwest = "0.12.15"
-tokio = "1.45.0"
+tokio = { version = "1.45.0", features = ["rt", "rt-multi-thread", "macros"] }
http = { version = "1.3.1" }
serde_json = "1.0.140"
serde = { version = "1.0.219", features = ["derive"] }
@@ -16,8 +16,9 @@ serde_with = "3.12.0"
fqdn = { version = "0.4.6", features = ["serde"] }
strum = { version = "0.27.1", features = ["derive"] }
env_logger = "0.11.8"
-clap = { version = "4.5.38", features = ["derive"] }
+clap = { version = "4.5.38", features = ["derive", "string"] }
log = "0.4.27"
+homedir = "0.3.4"
[dev-dependencies]
wiremock = "0.6.3"
diff --git a/src/config.rs b/src/config.rs
index 2c5427e..881759c 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -5,8 +5,9 @@ use fqdn::FQDN;
use serde::{Deserialize, Serialize};
use serde_with::DisplayFromStr;
use serde_with::serde_as;
-use std::path::Path;
+use std::path::{Path, PathBuf};
use std::{fs, io};
+use log::warn;
use strum::Display;
#[derive(Debug, Deserialize, Serialize)]
@@ -16,17 +17,38 @@ pub struct Config {
}
impl Config {
- pub fn load<P: AsRef<Path>>(path: P) -> AppResult<Config> {
- let path = path.as_ref();
- let content = fs::read_to_string(path).map_err(|e| match e.kind() {
- io::ErrorKind::NotFound => AppError::FileNotFound(path.to_path_buf()),
- _ => AppError::IoError(e),
- })?;
-
- serde_json::from_str(&content).map_err(|e| AppError::ConfigParseError {
- source: e,
- path: path.to_path_buf(),
- })
+ pub fn load(paths: Vec<PathBuf>) -> AppResult<Config> {
+ let mut index = 1;
+ for path in &paths {
+ let content = fs::read_to_string(&path);
+ match content {
+ Ok(value) => {
+ match serde_json::from_str(&value) {
+ Ok(config) => { return Ok(config) },
+ Err(error) => {
+ if index == paths.len() { return Err(AppError::ConfigParseError {
+ source: error,
+ path: path.to_path_buf(),
+ })}
+ warn!("{}", error);
+ }
+ }
+ }
+ Err(e) => {
+ let error = match e.kind() {
+ io::ErrorKind::NotFound => AppError::ConfigFileNotFound(path.clone()),
+ _ => AppError::IoError(e),
+ };
+ if index == paths.len() {
+ return Err(error);
+ }
+ warn!("{}", error);
+ },
+ }
+ index += 1;
+ }
+
+ Err(AppError::ConfigFileNotProvided)
}
}
@@ -70,91 +92,102 @@ mod tests {
use super::*;
use crate::dyndns_service::DynDnsProvider::Gandi;
use crate::dyndns_service::gandi::GandiConfig;
- use crate::{assert_config_parse_error, assert_error, assert_file_not_found, assert_io_error};
+ use crate::{
+ assert_config_file_not_found, assert_config_parse_error, assert_error, assert_io_error,
+ test,
+ };
use serde_json::json;
- use std::error::Error;
- use std::fs::{File, read_dir};
- use std::io::BufReader;
+ use std::fs::read_dir;
+ use std::path::PathBuf;
use std::str::FromStr;
- #[test]
- fn check_minimal_config() {
- let input = json!({
- "fqdn": "dyn.domain.com",
- "providers": [ {
- "type": "GANDI",
- "api_key": "SOME-API-KEY",
- } ]
- });
-
- let wan_config = serde_json::from_value::<WanConfig>(input).unwrap();
-
- assert_eq!(
- wan_config.dns_record.fqdn,
- FQDN::from_str("dyn.domain.com").unwrap()
- );
- assert_eq!(wan_config.interface, None);
-
- assert_eq!(wan_config.providers.len(), 1);
- let expected = GandiConfig::new("SOME-API-KEY".to_string());
- let actual = wan_config.providers.get(0).unwrap();
- assert_eq!(&Gandi(expected), actual);
+ test! {
+ fn check_minimal_config() {
+ let input = json!({
+ "fqdn": "dyn.domain.com",
+ "providers": [ {
+ "type": "GANDI",
+ "api_key": "SOME-API-KEY",
+ } ]
+ });
+
+ let wan_config = serde_json::from_value::<WanConfig>(input)?;
+
+ assert_eq!(
+ wan_config.dns_record.fqdn,
+ FQDN::from_str("dyn.domain.com")?
+ );
+ assert_eq!(wan_config.interface, None);
+
+ assert_eq!(wan_config.providers.len(), 1);
+ let expected = GandiConfig::new("SOME-API-KEY".to_string());
+ let actual = wan_config.providers.get(0).unwrap();
+ assert_eq!(&Gandi(expected), actual);
+ }
}
- #[test]
- fn check_defaults_on_dns_record_deserialization() {
- let input = json!({
- "fqdn": "dyn.mydomain.com",
- });
+ test! {
+ fn check_defaults_on_dns_record_deserialization() {
+ let input = json!({
+ "fqdn": "dyn.mydomain.com",
+ });
- let dns_record = serde_json::from_value::<DnsRecord>(input).unwrap();
+ let dns_record = serde_json::from_value::<DnsRecord>(input)?;
- assert_eq!(dns_record.record_type, default_record_type());
- assert_eq!(dns_record.ttl, default_ttl());
+ assert_eq!(dns_record.record_type, default_record_type());
+ assert_eq!(dns_record.ttl, default_ttl());
- assert_eq!(dns_record.fqdn, FQDN::from_str("dyn.mydomain.com").unwrap());
+ assert_eq!(dns_record.fqdn, FQDN::from_str("dyn.mydomain.com")?);
+ }
}
- #[test]
- fn check_file_configs() -> Result<(), Box<dyn Error>> {
- let path = Path::new("test");
- for entry in read_dir(path)? {
- let entry = entry?;
- let test_file_path = entry.path();
- if test_file_path.extension().unwrap_or_default() != "json" {
- continue;
+ test! {
+ fn check_test_file_configs() {
+ let path = Path::new("test");
+ for entry in read_dir(path)? {
+ let entry = entry?;
+ let test_file_path = entry.path();
+ if test_file_path.extension().unwrap_or_default() != "json" {
+ continue;
+ }
+
+ Config::load(vec![test_file_path])?;
}
+ }
+ }
- let file = File::open(test_file_path)?;
- let reader = BufReader::new(file);
+ test! {
+ fn check_chooses_fallback_config() {
+ let configs = vec![PathBuf::from_str("test/unknown.json")?,
+ PathBuf::from_str("test/also-unknown.json")?,
+ PathBuf::from_str("test/config.bork")?,
+ PathBuf::from_str("test/noop.json")?];
- let actual: Config = serde_json::from_reader(reader)?;
- assert_eq!(actual.networks.len(), 2);
+ Config::load(configs)?;
}
- Ok(())
}
- #[test]
- fn check_missing_config() -> Result<(), Box<dyn Error>> {
- let path = Path::new("test/unknown.yaml");
+ test! {
+ fn missing_config() {
+ let path = Path::new("test/unknown.yaml");
- assert_file_not_found!(Config::load(path), path);
- Ok(())
+ assert_config_file_not_found!(Config::load(vec![path.to_path_buf()]), path);
+ }
}
- #[test]
- fn check_broken_config() -> Result<(), Box<dyn Error>> {
- let path = Path::new("test/config.bork");
+ test! {
+ fn broken_config() {
+ let path = Path::new("test/config.bork");
- assert_config_parse_error!(Config::load(path), path);
- Ok(())
+ assert_config_parse_error!(Config::load(vec![path.to_path_buf()]), path);
+ }
}
- #[test]
- fn check_inaccessible_config() -> Result<(), Box<dyn Error>> {
- let path = Path::new("/root/secure-config.json");
+ test! {
+ fn inaccessible_config() {
+ let path = Path::new("/root/secure-config.json");
- assert_io_error!(Config::load(path));
- Ok(())
+ assert_io_error!(Config::load(vec![path.to_path_buf()]));
+ }
}
}
diff --git a/src/dyndns_service.rs b/src/dyndns_service.rs
index a80fe4b..3f34a1b 100644
--- a/src/dyndns_service.rs
+++ b/src/dyndns_service.rs
@@ -10,6 +10,8 @@ use std::error::Error;
pub enum DynDnsProvider {
#[serde(rename = "GANDI")]
Gandi(GandiConfig),
+ #[serde(rename = "NOOP")]
+ Noop,
}
pub struct DynDnsService {}
diff --git a/src/error.rs b/src/error.rs
index 3e05d2a..429aa58 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -1,3 +1,4 @@
+use homedir::GetHomeError;
use reqwest::{Error as ReqwestError, Url};
use serde_json::Error as JsonError;
use std::fmt;
@@ -7,17 +8,24 @@ use std::path::PathBuf;
pub type AppResult<T> = Result<T, AppError>;
#[derive(Debug)]
pub enum AppError {
- FileNotFound(PathBuf),
- IoError(io::Error),
+ ConfigFileNotFound(PathBuf),
+ ConfigFileNotProvided,
ConfigParseError { source: JsonError, path: PathBuf },
+ IoError(io::Error),
RequestFailed { url: Url, source: ReqwestError },
InvalidResponse { url: Url, reason: String },
+ UnableToGetHomeDirectory(GetHomeError),
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
- Self::FileNotFound(path) => write!(f, "File not found: {}", path.display()),
+ Self::ConfigFileNotFound(path) => {
+ write!(f, "Config file not found: {}", path.display())
+ }
+ Self::ConfigFileNotProvided => {
+ write!(f, "Config file not provided")
+ }
Self::IoError(err) => write!(f, "I/O error: {}", err),
Self::ConfigParseError { path, .. } => {
write!(f, "Failed to parse config at {}", path.display())
@@ -26,6 +34,9 @@ impl fmt::Display for AppError {
Self::InvalidResponse { url, reason } => {
write!(f, "Invalid response from {}: {}", url, reason)
}
+ Self::UnableToGetHomeDirectory(err) => {
+ write!(f, "Failed to get home directory: {}", err)
+ }
}
}
}
diff --git a/src/ip_service.rs b/src/ip_service.rs
index f1e3078..3d869db 100644
--- a/src/ip_service.rs
+++ b/src/ip_service.rs
@@ -1,11 +1,11 @@
use crate::error::{AppError, AppResult};
-use crate::ip_service::IpServiceProvider::IdentMe;
+use crate::ip_service::IpServiceProvider::{IdentMe, Noop};
use http::StatusCode;
use reqwest::{Client, Url};
use serde::{Deserialize, Serialize};
use serde_with::DisplayFromStr;
use serde_with::serde_as;
-use std::net::IpAddr;
+use std::net::{IpAddr, Ipv4Addr};
use std::str::FromStr;
#[derive(Debug, Deserialize, PartialEq, Serialize)]
@@ -13,12 +13,15 @@ use std::str::FromStr;
pub enum IpServiceProvider {
#[serde(rename = "IDENTME")]
IdentMe(IdentMeConfig),
+ #[serde(rename = "NOOP")]
+ Noop,
}
impl IpService for IpServiceProvider {
async fn resolve(&self, client: &Client) -> AppResult<IpAddr> {
match self {
IdentMe(ident_me) => ident_me.resolve(client).await,
+ Noop => Ok(IpAddr::V4(Ipv4Addr::UNSPECIFIED)),
}
}
}
diff --git a/src/main.rs b/src/main.rs
index 57f4c7d..66fbdee 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,6 +1,10 @@
use crate::config::Config;
-use crate::error::AppResult;
+use crate::error::{AppError, AppResult};
+use crate::ip_service::IpService;
use clap::Parser;
+use homedir::my_home;
+use log::info;
+use reqwest::ClientBuilder;
use std::path::PathBuf;
use std::process::exit;
@@ -13,17 +17,54 @@ mod ip_service;
mod test_macros;
#[derive(Parser, Debug)]
-#[command(version, about, long_about = None)]
+#[command(ignore_errors(true), version, about, long_about = None)]
struct Args {
- #[arg(short, long, default_value = "~/.config/multiwan-dyndns/config.json")]
- config_file: PathBuf,
+ #[arg(short, long)]
+ config_file: Option<PathBuf>,
}
-fn main() {
+impl Args {
+ fn get_config_files(&self) -> Vec<PathBuf> {
+ match self.config_file {
+ Some(ref config_file) => vec![config_file.to_path_buf()],
+ None => DefaultConfigFile::default().paths,
+ }
+ }
+}
+
+struct DefaultConfigFile {
+ paths: Vec<PathBuf>,
+}
+
+impl DefaultConfigFile {
+ fn user_home_config() -> AppResult<Option<PathBuf>> {
+ let home = my_home().map_err(AppError::UnableToGetHomeDirectory)?;
+ match home {
+ None => Ok(None),
+ Some(mut path) => {
+ path.push(".config/multiwan-dyndns/config.json");
+ Ok(Some(path))
+ }
+ }
+ }
+}
+impl Default for DefaultConfigFile {
+ fn default() -> Self {
+ let mut paths = Vec::new();
+ paths.push(PathBuf::from("/etc/multiwan-dyndns.json"));
+ if let Some(user_home) = Self::user_home_config().unwrap() {
+ paths.push(user_home);
+ }
+ Self { paths }
+ }
+}
+
+#[tokio::main]
+async fn main() {
env_logger::init();
let args = Args::parse();
- match run(args) {
+ match run(args).await {
Ok(_) => exit(0),
Err(e) => {
log::error!("{}", e);
@@ -32,10 +73,12 @@ fn main() {
}
}
-fn run(args: Args) -> AppResult<()> {
- let config = Config::load(&args.config_file)?;
+async fn run(args: Args) -> AppResult<()> {
+ let config = Config::load(args.get_config_files())?;
+ let client = ClientBuilder::new().build()?;
for network in config.networks {
- // let ip = IpService::resolve(&network.ip_service)?;
+ let ip = network.ip_service.resolve(&client).await?;
+ info!("{:?}: {}", network.interface, ip);
// for dyndns in network.providers {
// DynDnsService::register(&dyndns, &ip)?;
// }
@@ -43,3 +86,44 @@ fn run(args: Args) -> AppResult<()> {
Ok(())
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use homedir::my_home;
+ use std::ffi::OsString;
+ use std::iter;
+ use std::path::Path;
+
+ macro_rules! assert_contains_config {
+ ($config:expr, $expected:expr) => {
+ assert!(
+ $config.contains(&$expected.to_path_buf()),
+ "Expected {} path {:?}\nActual paths: {:?}",
+ stringify!($expected),
+ $expected,
+ $config
+ );
+ };
+ }
+
+ test! {
+ fn verify_default_config_path_is_in_home_directory() {
+ let args = Args::parse_from(iter::empty::<OsString>());
+
+ let global_config = Path::new("/etc/multiwan-dyndns.json");
+ let user_config_path = format!("{}/.config/multiwan-dyndns/config.json", my_home()?.unwrap().to_string_lossy());
+ let user_config = Path::new(&user_config_path);
+
+ let actual = args.get_config_files();
+ assert_contains_config!(actual, user_config);
+ assert_contains_config!(actual, global_config);
+ }
+ }
+
+ #[tokio::test]
+ async fn verify_it_actually_runs() {
+ let args = vec!["multiwan-dyndns", "--config-file", "test/noop.json"];
+ run(Args::parse_from(args)).await.expect("app didn't run!");
+ }
+}
diff --git a/src/test_macros.rs b/src/test_macros.rs
index 9f3ff2b..e1998cd 100644
--- a/src/test_macros.rs
+++ b/src/test_macros.rs
@@ -1,4 +1,17 @@
#[macro_export]
+macro_rules! test {
+ (fn $name:ident() $body:block) => {
+ #[test]
+ fn $name() -> Result<(), Box<dyn std::error::Error>> {
+ (|| -> Result<(), Box<dyn std::error::Error>> {
+ $body;
+ Ok(())
+ })()
+ }
+ };
+}
+
+#[macro_export]
macro_rules! assert_error {
($result:expr, $pattern:pat => $body:block) => {
match $result {
@@ -12,9 +25,9 @@ macro_rules! assert_error {
}
#[macro_export]
-macro_rules! assert_file_not_found {
+macro_rules! assert_config_file_not_found {
($result:expr, $expected_path:expr) => {
- assert_error!($result, AppError::FileNotFound(path) => {
+ assert_error!($result, AppError::ConfigFileNotFound(path) => {
assert_eq!(path, $expected_path,
"Expected file not found for path {:?}, but got path {:?}",
$expected_path, path);
diff --git a/test/noop.json b/test/noop.json
new file mode 100644
index 0000000..837820e
--- /dev/null
+++ b/test/noop.json
@@ -0,0 +1,11 @@
+[
+ {
+ "fqdn": "dyn.domain.com",
+ "providers": [{
+ "type": "NOOP"
+ }],
+ "ip_service": {
+ "type": "NOOP"
+ }
+ }
+]