Quien: A Go-powered WHOIS alternative that does way more than WHOIS
I don’t know about you, but my typical domain investigation workflow looks like: run WHOIS, open a new tab for DNS, check the TLS cert with openssl s_client, grep for SPF records, remember DMARC lives at a subdomain, forget the syntax, Google it again. Every single time.
Quien kills that entire workflow. It’s a Go CLI that pulls domain registration, DNS, RDAP, TLS, DKIM, DMARC, SPF, BGP, ASN, BIMI, Core Web Vitals, nameservers, and PeeringDB data from a single command.
But this is a Go blog. I’m less interested in what Quien does and more interested in how it does it — the patterns in its codebase and what’s worth stealing for your own projects.
What Quien actually queries
When you run quien example.com, it fires off queries for:
- WHOIS / RDAP for domain registration data
- DNS records: A, AAAA, MX, NS, TXT, CNAME, and more
- TLS certificate chain, expiry, issuer
- SPF, DMARC, and DKIM for email authentication
- BIMI brand indicator records
- BGP / ASN routing info via PeeringDB
- Core Web Vitals for SEO performance data
- IP geolocation and reverse DNS
That’s a lot of network calls. The interesting part is how it coordinates all of them.
Concurrent lookups with goroutines and errgroup
Quien doesn’t make these calls sequentially. That would be painfully slow. It runs them concurrently using errgroup from the x/sync package — one of the most useful patterns in Go for coordinating parallel work.
Here’s a simplified version of the approach:
package main
import (
"context"
"fmt"
"sync"
"time"
"golang.org/x/sync/errgroup"
)
type DomainReport struct {
mu sync.Mutex
DNS string
TLS string
WHOIS string
DMARC string
}
func (r *DomainReport) Set(field string, value string) {
r.mu.Lock()
defer r.mu.Unlock()
switch field {
case "dns":
r.DNS = value
case "tls":
r.TLS = value
case "whois":
r.WHOIS = value
case "dmarc":
r.DMARC = value
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
report := &DomainReport{}
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error {
// Simulate DNS lookup
time.Sleep(100 * time.Millisecond)
report.Set("dns", "A: 93.184.216.34")
return nil
})
g.Go(func() error {
// Simulate TLS check
time.Sleep(200 * time.Millisecond)
report.Set("tls", "Valid until 2025-12-01")
return nil
})
g.Go(func() error {
// Simulate WHOIS/RDAP
time.Sleep(300 * time.Millisecond)
report.Set("whois", "Registrar: Example Inc")
return nil
})
g.Go(func() error {
// Simulate DMARC lookup
time.Sleep(50 * time.Millisecond)
report.Set("dmarc", "v=DMARC1; p=reject")
return nil
})
if err := g.Wait(); err != nil {
fmt.Printf("Error: %v\n", err)
return
}
fmt.Printf("DNS: %s\n", report.DNS)
fmt.Printf("TLS: %s\n", report.TLS)
fmt.Printf("WHOIS: %s\n", report.WHOIS)
fmt.Printf("DMARC: %s\n", report.DMARC)
}
The errgroup.WithContext call does the heavy lifting here. If any single lookup fails and returns an error, the context gets cancelled, and every other in-flight goroutine can check ctx.Done() and bail out early. This prevents the tool from hanging when, say, an RDAP server is dead. If you want to dig deeper into how context works in Go, check out this ByteSizeGo post on context.
DNS lookups in Go: using net and miekg/dns
Go’s standard library net package handles basic DNS resolution fine. But Quien needs more than net.LookupHost. It queries specific record types — TXT records for SPF and DMARC, MX records for mail servers, NS records for nameservers.
Go’s net.Resolver gets you surprisingly far:
package main
import (
"context"
"fmt"
"net"
"strings"
"time"
)
func main() {
domain := "google.com"
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resolver := &net.Resolver{
PreferGo: true,
}
// Look up TXT records (where SPF, DMARC, DKIM live)
txtRecords, err := resolver.LookupTXT(ctx, domain)
if err != nil {
fmt.Printf("TXT lookup failed: %v\n", err)
return
}
for _, txt := range txtRecords {
if strings.HasPrefix(txt, "v=spf1") {
fmt.Printf("SPF: %s\n", txt)
}
}
// DMARC lives at _dmarc.<domain>
dmarcRecords, err := resolver.LookupTXT(ctx, "_dmarc."+domain)
if err != nil {
fmt.Printf("DMARC lookup failed: %v\n", err)
return
}
for _, txt := range dmarcRecords {
if strings.HasPrefix(txt, "v=DMARC1") {
fmt.Printf("DMARC: %s\n", txt)
}
}
// Nameservers
ns, err := resolver.LookupNS(ctx, domain)
if err != nil {
fmt.Printf("NS lookup failed: %v\n", err)
return
}
for _, n := range ns {
fmt.Printf("NS: %s\n", n.Host)
}
}
Notice PreferGo: true on the resolver. This tells Go to use its pure-Go DNS resolver instead of the system’s C resolver via cgo. For a CLI tool like Quien, this matters more than it looks — it means you can cross-compile without worrying about cgo dependencies. A small flag that saves you real headaches when distributing binaries.
For more advanced queries (querying specific DNS servers, getting raw response data), projects like Quien often reach for miekg/dns, a full DNS library in Go that gives you complete control over DNS packets.
TLS certificate inspection
Checking TLS certificates in Go is straightforward with crypto/tls. Quien connects to the domain’s HTTPS port and walks the certificate chain:
package main
import (
"crypto/tls"
"fmt"
"time"
)
func checkTLS(domain string) error {
conn, err := tls.DialWithDialer(
&net.Dialer{Timeout: 5 * time.Second},
"tcp",
domain+":443",
&tls.Config{
// We want to inspect, not skip verification
InsecureSkipVerify: false,
},
)
if err != nil {
return fmt.Errorf("TLS connect failed: %w", err)
}
defer conn.Close()
state := conn.ConnectionState()
for _, cert := range state.PeerCertificates {
fmt.Printf("Subject: %s\n", cert.Subject.CommonName)
fmt.Printf("Issuer: %s\n", cert.Issuer.CommonName)
fmt.Printf("Expires: %s\n", cert.NotAfter.Format(time.RFC3339))
fmt.Printf("DNS Names: %v\n", cert.DNSNames)
if time.Now().After(cert.NotAfter) {
fmt.Println("⚠️ Certificate is EXPIRED")
} else {
daysLeft := time.Until(cert.NotAfter).Hours() / 24
fmt.Printf("✅ Valid for %.0f more days\n", daysLeft)
}
fmt.Println("---")
}
fmt.Printf("TLS Version: %d\n", state.Version)
fmt.Printf("Cipher Suite: %s\n", tls.CipherSuiteName(state.CipherSuite))
return nil
}
ConnectionState() gives you everything: the full certificate chain, the negotiated TLS version, the cipher suite. You can check for expiring certs, weak cipher suites, missing SAN entries. All with the standard library. No OpenSSL bindings, no shelling out to external tools.
I’m always a little surprised by how much crypto/tls can do without any third-party dependencies.
RDAP over WHOIS: why it matters
Traditional WHOIS is a text-based protocol with inconsistent formatting across registrars. I mean truly inconsistent — every registrar returns data in its own special way, which makes parsing a nightmare of brittle regexes.
RDAP (Registration Data Access Protocol) is the modern replacement. It returns structured JSON. For Go, that’s a different world — you unmarshal directly into structs and move on with your life.
type RDAPResponse struct {
Handle string `json:"handle"`
Name string `json:"name"`
Links []Link `json:"links"`
Events []Event `json:"events"`
Nameservers []Entity `json:"nameservers"`
}
type Event struct {
Action string `json:"eventAction"`
Date string `json:"eventDate"`
}
type Link struct {
Rel string `json:"rel"`
Href string `json:"href"`
}
Quien queries RDAP endpoints by first looking up the right RDAP server for the TLD via IANA’s RDAP bootstrap, then hitting that server’s REST API. The response comes back as JSON, encoding/json handles deserialization, and you never have to write another WHOIS text parser again. Good riddance.
Building the CLI
Quien uses Cobra, which is the de facto standard for Go CLIs. If you’ve used kubectl, gh, or hugo, you’ve used a Cobra-powered CLI.
The structure is clean: each lookup type (DNS, TLS, WHOIS, etc.) lives in its own module, and the CLI layer just orchestrates them. This separation makes testing straightforward — you can test each lookup function independently with mock data or a test server, without dragging the CLI framework into your test suite.
If you’re building your own CLI tools in Go, this pattern of separating business logic from the command layer is worth adopting early. It saves you pain later. For more on structuring Go applications well, the post on functional options covers a configuration pattern that pairs nicely with CLI tools.
BGP and ASN lookups via PeeringDB
One of the more unusual features: BGP and ASN lookups. Quien queries PeeringDB’s API to get information about the autonomous system hosting a given IP. This tells you which network provider is behind the domain.
The flow is: resolve the domain to an IP, look up which ASN owns that IP range, then query PeeringDB for details about that ASN. Standard net/http calls and JSON parsing. No special libraries needed.
What’s worth stealing from this codebase
A few patterns from Quien that I think are genuinely useful:
Coordinate parallel network calls with errgroup and fail fast on errors. Use PreferGo: true on net.Resolver to avoid cgo and get portable binaries. Lean on crypto/tls for certificate inspection — it does more than most people realize. Prefer RDAP over WHOIS whenever possible, because structured JSON beats regex-parsed text every time. And keep your lookup logic separate from your Cobra command definitions so you can test them in isolation.
If you’re building any kind of network inspection or domain analysis tool in Go, Quien’s source at github.com/retlehs/quien is worth reading through. And if you’re interested in related concurrency patterns, the singleflight post covers a technique for deduplicating concurrent calls to the same resource — useful when multiple goroutines might try to resolve the same domain simultaneously.