Skip to content

Commit 8c95394

Browse files
committed
feat: support wildcard custom domains in gateway TXT resolution
Add fallback to `_dstack-app-address-wildcard.{parent}` TXT record when exact `_dstack-app-address.{sni}` lookup fails. This allows a single TXT record to route all subdomains of a custom domain to the same app, while still allowing per-subdomain overrides via exact records.
1 parent 00d3a9f commit 8c95394

1 file changed

Lines changed: 36 additions & 29 deletions

File tree

gateway/src/proxy/tls_passthough.rs

Lines changed: 36 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -34,43 +34,50 @@ impl AppAddress {
3434
}
3535
}
3636

37-
/// resolve app address by sni
37+
/// Resolve app address by sni, with wildcard domain fallback.
38+
///
39+
/// Lookup order:
40+
/// 1. `{prefix}.{sni}` — exact match (e.g. `_dstack-app-address.foo.myapp.com`)
41+
/// 2. (compat) `_tapp-address.{sni}` — legacy exact match
42+
/// 3. `{prefix}-wildcard.{parent}` — wildcard match (e.g. `_dstack-app-address-wildcard.myapp.com`)
3843
async fn resolve_app_address(prefix: &str, sni: &str, compat: bool) -> Result<AppAddress> {
39-
let txt_domain = format!("{prefix}.{sni}");
4044
let resolver = hickory_resolver::AsyncResolver::tokio_from_system_conf()
4145
.context("failed to create dns resolver")?;
4246

47+
// 1. Try exact match
48+
let mut domains = vec![format!("{prefix}.{sni}")];
4349
if compat && prefix != "_tapp-address" {
44-
let txt_domain_legacy = format!("_tapp-address.{sni}");
45-
let (lookup, lookup_legacy) = tokio::join!(
46-
resolver.txt_lookup(txt_domain),
47-
resolver.txt_lookup(txt_domain_legacy),
48-
);
49-
for lookup in [lookup, lookup_legacy] {
50-
let Ok(lookup) = lookup else {
51-
continue;
52-
};
53-
let Some(txt_record) = lookup.iter().next() else {
54-
continue;
55-
};
56-
let Some(data) = txt_record.txt_data().first() else {
50+
domains.push(format!("_tapp-address.{sni}"));
51+
}
52+
53+
// 2. Try wildcard fallback on parent domain
54+
if let Some((_, parent)) = sni.split_once('.') {
55+
domains.push(format!("{prefix}-wildcard.{parent}"));
56+
}
57+
58+
for domain in &domains {
59+
let Ok(lookup) = resolver.txt_lookup(domain.as_str()).await else {
60+
continue;
61+
};
62+
let Some(txt_record) = lookup.iter().next() else {
63+
continue;
64+
};
65+
let Some(data) = txt_record.txt_data().first() else {
66+
continue;
67+
};
68+
match AppAddress::parse(data) {
69+
Ok(addr) => {
70+
debug!("resolved app address via TXT {domain}");
71+
return Ok(addr);
72+
}
73+
Err(e) => {
74+
warn!("failed to parse app address from TXT {domain}: {e}");
5775
continue;
58-
};
59-
return AppAddress::parse(data).context("failed to parse app address");
76+
}
6077
}
61-
anyhow::bail!("failed to resolve legacy app address");
62-
} else {
63-
let lookup = resolver
64-
.txt_lookup(txt_domain)
65-
.await
66-
.context("failed to lookup app address")?;
67-
let txt_record = lookup.iter().next().context("no txt record found")?;
68-
let data = txt_record
69-
.txt_data()
70-
.first()
71-
.context("no data in txt record")?;
72-
AppAddress::parse(data).context("failed to parse app address")
7378
}
79+
80+
bail!("failed to resolve app address for {sni} (tried: {})", domains.join(", "));
7481
}
7582

7683
pub(crate) async fn proxy_with_sni(

0 commit comments

Comments
 (0)