Soar

SoarSOA, start of authority: the first record in every DNS zone.

A DNS tunnel for networks that block everything except DNS.

Soar carries TCP connections through the recursive resolvers a censor can't afford to switch off: the ISP's, public ones, and in-country ones it finds on its own. It uses all of them at once, shrugs off forged answers and blocked UDP, and connects in under a second.

query, data rides in the name answer, authenticated by the server dead or blocked resolver
187KB/s

in a Russia-like network where UDP to public resolvers is intercepted. dnstt, VayDNS and slipstream can't connect at all there; the next best manages 14.

0.2–0.9s

to connect across all nine test networks. MasterDnsVPN and CottenDNS take 2 to 49 seconds.

7of 9

emulated networks where Soar has the highest throughput. With 10% loss each way slipstream-rust edges it 71 to 69, and with two dead resolvers VayDNS, handed a good one, leads 153 to 144.

The name

Every query ends at the authority

Every DNS zone begins with an SOA record, its start of authority. Soar's server is the authoritative name server for a zone you delegate to it, so every query for a name in that zone eventually lands there, whichever resolver it passed through.

Censors can block an IP address, a TLS fingerprint, or a whole protocol. Blocking the resolvers that every phone, bank and government site depends on is much harder. Add an r to SOA and the authority soars over the wall, using the one service the censor has to leave running.

; parent zone, example.com
$ORIGIN example.com.
@        IN  SOA  ns1.example.com. hostmaster.example.com. (
                  2026101001 ; serial
                  7200       ; refresh
                  900        ; retry
                  1209600    ; expire
                  60 )       ; negative TTL

; hand the tunnel zone to the Soar server
t        IN  NS   ns.t.example.com.
ns.t     IN  A    203.0.113.53

; a query a phone sends, base32 under the zone
; 4-byte header, sealed frames, 8-byte tag
aeb3k7qp4xw2mfzr…d6ty.t.example.com.  IN  TXT
How it works

The life of a request

A Soar client opens TCP streams the way any app would. Underneath, each stream becomes a series of DNS queries and answers, spread across every resolver that works.

Handshake

The client puts a fresh X25519 key in a query name. The server answers with its own key, a session ID, and an Ed25519 signature over both. Only the real server can sign, so a forged answer from the censor is ignored and the client keeps waiting for the genuine one.

query   sid=0 (2) | client X25519 key (32)
answer  server X25519 key (32) | sid (2)
        | Ed25519 signature (64)

Spread across resolvers

Each resolver is a separate path with its own round-trip time, loss record, name-length and answer-size limits, and congestion window. Soar sends on all of them at once and keeps checking ones it hasn't verified yet. Working resolvers are found by discovery and vetted with the same signed handshake.

example
path       rtt    loss  name  answer  cwnd
isp-a      48ms   1%    253   1232    24
isp-b      71ms   3%    101   512     11
national   92ms   2%    253   900     16
public-1   ––     dead   ––    ––      0

Data rides in the name

Upstream data is sealed into a base32 query name. The header is 4 bytes and the authentication tag 8, which matters on networks that drop names longer than about 101 characters and leave roughly 50 bytes of room per query. Every path starts at that safe length and probes up to the full 253.

sid (2) | protected pn (2)
| seal( hold budget, answer size class
|       | STREAM id=4 off=1180 len=37 … )
| tag (8)

The server holds the line

When there is nothing to send back yet, the server holds the client's query open until data arrives or the client's hold budget runs out, then answers the oldest held query first. Downstream data leaves the moment it is ready instead of waiting for the next poll.

t=0ms    query #812 arrives, nothing to send: hold
t=0ms    query #813 arrives: hold
t=37ms   origin replies with 1.1 KB
t=37ms   answer #812 (oldest first), then #813

An answer is an acknowledgement

Only the server can produce an authenticated answer, so an answer proves its query arrived and upstream needs no ACK frames. Downstream, the client acknowledges packets and also reports which queries got no answer, so the server resends exactly what was lost instead of waiting out a timer.

