Standard library · Network

net.doh

Encrypted DNS: a resolver over DNS over HTTPS and DNS over TLS with server failover and a TTL cache, plain UDP DNS as a fallback, presets, and a local stub server for other programs.

import net.doh

Ordinary DNS asks "what is the address of this name?" in plain text over UDP, so the network between you and the resolver can read every name you look up, and can change or block the answer. DNS over HTTPS (DoH) and DNS over TLS (DoT) send the same question inside an encrypted connection. This package speaks both, and plain UDP DNS too, and adds what a program needs on top: a Resolver that tries a list of servers in order, remembers answers for their TTL and answers LookupHost with a list of addresses. A Stub turns the resolver into a small local DNS server, so other programs can use it by pointing their DNS at it. To see how the pieces fit together in an app, read Building a v2ray style or VPN app.

Core ideas:

  • A Server says how to reach one DNS server: KindDoH (HTTPS POST), KindDoHGet (HTTPS GET), KindDoT (TLS on port 853) or KindUDP (plain). Cloudflare, Google, Quad9 and their DoT forms are ready made, and NewServer, NewDoT and NewPlain make your own.
  • Give a server its IP addresses in addrs. Then the program does not need to resolve the server's own name first, which matters when DNS is exactly what is blocked.
  • Query asks one server once. NewResolver and Resolve ask a list: a server that fails, times out or answers SERVFAIL or REFUSED is skipped for the next, and answers are cached (an answer for its TTL, kept between the limits of SetTTLLimits; a "no such name" answer for a short fixed time).
  • Certificates are checked. insecure turns that off for tests only.

Things that catch people out:

  • Encrypted DNS hides the question from the network, not from the resolver. The resolver you pick sees every name you look up. Choose one you trust, and remember that connecting to a well known resolver address is itself visible.
  • Blocked resolvers. A network can block the addresses of public DoH servers. Use several servers, keep a plain or private one as the last in the list, and see IranPlainServers for the plain servers used in Iran. Plain DNS is readable and can be changed on the way.
  • No DNSSEC. Answers are trusted as the server gave them; nothing here checks DNSSEC signatures.
  • The stub answers on UDP and TCP (the same port, TCP with the two byte length prefix) and has no access control. It listens on 127.0.0.1 when the host is empty; give 0.0.0.0 only on purpose, because an open DNS server on a public address is abused.
  • Free what you hold. FreeResponse, FreeAnswer and FreeServers release memory; a Resolver and a Stub live until the program ends.
  • The cache and counters are shared across threads and the resolver takes its own lock, so one resolver can serve many threads.
  • IPv6 resolvers work. A server address may be an IPv6 literal, over UDP, TLS or HTTPS. The built-in servers list IPv4 first; WithIPv6, Cloudflare6, Google6 and Quad96 add or use the IPv6 addresses (2606:4700:4700::1111, 2001:4860:4860::8888, 2620:fe::fe). The local stub can listen on ::1 or ::.
  • Connections are reused. DoH over HTTPS keeps its connections alive and reuses them for later queries (up to 8 idle ones, for 30 seconds); a stale one is replaced by a new connection and the query is repeated once. ReusedConnections counts the reuses and CloseIdleConnections closes the idle ones.
  • Iran presets are unverified. The addresses of Shecan, Server403, Electro, Radar and Begzar come from community lists and could not be confirmed against the official sites of those services. Check them before relying on them.

Using a resolver

Make a Resolver from a list of servers and ask it. The second lookup of the same name comes from the cache and does not touch the network. The example uses a tiny DNS server on loopback so that it runs anywhere; with real servers use DefaultServers or your own list.

net-doh-guide-resolver.salam
import compress
import mem
import net.dns
import net.doh
import net.udp
import sync

mut udp_port := 0
mut queries := 0
mut ready := sync.NewWaitGroup()

func record(name: str, rtype: int, ttl: int, data: str): dns.Record:
    ret dns.Record { name = name, rtype = rtype, rclass = 1, ttl = ttl, data = data }
end

func answer_for(p: void*, n: int): compress.ByteBuf:
    queries++
    question := dns.Decode(p, n)
    asked := question.questions.items.get(0)
    mut records := Vector {} as Vector<dns.Record>
    mut rcode := 0
    if asked.name == "a.test" and asked.qtype == dns.TypeA:
        records.push(record("a.test", dns.TypeA, 120, "10.0.0.1"))
        records.push(record("a.test", dns.TypeA, 300, "10.0.0.2"))
    else asked.name == "v6.test" and asked.qtype == dns.TypeAAAA:
        records.push(record("v6.test", dns.TypeAAAA, 300, "2001:db8::5"))
    else asked.name == "txt.test" and asked.qtype == dns.TypeTXT:
        records.push(record("txt.test", dns.TypeTXT, 60, "hello world"))
    else asked.name == "alias.test":
        records.push(record("alias.test", dns.TypeCNAME, 60, "target.test"))
        if asked.qtype == dns.TypeA:
            records.push(record("target.test", dns.TypeA, 60, "10.9.9.9"))
        end
    else:
        rcode = 3
    end
    ret doh.EncodeResponse(question.id, asked.name, asked.qtype, rcode, records)
end

func udp_server():
    s := udp.Bind(0)
    udp_port = udp.Port(s)
    sync.Done(ready)
    buf := mem.Allocate(2048 as u64)
    mut from_host := ""
    mut from_port := 0
    until true:
        n := udp.ReceiveBytes(s, buf, 2048, from_host, from_port)
        if n > 0:
            answer := answer_for(buf, n)
            udp.SendBytes(s, from_host, from_port, answer.data, answer.len)
        end
    end
end

func start_mock():
    sync.Add(ready, 1 as i64)
    _t := spawn(udp_server)
    sync.Wait(ready)
