From 5695c7f3acb43b4945b97f8fb399bdd5fe0573fc Mon Sep 17 00:00:00 2001 From: Jake Meyer Date: Sat, 9 Nov 2024 13:59:51 -0800 Subject: [PATCH] Add support for fallback STUN urls --- Cargo.toml | 1 + README.md | 38 ++++++++++- src/client.rs | 123 +++++++++++++++++++----------------- src/config.rs | 15 +++-- src/main.rs | 2 + src/providers/cloudflare.rs | 11 ++-- 6 files changed, 117 insertions(+), 73 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index c843475..c682bb9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,7 @@ toml = "0.8.19" tracing = { version = "0.1.40", features = ["log"] } tracing-subscriber = "0.3.18" typetag = "0.2.18" +url = { version = "2.5.3", features = ["serde"] } [profile.release] strip = true diff --git a/README.md b/README.md index 5c25fb1..69e011a 100755 --- a/README.md +++ b/README.md @@ -1,19 +1,51 @@ # DDRS - A dynamic DNS client written in Rust 🦀 ## Features -* IP lookups via [STUN](https://en.wikipedia.org/wiki/STUN), HTTP(S), and local network interfaces +* IP lookups via [STUN](https://en.wikipedia.org/wiki/STUN), HTTP(S), or local network interfaces * Support for multiple DNS providers * Support for multiple domains/subdomains * Support for IPv4 and IPv6 -## Default Config +## Config The configuration file is in [TOML](https://toml.io/en/) format. The default location for the configuration file is `/etc/ddrs/config.toml`. A custom location can be specified with the `--config` flag. +### Options +* `versions` - IP version to fetch and update +* `dry_run` - Fetch the IP address but do not update the DNS records +* `stun_urls` - A list of STUN servers to use for IP lookups +* `http_ipv4` - A list of HTTP(S) URLs to use for IPv4 lookups +* `http_ipv6` - A list of HTTP(S) URLs to use for IPv6 lookups +* `source` - The source to use for IP lookups + * `type` - The source type. Must be `stun`, `http`, or `interface` + * `name` - Only required for `interface` source type, (e.g. `eth0`, `wlan0`) + +### Default Config + ```toml versions = ["v4"] # versions = ["v4", "v6"] +dry_run = false + +stun_urls = [ + "stun://stun.l.google.com:19302", + "stun://stun.cloudflare.com:3478", + "stun://global.stun.twilio.com:3478", +] + +http_ipv4 = [ + "https://api.ipify.org", + "https://ipv4.icanhazip.com", + "https://ipv4.seeip.org", +] + +http_ipv6 = [ + "https://api6.ipify.org", + "https://ipv6.icanhazip.com", + "https://ipv6.seeip.org", +] + [source] type = "stun" @@ -60,7 +92,7 @@ comment = "Root domain" ``` ## Deployment -* Logging can be configured with the [RUST_LOG](https://docs.rs/env_logger/latest/env_logger/#enabling-logging) environment variable. By default, the log level is set to `info`. For more verbose logging, set the environment variable to `ddrs=debug`. +* Logging can be configured with the [RUST_LOG](https://docs.rs/env_logger/latest/env_logger/#enabling-logging) environment variable. By default, the log level is set to `info`. For more verbose logging, set the environment variable to `RUST_LOG=ddrs=debug`. ### Docker Compose * Create configuration file `config.toml` diff --git a/src/client.rs b/src/client.rs index 2e90740..1f0057f 100755 --- a/src/client.rs +++ b/src/client.rs @@ -15,6 +15,7 @@ use tokio::task::{JoinHandle, JoinSet}; use tokio::time; use tokio_util::sync::CancellationToken; use tracing::{debug, error, info}; +use url::Url; use stun::agent::TransactionId; use stun::client::ClientBuilder; @@ -105,64 +106,66 @@ impl Client { /// Fetches the IP address via a STUN request to a public server async fn fetch_ip_stun(&self, version: &IpVersion) -> Result { - let resolved = resolve_host(&self.resolver, &self.config.stun_url).await?; - let (handler_tx, mut handler_rx) = tokio::sync::mpsc::unbounded_channel(); - let bind_address = match version { - IpVersion::V4 => "0:0", - IpVersion::V6 => "[::]:0", - }; - let conn = UdpSocket::bind(bind_address).await?; - let stun_ip = match version { - IpVersion::V4 => { - if let Some(v4) = resolved.v4 { - SocketAddr::new(IpAddr::V4(v4), self.config.stun_port) - } else { - error!( - "Failed to create ipv4 socket address for STUN server, is ipv4 enabled?" - ); - return Err(anyhow!( + for url in &self.config.stun_urls { + let url = Url::parse(url)?; + let host = url + .host_str() + .ok_or(anyhow!("Unable to parse host for url: {}", url))?; + let port = url + .port() + .ok_or(anyhow!("Unable to parse port for url: {}", url))?; + let resolved = resolve_host(&self.resolver, host).await?; + let (handler_tx, mut handler_rx) = tokio::sync::mpsc::unbounded_channel(); + let bind_address = match version { + IpVersion::V4 => "0:0", + IpVersion::V6 => "[::]:0", + }; + let conn = UdpSocket::bind(bind_address).await?; + let stun_ip = match version { + IpVersion::V4 => { + if let Some(v4) = resolved.v4 { + SocketAddr::new(IpAddr::V4(v4), port) + } else { + return Err(anyhow!( "Failed to create ipv4 socket address for STUN server, is ipv4 enabled?" )); + } } - } - IpVersion::V6 => { - if let Some(v6) = resolved.v6 { - SocketAddr::new(IpAddr::V6(v6), self.config.stun_port) - } else { - error!( - "Failed to create ipv6 socket address for STUN server, is ipv6 enabled?" - ); - return Err(anyhow!( + IpVersion::V6 => { + if let Some(v6) = resolved.v6 { + SocketAddr::new(IpAddr::V6(v6), port) + } else { + return Err(anyhow!( "Failed to create ipv6 socket address for STUN server, is ipv6 enabled?" )); + } } - } - }; - if let Err(e) = conn.connect(stun_ip).await { - return Err(anyhow!(e).context("Failed to connect to STUN server")); - } - let mut client = ClientBuilder::new().with_conn(Arc::new(conn)).build()?; - let mut msg = Message::new(); - msg.build(&[Box::::default(), Box::new(BINDING_REQUEST)])?; - let handler = Arc::new(handler_tx); - client.send(&msg, Some(handler.clone())).await?; - if let Some(event) = handler_rx.recv().await { - let msg = event.event_body?; - let mut xor_addr = XorMappedAddress { - ip: match version { - IpVersion::V4 => IpAddr::V4(Ipv4Addr::from(0)), - IpVersion::V6 => IpAddr::V6(Ipv6Addr::from(0)), - }, - port: 0, }; - xor_addr.get_from(&msg)?; - client.close().await?; - Ok(xor_addr.ip) - } else { + if let Err(e) = conn.connect(stun_ip).await { + return Err(anyhow!(e).context("Failed to connect to STUN server")); + } + let mut client = ClientBuilder::new().with_conn(Arc::new(conn)).build()?; + let mut msg = Message::new(); + msg.build(&[Box::::default(), Box::new(BINDING_REQUEST)])?; + let handler = Arc::new(handler_tx); + client.send(&msg, Some(handler.clone())).await?; + if let Some(event) = handler_rx.recv().await { + let msg = event.event_body?; + let mut xor_addr = XorMappedAddress { + ip: match version { + IpVersion::V4 => IpAddr::V4(Ipv4Addr::from(0)), + IpVersion::V6 => IpAddr::V6(Ipv6Addr::from(0)), + }, + port: 0, + }; + xor_addr.get_from(&msg)?; + client.close().await?; + return Ok(xor_addr.ip); + } client.close().await?; - error!("Failed to receive STUN response"); - Err(anyhow!("Failed to receive STUN response")) + continue; } + Err(anyhow!("Failed to fetch IP address via STUN")) } /// Fetches the IP address via a HTTP request @@ -179,7 +182,6 @@ impl Client { } } } - error!("Failed to fetch IP address from HTTP"); Err(anyhow!("Failed to fetch IP address from HTTP")) } @@ -204,14 +206,18 @@ impl Client { v6: None, }; for version in &self.config.versions { - if let Some(ip) = match &self.config.source { - IpSource::Stun => self.fetch_ip_stun(version).await.ok(), - IpSource::Http => self.fetch_ip_http(version).await.ok(), - IpSource::Interface(interface) => fetch_ip_interface(interface, version).ok(), - } { - match version { + let ip_result = match &self.config.source { + IpSource::Stun => self.fetch_ip_stun(version).await, + IpSource::Http => self.fetch_ip_http(version).await, + IpSource::Interface(interface) => fetch_ip_interface(interface, version), + }; + match ip_result { + Ok(ip) => match version { IpVersion::V4 => update.v4 = Some(ip), IpVersion::V6 => update.v6 = Some(ip), + }, + Err(error) => { + error!("Error fetching IP: {}", error); } } } @@ -224,7 +230,7 @@ impl Client { info!("Dry run mode enabled, skipping update..."); continue; } - info!("IP address update detected, updating providers..."); + info!("IP address update detected, updating with IP(s): {update}"); let mut set = JoinSet::new(); for provider in &self.config.providers { @@ -250,7 +256,7 @@ impl Client { } } if !failed { - info!("Providers updated successfully wih IP(s): {update}"); + info!("All providers updated successfully"); let mut cache = self.cache.write().await; *cache = update; } @@ -315,7 +321,6 @@ fn fetch_ip_interface(interface: &IpSourceInterface, version: &IpVersion) -> Res } } } - error!("Failed to find network interface: {}", interface.name); Err(anyhow!( "Failed to find network interface: {}", interface.name diff --git a/src/config.rs b/src/config.rs index 3749338..c212c7f 100755 --- a/src/config.rs +++ b/src/config.rs @@ -18,10 +18,8 @@ pub struct Config { pub versions: SmallVec<[IpVersion; 2]>, /// Toggle dry run mode pub dry_run: bool, - /// STUN server address - pub stun_url: String, - /// STUN server port - pub stun_port: u16, + /// STUN servers with port + pub stun_urls: SmallVec<[String; 3]>, /// HTTP servers for IPv4 address checks pub http_ipv4: SmallVec<[String; 3]>, /// HTTP servers for IPv6 address checks @@ -33,12 +31,15 @@ pub struct Config { impl Default for Config { fn default() -> Self { Self { - interval: Duration::from_secs(10), + interval: Duration::from_secs(30), source: IpSource::Stun, versions: smallvec![IpVersion::V4], dry_run: false, - stun_url: String::from("stun.l.google.com"), - stun_port: 19302, + stun_urls: smallvec![ + String::from("stun://stun.l.google.com:19302"), + String::from("stun://stun.cloudflare.com:3478"), + String::from("stun://global.stun.twilio.com:3478"), + ], http_ipv4: smallvec![ String::from("https://api.ipify.org"), String::from("https://ipv4.icanhazip.com"), diff --git a/src/main.rs b/src/main.rs index 2870fa7..e2d9199 100755 --- a/src/main.rs +++ b/src/main.rs @@ -39,6 +39,8 @@ async fn main() -> Result<()> { let config = toml::from_str::(&std::fs::read_to_string(config_path)?)?; + dbg!(&config); + if config.providers.is_empty() { return Err(anyhow!("No providers configured")); } diff --git a/src/providers/cloudflare.rs b/src/providers/cloudflare.rs index 9170e95..d1b36f3 100644 --- a/src/providers/cloudflare.rs +++ b/src/providers/cloudflare.rs @@ -86,9 +86,9 @@ impl Provider for Cloudflare { .await? .json::() .await?; - let zone_result = zones - .result - .ok_or(anyhow!("Failed to list Cloudflare zones"))?; + let zone_result = zones.result.ok_or(anyhow!( + "Failed to list Cloudflare zones, is your token valid?" + ))?; let zone_id = &zone_result .first() .ok_or(anyhow!("Failed to find a matching Cloudflare zone"))? @@ -228,7 +228,10 @@ mod tests { .update(UPDATE_BOTH, Client::new()) .await .unwrap_err(); - assert_eq!(error.to_string(), "Failed to list Cloudflare zones"); + assert_eq!( + error.to_string(), + "Failed to list Cloudflare zones, is your token valid?" + ); } #[tokio::test]