DNS Relay
A DNS relay names every node of a Rings network on the internet and relays its services there.
An operator delegates a DNS zone to a relay node, for example rings.rs or a user’s
p2p.example.com. Every node then has the name <did>.<zone>, and the services it announces
are reachable at that name by ordinary internet software: a mail server delivering to
me@<did>.<zone>, an SSH client, a browser (#1049).
mail server ──DNS──▶ relay: <did>.<zone> AAAA addr(did), MX <did>.<zone>
mail server ──TCP──▶ relay: (addr(did), 25) ──TCP relay over Rings──▶ node did ──▶ 127.0.0.1:2525
Every DID gets its own IPv6 address in the relay’s prefix. A protocol that names no host before its first byte reaches the server, as SMTP and SSH do not, is therefore routed by destination address alone: the relay parses nothing it carries, and TLS, where a service uses it, ends on the node.
The model
Z the zones delegated to the relay (NS records at their parents)
o an owner; label(o) is its canonical DNS label, a DID's "0x" and 40 hex digits
σ(o) what o announces: its ports, each with or without a PROXY header, and its TXT
records, or ⊥
P, k the relay's IPv6 prefix and secret address key
addr(o) = P ∨ (HMAC-SHA256_k(label(o)) ∧ hostmask(P))
B the address book: addr(o) ↦ o for every owner the relay answered for
μ the memory: the last σ(o) seen; it answers without a lookup for a freshness window,
and for a grace period g after o vanishes
DNS label(o).z AAAA addr(o) when σ(o) ≠ ⊥ (or μ(o) is within g)
label(o).z MX 10 label(o).z when σ(o) serves port 25
s.label(o).z TXT txt(σ(o))(s)
any other name in z: NXDOMAIN, with z's SOA
ingress a TCP connection to (a, p) is relayed to port p of B a when σ(B a) serves p,
a PROXY v2 header first when σ says so, else closed
Each owner has its own IPv6 address, so a protocol that names no host before its first byte
reaches the server, as SMTP and SSH do not, is routed by destination address alone: the relay
parses nothing it carries. The address key stops anyone from grinding an owner onto another
owner’s address, and the book keeps the map’s inverse, injective, across restarts. The memory
keeps an owner that goes offline named for the grace period, so a mail server retries a
delivery instead of bouncing it; an owner that announces nothing is forgotten at once. The book
and the memory hold at most max_owners owners, and at most max_lookups directory lookups run
at once, so neither made-up names nor made-up owners grow the relay’s state or its work without
bound.
Running a relay
A relay is a native node with a dns_relay section in its config.yaml:
dns_relay:
zones:
- origin: rings.rs
nameservers: [ns1.example.net]
contact: hostmaster.example.net
prefix: 2001:db8:1::/64
ports: [22, 25, 80, 443]
state_dir: ~/.rings/dns-relay
zones: the zones delegated to the relay. A nameserver lies outside every relayed zone, so the relay never needs glue for it. Zones are pairwise disjoint.prefix: the IPv6 prefix routed to this host; every owner gets an address in it. A /64 is plenty.ports: the ports the ingress accepts on, for every address in the prefix.state_dir: where the relay keeps its address key (address.key) and, as append-only journals, its address book (address-book.jsonl) and its memory of announcements (announcements.jsonl). Keep it: a new key moves every owner to a new address.dns_listen(default[::]:53and0.0.0.0:53),ingress_listen(default::, an IPv6 address),ttl_secsandnegative_ttl_secs(default 60),book_retention_secs(default eight days),grace_secs(default seven days),fresh_secs(default 15: how long an owner’s last lookup answers without asking the DHT again),max_owners(default 65536: the owners the relay keeps an address and a memory for, the least recently answered forgotten first),max_lookups(default 64: DHT lookups in flight),max_connections(default 4096) andmax_connections_per_owner(default 64).
On the host:
- Make the prefix local, so the wildcard listener receives every address in it:
ip -6 route add local 2001:db8:1::/64 dev lo. The provider must route the prefix to the host. - Delegate the zone at its parent:
rings.rs. NS ns1.example.net., and give the nameserver anA/AAAArecord pointing at the relay host. - Let the node bind port 53 and the ingress ports (on Linux,
CAP_NET_BIND_SERVICE).
DNS listens on every dns_listen address over UDP and TCP, and on an IPv6 address IPv6 only, so
the default’s two wildcards are two sockets on every system, even where IPv6 sockets are
dual-stack by default, as on Linux. A host running a local stub resolver, such as
systemd-resolved (the default on Debian and Ubuntu), already holds port 53 on 127.0.0.53 and
127.0.0.54, and the IPv4 wildcard cannot bind beside it. Such a host lists its own addresses
instead: dns_listen: ["[::]:53", "192.0.2.1:53"], with the IPv4 address the nameserver’s A
record reaches (behind a provider’s NAT, the host’s private address).
The ingress listens IPv6 only, so a host service on the same port’s IPv4 wildcard, such as an
sshd on 0.0.0.0:22, keeps every IPv4 connection. An IPv6 host service on one of the ingress
ports keeps its connections when it listens on the host’s own address (ListenAddress 2001:db8::1 for sshd), not on [::]: the kernel prefers the exact address to the relay’s
wildcard listener, and two wildcard listeners on one port cannot both bind. A relay node may announce services of its own: its own name resolves like any
other, and the ingress dials its own targets directly, since the overlay has no hop to itself.
What the relay answers
<did>.<zone> AAAAis the owner’s address,MXis the name itself when the owner serves port 25, and the TXT records the owner publishes are served at their names under its own.- A name that is no DID, or a DID that announces nothing, is
NXDOMAIN; a DID whose descriptor the relay cannot fetch isSERVFAIL, which resolvers retry and do not cache. - An
ANYquery is answered with one record set of the name, as RFC 8482 recommends, so the relay is a poor amplifier of forged queries. - Any 40 hex digits name an owner, so a flood of queries for made-up owners would become as many
DHT lookups. At most
max_lookupsare in flight: a query that finds them taken is answered from the relay’s memory, or withSERVFAILat once. - An owner that goes offline vanishes from the DHT within minutes. The relay keeps answering for
it from its memory for
grace_secs, so a mail server sees a connection failure, a temporary error it retries, and does not bounce the mail. An owner that announces nothing (an emptyservicessection) is forgotten at once.
Serving a node through relays
A node announces what it serves with a services section:
services:
ports:
- port: 25
target: 127.0.0.1:2525
proxy_protocol: v2
- port: 22
target: 127.0.0.1:22
txt:
- name: _acme-challenge
values: ["<token>"]
ports: each port of the node’s relayed address, and the local address the node dials for a connection to it. The node registers each as the TCP relay servicedns-relay/<port>, and its relay dials nothing else, so a relay can reach only what the node mapped. The node dials the target from its own loopback, so the service sees every connection come from the node itself. Withproxy_protocol: v2(defaultoff), the relay sends a PROXY protocol v2 header naming the client and the relayed address before the client’s bytes, for a service that reads one (Postfix’ssmtpd_upstream_proxy_protocol, HAProxy, the mailbox example) and so logs, rates and filters by the real client. The header is unauthenticated: see SECURITY.md.txt: TXT records under the node’s name;nameis relative to it, and empty for the name itself. An ACME DNS-01 challenge is published this way, so the node can hold a certificate for<did>.<zone>and end TLS itself.heartbeat_interval_secs(default 30) andttl_secs(default 90): how often the node publishes its descriptor, and how long after its last heartbeat a relay still reads it. The descriptor is a signed 10-minute lease that each heartbeat re-appends unchanged (#918); a change of ports or records signs a new one. The interval must be below the TTL and below 300 seconds, and the TTL at most 600 seconds.
The node is then <did>.<zone> in every zone a relay serves, with the DID that rings run
prints. With port 22 mapped to the node’s SSH server, ssh user@<did>.<zone> reaches it.
Example: a mailbox
examples/mailbox is me@<did>.<zone> as a program: an SMTP receiver for its own address in
every zone, a mailbox store (INBOX, Sent, Drafts, Trash, Junk and Archive, with flags and UIDs),
an IMAP4rev1 server for the user’s mail client (IDLE, UIDPLUS, MOVE, SPECIAL-USE), and SMTP
submission that delivers to each recipient’s mail exchanger, so one node’s mailbox writes to
another’s through the relays.
cargo run -p rings-mailbox-example -- --did <the node's DID>
It listens on loopback only: the receiver on 127.0.0.1:2525 (map it as port 25 in the node’s
services; with proxy_protocol: v2 there, start the mailbox with --receiver-proxy-protocol
so its Received headers name the sending server), submission on 127.0.0.1:1587 and IMAP on
127.0.0.1:1143, with user me and the password it writes to ~/.rings/mailbox/password. Point
a mail client at the last two. The example ends no TLS; expose IMAP through a relay only behind
a service that does.
The same mailbox can instead receive at <did>@<domain> through mail gateways, which needs no
relay and no served port: see Mail Gateway.