end

func mock_server(name: str): doh.Server:
    mut server := doh.NewPlain(name, "127.0.0.1")
    server.port = udp_port
    ret server
end

func main:
    start_mock()
    mut servers := Vector {} as Vector<doh.Server>
    servers.push(mock_server("mock"))
    resolver := doh.NewResolver(servers)

    addresses := doh.LookupHost(resolver, "a.test")
    println "addresses:", addresses.len(), addresses.get(0)
    again := doh.LookupHost(resolver, "a.test")
    println again.len()
    stats := doh.Stats(resolver)
    println "asked the server", queries, "time(s); cache hits:", stats.hits
end
Outputaddresses: 2 10.0.0.1 2 asked the server 1 time(s); cache hits: 1

For real use, put your servers in the order you trust them and give each one its addresses:

mut servers := Vector {} as Vector<doh.Server>
servers.push(doh.Cloudflare())
servers.push(doh.GoogleDoT())
servers.push(doh.NewPlain("home", "192.168.1.1"))
resolver := doh.NewResolver(servers)
println doh.LookupHost(resolver, "example.com")

DNS when networks interfere

Three kinds of trouble are common. The network can read your lookups: use DoH or DoT. It can change answers for some names, or return nothing: encrypted DNS stops changes on the path, but not a block of the resolver's address. It can block the resolvers themselves: list several, keep addrs filled so that no lookup is needed to reach them, and keep a plain server that works in your network as the last choice. IranPlainServers lists plain servers used in Iran; their traffic is not encrypted.

Run a Stub on 127.0.0.1 and point the operating system or a program at it, and every lookup it makes goes through your resolver and its cache. When a program does name lookups itself through a proxy, as SOCKS5 and Trojan do, let the proxy resolve the name, so that the lookup is not made locally at all.

What to know

  • Encrypted DNS hides the question from the network, not from the resolver you choose.
  • Fill addrs, or set a bootstrap with SetBootstrap, so that the resolver does not need DNS to find its DNS.
  • Failed servers are skipped; a "no such name" answer is final and cached for the short failure time.
  • No DNSSEC validation. The stub has no access control; bind it to loopback.
  • Query has a timeout in milliseconds, and a Resolver has SetTimeout; keep it short enough that a blocked server does not stall your program.
  • No logging happens by itself.

Types

CacheEntry

struct CacheEntry
One cached answer: the bytes, when it expires and whether it is a cached failure.
NameTypeDescription
rawcompress.ByteBufThe cached answer as DNS wire bytes.
expires_msi64 = 0When the entry expires, as a time in milliseconds.
negativebool = falseTrue for a cached 'no such name' or 'no data' answer.

Answer

struct Answer
What Resolve returns: the answer, and whether it came from the cache.
NameTypeDescription
okbool = falseTrue when some server gave a usable answer, or the cache did.
cachedbool = falseTrue when the answer came from the cache without asking a server.
negativebool = falseTrue when the answer says the name does not exist or has no data of that type.
rcodeint = 0The DNS response code: 0 is success, 3 is a name that does not exist.
ttlint = 0How many seconds the answer may still be used, after the limits of SetTTLLimits.
rawcompress.ByteBufThe answer as DNS wire bytes. Free it with FreeAnswer.
msgdns.MessageThe decoded dns.Message.
serverstr = ""The name of the server that answered, empty for a cached answer.
errstr = ""Why no server could answer, when ok is false.
net-doh-Resolver.salam
import compress
import mem
import net.dns
import net.doh
import net.udp
import sync

mut udp_port := 0
mut queries := 0
mut ready := sync.NewWaitGroup()

func record(name: str, rtype: int, ttl: int, data: str): dns.Record:
    ret dns.Record { name = name, rtype = rtype, rclass = 1, ttl = ttl, data = data }
end

func answer_for(p: void*, n: int): compress.ByteBuf:
    queries++
    question := dns.Decode(p, n)
    asked := question.questions.items.get(0)
    mut records := Vector {} as Vector<dns.Record>
    mut rcode := 0
    if asked.name == "a.test" and asked.qtype == dns.TypeA:
        records.push(record("a.test", dns.TypeA, 120, "10.0.0.1"))
        records.push(record("a.test", dns.TypeA, 300, "10.0.0.2"))
    else asked.name == "v6.test" and asked.qtype == dns.TypeAAAA:
        records.push(record("v6.test", dns.TypeAAAA, 300, "2001:db8::5"))
    else asked.name == "txt.test" and asked.qtype == dns.TypeTXT:
        records.push(record("txt.test", dns.TypeTXT, 60, "hello world"))
    else asked.name == "alias.test":
        records.push(record("alias.test", dns.TypeCNAME, 60, "target.test"))
        if asked.qtype == dns.TypeA:
            records.push(record("target.test", dns.TypeA, 60, "10.9.9.9"))
        end
    else:
        rcode = 3
    end
    ret doh.EncodeResponse(question.id, asked.name, asked.qtype, rcode, records)
end

func udp_server():
    s := udp.Bind(0)
    udp_port = udp.Port(s)
    sync.Done(ready)
    buf := mem.Allocate(2048 as u64)
    mut from_host := ""
    mut from_port := 0
    until true:
        n := udp.ReceiveBytes(s, buf, 2048, from_host, from_port)
        if n > 0:
            answer := answer_for(buf, n)
            udp.SendBytes(s, from_host, from_port, answer.data, answer.len)
        end
    end
end

func start_mock():
    sync.Add(ready, 1 as i64)
    _t := spawn(udp_server)
    sync.Wait(ready)
end

func mock_server(name: str): doh.Server:
    mut server := doh.NewPlain(name, "127.0.0.1")
    server.port = udp_port
    ret server
end

func first(v: Vector<str>): str:
    if v.len() == 0:
        ret "none"
    end
    ret v.get(0)
end

func main:
    start_mock()
    mut servers := Vector {} as Vector<doh.Server>
    servers.push(mock_server("mock"))
    resolver := doh.NewResolver(servers)
    doh.SetTimeout(resolver, 2000)
    doh.SetTTLLimits(resolver, 30, 3600, 15)

    mut a := doh.ResolveAt(resolver, "a.test", dns.TypeA, 1000 as i64)
    println a.ok, a.cached, a.ttl, a.negative, a.rcode, a.msg.answers.items.len(), queries
    mut again := doh.ResolveAt(resolver, "a.test", dns.TypeA, 61000 as i64)
    println again.cached, again.ttl, queries
    mut later := doh.ResolveAt(resolver, "a.test", dns.TypeA, 200000 as i64)
    println later.cached, later.ttl, queries
    doh.FreeAnswer(a)
    doh.FreeAnswer(again)
    doh.FreeAnswer(later)

    n1 := doh.ResolveAt(resolver, "nothing.test", dns.TypeA, 500000 as i64)
    println n1.ok, n1.negative, n1.rcode, n1.ttl, n1.cached
    n2 := doh.ResolveAt(resolver, "nothing.test", dns.TypeA, 505000 as i64)
    println n2.cached, n2.negative, queries
    mut live := doh.Resolve(resolver, "a.test", dns.TypeA)
    println live.ok, live.cached
    doh.FreeAnswer(live)

    println first(doh.LookupA(resolver, "a.test")), doh.LookupA(resolver, "a.test").len()
    println first(doh.LookupAAAA(resolver, "v6.test")), first(doh.LookupTXT(resolver, "txt.test"))
    println doh.LookupCNAME(resolver, "alias.test"), first(doh.LookupHost(resolver, "alias.test")), first(doh.LookupHost(resolver, "5.6.7.8"))

    stats := doh.Stats(resolver)
    println stats.hits > 0, stats.misses > 0, stats.upstream > 0, stats.failures
    println doh.CacheSize(resolver) > 0
    doh.ClearCache(resolver)
    println doh.CacheSize(resolver)

    mut dead := Vector {} as Vector<doh.Server>
    dead.push(doh.NewServer("dead", doh.KindDoH, "http://127.0.0.1:1/dns-query"))
    broken := doh.NewResolver(dead)
    doh.SetTimeout(broken, 300)
    failed := doh.Resolve(broken, "a.test", dns.TypeA)
    println failed.ok, failed.err.len() > 0, doh.Stats(broken).failures
    println first(doh.LookupA(broken, "a.test"))

    mut helper := Vector {} as Vector<doh.Server>
    helper.push(mock_server("bootstrap"))
    doh.SetBootstrap(broken, helper)
    println "bootstrap set"
end
Outputtrue false 120 false 0 2 1 true 60 1 false 120 2 true true 3 15 false true true 3 true false 10.0.0.1 2 2001:db8:0:0:0:0:0:5 hello world target.test 10.9.9.9 5.6.7.8 true true true 0 true 0 false true 1 none bootstrap set

ResolverStats

struct ResolverStats
Counters of a resolver. Stats returns them.
NameTypeDescription
hitsi64 = 0Questions answered from the cache.
missesi64 = 0Questions that were not in the cache.
upstreami64 = 0Servers asked in total (a failed server and its successor count twice).
failuresi64 = 0Questions that no server could answer.

The example for this item is shown with the ones above: net-doh-Resolver.salam

Resolver

struct Resolver
A caching resolver over a list of servers: it asks them in order, falls over to the next on failure and remembers answers for their TTL. Make it with NewResolver; it is safe to use from several threads.
NameTypeDescription
stState*The internal state: servers, cache, limits and counters.

The example for this item is shown with the ones above: net-doh-Resolver.salam

Response

struct Response
The answer of one Query: the raw DNS message, the decoded message, how long it took and which server gave it.
NameTypeDescription
okbool = falseTrue when a valid DNS message with the same id came back. A name that does not exist is still ok: look at the message's rcode.
rawcompress.ByteBufThe answer as DNS wire bytes. Free it with FreeResponse.
msgdns.MessageThe decoded dns.Message: its questions, answers and rcode.
rtt_msi64 = 0The round trip time in milliseconds.
serverstr = ""The name of the server that answered.
errstr = ""A readable reason when ok is false, such as id mismatch or a connection error.
net-doh-Query.salam
import compress
import mem
import net.dns
import net.doh
import net.udp
import sync

mut udp_port := 0
mut queries := 0
mut ready := sync.NewWaitGroup()

func record(name: str, rtype: int, ttl: int, data: str): dns.Record:
    ret dns.Record { name = name, rtype = rtype, rclass = 1, ttl = ttl, data = data }
end

func answer_for(p: void*, n: int): compress.ByteBuf:
    queries++
    question := dns.Decode(p, n)
    asked := question.questions.items.get(0)
    mut records := Vector {} as Vector<dns.Record>
    mut rcode := 0
    if asked.name == "a.test" and asked.qtype == dns.TypeA:
        records.push(record("a.test", dns.TypeA, 120, "10.0.0.1"))
        records.push(record("a.test", dns.TypeA, 300, "10.0.0.2"))
    else asked.name == "v6.test" and asked.qtype == dns.TypeAAAA:
        records.push(record("v6.test", dns.TypeAAAA, 300, "2001:db8::5"))
    else asked.name == "txt.test" and asked.qtype == dns.TypeTXT:
        records.push(record("txt.test", dns.TypeTXT, 60, "hello world"))
    else asked.name == "alias.test":
        records.push(record("alias.test", dns.TypeCNAME, 60, "target.test"))
        if asked.qtype == dns.TypeA:
            records.push(record("target.test", dns.TypeA, 60, "10.9.9.9"))
        end
    else:
        rcode = 3
    end
    ret doh.EncodeResponse(question.id, asked.name, asked.qtype, rcode, records)
end

func udp_server():
    s := udp.Bind(0)
    udp_port = udp.Port(s)
    sync.Done(ready)
    buf := mem.Allocate(2048 as u64)
    mut from_host := ""
    mut from_port := 0
    until true:
        n := udp.ReceiveBytes(s, buf, 2048, from_host, from_port)
        if n > 0:
            answer := answer_for(buf, n)
            udp.SendBytes(s, from_host, from_port, answer.data, answer.len)
        end
    end
end

func start_mock():
    sync.Add(ready, 1 as i64)
    _t := spawn(udp_server)
    sync.Wait(ready)
end

func mock_server(name: str): doh.Server:
    mut server := doh.NewPlain(name, "127.0.0.1")
    server.port = udp_port
    ret server
end

func main:
    start_mock()
    server := mock_server("mock")
    println server.kind == doh.KindUDP, server.port == udp_port, server.use_tls, server.addrs.get(0)

    mut r := doh.Query(server, "a.test", dns.TypeA, 2000)
    println r.ok, r.server, r.msg.rcode, r.msg.answers.items.len(), r.msg.answers.items.get(0).data
    println r.raw.len > 12, r.err.len()
    doh.FreeResponse(r)

    mut direct := doh.QueryAddr(server, "127.0.0.1", "v6.test", dns.TypeAAAA, 2000)
    println direct.ok, direct.msg.answers.items.get(0).data
    doh.FreeResponse(direct)

    mut missing := doh.Query(server, "nothing.test", dns.TypeA, 2000)
    println "missing name:", missing.ok, missing.msg.rcode, missing.msg.answers.items.len()
    doh.FreeResponse(missing)

    dead := doh.NewServer("dead", doh.KindDoH, "http://127.0.0.1:1/dns-query")
    failed := doh.Query(dead, "a.test", dns.TypeA, 500)
    println "dead server:", failed.ok, failed.err.len() > 0
end
Outputtrue true false 127.0.0.1 true mock 0 2 10.0.0.1 true 0 true 2001:db8:0:0:0:0:0:5 missing name: true 3 0 dead server: false true

Stub

struct Stub
A small DNS server on a local UDP port that answers other programs from a Resolver. Point a program's DNS at it and its lookups go through your encrypted resolver and cache. Start it with StartStub.
NameTypeDescription
okbool = falseTrue when the port was bound.
portint = 0The UDP port it listens on, useful when you asked for 0.
hoststr = ""The address the stub listens on.
tcp_okbool = falseTrue when the TCP side of the stub is listening too.
stStubState*The internal state.
net-doh-Stub.salam
import compress
import mem
import net.dns
import net.doh
import net.udp
import sync

mut udp_port := 0
mut queries := 0
mut ready := sync.NewWaitGroup()

func record(name: str, rtype: int, ttl: int, data: str): dns.Record:
    ret dns.Record { name = name, rtype = rtype, rclass = 1, ttl = ttl, data = data }
end

func answer_for(p: void*, n: int): compress.ByteBuf:
    queries++
    question := dns.Decode(p, n)
    asked := question.questions.items.get(0)
    mut records := Vector {} as Vector<dns.Record>
    mut rcode := 0
    if asked.name == "a.test" and asked.qtype == dns.TypeA:
        records.push(record("a.test", dns.TypeA, 120, "10.0.0.1"))
        records.push(record("a.test", dns.TypeA, 300, "10.0.0.2"))
    else asked.name == "v6.test" and asked.qtype == dns.TypeAAAA:
        records.push(record("v6.test", dns.TypeAAAA, 300, "2001:db8::5"))
    else asked.name == "txt.test" and asked.qtype == dns.TypeTXT:
        records.push(record("txt.test", dns.TypeTXT, 60, "hello world"))
    else asked.name == "alias.test":
        records.push(record("alias.test", dns.TypeCNAME, 60, "target.test"))
        if asked.qtype == dns.TypeA:
            records.push(record("target.test", dns.TypeA, 60, "10.9.9.9"))
        end
    else:
        rcode = 3
    end
    ret doh.EncodeResponse(question.id, asked.name, asked.qtype, rcode, records)
end

func udp_server():
    s := udp.Bind(0)
    udp_port = udp.Port(s)
    sync.Done(ready)
    buf := mem.Allocate(2048 as u64)
    mut from_host := ""
    mut from_port := 0
    until true:
        n := udp.ReceiveBytes(s, buf, 2048, from_host, from_port)
        if n > 0:
            answer := answer_for(buf, n)
            udp.SendBytes(s, from_host, from_port, answer.data, answer.len)
        end
    end
end

func start_mock():
    sync.Add(ready, 1 as i64)
    _t := spawn(udp_server)
    sync.Wait(ready)
end

func mock_server(name: str): doh.Server:
    mut server := doh.NewPlain(name, "127.0.0.1")
    server.port = udp_port
    ret server
end

func main:
    start_mock()
    mut servers := Vector {} as Vector<doh.Server>
    servers.push(mock_server("mock"))
    resolver := doh.NewResolver(servers)
    stub := doh.StartStub(resolver, "127.0.0.1", 0)
    println "stub up:", stub.ok, stub.port > 0

    sock := udp.Open()
    udp.SetTimeout(sock, 3000)
    buf := mem.Allocate(2048 as u64)
    mut from_host := ""
    mut from_port := 0
    each name in ["a.test", "a.test", "nothing.test"]:
        mut asked := compress.NewByteBuf()
        compress.BufPush(asked, 0x12)
        compress.BufPush(asked, 0x34)
        compress.BufPush(asked, 0x01)
        compress.BufPush(asked, 0x00)
        compress.BufPush(asked, 0)
        compress.BufPush(asked, 1)
        repeat 6:
            compress.BufPush(asked, 0)
        end
        each label in name.split("."):
            compress.BufPush(asked, label.len())
            compress.BufPushBytes(asked, label as void*, label.len())
        end
        compress.BufPush(asked, 0)
        compress.BufPush(asked, 0)
        compress.BufPush(asked, 1)
        compress.BufPush(asked, 0)
        compress.BufPush(asked, 1)
        udp.SendBytes(sock, "127.0.0.1", stub.port, asked.data, asked.len)
        n := udp.ReceiveBytes(sock, buf, 2048, from_host, from_port)
        reply := dns.Decode(buf, n)
        println name, reply.id == 4660, reply.rcode, reply.answers.items.len(), queries
    end
    println "served:", doh.StubServed(stub)
    doh.StopStub(stub)
    udp.Close(sock)
end
Outputstub up: true true a.test true 0 2 1 a.test true 0 2 1 nothing.test true 3 0 2 served: 3

Server

struct Server
One DNS server and how to talk to it: the protocol, where it is and which addresses to try first. The preset functions and NewServer, NewDoT and NewPlain make them.
NameTypeDescription
namestr = ""A short name for logs and for server.
kindint = 0KindDoH, KindDoHGet, KindDoT or KindUDP.
urlstr = ""The URL of a DoH endpoint, such as https://dns.google/dns-query.
hoststr = ""The host name (for the TLS name and the Host header) or the IP.
portint = 0The port: 443 for DoH, 853 for DoT, 53 for plain DNS.
pathstr = ""The request path of a DoH endpoint.
use_tlsbool = trueTrue when the server uses TLS. It is true for DoH over https and for DoT.
addrsVectorIP addresses to connect to, so that the server's own name does not have to be looked up first. This is what makes an encrypted resolver work when DNS is the thing that is blocked.
insecurebool = falseTrue to skip the certificate check. For tests only.
net-doh-Servers.salam
import net.doh

func main:
    cloudflare := doh.Cloudflare()
    println cloudflare.name, cloudflare.kind == doh.KindDoH, cloudflare.url, cloudflare.host, cloudflare.port, cloudflare.path, cloudflare.use_tls
    println cloudflare.addrs.get(0), cloudflare.addrs.get(1)
    println doh.Google().host, doh.Google().addrs.get(0), doh.Quad9().host, doh.Quad9().addrs.get(0)
    dot := doh.CloudflareDoT()
    println dot.kind == doh.KindDoT, dot.host, dot.port, dot.addrs.get(0)
    println doh.GoogleDoT().addrs.get(0), doh.Quad9DoT().host

    custom := doh.NewServer("mine", doh.KindDoHGet, "https://dns.example.net/resolve")
    println custom.kind == doh.KindDoHGet, custom.host, custom.path, custom.addrs.len()
    pinned := doh.WithBootstrap(custom, "203.0.113.53")
    println pinned.addrs.len(), custom.addrs.len(), pinned.addrs.get(0)

    mine := doh.NewDoT("dot", "dns.example.net", "203.0.113.54")
    plain := doh.NewPlain("plain", "203.0.113.55")
    println mine.port, mine.use_tls, plain.port, plain.kind == doh.KindUDP, plain.use_tls
    println doh.KindDoH, doh.KindDoHGet, doh.KindDoT, doh.KindUDP

    mut servers := doh.DefaultServers()
    println servers.len(), servers.get(0).name, servers.get(2).name
    iran := doh.IranPlainServers()
    println iran.len(), iran.get(0).name, iran.get(0).port, iran.get(0).addrs.get(0)
    println doh.Shecan().len(), doh.Electro().get(0).name, doh.Radar().get(1).name, doh.Begzar().get(0).addrs.get(0)
    doh.FreeServers(servers)
end
Outputcloudflare true https://cloudflare-dns.com/dns-query cloudflare-dns.com 443 /dns-query true 1.1.1.1 1.0.0.1 dns.google 8.8.8.8 dns.quad9.net 9.9.9.9 true one.one.one.one 853 1.1.1.1 8.8.8.8 dns.quad9.net true dns.example.net /resolve 0 1 0 203.0.113.53 853 true 53 true false 0 1 2 3 3 cloudflare quad9 6 shecan 53 178.22.122.100 2 electro radar2 185.55.226.26

Url

struct Url
A parsed DoH URL: scheme (as use_tls), host, port and path.
NameTypeDescription
okbool = falseTrue when the URL was valid.
use_tlsbool = trueTrue for https, false for http.
hoststr = ""The host.
portint = 0The port, or 443 or 80 when the URL has none.
pathstr = ""The path with the query, at least /.
net-doh-Parse.salam
import compress
import net.dns
import net.doh

func main:
    println doh.IsIP("1.2.3.4"), doh.IsIP("2001:db8::1"), doh.IsIP("dns.google"), doh.IsIP("1.2.3")
    url := doh.ParseURL("https://dns.example.net/dns-query")
    println url.ok, url.use_tls, url.host, url.port, url.path
    plain := doh.ParseURL("http://127.0.0.1:8053")
    println plain.ok, plain.use_tls, plain.port, plain.path
    println doh.ParseURL("ftp://x").ok

    mut v4 := compress.NewByteBuf()
    println doh.ParseIPv4("10.0.0.7", v4), v4.len
    mut v6 := compress.NewByteBuf()
    println doh.ParseIPv6("2001:db8::7", v6), v6.len
    println doh.ParseIPv4("10.0.0", compress.NewByteBuf())

    mut records := Vector {} as Vector<dns.Record>
    records.push(dns.Record { name = "example.test", rtype = dns.TypeA, rclass = 1, ttl = 300, data = "192.0.2.7" })
    wire := doh.EncodeResponse(4242, "example.test", dns.TypeA, 0, records)
    message := dns.Decode(wire.data, wire.len)
    println message.id, message.qr, message.rcode, message.questions.items.len(), message.answers.items.len()
    answer := message.answers.items.get(0)
    println answer.name, answer.ttl, answer.data
    nx := doh.EncodeResponse(7, "nothing.test", dns.TypeA, 3, Vector {} as Vector<dns.Record>)
    println dns.Decode(nx.data, nx.len).rcode
end
Outputtrue true false false true true dns.example.net 443 /dns-query true false 8053 / false true 4 true 16 false 4242 true 0 1 1 example.test 300 192.0.2.7 3

Constants and variables

Server kinds Kind…

The values of kind.
NameValue
KindDoH0
KindDoHGet1
KindDoT2
KindUDP3
net-doh-Http.salam
import compress
import mem
import net.dns
import net.doh
import net.tcp
import str
import sync

mut http_port := 0
mut methods := ""
mut ready := sync.NewWaitGroup()

func find_text(b: compress.ByteBuf, pattern: str): int:
    n := pattern.len()
    mut i := 0
    until i + n <= b.len:
        mut same := true
        repeat n in j:
            if compress.BufGet(b, i + j) != (pattern as u8*)[j] as int:
                same = false
                break
            end
        end
        if same:
            ret i
        end
        i++
    end
    ret 0 - 1
end

func text_at(b: compress.ByteBuf, off: int, n: int): str:
    buf := mem.Allocate((n + 1) as u64)
    mem.Copy(buf, ((b.data as i64) + (off as i64)) as void*, n as u64)
    (buf as u8*)[n] = 0 as u8
    ret buf as str
end

func from_base64url(s: str): compress.ByteBuf:
    alphabet := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
    mut out := compress.NewByteBuf()
    mut acc := 0
    mut bits := 0
    repeat s.len() in i:
        v := alphabet.find(s.substr(i, 1))
        if v < 0:
            break
        end
        acc = (acc << 6) | v
        bits += 6
        if bits >= 8:
            bits -= 8
            compress.BufPush(out, (acc >> bits) & 255)
            acc = acc & ((1 << bits) - 1)
        end
    end
    ret out
end

func serve_one(c: tcp.Conn):
    mut raw := compress.NewByteBuf()
    buf := mem.Allocate(4096 as u64)
    mut head_end := 0 - 1
    mut need := 0
    until true:
        n := tcp.ReadBytes(c, buf, 4096)
        if n <= 0:
            break
        end
        compress.BufPushBytes(raw, buf, n)
        head_end = find_text(raw, "\r\n\r\n")
        if head_end >= 0:
            head := str.Lower(text_at(raw, 0, head_end))
            at := head.find("content-length:")
            if at >= 0:
                rest := head.substr(at + 15, head.len() - at - 15)
                mut line_end := rest.find("\r\n")
                if line_end < 0:
                    line_end = rest.len()
                end
                need = str.Trim(rest.substr(0, line_end)).to_int()
            end
            if raw.len >= head_end + 4 + need:
                break
            end
        end
    end
    mut wire := compress.NewByteBuf()
    if text_at(raw, 0, 4) == "POST":
        methods += "POST "
        compress.BufPushBytes(wire, ((raw.data as i64) + (head_end as i64) + 4) as void*, need)
    else:
        methods += "GET "
        line := text_at(raw, 0, find_text(raw, "\r\n"))
        rest := line.substr(line.find("dns=") + 4, line.len())
        wire = from_base64url(rest.substr(0, rest.find(" ")))
    end
    question := dns.Decode(wire.data, wire.len)
    asked := question.questions.items.get(0)
    mut records := Vector {} as Vector<dns.Record>
    records.push(dns.Record { name = asked.name, rtype = dns.TypeA, rclass = 1, ttl = 300, data = "192.0.2.80" })
    answer := doh.EncodeResponse(question.id, asked.name, asked.qtype, 0, records)
    head := "HTTP/1.1 200 OK\r\nContent-Type: application/dns-message\r\nConnection: close\r\nContent-Length: " + answer.len + "\r\n\r\n"
    tcp.Write(c, head)
    tcp.WriteBytes(c, answer.data, answer.len)
    tcp.Close(c)
end

func http_server():
    srv := tcp.Bind(0)
    http_port = tcp.Port(srv)
    sync.Done(ready)
    until true:
        serve_one(tcp.Accept(srv))
    end
end

func main:
    sync.Add(ready, 1 as i64)
    _t := spawn(http_server)
    sync.Wait(ready)
    base := "http://127.0.0.1:" + http_port + "/dns-query"

    post := doh.NewServer("post", doh.KindDoH, base)
    println post.use_tls, post.port == http_port, post.path
    r := doh.Query(post, "web.test", dns.TypeA, 2000)
    println r.ok, r.msg.answers.items.get(0).data

    get := doh.NewServer("get", doh.KindDoHGet, base)
    g := doh.Query(get, "web.test", dns.TypeA, 2000)
    println g.ok, g.msg.answers.items.get(0).data
    println methods
end
Outputfalse true /dns-query true 192.0.2.80 true 192.0.2.80 POST GET

Functions

NewResolver

func NewResolver(servers: Vector<Server>): Resolver
Makes a resolver that asks these servers in order. The defaults are a minimum TTL of 10 seconds, a maximum of 86400, 30 seconds for failures, a 4000 millisecond timeout and 2048 cache entries.

The example for this item is shown with the ones above: net-doh-Resolver.salam

SetBootstrap

func SetBootstrap(r: Resolver, servers: Vector<Server>)
Sets the servers used to find the address of a DoH or DoT server that has a host name but no addrs. They must be reachable by IP, for example plain ones.

The example for this item is shown with the ones above: net-doh-Resolver.salam

SetTTLLimits

func SetTTLLimits(r: Resolver, min_ttl: int, max_ttl: int, neg_ttl: int)
Sets the smallest and largest time an answer is kept, and how long a failure ('no such name') is kept, in seconds.

The example for this item is shown with the ones above: net-doh-Resolver.salam

SetTimeout

func SetTimeout(r: Resolver, timeout_ms: int)
Sets how long each query to one server may take, in milliseconds.

The example for this item is shown with the ones above: net-doh-Resolver.salam

Stats

func Stats(r: Resolver): ResolverStats
Returns the counters of a resolver.

The example for this item is shown with the ones above: net-doh-Resolver.salam

CacheSize

func CacheSize(r: Resolver): int
The number of entries in the cache.

The example for this item is shown with the ones above: net-doh-Resolver.salam

ClearCache

func ClearCache(r: Resolver)
Throws away every cached answer.

The example for this item is shown with the ones above: net-doh-Resolver.salam

ResolveAt

func ResolveAt(r: Resolver, name: str, qtype: int, now_ms: i64): Answer
Like Resolve but with the clock given in milliseconds, so the cache can be tested without waiting.

The example for this item is shown with the ones above: net-doh-Resolver.salam

Resolve

func Resolve(r: Resolver, name: str, qtype: int): Answer
Answers a question from the cache, or asks the servers in order and caches the result. A server that fails, times out or answers SERVFAIL or REFUSED is skipped for the next one; a 'no such name' answer is final and cached for a short time.

The example for this item is shown with the ones above: net-doh-Resolver.salam

FreeAnswer

func FreeAnswer(a &: Answer)
Frees the raw bytes of an answer.

The example for this item is shown with the ones above: net-doh-Resolver.salam

LookupA

func LookupA(r: Resolver, name: str): Vector<str>
Returns the IPv4 addresses of a name as text. Empty when there are none.

The example for this item is shown with the ones above: net-doh-Resolver.salam

LookupAAAA

func LookupAAAA(r: Resolver, name: str): Vector<str>
Returns the IPv6 addresses of a name as text.

The example for this item is shown with the ones above: net-doh-Resolver.salam

LookupTXT

func LookupTXT(r: Resolver, name: str): Vector<str>
Returns the TXT records of a name.

The example for this item is shown with the ones above: net-doh-Resolver.salam

LookupCNAME

func LookupCNAME(r: Resolver, name: str): str
Returns the canonical name a name points to, or an empty text.

The example for this item is shown with the ones above: net-doh-Resolver.salam

LookupHost

func LookupHost(r: Resolver, name: str): Vector<str>
Returns the addresses of a name, IPv4 then IPv6. A name that is already an IP comes back as itself without asking anyone.

The example for this item is shown with the ones above: net-doh-Resolver.salam

QueryAddr

func QueryAddr(srv: Server, addr: str, name: str, qtype: int, timeout_ms: int): Response
Sends one query to one address of a server and waits up to the timeout. Use it when you already know which IP to ask.

The example for this item is shown with the ones above: net-doh-Query.salam

Query

func Query(srv: Server, name: str, qtype: int, timeout_ms: int): Response
Sends one query to a server, trying each of its addrs in turn, or resolving its host name when it has none. QueryAddr does one address.

The example for this item is shown with the ones above: net-doh-Query.salam

FreeResponse

func FreeResponse(r &: Response)
Frees the raw bytes of a response.

The example for this item is shown with the ones above: net-doh-Query.salam

ReusedConnections

func ReusedConnections(): i64
How many DoH queries so far used a connection kept from an earlier query.

The example for this item is shown with the ones above: net-doh-Http.salam

CloseIdleConnections

func CloseIdleConnections()
Closes the kept DoH connections that are idle.

The example for this item is shown with the ones above: net-doh-Http.salam

StartStub

func StartStub(r: Resolver, host: str, port: int): Stub
Starts the local DNS server on a host and port (0 picks a free port), answering on UDP and on TCP, on background threads. An empty host means 127.0.0.1. Check ok, and tcp_ok for the TCP side.

The example for this item is shown with the ones above: net-doh-Stub.salam

StubServed

func StubServed(s: Stub): i64
How many queries the stub has answered.

The example for this item is shown with the ones above: net-doh-Stub.salam

StopStub

func StopStub(s: Stub)
Stops the local DNS server.

The example for this item is shown with the ones above: net-doh-Stub.salam

ParseIPv4

func ParseIPv4(text: str, out &: compress.ByteBuf): bool
Parses IPv4 text into four bytes appended to a buffer. False when it is not an address.

The example for this item is shown with the ones above: net-doh-Parse.salam

ParseIPv6

func ParseIPv6(text: str, out &: compress.ByteBuf): bool
Parses IPv6 text into sixteen bytes appended to a buffer. False when it is not an address.

The example for this item is shown with the ones above: net-doh-Parse.salam

IsIP

func IsIP(host: str): bool
True when the text is an IPv4 or IPv6 address and not a name.

The example for this item is shown with the ones above: net-doh-Parse.salam

ParseURL

func ParseURL(raw: str): Url
Parses an http:// or https:// URL.

The example for this item is shown with the ones above: net-doh-Parse.salam

NewServer

func NewServer(name: str, kind: int, url: str): Server
Makes a server from a name, a kind and a URL. For the two DoH kinds the host, port, path and TLS flag come from the URL. Add addrs or use WithBootstrap so that its name need not be resolved.

The example for this item is shown with the ones above: net-doh-Servers.salam

NewDoT

func NewDoT(name: str, host: str, addr: str): Server
Makes a DNS over TLS server from a name, the TLS host name and an IP address, on port 853.

The example for this item is shown with the ones above: net-doh-Servers.salam

NewPlain

func NewPlain(name: str, addr: str): Server
Makes a plain UDP DNS server on port 53 from a name and an IP address. Change port for another port.

The example for this item is shown with the ones above: net-doh-Servers.salam

WithBootstrap

func WithBootstrap(s: Server, addr: str): Server
Returns a copy of a server with one more IP address to try.

The example for this item is shown with the ones above: net-doh-Servers.salam

WithIPv6

func WithIPv6(s: Server): Server
Returns the server with the well-known IPv6 addresses of Cloudflare, Google or Quad9 appended to its bootstrap list, chosen by its host name. Other servers come back unchanged.
net-doh-IPv6.salam
import net.doh

func main:
    v6 := doh.Cloudflare6()
    println v6.addrs.get(0), doh.Google6().addrs.get(0), doh.Quad96().addrs.get(0)
    both := doh.WithIPv6(doh.Cloudflare())
    println both.addrs.len()
end

Cloudflare6

func Cloudflare6(): Server
The Cloudflare resolver over DoH, reached only by its IPv6 addresses.

The example for this item is shown with the ones above: net-doh-IPv6.salam

Google6

func Google6(): Server
The Google resolver over DoH, reached only by its IPv6 addresses.

The example for this item is shown with the ones above: net-doh-IPv6.salam

Quad96

func Quad96(): Server
The Quad9 resolver over DoH, reached only by its IPv6 addresses.

The example for this item is shown with the ones above: net-doh-IPv6.salam

Cloudflare

func Cloudflare(): Server
The Cloudflare resolver over DoH (cloudflare-dns.com), with its addresses 1.1.1.1 and 1.0.0.1.

The example for this item is shown with the ones above: net-doh-Servers.salam

Google

func Google(): Server
The Google resolver over DoH (dns.google), with 8.8.8.8 and 8.8.4.4.

The example for this item is shown with the ones above: net-doh-Servers.salam

Quad9

func Quad9(): Server
The Quad9 resolver over DoH (dns.quad9.net), with 9.9.9.9 and 149.112.112.112.

The example for this item is shown with the ones above: net-doh-Servers.salam

CloudflareDoT

func CloudflareDoT(): Server
The Cloudflare resolver over DNS over TLS, at 1.1.1.1.

The example for this item is shown with the ones above: net-doh-Servers.salam

GoogleDoT

func GoogleDoT(): Server
The Google resolver over DNS over TLS, at 8.8.8.8.
net-doh-Encrypted.salam
import net.doh

func main:
    mut servers := Vector {} as Vector<doh.Server>
    servers.push(doh.Cloudflare())
    servers.push(doh.GoogleDoT())
    servers.push(doh.Quad9())
    resolver := doh.NewResolver(servers)
    doh.SetTimeout(resolver, 3000)

    answer := doh.Resolve(resolver, "example.com", 1)
    println answer.ok, answer.cached, answer.server, answer.ttl
    each ip in doh.LookupHost(resolver, "example.com"):
        println ip
    end

    stub := doh.StartStub(resolver, "127.0.0.1", 5353)
    println "local resolver on port", stub.port
    doh.StopStub(stub)
end

Quad9DoT

func Quad9DoT(): Server
The Quad9 resolver over DNS over TLS, at 9.9.9.9.

The example for this item is shown with the ones above: net-doh-Servers.salam

DefaultServers

func DefaultServers(): Vector<Server>
Cloudflare, Google and Quad9 over DoH, in that order: a reasonable list for a Resolver.

The example for this item is shown with the ones above: net-doh-Servers.salam

Shecan

func Shecan(): Vector<Server>
The two plain UDP servers of the Shecan DNS service, used in Iran. They are not encrypted. The addresses are unverified against the official site.

The example for this item is shown with the ones above: net-doh-Servers.salam

Server403

func Server403(): Vector<Server>
The two plain UDP servers of the 403 DNS service. Their addresses are private ones, reachable only from inside some Iranian networks. Unverified against the official site.

Electro

func Electro(): Vector<Server>
The two plain UDP servers of the Electro DNS service. They are not encrypted. The addresses are unverified against the official site.

The example for this item is shown with the ones above: net-doh-Servers.salam

Radar

func Radar(): Vector<Server>
The two plain UDP servers of the Radar DNS service. Their addresses are private ones, reachable only from inside some Iranian networks. Unverified against the official site.

The example for this item is shown with the ones above: net-doh-Servers.salam

Begzar

func Begzar(): Vector<Server>
The two plain UDP servers of the Begzar DNS service. They are not encrypted. The addresses come from a community list and are unverified against the official site.

The example for this item is shown with the ones above: net-doh-Servers.salam

IranPlainServers

func IranPlainServers(): Vector<Server>
Six plain UDP servers: Shecan, 403 and Electro, for use inside Iran. Plain DNS can be read and changed by the network, so prefer DefaultServers where they are reachable.

The example for this item is shown with the ones above: net-doh-Servers.salam

EncodeResponse

func EncodeResponse(id: int, name: str, qtype: int, rcode: int, records: Vector<dns.Record>): compress.ByteBuf
Builds a DNS response message for a question and a list of records. It exists for tests and for the local stub, so you can make a fake DNS server in a few lines.

The example for this item is shown with the ones above: net-doh-Parse.salam

FreeServers

func FreeServers(v &: Vector<Server>)
Frees a vector of servers and their address lists.

The example for this item is shown with the ones above: net-doh-Servers.salam