Use an HTTP challenge for ensuring that domains are correctly set up, rather than checking DNS records directly

main
Brian Picciano 12 months ago
parent 28104f36e1
commit 03428cef02
  1. 130
      Cargo.lock
  2. 2
      Cargo.toml
  3. 6
      README.md
  4. 154
      src/domain/checker.rs
  5. 12
      src/domain/manager.rs
  6. 15
      src/main.rs
  7. 23
      src/service/http.rs

130
Cargo.lock generated

@ -285,6 +285,16 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" 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]] [[package]]
name = "core-foundation-sys" name = "core-foundation-sys"
version = "0.8.4" version = "0.8.4"
@ -461,8 +471,10 @@ dependencies = [
"mockall", "mockall",
"openssl", "openssl",
"pem", "pem",
"rand 0.8.5",
"reqwest",
"rust-embed", "rust-embed",
"rustls 0.21.1", "rustls",
"serde", "serde",
"serde_json", "serde_json",
"serde_urlencoded", "serde_urlencoded",
@ -475,7 +487,7 @@ dependencies = [
"thiserror", "thiserror",
"tls-listener", "tls-listener",
"tokio", "tokio",
"tokio-rustls 0.24.0", "tokio-rustls",
"tokio-util", "tokio-util",
"trust-dns-client", "trust-dns-client",
] ]
@ -1528,15 +1540,29 @@ dependencies = [
[[package]] [[package]]
name = "hyper-rustls" name = "hyper-rustls"
version = "0.23.2" version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1788965e61b367cd03a62950836d5cd41560c3577d90e40e0819373194d1661c" checksum = "8d78e1e73ec14cf7375674f74d7dde185c8206fd9dea6fb6295e8a98098aaa97"
dependencies = [ dependencies = [
"futures-util",
"http", "http",
"hyper", "hyper",
"rustls 0.20.8", "rustls",
"tokio", "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]] [[package]]
@ -1889,6 +1915,24 @@ dependencies = [
"syn 1.0.109", "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]] [[package]]
name = "nibble_vec" name = "nibble_vec"
version = "0.1.0" version = "0.1.0"
@ -1984,6 +2028,12 @@ dependencies = [
"syn 2.0.15", "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]] [[package]]
name = "openssl-sys" name = "openssl-sys"
version = "0.9.87" version = "0.9.87"
@ -2338,9 +2388,9 @@ dependencies = [
[[package]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.11.17" version = "0.11.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13293b639a097af28fc8a90f22add145a9c954e49d77da06263d58cf44d5fb91" checksum = "cde824a14b7c14f85caff81225f411faacc04a2013f41670f41443742b1c1c55"
dependencies = [ dependencies = [
"base64 0.21.0", "base64 0.21.0",
"bytes", "bytes",
@ -2352,20 +2402,23 @@ dependencies = [
"http-body", "http-body",
"hyper", "hyper",
"hyper-rustls", "hyper-rustls",
"hyper-tls",
"ipnet", "ipnet",
"js-sys", "js-sys",
"log", "log",
"mime", "mime",
"native-tls",
"once_cell", "once_cell",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"rustls 0.20.8", "rustls",
"rustls-pemfile", "rustls-pemfile",
"serde", "serde",
"serde_json", "serde_json",
"serde_urlencoded", "serde_urlencoded",
"tokio", "tokio",
"tokio-rustls 0.23.4", "tokio-native-tls",
"tokio-rustls",
"tower-service", "tower-service",
"trust-dns-resolver", "trust-dns-resolver",
"url", "url",
@ -2449,18 +2502,6 @@ dependencies = [
"windows-sys 0.48.0", "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]] [[package]]
name = "rustls" name = "rustls"
version = "0.21.1" version = "0.21.1"
@ -2507,6 +2548,15 @@ dependencies = [
"winapi-util", "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]] [[package]]
name = "scopeguard" name = "scopeguard"
version = "1.1.0" version = "1.1.0"
@ -2523,6 +2573,29 @@ dependencies = [
"untrusted", "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]] [[package]]
name = "serde" name = "serde"
version = "1.0.162" version = "1.0.162"
@ -2833,7 +2906,7 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
"thiserror", "thiserror",
"tokio", "tokio",
"tokio-rustls 0.24.0", "tokio-rustls",
] ]
[[package]] [[package]]
@ -2867,14 +2940,13 @@ dependencies = [
] ]
[[package]] [[package]]
name = "tokio-rustls" name = "tokio-native-tls"
version = "0.23.4" version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c43ee83903113e03984cb9e5cebe6c04a5116269e900e3ddba8f068a62adda59" checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [ dependencies = [
"rustls 0.20.8", "native-tls",
"tokio", "tokio",
"webpki",
] ]
[[package]] [[package]]
@ -2883,7 +2955,7 @@ version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0d409377ff5b1e3ca6437aa86c1eb7d40c134bfec254e44c830defa92669db5" checksum = "e0d409377ff5b1e3ca6437aa86c1eb7d40c134bfec254e44c830defa92669db5"
dependencies = [ dependencies = [
"rustls 0.21.1", "rustls",
"tokio", "tokio",
] ]

@ -42,3 +42,5 @@ tokio-rustls = "0.24.0"
log = "0.4.19" log = "0.4.19"
env_logger = "0.10.0" env_logger = "0.10.0"
serde_yaml = "0.9.22" serde_yaml = "0.9.22"
rand = "0.8.5"
reqwest = "0.11.18"

@ -128,9 +128,3 @@ Within the shell which opens you can do `cargo run` to start a local instance.
* Alternative URLs (reverse proxy) * Alternative URLs (reverse proxy)
* Google Drive * Google Drive
* Dropbox * 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.

@ -1,14 +1,15 @@
use std::net; use std::net;
use std::ops::DerefMut;
use std::str::FromStr; use std::str::FromStr;
use crate::domain;
use crate::error::unexpected::{self, Mappable}; use crate::error::unexpected::{self, Mappable};
use crate::{domain, token};
use trust_dns_client::client::{AsyncClient, ClientHandle}; use trust_dns_client::client::{AsyncClient, ClientHandle};
use trust_dns_client::rr::{DNSClass, Name, RData, RecordType}; use trust_dns_client::rr::{DNSClass, Name, RData, RecordType};
use trust_dns_client::udp; use trust_dns_client::udp;
use rand::Rng;
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
pub enum CheckDomainError { pub enum CheckDomainError {
#[error("no service dns records set")] #[error("no service dns records set")]
@ -27,123 +28,50 @@ pub enum DNSRecord {
CNAME(domain::Name), CNAME(domain::Name),
} }
impl DNSRecord {
async fn check_a(
client: &mut AsyncClient,
domain: &trust_dns_client::rr::Name,
addr: &net::Ipv4Addr,
) -> Result<bool, unexpected::Error> {
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<bool, unexpected::Error> {
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<bool, unexpected::Error> {
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<bool, unexpected::Error> {
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 { pub struct DNSChecker {
// TODO we should use some kind of connection pool here, I suppose // TODO we should use some kind of connection pool here, I suppose
client: tokio::sync::Mutex<AsyncClient>, client: tokio::sync::Mutex<AsyncClient>,
service_dns_records: Vec<DNSRecord>, token_store: Box<dyn token::Store + Send + Sync>,
service_primary_domain: domain::Name,
} }
impl DNSChecker { impl DNSChecker {
pub async fn new( pub async fn new<TokenStore>(
token_store: TokenStore,
config: &domain::ConfigDNS, config: &domain::ConfigDNS,
service_dns_records: Vec<DNSRecord>, service_primary_domain: domain::Name,
) -> Result<Self, unexpected::Error> { ) -> Result<Self, unexpected::Error>
where
TokenStore: token::Store + Send + Sync + 'static,
{
let stream = udp::UdpClientStream::<tokio::net::UdpSocket>::new(config.resolver_addr); let stream = udp::UdpClientStream::<tokio::net::UdpSocket>::new(config.resolver_addr);
let (client, bg) = AsyncClient::connect(stream).await.or_unexpected()?; let (client, bg) = AsyncClient::connect(stream).await.or_unexpected()?;
tokio::spawn(bg); tokio::spawn(bg);
// TODO there should be a mechanism to clean this up // TODO there should be a mechanism to clean this up
Ok(Self { Ok(Self {
token_store: Box::from(token_store),
client: tokio::sync::Mutex::new(client), client: tokio::sync::Mutex::new(client),
service_dns_records, service_primary_domain,
}) })
} }
pub fn get_challenge_token(&self, domain: &domain::Name) -> unexpected::Result<Option<String>> {
self.token_store.get(domain.as_str())
}
pub async fn check_domain( pub async fn check_domain(
&self, &self,
domain: &domain::Name, domain: &domain::Name,
challenge_token: &str, challenge_token: &str,
) -> Result<(), CheckDomainError> { ) -> 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 // check that the TXT record with the challenge token is correctly installed on the domain
{ {
let domain = Name::from_str("_domani_challenge") let domain = Name::from_str("_domani_challenge")
.or_unexpected_while("parsing TXT name")? .or_unexpected_while("parsing TXT name")?
.append_domain(domain) .append_domain(domain_rr)
.or_unexpected_while("appending domain to TXT")?; .or_unexpected_while("appending domain to TXT")?;
let response = self let response = self
@ -166,16 +94,40 @@ impl DNSChecker {
} }
} }
// check that one of the possible DNS records is installed on the domain // check that DNS correctly resolves for the domain. This is done by serving an HTTP
for record in &self.service_dns_records { // challenge on the domain, which we then query for here.
let mut client = self.client.lock().await; //
match record.check(client.deref_mut(), domain).await { // first store the challenge token, so that the HTTP server can find it via
Ok(true) => return Ok(()), // get_challenge_token.
Ok(false) => (), let token: String = rand::thread_rng()
Err(e) => return Err(e.into()), .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(())
} }
} }

@ -160,6 +160,11 @@ pub trait Manager: Sync + Send + rustls::server::ResolvesServerCert {
token: &str, token: &str,
) -> Result<String, GetAcmeHttp01ChallengeKeyError>; ) -> Result<String, GetAcmeHttp01ChallengeKeyError>;
fn get_domain_checker_challenge_token(
&self,
domain: &domain::Name,
) -> unexpected::Result<Option<String>>;
fn all_domains(&self) -> Result<Vec<domain::Name>, unexpected::Error>; fn all_domains(&self) -> Result<Vec<domain::Name>, unexpected::Error>;
} }
@ -288,6 +293,13 @@ impl Manager for ManagerImpl {
Err(GetAcmeHttp01ChallengeKeyError::NotFound) Err(GetAcmeHttp01ChallengeKeyError::NotFound)
} }
fn get_domain_checker_challenge_token(
&self,
domain: &domain::Name,
) -> unexpected::Result<Option<String>> {
self.domain_checker.get_challenge_token(domain)
}
fn all_domains(&self) -> Result<Vec<domain::Name>, unexpected::Error> { fn all_domains(&self) -> Result<Vec<domain::Name>, unexpected::Error> {
self.domain_store.all_domains() self.domain_store.all_domains()
} }

@ -78,21 +78,16 @@ async fn main() {
config config
}; };
let token_store = domani::token::MemStore::new();
let origin_store = domani::origin::git::FSStore::new(&config.origin) let origin_store = domani::origin::git::FSStore::new(&config.origin)
.expect("git origin store initialization failed"); .expect("git origin store initialization failed");
let domain_checker = { let domain_checker = domani::domain::checker::DNSChecker::new(
let dns_records = config.service.dns_records.clone(); domani::token::MemStore::new(),
domani::domain::checker::DNSChecker::new(
&config.domain.dns, &config.domain.dns,
dns_records.into_iter().map(|r| r.into()).collect(), config.service.primary_domain.clone(),
) )
.await .await
.expect("domain checker initialization failed") .expect("domain checker initialization failed");
};
let domain_config_store = let domain_config_store =
domani::domain::store::FSStore::new(&config.domain.store_dir_path.join("domains")) domani::domain::store::FSStore::new(&config.domain.store_dir_path.join("domains"))
@ -111,7 +106,7 @@ async fn main() {
Some( Some(
domani::domain::acme::manager::ManagerImpl::new( domani::domain::acme::manager::ManagerImpl::new(
domain_acme_store, domain_acme_store,
token_store, domani::token::MemStore::new(),
&acme_config, &acme_config,
) )
.await .await

@ -356,8 +356,9 @@ impl<'svc> Service {
let method = req.method(); let method = req.method();
let path = req.uri().path(); let path = req.uri().path();
// Serving acme challenges always takes priority. We serve them from the same store no matter // Serving acme challenges always takes priority. We serve them from the same store no
// the domain, presumably they are cryptographically random enough that it doesn't matter. // 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/") { if method == Method::GET && path.starts_with("/.well-known/acme-challenge/") {
let token = path.trim_start_matches("/.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 a managed domain was given then serve that from its origin
if let Some(domain) = maybe_host { if let Some(domain) = maybe_host {
return self.serve_origin(domain, req.uri().path()); return self.serve_origin(domain, req.uri().path());

Loading…
Cancel
Save