tendns
A Rust library and DNS forwarder for encrypted TXT discovery and hexadecimal IPv4 names. The standalone binary supports UDP/TCP and HTTP/HTTPS listeners and DoH upstreams. Built on Hickory DNS. Licensed under MIT.
Library
[]
= { = "0.2", = false }
The library-only build contains the protocol, validated Policy, Resolver, and
Exchange transport interface. It does not include the CLI, TOML parser, DNS/HTTP
servers, Cloudflare client, or built-in network forwarder. The default cli
feature enables all standalone functionality, including cargo install tendns.
Construct a Policy from Discovery domain/key pairs, hex zones, a TTL cap, and a
publication-cache capacity. Domain canonicalization, overlap validation, and
most-specific key selection are shared with the standalone server. Configuration
formats and secret storage remain the embedding application's responsibility.
See the API documentation for a construction example.
Implement Exchange using your existing raw DNS transport, then create a resolver
with Resolver::new(Arc<Policy>, Arc<dyn Exchange>). The transport must validate
response association and perform UDP truncation fallback. Internal TXT fetches
must bypass the tendns wrapper to avoid recursion. Create one resolver per
upstream policy: do not share cached publications between split-horizon upstreams.
Policy::claimsidentifies protected/hex names before choosing local versus ordinary native resolution. It checks only the supplied name, without expanding search domains or NSS aliases. Unrelated names need not change their OS/NSS path.Resolver::answer_with_deadlineanswers DNS messages using one absolute Tokio deadline across cache waits, parent/direct lookups, and CNAME following. The same deadline reachesExchange::exchange_with_deadline; override that method when the underlying transport already supports deadlines. Packet-serving callers should useresolver::validate_requestfirst.Resolver::lookup_ipfollows private CNAMEs and selects address families usingIpStrategy. Its async fallback receives only names outside the configured namespaces and the same deadline, preserving the application's native resolver. Private misses, authentication failures, and expired publications never invoke that fallback. Expiry is checked again after a public CNAME target resolves.LookupErrordistinguishes NXDOMAIN-likeNotFoundfrom family-specificNoDataresults.
Deadlines enforced by the resolver and the default Exchange implementation
return errors identifiable with error.is::<tendns::TimeoutError>(), whether the
deadline has already elapsed or expires during the operation. Custom transports
may also return their own error types.
For strict namespace protection, pass absolute names and prevent the fallback
from searching or aliasing into protected namespaces. Unclaimed relative names
are passed through unchanged: a native search list could expand nas into
nas.example.com and resolve it outside tendns. Native NSS alias expansion is
likewise opaque to the library.
Do not wrap address results in a fixed-lifetime cache: it could outlive authenticated expiry. The resolver already caches publications with TTL/expiry checks. Recreate the policy/resolver when keys change. Admission limits and destination filtering (such as rejecting fake-IP ranges) belong to the embedding application.
The Rust library API changed in 0.2: Resolver::new takes Arc<Policy> instead of
the server's Arc<Config>. Standalone TOML, CLI commands, and the td1 wire format
are unchanged.
Build and run
Requires Rust 1.99.1 or newer and a C toolchain for AWS-LC.
CARGO_BUILD_JOBS=1
Use tendns.example.toml as the configuration reference. Set a fresh generated
key for each discovery domain. The example binds UDP and TCP on 0.0.0.0:5300.
Port 53 may require elevated binding privileges. Keeping the listener internal
is the operator's responsibility. UDP/TCP DNS has no authentication; HTTP/HTTPS
listeners support bearer tokens.
Use port 53 for clients whose resolver configuration cannot specify a port. For a quick check of the example's listener:
Protect configuration files containing keys with appropriate filesystem
permissions, such as mode 0600. tendns.toml is ignored by Git. Keys are
32 random bytes represented as unpadded base64url, not passwords.
Listeners and DoH
Listeners use typed TOML entries. This replaces the earlier listen = [...]
format. With no listeners specified, the default is UDP/TCP on 0.0.0.0:5300.
An explicit list can contain only HTTP/HTTPS listeners, without ordinary DNS.
[[]]
= "dns"
= "0.0.0.0:5300"
[[]]
= "http"
= "0.0.0.0:8053"
= "/dns-query"
= ["replace-with-a-random-token"]
[[]]
= "https"
= "0.0.0.0:8443"
= "/dns-query"
= true
= "server.crt"
= "server.key"
= ["another-random-token"]
= "tokens.txt"
HTTPS requires a PEM certificate chain and an unencrypted PEM private key (PKCS#8, PKCS#1, or SEC1). Certificates, keys, and token files load once at startup; restart to rotate them. Relative file paths resolve beside the TOML file. Invalid or unreadable configured files fail startup before listeners are advertised.
Authentication is independent per HTTP/HTTPS listener. auth_tokens and
auth_token_file are optional and additive: any token from either source is
accepted through Authorization: Bearer TOKEN. Files contain one token per line;
LF and CRLF are accepted and empty lines ignored. Tokens are case-sensitive,
contain no whitespace, and use the bearer-token alphabet (letters, digits,
-._~+/, and optional trailing =). Limits: 1024 tokens per listener, 1024 bytes
per token, 1 MiB per file. tendns keygen can generate suitable random tokens;
generate separate values rather than reusing discovery encryption keys.
An empty combined token list disables authentication and prints a warning for that listener. Missing or invalid credentials produce HTTP 401 with a Bearer challenge. DNS listeners reject auth fields. Protect token files like private keys. Plain HTTP exposes queries, answers, and bearer tokens: use HTTPS outside trusted networks. Authentication does not make an HTTP connection encrypted.
Each HTTP/HTTPS listener has an optional path, defaulting to /dns-query.
For example, path = "/private/resolve" moves both binary and JSON queries to
that endpoint. It must start with / and contain no query string, fragment, or
whitespace. Matching is exact, case-sensitive, and does not decode percent escapes
or redirect trailing slashes. DNS listeners reject path.
Plain HTTP serves HTTP/1.1 only. HTTPS serves HTTP/1.1 and, by default, HTTP/2
negotiated through ALPN. Set http2 = false on an HTTPS listener to disable
HTTP/2; HTTP and DNS listeners reject this setting. All listeners share the
existing resolver, cache, and global query concurrency/deadline limits:
- Binary (RFC 8484): GET
?dns=BASE64URLwith an unpadded DNS wire message, or POST a DNS wire message withContent-Type: application/dns-message. - JSON (common, non-standard DNS JSON format): GET
?name=example.com&type=A, with symbolic or numerictype(defaultA). Optionaldoandcdaccepttrue/falseor1/0. Accept: application/dns-messageorapplication/dns-jsonselects the response format; quality weights and wildcards are supported. WithoutAccept,namequeries default to JSON and wire queries to binary. JSON POST is not supported.- JSON includes
Status, DNS flags,Question,Answer,Authority, andAdditional; record data uses DNS presentation syntax, including RFC 3597 generic\# length hexfor SVCB, HTTPS, NULL, and unknown types. DNS failures are HTTP 200 with their DNS status. Malformed HTTP queries use HTTP error statuses.
Responses use Cache-Control: no-store to avoid shared HTTP caches retaining
private answers; DNS TTLs still apply. Request URIs are limited to 8 KiB, binary
POST bodies to 65535 bytes, and body reads to request_timeout (1s-24h). HTTP connections
and in-flight HTTP requests are each bounded by max_inflight per listener.
Connections are rotated after five minutes, with a short graceful-close window.
No CORS access or client-subnet parameter support is enabled.
Releases
The release-* branch builds and tests the same source as the v* release tag.
Dependencies use broad manifest constraints; Cargo.lock fixes the exact
release graph, and CI builds with --locked. Binary releases are distributed
through GitHub; the library and installable CLI are packaged for crates.io.
The release workflow packages and smoke-tests eight binaries: Linux x86_64
(GNU and musl), Linux aarch64 (GNU and musl), Linux armv7 (GNU and musl), and
macOS x86_64 and aarch64. Each archive contains tendns, this README, LICENSE, and
tendns.example.toml; the release includes SHA256SUMS and build-provenance
attestations. A tag creates a draft release after all builds pass. The draft is
published only after reviewing the artifacts.
Generate TXT values
The txt command is offline: it loads the same config as the server, selects
the most-specific discovery domain containing --name, and prints one encrypted
TXT value. No running server, DNS lookup, or provider API is needed.
The examples below assume tendns is on PATH; otherwise use target/release/tendns.
Publish the output as TXT at exactly home.example.com. A client using tendns
can then query home.example.com for A or AAAA. --a and --aaaa are repeatable.
The configured valid_for supplies the default validity when the flag is absent.
For a hidden child name:
Publish at example.com, not nas.example.com. The server answers A queries
for nas.example.com using that parent record without querying the child name
upstream when the parent entry is present and valid.
For multiple names in one publication:
--entry is repeatable and uses LABEL:TYPE:VALUE; @ selects the public TXT
owner. Labels contain at most 63 ASCII letters/digits/hyphens, not dots.
Internationalized names must use Punycode. --cname target.example.net is a
shortcut for an alias at the owner or the current --subdomain. An alias cannot
coexist with another entry at the same resulting hostname.
The value begins td1. and uses AES-256-GCM-SIV with a fresh random IV and an
authentication tag. It is bound to the public TXT owner: moving it to a different
name will not work. The CLI prints only the value on stdout; errors use stderr.
Values longer than 255 bytes must be split into adjacent character-strings
within one TXT record, with no inserted spaces in the data. Do not publish
the fragments as separate records. The server concatenates strings per RR.
Publish with Cloudflare
cloudflare-set and cloudflare-remove use the same TOML discovery keys and a
native HTTPS client. Set CLOUDFLARE_API_TOKEN to a token with Zone Read and
DNS Write permissions, scoped to the relevant zones. The commands discover
the most-specific accessible Cloudflare zone containing the TXT owner. No zone
ID, provider credentials in TOML, or external curl executable is required.
Here --name is the hostname clients resolve, unlike txt --name, which
specifies the public TXT owner. By default these commands edit TXT at the
immediate parent (example.com) and keep the child label (nas or www)
encrypted. --direct edits TXT at the exact hostname with an empty hidden label.
At a configured discovery domain's apex, --direct is required: publishing in
its parent would cross the key boundary. A direct set is refused when an existing
parent entry shadows it or the parent TXT lookup would fail validation.
Set reuses the owner's existing td1 TXT: decrypt, edit the array, re-encrypt,
and PATCH the same Cloudflare record. It creates a TXT record only when none
contains an encrypted publication. Other TXT records and Cloudflare comments/tags
are left untouched; hidden labels are never written into provider metadata.
Entries are keyed by hostname and type. A missing type is appended; setting an
existing type replaces all its values while preserving other types and hostnames.
--a and --aaaa are repeatable for multiple addresses, but one invocation sets
only one type. CNAME/address conflicts are errors, not implicit deletions.
Remove deletes all values of the selected hostname/type, preserving the original
publication timestamps and DNS TTL; it deletes the TXT record only if empty.
An absent entry is reported as an error. Placement is not migrated automatically:
removing a hidden entry can expose a previously shadowed direct entry.
Set refreshes validity for every entry in that encrypted TXT, including
unchanged entries. Use --valid-for to override the configured duration. Expired
but authenticated publications can be refreshed; unreadable ciphertext and
multiple encrypted records at one owner are refused without changes. Consolidate
multiple publications manually before managing that owner with these commands.
Set applies the configured ttl, which must be 60–86400 seconds or 1 for
Cloudflare Auto (normally 300 seconds). Values are split into quoted TXT strings;
provider size limits and the resolver's record limits are checked before writing.
Run only one writer per TXT owner. Cloudflare does not provide a documented conditional update for this read-modify-write operation; concurrent commands can lose updates. Redirects and automatic retries are disabled. After a timeout or other uncertain write result, check the provider state before retrying. These commands do not schedule renewals or detect address changes.
Lookup behavior
For a discovery query such as a.example.com, tendns first checks TXT at its
immediate parent for a hidden a entry, then checks TXT at the queried name for
an entry without a subdomain. Parent entries win, including when they lack the
requested address family. Only one hidden label is supported. Parent lookup
does not cross the selected discovery domain's boundary.
A timeout, invalid ciphertext, unsupported encrypted version, or expired publication is an error, not permission to try the child. Confirmed absence does permit a direct TXT lookup, which exposes the queried name upstream. For both locations, absence produces NXDOMAIN; an existing name without the requested type produces NODATA. There is no fallback to plaintext A/AAAA inside a discovery domain. CNAME targets are followed with loop/depth bounds. An owner containing only hidden children, and the hex-zone apex, return NODATA rather than NXDOMAIN so downstream caches do not suppress their children.
Multiple encrypted TXT values contribute independent entries. Duplicate entries
are deduplicated; CNAME/address conflicts at the same hostname are errors.
Publish independent td1. values as separate TXT records, not strings within one RR.
Timestamps validate freshness, not replacement order. Replace or remove old TXT
values explicitly when updating. An invalid or expired encrypted value fails the
lookup, including when other values are valid: it might contain the hidden label
being queried. Publication is operator-triggered; regenerate and republish before expiry,
including when addresses are unchanged.
For a configured hex zone, c0000201.hex.example.net resolves to 192.0.2.1.
Exactly one eight-character ASCII hexadecimal label is accepted, including
uppercase. All IPv4 addresses are permitted, including private and loopback
addresses. AAAA and other unsupported types return NODATA; invalid names within
the zone return NXDOMAIN. Hex and discovery zones cannot overlap.
Names outside configured zones are forwarded. Plain upstreams use UDP with TCP fallback. DoH uses certificate-verified HTTPS/HTTP2 with explicit bootstrap IPs, so starting the proxy does not depend on the system DNS resolver. Upstreams are tried in configured order. Plain fallback exists only when explicitly configured. Transfers, updates, TSIG, and multi-question requests are not supported.
Discovery caching is bounded and never extends the source TTL or authenticated expiry. Concurrent requests for the same cached owner share a fetch. Generic forwarding uses upstream caches rather than adding another response cache. Synthetic answers do not assert DNSSEC validation. Operators using downstream DNSSEC validation must configure the local namespaces accordingly.
Security limits
Internal clients receive plaintext addresses through ordinary DNS. Anyone with the configured key can decrypt and create publications for its domain. Public TXT owners, query patterns, ciphertext sizes, and update timing remain visible. Hex labels directly disclose the encoded address. Service authentication and network access control remain separate from discovery.
There is no persistent replay state. Authentic older values may be accepted
until expiry; a reasonably correct clock is required. SIV is a safety net against
accidental nonce reuse, not replay protection. Removing a TXT record does not
erase addresses clients already learned. Configuration keys are separate from
provider API credentials. Only the Cloudflare publishing commands need a provider
token, read from CLOUDFLARE_API_TOKEN; the DNS server does not.
Check
CARGO_BUILD_JOBS=1
CARGO_BUILD_JOBS=1
Tests use local DNS and HTTPS fixtures; they do not require a public resolver. The server exits gracefully on SIGINT or SIGTERM.