diff --git a/README.md b/README.md index 19079a9..5c836a4 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,10 @@ service: # DNS records which users must add to their domain's DNS so that # Domani can serve the domains. All records given must route to this Domani - # instance. At least one record must be given. + # instance. + # + # A CNAME record with the primary_domain of this server is automatically + # included. dns_records: #- type: A # addr: 127.0.0.1 @@ -69,6 +72,10 @@ service: #- type: AAAA # addr: ::1 + # NOTE that the name given here must resolve to the Domani server. + #- type: CNAME + # name: domain.com + # The domain name which will be used to serve the web interface of Domani. If # service.http.https_addr is enabled then an HTTPS certificate for this domain # will be retrieved automatically. @@ -116,9 +123,14 @@ Within the shell which opens you can do `cargo run` to start a local instance. ## Roadmap -* Support for CNAME records * Support for more backends than just git repositories, including: * IPFS/IPNS * Alternative URLs (reverse proxy) * Google Drive * Dropbox + +* Better support for dDNS servers. If a server is behind dDNS then users can + only add it to their domain via CNAME. If they add the CNAME (or ALIAS or + whatever) to the zone apex then Domani can't see that actual CNAME record (the + DNS server will flatten it to A records). This breaks Domani. + diff --git a/TODO b/TODO deleted file mode 100644 index e69de29..0000000 diff --git a/src/domain/checker.rs b/src/domain/checker.rs index 702957f..c8e58f6 100644 --- a/src/domain/checker.rs +++ b/src/domain/checker.rs @@ -24,32 +24,10 @@ pub enum CheckDomainError { pub enum DNSRecord { A(net::Ipv4Addr), AAAA(net::Ipv6Addr), + CNAME(domain::Name), } impl DNSRecord { - async fn check_aaaa( - client: &mut AsyncClient, - domain: &trust_dns_client::rr::Name, - addr: &net::Ipv6Addr, - ) -> Result { - let response = client - .query(domain.clone(), DNSClass::IN, RecordType::AAAA) - .await - .or_unexpected_while("querying A record")?; - - let records = response.answers(); - - if records.len() != 1 { - return Ok(false); - } - - // if the single record isn't a AAAA, or it's not the target AAAA, then return false - match records[0].data() { - Some(RData::AAAA(remote_addr)) if remote_addr == addr => Ok(true), - _ => return Ok(false), - } - } - async fn check_a( client: &mut AsyncClient, domain: &trust_dns_client::rr::Name, @@ -62,15 +40,61 @@ impl DNSRecord { let records = response.answers(); - if records.len() != 1 { - return Ok(false); + for record in records { + if let Some(RData::A(record_addr)) = record.data() { + if record_addr == addr { + return Ok(true); + } + } } - // if the single record isn't a A, or it's not the target A, then return false - match records[0].data() { - Some(RData::A(remote_addr)) if remote_addr == addr => Ok(true), - _ => return Ok(false), + return Ok(false); + } + + async fn check_aaaa( + client: &mut AsyncClient, + domain: &trust_dns_client::rr::Name, + addr: &net::Ipv6Addr, + ) -> Result { + let response = client + .query(domain.clone(), DNSClass::IN, RecordType::AAAA) + .await + .or_unexpected_while("querying AAAA record")?; + + let records = response.answers(); + + for record in records { + if let Some(RData::AAAA(record_addr)) = record.data() { + if record_addr == addr { + return Ok(true); + } + } } + + return Ok(false); + } + + async fn check_cname( + client: &mut AsyncClient, + domain: &trust_dns_client::rr::Name, + cname: &trust_dns_client::rr::Name, + ) -> Result { + let response = client + .query(domain.clone(), DNSClass::IN, RecordType::CNAME) + .await + .or_unexpected_while("querying CNAME record")?; + + let records = response.answers(); + + for record in records { + if let Some(RData::CNAME(record_cname)) = record.data() { + if record_cname == cname { + return Ok(true); + } + } + } + + return Ok(false); } async fn check( @@ -81,6 +105,7 @@ impl DNSRecord { match self { Self::A(addr) => Self::check_a(client, domain, &addr).await, Self::AAAA(addr) => Self::check_aaaa(client, domain, &addr).await, + Self::CNAME(name) => Self::check_cname(client, domain, name.as_rr()).await, } } } diff --git a/src/domain/name.rs b/src/domain/name.rs index ee462ca..4cbec1c 100644 --- a/src/domain/name.rs +++ b/src/domain/name.rs @@ -1,5 +1,5 @@ -use std::fmt; use std::str::FromStr; +use std::{cmp, fmt}; use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; use trust_dns_client::rr as trust_dns_rr; @@ -40,6 +40,12 @@ impl FromStr for Name { } } +impl cmp::PartialEq for Name { + fn eq(&self, other: &Self) -> bool { + self.inner == other.inner + } +} + impl Serialize for Name { fn serialize(&self, serializer: S) -> Result where diff --git a/src/main.rs b/src/main.rs index 1935039..409393b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,14 +34,6 @@ struct Cli { async fn main() { let cli = Cli::parse(); - let config: domani::config::Config = { - let path = &cli.config_path; - let f = std::fs::File::open(path) - .unwrap_or_else(|e| panic!("failed to open config file at {}: {e}", path.display())); - serde_yaml::from_reader(f) - .unwrap_or_else(|e| panic!("failed to parse config file at {}: {e}", path.display())) - }; - env_logger::Builder::new() .filter_level(cli.log_level) .format_timestamp( @@ -50,14 +42,47 @@ async fn main() { ) .init(); + let config = { + let mut config: domani::config::Config = { + let path = &cli.config_path; + let f = std::fs::File::open(path).unwrap_or_else(|e| { + panic!("failed to open config file at {}: {e}", path.display()) + }); + serde_yaml::from_reader(f).unwrap_or_else(|e| { + panic!("failed to parse config file at {}: {e}", path.display()) + }) + }; + + // primary_cname is a CNAME record which points to the primary domain of the service. Since + // the primary domain _must_ point to the service (otherwise HTTPS wouldn't work) it's + // reasonable to assume that a CNAME on any domain would suffice to point that domain to + // the service. + let primary_cname = domani::service::ConfigDNSRecord::CNAME { + name: config.service.primary_domain.clone(), + }; + + let dns_records_have_primary_cname = config + .service + .dns_records + .iter() + .any(|r| r == &primary_cname); + + if !dns_records_have_primary_cname { + log::info!( + "Adding 'CNAME {}' to service.dns_records", + &config.service.primary_domain + ); + config.service.dns_records.push(primary_cname); + } + + config + }; + let origin_store = domani::origin::git::FSStore::new(&config.origin) .expect("git origin store initialization failed"); let domain_checker = { let dns_records = config.service.dns_records.clone(); - if dns_records.len() == 0 { - panic!("service.dns_records must have at least one record defined") - } domani::domain::checker::DNSChecker::new( &config.domain.dns, diff --git a/src/service.rs b/src/service.rs index bb3c5dd..984ac3b 100644 --- a/src/service.rs +++ b/src/service.rs @@ -9,11 +9,12 @@ fn default_primary_domain() -> domain::Name { domain::Name::from_str("localhost").unwrap() } -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Clone, PartialEq)] #[serde(tag = "type")] pub enum ConfigDNSRecord { A { addr: net::Ipv4Addr }, AAAA { addr: net::Ipv6Addr }, + CNAME { name: domain::Name }, } impl From for domain::checker::DNSRecord { @@ -21,6 +22,7 @@ impl From for domain::checker::DNSRecord { match r { ConfigDNSRecord::A { addr } => Self::A(addr), ConfigDNSRecord::AAAA { addr } => Self::AAAA(addr), + ConfigDNSRecord::CNAME { name } => Self::CNAME(name), } } } diff --git a/src/service/http.rs b/src/service/http.rs index ada0aed..f442f99 100644 --- a/src/service/http.rs +++ b/src/service/http.rs @@ -230,6 +230,9 @@ impl<'svc> Service { flat_config: service::util::FlatConfig, dns_records: &'a [service::ConfigDNSRecord], challenge_token: String, + + domain_is_zone_apex: bool, + dns_records_have_cname: bool, } let config: domain::Domain = match domain_config.try_into() { @@ -249,6 +252,12 @@ impl<'svc> Service { } }; + let domain_is_zone_apex = args.domain.as_rr().num_labels() == 2; + let dns_records_have_cname = self.config.dns_records.iter().any(|r| match r { + service::ConfigDNSRecord::CNAME { .. } => true, + _ => false, + }); + self.render_page( "/domain_init.html", Response { @@ -256,6 +265,9 @@ impl<'svc> Service { flat_config: config.into(), dns_records: &self.config.dns_records, challenge_token: config_hash, + + domain_is_zone_apex, + dns_records_have_cname, }, ) } diff --git a/src/service/http/tpl/domain_init.html b/src/service/http/tpl/domain_init.html index 897d497..ca780c4 100644 --- a/src/service/http/tpl/domain_init.html +++ b/src/service/http/tpl/domain_init.html @@ -49,11 +49,21 @@ query for your domain name. It can be one or more of:

{{ this.type }} {{ lookup ../data "domain" }} + {{ #if this.name }} + {{ this.name }} + {{ else }} {{ this.addr }} + {{ /if }} {{ /each }} +{{ #if data.domain_is_zone_apex }}{{ #if data.dns_records_have_cname }} +

(Please note that not all DNS providers support putting a CNAME at the zone +apex, while others support it via an alternative record type like ALIAS or +ANAME.)

+{{ /if }}{{ /if }} +

Once both entries are installed, you can hit the following button to check your configuration and set up your domain.