@propgate/dns › The resolver
The resolver
query sends one DNS message and returns what happened. It is built on
node:dgram and node:net directly rather than node:dns; see
the overview for why that trade was made.
import { query, RecordType } from "@propgate/dns";
const outcome = await query({
target: { address: "8.8.8.8", port: 53 },
name: "example.com",
type: RecordType.A,
});
if (outcome.status === "answered") {
console.log(outcome.message.answers.length, "records over", outcome.transport);
}Outcomes are values, not throws
A timeout, a refusal, and a mangled response are observations about a
domain's DNS, not exceptional conditions in your program. query returns a
discriminated union on status rather than throwing, because a single
catch around a DNS call cannot tell "the server was slow" from "the record
does not exist". Collapsing those two is exactly how a resolver ends up
reporting "not found" for a domain that was merely unlucky this millisecond.
Status | Carries | Means |
|---|---|---|
answered | message, transport, retriedOverTcp, elapsedMs | A decoded response arrived. |
timeout | transport, timeoutMs, elapsedMs, retriedOverTcp | Nothing arrived before the deadline. |
unreachable | transport, code, detail, elapsedMs | Connection refused, host unreachable, network down. |
malformed | transport, reason, offset, detail, elapsedMs | Bytes arrived and did not decode as a DNS message. |
truncated | message, transport ("udp"), elapsedMs | The UDP answer had TC set and
|
import { query, RecordType } from "@propgate/dns";
const outcome = await query({
target: { address: "8.8.8.8", port: 53 },
name: "example.com",
type: RecordType.A,
});
// Every branch is something a caller can act on. A single catch around this
// would have to guess which of these five actually happened.
switch (outcome.status) {
case "answered":
console.log("answered over", outcome.transport, "in", outcome.elapsedMs, "ms");
break;
case "timeout":
console.log(outcome.retriedOverTcp ? "TCP retry never came back" : "no answer in time");
break;
case "unreachable":
console.log(outcome.code, outcome.detail);
break;
case "malformed":
console.log("could not decode:", outcome.reason, "at byte", outcome.offset);
break;
case "truncated":
console.log("truncated over UDP, not retried:", outcome.message.flags.tc);
break;
}The one field worth pausing on is retriedOverTcp on timeout. It is only
knowable at the moment of the retry: a bare TCP timeout means the server
might be dead, but a TCP timeout that follows the same server already
answering over UDP means the server is alive and something between you and
it is eating TCP specifically. Without carrying that distinction forward,
both cases look identical to a caller, which is the whole reason
TCP_SILENTLY_BLOCKED sat unemitted before this field existed.
Everything is port-aware
Addresses are { address, port, transport }, and port is never assumed to
be 53:
import { query, RecordType } from "@propgate/dns";
// Nothing here assumes port 53. A resolver container, a split-horizon test
// rig, or a nameserver running on an alternate port is the same call.
const outcome = await query({
target: { address: "127.0.0.1", port: 8053, transport: "udp" },
name: "example.com",
type: RecordType.A,
});That is not a convenience for tests. Root hints are injectable through
ResolverOptions, and a glue record carries an address but no port. A
resolver that hardcodes 53 anywhere in the delegation-following path breaks
the moment it is pointed at a fixture tier serving real port 53 on
non-default loopback addresses, and hardcoding a high port instead breaks
production.
Truncation and the TCP fallback
By default, a truncated UDP answer is retried over TCP automatically, which is
required behaviour for a resolver to see a 4096-bit DKIM key at all. Pass
retryOverTcp: false to observe the TC bit instead of resolving past it:
import { query, RecordType } from "@propgate/dns";
// Omitting ednsBufferSize sends no OPT record at all, which caps the answer
// at 512 bytes by RFC 1035 — the only way to drive truncation from the
// client rather than by tuning the server.
const truncated = await query({
target: { address: "198.51.100.1", port: 53 },
name: "big-key._domainkey.example.com",
type: RecordType.TXT,
retryOverTcp: false,
});
if (truncated.status === "truncated") {
console.log("TC bit set:", truncated.message.flags.tc);
}Omitting ednsBufferSize entirely (rather than passing even a small value)
is what sends no OPT record and caps the response at 512 bytes by RFC 1035.
It is the only lever that drives truncation from the client side rather than
by tuning what the server sends.
A swallowed TCP retry, where the server answers over UDP, asks for TCP, and
the TCP connection then produces nothing, is not a dead server. It is a
middlebox blocking TCP/53, and it is exactly what retriedOverTcp exists to
distinguish from an ordinary timeout:
import { DiagnosisCode, query, RecordType } from "@propgate/dns";
// By default a truncated UDP answer is retried over TCP automatically.
const outcome = await query({
target: { address: "198.51.100.1", port: 53 },
name: "big-key._domainkey.example.com",
type: RecordType.TXT,
});
// retriedOverTcp on a timeout means the *retry* is what timed out, not the
// first exchange — this server already answered over UDP and asked for TCP,
// which means it is alive. A bare TCP timeout can't tell you that; this can.
// It is exactly the shape the evaluators report as TCP_SILENTLY_BLOCKED.
if (outcome.status === "timeout" && outcome.retriedOverTcp) {
console.log(DiagnosisCode.TCP_SILENTLY_BLOCKED, "— alive over UDP, silent over TCP");
}To everyone else this looks like an intermittent outage: the record is fine, the server is fine, and a large DKIM key simply never arrives. See the evaluators for where this reasoning is built in rather than left to the caller.
Next
The evaluators build the six semantic checks on top of
this: runChecks, a DomainProfile, and one subsection per check kind.