Use an HTTP challenge for ensuring that domains are correctly set up, rather than checking DNS records directly
This commit is contained in:
parent
28104f36e1
commit
03428cef02
130
Cargo.lock
generated
130
Cargo.lock
generated
@ -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",
|
||||
]
|
||||
|
||||
|
@ -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"
|
||||
|
@ -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.
|
||||
|
||||
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
|
15
src/main.rs
15
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(
|
||||
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
|
||||
|
@ -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());
|
||||
|
Loading…
Reference in New Issue
Block a user