add manual crud commands.

refactor cli interface.
remove file handler.
This commit is contained in:
publicmatt 2024-04-03 07:33:48 -07:00
parent dbdcf5a3ab
commit 0b128ee2e1
15 changed files with 1174 additions and 669 deletions

3
.env.example Normal file
View File

@ -0,0 +1,3 @@
GODADDY_DOMAIN="example.com"
GODADDY_KEY="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
GODADDY_SECRET="XXXXXXXXXXXXXXXXXXXXXX"

18
.gitignore vendored
View File

@ -1,10 +1,28 @@
deploy.sh
.env
# IntelliJ IDEA
*.iml
.idea
# Rust
/target
.cargo/
# Project
ddns_ip
records.json
# Generated by Cargo
# will have compiled files and executables
debug/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
# Cargo.lock
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb

1055
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -14,10 +14,12 @@ log = { version = "0.4", features = ["std", "serde"] }
simple_logger = "1.16.0"
strfmt = "0.1.6"
dirs = "4.0.0"
dotenv = "0.15.0"
clap = { version = "4.4.12", features = ["derive"]}
[profile.release]
opt-level = 3
lto = true
debug = false
codegen-units = 1
panic = "abort"
panic = "abort"

17
src/auth/mod.rs Normal file
View File

@ -0,0 +1,17 @@
use clap::Parser;
#[derive(Parser, Debug)]
pub struct Auth {
#[clap(long)]
pub key: String,
#[clap(long)]
pub secret: String,
}
impl Auth {
pub fn as_header(&self) -> String {
let header = format!("sso-key {}:{}", self.key, self.secret);
return header;
}
}

View File

@ -1,80 +0,0 @@
use std::fs::{create_dir, read_to_string, File};
use std::io::{Error, ErrorKind, Write};
use std::path::Path;
use crate::file_handler::path_handler::{
get_application_folder_path, get_ip_file_path, get_records_file_path,
};
pub mod path_handler;
/// Sets up the application folder.
pub fn application_folder_setup() -> std::io::Result<()> {
let app_folder_path = get_application_folder_path();
if app_folder_path.is_none() {
return Err(Error::from(ErrorKind::NotFound));
}
let path = app_folder_path.unwrap();
if path.exists() {
return Ok(());
}
create_dir(&path)
}
/// Gets the contents of the RECORDS_FILE.
pub fn get_records_file() -> String {
let path = get_records_file_path().expect("Couldn't get RECORDS_FILE path.");
read_file(&path).expect(&format!(
"{} does not exist. Please create it.",
path.display()
))
}
/// Gets the contents of the IP_FILE.
pub fn get_ip_file() -> Option<String> {
let path = get_ip_file_path().expect("Couldn't get IP_FILE path.");
read_file(&path).ok()
}
/// Stores the current IP value in the FILE_NAME.
///
/// # Arguments
///
/// * `content` - A &[u8] holding the current IP value.
pub fn set_ip_file(content: &[u8]) -> () {
let path = get_ip_file_path().expect("Couldn't get IP_FILE path.");
write_file(&path, content)
}
/// Reads a file and returns its contents as String if Ok().
///
/// # Arguments
///
/// * `path` - A &Path holding the path of the file to be read.
fn read_file(path: &Path) -> std::io::Result<String> {
read_to_string(path)
}
/// Writes a file.
///
/// # Arguments
///
/// * `path` - A &Path holding the path of the file to be written.
///
/// * `content` - A &[u8] holding the info that will be written to the file.
fn write_file(path: &Path, content: &[u8]) -> () {
let display = &path.display();
let mut file =
File::create(path).expect(&format!("Error opening or creating file: {}", display));
file.write_all(content)
.expect(&format!("Error writing file: {}", display))
}

View File

