diff options
| author | Gus Power <gus@infinitesidequests.com> | 2025-05-15 15:18:39 +0100 |
|---|---|---|
| committer | Gus Power <gus@infinitesidequests.com> | 2025-05-15 15:18:39 +0100 |
| commit | 5f2466e463edcf6d161f9ba6371eaf7afc3549e3 (patch) | |
| tree | 6814ffd62c52245cc081ca6ebb419273d7b4b062 | |
| parent | dd1483cb6d9c060a17dc68357975de2b1ec09c08 (diff) | |
construct full config, use Args to load it
| -rw-r--r-- | Cargo.lock | 173 | ||||
| -rw-r--r-- | Cargo.toml | 5 | ||||
| -rw-r--r-- | src/config.rs | 39 | ||||
| -rw-r--r-- | src/dyndns_service.rs | 2 | ||||
| -rw-r--r-- | src/error.rs | 96 | ||||
| -rw-r--r-- | src/ip_service.rs | 37 | ||||
| -rw-r--r-- | src/main.rs | 40 | ||||
| -rw-r--r-- | test/full-config.json | 12 |
8 files changed, 391 insertions, 13 deletions
@@ -42,6 +42,56 @@ dependencies = [ ] [[package]] +name = "anstream" +version = "0.6.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" + +[[package]] +name = "anstyle-parse" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" +dependencies = [ + "anstyle", + "once_cell", + "windows-sys 0.59.0", +] + +[[package]] name = "assert-json-diff" version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -142,6 +192,52 @@ dependencies = [ ] [[package]] +name = "clap" +version = "4.5.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed93b9805f8ba930df42c2590f05453d5ec36cbb85d018868a5b24d31f6ac000" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "379026ff283facf611b0ea629334361c4211d1b12ee01024eec1591133b04120" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" + +[[package]] +name = "colorchoice" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" + +[[package]] name = "core-foundation" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -241,6 +337,29 @@ dependencies = [ ] [[package]] +name = "env_filter" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -754,12 +873,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" [[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] name = "itoa" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] +name = "jiff" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02000660d30638906021176af16b17498bd0d12813dbfe7b276d8bc7f3c0806" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde", +] + +[[package]] +name = "jiff-static" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c30758ddd7188629c6713fc45d1188af4f44c90582311d0c8d8c9907f60c48" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] name = "js-sys" version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -829,8 +978,11 @@ dependencies = [ name = "multiwan-dyndns" version = "0.1.0" dependencies = [ + "clap", + "env_logger", "fqdn", "http", + "log", "reqwest", "serde", "serde_json", @@ -966,6 +1118,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] +name = "portable-atomic" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + +[[package]] name = "potential_utf" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1589,6 +1756,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] name = "vcpkg" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -9,12 +9,15 @@ license = "MIT OR Apache-2.0" [dependencies] reqwest = "0.12.15" tokio = "1.45.0" -http = "1.3.1" +http = { version = "1.3.1" } serde_json = "1.0.140" serde = { version = "1.0.219", features = ["derive"] } 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"] } +log = "0.4.27" [dev-dependencies] wiremock = "0.6.3" diff --git a/src/config.rs b/src/config.rs index 9230f38..f098838 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,22 +1,47 @@ +use std::{fs, io}; +use std::path::{Path, PathBuf}; use crate::dyndns_service::DynDnsProvider; use fqdn::FQDN; use serde::{Deserialize, Serialize}; use serde_with::DisplayFromStr; use serde_with::serde_as; use strum::Display; +use crate::error::{AppError, AppResult}; +use crate::ip_service::IpServiceProvider; #[derive(Debug, Deserialize, Serialize)] #[serde(transparent)] -struct Config { - wans: Vec<WanConfig> +pub struct Config { + pub networks: Vec<WanConfig> +} + +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(), + }) + } + } #[derive(Debug, Deserialize, Serialize)] -struct WanConfig { - interface: Option<String>, +pub struct WanConfig { + pub interface: Option<String>, #[serde(flatten)] - dns_record: DnsRecord, - providers: Vec<DynDnsProvider>, + pub dns_record: DnsRecord, + pub providers: Vec<DynDnsProvider>, + #[serde(default)] + pub ip_service: IpServiceProvider } #[derive(Debug, Display, Deserialize, Serialize)] @@ -107,7 +132,7 @@ mod tests { let reader = BufReader::new(file); let actual: Config = serde_json::from_reader(reader)?; - assert_eq!(actual.wans.len(), 2); + assert_eq!(actual.networks.len(), 2); } Ok(()) } diff --git a/src/dyndns_service.rs b/src/dyndns_service.rs index fac34e7..d1449f5 100644 --- a/src/dyndns_service.rs +++ b/src/dyndns_service.rs @@ -11,7 +11,7 @@ pub enum DynDnsProvider { GANDI(Gandi) } -struct DynDnsService {} +pub struct DynDnsService {} impl DynDnsService { pub async fn register(config: &impl DynDnsServiceConfiguration) -> Result<(), Box<dyn Error>> { diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..d7bbfaf --- /dev/null +++ b/src/error.rs @@ -0,0 +1,96 @@ +use std::fmt; +use std::io; +use std::path::PathBuf; +use reqwest::Error as ReqwestError; +use serde_json::Error as JsonError; + +pub type AppResult<T> = Result<T, AppError>; +#[derive(Debug)] +pub enum AppError { + FileNotFound(PathBuf), + IoError(io::Error), + ConfigParseError { source: JsonError, path: PathBuf }, + RequestFailed { url: String, source: ReqwestError }, + InvalidResponse { url: String, reason: String }, + Timeout { url: String }, +} + +impl AppError { + pub fn with_path(self, path: impl Into<PathBuf>) -> Self { + match self { + Self::ConfigParseError { source, .. } => Self::ConfigParseError { + source, + path: path.into(), + }, + err => err, + } + } + + pub fn with_url(self, url: impl Into<String>) -> Self { + match self { + Self::RequestFailed { source, .. } => Self::RequestFailed { + source, + url: url.into(), + }, + Self::InvalidResponse { reason, .. } => Self::InvalidResponse { + reason, + url: url.into(), + }, + Self::Timeout { .. } => Self::Timeout { + url: url.into(), + }, + err => err, + } + } +} + +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::IoError(err) => write!(f, "I/O error: {}", err), + Self::ConfigParseError { path, .. } => write!(f, "Failed to parse config at {}", path.display()), + Self::RequestFailed { url, .. } => write!(f, "Request to {} failed", url), + Self::InvalidResponse { url, reason } => { + write!(f, "Invalid response from {}: {}", url, reason) + } + Self::Timeout { url } => write!(f, "Request to {} timed out", url), + } + } +} + +impl std::error::Error for AppError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::IoError(err) => Some(err), + Self::ConfigParseError { source, .. } => Some(source), + Self::RequestFailed { source, .. } => Some(source), + _ => None, + } + } +} + +// Convenient conversions from library errors +impl From<io::Error> for AppError { + fn from(err: io::Error) -> Self { + Self::IoError(err) + } +} + +impl From<ReqwestError> for AppError { + fn from(err: ReqwestError) -> Self { + Self::RequestFailed { + url: err.url().map_or_else(|| "unknown".to_string(), |u| u.to_string()), + source: err, + } + } +} + +impl From<JsonError> for AppError { + fn from(err: JsonError) -> Self { + Self::ConfigParseError { + source: err, + path: PathBuf::from("unknown"), // Default path + } + } +} diff --git a/src/ip_service.rs b/src/ip_service.rs index 8e2bee8..ee1a092 100644 --- a/src/ip_service.rs +++ b/src/ip_service.rs @@ -1,12 +1,28 @@ +use serde_with::DisplayFromStr; use reqwest::Url; use std::error::Error; use std::net::IpAddr; use std::str::FromStr; +use serde::{Deserialize, Serialize}; +use serde_with::serde_as; +use crate::ip_service::IpServiceProvider::IDENTME; + +#[derive(Debug, Deserialize, PartialEq, Serialize)] +#[serde(tag = "type")] +pub enum IpServiceProvider { + IDENTME(IdentMe) +} + +impl Default for IpServiceProvider { + fn default() -> Self { + IDENTME(IdentMe::default()) + } +} pub struct IpService {} impl IpService { - async fn resolve(config: &impl IpServiceConfiguration) -> Result<IpAddr, Box<dyn Error>> { + pub(crate) async fn resolve(config: &impl IpServiceConfiguration) -> Result<IpAddr, Box<dyn Error>> { let response = reqwest::get(config.get_service_url()).await.unwrap(); Ok(IpAddr::from_str(&response.text().await.unwrap())?) } @@ -16,6 +32,25 @@ pub trait IpServiceConfiguration { fn get_service_url(&self) -> Url; } +#[serde_as] +#[derive(Debug, Serialize, Deserialize, PartialEq)] +pub struct IdentMe { + #[serde_as(as = "DisplayFromStr")] + url: Url +} + +impl Default for IdentMe { + fn default() -> Self { + Self { url: Url::parse("https://v4.ident.me/").unwrap() } + } +} + +impl IpServiceConfiguration for IdentMe { + fn get_service_url(&self) -> Url { + self.url.clone() + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/main.rs b/src/main.rs index 62d2f38..7fce580 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,45 @@ +use std::path::{Path, PathBuf}; +use std::process::exit; +use clap::Parser; +use log::info; +use crate::config::Config; +use crate::dyndns_service::DynDnsService; +use crate::error::AppResult; +use crate::ip_service::IpService; + mod dyndns_service; mod ip_service; mod config; +mod error; + +#[derive(Parser, Debug)] +#[command(version, about, long_about = None)] +struct Args { + #[arg(short, long, default_value = "~/.config/multiwan-dyndns/config.json")] + config_file: PathBuf, +} fn main() { - println!("Hello, world!"); + env_logger::init(); + + let args = Args::parse(); + match run(args) { + Ok(_) => exit(0), + Err(e) => { + log::error!("{}", e); + exit(1); + } + } +} + +fn run(args: Args) -> AppResult<()> { + let config = Config::load(&args.config_file)?; + for network in config.networks { + // let ip = IpService::resolve(&network.ip_service)?; + // for dyndns in network.providers { + // DynDnsService::register(&dyndns, &ip)?; + // } + } + + Ok(()) } diff --git a/test/full-config.json b/test/full-config.json index fab3129..fe70d94 100644 --- a/test/full-config.json +++ b/test/full-config.json @@ -9,7 +9,11 @@ "type": "GANDI", "api_key": "SOME_API_KEY" } - ] + ], + "ip_service": { + "type": "IDENTME", + "url": "https://v4.ident.me/" + } }, { "interface": "eth1", @@ -21,6 +25,10 @@ "type": "GANDI", "api_key": "SOME_OTHER_API_KEY" } - ] + ], + "ip_service": { + "type": "IDENTME", + "url": "https://v4.ident.me/" + } } ] |
