Author SHA1 Message Date
133dde6f1c ci: use go 1.25 and pin GOTOOLCHAIN=local to fix covdata failure
go.mod requires go 1.25.6 but the workflow was requesting go-version: '1.24'.
With GOTOOLCHAIN=auto (the default), the runner auto-downloaded Go 1.25.6
which lacked the covdata sub-tool, causing the test job to fail with
'go: no such tool covdata' for all cmd/* packages.

Changes:
- Bump go-version from '1.24' to '1.25' to match the go.mod directive
- Add a 'Pin toolchain' step that sets GOTOOLCHAIN=local, preventing
  any further auto-upgrade surprises regardless of future go.mod bumps

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-10 15:06:22 +10:00
gary 683a2a69fa Merge pull request 'Fix DNS resolution: NS name bug, glue bypass, FormatRecord duplicate header, result deduplication' (#23) from agent/go-expert-developer/da16f960 into main
CI / test (push) Failing after 2m49s
CI / docker (push) Has been skipped
Reviewed-on: http://gitea.hansenits.com.au/hits/ExploreDNS/pulls/23
2026-06-08 10:21:08 +00:00
gary 2983846f74 Merge pull request 'docs: add web interface documentation' (#21) from agent/go-expert-developer/17032578 into main
CI / test (push) Failing after 1m30s
CI / docker (push) Has been skipped
Reviewed-on: http://gitea.hansenits.com.au/hits/ExploreDNS/pulls/21
2026-06-08 10:15:10 +00:00
1a0cb1f301 docs: document web interface server in README
CI / test (pull_request) Failing after 3m36s
Add Web Interface section covering:
- How to build the server binary (make build-server / make build-all)
- How to run it with the --addr flag
- What the web UI does and all API endpoints (POST /api/traverse,
  GET /api/traverse/{id}, SSE stream, GET /api/health)
- Request/response shapes and SSE event format

Also update Makefile with build-server and build-all targets, and
expand Project Structure and Development sections to include the
new cmd/server/ and web/api/ packages.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-08 18:12:06 +10:00
3da28da9a2 Fix NS resolution, deduplicate results, fix FormatRecord duplication
CI / test (pull_request) Failing after 2m31s
Three bugs fixed:

1. processReferral was calling ResolveNS with ref.Name (the query domain,
   e.g. '800adventures.com.au.') instead of ref.Bailiwick (the NS hostname,
   e.g. 'ns-a.hansenits.com.'). This caused the sub-traversal to look up the
   wrong name and always fail to find the nameserver's IP address.

2. In ResolveNS (and Referral.Resolve), child referrals whose name matched the
   visited set were unconditionally skipped. When the .com TLD returns glue A
   records for the target NS alongside its delegation, the child referral has
   addresses and should be queried directly rather than skipped.

3. FormatRecord was prepending the DNS header fields and then appending
   rr.String() which already includes those same fields, producing doubled
   output like 'example.com. 300 IN A example.com. 300 IN A 1.2.3.4'.
   Now simply returns rr.String().

Additional improvements:
- Results section deduplicates terminal results: same NS failure or same
  (NS, answer) pair is merged with summed probability, avoiding the same
  nameserver appearing 15 times with 6.7% each.
- Result lines now include the NS hostname (from Bailiwick) and use the
  compact rdata format, e.g. '33% ns-a.hansenits.com answered with 13.54.63.231'.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-08 18:07:38 +10:00
43af39dfd7 Add Dockerfiles and CI pipeline for Docker build/push
CI / test (push) Failing after 2m13s
CI / docker (push) Has been skipped
CI / test (pull_request) Failing after 2m44s
CI / docker (pull_request) Has been skipped
- Dockerfile.cli: multi-stage build for exploredns CLI tool
- Dockerfile.web: multi-stage build for the HTTP API/web server
- Both use golang:1.24-alpine builder + alpine:3.21 final image
- CI pipeline docker job builds and pushes to gitea.hansenits.com.au registry on push to main/master

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-08 17:56:32 +10:00
multica-agent 25e6039bde Improve DNS output: distinguish NS resolution failures from domain errors (#20)
CI / test (push) Failing after 2m19s
2026-06-08 05:59:33 +00:00
multica-agent d6063eb636 feat: DNS host selection - fix code review issues (HAN-400) (#19)
CI / test (push) Failing after 1m58s
2026-06-08 03:25:37 +00:00
20 changed files with 633 additions and 63 deletions
+38 -1
View File
@@ -14,9 +14,12 @@ jobs:
- uses: actions/setup-go@v5
with:
go-version: '1.24'
go-version: '1.25'
cache: true
- name: Pin toolchain
run: echo "GOTOOLCHAIN=local" >> $GITHUB_ENV
- name: Vet
run: go vet ./...
@@ -33,3 +36,37 @@ jobs:
- name: Build
run: go build -v ./...
docker:
runs-on: ubuntu-latest
needs: test
if: github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: gitea.hansenits.com.au
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push CLI image
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.cli
push: true
tags: |
gitea.hansenits.com.au/hits/exploredns-cli:latest
gitea.hansenits.com.au/hits/exploredns-cli:${{ github.sha }}
- name: Build and push web image
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.web
push: true
tags: |
gitea.hansenits.com.au/hits/exploredns-web:latest
gitea.hansenits.com.au/hits/exploredns-web:${{ github.sha }}
+20
View File
@@ -0,0 +1,20 @@
# Build stage
FROM golang:1.24-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/exploredns ./cmd/exploredns
# Final stage
FROM alpine:3.21
RUN apk --no-cache add ca-certificates
COPY --from=builder /out/exploredns /usr/local/bin/exploredns
ENTRYPOINT ["exploredns"]
+22
View File
@@ -0,0 +1,22 @@
# Build stage
FROM golang:1.24-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server
# Final stage
FROM alpine:3.21
RUN apk --no-cache add ca-certificates
COPY --from=builder /out/server /usr/local/bin/server
EXPOSE 8080
ENTRYPOINT ["server"]
+7 -1
View File
@@ -1,13 +1,19 @@
BINARY_NAME=exploredns
SERVER_BINARY_NAME=exploredns-server
BUILD_DIR=bin
GO=go
GOFLAGS=-v
.PHONY: build test lint clean cover
.PHONY: build build-server build-all test lint clean cover
build:
$(GO) build $(GOFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/exploredns
build-server:
$(GO) build $(GOFLAGS) -o $(BUILD_DIR)/$(SERVER_BINARY_NAME) ./cmd/server
build-all: build build-server
test:
$(GO) test -v -race -coverprofile=coverage.out ./...
+120 -5
View File
@@ -26,6 +26,8 @@ binary with no runtime dependencies.
- **Fast mode** — shares glue across branches for speed; disable for independent
paths
- **CNAME tracking** — follows CNAME chains and detects loops
- **Web interface** — browser-based UI backed by an HTTP API server with
real-time Server-Sent Events progress streaming
---
@@ -44,12 +46,15 @@ Requires Go 1.21 or later.
git clone https://gitea.hansenits.com.au/hits/ExploreDNS.git
cd ExploreDNS
make build # produces bin/exploredns
make build-server # produces bin/exploredns-server
make build-all # produces both binaries
```
### go install
```sh
go install github.com/hits/ExploreDNS/cmd/exploredns@latest
go install github.com/hits/ExploreDNS/cmd/server@latest
```
---
@@ -138,7 +143,113 @@ Output Options:
---
## Output Formats
## Web Interface
ExploreDNS ships a second binary — `exploredns-server` — that exposes a
browser-based UI and a JSON REST API backed by the same traversal engine as
the CLI.
### Starting the server
```sh
# Default: listen on :8080
./bin/exploredns-server
# Custom address
./bin/exploredns-server --addr :9090
./bin/exploredns-server --addr 127.0.0.1:8080
```
Or via Make:
```sh
make build-server
./bin/exploredns-server
```
Open `http://localhost:8080` in your browser. The SPA lets you enter a domain,
choose a record type, and watch the traversal progress in real time. When the
traversal completes the full result tree is displayed in the browser.
### API endpoints
| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/api/traverse` | Start an asynchronous traversal |
| `GET` | `/api/traverse/{id}` | Poll traversal status and results |
| `GET` | `/api/traverse/{id}/stream` | Server-Sent Events live progress stream |
| `GET` | `/api/health` | Health check — returns `{"status":"ok"}` |
#### POST /api/traverse
Request body (JSON):
```json
{
"domain": "www.example.com",
"type": "A",
"all_roots": false
}
```
`type` defaults to `"A"` if omitted. `all_roots` queries all 13 root server
sets in parallel (equivalent to `--all-root-servers` in the CLI).
Response (`202 Accepted`):
```json
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "running"
}
```
#### GET /api/traverse/{id}
Returns a snapshot of the job including the full result list once complete.
`status` is one of `running`, `complete`, or `error`.
```json
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "complete",
"domain": "www.example.com",
"query_type": "A",
"started_at": "2024-01-01T12:00:00Z",
"done_at": "2024-01-01T12:00:02Z",
"results": [
{
"depth": 2,
"probability": 1.0,
"response_type": "Answer",
"server": "192.0.2.53:53",
"answers": ["www.example.com. 3600 IN A 93.184.216.34"]
}
]
}
```
#### GET /api/traverse/{id}/stream
An [SSE](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events)
stream of `ProgressEvent` objects, one per `data:` message. Past events
recorded before the client connected are replayed immediately, then live events
follow. The stream ends with `event: done`.
```
data: {"stage":"start","depth":1,"name":"www.example.com","qtype":"A","bailiwick":"com"}
data: {"stage":"complete","depth":1,"name":"www.example.com","qtype":"A","server":"192.0.2.53:53","bailiwick":"com"}
event: done
data: {}
```
Completed jobs are kept in memory for one hour before being purged.
---
### Text (default)
@@ -173,12 +284,14 @@ responding server, the response type, and the decoded DNS records.
```
cmd/exploredns/ CLI entry point and flag parsing
cmd/server/ HTTP API server entry point
internal/config/ Configuration types, validation, and usage text
internal/dns/ DNS query layer, root discovery, transport
internal/traverse/ Core traversal engine, referral resolution, caching
internal/fingerprint/ DNS server version fingerprinting (version.bind CHAOS)
internal/output/ Result formatting — text tree and JSON renderers
internal/integration/ End-to-end integration tests
web/api/ HTTP handler, job store, SSE streaming, static assets
```
---
@@ -186,10 +299,12 @@ internal/integration/ End-to-end integration tests
## Development
```sh
make build # compile binary to bin/exploredns
make test # run all unit and integration tests
make lint # run go vet
make clean # remove build artefacts
make build # compile CLI binary to bin/exploredns
make build-server # compile server binary to bin/exploredns-server
make build-all # compile both binaries
make test # run all unit and integration tests
make lint # run go vet
make clean # remove build artefacts
```
Run a single package's tests:
+6
View File
@@ -21,6 +21,7 @@ func main() {
allRootServers := flag.Bool("all-root-servers", cfg.AllRootServers, "Use all 13 root servers")
rootAAAA := flag.Bool("root-aaaa", cfg.RootAAAA, "Include IPv6 root addresses")
followAAAA := flag.Bool("follow-aaaa", cfg.FollowAAAA, "Only follow AAAA for referrals")
dnsUpstream := flag.String("dns-upstream", cfg.DNSUpstream, "Upstream resolver for root discovery (e.g. 8.8.8.8:53, default: system)")
udpSize := flag.Int("udp-size", cfg.UDPSize, "EDNS0 buffer size (512-4096)")
allowTCP := flag.Bool("allow-tcp", cfg.AllowTCP, "TCP fallback on truncation")
alwaysTCP := flag.Bool("always-tcp", cfg.AlwaysTCP, "Always use TCP")
@@ -71,6 +72,7 @@ func main() {
cfg.AllRootServers = *allRootServers
cfg.RootAAAA = *rootAAAA
cfg.FollowAAAA = *followAAAA
cfg.DNSUpstream = *dnsUpstream
cfg.UDPSize = *udpSize
cfg.AllowTCP = *allowTCP
cfg.AlwaysTCP = *alwaysTCP
@@ -157,6 +159,7 @@ func main() {
IncludeAAAA: cfg.RootAAAA,
Server: rootServerAddr,
AllRoots: cfg.AllRootServers,
Resolver: cfg.DNSUpstream,
}
traverserConfig := &traverse.TraverserConfig{
@@ -179,6 +182,9 @@ func main() {
fmt.Fprintf(os.Stderr, " Allow TCP: %v\n", cfg.AllowTCP)
fmt.Fprintf(os.Stderr, " Always TCP: %v\n", cfg.AlwaysTCP)
fmt.Fprintf(os.Stderr, " Fast Mode: %v\n", cfg.Fast)
if cfg.DNSUpstream != "" {
fmt.Fprintf(os.Stderr, " DNS Upstream: %s\n", cfg.DNSUpstream)
}
}
if !cfg.Quiet && !*jsonOutput {
+20 -9
View File
@@ -28,15 +28,18 @@ type Config struct {
AllRootServers bool
RootAAAA bool
FollowAAAA bool
UDPSize int
AllowTCP bool
AlwaysTCP bool
MaxDepth int
Retries int
Fast bool
Verbose bool
Debug int
Quiet bool
// DNSUpstream is the upstream resolver used for root server discovery.
// Format: "host:port" (e.g. "8.8.8.8:53"). Empty means use the system resolver.
DNSUpstream string
UDPSize int
AllowTCP bool
AlwaysTCP bool
MaxDepth int
Retries int
Fast bool
Verbose bool
Debug int
Quiet bool
ShowProgress bool
ShowResolves bool
@@ -127,6 +130,12 @@ func (c *Config) Validate() error {
return ErrAlwaysTCPRequiresTCP
}
if c.DNSUpstream != "" {
if _, _, err := net.SplitHostPort(c.DNSUpstream); err != nil {
return fmt.Errorf("--dns-upstream %q is not a valid host:port address", c.DNSUpstream)
}
}
return nil
}
@@ -173,6 +182,7 @@ func PrintUsage() {
{"--all-root-servers", "Use all 13 root servers"},
{"--root-aaaa", "Include IPv6 root addresses"},
{"--follow-aaaa", "Only follow AAAA for referrals"},
{"--dns-upstream", "Upstream resolver for root discovery (default: system)"},
},
"Transport Options": {
{"--udp-size", "EDNS0 buffer size (default 2048)"},
@@ -220,6 +230,7 @@ func DefaultConfig() *Config {
AllRootServers: false,
RootAAAA: false,
FollowAAAA: false,
DNSUpstream: "",
UDPSize: 2048,
AllowTCP: true,
AlwaysTCP: false,
+1 -9
View File
@@ -1,7 +1,6 @@
package dns
import (
"fmt"
"strings"
"github.com/miekg/dns"
@@ -234,12 +233,5 @@ func FormatRecord(rr dns.RR) string {
if rr == nil {
return ""
}
header := rr.Header()
return fmt.Sprintf("%s %d %s %s %s",
header.Name,
header.Ttl,
dns.ClassToString[header.Class],
QNameType(header.Rrtype),
rr.String(),
)
return rr.String()
}
+74
View File
@@ -0,0 +1,74 @@
package dns
import "net"
// RootHints contains the 13 IANA root name servers with their well-known
// IPv4 and IPv6 addresses as published at https://www.iana.org/domains/root/servers.
// These addresses change very rarely and are safe to embed as application constants.
var RootHints = []RootServer{
{
Name: "a.root-servers.net.",
IPv4: []net.IP{net.ParseIP("198.41.0.4")},
IPv6: []net.IP{net.ParseIP("2001:503:ba3e::2:30")},
},
{
Name: "b.root-servers.net.",
IPv4: []net.IP{net.ParseIP("170.247.170.2")},
IPv6: []net.IP{net.ParseIP("2801:1b8:10::b")},
},
{
Name: "c.root-servers.net.",
IPv4: []net.IP{net.ParseIP("192.33.4.12")},
IPv6: []net.IP{net.ParseIP("2001:500:2::c")},
},
{
Name: "d.root-servers.net.",
IPv4: []net.IP{net.ParseIP("199.7.91.13")},
IPv6: []net.IP{net.ParseIP("2001:500:2d::d")},
},
{
Name: "e.root-servers.net.",
IPv4: []net.IP{net.ParseIP("192.203.230.10")},
IPv6: []net.IP{net.ParseIP("2001:500:a8::e")},
},
{
Name: "f.root-servers.net.",
IPv4: []net.IP{net.ParseIP("192.5.5.241")},
IPv6: []net.IP{net.ParseIP("2001:500:2f::f")},
},
{
Name: "g.root-servers.net.",
IPv4: []net.IP{net.ParseIP("192.112.36.4")},
IPv6: []net.IP{net.ParseIP("2001:500:12::d0d")},
},
{
Name: "h.root-servers.net.",
IPv4: []net.IP{net.ParseIP("198.97.190.53")},
IPv6: []net.IP{net.ParseIP("2001:500:1::53")},
},
{
Name: "i.root-servers.net.",
IPv4: []net.IP{net.ParseIP("192.36.148.17")},
IPv6: []net.IP{net.ParseIP("2001:7fe::53")},
},
{
Name: "j.root-servers.net.",
IPv4: []net.IP{net.ParseIP("192.58.128.30")},
IPv6: []net.IP{net.ParseIP("2001:503:c27::2:30")},
},
{
Name: "k.root-servers.net.",
IPv4: []net.IP{net.ParseIP("193.0.14.129")},
IPv6: []net.IP{net.ParseIP("2001:7fd::1")},
},
{
Name: "l.root-servers.net.",
IPv4: []net.IP{net.ParseIP("199.7.83.42")},
IPv6: []net.IP{net.ParseIP("2001:500:9f::42")},
},
{
Name: "m.root-servers.net.",
IPv4: []net.IP{net.ParseIP("202.12.27.33")},
IPv6: []net.IP{net.ParseIP("2001:dc3::35")},
},
}
+157
View File
@@ -0,0 +1,157 @@
package dns
import (
"net"
"testing"
)
func TestRootHintsCount(t *testing.T) {
if len(RootHints) != 13 {
t.Errorf("expected 13 root hints, got %d", len(RootHints))
}
}
func TestRootHintsNames(t *testing.T) {
wantNames := []string{
"a.root-servers.net.",
"b.root-servers.net.",
"c.root-servers.net.",
"d.root-servers.net.",
"e.root-servers.net.",
"f.root-servers.net.",
"g.root-servers.net.",
"h.root-servers.net.",
"i.root-servers.net.",
"j.root-servers.net.",
"k.root-servers.net.",
"l.root-servers.net.",
"m.root-servers.net.",
}
for i, rs := range RootHints {
if rs.Name != wantNames[i] {
t.Errorf("RootHints[%d].Name = %q, want %q", i, rs.Name, wantNames[i])
}
}
}
func TestRootHintsHaveIPv4(t *testing.T) {
for _, rs := range RootHints {
if len(rs.IPv4) == 0 {
t.Errorf("root server %q has no IPv4 address", rs.Name)
}
for _, ip := range rs.IPv4 {
if ip.To4() == nil {
t.Errorf("root server %q: expected IPv4, got %v", rs.Name, ip)
}
}
}
}
func TestRootHintsHaveIPv6(t *testing.T) {
for _, rs := range RootHints {
if len(rs.IPv6) == 0 {
t.Errorf("root server %q has no IPv6 address", rs.Name)
}
for _, ip := range rs.IPv6 {
if ip.To4() != nil {
t.Errorf("root server %q: expected IPv6, got IPv4-mappable %v", rs.Name, ip)
}
}
}
}
func TestRootHintsAllIPsIPv4Only(t *testing.T) {
for _, rs := range RootHints {
ips := rs.AllIPs(false)
if len(ips) != len(rs.IPv4) {
t.Errorf("root server %q: AllIPs(false) = %d, want %d", rs.Name, len(ips), len(rs.IPv4))
}
}
}
func TestRootHintsAllIPsBoth(t *testing.T) {
for _, rs := range RootHints {
ips := rs.AllIPs(true)
want := len(rs.IPv4) + len(rs.IPv6)
if len(ips) != want {
t.Errorf("root server %q: AllIPs(true) = %d, want %d", rs.Name, len(ips), want)
}
}
}
func TestRootHintsNoParseFail(t *testing.T) {
// Ensure none of the IPs failed to parse (net.ParseIP returns nil on failure).
for _, rs := range RootHints {
for _, ip := range rs.IPv4 {
if ip == nil {
t.Errorf("root server %q has nil IPv4 (parse failed)", rs.Name)
}
}
for _, ip := range rs.IPv6 {
if ip == nil {
t.Errorf("root server %q has nil IPv6 (parse failed)", rs.Name)
}
}
}
}
func TestRootHintsKnownAddress(t *testing.T) {
// Spot-check a.root-servers.net. which has been stable for decades.
for _, rs := range RootHints {
if rs.Name == "a.root-servers.net." {
want := net.ParseIP("198.41.0.4")
if !rs.IPv4[0].Equal(want) {
t.Errorf("a.root-servers.net. IPv4 = %v, want %v", rs.IPv4[0], want)
}
return
}
}
t.Error("a.root-servers.net. not found in RootHints")
}
func TestResolverFromConfigExplicit(t *testing.T) {
cfg := &RootDiscoveryConfig{Resolver: "8.8.8.8:53"}
got := resolverFromConfig(cfg)
if got != "8.8.8.8:53" {
t.Errorf("resolverFromConfig = %q, want 8.8.8.8:53", got)
}
}
func TestResolverFromConfigEmpty(t *testing.T) {
cfg := &RootDiscoveryConfig{}
got := resolverFromConfig(cfg)
// Should return the system resolver; just check it's non-empty and contains a port.
if got == "" {
t.Error("resolverFromConfig with empty Resolver returned empty string")
}
}
func TestResolverFromConfigNil(t *testing.T) {
got := resolverFromConfig(nil)
if got == "" {
t.Error("resolverFromConfig(nil) returned empty string")
}
}
func TestSystemResolverNonEmpty(t *testing.T) {
got := systemResolver()
if got == "" {
t.Error("systemResolver() returned empty string")
}
// Must contain a colon (host:port format).
host, port, err := splitHostPort(got)
if err != nil {
t.Errorf("systemResolver() = %q: not a valid host:port: %v", got, err)
}
if host == "" {
t.Errorf("systemResolver() host is empty in %q", got)
}
if port == "" {
t.Errorf("systemResolver() port is empty in %q", got)
}
}
// splitHostPort is a thin wrapper around net.SplitHostPort for test use.
func splitHostPort(addr string) (host, port string, err error) {
return net.SplitHostPort(addr)
}
+1 -1
View File
@@ -217,7 +217,7 @@ func TestResolveRootServerDirect(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
servers, err := resolveRootServer(ctx, "a.root-servers.net.", false)
servers, err := resolveRootServer(ctx, systemResolver(), "a.root-servers.net.", false)
if err != nil {
t.Skipf("skipping (no local DNS): %v", err)
}
+63 -22
View File
@@ -26,7 +26,13 @@ func (rs *RootServer) AllIPs(includeAAAA bool) []net.IP {
}
type RootDiscoveryConfig struct {
Server string
// Server overrides which root server to use as the traversal starting point.
// When empty, a root server is discovered via the upstream resolver.
Server string
// Resolver is the upstream DNS resolver used to resolve root server names.
// When empty, the system resolver from /etc/resolv.conf is used.
// Format: "host:port" (e.g. "8.8.8.8:53" or "1.1.1.1:53").
Resolver string
AllRoots bool
IncludeAAAA bool
}
@@ -43,20 +49,44 @@ func DiscoverRoots(ctx context.Context, cfg *RootDiscoveryConfig) ([]RootServer,
cfg = DefaultRootDiscoveryConfig()
}
resolver := resolverFromConfig(cfg)
if cfg.Server != "" {
return discoverRootOverride(ctx, cfg.Server, cfg.IncludeAAAA)
return discoverRootOverride(ctx, resolver, cfg.Server, cfg.IncludeAAAA)
}
if cfg.AllRoots {
return discoverAllRoots(ctx, cfg.IncludeAAAA)
servers, err := discoverAllRoots(ctx, resolver, cfg.IncludeAAAA)
if err != nil {
return filterHints(RootHints, cfg.IncludeAAAA), nil
}
return servers, nil
}
return discoverSingleRoot(ctx, cfg.IncludeAAAA)
servers, err := discoverSingleRoot(ctx, resolver, cfg.IncludeAAAA)
if err != nil {
hints := filterHints(RootHints, cfg.IncludeAAAA)
if len(hints) > 0 {
return hints[:1], nil
}
return nil, err
}
return servers, nil
}
func discoverRootOverride(ctx context.Context, server string, includeAAAA bool) ([]RootServer, error) {
resolver := systemResolver()
// filterHints returns a copy of hints with IPv6 addresses stripped when includeAAAA is false.
func filterHints(hints []RootServer, includeAAAA bool) []RootServer {
out := make([]RootServer, len(hints))
for i, h := range hints {
out[i] = RootServer{Name: h.Name, IPv4: h.IPv4}
if includeAAAA {
out[i].IPv6 = h.IPv6
}
}
return out
}
func discoverRootOverride(ctx context.Context, resolver, server string, includeAAAA bool) ([]RootServer, error) {
nsMsg, err := queryResolver(ctx, resolver, ".", dns.TypeNS)
if err != nil {
return nil, fmt.Errorf("query root NS records: %w", err)
@@ -70,16 +100,14 @@ func discoverRootOverride(ctx context.Context, server string, includeAAAA bool)
normalized := normalizeServerName(server)
for _, name := range nsSet {
if normalizeServerName(name) == normalized {
return resolveRootServer(ctx, name, includeAAAA)
return resolveRootServer(ctx, resolver, name, includeAAAA)
}
}
return resolveRootServer(ctx, server, includeAAAA)
return resolveRootServer(ctx, resolver, server, includeAAAA)
}
func discoverSingleRoot(ctx context.Context, includeAAAA bool) ([]RootServer, error) {
resolver := systemResolver()
func discoverSingleRoot(ctx context.Context, resolver string, includeAAAA bool) ([]RootServer, error) {
nsMsg, err := queryResolver(ctx, resolver, ".", dns.TypeNS)
if err != nil {
return nil, fmt.Errorf("query root NS records: %w", err)
@@ -95,12 +123,10 @@ func discoverSingleRoot(ctx context.Context, includeAAAA bool) ([]RootServer, er
}
pick := nsSet[0]
return resolveRootServer(ctx, pick, includeAAAA)
return resolveRootServer(ctx, resolver, pick, includeAAAA)
}
func discoverAllRoots(ctx context.Context, includeAAAA bool) ([]RootServer, error) {
resolver := systemResolver()
func discoverAllRoots(ctx context.Context, resolver string, includeAAAA bool) ([]RootServer, error) {
nsMsg, err := queryResolver(ctx, resolver, ".", dns.TypeNS)
if err != nil {
return nil, fmt.Errorf("query root NS records: %w", err)
@@ -117,7 +143,7 @@ func discoverAllRoots(ctx context.Context, includeAAAA bool) ([]RootServer, erro
var servers []RootServer
for _, name := range nsSet {
resolved, err := resolveRootServer(ctx, name, includeAAAA)
resolved, err := resolveRootServer(ctx, resolver, name, includeAAAA)
if err != nil {
servers = append(servers, RootServer{Name: name})
continue
@@ -132,9 +158,7 @@ func discoverAllRoots(ctx context.Context, includeAAAA bool) ([]RootServer, erro
return servers, nil
}
func resolveRootServer(ctx context.Context, name string, includeAAAA bool) ([]RootServer, error) {
resolver := systemResolver()
func resolveRootServer(ctx context.Context, resolver, name string, includeAAAA bool) ([]RootServer, error) {
var ipv4 []net.IP
aMsg, err := queryResolver(ctx, resolver, name, dns.TypeA)
if err == nil {
@@ -156,15 +180,32 @@ func resolveRootServer(ctx context.Context, name string, includeAAAA bool) ([]Ro
return []RootServer{{Name: name, IPv4: ipv4, IPv6: ipv6}}, nil
}
// resolverFromConfig returns the upstream DNS resolver address to use.
// If cfg.Resolver is set, it is used directly. Otherwise the system resolver
// is read from /etc/resolv.conf. Falls back to 127.0.0.1:53 if neither is available.
func resolverFromConfig(cfg *RootDiscoveryConfig) string {
if cfg != nil && cfg.Resolver != "" {
return cfg.Resolver
}
return systemResolver()
}
// systemResolver returns the first nameserver from the system DNS configuration.
// This is Unix-only: it reads /etc/resolv.conf, which does not exist on Windows.
// On Windows (or any system without /etc/resolv.conf) the fallback 127.0.0.1:53 applies.
func systemResolver() string {
return "127.0.0.1:53"
cc, err := dns.ClientConfigFromFile("/etc/resolv.conf")
if err != nil || len(cc.Servers) == 0 {
return "127.0.0.1:53"
}
return net.JoinHostPort(cc.Servers[0], cc.Port)
}
func queryResolver(ctx context.Context, resolverAddr, name string, qtype uint16) (*dns.Msg, error) {
c := &dns.Client{
Net: "udp",
ReadTimeout: 5,
WriteTimeout: 5,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
}
if deadline, ok := ctx.Deadline(); ok {
c.ReadTimeout = time.Until(deadline)
+53
View File
@@ -144,6 +144,8 @@ func summaryTypeLabel(respType string) string {
return "query type not implemented by server"
case "cname_loop":
return "resulted in a CNAME loop"
case "ns_error":
return "nameserver lookup failed"
case "error":
return "resulted in an error"
case "referral":
@@ -194,3 +196,54 @@ func containsString(items []string, target string) bool {
}
return false
}
// DeduplicateResults collapses terminal results that represent the same
// outcome from the same server into a single entry with summed probability.
// This prevents the same nameserver failure (or answer) from appearing once
// per delegation path when several parent servers all refer to the same child.
func DeduplicateResults(results []traverse.TraversalResult) []traverse.TraversalResult {
type entry struct {
result traverse.TraversalResult
prob float64
}
keys := make(map[string]*entry)
var order []string
for _, r := range results {
if r.Response == nil || r.Referral == nil {
continue
}
key := resultDeduplicationKey(r)
if e, ok := keys[key]; ok {
e.prob += r.Referral.Prob
} else {
keys[key] = &entry{result: r, prob: r.Referral.Prob}
order = append(order, key)
}
}
deduped := make([]traverse.TraversalResult, 0, len(order))
for _, key := range order {
e := keys[key]
refCopy := *e.result.Referral
refCopy.Prob = e.prob
deduped = append(deduped, traverse.TraversalResult{
Referral: &refCopy,
Response: e.result.Response,
})
}
return deduped
}
func resultDeduplicationKey(r traverse.TraversalResult) string {
bailiwick := strings.TrimSuffix(r.Referral.Bailiwick, ".")
switch r.Response.Type {
case traverse.RespAnswer:
key, _ := answerKey(r.Response)
return "answer:" + bailiwick + ":" + key
case traverse.RespNSResolutionFailed:
return "ns_error:" + r.Response.ErrorMessage
default:
return r.Response.Type.String() + ":" + bailiwick + ":" + r.Response.ErrorMessage
}
}
+1
View File
@@ -88,6 +88,7 @@ func TestSummaryTypeLabelAllTypes(t *testing.T) {
"refused": "query refused by server",
"notimp": "query type not implemented by server",
"cname_loop": "resulted in a CNAME loop",
"ns_error": "nameserver lookup failed",
"error": "resulted in an error",
"referral": "resulted in a referral",
"unknown_type": "unknown_type",
+13 -5
View File
@@ -120,7 +120,8 @@ func (f *textFormatter) writeResults(results []traverse.TraversalResult) error {
}
terminal := terminalResults(results)
for _, result := range terminal {
deduped := DeduplicateResults(terminal)
for _, result := range deduped {
prefix := strings.Repeat(" ", result.Referral.Depth+1)
line := prefix + f.formatResultLine(result)
if _, err := fmt.Fprintln(f.w, line); err != nil {
@@ -196,14 +197,15 @@ func (f *textFormatter) formatResultLine(result traverse.TraversalResult) string
prob := formatProbability(result.Referral.Prob)
switch result.Response.Type {
case traverse.RespAnswer:
key, rrs := answerKey(result.Response)
key, _ := answerKey(result.Response)
if key == "" {
return fmt.Sprintf("%s resulted in answer", prob)
}
if len(rrs) == 1 {
return f.colorize(fmt.Sprintf("%s answered with %s", prob, rrs[0]), colorGreen)
nsLabel := ""
if result.Referral.Bailiwick != "" && result.Referral.Bailiwick != "." {
nsLabel = trimDomain(result.Referral.Bailiwick) + " "
}
return f.colorize(fmt.Sprintf("%s answered with %s", prob, strings.Join(rrs, " / ")), colorGreen)
return f.colorize(fmt.Sprintf("%s %sanswered with %s", prob, nsLabel, key), colorGreen)
case traverse.RespNODATA:
return fmt.Sprintf("%s found no such record", prob)
case traverse.RespNXDOMAIN:
@@ -220,6 +222,12 @@ func (f *textFormatter) formatResultLine(result traverse.TraversalResult) string
msg = result.Response.ErrorMessage
}
return f.colorize(fmt.Sprintf("%s %s", prob, msg), colorRed)
case traverse.RespNSResolutionFailed:
msg := "nameserver lookup failed"
if result.Response.ErrorMessage != "" {
msg = result.Response.ErrorMessage
}
return f.colorize(fmt.Sprintf("%s %s", prob, msg), colorYellow)
case traverse.RespError:
msg := "resulted in an error"
if result.Response.ErrorMessage != "" {
+1
View File
@@ -121,6 +121,7 @@ Answers: []miekgdns.RR{
{"refused", traverse.RespREFUSED, nil, ""},
{"notimp", traverse.RespNOTIMPL, nil, ""},
{"cname_loop", traverse.RespCNAMELoop, nil, "loop detected"},
{"ns_error", traverse.RespNSResolutionFailed, nil, "nameserver ns1.example.com could not be resolved"},
{"error", traverse.RespError, nil, "something went wrong"},
}
+4 -1
View File
@@ -230,7 +230,10 @@ func (r *Referral) Resolve(ctx context.Context, traverser *Traverser, cache *Inf
if resp.Type == RespReferral {
children := resp.ChildReferrals()
for _, child := range children {
if visited != nil && visited[child.Name] {
// Only skip visited names when they have no addresses; if glue
// was included in the referral response we still need to query
// that child to get the authoritative answer.
if visited != nil && visited[child.Name] && !child.HasAddresses() {
continue
}
if !stack.Push(child) {
+8 -1
View File
@@ -20,6 +20,11 @@ const (
RespNOTIMPL
RespCNAMELoop
RespError
// RespNSResolutionFailed indicates that the traversal could not resolve the
// IP address of an in-bailiwick nameserver. The domain may still be
// reachable in practice (e.g. via glue records held by the registry), but
// the iterative traversal could not complete that path.
RespNSResolutionFailed
)
func (rt ResponseType) String() string {
@@ -44,6 +49,8 @@ func (rt ResponseType) String() string {
return "cname_loop"
case RespError:
return "error"
case RespNSResolutionFailed:
return "ns_error"
default:
return "unknown"
}
@@ -231,7 +238,7 @@ func (r *Response) resolveGlue(child *Referral) {
func (r *Response) IsTerminal() bool {
switch r.Type {
case RespAnswer, RespNODATA, RespNXDOMAIN, RespSERVFAIL, RespREFUSED, RespNOTIMPL, RespCNAMELoop, RespError:
case RespAnswer, RespNODATA, RespNXDOMAIN, RespSERVFAIL, RespREFUSED, RespNOTIMPL, RespCNAMELoop, RespError, RespNSResolutionFailed:
return true
default:
return false
+2
View File
@@ -246,6 +246,7 @@ func TestResponseIsTerminal(t *testing.T) {
{RespNXDOMAIN, true},
{RespSERVFAIL, true},
{RespError, true},
{RespNSResolutionFailed, true},
{RespReferral, false},
{RespCNAMEFollow, false},
}
@@ -272,6 +273,7 @@ func TestResponseTypeString(t *testing.T) {
{RespNXDOMAIN, "nxdomain"},
{RespSERVFAIL, "servfail"},
{RespError, "error"},
{RespNSResolutionFailed, "ns_error"},
}
for _, tt := range tests {
+22 -8
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"net"
"strings"
"sync"
"time"
@@ -226,15 +227,24 @@ func (t *Traverser) processReferral(ctx context.Context, ref *Referral, cache *I
}
t.mu.Unlock()
ref.Addresses = t.resolveGlueViaSystem(ctx, ref.Name, cache)
// Resolve the nameserver's IP address. The NS hostname is stored in
// Bailiwick; ref.Name is the domain being queried (not the NS name).
nsToResolve := ref.Bailiwick
if nsToResolve == "" || nsToResolve == "." {
nsToResolve = ref.Name
}
nsName := strings.TrimSuffix(nsToResolve, ".")
ref.Addresses = t.resolveGlueViaSystem(ctx, nsToResolve, cache)
if len(ref.Addresses) > 0 {
ref.State = StateResolved
} else {
addrs, err := t.ResolveNS(ctx, ref.Name, cache, visitedCopy, t.depth)
addrs, err := t.ResolveNS(ctx, nsToResolve, cache, visitedCopy, t.depth)
if err != nil {
return &Response{
Referral: ref,
Type: RespError,
Referral: ref,
Type: RespNSResolutionFailed,
ErrorMessage: fmt.Sprintf("nameserver %s could not be resolved", nsName),
}
}
if len(addrs) > 0 {
@@ -242,8 +252,9 @@ func (t *Traverser) processReferral(ctx context.Context, ref *Referral, cache *I
ref.State = StateResolved
} else {
return &Response{
Referral: ref,
Type: RespError,
Referral: ref,
Type: RespNSResolutionFailed,
ErrorMessage: fmt.Sprintf("nameserver %s could not be resolved", nsName),
}
}
}
@@ -360,7 +371,7 @@ func (t *Traverser) ResolveNS(ctx context.Context, nsName string, cache *InfoCac
break
}
if resp.Type == RespSERVFAIL || resp.Type == RespError {
if resp.Type == RespSERVFAIL || resp.Type == RespError || resp.Type == RespNSResolutionFailed {
lastErr = fmt.Errorf("server error resolving %s: %s", nsName, resp.Type)
continue
}
@@ -368,7 +379,10 @@ func (t *Traverser) ResolveNS(ctx context.Context, nsName string, cache *InfoCac
if resp.Type == RespReferral {
children := resp.ChildReferrals()
for _, child := range children {
if visited != nil && visited[child.Name] {
// Only skip visited names when they have no addresses; if glue
// was included in the referral response we still need to query
// that child to get the authoritative answer.
if visited != nil && visited[child.Name] && !child.HasAddresses() {
continue
}
if !stack.Push(child) {