Skip to content
homerouterPublic
forked from cfal/tendns

About

A Rust library and DNS forwarder for encrypted TXT discovery and hexadecimal IPv4 names.

Resources

Stars

0 stars

Watchers

0 watching

Forks

 
 

Repository files navigation

tendns

A Rust library and DNS forwarder for dynamic discovery through encrypted DNS TXT records. Use public subdomain names for recognizable endpoints and tracking, or hide child labels inside a parent's encrypted publication. Both modes encrypt addresses; hidden-label privacy has limits.

Hexadecimal IPv4 names also let VPN/proxy applications route a domain suffix through a particular tunnel while selecting an exact internal destination, such as 0a14001e.via-vpn.internal for 10.20.0.30. Routing belongs to the host application; hex names are not encrypted.

Embed the library in an existing resolver or run the binary with UDP/TCP and HTTP/HTTPS listeners and DoH upstreams. Built on Hickory DNS. MIT licensed.

Embed | Run | Publish | Design and protocol | Tests

Library

[dependencies]
tendns = { version = "0.2", default-features = 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. Both builds require Rust 1.99.1 or newer and a C toolchain for AWS-LC. Async use requires a Tokio runtime with its time driver enabled. Use the re-exported tendns::hickory_proto types to avoid mismatched Hickory crate versions.

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 an adapter and lookup example, and embedding contracts for the complete integration requirements. The library also exposes protocol::{Secret, Publication, encrypt} for in-process publishing. For 0.1 callers migrating to 0.2, Resolver::new now takes Arc<Policy> instead of Arc<Config>; the td1 wire format is unchanged.

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::claims identifies 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_deadline answers DNS messages using one absolute Tokio deadline across cache waits, parent/direct lookups, and CNAME following. The same deadline reaches Exchange::exchange_with_deadline; override that method when the underlying transport already supports deadlines. Packet-serving callers should use resolver::validate_request first.
  • Resolver::lookup_ip follows private CNAMEs and selects address families using IpStrategy. 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. LookupError distinguishes NXDOMAIN-like NotFound from family-specific NoData results.

The Then family strategies return only the preferred family when available, otherwise the other family; Ipv4AndIpv6 keeps both. This also filters fallback results. Inspect negative results with error.downcast_ref::<tendns::LookupError>(). Deadlines enforced by the resolver and the default exchange_with_deadline method return errors identifiable with error.is::<tendns::TimeoutError>(). Custom transports and native fallback may return their own error types. Resolver::with_clock supports injected Unix time for expiry tests; TTL aging and deadlines still use Tokio's monotonic clock.

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. Public CNAME chains followed inside an upstream/native resolver are also opaque: tendns does not intercept their intermediate targets.

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.

Build and run

Requires Rust 1.99.1 or newer and a C toolchain for AWS-LC.

CARGO_BUILD_JOBS=1 cargo build --release
target/release/tendns keygen
target/release/tendns --config tendns.toml serve

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:

dig @127.0.0.1 -p 5300 c0000201.hex.example.net A
dig @127.0.0.1 -p 5300 example.net A +tcp

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. The server exits gracefully on SIGINT or SIGTERM.

Configuration

See tendns.example.toml for namespace keys, upstreams, and listeners. Top-level settings:

Setting Default Meaning and bounds
ttl 60 Positive seconds; synthesized/negative-cache TTL cap and Cloudflare Set's requested TXT TTL
valid_for "24h" Publication validity; at least 1s, no fixed maximum validity window
upstream_timeout "3s" Per upstream connection attempt; at least 1s
request_timeout "10s" Resolution budget; 1s-24h; also used separately for HTTP transport timers
cache_capacity 1024 Resident publication-cache owner slots; 1-65536, not a byte limit or LRU
max_inflight 256 Shared resolver admission limit; 1-65536; also per-listener HTTP connection/request limits

Resolver admission is immediate: overload returns SERVFAIL rather than queueing. At least one upstream is required for serve, including hex-only configurations; offline TXT generation does not require one. Upstreams must provide interchangeable DNS views: they are a failover list, not per-domain routes.

Listeners and DoH

Listeners use typed TOML entries. 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.

[[listeners]]
type = "dns"
address = "0.0.0.0:5300"

[[listeners]]
type = "http"
address = "0.0.0.0:8053"
path = "/dns-query"
auth_tokens = ["replace-with-a-random-token"]

[[listeners]]
type = "https"
address = "0.0.0.0:8443"
path = "/dns-query"
http2 = true
cert = "server.crt"
key = "server.key"
auth_tokens = ["another-random-token"]
auth_token_file = "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. Tokens grant equivalent access to the listener's resolver, not per-domain or per-token authorization rules.

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=BASE64URL with an unpadded DNS wire message, or POST a DNS wire message with Content-Type: application/dns-message.
  • JSON (common, non-standard DNS JSON format): GET ?name=example.com&type=A, with symbolic or numeric type (default A). Optional do and cd accept true/false or 1/0.
  • Accept: application/dns-message or application/dns-json selects the response format; quality weights and wildcards are supported. Without Accept, name queries default to JSON and wire queries to binary. JSON POST is not supported.
  • JSON includes Status, DNS flags, Question, Answer, Authority, and Additional; record data uses DNS presentation syntax, including RFC 3597 generic \# length hex for SVCB, HTTPS, NULL, and unknown types. DNS failures are HTTP 200 with their DNS status. Malformed HTTP queries use HTTP error statuses.
curl --cacert server.crt \
  -H "Authorization: Bearer $DNS_TOKEN" \
  -H 'Accept: application/dns-json' \
  'https://localhost:8443/dns-query?name=c0000201.hex.example.net&type=A'

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. TLS, HTTP headers, and body reads have separate timeout phases. The resolver's absolute deadline starts when resolution begins, not at connection acceptance; request_timeout is not one end-to-end HTTP connection budget.

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.

tendns --config tendns.toml txt --name home.example.com \
  --a 192.0.2.1 --aaaa 2001:db8::1 --valid-for 24h

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:

tendns --config tendns.toml txt --name example.com \
  --subdomain nas --a 192.0.2.10

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:

tendns --config tendns.toml txt --name example.com \
  --entry '@:A:192.0.2.1' \
  --entry 'nas:AAAA:2001:db8::1' \
  --entry 'www:CNAME:target.example.net.'

--entry is repeatable and uses LABEL:TYPE:VALUE; @ selects the public TXT owner. Nonempty labels contain 1-63 ASCII letters/digits/hyphens, no dots, and no leading/trailing hyphen. 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. See the protocol specification for the exact encoding, limits, and regression vector. 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.

tendns --config tendns.toml cloudflare-set --name nas.example.com --a 192.0.2.10
tendns --config tendns.toml cloudflare-set --name nas.example.com --aaaa 2001:db8::10
tendns --config tendns.toml cloudflare-set --name www.example.com --cname target.example.net
tendns --config tendns.toml cloudflare-remove --name nas.example.com --type A

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. When the parent is inside that boundary, a direct set requires read access to the parent's Cloudflare zone. An inaccessible zone, parent CNAME, shadowing entry, or invalid parent TXT refuses the set. This checks API-visible records, not recursive DNS caches or other split-horizon views.

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. ttl = 1 still caps tendns's synthesized answers at one second; Auto changes the provider TTL, not the local policy cap.

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. All query types at claimed names use local semantics: public TXT/MX/NS/SOA records are not passed through, including publication TXT. A local CNAME may still lead to public records outside the configured namespaces. 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. Exact duplicates across publications are deduplicated; CNAME/address conflicts are errors. Within one publication a CNAME must be the only entry for its hostname. 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. Ordinary TXT values are skipped for decryption but still count toward the limit of 16 exact-owner IN TXT records and 16 * 5504 aggregate data bytes. A crowded apex can therefore prevent discovery even when its encrypted TXT is valid.

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. A and ANY return the synthesized A record; other permitted 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 startup does not depend on the system DNS resolver. Targets are tried in configured order, including each DoH bootstrap IP. Transport failures and SERVFAIL/REFUSED permit failover; NXDOMAIN is returned immediately. Plain fallback exists only when explicitly configured. Transfers, updates, and multi-question requests are rejected. TSIG/SIG(0) authentication is not implemented; the request validator does not reject wire TSIG with the default Hickory features. See packet validation.

Discovery caching is bounded and never extends the source TTL or authenticated expiry. Fetches are serialized per resident owner slot; errors, zero TTL, cancellation, or eviction can cause another 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. See DESIGN.md for the full resolution and cache contracts.

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. An untrusted upstream or attacker able to suppress parent TXT data can induce a direct-owner lookup and expose a hidden child label: absence is not authenticated. DoH authenticates the upstream connection, not publication completeness. For proxies, unfiltered discovery/hex destinations can enable SSRF to internal or loopback services; apply destination policy outside tendns.

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.

Releases

Binary releases are on GitHub; the library and installable CLI are on crates.io. The release-* branch builds and tests the same source as the v* release tag. Cargo.lock pins the release graph; CI builds with --locked. Embedders resolve the manifest's dependency constraints in their own graph.

The workflow packages and smoke-tests eight binaries: Linux x86_64, aarch64, and armv7 (each GNU and musl), plus macOS x86_64 and aarch64. Archives contain tendns, README.md, DESIGN.md, LICENSE, and tendns.example.toml; releases include SHA256SUMS and build-provenance attestations. Tags create drafts after all builds pass; publication follows artifact review.

Development

Tests use local DNS and HTTPS fixtures; they do not require a public resolver. See the test guide for default/library-only tests, doctests, lint, packaging checks, and remaining gaps.

About

A Rust library and DNS forwarder for encrypted TXT discovery and hexadecimal IPv4 names.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages