Compare commits

..

14 Commits

Author SHA1 Message Date
64da1e7a2e Clarify license 2024-08-05 14:29:22 +02:00
60bdb83b6b move tests 2024-07-25 21:20:05 +02:00
1cd57845a1 update dependencies 2024-07-25 21:17:57 +02:00
787f74e3ec break out module 2024-07-25 21:16:42 +02:00
9b68b32354 prefer box 2024-07-25 21:07:31 +02:00
e5c9cb6024 prefer match 2024-07-25 21:03:43 +02:00
0cd2d364aa break out network_change_listener 2024-07-25 21:01:40 +02:00
f34c4609a5 exit listener 2024-07-25 20:46:30 +02:00
5466090256 remove main 2024-07-25 20:35:08 +02:00
b6f447e39f add check every so other 2024-07-25 20:32:52 +02:00
8e5d472018 tests and time support 2024-07-25 18:16:12 +02:00
b22616e209 license 2024-07-14 17:13:24 +02:00
4fd9a9832a readme 2024-07-14 17:04:08 +02:00
2e2d723b2a generics 2024-07-14 16:58:22 +02:00
16 changed files with 552 additions and 167 deletions

108
Cargo.lock generated
View File

@ -109,9 +109,9 @@ checksum = "a12916984aab3fa6e39d655a33e09c0071eb36d6ab3aea5c2d78551f1df6d952"
[[package]]
name = "cc"
version = "1.1.4"
version = "1.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9711f33475c22aab363b05564a17d7b789bf3dfec5ebabb586adee56f0e271b5"
checksum = "2aba8f4e9906c7ce3c73463f62a7f0c65183ada1a2d47e397cc8810827f9694f"
[[package]]
name = "cfg-if"
@ -337,7 +337,7 @@ checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.71",
"syn 2.0.72",
]
[[package]]
@ -705,13 +705,14 @@ dependencies = [
[[package]]
name = "mio"
version = "0.8.11"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
checksum = "4569e456d394deccd22ce1c1913e6ea0e54519f577285001215d33557431afe4"
dependencies = [
"hermit-abi",
"libc",
"wasi",
"windows-sys 0.48.0",
"windows-sys 0.52.0",
]
[[package]]
@ -816,21 +817,11 @@ dependencies = [
"autocfg",
]
[[package]]
name = "num_cpus"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43"
dependencies = [
"hermit-abi",
"libc",
]
[[package]]
name = "object"
version = "0.36.1"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "081b846d1d56ddfc18fdf1a922e4f6e07a11768ea1b92dec44e42b72712ccfce"
checksum = "3f203fa8daa7bb185f760ae12bd8e097f63d17041dcdcaf675ac54cdf863170e"
dependencies = [
"memchr",
]
@ -843,9 +834,9 @@ checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
[[package]]
name = "openssl"
version = "0.10.64"
version = "0.10.66"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95a0481286a310808298130d22dd1fef0fa571e05a8f44ec801801e84b216b1f"
checksum = "9529f4786b70a3e8c61e11179af17ab6188ad8d0ded78c5529441ed39d4bd9c1"
dependencies = [
"bitflags 2.6.0",
"cfg-if",
@ -864,7 +855,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.71",
"syn 2.0.72",
]
[[package]]
@ -875,9 +866,9 @@ checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
[[package]]
name = "openssl-sys"
version = "0.9.102"
version = "0.9.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c597637d56fbc83893a35eb0dd04b2b8e7a50c91e64e9493e398b5df4fb45fa2"
checksum = "7f9e8deee91df40a943c71b917e5874b951d32a802526c85721ce3b776c929d6"
dependencies = [
"cc",
"libc",
@ -952,7 +943,7 @@ checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.71",
"syn 2.0.72",
]
[[package]]
@ -1029,9 +1020,9 @@ dependencies = [
[[package]]
name = "redox_syscall"
version = "0.5.2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c82cf8cff14456045f55ec4241383baeff27af886adb72ffb2162f99911de0fd"
checksum = "2a908a6e00f1fdd0dfd9c0eb08ce85126f6d8bbda50017e74bc4a4b7d4a926a4"
dependencies = [
"bitflags 2.6.0",
]
@ -1144,9 +1135,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.11"
version = "0.23.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4828ea528154ae444e5a642dbb7d5623354030dc9822b83fd9bb79683c7399d0"
checksum = "c58f8c84392efc0a126acce10fa59ff7b3d2ac06ab451a33f2741989b806b044"
dependencies = [
"once_cell",
"rustls-pki-types",
@ -1173,9 +1164,9 @@ checksum = "976295e77ce332211c0d24d92c0e83e50f5c5f046d11082cea19f3df13a3562d"
[[package]]
name = "rustls-webpki"
version = "0.102.5"
version = "0.102.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9a6fccd794a42c2c105b513a2f62bc3fd8f3ba57a4593677ceb0bd035164d78"
checksum = "8e6b52d4fda176fd835fdc55a835d4a89b8499cad995885a21149d5ad62f852e"
dependencies = [
"ring",
"rustls-pki-types",
@ -1253,7 +1244,7 @@ checksum = "e0cd7e117be63d3c3678776753929474f3b04a43a080c744d6b0ae2a8c28e222"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.71",
"syn 2.0.72",
]
[[package]]
@ -1269,9 +1260,9 @@ dependencies = [
[[package]]
name = "serde_spanned"
version = "0.6.6"
version = "0.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79e674e01f999af37c49f70a6ede167a8a60b2503e56c5599532a65baa5969a0"
checksum = "eb5b1b31579f3811bf615c144393417496f152e12ac8b7663bf664f4a815306d"
dependencies = [
"serde",
]
@ -1360,9 +1351,9 @@ dependencies = [
[[package]]
name = "syn"
version = "2.0.71"
version = "2.0.72"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b146dcf730474b4bcd16c311627b31ede9ab149045db4d6088b3becaea046462"
checksum = "dc4b9b9bf2add8093d3f2c0204471e951b2285580335de42f9d2534f3ae7a8af"
dependencies = [
"proc-macro2",
"quote",
@ -1410,29 +1401,29 @@ dependencies = [
[[package]]
name = "thiserror"
version = "1.0.62"
version = "1.0.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2675633b1499176c2dff06b0856a27976a8f9d436737b4cf4f312d4d91d8bbb"
checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.62"
version = "1.0.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d20468752b09f49e909e55a5d338caa8bedf615594e9d80bc4c565d30faf798c"
checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.71",
"syn 2.0.72",
]
[[package]]
name = "thread-id"
version = "4.2.1"
version = "4.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0ec81c46e9eb50deaa257be2f148adf052d1fb7701cfd55ccfab2525280b70b"
checksum = "cfe8f25bbdd100db7e1d34acf7fd2dc59c4bf8f7483f505eaa7d4f12f76cc0ea"
dependencies = [
"libc",
"winapi",
@ -1455,32 +1446,31 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.38.0"
version = "1.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba4f4a02a7a80d6f274636f0aa95c7e383b912d41fe721a31f29e29698585a4a"
checksum = "d040ac2b29ab03b09d4129c2f5bbd012a3ac2f79d38ff506a4bf8dd34b0eac8a"
dependencies = [
"backtrace",
"bytes",
"libc",
"mio",
"num_cpus",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys 0.48.0",
"windows-sys 0.52.0",
]
[[package]]
name = "tokio-macros"
version = "2.3.0"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a"
checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.71",
"syn 2.0.72",
]
[[package]]
@ -1519,9 +1509,9 @@ dependencies = [
[[package]]
name = "toml"
version = "0.8.14"
version = "0.8.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f49eb2ab21d2f26bd6db7bf383edc527a7ebaee412d17af4d40fdccd442f335"
checksum = "81967dd0dd2c1ab0bc3468bd7caecc32b8a4aa47d0c8c695d8c2b2108168d62c"
dependencies = [
"serde",
"serde_spanned",
@ -1531,18 +1521,18 @@ dependencies = [
[[package]]
name = "toml_datetime"
version = "0.6.6"
version = "0.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4badfd56924ae69bcc9039335b2e017639ce3f9b001c393c1b2d1ef846ce2cbf"
checksum = "f8fb9f64314842840f1d940ac544da178732128f1c78c21772e876579e0da1db"
dependencies = [
"serde",
]
[[package]]
name = "toml_edit"
version = "0.22.15"
version = "0.22.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d59a3a72298453f564e2b111fa896f8d07fabb36f51f06d7e875fc5e0b5a3ef1"
checksum = "8d9f8729f5aea9562aac1cc0441f5d6de3cff1ee0c5d67293eeca5eb36ee7c16"
dependencies = [
"indexmap",
"serde",
@ -1707,7 +1697,7 @@ dependencies = [
"once_cell",
"proc-macro2",
"quote",
"syn 2.0.71",
"syn 2.0.72",
"wasm-bindgen-shared",
]
@ -1741,7 +1731,7 @@ checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.71",
"syn 2.0.72",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
@ -1934,9 +1924,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "0.6.13"
version = "0.6.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59b5e5f6c299a3c7890b876a2a587f3115162487e704907d9b6cd29473052ba1"
checksum = "b480ae9340fc261e6be3e95a1ba86d54ae3f9171132a73ce8d4bbaf68339507c"
dependencies = [
"memchr",
]

24
LICENSE Normal file
View File

@ -0,0 +1,24 @@
BSD 2-Clause License
Copyright (c) 2024, Love Billenius
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

80
README.md Normal file
View File

@ -0,0 +1,80 @@
# Dynamic IP Updater with Cloudflare DNS
This project is a dynamic IP updater for Cloudflare DNS records. It listens for changes in the IPv4 address of the network interface and updates the DNS records in Cloudflare accordingly.
## Features
- Automatically detects changes in the public IP address.
- Updates Cloudflare DNS records when the public IP changes.
- Listens for network interface events to detect IP changes.
- Handles errors and retries updates to Cloudflare.
## Prerequisites
- Rust and Cargo installed.
- Cloudflare account with API token.
- Network interface with dynamic IP address.
## Installation
1. **Clone the repository:**
```sh
git clone https://github.com/yourusername/dynip-cloudflare.git
cd dynip-cloudflare
```
2. **Build the project:**
```sh
cargo build --release
```
3. **Create configuration file:**
Create a `config.toml` file in the current directory or in the configuration directory (typically `~/.config/dynip-cloudflare/`) with the following content:
```toml
zone_id = "your_zone_id"
api_key = "your_api_key"
domains = ["example.com", "sub.example.com"]
max_errors_in_row = 5
```
## Usage
1. **Run the program:**
```sh
./target/release/dynip-cloudflare
```
2. **Logging:**
The program logs to both stderr and a rotating file located in the `logs` directory. The log level is set to `Info`.
## Configuration
### `config.toml`
- `zone_id`: The Zone ID of your Cloudflare domain.
- `api_key`: The API key for your Cloudflare account.
- `domains`: A list of domain names to update.
- `max_errors_in_row`: Maximum number of consecutive errors before the program stops. Will be set to 10 as default
### Example
```toml
zone_id = "your_zone_id"
api_key = "your_api_key"
domains = ["example.com", "sub.example.com"]
max_errors_in_row = 5
```
## Error Handling
The program handles errors gracefully, with retries and logging of error messages. If the number of consecutive errors exceeds `max_errors_in_row`, the program will stop.
## License
This project is licensed under the BSD 2-Clause License.

View File

@ -1,18 +1,26 @@
// SPDX: BSD-2-Clause
use crate::utils::duration_to_string;
use anyhow;
use dirs;
use log::warn;
use serde::{self, Deserialize, Serialize};
use std::{env, path::PathBuf};
use std::env;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::{fs, io::AsyncReadExt};
use crate::PROGRAM_NAME;
#[derive(Deserialize, Serialize, Debug)]
#[derive(Debug, Deserialize, Serialize)]
pub struct Config {
pub zone_id: Box<str>,
pub api_key: Box<str>,
pub zone_id: String,
pub api_key: String,
pub domains: Vec<Box<str>>,
#[serde(default)]
pub max_errors_in_row: Option<usize>,
#[serde(with = "duration_format", default)]
pub max_duration: Option<Duration>,
}
pub async fn get_config_path() -> Result<PathBuf, Vec<PathBuf>> {
@ -58,9 +66,64 @@ pub async fn get_config_path() -> Result<PathBuf, Vec<PathBuf>> {
Err(tried_paths)
}
pub async fn read_config(path: &PathBuf) -> anyhow::Result<Config> {
let mut file = fs::File::open(&path).await?;
pub async fn read_config<P: AsRef<Path>>(path: &P) -> anyhow::Result<Config> {
let mut file = fs::File::open(path).await?;
let mut buf = String::new();
file.read_to_string(&mut buf).await?;
Ok(toml::from_str(&buf)?)
}
mod duration_format {
use super::*;
use serde::{self, Deserialize, Deserializer, Serializer};
use std::time::Duration;
pub fn serialize<S>(duration: &Option<Duration>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match duration.as_ref() {
Some(duration) => serializer.serialize_str(duration_to_string(duration).trim()),
None => serializer.serialize_none(),
}
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
where
D: Deserializer<'de>,
{
Option::<Box<str>>::deserialize(deserializer)?.map_or(Ok(None), |s| {
parse_duration(&s)
.map(Some)
.map_err(serde::de::Error::custom)
})
}
fn parse_duration(s: &str) -> Result<Duration, String> {
let mut total_duration = Duration::new(0, 0);
let units = [("d", 86400), ("h", 3600), ("m", 60), ("s", 1)];
let mut remainder = s;
for &(unit, factor) in &units {
if let Some(idx) = remainder.find(unit) {
let (value, rest) = remainder.split_at(idx);
let value: u64 = value.trim().parse().map_err(|_| "Invalid number")?;
total_duration += Duration::from_secs(value * factor);
remainder = &rest[unit.len()..];
}
}
if let Some(idx) = remainder.find("ns") {
let (value, rest) = remainder.split_at(idx);
let value: u32 = value.trim().parse().map_err(|_| "Invalid number")?;
total_duration += Duration::new(0, value);
remainder = &rest["ns".len()..];
}
if !remainder.trim().is_empty() {
return Err("Invalid duration format".to_string());
}
Ok(total_duration)
}
}

43
src/exit_listener.rs Normal file
View File

@ -0,0 +1,43 @@
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use tokio::{
self, signal,
sync::{futures::Notified, Notify},
};
pub struct ExitListener {
should_exit: Arc<AtomicBool>,
notify: Arc<Notify>,
}
impl ExitListener {
pub fn new() -> Self {
let this = Self {
should_exit: Arc::new(AtomicBool::new(false)),
notify: Arc::new(Notify::new()),
};
let should_exit = this.should_exit.clone();
let notify = this.notify.clone();
tokio::spawn(async move {
signal::ctrl_c()
.await
.expect("Failed to install CTRL+C signal handler");
should_exit.store(true, Ordering::SeqCst);
notify.notify_one();
});
this
}
pub fn notified(&self) -> Notified<'_> {
self.notify.notified()
}
pub fn should_exit(&self) -> bool {
self.should_exit.load(Ordering::SeqCst)
}
}

View File

@ -1,49 +1,34 @@
// SPDX: BSD-2-Clause
use anyhow::{Context, Result};
use log::{error, info};
use reqwest::Client;
use serde::{self, Deserialize, Serialize};
use std::{
collections::HashMap,
fmt,
net::{IpAddr, Ipv4Addr},
};
use super::cloudflare_responses::{CloudflareResponse, DnsRecord};
use crate::get_current_public_ipv4;
pub struct CloudflareClient {
pub struct CloudflareClient<A, Z>
where
A: fmt::Display,
Z: fmt::Display,
{
client: Client,
domains: Vec<Box<str>>,
current_ip: Ipv4Addr,
api_key: Box<str>,
zone_id: Box<str>,
api_key: A,
zone_id: Z,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
struct DnsRecord {
id: String,
#[serde(rename = "type")]
record_type: Box<str>,
name: Box<str>,
content: Box<str>,
ttl: u32,
proxied: bool,
locked: bool,
zone_id: Box<str>,
zone_name: Box<str>,
modified_on: Box<str>,
created_on: Box<str>,
meta: HashMap<Box<str>, serde_json::Value>,
}
#[derive(Serialize, Deserialize, Debug)]
struct CloudflareResponse {
success: bool,
errors: Vec<HashMap<String, serde_json::Value>>,
messages: Vec<HashMap<String, serde_json::Value>>,
result: Option<Vec<DnsRecord>>,
}
impl CloudflareClient {
pub async fn new(api_key: Box<str>, zone_id: Box<str>, domains: Vec<Box<str>>) -> Result<Self> {
impl<Api, Zone> CloudflareClient<Api, Zone>
where
Api: fmt::Display,
Zone: fmt::Display,
{
pub async fn new(api_key: Api, zone_id: Zone, domains: Vec<Box<str>>) -> Result<Self> {
let force_ipv4 = IpAddr::from([0, 0, 0, 0]);
let client = reqwest::ClientBuilder::new()
.local_address(force_ipv4)
@ -117,7 +102,7 @@ impl CloudflareClient {
Ok(())
}
async fn get_dns_records(&self, domain: &str) -> Result<Vec<DnsRecord>> {
async fn get_dns_records<D: fmt::Display>(&self, domain: &D) -> Result<Vec<DnsRecord>> {
let url = format!(
"https://api.cloudflare.com/client/v4/zones/{}/dns_records?type=A&name={}",
self.zone_id, domain

View File

@ -0,0 +1,27 @@
use std::collections::HashMap;
use serde;
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
pub struct DnsRecord {
pub id: Box<str>,
#[serde(rename = "type")]
pub record_type: Box<str>,
pub name: Box<str>,
pub content: Box<str>,
pub ttl: u32,
pub proxied: bool,
pub locked: bool,
pub zone_id: Box<str>,
pub zone_name: Box<str>,
pub modified_on: Box<str>,
pub created_on: Box<str>,
pub meta: HashMap<Box<str>, serde_json::Value>,
}
#[derive(serde::Serialize, serde::Deserialize, Debug)]
pub struct CloudflareResponse {
pub success: bool,
pub errors: Box<[HashMap<Box<str>, serde_json::Value>]>,
pub messages: Box<[HashMap<Box<str>, serde_json::Value>]>,
pub result: Option<Vec<DnsRecord>>,
}

3
src/internet/mod.rs Normal file
View File

@ -0,0 +1,3 @@
mod cloudflare;
pub mod cloudflare_responses;
pub use cloudflare::CloudflareClient;

View File

@ -1,15 +1,21 @@
mod cloudflare;
// SPDX: BSD-2-Clause
mod config;
mod exit_listener;
mod internet;
mod logging;
mod public_ip;
mod message_handler;
mod network_change_listener;
mod public_ip;
pub mod utils;
pub use cloudflare::CloudflareClient;
pub use config::{get_config_path, read_config, Config};
pub use exit_listener::ExitListener;
pub use internet::CloudflareClient;
pub use logging::init_logger;
pub use public_ip::get_current_public_ipv4;
pub use message_handler::MessageHandler;
pub use network_change_listener::NetworkChangeListener;
pub use public_ip::get_current_public_ipv4;
pub const PROGRAM_NAME: &'static str = "dynip-cloudflare";
pub const MAX_ERORS_IN_ROW_DEFAULT: usize = 10;

View File

@ -1,3 +1,5 @@
// SPDX: BSD-2-Clause
use chrono::Local;
use colored::*;
use log::LevelFilter;

View File

@ -1,30 +1,14 @@
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
// SPDX: BSD-2-Clause
use futures::stream::StreamExt;
use futures::future::{self, Either};
use log::{error, info};
use netlink_sys::{AsyncSocket, SocketAddr};
use rtnetlink::new_connection;
use dynip_cloudflare::{utils, CloudflareClient, MessageHandler, MAX_ERORS_IN_ROW_DEFAULT};
use scopeguard::defer;
use tokio::{signal, sync::Notify};
use tokio::time;
const RTNLGRP_LINK: u32 = 1;
const RTNLGRP_IPV4_IFADDR: u32 = 5;
const fn nl_mgrp(group: u32) -> u32 {
if group > 31 {
panic!("use netlink_sys::Socket::add_membership() for this group");
}
if group == 0 {
0
} else {
1 << (group - 1)
}
}
use dynip_cloudflare::{
utils, CloudflareClient, ExitListener, MessageHandler, NetworkChangeListener,
MAX_ERORS_IN_ROW_DEFAULT,
};
#[tokio::main]
async fn main() {
@ -32,24 +16,11 @@ async fn main() {
defer! {
log::logger().flush();
}
let should_exit = Arc::new(AtomicBool::new(false));
let notify = Arc::new(Notify::new());
let exit_listener = ExitListener::new();
let should_exit_clone = should_exit.clone();
let notify_clone = notify.clone();
tokio::spawn(async move {
signal::ctrl_c()
.await
.expect("Failed to install CTRL+C signal handler");
should_exit_clone.store(true, Ordering::SeqCst);
notify_clone.notify_one();
});
let config = if let Some(aux) = utils::get_config().await {
aux
} else {
return;
let config = match utils::get_config().await {
Some(aux) => aux,
None => return,
};
let mut cloudflare =
@ -61,29 +32,48 @@ async fn main() {
}
};
let (mut conn, mut _handle, mut messages) = new_connection().unwrap();
let groups = nl_mgrp(RTNLGRP_LINK) | nl_mgrp(RTNLGRP_IPV4_IFADDR);
let addr = SocketAddr::new(0, groups);
if let Err(e) = conn.socket_mut().socket_mut().bind(&addr) {
error!("Failed to bind to socket: {:?}", &e);
return;
}
tokio::spawn(conn);
info!("Listening for IPv4 address changes and interface connect/disconnect events...");
let mut network_change_listener = match NetworkChangeListener::new() {
Some(aux) => {
info!("Listening for IPv4 address changes and interface connect/disconnect events...");
aux
}
None => {
error!("Failed to initialize networkchangelistener");
return;
}
};
let mut message_handler = MessageHandler::new(
&mut cloudflare,
config.max_errors_in_row.unwrap_or(MAX_ERORS_IN_ROW_DEFAULT),
);
while !should_exit.load(Ordering::SeqCst) {
let mut interval = config.max_duration.map(|duration| {
let mut interval = time::interval(duration);
interval.set_missed_tick_behavior(time::MissedTickBehavior::Delay);
interval
});
while !exit_listener.should_exit() {
let tick_future = match interval.as_mut() {
Some(interval) => Either::Left(interval.tick()),
None => Either::Right(future::pending::<tokio::time::Instant>()),
};
tokio::select! {
_ = notify.notified() => break,
message = messages.next() => {
_ = exit_listener.notified() => break,
_ = tick_future => {
if let Some(duration) = config.max_duration.as_ref() {
let duration_string = utils::duration_to_string(duration);
let log_string = format!("{} has passed since last check, checking...", duration_string.trim());
message_handler.log_and_check(Some(&log_string), Option::<&&str>::None).await;
}
}
message = network_change_listener.next_message() => {
if let Some((message, _)) = message {
if let Some(interval) = interval.as_mut() {
interval.reset();
}
message_handler.handle_message(message).await;
}
}

View File

@ -1,19 +1,28 @@
use std::fmt;
// SPDX: BSD-2-Clause
use std::fmt;
use log::{debug, error, info};
use netlink_packet_core::{NetlinkMessage, NetlinkPayload};
use netlink_packet_route::RouteNetlinkMessage as RtnlMessage;
use crate::CloudflareClient;
pub struct MessageHandler<'a> {
cloudflare: &'a mut CloudflareClient,
pub struct MessageHandler<'a, Api, Zone>
where
Api: fmt::Display,
Zone: fmt::Display,
{
cloudflare: &'a mut CloudflareClient<Api, Zone>,
errs_counter: usize,
errs_max: usize,
}
impl<'a> MessageHandler<'a> {
pub fn new(cloudflare: &'a mut CloudflareClient, errs_max: usize) -> Self {
impl<'a, Api, Zone> MessageHandler<'a, Api, Zone>
where
Api: fmt::Display,
Zone: fmt::Display,
{
pub fn new(cloudflare: &'a mut CloudflareClient<Api, Zone>, errs_max: usize) -> Self {
Self {
cloudflare,
errs_counter: 0,
@ -24,13 +33,14 @@ impl<'a> MessageHandler<'a> {
pub async fn handle_message(&mut self, message: NetlinkMessage<RtnlMessage>) -> Option<()> {
match message.payload {
NetlinkPayload::InnerMessage(RtnlMessage::NewAddress(msg)) => {
self.log_and_check("New IPv4 address", &msg).await
self.log_and_check(Some("New IPv4 address"), Some(&msg))
.await
}
NetlinkPayload::InnerMessage(RtnlMessage::DelAddress(msg)) => {
self.log_info("Deleted IPv4 address", &msg).await
}
NetlinkPayload::InnerMessage(RtnlMessage::NewLink(link)) => {
self.log_and_check("New link (interface connected)", &link)
self.log_and_check(Some("New link (interface connected)"), Some(&link))
.await
}
NetlinkPayload::InnerMessage(RtnlMessage::DelLink(link)) => {
@ -44,13 +54,21 @@ impl<'a> MessageHandler<'a> {
}
}
async fn log_and_check<D, M>(&mut self, log_msg: &D, msg: &M) -> Option<()>
pub async fn log_and_check<D, M>(&mut self, log_msg: Option<&D>, msg: Option<&M>) -> Option<()>
where
D: fmt::Display + ?Sized,
M: fmt::Debug,
{
info!("{}", log_msg);
debug!("{}: {:?}", log_msg, msg);
if let Some(s) = log_msg {
info!("{}", s);
}
if let Some(m) = msg {
if let Some(lm) = log_msg {
debug!("{}: {:?}", lm, m);
} else {
debug!("{:?}", m);
}
}
if let Err(e) = self.cloudflare.check().await {
self.errs_counter += 1;
error!(

View File

@ -0,0 +1,60 @@
use futures::{
channel::mpsc::UnboundedReceiver,
stream::{Next, StreamExt},
};
use log::error;
use netlink_packet_core::NetlinkMessage;
use netlink_packet_route::RouteNetlinkMessage;
use netlink_sys::{AsyncSocket, SocketAddr};
use rtnetlink::new_connection;
use tokio::task::JoinHandle;
const RTNLGRP_LINK: u32 = 1;
const RTNLGRP_IPV4_IFADDR: u32 = 5;
const fn nl_mgrp(group: u32) -> u32 {
if group > 31 {
panic!("use netlink_sys::Socket::add_membership() for this group");
}
if group == 0 {
0
} else {
1 << (group - 1)
}
}
type Messages = UnboundedReceiver<(NetlinkMessage<RouteNetlinkMessage>, SocketAddr)>;
pub struct NetworkChangeListener {
messages: Messages,
thread_handle: JoinHandle<()>,
}
impl NetworkChangeListener {
pub fn new() -> Option<Self> {
let (mut conn, mut _handle, messages) = new_connection().ok()?;
let groups = nl_mgrp(RTNLGRP_LINK) | nl_mgrp(RTNLGRP_IPV4_IFADDR);
let addr = SocketAddr::new(0, groups);
if let Err(e) = conn.socket_mut().socket_mut().bind(&addr) {
error!("Failed to bind to socket: {:?}", &e);
return None;
}
let thread_handle = tokio::spawn(conn);
Some(Self {
messages,
thread_handle,
})
}
pub fn next_message(&mut self) -> Next<'_, Messages> {
self.messages.next()
}
}
impl Drop for NetworkChangeListener {
fn drop(&mut self) {
self.thread_handle.abort();
}
}

View File

@ -1,3 +1,5 @@
// SPDX: BSD-2-Clause
use anyhow::{anyhow, Context, Result};
use log::error;
use reqwest::Client;

View File

@ -1,3 +1,7 @@
// SPDX: BSD-2-Clause
use std::time::Duration;
use crate::{get_config_path, read_config, Config};
use log::error;
@ -26,3 +30,32 @@ pub async fn get_config() -> Option<Config> {
}
read_result.ok()
}
pub fn duration_to_string(duration: &Duration) -> String {
let mut secs = duration.as_secs();
let nanos = duration.subsec_nanos();
let days = secs / 86400;
secs %= 86400;
let hours = secs / 3600;
secs %= 3600;
let minutes = secs / 60;
secs %= 60;
let mut ret = String::new();
if days > 0 {
ret.push_str(&format!("{}d ", days));
}
if hours > 0 {
ret.push_str(&format!("{}h ", hours));
}
if minutes > 0 {
ret.push_str(&format!("{}m ", minutes));
}
if secs > 0 {
ret.push_str(&format!("{}s ", secs));
}
if nanos > 0 {
ret.push_str(&format!("{}ns", nanos));
}
ret
}

View File

@ -0,0 +1,59 @@
use dynip_cloudflare::Config;
use std::time::Duration;
use toml;
const TOML_STR_ONE: &str = r#"
zone_id = ""
api_key = ""
domains = [""]
max_duration = "1d 2h 30m 45s 500000000ns"
"#;
#[test]
fn test_deserialize() {
let config: Config = toml::from_str(TOML_STR_ONE).unwrap();
assert_eq!(config.max_duration, Some(Duration::new(95445, 500000000)));
}
#[test]
fn test_serialize() {
let config = Config {
zone_id: "".into(),
api_key: "".into(),
domains: vec!["".into()],
max_errors_in_row: None,
max_duration: Some(Duration::new(95445, 500000000)),
};
let toml_str = toml::to_string(&config).unwrap();
assert_eq!(TOML_STR_ONE.trim(), toml_str.trim());
}
#[test]
fn test_deserialize_none() {
let toml_str = r#"
zone_id = ""
api_key = ""
domains = [""]
max_errors_in_row = 5
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.max_duration, None);
}
#[test]
fn test_serialize_none() {
let toml_to_be = r#"
zone_id = ""
api_key = ""
domains = [""]
"#;
let config = Config {
zone_id: "".into(),
api_key: "".into(),
domains: vec!["".into()],
max_errors_in_row: None,
max_duration: None,
};
let toml_str = toml::to_string(&config).unwrap();
assert_eq!(toml_to_be.trim(), toml_str.trim());
}