@ -1,50 +0,0 @@
use std::path::PathBuf;
const APP_DIR: &'static str = ".godaddy-ddns";
const IP_FILE_NAME: &'static str = "ddns_ip";
const RECORDS_FILE_NAME: &'static str = "records.json";
/// Gets the application folder path and returns it if found.
pub fn get_application_folder_path() -> Option<PathBuf> {
let home = dirs::home_dir();
if home.is_none() {
return None;
}
Some(PathBuf::from(format!(
"{}/{}",
home.unwrap().display(),
APP_DIR
)))
}
/// Gets the IP_FILE path and returns it if found.
pub fn get_ip_file_path() -> Option<PathBuf> {
let app_folder = get_application_folder_path();
if app_folder.is_none() {
return None;
}
Some(PathBuf::from(format!(
"{}/{}",
app_folder.unwrap().display(),
IP_FILE_NAME
)))
}
/// Gets the RECORDS_FILE path and returns it if found.
pub fn get_records_file_path() -> Option<PathBuf> {
let app_folder = get_application_folder_path();
if app_folder.is_none() {
return None;
}
Some(PathBuf::from(format!(
"{}/{}",
app_folder.unwrap().display(),
RECORDS_FILE_NAME
)))
}

65
src/godaddy/mod.rs Normal file
View File

@ -0,0 +1,65 @@
use serde::Deserialize;
use std::fmt;
use crate::records::dns_record::DNSRecord;
pub enum Api {
Patch(DNSRecord),
Delete(DNSRecord),
Get(DNSRecord),
List(String),
}
#[derive(Deserialize, Debug)]
pub struct ResponseError {
pub code: String,
pub message: String,
pub fields: Vec<ResponseField>,
}
#[derive(Deserialize, Debug)]
pub struct ResponseField {
#[serde(default)]
pub code: String,
#[serde(default)]
pub message: String,
#[serde(default)]
pub path: String,
#[serde(default)]
#[serde(rename = "pathRelated")]
pub path_related: String,
}
impl fmt::Display for Api {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Api::Patch(record) => write!(
f,
"https://api.godaddy.com/v1/domains/{domain}/records",
domain = record.domain,
),
Api::Delete(record) => write!(
f,
"https://api.godaddy.com/v1/domains/{domain}/records/{record_type}/{name}",
domain = record.domain,
record_type = record.record_type,
name = record.name
),
Api::Get(record) => write!(
f,
"https://api.godaddy.com/v1/domains/{domain}/records/{record_type}/{name}",
domain = record.domain,
record_type = record.record_type,
name = record.name
),
Api::List(domain) => write!(
f,
"https://api.godaddy.com/v1/domains/{domain}/records",
domain = domain,
),
}
}
}

View File

@ -1,6 +0,0 @@
use serde::Deserialize;
#[derive(Deserialize)]
pub struct IP {
pub origin: String,
}

View File

