Skip to content

Feat/split transport - #2

Merged
sneurlax merged 22 commits into
cypherstack:mainfrom
sneurlax:feat/split-transport
Oct 14, 2026
Merged

sneurlax merged 22 commits into
cypherstack:mainfrom
sneurlax:feat/split-transport

Conversation

@sneurlax

Copy link
Copy Markdown
Member

No description provided.

slopbot9k and others added 22 commits October 13, 2026 10:19
A queued request started from _release ran inside the finishing lane's
zone, so each new lane's zone was parented on its predecessor. The
predecessor's error handler captures its lane, keeping every retired
HttpClient and ID bitmap reachable under sustained queued load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RFC 8484 §5.1 requires DoH clients to reduce DNS TTLs by the response's
HTTP Age. IoDohTransport now parses Age per RFC 9111 §5.1: the first
member of a list-valued or repeated header, delta-seconds only, leading
zeros allowed, saturating at 2^31. It is carried on DohHttpResponse.age,
and DohResolver subtracts it from JSON answer TTLs, floored at zero,
keeping it on DnsResponse.age.

RFC 2181 §8 treats a received TTL with the most significant bit set as
zero. JSON answers and issued local evidence previously kept values up
to 0xffffffff as positive lifetimes.

Wire bodies are unchanged; callers must apply the age themselves.

BREAKING CHANGE: TTLs of 2^31 or more are now reported as 0, and
DohHttpResponse and DnsResponse gain an age field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JSON permits unpaired surrogate escapes, and utf8.encode replaced them
with U+FFFD, so the decoded octets differed from what the resolver sent.
Reject them as malformed instead.

DnsTxtRecord also accepted any int, so caller-built evidence passed to
DnsValidationIssuer.issueTxt could carry values outside 0-255. Validate
in the constructor so every record holds real TXT octets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DnsException was one class carrying a DnsError enum code, plus a
responseCode that only meant something for two codes. It is now a
sealed base class with a final subtype per failure, so callers can catch
one case with an on-clause or switch exhaustively. CNAME failures share
a sealed DnsAliasException parent. DnsQueryFailedException carries the
DNS rcode and DnsTransportException an optional httpStatus. The unused
noRecords code is gone; an authenticated empty answer still succeeds
with no records.

Errors also reached callers two ways: invalid names threw from
startTxtLookup and invalid wire queries or URIs threw ArgumentError,
while a closed transport or full queue failed the returned future.
Starting an operation now never throws; all of these complete
DnsOperation.result. The new DnsInvalidQueryException covers unusable
wire queries and request URIs, including from validateDnsWireResponse
and padDnsWireQuery. ArgumentError remains for invalid constructor
arguments only.

Every lookup now completes. The response handler caught only
DnsException and FormatException while its finally block cancelled the
deadline, so any other error left the lookup pending forever; it now
maps to DnsMalformedResponseException. A custom transport that throws
from start() fails the lookup instead of escaping. The resolver also
stops cancelling transport operations that have already completed, so
transports no longer need cancel() to be harmless after completion.

BREAKING CHANGE: DnsError and DnsException.code/responseCode are
removed in favour of DnsException subtypes; start methods no longer
throw; invalid wire queries raise DnsInvalidQueryException, not
ArgumentError.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RFC 8484 §4.1 says DoH clients SHOULD use DNS ID 0, but a connection
refused any ID it had already carried, so every ID 0 query after the
first opened a new connection and reuse never happened. The transport
now gives each exchange a random ID unique on its connection, validates
the reply against the query as sent, and restores the caller's ID in the
returned body. A stale reply on a reused connection still fails the ID
check rather than answering the next request. A connection retires once
half its ID space is used, keeping random selection to about two draws.

SIG(0) (RFC 2931 §3.1) and TSIG sign the header, so queries carrying
either in their additional section are sent byte-for-byte with their own
ID, which likewise never repeats on a connection. Padding already skipped
them because the signature, not OPT, is the last record.

A SIG(0)-signed response to an unsigned query (RFC 2931 §2.4) covers the
response header and the request as sent, so startWire also takes
preserveMessage: the query goes out unpadded with its own ID and the
response is returned exactly as received.

Lookups sharing a TLS connection reach the resolver together, so it can
link them, and through SOCKS they also share one stream, defeating
per-lookup isolation such as SocksCredentials.isolation. Reuse now
defaults to on only for direct transports.

The concurrency test assumed requests reached the server in start order,
but concurrent TLS handshakes race, so it intermittently answered the
wrong request and timed out. It now keys held requests by question name.

BREAKING CHANGE: proxied transports no longer reuse connections unless
reuseConnections: true is passed, servers see transport-chosen IDs unless
the message is preserved, and DohWireTransport.startWire gains a
preserveMessage parameter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An endpoint such as https://resolver.example/resolve?x=%FF passed
construction, but startTxtLookup read endpoint.queryParameters on every
call and threw FormatException before returning an operation, breaking
the guarantee that starting never throws. The parameters are now decoded
once in the constructor, which rejects undecodable endpoints with
ArgumentError.