query  ACK 640–702,704–719  LOST 703
answer STREAM id=4 off=88340 (resent 703's data,
       re-cut to fit this resolver's answer size)

The tail goes twice

The last packet of a request or response is sent on two different paths. When one copy is lost, the transfer still finishes on time instead of stalling for a retransmission timeout.

pn 742  STREAM id=4 … FIN  → isp-a
pn 742  STREAM id=4 … FIN  → national   (dup)
Design

Why it's fast where others stall

Each existing DNS tunnel does one thing well. Soar was designed from scratch against all of them, in an emulator that models what censors and resolvers actually do.

Packets, not segments

A session has one packet-number space, and stream data travels as byte ranges. A retransmission is re-cut to fit whichever resolver it goes out on, and several frames share each packet, so nothing is stuck waiting for a resolver with the right size limit.

Every resolver at once

dnstt and VayDNS use a single resolver. Soar spreads traffic over all of them, weighted by how each one is performing, and drops a dead one without the user noticing.

Loss read in runs

Resolver rate limits drop queries in runs. Random loss on a healthy path is scattered. Soar shrinks a path's window only on runs of losses, so a lossy but healthy resolver stays fully used while an overloaded one gets backed off.

Answer sizes measured, not assumed

Some resolvers silently drop answers over 512 bytes once traffic picks up, with no truncation flag. Soar starts every path at 512 bytes and probes upward with paired canary queries, and falls back to AAAA answers where a resolver refuses TXT.

Fast loss detection at both ends

Per-path packet thresholds on the client and RFC 9002-style packet and time thresholds on the server. Error answers such as truncation or REFUSED count against a path only after a full deadline, so a forged error can't push traffic off a working resolver.

Built for phones

Soar is a Go client library and server in one module, written for memory-constrained mobile clients, iOS network extensions included. An idle session goes quiet: it polls only while data is in flight, which saves battery and keeps a quiet resolver quiet.

Restricted airspace

Built for Iran, Russia and China

Each country breaks DNS in its own way. Soar handles each one directly, and the emulator reproduces each one so the fixes are measured.

R-IR · Iran

A hostile mix

The network
Resolvers that filter TXT, some that are dead, and others that are lossy, rate-limited and jittery, cap answer sizes, or drop names over 101 characters.
What Soar does
AAAA answers where TXT is refused, safe-length names by default, per-path answer sizes, and discovery of in-country resolvers from bundled lists.
105 KB/snext best 43, CottenDNS,
after 17 s to connect
R-RU · Russia

UDP/53 intercepted

The network
UDP queries to public resolvers are intercepted, while TCP port 53 is left alone. The national resolver that UDP lands on is slow and rate-limited.
What Soar does
Runs paths over TCP/53 with RFC 7766 pipelining, many queries in flight on one connection, alongside whatever UDP paths still work.
187 KB/snext best 14, CottenDNS, after 18 s;
dnstt, VayDNS, slipstream fail
R-CN · China

Forged answers

The network
Forged answers race the real ones on UDP/53, and domestic resolvers rate-limit each client address.
What Soar does
Forged answers can't authenticate, so they're dropped and the real answer still counts. Rate limits read as runs of loss, and the window backs off only on that path.
60 KB/snext best 43, slipstream-rust;
VayDNS, MasterDnsVPN fail
Benchmarks

Eight tunnels, nine networks

Every tunnel fetches 100 KB through the same emulated recursive resolvers. Pick a network to see throughput and time to connect.

Tunnel100 KB fetch, KB/sConnect
NetworkSoarBest otherSoar vs best

How these were measured. A purpose-built emulator puts real builds of each tunnel behind emulated recursive resolvers with delay, jitter, loss in each direction, per-resolver query rate limits, answer-size caps, long-name filtering, TXT filtering, dead resolvers, blocked UDP and forged-answer injection. Throughput is for a 100 KB fetch. dnstt and VayDNS use one resolver, so they're given the best one in each network. MasterDnsVPN runs its defaults (3× duplication) and CottenDNS runs both its speed and survival presets.

These are emulator results. Field measurements from inside Iran, Russia and China come next.

Wire format

Twelve bytes of overhead upstream

Upstream travels in the query name as lower-case base32. Downstream travels in TXT character-strings, or in numbered 15-byte AAAA chunks where TXT is filtered.

Upstream data packet, in the query name
sid2 B
pn2 B, protected
hdrhold · size class
framessealed
tag8 B

The 1-byte header carries the client's hold budget in 100 ms steps and the answer size class it wants (512 + 48·k bytes).

Downstream data packet, in the answer
pn2 B, protected
held×10 ms
backlog1 B
framessealed
tag8 B

The server reports how long it held the query, so the client can separate resolver round trips from server wait, and how much data is still queued.

PADDINGPINGACK rangesLOSTSTREAM id · offset · len · FINMAX_STREAM_DATARESET_STREAMCLOSE

Forward secret

Every session starts with an ephemeral X25519 key exchange, so recorded traffic stays sealed even if the server's long-term key leaks later.

Server authenticated

The server signs the handshake transcript with Ed25519. Clients ship only its public key, so there is no shared secret for a censor to pull out of an app.

Sealed per direction

ChaCha20-Poly1305 with a separate key each way and the tag truncated to 8 bytes. Packet numbers are protected QUIC-style, so the wire carries no visible counters.

Part of kindling

The last resort that still gets through

Soar ships as a transport in kindling, Lantern's open-source library for getting an app's first requests through a censored network. Kindling races domain fronting, proxyless dialing and AMP caching against each other and uses whichever connects first.

Soar sits in kindling's last-resort tier. It's dialed only when every faster transport has failed, which is exactly the network where nothing else works. Any Go app can use the same setup with one option, kindling.WithDNSTunnel(client).

  1. Tier 1 · raced in paralleldomain fronting · proxyless smart dialer · AMP cache · your own transports
  2. Last resort · only if tier 1 failsSoar DNS tunnel
Get started

Run a server, ship a key

Delegate a zone to a host with an NS record, generate a key pair, and point clients at it.

Server
# install
go install github.com/getlantern/soar/cmd/soar-server@latest

# prints the private key (keep it) and the public key (ship it)
soar-server keygen

# answer for the delegated zone
soar-server -zone t.example.com -key-file key -listen :53
Client, Go
c, err := soar.NewClient(soar.Config{
	Zone:      "t.example.com",
	ServerKey: serverPublicKey,
	Resolvers: []string{"192.0.2.1:53", "192.0.2.2:53"},
	Discovery: soar.Discovery{Enabled: true, Country: "IR"},
})
conn, err := c.DialContext(ctx, "tcp", "example.com:443")

The server dials destinations for anyone holding its public key, so by default it refuses private addresses, including its own, and allows only ports 80 and 443 (-allow-ports changes that). The client also plugs straight into kindling as its DNS-tunnel transport, which is how Lantern uses it.

Pre-release

Soar is open source under the Apache 2.0 license and built by the Lantern team, who make Lantern, a free app for getting past internet censorship. The protocol may still change and has no compatibility promise yet.

Its bundled Iran resolver and range lists come from KevinNet DNS (MIT). Issues and pull requests are welcome on GitHub.