use log::{debug, trace};
use reqwest::{Client, ClientBuilder, Method, RequestBuilder};
use serde::{Deserialize, Serialize};
use std::time::Duration;
use std::{env, ops::Deref};
use strum_macros::{Display, EnumString};
const ENV_NAMECOM_REQUEST_PROXY: &str = "NAMECOM_REQUEST_PROXY";
#[derive(Deserialize, Debug)]
pub struct ListingResponse {
records: Vec<NameComRecord>,
}
impl Deref for ListingResponse {
type Target = Vec<NameComRecord>;
fn deref(&self) -> &Self::Target {
&self.records
}
}
#[derive(Copy, Clone, Debug, Deserialize, Display, EnumString, Eq, Hash, PartialEq, Serialize)]
pub enum RecordType {
A,
#[strum(serialize = "AAAA")]
#[serde(rename = "AAAA")]
Aaaa,
#[strum(serialize = "ANAME")]
#[serde(rename = "ANAME")]
Aname,
#[strum(serialize = "CNAME")]
#[serde(rename = "CNAME")]
Cname,
#[strum(serialize = "MX")]
#[serde(rename = "MX")]
Mx,
#[strum(serialize = "NS")]
#[serde(rename = "NS")]
Ns,
#[strum(serialize = "SRV")]
#[serde(rename = "SRV")]
Srv,
#[strum(serialize = "TXT")]
#[serde(rename = "TXT")]
Txt,
}
#[derive(Clone, Debug, Deserialize)]
pub struct NameComRecord {
id: i32,
#[serde(rename = "domainName")]
#[allow(unused)]
domain_name: String,
host: Option<String>,
fqdn: String,
#[allow(unused)]
answer: String,
#[serde(rename = "type")]
rec_type: RecordType,
#[allow(unused)]
ttl: u32,
#[allow(unused)]
priority: Option<u32>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct NameComNewRecord {
pub host: Option<String>,
#[serde(rename = "type")]
pub rec_type: RecordType,
pub answer: String,
pub ttl: u32,
pub priority: Option<u32>,
}
#[allow(clippy::module_name_repetitions)]
pub struct NameComDnsApi {
url: String,
username: String,
password: String,
client: Client,
}
impl NameComDnsApi {
pub fn create(
username: &str,
password: &str,
api_url: &str,
timeout: u64,
) -> reqwest::Result<Self> {
let mut builder = ClientBuilder::new();
if timeout != 0 {
debug!("Setting timeout to {timeout} seconds");
builder = builder.timeout(Duration::from_secs(timeout));
}
if let Ok(proxy) = env::var(ENV_NAMECOM_REQUEST_PROXY) {
debug!("'ENV_NAMECOM_REQUEST_PROXY' is set, using proxy {proxy}");
builder = builder.proxy(reqwest::Proxy::all(proxy)?);
}
debug!("Building reqwest client");
let client = builder.build()?;
Ok(Self {
url: api_url.to_string(),
username: username.to_string(),
password: password.to_string(),
client,
})
}
fn with_param(&self, method: Method, path: &str) -> RequestBuilder {
let url = format!("{}/v4/{path}", self.url);
debug!("Creating reqwest client: {url:?}");
self.client
.request(method, &url)
.basic_auth(&self.username, Some(&self.password))
}
pub async fn list_records(&self, domain: &str) -> reqwest::Result<ListingResponse> {
self.with_param(Method::GET, &format!("domains/{domain}/records"))
.send()
.await?
.error_for_status()?
.json::<ListingResponse>()
.await
}
pub async fn _get_record(&self, domain: &str, id: i32) -> reqwest::Result<NameComRecord> {
self.with_param(Method::GET, &format!("domains/{domain}/records/{id}"))
.send()
.await?
.error_for_status()?
.json::<NameComRecord>()
.await
}
pub async fn create_record(
&self,
domain: &str,
record: &NameComNewRecord,
) -> reqwest::Result<NameComRecord> {
self.with_param(Method::POST, &format!("domains/{domain}/records"))
.json(&record)
.send()
.await?
.error_for_status()?
.json::<NameComRecord>()
.await
}
pub async fn update_record(
&self,
domain: &str,
id: i32,
record: &NameComNewRecord,
) -> reqwest::Result<NameComRecord> {
self.with_param(Method::PUT, &format!("domains/{domain}/records/{id}"))
.json(&record)
.send()
.await?
.error_for_status()?
.json::<NameComRecord>()
.await
}
pub async fn _delete_record(&self, domain: &str, id: i32) -> reqwest::Result<()> {
self.with_param(Method::DELETE, &format!("domains/{domain}/records/{id}"))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn search_records(
&self,
domain: &str,
rec_type: RecordType,
host: Option<&str>,
) -> reqwest::Result<Vec<i32>> {
Ok(self
.list_records(domain)
.await?
.iter()
.filter_map(|record| {
trace!("Found record {:?}", record.fqdn);
if (record.host.as_deref() == host) && record.rec_type == rec_type {
Some(record.id)
} else {
None
}
})
.collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
async fn make_proxy_server(listener: tokio::net::TcpListener) {
loop {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = vec![0; 1024];
let n = socket.read(&mut buf).await.unwrap();
let request = String::from_utf8_lossy(&buf[..n]);
if request.starts_with("GET") {
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nX-Requested-Through-Proxy: true\r\n\r\n{request}",
request.len()
);
socket.write_all(response.as_bytes()).await.unwrap();
}
}
}
#[tokio::test]
async fn test_namecom_api_proxy_feature() {
let listener = tokio::net::TcpListener::bind("localhost:0").await.unwrap();
let addr = listener.local_addr().unwrap();
env::set_var(ENV_NAMECOM_REQUEST_PROXY, format!("http://{addr}"));
let api = NameComDnsApi::create("", "", "", 0).unwrap();
let result = api
.client
.get("http://httpbin.org")
.timeout(Duration::from_secs(2))
.send()
.await
.unwrap_err();
assert!(result.is_connect() || result.is_timeout());
let proxy = tokio::spawn(make_proxy_server(listener));
let result = api
.client
.get("http://httpbin.org")
.timeout(Duration::from_secs(2))
.send()
.await
.unwrap();
assert_eq!(result.status(), 200);
assert!(result.headers().get("X-Requested-Through-Proxy").is_some());
proxy.abort();
}
}