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

This commit is contained in:
Brian Picciano 2023-07-12 20:25:35 +02:00
parent 28104f36e1
commit 03428cef02
7 changed files with 197 additions and 151 deletions

130
Cargo.lock generated
View File

@ -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",
]

View File

@ -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"

View File

@ -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.

View File

@ -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<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 {
// TODO we should use some kind of connection pool here, I suppose
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 {
pub async fn new(
pub async fn new<TokenStore>(
token_store: TokenStore,
config: &domain::ConfigDNS,
service_dns_records: Vec<DNSRecord>,
) -> Result<Self, unexpected::Error> {
service_primary_domain: domain::Name,
) -> Result<Self, unexpected::Error>
where
TokenStore: token::Store + Send + Sync + 'static,
{
let stream = udp::UdpClientStream::<tokio::net::UdpSocket>::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<Option<String>> {
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(())
}
}

View File

@ -160,6 +160,11 @@ pub trait Manager: Sync + Send + rustls::server::ResolvesServerCert {
token: &str,
) -> 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>;
}
@ -288,6 +293,13 @@ impl Manager for ManagerImpl {
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> {
self.domain_store.all_domains()
}

View File

@ -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(
let domain_checker = domani::domain::checker::DNSChecker::new(
domani::token::MemStore::new(),
&config.domain.dns,
dns_records.into_iter().map(|r| r.into()).collect(),
config.service.primary_domain.clone(),
)
.await
.expect("domain checker initialization failed")
};
.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

View File

@ -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());