@ -1,44 +1,25 @@
use log::debug;
use std::error::Error;
use std::net::IpAddr;
use crate::file_handler::{get_ip_file, set_ip_file};
use crate::ip_handler::ip::IP;
use serde::Deserialize;
mod ip;
#[derive(Deserialize)]
struct IpResponse {
origin: String,
}
const WEBSITE_URL: &'static str = "https://httpbin.org/ip";
/// Returns an Option holding the current IP address if the value has changed, otherwise returns None.
pub async fn get_ip_to_publish() -> Option<String> {
let previous_ip = match check_previous_ip() {
Some(x) => x,
None => String::new(),
};
let current_ip = check_current_ip()
.await
.expect("Error getting the current IP.");
debug!("Current IP: {}, Previous IP: {}", current_ip, previous_ip);
if current_ip.eq(&previous_ip) {
return None;
}
Some(current_ip)
}
/// Checks the current WAN IP.
///
/// Connects to WEBSITE_URL and retrieves the current WAN IP value.
async fn check_current_ip() -> Result<String, Box<dyn std::error::Error>> {
let resp = reqwest::get(WEBSITE_URL).await?.json::<IP>().await?;
async fn check_current_ip() -> Result<IpAddr, Box<dyn Error>> {
const WEBSITE_URL: &'static str = "https://httpbin.org/ip";
let resp = reqwest::get(WEBSITE_URL)
.await?
.json::<IpResponse>()
.await?;
let ip = resp.origin.parse::<IpAddr>()?;
set_ip_file((&resp.origin).as_ref());
Ok(resp.origin)
}
/// Reads the current IP value from the FILE_NAME and returns it.
fn check_previous_ip() -> Option<String> {
get_ip_file()
Ok(ip)
}

View File

@ -1,12 +1,82 @@
extern crate dotenv;
use dotenv::dotenv;
use std::env;
use std::error::Error;
use log::LevelFilter;
use simple_logger::SimpleLogger;
mod file_handler;
mod records_handler;
mod auth;
mod godaddy;
mod ip_handler;
pub mod records;
use crate::auth::Auth;
use records::dns_record::{DNSRecord, RecordType};
use clap::{Parser, Subcommand};
#[derive(Parser, Debug)]
#[clap(
author = "publicmatt",
version = "1.0",
about = "add/update/delete godaddy dns records"
)]
struct Cli {
#[clap(subcommand)]
subcommand: SubCommands,
}
#[derive(Subcommand, Debug)]
enum SubCommands {
#[clap(about = "list dns record names/types")]
List(ListCmd),
#[clap(about = "delete dns record name/type")]
Delete(DeleteCmd),
#[clap(about = "add/update dns record name/type")]
Update(UpdateCmd),
}
#[derive(Parser, Debug)]
struct UpdateCmd {
#[clap(long)]
domain: String,
/// Subdomain
#[clap(long)]
name: String,
#[clap(long)]
ttl: Option<u32>,
#[clap(long, default_value_t = RecordType::A)]
record_type: RecordType,
/// IP Address or data for the record
#[clap(long)]
data: String,
}
#[derive(Parser, Debug)]
struct DeleteCmd {
#[clap(long)]
domain: String,
/// Subdomain
#[clap(long)]
name: String,
#[clap(long, default_value_t = RecordType::A)]
record_type: RecordType,
}
#[derive(Parser, Debug)]
struct ListCmd {
#[clap(long)]
domain: String,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
@ -17,11 +87,43 @@ async fn main() -> Result<(), Box<dyn Error>> {
.init()
.unwrap();
file_handler::application_folder_setup().expect("Error setting up application folder.");
dotenv().ok();
let domain = env::var("DOMAIN").expect("You need to set DOMAIN env variable first.");
let key = env::var("KEY").expect("You need to set KEY env variable first.");
let secret = env::var("SECRET").expect("You need to set SECRET env variable first.");
let auth: Auth = Auth {
key: env::var("GODADDY_KEY").expect("You need to set GODADDY_KEY env variable first."),
secret: env::var("GODADDY_SECRET")
.expect("You need to set GODADDY_SECRET env variable first."),
};
records_handler::update(&domain, &key, &secret).await
let cli = Cli::parse();
match cli.subcommand {
SubCommands::Update(args) => {
let record = DNSRecord {
domain: args.domain,
name: args.name,
record_type: args.record_type,
data: args.data,
ttl: 600,
..Default::default()
};
records::update_record(record, &auth).await;
return Ok(());
}
SubCommands::Delete(args) => {
let record = DNSRecord {
domain: args.domain,
name: args.name,
record_type: args.record_type,
..Default::default()
};
records::delete_record(record, &auth).await;
return Ok(());
}
SubCommands::List(args) => {
let domain = args.domain;
records::list_records(&domain, &auth).await;
return Ok(());
}
}
}

85
src/records/dns_record.rs Normal file
View File

@ -0,0 +1,85 @@
use clap::ValueEnum;
use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(ValueEnum, Clone, Debug, Serialize, Deserialize)]
pub enum RecordType {
A,
AAAA,
CNAME,
MX,
NS,
SOA,
SRV,
TXT,
}
impl fmt::Display for RecordType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let text = match self {
RecordType::A => "A",
RecordType::AAAA => "AAAA",
RecordType::CNAME => "CNAME",
RecordType::MX => "MX",
RecordType::NS => "NS",
RecordType::SOA => "SOA",
RecordType::SRV => "SRV",
RecordType::TXT => "TXT",
};
f.write_fmt(format_args!(
"{:<width$}",
text,
width = f.width().unwrap_or(0)
))
}
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
pub struct DNSRecord {
#[serde(default)]
#[serde(skip_serializing)]
pub domain: String,
#[serde(default)]
pub name: String,
#[serde(default)]
#[serde(rename = "type")]
pub record_type: RecordType,
#[serde(default)]
pub data: String,
#[serde(default = "default_ttl")]
pub ttl: u32,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub port: Option<u16>, // SRV Only.
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub priority: Option<u32>, // MX and SRV only.
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub protocol: Option<String>, // SRV only.
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub service: Option<String>, // SRV only.
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub weight: Option<u32>, // SRV only.
}
impl Default for RecordType {
fn default() -> Self {
RecordType::A
}
}
fn default_ttl() -> u32 {
600
}

130
src/records/mod.rs Normal file
View File

@ -0,0 +1,130 @@
use log::{debug, info};
use crate::auth::Auth;
use crate::godaddy::{Api, ResponseError};
use crate::records::dns_record::DNSRecord;
use reqwest::Response;
pub mod dns_record;
/// Sends a put request to the GoDaddy API to update a DNS record.
pub async fn update_record(record: DNSRecord, auth: &Auth) -> () {
let api: Api = Api::Patch(record.clone());
let body = vec![record.clone()];
debug!("{:?}", body);
let header = auth.as_header();
let client = reqwest::Client::new();
let req = client
.patch(api.to_string())
.json(&body)
.header("accept", "application/json")
.header("content-type", "application/json")
.header("authorization", &header);
debug!("{:?}", api.to_string());
let response = req.send().await.expect("Error updating records.");
parse_response(response, record).await;
}
pub async fn delete_record(record: DNSRecord, auth: &Auth) -> () {
let api: Api = Api::Delete(record.clone());
let header = auth.as_header();
let client = reqwest::Client::new();
let req = client
.delete(api.to_string())
.header("accept", "application/json")
.header("authorization", &header);
let response = req.send().await.expect("Error deleting record.");
parse_response(response, record).await;
}
async fn parse_response(response: Response, record: DNSRecord) -> () {
match response.status() {
s if s.is_success() => {
info!("success: {:?}", record);
}
s if s.is_client_error() => {
let body = response.text().await.unwrap();
match serde_json::from_str::<ResponseError>(&body) {
Ok(json) => {
info!("client error [{}]: {}\n{:?}", s, json.message, json.fields);
}
Err(e) => {
eprintln!("Failed to parse JSON: {:?}", e);
eprintln!("Raw response body: {:?}", body);
}
};
}
s => {
let body = response.text().await.unwrap();
match serde_json::from_str::<ResponseError>(&body) {
Ok(json) => {
info!("client error [{}]: {}\n{:?}", s, json.message, json.fields);
}
Err(e) => {
eprintln!("Failed to parse JSON: {:?}", e);
eprintln!("Raw response body: {:?}", body);
}
}
}
}
}
pub async fn get_record(record: &DNSRecord, auth: &Auth) -> Vec<DNSRecord> {
let api: Api = Api::Get(record.clone());
let header = auth.as_header();
let client = reqwest::Client::new();
let req = client
.get(api.to_string())
.header("accept", "application/json")
.header("authorization", &header);
let response = req.send().await.expect("Error listing records.");
if response.status().is_success() {
let data = &response.text().await.expect("Error reading response text");
let record: Vec<DNSRecord> = serde_json::from_str(data).expect("Error parsing response");
return record;
} else {
return vec![];
}
}
pub async fn list_records(domain: &str, auth: &Auth) -> () {
let api: Api = Api::List(domain.to_string());
let header = auth.as_header();
let client = reqwest::Client::new();
let req = client
.get(api.to_string())
.header("accept", "application/json")
.header("authorization", &header);
let response = req.send().await.expect("Error listing records.");
if response.status().is_success() {
let body = response.text().await.expect("Error reading response text");
let records: Vec<DNSRecord> = serde_json::from_str(&body).unwrap();
// .expect("Error parsing records into object");
println!("{:<5} {:<25} {:<30}", "Type", "Name", "Value"); // Header
for record in records {
println!(
"{:<5} {:<25} {:<30}",
record.record_type, record.name, record.data
);
}
} else {
println!("Request failed with status: {}", response.status());
}
}

