Initial commit
This commit is contained in:
commit
d4459ad3b6
5 changed files with 1879 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
/target
|
||||
.env
|
||||
config.toml
|
1551
Cargo.lock
generated
Normal file
1551
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
19
Cargo.toml
Normal file
19
Cargo.toml
Normal file
|
@ -0,0 +1,19 @@
|
|||
[package]
|
||||
name = "cloudflare-ddns"
|
||||
version = "0.1.0"
|
||||
authors = ["Edward Shen <code@eddie.sh>"]
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
anyhow = "1"
|
||||
toml = "0.7"
|
||||
tabled = { version = "0.12", features = ["derive"] }
|
||||
url = { version = "2", features = ["serde"] }
|
||||
lettre = { version = "0.10", default_features = false, features = ["serde"] }
|
73
src/config.rs
Normal file
73
src/config.rs
Normal file
|
@ -0,0 +1,73 @@
|
|||
use std::collections::HashMap;
|
||||
use std::fmt::Display;
|
||||
|
||||
use lettre::Address;
|
||||
use reqwest::Url;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct Config {
|
||||
pub account: Account,
|
||||
#[serde(default)]
|
||||
pub ip_reflector: Reflector,
|
||||
#[serde(default)]
|
||||
pub zone: HashMap<String, Zone>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Default, Debug)]
|
||||
pub struct Reflector {
|
||||
pub ipv4: Option<Url>,
|
||||
pub ipv6: Option<Url>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct Account {
|
||||
pub email: Address,
|
||||
pub api_key: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct Zone {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub record: Vec<DnsRecord>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct DnsRecord {
|
||||
#[serde(default)]
|
||||
pub disabled: bool,
|
||||
pub name: String,
|
||||
pub id: String,
|
||||
pub proxy: bool,
|
||||
#[serde(rename = "type")]
|
||||
pub protocol_type: RecordType,
|
||||
}
|
||||
|
||||
impl DnsRecord {
|
||||
pub fn is_ipv4(&self) -> bool {
|
||||
self.protocol_type == RecordType::A
|
||||
}
|
||||
|
||||
pub fn is_ipv6(&self) -> bool {
|
||||
self.protocol_type == RecordType::AAAA
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||
// One of the rare times where I don't actually care about this.
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
pub enum RecordType {
|
||||
A,
|
||||
AAAA,
|
||||
}
|
||||
|
||||
impl Display for RecordType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::A => "A".fmt(f),
|
||||
Self::AAAA => "AAAA".fmt(f),
|
||||
}
|
||||
}
|
||||
}
|
233
src/main.rs
Normal file
233
src/main.rs
Normal file
|
@ -0,0 +1,233 @@
|
|||
#![warn(clippy::pedantic)]
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
|
||||
use crate::config::{Config, RecordType};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
use reqwest::Url;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use tabled::settings::object::Column;
|
||||
use tabled::settings::{Alignment, Modify};
|
||||
use tabled::{Table, Tabled};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
mod config;
|
||||
|
||||
const X_AUTH_EMAIL: &str = "X-Auth-Email";
|
||||
const X_AUTH_KEY: &str = "X-Auth-Key";
|
||||
|
||||
#[derive(Parser, Clone, Debug)]
|
||||
pub struct Args {
|
||||
#[command(subcommand)]
|
||||
cmd: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Clone, Debug)]
|
||||
pub enum Command {
|
||||
Run,
|
||||
List(List),
|
||||
}
|
||||
|
||||
#[derive(Parser, Clone, Debug)]
|
||||
pub struct List {
|
||||
zones: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
match Args::parse().cmd {
|
||||
Command::Run => handle_run(load_config()?).await,
|
||||
Command::List(list) => handle_list(load_config()?, list).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_run(conf: Config) -> Result<()> {
|
||||
let ipv4_addr = match conf.ip_reflector.ipv4 {
|
||||
Some(addr_to_req) => Some(IpAddr::V4(
|
||||
get_ipv4(addr_to_req)
|
||||
.await
|
||||
.context("Failed to query for ipv4 address, bailing.")?,
|
||||
)),
|
||||
None => None,
|
||||
};
|
||||
let ipv6_addr = match conf.ip_reflector.ipv6 {
|
||||
Some(addr_to_req) => Some(IpAddr::V6(
|
||||
get_ipv6(addr_to_req)
|
||||
.await
|
||||
.context("Failed to query for ipv4 address, bailing.")?,
|
||||
)),
|
||||
None => None,
|
||||
};
|
||||
|
||||
for zone in conf.zone.into_values() {
|
||||
let zone_id = zone.id;
|
||||
|
||||
let records_to_process = zone.record.into_iter().filter_map(|record| {
|
||||
if ipv4_addr.is_some() && record.is_ipv4() {
|
||||
return Some((&ipv4_addr, record));
|
||||
}
|
||||
|
||||
if ipv6_addr.is_some() && record.is_ipv6() {
|
||||
return Some((&ipv6_addr, record));
|
||||
}
|
||||
|
||||
None
|
||||
});
|
||||
|
||||
for (addr, record) in records_to_process.take(3) {
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[allow(dead_code)]
|
||||
struct UpdateDnsResponse {
|
||||
success: bool,
|
||||
errors: Vec<Message>,
|
||||
messages: Vec<Message>,
|
||||
}
|
||||
|
||||
let record_id = record.id;
|
||||
let resp: UpdateDnsResponse = reqwest::Client::new()
|
||||
.put(format!(
|
||||
"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{record_id}"
|
||||
))
|
||||
.header(X_AUTH_EMAIL, &conf.account.email.to_string())
|
||||
.header(X_AUTH_KEY, &conf.account.api_key)
|
||||
.json(&json!({
|
||||
"type": record.protocol_type,
|
||||
"name": record.name,
|
||||
"content": addr,
|
||||
"ttl": 1, // Auto TTL
|
||||
"proxied": record.proxy,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.context("while requesting an api endpoint")?
|
||||
.json()
|
||||
.await
|
||||
.context("while parsing into a json")?;
|
||||
|
||||
// TODO: handle success
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[allow(dead_code)]
|
||||
struct Message {
|
||||
code: u16,
|
||||
message: String,
|
||||
}
|
||||
|
||||
async fn handle_list(conf: Config, args: List) -> Result<()> {
|
||||
// Use provided zones or list all in config
|
||||
let known_zones: HashSet<_> = conf.zone.values().map(|zone| &zone.id).collect();
|
||||
let zones: Vec<_> = match args.zones {
|
||||
Some(zones) => {
|
||||
// These zones may be human readable. Map them to zone IDs.
|
||||
zones
|
||||
.into_iter()
|
||||
.filter_map(|maybe_zone_id| {
|
||||
if known_zones.contains(&maybe_zone_id) {
|
||||
return Some(maybe_zone_id);
|
||||
}
|
||||
if let Some(zone) = conf.zone.get(&maybe_zone_id) {
|
||||
return Some(zone.id.clone());
|
||||
}
|
||||
|
||||
eprintln!("Unknown zone {maybe_zone_id}, skipping");
|
||||
None
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
None => known_zones.into_iter().cloned().collect(),
|
||||
};
|
||||
|
||||
for zone in zones {
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[allow(dead_code)]
|
||||
struct ListZoneResponse {
|
||||
success: bool,
|
||||
errors: Vec<Message>,
|
||||
messages: Vec<Message>,
|
||||
result: Vec<DnsResponse>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Tabled)]
|
||||
#[tabled(rename_all = "PascalCase")]
|
||||
struct DnsResponse {
|
||||
name: String,
|
||||
#[tabled(rename = "Type")]
|
||||
r#type: RecordType,
|
||||
#[tabled(rename = "IP Address")]
|
||||
content: IpAddr,
|
||||
proxied: bool,
|
||||
id: String,
|
||||
}
|
||||
|
||||
let mut entries = vec![];
|
||||
for page_no in 1.. {
|
||||
// This technically requests one more than optimal, but tbh it
|
||||
// doesn't really matter
|
||||
let resp: ListZoneResponse = reqwest::Client::new()
|
||||
.get(format!(
|
||||
"https://api.cloudflare.com/client/v4/zones/{zone}/dns_records?type=A,AAAA&page={page_no}"
|
||||
))
|
||||
.header(X_AUTH_EMAIL, &conf.account.email.to_string())
|
||||
.header(X_AUTH_KEY, &conf.account.api_key)
|
||||
.send()
|
||||
.await
|
||||
.context("while requesting an api endpoint")?
|
||||
.json()
|
||||
.await
|
||||
.context("while parsing into a json")?;
|
||||
|
||||
// todo: handle messages, errors, and non-success response
|
||||
|
||||
if resp.result.is_empty() {
|
||||
break;
|
||||
} else {
|
||||
entries.extend(resp.result);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by subdomain, with higher level subdomains taking higher precedence than lower ones.
|
||||
entries.sort_unstable_by(|a, b| a.name.split('.').rev().cmp(b.name.split('.').rev()));
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
Table::new(entries).with(Modify::new(Column::from(0)).with(Alignment::right()))
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_ipv4(url: Url) -> Result<Ipv4Addr> {
|
||||
reqwest::get(url)
|
||||
.await
|
||||
.context("Failed send IPv4 reflector request")?
|
||||
.text()
|
||||
.await
|
||||
.context("Failed to get IPv4 reflector data")?
|
||||
.parse()
|
||||
.context("Response was not an IPv4 address")
|
||||
}
|
||||
|
||||
async fn get_ipv6(url: Url) -> Result<Ipv6Addr> {
|
||||
reqwest::get(url)
|
||||
.await
|
||||
.context("Failed send IPv4 reflector request")?
|
||||
.text()
|
||||
.await
|
||||
.context("Failed to get IPv4 reflector data")?
|
||||
.parse()
|
||||
.context("Response was not an IPv4 address")
|
||||
}
|
||||
|
||||
fn load_config() -> Result<Config> {
|
||||
let conf_str = std::fs::read_to_string("./config.toml")?;
|
||||
Ok(toml::from_str(&conf_str)?)
|
||||
}
|
Loading…
Reference in a new issue