wip: friendly records

This commit is contained in:
Hannaeko 2025-03-31 15:06:33 +02:00
parent 7e3e927946
commit 7cee790c85
14 changed files with 477 additions and 149 deletions

9
Cargo.lock generated
View file

@ -68,12 +68,13 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
[[package]]
name = "axum"
version = "0.8.0-rc.1"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b212eb691030da38f1eff381777e431eb6f0760a0d02ffcb1702f1da9894e2"
checksum = "6d6fd624c75e18b3b4c6b9caf42b1afe24437daaee904069137d8bab077be8b8"
dependencies = [
"axum-core",
"bytes",
"form_urlencoded",
"futures-util",
"http",
"http-body",
@ -100,9 +101,9 @@ dependencies = [
[[package]]
name = "axum-core"
version = "0.5.0-rc.1"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30256e79153b08607dcf9e0a72bd31564bc9228b9f145d1e1a29e3d01ad1fd16"
checksum = "df1362f362fd16024ae199c1970ce98f9661bf5ef94b9808fee734bc3698b733"
dependencies = [
"bytes",
"futures-util",

View file

@ -2,7 +2,7 @@
name = "nomilo"
version = "0.2.0-dev"
authors = ["DNS Witch Collective <dns-witch@dns-witch.eu.org>"]
edition = "2021"
edition = "2024"
license = "AGPL-3.0-or-later"
readme = "README.md"
repository = "https://git.familier.net.eu.org/dns-witch/nomilo"
@ -20,7 +20,7 @@ tokio = {version = "1", default-features = false, features = [ "macros", "rt-mul
#rand = "0.8"
tera = { version = "1", default-features = false }
domain = { version = "0.10.3", features = [ "tsig", "unstable-client-transport" ]}
axum = { version = "0.8.0-rc.1", default-features = false, features = [ "http1", "json", "form", "query", "tokio" ]}
axum = { version = "0.8.1", default-features = false, features = [ "http1", "json", "form", "query", "tokio" ]}
bb8 = { version = "0.9" }
rusqlite = { version = "0.32"}
async-trait = { version = "0.1" }

View file

@ -23,6 +23,14 @@ main {
margin: auto;
}
h1 {
font-weight: 200;
}
h2 {
font-weight: 300;
}
article.domain {
margin-bottom: 2em;
}
@ -38,7 +46,7 @@ article.domain header h3.folder-tab {
display: flex;
flex-direction: column;
justify-content: center;
padding: 0 1em;
padding: 0 1rem;
border-top-left-radius: .3rem;
background-color: #f2e0fd;
margin: 0;
@ -52,26 +60,23 @@ article.domain header h3.folder-tab ~ .sep {
background-color: #f2e0fd;
height: 100%;
clip-path: url("#corner-folder-tab-right");
flex-shrink: 0;
}
article.domain .records {
background: #f2e0fd;
padding: 1rem;
border-radius: 0 .3rem .3rem .3rem;
}
article.domain .records h4 {
margin: 0;
}
article.domain .records > ul {
background: #f2e0fd;
margin: 0;
padding: 0;
list-style: none;
padding: 1rem;;
border-radius: 0 .3rem .3rem .3rem;
display: grid;
grid-template-columns: auto 1fr;
row-gap: 1rem;
column-gap: 0;
}
article.domain .records .rrset {
display: grid;
align-items: baseline;
grid-template-columns: subgrid;
grid-column: 1 / 3;
}
article.domain .records .rrset .rtype {
@ -80,18 +85,8 @@ article.domain .records .rrset .rtype {
gap: .5em;
}
article.domain .records .rrset .rtype::after {
content: '';
display: block;
flex: 1;
border-bottom: .2rem solid #850085;
padding-left: 1em;
position: relative;
bottom: .25rem;
}
article.domain .records .rrset ul {
padding: 0;
padding: 1rem 0 1rem 2rem;
display: flex;
flex-direction: column;
gap: .5rem;
@ -105,32 +100,16 @@ article.domain .records .rrset li {
gap: .5rem;
}
article.domain .records .rrset li::before {
content: '';
height: 1em;
width: 1rem;
border-bottom: .2rem solid #850085;
position: relative;
bottom: .25rem;
}
article.domain .records .rrset li:not(:first-child)::before {
border-left: .2rem solid #850085;
border-bottom-left-radius: .3rem;
}
article.domain .records .rrset li:not(:last-child)::after {
content: '';
height: 100%;
width: 1rem;
position: absolute;
top: 1.1em;
border-left: .2rem solid #850085;
article.domain .records .rrset .rdata {
display: flex;
gap: .2rem;
flex-wrap: wrap;
}
article.domain .records .rrset .rdata-main {
display: flex;
gap: .3rem;
margin-right: .1rem;
}
article.domain .records .rrset .rdata-main .pill {
@ -138,7 +117,6 @@ article.domain .records .rrset .rdata-main .pill {
}
article.domain .records .rrset .rdata-complementary {
margin-top: .2em;
font-size: .9em;
gap: .2rem;
display: flex;

View file

@ -6,6 +6,12 @@ example.com. IN SOA ns.example.com. admin.example.com. (
300 ; minimum (5 minutes)
)
example.com. 600 IN A 198.51.100.3
example.com. 600 IN AAAA 2001:db8:cafe:bc68::2
example.com. 3600 IN MX 1 srv1.example.com.
example.com. 3600 IN MX 100 mail.example.net.
example.com. 84600 IN NS ns.example.com.
ns.example.com. 84600 IN A 198.51.100.3

View file

@ -58,7 +58,8 @@ async fn main() {
.route("/api/zones/{zone_name}/records", routing::get(routes::api::zones::get_zone_records))
.route("/api/zones/{zone_name}/records", routing::post(routes::api::zones::create_zone_records))
/* ----- UI ----- */
.route("/zones/{zone_name}/records", routing::get(routes::ui::zones::get_zone_records_page))
.route("/zones/{zone_name}/records", routing::get(routes::ui::zones::get_records_page))
.route("/zones/{zone_name}/records/new", routing::get(routes::ui::zones::get_new_record_page))
.nest_service("/assets", ServeDir::new("assets"))
.with_state(app_state);

View file

@ -461,22 +461,3 @@ impl TryFrom<internal::Record> for RecordImpl {
}
}
pub struct RecordList {
pub records: Vec<RecordImpl>
}
impl TryFrom<internal::RecordList> for RecordList {
type Error = Error;
fn try_from(value: internal::RecordList) -> Result<Self, Self::Error> {
let mut records = Vec::with_capacity(value.records.len());
for record in value.records.into_iter() {
records.push(record.try_into()?)
}
Ok(RecordList { records })
}
}

View file

@ -1 +1,3 @@
pub mod rdata;
pub use rdata::*;

View file

@ -1,98 +1,309 @@
use std::fmt;
use serde::{Deserialize, Serialize};
use serde::{Deserialize, Serialize, Serializer, ser::SerializeStruct};
#[derive(Debug, Deserialize, Serialize)]
pub enum FriendlyRData {
Address(Address),
Service(Service),
MailServer(MailServer), // Include SPF, etc ?!
NameServer(NameServer),
TextData(TextData),
Alias(Alias),
use crate::resources::dns::internal;
}
#[derive(Debug, Deserialize, Serialize, PartialEq)]
#[serde(rename_all="lowercase")]
pub enum FriendlyRType {
Address,
Service,
Alias,
MailServer,
NameServer,
Service,
Spf,
TextData,
Alias
}
pub struct FriendlyRecord {
impl fmt::Display for FriendlyRType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
FriendlyRType::Address => write!(f, "address"),
FriendlyRType::Alias => write!(f, "alias"),
FriendlyRType::MailServer => write!(f, "mailserver"),
FriendlyRType::NameServer => write!(f, "nameserver"),
FriendlyRType::Service => write!(f, "service"),
FriendlyRType::Spf => write!(f, "spf"),
FriendlyRType::TextData => write!(f, "textdata"),
}
}
}
pub trait HasRType {
fn rtype(&self) -> FriendlyRType;
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(tag = "_type")]
pub enum FriendlyRData {
Address(Address),
Alias(Alias),
MailServer(MailServer),
NameServer(NameServer),
Service(Service),
Spf(Spf),
TextData(TextData),
}
impl HasRType for FriendlyRData {
fn rtype(&self) -> FriendlyRType {
match self {
FriendlyRData::Address(_) => FriendlyRType::Address,
FriendlyRData::Alias(_) => FriendlyRType::Alias,
FriendlyRData::MailServer(_) => FriendlyRType::MailServer,
FriendlyRData::NameServer(_) => FriendlyRType::NameServer,
FriendlyRData::Service(_) => FriendlyRType::Service,
FriendlyRData::Spf(_) => FriendlyRType::Spf,
FriendlyRData::TextData(_) => FriendlyRType::TextData,
}
}
}
#[derive(Debug)]
pub struct FriendlyRecord<T> {
ttl: i64,
data: T,
}
impl<T: Serialize + HasRType> Serialize for FriendlyRecord<T> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut state = serializer.serialize_struct("FriendlyRecord", 3)?;
state.serialize_field("ttl", &self.ttl)?;
state.serialize_field("data", &self.data)?;
state.serialize_field("rtype", &self.data.rtype())?;
state.end()
}
}
impl<T> FriendlyRecord<T> {
fn new(ttl: u32, data: T) -> Self {
FriendlyRecord {
ttl: ttl.into(),
data,
}
}
}
#[derive(Debug)]
pub struct FriendlyRecordSet<T> {
ttl: i64,
data: Vec<T>,
}
impl<T: Serialize + HasRType> Serialize for FriendlyRecordSet<T> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut state = serializer.serialize_struct("FriendlyRecordSet", 3)?;
state.serialize_field("ttl", &self.ttl)?;
state.serialize_field("data", &self.data)?;
state.serialize_field("rtype", &self.data.first().map(|d| d.rtype().to_string()))?;
state.end()
}
}
impl<T> FriendlyRecordSet<T> {
fn new(ttl: u32) -> Self {
FriendlyRecordSet {
ttl: ttl.into(),
data: Vec::new()
}
}
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all="lowercase")]
pub enum ConfigurationType {
Mail,
Web,
}
#[derive(Debug, Serialize)]
pub struct MailConfiguration {
pub servers: Option<FriendlyRecordSet<MailServer>>,
pub spf: Option<FriendlyRecord<Spf>>,
}
impl MailConfiguration {
pub fn new() -> Self {
MailConfiguration {
servers: None,
spf: None
}
}
}
#[derive(Debug, Serialize)]
pub struct WebConfiguration {
pub addresses: Option<FriendlyRecordSet<Address>>,
}
impl WebConfiguration {
pub fn new() -> Self {
WebConfiguration {
addresses: None,
}
}
}
#[derive(Debug, Serialize)]
pub struct FriendlyRecordSetGroup {
owner: String,
friendly_type: FriendlyRType,
rdata: FriendlyRData,
ttl: u32,
general_records: Vec<FriendlyRecordSet<FriendlyRData>>,
mail: Option<MailConfiguration>,
web: Option<WebConfiguration>,
}
pub struct RecordSet {
friendly_type: FriendlyRType,
records: Vec<FriendlyRecord>
impl FriendlyRecordSetGroup {
pub fn new(owner: String) -> Self {
FriendlyRecordSetGroup {
owner,
general_records: Vec::new(),
mail: None,
web: None,
}
}
}
pub struct RecordSetGroup {
owner: String,
rrsets: Vec<RecordSet>
#[derive(Debug, Serialize)]
pub struct FriendlyRecords {
records: Vec<FriendlyRecordSetGroup>,
aliases: Vec<Alias>,
}
pub struct FriendlyRecords(Vec<RecordSetGroup>);
impl FriendlyRecords {
pub fn new() -> Self {
FriendlyRecords {
records: Vec::new(),
aliases: Vec::new(),
}
}
}
impl From<internal::RecordList> for FriendlyRecords {
fn from(value: internal::RecordList) -> Self {
let mut records = FriendlyRecords::new();
for record in value.records {
let internal::Record { name, ttl, rdata } = record;
let name = name.to_string();
let rdata = rdata.friendly(&name, ttl);
if rdata.is_none() {
continue;
}
let rdata = rdata.unwrap();
if let FriendlyRData::Alias(alias) = rdata {
records.aliases.push(alias)
} else {
if records.records.is_empty() {
records.records.push(FriendlyRecordSetGroup::new(name));
} else {
let group = records.records.last().unwrap();
if group.owner != name {
records.records.push(FriendlyRecordSetGroup::new(name));
}
}
let current_group = records.records.last_mut().unwrap();
match rdata {
FriendlyRData::Address(address) => {
let web = current_group.web.get_or_insert_with(WebConfiguration::new);
let addresses = web.addresses.get_or_insert_with(|| FriendlyRecordSet::new(ttl));
addresses.data.push(address);
},
FriendlyRData::MailServer(mailserver) => {
let mail: &mut MailConfiguration = current_group.mail.get_or_insert_with(MailConfiguration::new);
let servers = mail.servers.get_or_insert_with(|| FriendlyRecordSet::new(ttl));
servers.data.push(mailserver);
},
FriendlyRData::Spf(spf) => {
let mail: &mut MailConfiguration = current_group.mail.get_or_insert_with(MailConfiguration::new);
mail.spf = Some(FriendlyRecord::new(ttl, spf));
},
FriendlyRData::Alias(_) => {},
data => {
// TODO: NS -> Skip if NS for zone (authority), create Delegation section with glue + DS for others (how to check if record is glue?)
if current_group.general_records.is_empty() {
current_group.general_records.push(FriendlyRecordSet::new(ttl));
} else {
let rrset = current_group.general_records.last().unwrap();
if rrset.data.last().unwrap().rtype() != data.rtype() {
current_group.general_records.push(FriendlyRecordSet::new(ttl));
}
}
let current_rrset = current_group.general_records.last_mut().unwrap();
current_rrset.data.push(data)
}
}
}
}
records
}
}
/* --------- RDATA --------- */
#[derive(Debug, Deserialize, Serialize)]
pub struct Address {
pub address: String
}
impl HasRType for Address {
fn rtype(&self) -> FriendlyRType {
FriendlyRType::Address
}
}
#[derive(Debug, Deserialize, Serialize)]
pub struct Service {
pub service_type: String,
pub port: u16,
pub weight: u16,
pub priority: u16,
pub service_type: ServiceType,
pub service_name: Option<String>,
pub service_protocol: Option<String>,
pub port: i64,
pub weight: i64,
pub priority: i64,
pub server: String,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ServiceType {
ServiceProtocol { service_name: String, protocol: String },
Service { service_name: String },
None,
Other,
}
impl ServiceType {
pub fn from_name(name: String) -> (Self, String) {
let labels: Vec<_> = name.splitn(3, '.').collect();
let prefix: Vec<_> = labels.iter().cloned().take(2).map(|label| label.strip_prefix('_')).collect();
if prefix.is_empty() || prefix[0].is_none() {
(ServiceType::None, labels[0..].join(".").to_owned())
} else if prefix.len() == 1 || prefix[1].is_none() {
(ServiceType::Service { service_name: prefix[0].unwrap().into() }, labels[1..].join(".").to_owned())
} else {
(ServiceType::ServiceProtocol { service_name: prefix[0].unwrap().into(), protocol: prefix[1].unwrap().into() }, labels[2].to_owned())
}
}
}
impl fmt::Display for ServiceType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ServiceType::ServiceProtocol { service_name, protocol } => write!(f, "{}/{}", service_name, protocol),
ServiceType::Service { service_name, } => write!(f, "{}", service_name),
ServiceType::None => write!(f, "-"),
}
}
}
#[derive(Debug, Deserialize, Serialize)]
pub struct MailServer {
pub preference: u16,
pub preference: i64,
pub mail_exchanger: String,
}
impl HasRType for MailServer {
fn rtype(&self) -> FriendlyRType {
FriendlyRType::MailServer
}
}
#[derive(Debug, Deserialize, Serialize)]
pub struct NameServer {
pub target: String,
@ -105,5 +316,18 @@ pub struct TextData {
#[derive(Debug, Deserialize, Serialize)]
pub struct Alias {
pub from: String,
pub target: String,
pub ttl: i64,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct Spf {
pub policy: String
}
impl HasRType for Spf {
fn rtype(&self) -> FriendlyRType {
FriendlyRType::Spf
}
}

View file

@ -2,6 +2,8 @@ use std::net::{Ipv4Addr, Ipv6Addr};
use super::{Name, Rtype};
use crate::resources::dns::friendly;
#[derive(Clone)]
pub enum RData {
A(A),
@ -29,6 +31,18 @@ impl RData {
RData::Txt(_) => Rtype::Txt,
}
}
pub fn friendly(self, owner: &str, ttl: u32) -> Option<friendly::FriendlyRData> {
match self {
RData::A(data) => data.friendly(),
RData::Aaaa(data) => data.friendly(),
RData::Cname(data) => data.friendly(owner, ttl),
RData::Mx(data) => data.friendly(),
RData::Ns(data) => data.friendly(),
RData::Srv(data) => data.friendly(owner),
_ => None,
}
}
}
#[derive(Clone)]
@ -36,27 +50,70 @@ pub struct A {
pub address: Ipv4Addr,
}
impl A {
pub fn friendly(self) -> Option<friendly::FriendlyRData> {
Some(friendly::FriendlyRData::Address(friendly::Address {
address: self.address.to_string()
}))
}
}
#[derive(Clone)]
pub struct Aaaa {
pub address: Ipv6Addr,
}
impl Aaaa {
pub fn friendly(self) -> Option<friendly::FriendlyRData> {
Some(friendly::FriendlyRData::Address(friendly::Address {
address: self.address.to_string()
}))
}
}
#[derive(Clone)]
pub struct Cname {
pub target: Name,
}
impl Cname {
pub fn friendly(self, owner: &str, ttl: u32) -> Option<friendly::FriendlyRData> {
Some(friendly::FriendlyRData::Alias(friendly::Alias {
from: owner.into(),
target: self.target.to_string(),
ttl: ttl.into(),
}))
}
}
#[derive(Clone)]
pub struct Mx {
pub preference: u16,
pub mail_exchanger: Name,
}
impl Mx {
pub fn friendly(self) -> Option<friendly::FriendlyRData> {
Some(friendly::FriendlyRData::MailServer(friendly::MailServer {
preference: self.preference.into(),
mail_exchanger: self.mail_exchanger.to_string(),
}))
}
}
#[derive(Clone)]
pub struct Ns {
pub target: Name,
}
impl Ns {
pub fn friendly(self) -> Option<friendly::FriendlyRData> {
Some(friendly::FriendlyRData::NameServer(friendly::NameServer {
target: self.target.to_string(),
}))
}
}
#[derive(Clone)]
pub struct Ptr {
pub target: Name,
@ -81,6 +138,32 @@ pub struct Srv {
pub weight: u16,
}
impl Srv {
pub fn friendly(self, owner: &str) -> Option<friendly::FriendlyRData> {
let labels: Vec<_> = owner.splitn(3, '.').collect();
if labels.len() != 3 {
None
} else {
let service_name = labels[0]. strip_prefix('_');
let protocol = labels[1]. strip_prefix('_');
if let (Some(service_name), Some(protocol)) = (service_name, protocol) {
Some(friendly::FriendlyRData::Service(friendly::Service {
service_type: friendly::ServiceType::Other,
service_name: Some(service_name.into()),
service_protocol: Some(protocol.into()),
port: self.port.into(),
weight: self.weight.into(),
priority: self.priority.into(),
server: self.server.to_string(),
}))
} else {
None
}
}
}
}
#[derive(Clone)]
pub struct Txt {
pub text: Vec<u8>,

View file

@ -1,3 +1,3 @@
pub mod external;
pub mod internal;
//pub mod friendly;
pub mod friendly;

View file

@ -41,9 +41,8 @@ impl Zone {
db.create_zone(create_zone).await
}
pub async fn get_records(zone_name: &str, db: BoxedDb, record_driver: BoxedRecordDriver) ->Result<RecordList, Error> {
let zone = db.get_zone_by_name(zone_name).await?;
let mut records = record_driver.get_records(&zone.name).await?;
pub async fn get_records(&self, record_driver: BoxedRecordDriver) ->Result<RecordList, Error> {
let mut records = record_driver.get_records(&self.name).await?;
records.sort();

View file

@ -21,7 +21,8 @@ pub async fn get_zone_records(
State(app): State<AppState>,
) -> Result<Json<external::RecordList>, Error>
{
Zone::get_records(&zone_name, app.db, app.records)
let zone = app.db.get_zone_by_name(&zone_name).await?;
zone.get_records(app.records)
.await
.map(|records| Json(records.into()))
}

View file

@ -1,26 +1,75 @@
use axum::extract::{Path, State};
use axum::extract::Request;
use axum::extract::{Query, Path, State};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::validation;
use crate::AppState;
use crate::errors::Error;
use crate::resources::zone::Zone;
use crate::template::Template;
use crate::resources::dns::external;
use crate::resources::dns::friendly;
pub async fn get_zone_records_page(
pub async fn get_records_page(
Path(zone_name): Path<String>,
State(app): State<AppState>,
request: Request,
) -> Result<Template<'static, Value>, Error> {
let records = Zone::get_records(&zone_name, app.db, app.records).await?;
//records.0.sort_by_key(|record| (&record.name, record.rdata));
let zone = app.db.get_zone_by_name(&zone_name).await?;
let records = zone.get_records(app.records).await?;
let records = friendly::FriendlyRecords::from(records.clone());
Ok(Template::new(
"pages/records.html",
app.template_engine,
json!({
"current_zone": zone_name,
"records": external::RecordList::from(records),
"current_zone": zone.name,
"records": records,
"url": request.uri().to_string(),
})
))
}
#[derive(Deserialize)]
pub struct NewRecordQuery {
subdomain: Option<String>,
config: Option<friendly::ConfigurationType>,
rtype: Option<friendly::FriendlyRType>,
}
pub async fn get_new_record_page(
Path(zone_name): Path<String>,
State(app): State<AppState>,
Query(params): Query<NewRecordQuery>,
request: Request,
) -> Result<Template<'static, Value>, Error> {
let zone = app.db.get_zone_by_name(&zone_name).await?;
// Syntax validation of the subdomain
let (name, domain_error) = if let Some(input_name) = params.subdomain {
if input_name.is_empty() {
(Some(zone.name.clone()), None)
} else {
let new_name = format!("{input_name}.{zone_name}");
let res = validation::normalize_domain(&new_name);
match res {
Err(error) => (Some(input_name), Some(error)),
Ok(new_name) => (Some(new_name), None),
}
}
} else {
(None, None)
};
Ok(Template::new(
"pages/new_record.html",
app.template_engine,
json!({
"current_zone": zone.name,
"new_record_name": name,
"domain_error": domain_error,
"config": params.config,
"rtype": params.rtype,
"url": request.uri().to_string(),
})
))
}

View file

@ -13,11 +13,13 @@ pub enum DomainValidationError {
/// https://doc.zonemaster.fr/v2024.1/specifications/tests/RequirementsAndNormalizationOfDomainNames.html
/// TODO: No support of dots in labels, how to handle RNAME in SOA?
pub fn normalize_domain(domain_name: &str) -> Result<String, Error> {
let domain = domain_name.strip_prefix('.').unwrap_or(domain_name).to_lowercase();
if domain_name.is_empty() {
return Err(Error::from(DomainValidationError::EmptyDomain))
}
if domain.is_empty() {
Err(Error::from(DomainValidationError::EmptyDomain))
} else if domain.as_bytes().len() > 255 {
let domain = domain_name.strip_suffix('.').unwrap_or(domain_name).to_lowercase();
if domain.as_bytes().len() > 255 {
Err(Error::from(DomainValidationError::DomainTooLong { length: domain.as_bytes().len() }))
} else {
let labels = domain.split('.').collect::<Vec<_>>();
@ -48,6 +50,7 @@ pub fn normalize_domain(domain_name: &str) -> Result<String, Error> {
Ok(domain)
}
}
pub enum TxtParseError {
MissingEscape,