Skip to content

Commit

Permalink
Add support for fallback STUN urls
Browse files Browse the repository at this point in the history
  • Loading branch information
jakewmeyer committed Nov 9, 2024
1 parent 8c3bed4 commit 5695c7f
Show file tree
Hide file tree
Showing 6 changed files with 117 additions and 73 deletions.
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 35 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -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"

Expand Down Expand Up @@ -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`
Expand Down
123 changes: 64 additions & 59 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<IpAddr> {
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::<TransactionId>::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::<TransactionId>::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
Expand All @@ -179,7 +182,6 @@ impl Client {
}
}
}
error!("Failed to fetch IP address from HTTP");
Err(anyhow!("Failed to fetch IP address from HTTP"))
}

Expand All @@ -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);
}
}
}
Expand All @@ -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 {
Expand All @@ -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;
}
Expand Down Expand Up @@ -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
Expand Down
15 changes: 8 additions & 7 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"),
Expand Down
2 changes: 2 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ async fn main() -> Result<()> {

let config = toml::from_str::<Config>(&std::fs::read_to_string(config_path)?)?;

dbg!(&config);

if config.providers.is_empty() {
return Err(anyhow!("No providers configured"));
}
Expand Down
11 changes: 7 additions & 4 deletions src/providers/cloudflare.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,9 @@ impl Provider for Cloudflare {
.await?
.json::<ZoneList>()
.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"))?
Expand Down Expand Up @@ -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]
Expand Down

0 comments on commit 5695c7f

Please sign in to comment.