use crate::{
DnsRecord, DnsRecordType, Error, IntoFqdn, http::HttpClientBuilder,
utils::strip_origin_from_name,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
use std::time::Duration;
const DEFAULT_ENDPOINT: &str = "https://spaceship.dev/api/v1";
#[derive(Clone)]
pub struct SpaceshipProvider {
client: HttpClientBuilder,
endpoint: String,
}
#[derive(Serialize, Debug)]
struct PutRecordsRequest {
#[serde(skip_serializing_if = "Option::is_none")]
force: Option<bool>,
items: Vec<SpaceshipDnsRecord>,
}
#[derive(Deserialize, Debug)]
struct GetRecordsResponse {
items: Vec<SpaceshipDnsRecord>,
total: usize,
}
#[derive(Serialize, Debug)]
struct SpaceshipDeleteRecord {
#[serde(rename = "type")]
record_type: String,
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
value: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
address: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
nameserver: Option<String>,
#[serde(rename = "aliasName", skip_serializing_if = "Option::is_none")]
alias_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pointer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
cname: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
exchange: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
preference: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
priority: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
weight: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
port: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
service: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
protocol: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
target: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
usage: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
selector: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
matching: Option<u8>,
#[serde(rename = "associationData", skip_serializing_if = "Option::is_none")]
association_data: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
flag: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
tag: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
struct SpaceshipDnsRecord {
#[serde(rename = "type")]
record_type: String,
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
value: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
address: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
nameserver: Option<String>,
#[serde(rename = "aliasName", skip_serializing_if = "Option::is_none")]
alias_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pointer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
cname: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
exchange: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
preference: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
priority: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
weight: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
port: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
service: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
protocol: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
target: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
usage: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
selector: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
matching: Option<u8>,
#[serde(rename = "associationData", skip_serializing_if = "Option::is_none")]
association_data: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
flag: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
tag: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
ttl: Option<u32>,
#[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")]
extra: BTreeMap<String, Value>,
}
impl SpaceshipProvider {
pub(crate) fn new(
api_key: impl AsRef<str>,
api_secret: impl AsRef<str>,
timeout: Option<Duration>,
) -> Self {
let client = HttpClientBuilder::default()
.with_header("X-Api-Key", api_key.as_ref())
.with_header("X-Api-Secret", api_secret.as_ref())
.with_timeout(timeout);
Self {
client,
endpoint: DEFAULT_ENDPOINT.to_string(),
}
}
#[cfg(test)]
pub(crate) fn with_endpoint(self, endpoint: impl AsRef<str>) -> Self {
Self {
endpoint: endpoint.as_ref().to_string(),
..self
}
}
pub(crate) async fn create(
&self,
name: impl IntoFqdn<'_>,
record: DnsRecord,
ttl: u32,
origin: impl IntoFqdn<'_>,
) -> crate::Result<()> {
let name = name.into_name();
let domain = origin.into_name();
let subdomain = strip_origin_from_name(&name, &domain, None);
self.client
.put(format!("{}/dns/records/{}", self.endpoint, domain))
.with_body(PutRecordsRequest {
force: None,
items: vec![SpaceshipDnsRecord::from_dns_record(
record,
&subdomain,
Some(ttl),
)?],
})?
.send_raw()
.await
.map(|_| ())
}
pub(crate) async fn update(
&self,
name: impl IntoFqdn<'_>,
record: DnsRecord,
ttl: u32,
origin: impl IntoFqdn<'_>,
) -> crate::Result<()> {
let name = name.into_name();
let domain = origin.into_name();
let subdomain = strip_origin_from_name(&name, &domain, None);
self.client
.put(format!("{}/dns/records/{}", self.endpoint, domain))
.with_body(PutRecordsRequest {
force: None,
items: vec![SpaceshipDnsRecord::from_dns_record(
record,
&subdomain,
Some(ttl),
)?],
})?
.send_raw()
.await
.map(|_| ())
}
pub(crate) async fn delete(
&self,
name: impl IntoFqdn<'_>,
origin: impl IntoFqdn<'_>,
record_type: DnsRecordType,
) -> crate::Result<()> {
let name = name.into_name();
let domain = origin.into_name();
let subdomain = self.normalize_subdomain_for_delete(
strip_origin_from_name(&name, &domain, None),
record_type,
)?;
let record_type = record_type.to_string();
let records = self.fetch_records(&domain).await?;
let to_delete: Vec<SpaceshipDeleteRecord> = records
.into_iter()
.filter(|record| record.name == subdomain && record.record_type == record_type)
.map(SpaceshipDnsRecord::into_delete_record)
.collect::<crate::Result<Vec<_>>>()?;
if to_delete.is_empty() {
return Err(Error::NotFound);
}
for item in to_delete {
self.client
.delete(format!("{}/dns/records/{}", self.endpoint, domain))
.with_body(vec![item])?
.send_raw()
.await?;
}
Ok(())
}
async fn fetch_records(&self, domain: &str) -> crate::Result<Vec<SpaceshipDnsRecord>> {
const PAGE_SIZE: usize = 100;
let mut skip = 0usize;
let mut all_items = Vec::new();
loop {
let response = self
.client
.get(format!(
"{}/dns/records/{}?take={}&skip={}",
self.endpoint, domain, PAGE_SIZE, skip
))
.send_with_retry::<GetRecordsResponse>(3)
.await?;
let received = response.items.len();
all_items.extend(response.items);
if all_items.len() >= response.total || received < PAGE_SIZE {
break;
}
skip += PAGE_SIZE;
}
Ok(all_items)
}
fn normalize_subdomain_for_delete(
&self,
subdomain: String,
record_type: DnsRecordType,
) -> crate::Result<String> {
match record_type {
DnsRecordType::SRV | DnsRecordType::TLSA => {
let (_left, _right, normalized_name) = split_service_protocol_labels(&subdomain)?;
Ok(normalized_name)
}
_ => Ok(subdomain),
}
}
}
impl SpaceshipDnsRecord {
fn from_dns_record(record: DnsRecord, name: &str, ttl: Option<u32>) -> crate::Result<Self> {
let mut item = Self {
record_type: match &record {
DnsRecord::A(_) => "A",
DnsRecord::AAAA(_) => "AAAA",
DnsRecord::CNAME(_) => "CNAME",
DnsRecord::NS(_) => "NS",
DnsRecord::MX(_) => "MX",
DnsRecord::TXT(_) => "TXT",
DnsRecord::SRV(_) => "SRV",
DnsRecord::TLSA(_) => "TLSA",
DnsRecord::CAA(_) => "CAA",
}
.to_string(),
name: name.to_string(),
value: None,
address: None,
nameserver: None,
alias_name: None,
pointer: None,
cname: None,
exchange: None,
preference: None,
priority: None,
weight: None,
port: None,
service: None,
protocol: None,
target: None,
usage: None,
selector: None,
matching: None,
association_data: None,
flag: None,
tag: None,
ttl,
extra: BTreeMap::new(),
};
match record {
DnsRecord::A(content) => {
item.address = Some(content.to_string());
}
DnsRecord::AAAA(content) => {
item.address = Some(content.to_string());
}
DnsRecord::CNAME(content) => {
item.cname = Some(content);
}
DnsRecord::NS(content) => {
item.nameserver = Some(content);
}
DnsRecord::MX(mx) => {
item.exchange = Some(mx.exchange);
item.preference = Some(mx.priority);
}
DnsRecord::TXT(content) => item.value = Some(content),
DnsRecord::SRV(srv) => {
let (service, protocol, normalized_name) = split_service_protocol_labels(name)?;
item.name = normalized_name;
item.service = Some(service);
item.protocol = Some(protocol);
item.priority = Some(srv.priority);
item.target = Some(srv.target);
item.weight = Some(srv.weight);
item.port = Some(Value::from(srv.port));
}
DnsRecord::TLSA(tlsa) => {
let (port, protocol, normalized_name) = split_service_protocol_labels(name)?;
item.name = normalized_name;
item.port = Some(Value::from(port));
item.protocol = Some(protocol);
item.usage = Some(u8::from(tlsa.cert_usage));
item.selector = Some(u8::from(tlsa.selector));
item.matching = Some(u8::from(tlsa.matching));
item.association_data = Some(
tlsa.cert_data
.into_iter()
.map(|byte| format!("{byte:02x}"))
.collect(),
);
}
DnsRecord::CAA(caa) => {
let (flag, tag, value) = caa.decompose();
item.flag = Some(flag);
item.tag = Some(tag);
item.value = Some(value);
}
}
Ok(item)
}
fn into_delete_record(self) -> crate::Result<SpaceshipDeleteRecord> {
let record_type = self.record_type.clone();
let name = self.name.clone();
let make_err = |field: &str| {
Error::Parse(format!(
"Missing required delete field '{field}' for {} record '{}'",
record_type, name
))
};
let mut out = SpaceshipDeleteRecord {
record_type: self.record_type.clone(),
name: self.name,
value: None,
address: None,
nameserver: None,
alias_name: None,
pointer: None,
cname: None,
exchange: None,
preference: None,
priority: None,
weight: None,
port: None,
service: None,
protocol: None,
target: None,
usage: None,
selector: None,
matching: None,
association_data: None,
flag: None,
tag: None,
};
match self.record_type.as_str() {
"A" | "AAAA" => {
out.address = Some(self.address.ok_or_else(|| make_err("address"))?);
}
"CNAME" => out.cname = Some(self.cname.ok_or_else(|| make_err("cname"))?),
"NS" => out.nameserver = Some(self.nameserver.ok_or_else(|| make_err("nameserver"))?),
"MX" => {
out.exchange = Some(self.exchange.ok_or_else(|| make_err("exchange"))?);
out.preference = Some(
self.preference
.or(self.priority)
.ok_or_else(|| make_err("preference"))?,
);
}
"TXT" => out.value = Some(self.value.ok_or_else(|| make_err("value"))?),
"SRV" => {
out.service = Some(self.service.ok_or_else(|| make_err("service"))?);
out.protocol = Some(self.protocol.ok_or_else(|| make_err("protocol"))?);
out.priority = Some(self.priority.ok_or_else(|| make_err("priority"))?);
out.weight = Some(self.weight.ok_or_else(|| make_err("weight"))?);
out.port = Some(self.port.ok_or_else(|| make_err("port"))?);
out.target = Some(self.target.ok_or_else(|| make_err("target"))?);
}
"TLSA" => {
out.port = Some(self.port.ok_or_else(|| make_err("port"))?);
out.protocol = Some(self.protocol.ok_or_else(|| make_err("protocol"))?);
out.usage = Some(self.usage.ok_or_else(|| make_err("usage"))?);
out.selector = Some(self.selector.ok_or_else(|| make_err("selector"))?);
out.matching = Some(self.matching.ok_or_else(|| make_err("matching"))?);
out.association_data = Some(
self.association_data
.ok_or_else(|| make_err("associationData"))?,
);
}
"CAA" => {
out.flag = Some(self.flag.ok_or_else(|| make_err("flag"))?);
out.tag = Some(self.tag.ok_or_else(|| make_err("tag"))?);
out.value = Some(self.value.ok_or_else(|| make_err("value"))?);
}
other => {
return Err(Error::Parse(format!(
"Unsupported Spaceship record type for delete: {other}"
)));
}
}
Ok(out)
}
}
fn split_service_protocol_labels(name: &str) -> crate::Result<(String, String, String)> {
let labels: Vec<&str> = name.split('.').collect();
if labels.len() < 2 {
return Err(Error::Parse(format!(
"Invalid DNS record name for service/protocol record: {name}"
)));
}
let first = labels[0];
let second = labels[1];
if !first.starts_with('_') || !second.starts_with('_') {
return Err(Error::Parse(format!(
"Expected service/protocol labels to start with '_' in record name: {name}"
)));
}
let normalized_name = if labels.len() > 2 {
labels[2..].join(".")
} else {
"@".to_string()
};
Ok((first.to_string(), second.to_string(), normalized_name))
}