A library isn't code. A library is thirty or forty decisions somebody already made, correctly, without ever telling you a decision was being made.
You find that out by deleting one.
hollow is a recursive DNS resolver written in Go with no third-party dependencies at all. It starts at the root servers and follows referrals down to whoever is authoritative, instead of forwarding your question to 8.8.8.8 and repeating the answer. It caches, it filters domains, and it can dump a reply octet by octet.
Here it is doing the walk:
$ hollow trace www.github.com
. (root)
+- 193.0.14.129:53 17ms udp, referral, 839 B, 13 NS + 26 glue, 1 of 26 servers
asked as WWW.GitHUB.com.
com.
+- c.gtld-servers.net. (192.26.92.30:53) 85ms udp, referral, 310 B, 8 NS + 2 glue, 1 of 26 servers
asked as wWw.gItHuB.cOm.
github.com.
+- ns-421.awsdns-52.com. (205.251.193.165:53) 35ms udp, answer, 296 B, 1 of 2 servers
asked as www.gIthuB.coM.
www.github.com. 3600 IN CNAME github.com.
github.com. 60 IN A 20.207.73.82
3 queries, 3 zones, 0 answers from cache, 136ms
Three packets, no upstream resolver anywhere in that. The asked as lines are the name as it actually went out: the case is randomised per query as a nonce, and a reply that doesn't echo it back exactly gets thrown away.
The package I'd have installed without thinking about it is github.com/miekg/dns. 16,234 known importers. It isn't the popular choice, it's the only choice; CoreDNS is built on it.
So here's the bill for not installing it.
What replacing it cost
The codec came to 1,341 lines. Message framing, name encoding and decoding, resource records, EDNS0, compression in both directions. Sitting against it: 1,925 lines of test.
That ratio is the first surprise. The implementation was not the hard part. DNS is a format from 1987 and it's documented to death. Writing a codec is mostly reading RFC 1035 and being careful with encoding/binary.
Writing one you'd let touch a socket is a different job.
Whole project: 9,984 lines of Go, 11,033 lines of test, 342 tests under -race, and a go.mod that reads in full:
module github.com/DevInIndia/hollow
go 1.25
No require block. No go.sum. No vendor/.
The line count is the boring half of the answer though. Here's the half I didn't expect.
Those thirty or forty decisions all land back on your desk. In the open. One at a time.
I got two of them wrong badly enough that an attacker could have reached them.
The one advantage I had going in
It wasn't the language.
It was having read Kurose and Ross's Computer Networking: A Top-Down Approach. That book is the reason DNS was interesting to me instead of plumbing I resented having to think about.
The top-down part matters more than it sounds. It starts where people touch the network and works downward, so DNS shows up early, as something with a purpose, long before it becomes a packet with fields in it. By the time you reach the bytes you already know why the pieces are there.
None of that is facts. Facts are in the RFC. What you get is shape. A referral is an ordinary answer, not an error. Glue records exist because of a bootstrap problem, not as an optimisation. Resolution is a walk down a tree, not a lookup in a table.
Deep in a hex dump, the RFC doesn't save you. Already having a picture of the thing it's describing does.
If you work on networks and find them boring, that book is worth your time. The boredom is usually a bottom-up problem.
Two bugs I wrote
Name compression is where DNS bites. Any name in a message can be replaced by a two-octet pointer back to an earlier copy of the same suffix. Saves a lot of space. Creates a lot of ways to be wrong.
My encoder keeps a map of suffixes it's already written so it can point at them later. The bug was in the map key.
Version one folded case. DNS names match case-insensitively, so a case-insensitive key felt correct. It isn't. A pointer doesn't reference a name, it references bytes sitting at an offset. Treat two spellings as one key and the encoder points the second at the first, and the name goes out on the wire in the other spelling.
In most programs that's cosmetic. In this one it takes out a security feature. hollow randomises the case of every outgoing query name as a nonce and throws away any reply that doesn't echo it back exactly. An encoder that quietly rewrites case is an encoder that breaks its own defence.
Version two joined the labels with a dot. Also felt correct. Also wrong, and worse.
A DNS label is allowed to contain a dot. It's escaped as \. in presentation form. So the single label a.b and the two-label pair a, b flatten to the same string. Same key, different names. The encoder points the second at the first and rewrites it into something else entirely.
Key on the octets the name encodes to, length prefixes included, and the problem goes away:
func suffixKey(labels [][]byte) string {
var b strings.Builder
for _, l := range labels {
b.WriteByte(byte(len(l)))
b.Write(l)
}
return b.String()
}
Two suffixes now collide exactly when a pointer between them is correct, which is the property you were leaning on the whole time.
Both were reachable from the network. Neither was caught by review, and neither was caught by the twenty malformed messages already written as test cases.
Both were found by testing.F. Go's built-in fuzzer has now put 38.4 million executions through the decoder without a crash.
It isn't checking for crashes. It asserts a round trip. Anything that decodes must re-encode, decode again to an identical message, and encode a second time to identical bytes. The first encode is allowed to differ from the input, because the encoder picks its own compression targets. The second is not, because by then it's the encoder's own output going back in. That fixed point is what caught the collision, since a rewritten name is a message that stops round-tripping.
Native fuzzing was the cheapest thing in the project, incidentally. No build tag, no separate corpus repo, no second toolchain. The seed corpus is two captured packets and one valid message per record type.
The termination proof
A compression pointer can point anywhere. At itself. In a loop. Handle that badly and a 40-byte packet hangs your resolver.
The obvious guard is that a pointer must point backwards. That isn't enough, and the reason why isn't obvious.
A pointer jumps backwards, sure. But the label walk then carries the cursor forward again. So this satisfies "backwards" at every step and still spins forever: a pointer at offset 20 targeting offset 15, where the labels at 15 walk forward and drop you back at offset 20.
What works is comparing each pointer against the previous pointer's target rather than the current read position:
limit := off
if bound >= 0 {
limit = bound
}
if target >= limit {
return "", fmt.Errorf("pointer at offset %d targets %d, not below %d: %w",
off, target, limit, ErrBadPointer)
}
bound = target
Targets then form a strictly decreasing sequence of non-negative integers, so the walk has to terminate. No visited set. No jump budget. Nothing to tune. And it rejects nothing legitimate, because a valid pointer can only reference a name emitted earlier in the same message.
miekg/dns hands you that for free and you never find out there was an argument to make.
The one-line security hole
Not in the codec, this one. It's in the check that decides whether to believe a nameserver.
When a server sends back a referral it also sends addresses for the servers it's delegating to. You must not believe all of them. A com server can tell you about names inside com. It cannot tell you the address of bank.example.org. That check is called bailiwick and getting it wrong is textbook cache poisoning.
The obvious implementation is one line:
strings.HasSuffix(name, zone)
Reads fine. It's a hole.
Remember the escaped dot. evil\.com is a single label, a sibling of com rather than a child of it, and its bytes end in com. all the same. A suffix test happily accepts glue for ns1.evil\.com inside a com referral, which is precisely the input the check exists to throw away.
The version that works unescapes into labels and compares them from the right:
for i := range zl {
if !strings.EqualFold(string(nl[len(nl)-len(zl)+i]), string(zl[i])) {
return false
}
}
Costs an allocation per call. Has a test that fails against the one-liner.
No cleverness involved. The one-liner is the version anyone writes first. What catches it is deciding that every reply is untrusted input, and then going back through the code looking for the places that assumption isn't actually enforced.
What the library would have decided for me
This is the real answer to "what did it take to replace it", and it's not a line count.
Every one of these came up because there was no library to have an opinion for me. Every one is now a line in the README.
When the UDP queue fills, do you drop the packet or block the reader? Blocking stops you draining the kernel's receive queue, so one slow query becomes a stall for every client on the box. You drop it. UDP already lets you.
A message that arrives with the response bit set never gets a reply. Answer one and two servers pointed at each other will trade a single packet forever, and anyone who puts a victim's address in the source field has made you a way to send traffic at them.
A dropped packet is exactly the event you want in the log and exactly the event that shows up ten thousand at a time. Log the first, count the rest. Otherwise a packet flood turns into a disk flood, which is the same attack with a different target.
Past a rate limit the response is dropped rather than refused, because an error is a response and a response is what an amplification attack came for. But every second one gets answered truncated instead, so a real client retries over TCP and succeeds while a spoofed source can't finish a handshake. That one detail is the difference between rate limiting and an outage for your own users.
The cache has to rewrite every record's TTL to the remaining seconds on the way out. Nothing off the shelf does this because it's DNS-specific. Skip it and two lookups a minute apart report the same countdown, which is a visible lie and the first thing anyone notices. Measured: example.com at 268 ms cold, 0 ms warm, counting down properly.
Then there's the trap that ended up as the first comment in the blocklist parser. A real hosts file opens like this:
127.0.0.1 localhost
127.0.0.1 localhost.localdomain
127.0.0.1 local
255.255.255.255 broadcasthost
::1 localhost
A parser that takes field two ingests every one of those and blocks localhost, which breaks the machine it's running on. Filtering the literal string localhost isn't enough either, because local and broadcasthost walk straight past it.
While I'm on blocklists: bufio.Scanner is the obvious way to read one and it's quietly wrong. Past its buffer limit it returns an error and refuses to continue, so a single absurd line in a generated list truncates the rest of the file while the load reports success. Half a blocklist that thinks it's whole.
Where the standard library ran out
Forty-one substitutions in, mostly it didn't. But a claim like that is worth nothing without the exceptions, so:
Terminal size is the one real gap. There's no portable way to ask a terminal how wide it is. Unix wants the TIOCGWINSZ ioctl, Windows wants GetConsoleScreenBufferInfo, and Go's standard library exposes neither. Everything else in this project replaced a package. This one didn't solve the problem. The dashboard reads COLUMNS and LINES, then flags, then falls back to 100x30, and if you resize the terminal while it's running it keeps the size it started with. That's in the limitations section, not dressed up as a design decision.
DNSSEC is missing, and that is not the standard library's fault. crypto/rsa, crypto/ecdsa, crypto/ed25519 and crypto/sha256 are all there and they're all the signature arithmetic needs. It's missing because a chain of trust from the root, NSEC and NSEC3 denial of existence, and several algorithms with their rollovers is multiple weeks of work. Filing that under "stdlib gap" would be convenient and untrue. Half-done validation would be worse than none, because a resolver that reports AD on evidence it never checked lies to everything downstream of it.
Four things in the standard library are new enough that this project written a year earlier would have imported something: testing/synctest for deadline tests on a fake clock, hash/maphash.Comparable for hashing an address with no allocation, math/rand/v2 for a shuffle that needs no seed, and crypto/rand.Read no longer being able to fail.
The thing I shipped that I can't vouch for
The Windows installer.
install.ps1 is a transcription of the shell installer, which is tested. It handles the TLS 1.2 default in PowerShell 5.1. It checks the architecture instead of downloading a 404 page and then failing on a missing checksum line. It verifies the hash before it writes anything.
It has never been run. There's no PowerShell on the machine this was developed on.
That's stated in a comment at the top of the file rather than a footnote nobody opens. But an installer nobody has executed is not a tested installer, and calling it one would be a lie of omission.
Same section, smaller sin: adblock $ options are honoured without their conditions. ||ads.example^$third-party asks for a domain to be blocked only in third-party context, which a DNS resolver has no way to observe. So the block ends up broader than the rule asked for. Skipping those rules instead would quietly unblock most of a real filter list, which is worse. Broader-than-asked is still wrong. It's named in the README rather than buried.
If you're thinking about doing this
Probably don't. Import miekg/dns. It's good, it's maintained, and the reason it sits under 16,000 packages is that these decisions are hard and someone already made them well.
The case for doing it yourself is when the absence of dependencies is the feature rather than a constraint you're working around. A resolver that pulls in nothing has no supply chain to audit, no transitive updates to track, and ships as one static binary with nothing to fetch at build time. That was the point here, and it's the only reason the trade came out positive.
The other reason is narrower and harder to argue for on a roadmap. If you want to know what a dependency is doing for you, delete one and find out. Not a small one. The one your project actually rests on. Reading the source won't teach you this, because the source shows you code and the code is the cheap part. You learn it at the moment you hit a question you didn't know was a question and there's nobody to ask.
Three things worth doing the same way again.
Fuzz early, and assert a property instead of the absence of crashes. The round-trip fixed point found what review and hand-written cases both missed, and the target itself is about forty lines.
Write down every decision the library would have made, as you make it. Mine ended up a 41-row table with a "what it cost" column, under a rule that an entry which only restates the substitution isn't worth reading. A row that couldn't be filled in was a row that wasn't understood yet.
Put the gaps in the same document as the claims. A limitations section isn't an apology, it's the thing that makes the rest of the page believable.
hollow is MIT, zero dependencies, four platforms, reproducible build.
git clone https://github.com/DevInIndia/hollow && cd hollow
cat go.mod # a module line and a go line
go list -deps ./... | grep '^[^/]*\.' # no output
go build ./cmd/hollow # nothing to fetch
hollow trace www.github.com # watch it walk
Code: https://github.com/DevInIndia/hollow
Docs, with real captured output instead of mockups: https://hollow-site.vercel.app
Top comments (0)