queryParameters also kept only the last value of a repeated key, so
tag=first&tag=second was sent as tag=second. queryParametersAll keeps
every value, while the resolver's own keys still override.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DoH JSON APIs encode TXT data differently: Google returns the text
verbatim, Cloudflare returns quoted character-strings with escapes. The
decoder guessed from a leading quote, so a Google TXT value starting
with " was misparsed or rejected. A value cannot reveal its own form, so
DohResolver now takes a DnsTxtFormat (raw or quoted), defaulting for
known Google and Cloudflare hosts and required for any other endpoint.
The quoted format accepts only quoted character-strings, which is what
Cloudflare emits, and requires at least one, so empty or blank data is
malformed while "" stays valid. A live test checks both providers decode identically.

BREAKING CHANGE: decodeDnsTxtBytes and DnsResponse.authenticatedTxt
take a DnsTxtFormat, and DohResolver requires txtFormat for endpoints
other than Google and Cloudflare.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Compression pointers may only target names the parser has walked, but it
skipped the RDATA of types it did not know, so a later name pointing
into an HTTPS TargetName or an IPSECKEY gateway was rejected while the
uncompressed equivalent was accepted. The parser now walks the embedded
names of NSAP-PTR, IPSECKEY, HIP, TALINK, SVCB and HTTPS, DSYNC, LP, TKEY,
TSIG and AMTRELAY. Those names must themselves be
uncompressed, as RFC 3597 requires for newer types, and the existing
backward-only pointer rules are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Wire bodies are returned as received, leaving every caller to apply the
HTTP Age and the RFC 2181 high-bit rule itself. effectiveDnsTtl applies
both to one TTL and now backs the JSON path too, so the rule lives in
one place. adjustDnsWireTtls returns a copy of a wire response with every
record TTL adjusted, skipping OPT, whose TTL field holds EDNS flags.
RRSIG validation is unaffected because it uses the Original TTL, but
SIG(0) and TSIG transaction signatures cover the whole message, so they
must be verified before adjusting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tool/check.sh needed a POSIX shell, so it failed from cmd or PowerShell.
tool/check.dart runs the same steps (pub get, format, analyze, test,
publish dry-run) through the running Dart VM, so no PATH or .bat lookup
is involved; it streams their output, works from any directory, and
exits with the first failing step's code. The example now runs from a
test, so dart test alone catches a broken example.

CI only ran on Ubuntu, but the transport depends on dart:io sockets, TLS
and timing. It now runs the checker on Linux, macOS and Windows for the
minimum and stable SDKs, without fail-fast, and .gitattributes pins LF
endings so Windows checkouts do not trip dart format or byte-exact
tests. lints moves to ^5.0.0, the newest release supporting Dart 3.5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds dartdoc to every public declaration, prioritising trust,
cancellation, limits and lifecycle, and validated with dart doc. The
README is reorganised around the trust model (including checking a
DnsValidator by identity, since anyone can construct an issuer),
per-provider JSON behaviour, transport limits, deadlines, connection
reuse and shutdown, the error and cancellation contract, and TTL
handling. The unreleased 0.0.1 changelog entry now describes the
package as it stands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t slot

Uri.parse accepts any digit string as a port and only throws when the
port is read. IoDohTransport read it in _origin after creating the
request and incrementing _active, so an endpoint such as
https://dns.google:99999999999999999999/resolve threw a synchronous
FormatException from start, left a request whose deadline later failed
with a null-check error, and leaked a concurrency slot: with
maxConcurrentRequests 1 every later lookup timed out.

isUsableDohEndpoint now checks the scheme, host, user info, fragment
and a readable port in 1-65535 before any state exists, and _start fails
the operation with DnsInvalidQueryException. DohResolver applies the same
check at construction so such an endpoint cannot make startTxtLookup
throw either.
_name re-walked every backward pointer chain for each name it decoded
and only remembered which offsets were valid targets. A valid message
near the 65,535-octet limit can build progressively longer chains, so
decompression was quadratic: one 60 KB reply cost about 11.9 million
byte reads on the isolate that also runs deadlines and UI work.

Each decoded name now caches the suffix starting at every label it
owns, as views into one buffer, and a pointer appends that suffix in one
step. Targets must still be the start of a label in a name decoded
before the current one began, so loops stay impossible and the accepted
set of messages is unchanged. compression_budget_test counts byte reads
rather than time so the regression is deterministic.
…ception

Sealing DnsException gave callers exhaustive switches but closed every
subtype, and dnssec_resolver reports its bogus and indeterminate results
through a DnsException subclass. It can no longer compile against this
package.

DnsAuthenticationUnavailableException is now a base class, the one open
extension point: a local validator subclasses it to say why it found no
secure answer, and a switch over DnsException still sees that as
authentication being unavailable. Every other subtype stays final.
Reuse is off behind a proxy so a long-lived transport cannot let the
resolver link unrelated lookups. A transport created per lookup with
SocksCredentials.isolation gains nothing from that default: its queries
already share one circuit, and a wire lookup makes several requests, so
each costs a new Tor stream and TLS handshake. Measured on such a
transport, reuse cut a Tor lookup from 7.2 s to 4.9 s. Say so next to
the default.
Move the DNS wire builders covered by test/support/LICENSE into doh_transport under the package MIT LICENSE.
@sneurlax
sneurlax merged commit 03b9554 into cypherstack:main Oct 14, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants