Compare commits
4 Commits
v1.0
...
f5d7e05e9f
Author | SHA1 | Date | |
---|---|---|---|
f5d7e05e9f | |||
b8181d2e89 | |||
ba401ccdc5 | |||
e535c66b50 |
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,3 +1 @@
|
|||||||
/target
|
/target
|
||||||
logs
|
|
||||||
dynip-cloudflare.toml
|
|
||||||
|
24
LICENSE
24
LICENSE
@ -1,24 +0,0 @@
|
|||||||
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
80
README.md
@ -1,80 +0,0 @@
|
|||||||
# 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 MIT License.
|
|
5
logs/dynip-cloudflare-2024-07-14.log
Normal file
5
logs/dynip-cloudflare-2024-07-14.log
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
2024-07-14 15:30:54 [ERROR] Failed to find any config file, tried the paths: /home/love/Documents/Code/dynip-cloudflare/dynip-cloudflare.toml, /home/love/.config/dynip-cloudflare/config.toml
|
||||||
|
2024-07-14 15:35:23 [ERROR] Failed to find any config file, tried the paths: /home/love/Documents/Code/dynip-cloudflare/dynip-cloudflare.toml, /home/love/.config/dynip-cloudflare/config.toml
|
||||||
|
2024-07-14 15:38:03 [ERROR] Failed to find any config file, tried the paths: /home/love/Documents/Code/dynip-cloudflare/dynip-cloudflare.toml, /home/love/.config/dynip-cloudflare/config.toml
|
||||||
|
2024-07-14 15:38:18 [ERROR] Failed to find any config file, tried the paths: /home/love/Documents/Code/dynip-cloudflare/dynip-cloudflare.toml, /home/love/.config/dynip-cloudflare/config.toml
|
||||||
|
2024-07-14 15:40:38 [ERROR] Failed to find any config file, tried the paths: /home/love/Documents/Code/dynip-cloudflare/dynip-cloudflare.toml, /home/love/.config/dynip-cloudflare/config.toml
|
1
logs/dynip-cloudflare-2024-07-14.log.gz
Normal file
1
logs/dynip-cloudflare-2024-07-14.log.gz
Normal file
@ -0,0 +1 @@
|
|||||||
|
2024-07-14 15:26:30 [ERROR] Failed to find any config file, tried the paths: /home/love/Documents/Code/dynip-cloudflare/dynip-cloudflare.toml, /home/love/.config/dynip-cloudflare/config.toml
|
@ -1,34 +1,41 @@
|
|||||||
// SPDX: BSD-2-Clause
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use log::{error, info};
|
use log::{error, info};
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
|
use serde::{self, Deserialize, Serialize};
|
||||||
use std::{
|
use std::{
|
||||||
fmt,
|
collections::HashMap,
|
||||||
net::{IpAddr, Ipv4Addr},
|
net::{IpAddr, Ipv4Addr},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::cloudflare_responses::{CloudflareResponse, DnsRecord};
|
|
||||||
use crate::get_current_public_ipv4;
|
use crate::get_current_public_ipv4;
|
||||||
|
|
||||||
pub struct CloudflareClient<A, Z>
|
pub struct CloudflareClient {
|
||||||
where
|
|
||||||
A: fmt::Display,
|
|
||||||
Z: fmt::Display,
|
|
||||||
{
|
|
||||||
client: Client,
|
client: Client,
|
||||||
domains: Vec<Box<str>>,
|
domains: Vec<Box<str>>,
|
||||||
current_ip: Ipv4Addr,
|
current_ip: Ipv4Addr,
|
||||||
api_key: A,
|
api_key: Box<str>,
|
||||||
zone_id: Z,
|
zone_id: Box<str>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<Api, Zone> CloudflareClient<Api, Zone>
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
where
|
struct DnsRecord {
|
||||||
Api: fmt::Display,
|
id: String,
|
||||||
Zone: fmt::Display,
|
#[serde(rename = "type")]
|
||||||
{
|
record_type: Box<str>,
|
||||||
pub async fn new(api_key: Api, zone_id: Zone, domains: Vec<Box<str>>) -> Result<Self> {
|
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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CloudflareClient {
|
||||||
|
pub async fn new(api_key: Box<str>, zone_id: Box<str>, domains: Vec<Box<str>>) -> Result<Self> {
|
||||||
let force_ipv4 = IpAddr::from([0, 0, 0, 0]);
|
let force_ipv4 = IpAddr::from([0, 0, 0, 0]);
|
||||||
let client = reqwest::ClientBuilder::new()
|
let client = reqwest::ClientBuilder::new()
|
||||||
.local_address(force_ipv4)
|
.local_address(force_ipv4)
|
||||||
@ -50,11 +57,10 @@ where
|
|||||||
pub async fn check(&mut self) -> Result<()> {
|
pub async fn check(&mut self) -> Result<()> {
|
||||||
let new_ip = get_current_public_ipv4(&self.client).await?;
|
let new_ip = get_current_public_ipv4(&self.client).await?;
|
||||||
if new_ip == self.current_ip {
|
if new_ip == self.current_ip {
|
||||||
info!("IP '{}' is already set", new_ip);
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
info!(
|
info!(
|
||||||
"IP has changed from '{}' -> '{}'",
|
"Ip has changed from '{}' -> '{}'",
|
||||||
&self.current_ip, &new_ip
|
&self.current_ip, &new_ip
|
||||||
);
|
);
|
||||||
self.update_dns_records(new_ip).await?;
|
self.update_dns_records(new_ip).await?;
|
||||||
@ -69,7 +75,7 @@ where
|
|||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!(
|
error!(
|
||||||
"Could not fetch DNS records for domain '{}': {:?}",
|
"Could not getch dns records for domain '{}': {:?}",
|
||||||
&domain, &e
|
&domain, &e
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
@ -79,10 +85,6 @@ where
|
|||||||
let new_ip_s = new_ip.to_string().into_boxed_str();
|
let new_ip_s = new_ip.to_string().into_boxed_str();
|
||||||
for mut record in records.into_iter() {
|
for mut record in records.into_iter() {
|
||||||
if record.content == new_ip_s {
|
if record.content == new_ip_s {
|
||||||
info!(
|
|
||||||
"On {}, ip already updated to '{}'",
|
|
||||||
&record.name, &record.content
|
|
||||||
);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
info!(
|
info!(
|
||||||
@ -102,13 +104,12 @@ where
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_dns_records<D: fmt::Display>(&self, domain: &D) -> Result<Vec<DnsRecord>> {
|
async fn get_dns_records(&self, domain: &str) -> Result<Vec<DnsRecord>> {
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"https://api.cloudflare.com/client/v4/zones/{}/dns_records?type=A&name={}",
|
"https://api.cloudflare.com/client/v4/zones/{}/dns_records?type=A&name={}",
|
||||||
self.zone_id, domain
|
self.zone_id, domain
|
||||||
);
|
);
|
||||||
|
let mut response = self
|
||||||
let response = self
|
|
||||||
.client
|
.client
|
||||||
.get(&url)
|
.get(&url)
|
||||||
.header("Authorization", format!("Bearer {}", self.api_key))
|
.header("Authorization", format!("Bearer {}", self.api_key))
|
||||||
@ -116,23 +117,12 @@ where
|
|||||||
.send()
|
.send()
|
||||||
.await?
|
.await?
|
||||||
.error_for_status()?
|
.error_for_status()?
|
||||||
.text()
|
.json::<HashMap<String, Vec<DnsRecord>>>()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let cloudflare_response: CloudflareResponse =
|
response
|
||||||
serde_json::from_str(&response).context("Failed to deserialize Cloudflare response")?;
|
.remove("result")
|
||||||
|
.context("Key result not in return")
|
||||||
if !cloudflare_response.success {
|
|
||||||
error!(
|
|
||||||
"Cloudflare API returned errors: {:?}",
|
|
||||||
cloudflare_response.errors
|
|
||||||
);
|
|
||||||
return Err(anyhow::anyhow!("Cloudflare API returned errors"));
|
|
||||||
}
|
|
||||||
|
|
||||||
cloudflare_response
|
|
||||||
.result
|
|
||||||
.context("Key 'result' not found in response")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_dns_record(&self, record: &mut DnsRecord, ip_content: Box<str>) -> Result<()> {
|
async fn update_dns_record(&self, record: &mut DnsRecord, ip_content: Box<str>) -> Result<()> {
|
@ -1,26 +1,18 @@
|
|||||||
// SPDX: BSD-2-Clause
|
|
||||||
|
|
||||||
use crate::utils::duration_to_string;
|
|
||||||
use anyhow;
|
use anyhow;
|
||||||
use dirs;
|
use dirs;
|
||||||
use log::warn;
|
use log::warn;
|
||||||
use serde::{self, Deserialize, Serialize};
|
use serde::{self, Deserialize, Serialize};
|
||||||
use std::env;
|
use std::{env, path::PathBuf};
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::time::Duration;
|
|
||||||
use tokio::{fs, io::AsyncReadExt};
|
use tokio::{fs, io::AsyncReadExt};
|
||||||
|
|
||||||
use crate::PROGRAM_NAME;
|
use crate::PROGRAM_NAME;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
#[derive(Deserialize, Serialize, Debug)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub zone_id: String,
|
pub zone_id: Box<str>,
|
||||||
pub api_key: String,
|
pub api_key: Box<str>,
|
||||||
pub domains: Vec<Box<str>>,
|
pub domains: Vec<Box<str>>,
|
||||||
#[serde(default)]
|
|
||||||
pub max_errors_in_row: Option<usize>,
|
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>> {
|
pub async fn get_config_path() -> Result<PathBuf, Vec<PathBuf>> {
|
||||||
@ -66,64 +58,9 @@ pub async fn get_config_path() -> Result<PathBuf, Vec<PathBuf>> {
|
|||||||
Err(tried_paths)
|
Err(tried_paths)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn read_config<P: AsRef<Path>>(path: &P) -> anyhow::Result<Config> {
|
pub async fn read_config(path: &PathBuf) -> anyhow::Result<Config> {
|
||||||
let mut file = fs::File::open(path).await?;
|
let mut file = fs::File::open(&path).await?;
|
||||||
let mut buf = String::new();
|
let mut buf = String::new();
|
||||||
file.read_to_string(&mut buf).await?;
|
file.read_to_string(&mut buf).await?;
|
||||||
Ok(toml::from_str(&buf)?)
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -1,43 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,27 +0,0 @@
|
|||||||
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>>,
|
|
||||||
}
|
|
@ -1,3 +0,0 @@
|
|||||||
mod cloudflare;
|
|
||||||
pub mod cloudflare_responses;
|
|
||||||
pub use cloudflare::CloudflareClient;
|
|
16
src/lib.rs
16
src/lib.rs
@ -1,23 +1,15 @@
|
|||||||
// SPDX: BSD-2-Clause
|
mod cloudflare;
|
||||||
|
|
||||||
mod config;
|
mod config;
|
||||||
mod exit_listener;
|
|
||||||
mod logging;
|
mod logging;
|
||||||
mod message_handler;
|
|
||||||
mod network_change_listener;
|
|
||||||
mod public_ip;
|
mod public_ip;
|
||||||
|
mod message_handler;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
|
|
||||||
mod tests;
|
pub use cloudflare::CloudflareClient;
|
||||||
mod internet;
|
|
||||||
|
|
||||||
pub use internet::CloudflareClient;
|
|
||||||
pub use config::{get_config_path, read_config, Config};
|
pub use config::{get_config_path, read_config, Config};
|
||||||
pub use exit_listener::ExitListener;
|
|
||||||
pub use logging::init_logger;
|
pub use logging::init_logger;
|
||||||
pub use message_handler::MessageHandler;
|
|
||||||
pub use network_change_listener::NetworkChangeListener;
|
|
||||||
pub use public_ip::get_current_public_ipv4;
|
pub use public_ip::get_current_public_ipv4;
|
||||||
|
pub use message_handler::MessageHandler;
|
||||||
|
|
||||||
pub const PROGRAM_NAME: &'static str = "dynip-cloudflare";
|
pub const PROGRAM_NAME: &'static str = "dynip-cloudflare";
|
||||||
pub const MAX_ERORS_IN_ROW_DEFAULT: usize = 10;
|
pub const MAX_ERORS_IN_ROW_DEFAULT: usize = 10;
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
// SPDX: BSD-2-Clause
|
|
||||||
|
|
||||||
use chrono::Local;
|
use chrono::Local;
|
||||||
use colored::*;
|
use colored::*;
|
||||||
use log::LevelFilter;
|
use log::LevelFilter;
|
||||||
|
106
src/main.rs
106
src/main.rs
@ -1,26 +1,55 @@
|
|||||||
// SPDX: BSD-2-Clause
|
use std::sync::{
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
use futures::future::{self, Either};
|
Arc,
|
||||||
use log::{error, info};
|
|
||||||
use scopeguard::defer;
|
|
||||||
use tokio::time;
|
|
||||||
|
|
||||||
use dynip_cloudflare::{
|
|
||||||
utils, CloudflareClient, ExitListener, MessageHandler, NetworkChangeListener,
|
|
||||||
MAX_ERORS_IN_ROW_DEFAULT,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use futures::stream::StreamExt;
|
||||||
|
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};
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
dynip_cloudflare::init_logger();
|
dynip_cloudflare::init_logger();
|
||||||
defer! {
|
defer! {
|
||||||
log::logger().flush();
|
log::logger().flush();
|
||||||
}
|
}
|
||||||
let exit_listener = ExitListener::new();
|
let should_exit = Arc::new(AtomicBool::new(false));
|
||||||
|
let notify = Arc::new(Notify::new());
|
||||||
|
|
||||||
let config = match utils::get_config().await {
|
let should_exit_clone = should_exit.clone();
|
||||||
Some(aux) => aux,
|
let notify_clone = notify.clone();
|
||||||
None => return,
|
|
||||||
|
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 mut cloudflare =
|
let mut cloudflare =
|
||||||
@ -32,48 +61,29 @@ async fn main() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut network_change_listener = match NetworkChangeListener::new() {
|
let (mut conn, mut _handle, mut messages) = new_connection().unwrap();
|
||||||
Some(aux) => {
|
let groups = nl_mgrp(RTNLGRP_LINK) | nl_mgrp(RTNLGRP_IPV4_IFADDR);
|
||||||
info!("Listening for IPv4 address changes and interface connect/disconnect events...");
|
|
||||||
aux
|
let addr = SocketAddr::new(0, groups);
|
||||||
}
|
|
||||||
None => {
|
if let Err(e) = conn.socket_mut().socket_mut().bind(&addr) {
|
||||||
error!("Failed to initialize networkchangelistener");
|
error!("Failed to bind to socket: {:?}", &e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
tokio::spawn(conn);
|
||||||
|
info!("Listening for IPv4 address changes and interface connect/disconnect events...");
|
||||||
|
|
||||||
let mut message_handler = MessageHandler::new(
|
let mut message_handler = MessageHandler::new(
|
||||||
&mut cloudflare,
|
&mut cloudflare,
|
||||||
config.max_errors_in_row.unwrap_or(MAX_ERORS_IN_ROW_DEFAULT),
|
config.max_errors_in_row.unwrap_or(MAX_ERORS_IN_ROW_DEFAULT),
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut interval = config.max_duration.map(|duration| {
|
while !should_exit.load(Ordering::SeqCst) {
|
||||||
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! {
|
tokio::select! {
|
||||||
_ = exit_listener.notified() => break,
|
_ = notify.notified() => break,
|
||||||
_ = tick_future => {
|
message = messages.next() => {
|
||||||
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((message, _)) = message {
|
||||||
if let Some(interval) = interval.as_mut() {
|
|
||||||
interval.reset();
|
|
||||||
}
|
|
||||||
message_handler.handle_message(message).await;
|
message_handler.handle_message(message).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,28 +1,19 @@
|
|||||||
// SPDX: BSD-2-Clause
|
|
||||||
|
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use log::{debug, error, info};
|
|
||||||
|
use log::{debug, error, info, log_enabled, Level};
|
||||||
use netlink_packet_core::{NetlinkMessage, NetlinkPayload};
|
use netlink_packet_core::{NetlinkMessage, NetlinkPayload};
|
||||||
use netlink_packet_route::RouteNetlinkMessage as RtnlMessage;
|
use netlink_packet_route::RouteNetlinkMessage as RtnlMessage;
|
||||||
|
|
||||||
use crate::CloudflareClient;
|
use crate::CloudflareClient;
|
||||||
|
|
||||||
pub struct MessageHandler<'a, Api, Zone>
|
pub struct MessageHandler<'a> {
|
||||||
where
|
cloudflare: &'a mut CloudflareClient,
|
||||||
Api: fmt::Display,
|
|
||||||
Zone: fmt::Display,
|
|
||||||
{
|
|
||||||
cloudflare: &'a mut CloudflareClient<Api, Zone>,
|
|
||||||
errs_counter: usize,
|
errs_counter: usize,
|
||||||
errs_max: usize,
|
errs_max: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a, Api, Zone> MessageHandler<'a, Api, Zone>
|
impl<'a> MessageHandler<'a> {
|
||||||
where
|
pub fn new(cloudflare: &'a mut CloudflareClient, errs_max: usize) -> Self {
|
||||||
Api: fmt::Display,
|
|
||||||
Zone: fmt::Display,
|
|
||||||
{
|
|
||||||
pub fn new(cloudflare: &'a mut CloudflareClient<Api, Zone>, errs_max: usize) -> Self {
|
|
||||||
Self {
|
Self {
|
||||||
cloudflare,
|
cloudflare,
|
||||||
errs_counter: 0,
|
errs_counter: 0,
|
||||||
@ -33,14 +24,13 @@ where
|
|||||||
pub async fn handle_message(&mut self, message: NetlinkMessage<RtnlMessage>) -> Option<()> {
|
pub async fn handle_message(&mut self, message: NetlinkMessage<RtnlMessage>) -> Option<()> {
|
||||||
match message.payload {
|
match message.payload {
|
||||||
NetlinkPayload::InnerMessage(RtnlMessage::NewAddress(msg)) => {
|
NetlinkPayload::InnerMessage(RtnlMessage::NewAddress(msg)) => {
|
||||||
self.log_and_check(Some("New IPv4 address"), Some(&msg))
|
self.log_and_check("New IPv4 address", &msg).await
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
NetlinkPayload::InnerMessage(RtnlMessage::DelAddress(msg)) => {
|
NetlinkPayload::InnerMessage(RtnlMessage::DelAddress(msg)) => {
|
||||||
self.log_info("Deleted IPv4 address", &msg).await
|
self.log_info("Deleted IPv4 address", &msg).await
|
||||||
}
|
}
|
||||||
NetlinkPayload::InnerMessage(RtnlMessage::NewLink(link)) => {
|
NetlinkPayload::InnerMessage(RtnlMessage::NewLink(link)) => {
|
||||||
self.log_and_check(Some("New link (interface connected)"), Some(&link))
|
self.log_and_check("New link (interface connected)", &link)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
NetlinkPayload::InnerMessage(RtnlMessage::DelLink(link)) => {
|
NetlinkPayload::InnerMessage(RtnlMessage::DelLink(link)) => {
|
||||||
@ -48,43 +38,43 @@ where
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
debug!("Unhandled message payload: {:?}", message.payload);
|
if log_enabled!(Level::Debug) {
|
||||||
|
debug!("Unhandled message payload: {:?}", message.payload);
|
||||||
|
}
|
||||||
Some(())
|
Some(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn log_and_check<D, M>(&mut self, log_msg: Option<&D>, msg: Option<&M>) -> Option<()>
|
async fn log_and_check<D, M>(&mut self, log_msg: &D, msg: &M) -> Option<()>
|
||||||
where
|
where
|
||||||
D: fmt::Display + ?Sized,
|
D: fmt::Display + ?Sized,
|
||||||
M: fmt::Debug,
|
M: fmt::Debug,
|
||||||
{
|
{
|
||||||
if let Some(s) = log_msg {
|
if log_enabled!(Level::Debug) {
|
||||||
info!("{}", s);
|
debug!("{}: {:?}", log_msg, msg);
|
||||||
}
|
} else {
|
||||||
if let Some(m) = msg {
|
info!("{}", log_msg);
|
||||||
if let Some(lm) = log_msg {
|
if let Err(e) = self.cloudflare.check().await {
|
||||||
debug!("{}: {:?}", lm, m);
|
self.errs_counter += 1;
|
||||||
} else {
|
error!(
|
||||||
debug!("{:?}", m);
|
"Failed to check cloudflare ({}/{}): {:?}",
|
||||||
}
|
self.errs_counter, self.errs_max, &e
|
||||||
}
|
);
|
||||||
if let Err(e) = self.cloudflare.check().await {
|
if self.errs_counter >= self.errs_max {
|
||||||
self.errs_counter += 1;
|
return None;
|
||||||
error!(
|
}
|
||||||
"Failed to check cloudflare ({}/{}): {:?}",
|
|
||||||
self.errs_counter, self.errs_max, &e
|
|
||||||
);
|
|
||||||
if self.errs_counter >= self.errs_max {
|
|
||||||
return None;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(())
|
Some(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn log_info<M: fmt::Debug>(&self, log_msg: &str, msg: &M) -> Option<()> {
|
async fn log_info<M: fmt::Debug>(&self, log_msg: &str, msg: &M) -> Option<()> {
|
||||||
info!("{}", log_msg);
|
if log_enabled!(Level::Debug) {
|
||||||
debug!("{:?} message: {:?}", log_msg, msg);
|
debug!("{:?} message: {:?}", log_msg, msg);
|
||||||
|
} else {
|
||||||
|
info!("{}", log_msg);
|
||||||
|
}
|
||||||
Some(())
|
Some(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,60 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,5 +1,3 @@
|
|||||||
// SPDX: BSD-2-Clause
|
|
||||||
|
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use log::error;
|
use log::error;
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
|
@ -1,59 +0,0 @@
|
|||||||
use crate::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());
|
|
||||||
}
|
|
@ -1,2 +0,0 @@
|
|||||||
#[cfg(test)]
|
|
||||||
mod config_serialization;
|
|
33
src/utils.rs
33
src/utils.rs
@ -1,7 +1,3 @@
|
|||||||
// SPDX: BSD-2-Clause
|
|
||||||
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use crate::{get_config_path, read_config, Config};
|
use crate::{get_config_path, read_config, Config};
|
||||||
use log::error;
|
use log::error;
|
||||||
|
|
||||||
@ -30,32 +26,3 @@ pub async fn get_config() -> Option<Config> {
|
|||||||
}
|
}
|
||||||
read_result.ok()
|
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
|
|
||||||
}
|
|
||||||
|
Reference in New Issue
Block a user