// Async Native DNS stub resolver core.
//
// The published root package is Native-only because it imports the async
// socket/TLS runtime. Tests and Native embedders can inject a DnsTransport
// without opening a socket.
///|
pub struct Resolver {
transport : DnsTransport
cache : DnsCache
recurse : Bool
max_cname_depth : Int
clock : () -> Int64
}
///|
pub(all) struct ResolveOptions {
server : String
timeout_ms : Int
retries : Int
recurse : Bool
max_cname_depth : Int
}
///|
pub fn ResolveOptions::default() -> ResolveOptions {
{
server: "8.8.8.8:53",
timeout_ms: default_timeout_ms,
retries: default_retries,
recurse: true,
max_cname_depth,
}
}
///|
/// Construct resolver options for callers outside this package. The fields of
/// public structs are read-only across MoonBit package boundaries, so exposing
/// this constructor makes server, timeout, and retry configuration available
/// to the CLI and embedding applications.
pub fn ResolveOptions::new(
server~ : String,
timeout_ms? : Int = default_timeout_ms,
retries? : Int = default_retries,
recurse? : Bool = true,
max_depth? : Int = max_cname_depth,
) -> ResolveOptions {
{ server, timeout_ms, retries, recurse, max_cname_depth: max_depth }
}
///|
pub(all) struct DnsResult {
answers : Array[RR]
cname_chain : Array[String]
}
///|
pub enum ResolveError {
FormatError(String)
ResponseMismatch(String)
RandomnessUnavailable
FormErr
NXDomain
NoData
Referral
ServFail
Refused
NotImplemented
ExtendedRcode(Int)
Timeout
TruncatedAndTcpFailed
ConnectionFailed(String)
CnameLoop(String)
} derive(Debug, Eq)
///|
let resolver_clock_origin : Int64 = @async.now()
///|
let resolver_clock_last : Ref[Int64] = Ref(0L)
///|
fn resolver_now_ms() -> Int64 {
// Keep an Int64 elapsed process-local clock. This avoids the ~24.855-day
// overflow of a 32-bit millisecond counter and clamps runtime regressions so
// TTL evaluation never moves backwards.
let elapsed = @async.now() - resolver_clock_origin
if elapsed > resolver_clock_last.val {
resolver_clock_last.val = elapsed
}
resolver_clock_last.val
}
// A DNS message ID is a transaction-security field. Native builds obtain it
// from OpenSSL RAND_bytes; failure is explicit instead of degrading to a
// predictable timestamp or counter.
///|
#warnings("-alert_internal")
fn random_id() -> Result[UInt16, ResolveError] {
try {
let bytes = @tls.rand_bytes(2)
Ok(((bytes[0].to_int() << 8) | bytes[1].to_int()).to_uint16())
} catch {
_ => Err(RandomnessUnavailable)
}
}
///|
pub fn Resolver::new(
options? : ResolveOptions = ResolveOptions::default(),
) -> Resolver {
let transport = FallbackTransport::with_options(
options.server,
options.timeout_ms,
options.retries,
).as_transport()
{
transport,
cache: DnsCache::new(),
recurse: options.recurse,
max_cname_depth: options.max_cname_depth,
clock: resolver_now_ms,
}
}
// Create a resolver with a caller-supplied transport. This is the primary
// extension point for Native adapters and deterministic tests.
///|
pub fn Resolver::with_transport(
transport~ : DnsTransport,
options? : ResolveOptions = ResolveOptions::default(),
) -> Resolver {
{
transport,
cache: DnsCache::new(),
recurse: options.recurse,
max_cname_depth: options.max_cname_depth,
clock: resolver_now_ms,
}
}
// Like `with_transport`, but with a caller-controlled monotonic millisecond
// source. The default clock uses runtime time; this constructor makes TTL
// expiry and LRU tests deterministic without sleeping.
///|
pub fn Resolver::with_transport_and_clock(
transport~ : DnsTransport,
clock~ : () -> Int,
options? : ResolveOptions = ResolveOptions::default(),
) -> Resolver {
Resolver::with_transport_and_clock64(
transport~,
clock=() => clock().to_int64(),
options~,
)
}
///|
/// Construct a resolver with a caller-controlled Int64 monotonic millisecond
/// source. Use this API for long-running processes and tests that cross the
/// 32-bit millisecond boundary. The older Int clock constructor remains as a
/// compatibility wrapper.
pub fn Resolver::with_transport_and_clock64(
transport~ : DnsTransport,
clock~ : () -> Int64,
options? : ResolveOptions = ResolveOptions::default(),
) -> Resolver {
{
transport,
cache: DnsCache::new(),
recurse: options.recurse,
max_cname_depth: options.max_cname_depth,
clock,
}
}
///|
pub fn Resolver::cache_stats(self : Resolver) -> CacheStats {
self.cache.stats()
}
///|
fn cached_result_for_tracker(
cached : DnsResult,
tracker : CnameTracker,
) -> Result[DnsResult, ResolveError] {
// Cached chains are relative to their cache key and therefore start with
// the name already present at the end of the current tracker. Replay only
// subsequent aliases through the tracker so cache hits preserve loop and
// maximum-depth enforcement as well as provenance.
for i in 1.. ()
Err(reason) => return Err(CnameLoop(reason))
}
}
Ok({ answers: cached.answers, cname_chain: tracker.get_chain() })
}
///|
fn relative_cache_result(
result : DnsResult,
entry_chain_length : Int,
) -> DnsResult {
let relative_chain : Array[String] = []
let start = if entry_chain_length > 0 { entry_chain_length - 1 } else { 0 }
for i in start.. Result[DnsResult, ResolveError] {
self.resolve_with_tracker(
name,
qtype,
CnameTracker::new(name, max_depth=self.max_cname_depth),
)
}
///|
async fn Resolver::resolve_with_tracker(
self : Resolver,
name : String,
qtype : UInt16,
tracker : CnameTracker,
) -> Result[DnsResult, ResolveError] {
let entry_chain_length = tracker.get_chain().length()
let now_ms = (self.clock)()
match self.cache.lookup_ms64(name, qtype, now_ms) {
Positive(cached) =>
match cached_result_for_tracker(cached, tracker) {
Ok(result) => return Ok(result)
Err(error) => return Err(error)
}
Negative(NXDomain, _) => return Err(NXDomain)
// NOERROR/NODATA is distinct from NXDOMAIN, but it remains a negative
// result. Never flatten a negative-cache hit into an empty success.
Negative(NoData, _) => return Err(NoData)
Miss => ()
}
let message_id = match random_id() {
Ok(id) => id
Err(error) => return Err(error)
}
// Advertise the payload capacity of the selected transport. This makes
// EDNS(0) part of every resolver request rather than an unused codec helper;
// the default native transport advertises 1232 bytes.
let opt = {
..OptRR::default(),
udp_payload_size: self.transport.max_payload(),
}
let query = match
build_query_with_edns_checked(message_id, name, qtype, self.recurse, opt) {
Ok(value) => value
Err(error) => return Err(FormatError(error))
}
let query_bytes = match query.encode_checked() {
Ok(value) => value
Err(error) => return Err(FormatError(error))
}
let response_bytes = match self.transport.send(query_bytes) {
Ok(bytes) => bytes
Err(error) => return Err(map_transport_error(error))
}
let response = match decode_message(response_bytes) {
Ok(message) => message
Err(error) => return Err(FormatError(error))
}
match validate_response(response, message_id, name, qtype) {
Ok(_) => ()
Err(error) => return Err(error)
}
match rcode_error(response) {
Ok(_) => ()
Err(NXDomain) => {
match negative_cache_ttl_for_query(response, name, self.max_cname_depth) {
Some(ttl) =>
self.cache.put_negative_ms64(name, qtype, NXDomain, ttl, now_ms)
None => ()
}
return Err(NXDomain)
}
Err(error) => return Err(error)
}
if response_is_referral(response) {
return Err(Referral)
}
// A CNAME query returns the CNAME RR itself. Other qtypes follow only a
// CNAME whose owner is the current queried name, never an unrelated answer.
if qtype == qtype_cname {
let answers = records_for_question(response.answers, name, qtype_cname)
let result = DnsResult::{ answers, cname_chain: tracker.get_chain() }
if result.answers.length() == 0 {
match extract_negative_ttl(response) {
Some(ttl) =>
self.cache.put_negative_ms64(name, qtype, NoData, ttl, now_ms)
None => ()
}
return Err(NoData)
} else {
self.cache.put_positive_ms64(
name,
qtype,
relative_cache_result(result, entry_chain_length),
min_answer_ttl(result.answers),
now_ms,
)
}
return Ok(result)
}
let current_name = Ref(name)
let cname_ttl = Ref(-1L)
for ;; {
let terminal = records_for_question(
response.answers,
current_name.val,
qtype,
)
if terminal.length() > 0 {
let terminal_ttl = min_answer_ttl(terminal)
let ttl = if cname_ttl.val < 0 {
terminal_ttl
} else {
min_ttl(cname_ttl.val, terminal_ttl)
}
let result = DnsResult::{
answers: terminal,
cname_chain: tracker.get_chain(),
}
self.cache.put_positive_ms64(
name,
qtype,
relative_cache_result(result, entry_chain_length),
ttl,
now_ms,
)
return Ok(result)
}
match cname_target_for_name(response.answers, current_name.val) {
None => break
Some(target) => {
match tracker.follow(target) {
Ok(_) => ()
Err(reason) => return Err(CnameLoop(reason))
}
match cname_ttl_for_name(response.answers, current_name.val) {
Some(ttl) =>
cname_ttl.val = if cname_ttl.val < 0 {
ttl
} else {
min_ttl(cname_ttl.val, ttl)
}
None => ()
}
current_name.val = target
}
}
}
// The response can contain only the first CNAME hop; recurse with the same
// tracker so cycles such as A -> B -> A are rejected globally.
if !dns_name_equal(current_name.val, name) {
let followed = match
self.resolve_with_tracker(current_name.val, qtype, tracker) {
Ok(result) => result
Err(NoData) => {
// The target's authority SOA supplies its exact negative TTL. The alias
// cache must not outlive either its own CNAME TTL or the target's
// remaining negative-cache deadline.
let target_ttl = match
self.cache.remaining_ttl_seconds_ms64(current_name.val, qtype, now_ms) {
Some(value) => value
// The recursive target response had no cacheable SOA lifetime. Do
// not invent one for the alias, or the alias could outlive proof of
// the negative result.
None => return Err(NoData)
}
let ttl = if cname_ttl.val < 0 {
target_ttl
} else {
min_ttl(cname_ttl.val, target_ttl)
}
self.cache.put_negative_ms64(name, qtype, NoData, ttl, now_ms)
return Err(NoData)
}
Err(error) => return Err(error)
}
let ttl = if cname_ttl.val < 0 {
min_answer_ttl(followed.answers)
} else {
min_ttl(cname_ttl.val, min_answer_ttl(followed.answers))
}
self.cache.put_positive_ms64(
name,
qtype,
relative_cache_result(followed, entry_chain_length),
ttl,
now_ms,
)
return Ok(followed)
}
// NOERROR with no requested records is NODATA, not NXDOMAIN. Cache it with
// RFC 2308 TTL and preserve it as a negative resolver result.
match extract_negative_ttl(response) {
Some(ttl) => self.cache.put_negative_ms64(name, qtype, NoData, ttl, now_ms)
None => ()
}
Err(NoData)
}