diff --git a/Cargo.lock b/Cargo.lock index 1694236..38bb4e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -285,6 +285,16 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" +[[package]] +name = "core-foundation" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.4" @@ -461,8 +471,10 @@ dependencies = [ "mockall", "openssl", "pem", + "rand 0.8.5", + "reqwest", "rust-embed", - "rustls 0.21.1", + "rustls", "serde", "serde_json", "serde_urlencoded", @@ -475,7 +487,7 @@ dependencies = [ "thiserror", "tls-listener", "tokio", - "tokio-rustls 0.24.0", + "tokio-rustls", "tokio-util", "trust-dns-client", ] @@ -1528,15 +1540,29 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.23.2" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1788965e61b367cd03a62950836d5cd41560c3577d90e40e0819373194d1661c" +checksum = "8d78e1e73ec14cf7375674f74d7dde185c8206fd9dea6fb6295e8a98098aaa97" dependencies = [ + "futures-util", "http", "hyper", - "rustls 0.20.8", + "rustls", "tokio", - "tokio-rustls 0.23.4", + "tokio-rustls", +] + +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper", + "native-tls", + "tokio", + "tokio-native-tls", ] [[package]] @@ -1889,6 +1915,24 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "native-tls" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e" +dependencies = [ + "lazy_static", + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "nibble_vec" version = "0.1.0" @@ -1984,6 +2028,12 @@ dependencies = [ "syn 2.0.15", ] +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + [[package]] name = "openssl-sys" version = "0.9.87" @@ -2338,9 +2388,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.11.17" +version = "0.11.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13293b639a097af28fc8a90f22add145a9c954e49d77da06263d58cf44d5fb91" +checksum = "cde824a14b7c14f85caff81225f411faacc04a2013f41670f41443742b1c1c55" dependencies = [ "base64 0.21.0", "bytes", @@ -2352,20 +2402,23 @@ dependencies = [ "http-body", "hyper", "hyper-rustls", + "hyper-tls", "ipnet", "js-sys", "log", "mime", + "native-tls", "once_cell", "percent-encoding", "pin-project-lite", - "rustls 0.20.8", + "rustls", "rustls-pemfile", "serde", "serde_json", "serde_urlencoded", "tokio", - "tokio-rustls 0.23.4", + "tokio-native-tls", + "tokio-rustls", "tower-service", "trust-dns-resolver", "url", @@ -2449,18 +2502,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "rustls" -version = "0.20.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fff78fc74d175294f4e83b28343315ffcfb114b156f0185e9741cb5570f50e2f" -dependencies = [ - "log", - "ring", - "sct", - "webpki", -] - [[package]] name = "rustls" version = "0.21.1" @@ -2507,6 +2548,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c3733bf4cf7ea0880754e19cb5a462007c4a8c1914bff372ccc95b464f1df88" +dependencies = [ + "windows-sys 0.48.0", +] + [[package]] name = "scopeguard" version = "1.1.0" @@ -2523,6 +2573,29 @@ dependencies = [ "untrusted", ] +[[package]] +name = "security-framework" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc758eb7bffce5b308734e9b0c1468893cae9ff70ebf13e7090be8dcbcc83a8" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f51d0c0d83bec45f16480d0ce0058397a69e48fcdc52d1dc8855fb68acbd31a7" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "serde" version = "1.0.162" @@ -2833,7 +2906,7 @@ dependencies = [ "pin-project-lite", "thiserror", "tokio", - "tokio-rustls 0.24.0", + "tokio-rustls", ] [[package]] @@ -2867,14 +2940,13 @@ dependencies = [ ] [[package]] -name = "tokio-rustls" -version = "0.23.4" +name = "tokio-native-tls" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c43ee83903113e03984cb9e5cebe6c04a5116269e900e3ddba8f068a62adda59" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" dependencies = [ - "rustls 0.20.8", + "native-tls", "tokio", - "webpki", ] [[package]] @@ -2883,7 +2955,7 @@ version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0d409377ff5b1e3ca6437aa86c1eb7d40c134bfec254e44c830defa92669db5" dependencies = [ - "rustls 0.21.1", + "rustls", "tokio", ] diff --git a/Cargo.toml b/Cargo.toml index 4d286aa..1d28e79 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,3 +42,5 @@ tokio-rustls = "0.24.0" log = "0.4.19" env_logger = "0.10.0" serde_yaml = "0.9.22" +rand = "0.8.5" +reqwest = "0.11.18" diff --git a/README.md b/README.md index 5c836a4..df733f9 100644 --- a/README.md +++ b/README.md @@ -128,9 +128,3 @@ Within the shell which opens you can do `cargo run` to start a local instance. * 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/src/domain/checker.rs b/src/domain/checker.rs index c8e58f6..002dab4 100644 --- a/src/domain/checker.rs +++ b/src/domain/checker.rs @@ -1,14 +1,15 @@ use std::net; -use std::ops::DerefMut; use std::str::FromStr; -use crate::domain; use crate::error::unexpected::{self, Mappable}; +use crate::{domain, token}; use trust_dns_client::client::{AsyncClient, ClientHandle}; use trust_dns_client::rr::{DNSClass, Name, RData, RecordType}; use trust_dns_client::udp; +use rand::Rng; + #[derive(thiserror::Error, Debug)] pub enum CheckDomainError { #[error("no service dns records set")] @@ -27,123 +28,50 @@ pub enum DNSRecord { CNAME(domain::Name), } -impl DNSRecord { - async fn check_a( - client: &mut AsyncClient, - domain: &trust_dns_client::rr::Name, - addr: &net::Ipv4Addr, - ) -> Result { - let response = client - .query(domain.clone(), DNSClass::IN, RecordType::A) - .await - .or_unexpected_while("querying A record")?; - - let records = response.answers(); - - for record in records { - if let Some(RData::A(record_addr)) = record.data() { - if record_addr == addr { - return Ok(true); - } - } - } - - 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( - &self, - client: &mut AsyncClient, - domain: &trust_dns_client::rr::Name, - ) -> Result { - 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, - } - } -} - pub struct DNSChecker { // TODO we should use some kind of connection pool here, I suppose client: tokio::sync::Mutex, - service_dns_records: Vec, + token_store: Box, + service_primary_domain: domain::Name, } impl DNSChecker { - pub async fn new( + pub async fn new( + token_store: TokenStore, config: &domain::ConfigDNS, - service_dns_records: Vec, - ) -> Result { + service_primary_domain: domain::Name, + ) -> Result + where + TokenStore: token::Store + Send + Sync + 'static, + { let stream = udp::UdpClientStream::::new(config.resolver_addr); let (client, bg) = AsyncClient::connect(stream).await.or_unexpected()?; tokio::spawn(bg); // TODO there should be a mechanism to clean this up Ok(Self { + token_store: Box::from(token_store), client: tokio::sync::Mutex::new(client), - service_dns_records, + service_primary_domain, }) } + pub fn get_challenge_token(&self, domain: &domain::Name) -> unexpected::Result> { + self.token_store.get(domain.as_str()) + } + pub async fn check_domain( &self, domain: &domain::Name, challenge_token: &str, ) -> Result<(), CheckDomainError> { - let domain = domain.as_rr(); + let domain_rr = domain.as_rr(); // check that the TXT record with the challenge token is correctly installed on the domain { let domain = Name::from_str("_domani_challenge") .or_unexpected_while("parsing TXT name")? - .append_domain(domain) + .append_domain(domain_rr) .or_unexpected_while("appending domain to TXT")?; let response = self @@ -166,16 +94,40 @@ impl DNSChecker { } } - // check that one of the possible DNS records is installed on the domain - for record in &self.service_dns_records { - let mut client = self.client.lock().await; - match record.check(client.deref_mut(), domain).await { - Ok(true) => return Ok(()), - Ok(false) => (), - Err(e) => return Err(e.into()), - } + // check that DNS correctly resolves for the domain. This is done by serving an HTTP + // challenge on the domain, which we then query for here. + // + // first store the challenge token, so that the HTTP server can find it via + // get_challenge_token. + let token: String = rand::thread_rng() + .sample_iter(rand::distributions::Alphanumeric) + .take(16) + .map(char::from) + .collect(); + + self.token_store + .set(domain.as_str().to_string(), token.clone()) + .or_unexpected_while("storing challenge token")?; + + let body = match reqwest::get(format!( + "http://{}/.well-known/domani-challenge", + self.service_primary_domain.as_str() + )) + .await + { + Err(_) => return Err(CheckDomainError::ServiceDNSRecordsNotSet), + Ok(res) => res + .error_for_status() + .or(Err(CheckDomainError::ServiceDNSRecordsNotSet))? + .text() + .await + .or(Err(CheckDomainError::ServiceDNSRecordsNotSet))?, + }; + + if body != token { + return Err(CheckDomainError::ServiceDNSRecordsNotSet); } - Err(CheckDomainError::ServiceDNSRecordsNotSet) + Ok(()) } } diff --git a/src/domain/manager.rs b/src/domain/manager.rs index 16f369d..b122c5c 100644 --- a/src/domain/manager.rs +++ b/src/domain/manager.rs @@ -160,6 +160,11 @@ pub trait Manager: Sync + Send + rustls::server::ResolvesServerCert { token: &str, ) -> Result; + fn get_domain_checker_challenge_token( + &self, + domain: &domain::Name, + ) -> unexpected::Result>; + fn all_domains(&self) -> Result, unexpected::Error>; } @@ -288,6 +293,13 @@ impl Manager for ManagerImpl { Err(GetAcmeHttp01ChallengeKeyError::NotFound) } + fn get_domain_checker_challenge_token( + &self, + domain: &domain::Name, + ) -> unexpected::Result> { + self.domain_checker.get_challenge_token(domain) + } + fn all_domains(&self) -> Result, unexpected::Error> { self.domain_store.all_domains() } diff --git a/src/main.rs b/src/main.rs index 72c8e5e..124bb0c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -78,21 +78,16 @@ async fn main() { config }; - let token_store = domani::token::MemStore::new(); - 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(); - - domani::domain::checker::DNSChecker::new( - &config.domain.dns, - dns_records.into_iter().map(|r| r.into()).collect(), - ) - .await - .expect("domain checker initialization failed") - }; + let domain_checker = domani::domain::checker::DNSChecker::new( + domani::token::MemStore::new(), + &config.domain.dns, + config.service.primary_domain.clone(), + ) + .await + .expect("domain checker initialization failed"); let domain_config_store = domani::domain::store::FSStore::new(&config.domain.store_dir_path.join("domains")) @@ -111,7 +106,7 @@ async fn main() { Some( domani::domain::acme::manager::ManagerImpl::new( domain_acme_store, - token_store, + domani::token::MemStore::new(), &acme_config, ) .await diff --git a/src/service/http.rs b/src/service/http.rs index f442f99..45c22ec 100644 --- a/src/service/http.rs +++ b/src/service/http.rs @@ -356,8 +356,9 @@ impl<'svc> Service { let method = req.method(); let path = req.uri().path(); - // Serving acme challenges always takes priority. We serve them from the same store no matter - // the domain, presumably they are cryptographically random enough that it doesn't matter. + // Serving acme challenges always takes priority. We serve them from the same store no + // matter the domain, presumably they are cryptographically random enough that it doesn't + // matter. if method == Method::GET && path.starts_with("/.well-known/acme-challenge/") { let token = path.trim_start_matches("/.well-known/acme-challenge/"); @@ -366,6 +367,24 @@ impl<'svc> Service { } } + // Serving domani challenges similarly takes priority. + if method == Method::GET && path == "/.well-known/domani-challenge" { + if let Some(ref domain) = maybe_host { + match self + .domain_manager + .get_domain_checker_challenge_token(domain) + { + Ok(Some(token)) => return self.serve(200, "token.txt", token.into()), + Ok(None) => return self.render_error_page(404, "Token not found"), + Err(e) => { + return self.internal_error( + format!("failed to get token for domain {}: {e}", domain).as_str(), + ) + } + } + } + } + // If a managed domain was given then serve that from its origin if let Some(domain) = maybe_host { return self.serve_origin(domain, req.uri().path());