Initial commit

This commit is contained in:
Edward Shen 2023-06-26 02:10:01 -07:00
commit d4459ad3b6
Signed by: edward
GPG key ID: 19182661E818369F
5 changed files with 1879 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
/target
.env
config.toml

1551
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

19
Cargo.toml Normal file
View 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
View 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
View 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)?)
}