View File

@ -1,47 +0,0 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)]
pub struct DNSRecordsHolder {
pub records: Vec<DNSRecord>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DNSRecord {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub record_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub data: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub port: Option<u16>, // SRV Only.
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub priority: Option<u32>, // MX and SRV only.
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub protocol: Option<String>, // SRV only.
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub service: Option<String>, // SRV only.
pub ttl: u32,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub interpolate: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub weight: Option<u32>, // SRV only.
}

View File

@ -1,118 +0,0 @@
use std::collections::HashMap;
use log::{debug, info};
use strfmt::strfmt;
use crate::file_handler::get_records_file;
use crate::records_handler::dns_record::{DNSRecord, DNSRecordsHolder};
use crate::ip_handler::get_ip_to_publish;
mod dns_record;
/// Updates the DNS records if the IP has changed and returns the result of the execution.
///
/// # Arguments
///
/// * `domain` - A &str holding the domain to update.
///
/// * `key` - A &str holding the GoDaddy developer key.
///
/// * `secret` - A &str holding the GoDaddy developer secret.
pub async fn update(domain: &str, key: &str, secret: &str) -> Result<(), Box<dyn std::error::Error>> {
let records = get_records();
info!("Checking if the IP has changed.");
let new_ip = get_ip_to_publish().await;
// There's no need to do anything here. So we stop the execution.
if new_ip.is_none() {
info!("The IP hasn't changed. Let's stop the execution here.");
return Ok(());
}
info!("The IP has changed. Let's update the DNS records.");
for record in records {
debug!("{:?}", record);
update_record(record, &new_ip.clone().unwrap(), domain, key, secret).await;
}
Ok(())
}
/// Gets a vector of DNSRecord from RECORDS_FILE_NAME and returns it.
fn get_records() -> Vec<DNSRecord> {
let content = get_records_file();
let base: DNSRecordsHolder =
serde_json::from_str(&content).expect("Failed to deserialize JSON");
base.records
}
/// Sends a put request to the GoDaddy API to update a DNS record.
///
/// # Arguments
///
/// * `record` - A DNSRecord holding the record to update.
///
/// * `value` - A &str holding the current WAN ip.
///
/// * `domain` - A &str holding the domain to update.
///
/// * `key` - A &str holding the GoDaddy developer key.
///
/// * `secret` - A &str holding the GoDaddy developer secret.
async fn update_record(
record: DNSRecord,
value: &str,
domain: &str,
key: &str,
secret: &str,
) -> () {
let url = format!(
"https://api.godaddy.com/v1/domains/{domain}/records/{record_type}/{name}",
domain = domain,
record_type = record.record_type.unwrap(),
name = record.name.unwrap()
);
let data = match &record.data {
Some(x) => {
if record.interpolate.is_some() && record.interpolate.unwrap() == true {
let mut vars = HashMap::new();
vars.insert("ip".to_string(), value);
strfmt(x, &vars).expect("Error interpolating {ip} from data.")
} else {
String::from(x)
}
}
None => String::from(value),
};
let body = vec![DNSRecord {
name: None,
record_type: None,
data: Some(data),
port: record.port,
priority: record.priority,
protocol: record.protocol,
service: record.service,
ttl: record.ttl,
interpolate: None,
weight: record.weight,
}];
let header = format!("sso-key {}:{}", key, secret);
let client = reqwest::Client::new();
let req = client
.put(url)
.json(&body)
.header("accept", "application/json")
.header("content-type", "application/json")
.header("authorization", &header);
req.send().await.expect("Error updating record.");
}