Author SHA1 Message Date
fe74ec61a2 fix: remove tracked binaries covered by .gitignore
CI / test (pull_request) Failing after 3h3m3s
CI / docker (pull_request) Has been cancelled
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 22:05:30 +10:00
851203e8f6 fix: merge main into PR branch, resolve CI and go.mod conflicts
- Keep go-version-file: 'go.mod' from PR (reads Go version from go.mod)
- Keep gitea.hansenits.com.au module path in coverage check
- Pick up .gitignore bin/* entry from main
- go.mod: keep go 1.24.0 directive from main (GOTOOLCHAIN=local)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 22:05:16 +10:00
Gary cf280a9f2b fix: update .gitignore to include bin directory
CI / test (push) Failing after 3h6m19s
CI / docker (push) Has been cancelled
2026-06-17 12:11:11 +10:00
gary 1f23f482c1 Merge pull request 'fix: pin go directive to 1.24.0 and add GOTOOLCHAIN=local to CI (HAN-410)' (#25) from agent/go-expert-developer/6b3715f8 into main
CI / test (push) Failing after 3h1m13s
CI / docker (push) Has been cancelled
Reviewed-on: http://gitea.hansenits.com.au/hits/ExploreDNS/pulls/25
2026-06-10 10:14:16 +00:00
17e2c47e4a fix: pin go directive to 1.24.0 and add GOTOOLCHAIN=local to CI
CI / test (pull_request) Failing after 3h13m49s
CI / docker (pull_request) Has been cancelled
The CI was consistently failing because go.mod declared 'go 1.25.6',
which triggered Go's GOTOOLCHAIN=auto mechanism to auto-download Go
1.25.6 on the CI runner. That pre-release build lacks the 'covdata'
tool, causing 'go test -race -coverprofile=coverage.out ./...' to exit
non-zero on cmd/ packages (which have no test files), even though all
actual tests passed.

Fix:
- Lower go directive from 1.25.6 to 1.24.0 (minimum required by deps)
- Set go-version: '1.24' in setup-go to match
- Add GOTOOLCHAIN=local to every go command in ci.yml to prevent any
  further auto-download regardless of future go.mod changes

All tests pass locally with GOTOOLCHAIN=local.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-10 09:17:07 +10:00
671a23e6e1 fix: update CI to use Go version from go.mod and fix module path in coverage check
CI / test (pull_request) Failing after 3h13m3s
CI / docker (pull_request) Has been cancelled
- Replace hardcoded go-version '1.24' with go-version-file: 'go.mod' so CI
  always uses the toolchain version that matches the module's requirements
  (go.mod currently declares go 1.25.6)
- Fix 'Check internal package coverage' step: update grep pattern from
  old module path 'github.com/hits/ExploreDNS/internal' to the renamed
  path 'gitea.hansenits.com.au/hits/ExploreDNS/internal'

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-09 15:17:29 +10:00
5d4fa94286 fix: rename Go module path to gitea.hansenits.com.au/hits/ExploreDNS
CI / test (pull_request) Failing after 1m32s
CI / docker (pull_request) Has been skipped
- Update go.mod module declaration
- Update all internal import paths in .go files
- Update go install lines in README.md

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-08 23:17:00 +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
34 changed files with 673 additions and 105 deletions
+40 -6
View File
@@ -14,22 +14,56 @@ jobs:
- uses: actions/setup-go@v5 - uses: actions/setup-go@v5
with: with:
go-version: '1.24' go-version-file: 'go.mod'
cache: true cache: true
- name: Vet - name: Vet
run: go vet ./... run: GOTOOLCHAIN=local go vet ./...
- name: Test - name: Test
run: go test -v -race -coverprofile=coverage.out ./... run: GOTOOLCHAIN=local go test -v -race -coverprofile=coverage.out ./...
- name: Coverage report - name: Coverage report
run: go tool cover -func=coverage.out run: GOTOOLCHAIN=local go tool cover -func=coverage.out
- name: Check internal package coverage - name: Check internal package coverage
run: | run: |
COVERAGE=$(go tool cover -func=coverage.out | grep "^github.com/hits/ExploreDNS/internal" | awk '{print $3}' | sed 's/%//' | awk '{sum+=$1; count++} END {if(count>0) print sum/count; else print 0}') COVERAGE=$(GOTOOLCHAIN=local go tool cover -func=coverage.out | grep "^gitea.hansenits.com.au/hits/ExploreDNS/internal" | awk '{print $3}' | sed 's/%//' | awk '{sum+=$1; count++} END {if(count>0) print sum/count; else print 0}')
echo "Average internal package coverage: ${COVERAGE}%" echo "Average internal package coverage: ${COVERAGE}%"
- name: Build - name: Build
run: go build -v ./... run: GOTOOLCHAIN=local 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 }}
+1
View File
@@ -8,6 +8,7 @@
*.dll *.dll
*.so *.so
*.dylib *.dylib
bin/*
# Test binary, built with `go test -c` # Test binary, built with `go test -c`
*.test *.test
+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 BINARY_NAME=exploredns
SERVER_BINARY_NAME=exploredns-server
BUILD_DIR=bin BUILD_DIR=bin
GO=go GO=go
GOFLAGS=-v GOFLAGS=-v
.PHONY: build test lint clean cover .PHONY: build build-server build-all test lint clean cover
build: build:
$(GO) build $(GOFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/exploredns $(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: test:
$(GO) test -v -race -coverprofile=coverage.out ./... $(GO) test -v -race -coverprofile=coverage.out ./...
+118 -3
View File
@@ -26,6 +26,8 @@ binary with no runtime dependencies.
- **Fast mode** — shares glue across branches for speed; disable for independent - **Fast mode** — shares glue across branches for speed; disable for independent
paths paths
- **CNAME tracking** — follows CNAME chains and detects loops - **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 git clone https://gitea.hansenits.com.au/hits/ExploreDNS.git
cd ExploreDNS cd ExploreDNS
make build # produces bin/exploredns make build # produces bin/exploredns
make build-server # produces bin/exploredns-server
make build-all # produces both binaries
``` ```
### go install ### go install
```sh ```sh
go install github.com/hits/ExploreDNS/cmd/exploredns@latest go install gitea.hansenits.com.au/hits/ExploreDNS/cmd/exploredns@latest
go install gitea.hansenits.com.au/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) ### 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/exploredns/ CLI entry point and flag parsing
cmd/server/ HTTP API server entry point
internal/config/ Configuration types, validation, and usage text internal/config/ Configuration types, validation, and usage text
internal/dns/ DNS query layer, root discovery, transport internal/dns/ DNS query layer, root discovery, transport
internal/traverse/ Core traversal engine, referral resolution, caching internal/traverse/ Core traversal engine, referral resolution, caching
internal/fingerprint/ DNS server version fingerprinting (version.bind CHAOS) internal/fingerprint/ DNS server version fingerprinting (version.bind CHAOS)
internal/output/ Result formatting — text tree and JSON renderers internal/output/ Result formatting — text tree and JSON renderers
internal/integration/ End-to-end integration tests internal/integration/ End-to-end integration tests
web/api/ HTTP handler, job store, SSE streaming, static assets
``` ```
--- ---
@@ -186,7 +299,9 @@ internal/integration/ End-to-end integration tests
## Development ## Development
```sh ```sh
make build # compile binary to bin/exploredns 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 test # run all unit and integration tests
make lint # run go vet make lint # run go vet
make clean # remove build artefacts make clean # remove build artefacts
+10 -4
View File
@@ -7,10 +7,10 @@ import (
"os" "os"
"time" "time"
"github.com/hits/ExploreDNS/internal/config" "gitea.hansenits.com.au/hits/ExploreDNS/internal/config"
"github.com/hits/ExploreDNS/internal/dns" "gitea.hansenits.com.au/hits/ExploreDNS/internal/dns"
"github.com/hits/ExploreDNS/internal/output" "gitea.hansenits.com.au/hits/ExploreDNS/internal/output"
"github.com/hits/ExploreDNS/internal/traverse" "gitea.hansenits.com.au/hits/ExploreDNS/internal/traverse"
) )
func main() { func main() {
@@ -21,6 +21,7 @@ func main() {
allRootServers := flag.Bool("all-root-servers", cfg.AllRootServers, "Use all 13 root servers") allRootServers := flag.Bool("all-root-servers", cfg.AllRootServers, "Use all 13 root servers")
rootAAAA := flag.Bool("root-aaaa", cfg.RootAAAA, "Include IPv6 root addresses") rootAAAA := flag.Bool("root-aaaa", cfg.RootAAAA, "Include IPv6 root addresses")
followAAAA := flag.Bool("follow-aaaa", cfg.FollowAAAA, "Only follow AAAA for referrals") 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)") udpSize := flag.Int("udp-size", cfg.UDPSize, "EDNS0 buffer size (512-4096)")
allowTCP := flag.Bool("allow-tcp", cfg.AllowTCP, "TCP fallback on truncation") allowTCP := flag.Bool("allow-tcp", cfg.AllowTCP, "TCP fallback on truncation")
alwaysTCP := flag.Bool("always-tcp", cfg.AlwaysTCP, "Always use TCP") alwaysTCP := flag.Bool("always-tcp", cfg.AlwaysTCP, "Always use TCP")
@@ -71,6 +72,7 @@ func main() {
cfg.AllRootServers = *allRootServers cfg.AllRootServers = *allRootServers
cfg.RootAAAA = *rootAAAA cfg.RootAAAA = *rootAAAA
cfg.FollowAAAA = *followAAAA cfg.FollowAAAA = *followAAAA
cfg.DNSUpstream = *dnsUpstream
cfg.UDPSize = *udpSize cfg.UDPSize = *udpSize
cfg.AllowTCP = *allowTCP cfg.AllowTCP = *allowTCP
cfg.AlwaysTCP = *alwaysTCP cfg.AlwaysTCP = *alwaysTCP
@@ -157,6 +159,7 @@ func main() {
IncludeAAAA: cfg.RootAAAA, IncludeAAAA: cfg.RootAAAA,
Server: rootServerAddr, Server: rootServerAddr,
AllRoots: cfg.AllRootServers, AllRoots: cfg.AllRootServers,
Resolver: cfg.DNSUpstream,
} }
traverserConfig := &traverse.TraverserConfig{ traverserConfig := &traverse.TraverserConfig{
@@ -179,6 +182,9 @@ func main() {
fmt.Fprintf(os.Stderr, " Allow TCP: %v\n", cfg.AllowTCP) fmt.Fprintf(os.Stderr, " Allow TCP: %v\n", cfg.AllowTCP)
fmt.Fprintf(os.Stderr, " Always TCP: %v\n", cfg.AlwaysTCP) fmt.Fprintf(os.Stderr, " Always TCP: %v\n", cfg.AlwaysTCP)
fmt.Fprintf(os.Stderr, " Fast Mode: %v\n", cfg.Fast) 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 { if !cfg.Quiet && !*jsonOutput {
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/hits/ExploreDNS/web/api" "gitea.hansenits.com.au/hits/ExploreDNS/web/api"
) )
func main() { func main() {
+2 -2
View File
@@ -1,6 +1,6 @@
module github.com/hits/ExploreDNS module gitea.hansenits.com.au/hits/ExploreDNS
go 1.25.6 go 1.24.0
require ( require (
github.com/miekg/dns v1.1.72 github.com/miekg/dns v1.1.72
+11
View File
@@ -28,6 +28,9 @@ type Config struct {
AllRootServers bool AllRootServers bool
RootAAAA bool RootAAAA bool
FollowAAAA bool FollowAAAA 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 UDPSize int
AllowTCP bool AllowTCP bool
AlwaysTCP bool AlwaysTCP bool
@@ -127,6 +130,12 @@ func (c *Config) Validate() error {
return ErrAlwaysTCPRequiresTCP 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 return nil
} }
@@ -173,6 +182,7 @@ func PrintUsage() {
{"--all-root-servers", "Use all 13 root servers"}, {"--all-root-servers", "Use all 13 root servers"},
{"--root-aaaa", "Include IPv6 root addresses"}, {"--root-aaaa", "Include IPv6 root addresses"},
{"--follow-aaaa", "Only follow AAAA for referrals"}, {"--follow-aaaa", "Only follow AAAA for referrals"},
{"--dns-upstream", "Upstream resolver for root discovery (default: system)"},
}, },
"Transport Options": { "Transport Options": {
{"--udp-size", "EDNS0 buffer size (default 2048)"}, {"--udp-size", "EDNS0 buffer size (default 2048)"},
@@ -220,6 +230,7 @@ func DefaultConfig() *Config {
AllRootServers: false, AllRootServers: false,
RootAAAA: false, RootAAAA: false,
FollowAAAA: false, FollowAAAA: false,
DNSUpstream: "",
UDPSize: 2048, UDPSize: 2048,
AllowTCP: true, AllowTCP: true,
AlwaysTCP: false, AlwaysTCP: false,
+1 -9
View File
@@ -1,7 +1,6 @@
package dns package dns
import ( import (
"fmt"
"strings" "strings"
"github.com/miekg/dns" "github.com/miekg/dns"
@@ -234,12 +233,5 @@ func FormatRecord(rr dns.RR) string {
if rr == nil { if rr == nil {
return "" return ""
} }
header := rr.Header() return rr.String()
return fmt.Sprintf("%s %d %s %s %s",
header.Name,
header.Ttl,
dns.ClassToString[header.Class],
QNameType(header.Rrtype),
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) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
servers, err := resolveRootServer(ctx, "a.root-servers.net.", false) servers, err := resolveRootServer(ctx, systemResolver(), "a.root-servers.net.", false)
if err != nil { if err != nil {
t.Skipf("skipping (no local DNS): %v", err) t.Skipf("skipping (no local DNS): %v", err)
} }
+61 -20
View File
@@ -26,7 +26,13 @@ func (rs *RootServer) AllIPs(includeAAAA bool) []net.IP {
} }
type RootDiscoveryConfig struct { type RootDiscoveryConfig struct {
// 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 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 AllRoots bool
IncludeAAAA bool IncludeAAAA bool
} }
@@ -43,20 +49,44 @@ func DiscoverRoots(ctx context.Context, cfg *RootDiscoveryConfig) ([]RootServer,
cfg = DefaultRootDiscoveryConfig() cfg = DefaultRootDiscoveryConfig()
} }
resolver := resolverFromConfig(cfg)
if cfg.Server != "" { if cfg.Server != "" {
return discoverRootOverride(ctx, cfg.Server, cfg.IncludeAAAA) return discoverRootOverride(ctx, resolver, cfg.Server, cfg.IncludeAAAA)
} }
if cfg.AllRoots { 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) { // filterHints returns a copy of hints with IPv6 addresses stripped when includeAAAA is false.
resolver := systemResolver() 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) nsMsg, err := queryResolver(ctx, resolver, ".", dns.TypeNS)
if err != nil { if err != nil {
return nil, fmt.Errorf("query root NS records: %w", err) 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) normalized := normalizeServerName(server)
for _, name := range nsSet { for _, name := range nsSet {
if normalizeServerName(name) == normalized { 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) { func discoverSingleRoot(ctx context.Context, resolver string, includeAAAA bool) ([]RootServer, error) {
resolver := systemResolver()
nsMsg, err := queryResolver(ctx, resolver, ".", dns.TypeNS) nsMsg, err := queryResolver(ctx, resolver, ".", dns.TypeNS)
if err != nil { if err != nil {
return nil, fmt.Errorf("query root NS records: %w", err) 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] pick := nsSet[0]
return resolveRootServer(ctx, pick, includeAAAA) return resolveRootServer(ctx, resolver, pick, includeAAAA)
} }
func discoverAllRoots(ctx context.Context, includeAAAA bool) ([]RootServer, error) { func discoverAllRoots(ctx context.Context, resolver string, includeAAAA bool) ([]RootServer, error) {
resolver := systemResolver()
nsMsg, err := queryResolver(ctx, resolver, ".", dns.TypeNS) nsMsg, err := queryResolver(ctx, resolver, ".", dns.TypeNS)
if err != nil { if err != nil {
return nil, fmt.Errorf("query root NS records: %w", err) 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 var servers []RootServer
for _, name := range nsSet { for _, name := range nsSet {
resolved, err := resolveRootServer(ctx, name, includeAAAA) resolved, err := resolveRootServer(ctx, resolver, name, includeAAAA)
if err != nil { if err != nil {
servers = append(servers, RootServer{Name: name}) servers = append(servers, RootServer{Name: name})
continue continue
@@ -132,9 +158,7 @@ func discoverAllRoots(ctx context.Context, includeAAAA bool) ([]RootServer, erro
return servers, nil return servers, nil
} }
func resolveRootServer(ctx context.Context, name string, includeAAAA bool) ([]RootServer, error) { func resolveRootServer(ctx context.Context, resolver, name string, includeAAAA bool) ([]RootServer, error) {
resolver := systemResolver()
var ipv4 []net.IP var ipv4 []net.IP
aMsg, err := queryResolver(ctx, resolver, name, dns.TypeA) aMsg, err := queryResolver(ctx, resolver, name, dns.TypeA)
if err == nil { 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 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 { func systemResolver() string {
cc, err := dns.ClientConfigFromFile("/etc/resolv.conf")
if err != nil || len(cc.Servers) == 0 {
return "127.0.0.1:53" 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) { func queryResolver(ctx context.Context, resolverAddr, name string, qtype uint16) (*dns.Msg, error) {
c := &dns.Client{ c := &dns.Client{
Net: "udp", Net: "udp",
ReadTimeout: 5, ReadTimeout: 5 * time.Second,
WriteTimeout: 5, WriteTimeout: 5 * time.Second,
} }
if deadline, ok := ctx.Deadline(); ok { if deadline, ok := ctx.Deadline(); ok {
c.ReadTimeout = time.Until(deadline) c.ReadTimeout = time.Until(deadline)
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"testing" "testing"
"time" "time"
dnsinternal "github.com/hits/ExploreDNS/internal/dns" dnsinternal "gitea.hansenits.com.au/hits/ExploreDNS/internal/dns"
"github.com/hits/ExploreDNS/internal/traverse" "gitea.hansenits.com.au/hits/ExploreDNS/internal/traverse"
"github.com/miekg/dns" "github.com/miekg/dns"
) )
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/hits/ExploreDNS/internal/dns" "gitea.hansenits.com.au/hits/ExploreDNS/internal/dns"
"github.com/hits/ExploreDNS/internal/traverse" "gitea.hansenits.com.au/hits/ExploreDNS/internal/traverse"
miekgdns "github.com/miekg/dns" miekgdns "github.com/miekg/dns"
) )
+1 -1
View File
@@ -15,7 +15,7 @@ import (
"io" "io"
"os" "os"
"github.com/hits/ExploreDNS/internal/traverse" "gitea.hansenits.com.au/hits/ExploreDNS/internal/traverse"
) )
type Format int type Format int
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/hits/ExploreDNS/internal/dns" "gitea.hansenits.com.au/hits/ExploreDNS/internal/dns"
"github.com/hits/ExploreDNS/internal/traverse" "gitea.hansenits.com.au/hits/ExploreDNS/internal/traverse"
miekgdns "github.com/miekg/dns" miekgdns "github.com/miekg/dns"
) )
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"encoding/json" "encoding/json"
"io" "io"
"github.com/hits/ExploreDNS/internal/dns" "gitea.hansenits.com.au/hits/ExploreDNS/internal/dns"
"github.com/hits/ExploreDNS/internal/traverse" "gitea.hansenits.com.au/hits/ExploreDNS/internal/traverse"
) )
type jsonFormatter struct { type jsonFormatter struct {
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"fmt" "fmt"
"net" "net"
"github.com/hits/ExploreDNS/internal/fingerprint" "gitea.hansenits.com.au/hits/ExploreDNS/internal/fingerprint"
"github.com/hits/ExploreDNS/internal/traverse" "gitea.hansenits.com.au/hits/ExploreDNS/internal/traverse"
) )
func RunTraversal(ctx context.Context, traverser *traverse.Traverser, cfg *Config, formatter Formatter, domain string) ([]traverse.TraversalResult, error) { func RunTraversal(ctx context.Context, traverser *traverse.Traverser, cfg *Config, formatter Formatter, domain string) ([]traverse.TraversalResult, error) {
+55 -2
View File
@@ -5,8 +5,8 @@ import (
"sort" "sort"
"strings" "strings"
"github.com/hits/ExploreDNS/internal/dns" "gitea.hansenits.com.au/hits/ExploreDNS/internal/dns"
"github.com/hits/ExploreDNS/internal/traverse" "gitea.hansenits.com.au/hits/ExploreDNS/internal/traverse"
miekgdns "github.com/miekg/dns" miekgdns "github.com/miekg/dns"
) )
@@ -144,6 +144,8 @@ func summaryTypeLabel(respType string) string {
return "query type not implemented by server" return "query type not implemented by server"
case "cname_loop": case "cname_loop":
return "resulted in a CNAME loop" return "resulted in a CNAME loop"
case "ns_error":
return "nameserver lookup failed"
case "error": case "error":
return "resulted in an error" return "resulted in an error"
case "referral": case "referral":
@@ -194,3 +196,54 @@ func containsString(items []string, target string) bool {
} }
return false 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
}
}
+3 -2
View File
@@ -4,8 +4,8 @@ import (
"net" "net"
"testing" "testing"
"github.com/hits/ExploreDNS/internal/dns" "gitea.hansenits.com.au/hits/ExploreDNS/internal/dns"
"github.com/hits/ExploreDNS/internal/traverse" "gitea.hansenits.com.au/hits/ExploreDNS/internal/traverse"
miekgdns "github.com/miekg/dns" miekgdns "github.com/miekg/dns"
) )
@@ -88,6 +88,7 @@ func TestSummaryTypeLabelAllTypes(t *testing.T) {
"refused": "query refused by server", "refused": "query refused by server",
"notimp": "query type not implemented by server", "notimp": "query type not implemented by server",
"cname_loop": "resulted in a CNAME loop", "cname_loop": "resulted in a CNAME loop",
"ns_error": "nameserver lookup failed",
"error": "resulted in an error", "error": "resulted in an error",
"referral": "resulted in a referral", "referral": "resulted in a referral",
"unknown_type": "unknown_type", "unknown_type": "unknown_type",
+15 -7
View File
@@ -6,8 +6,8 @@ import (
"sort" "sort"
"strings" "strings"
"github.com/hits/ExploreDNS/internal/dns" "gitea.hansenits.com.au/hits/ExploreDNS/internal/dns"
"github.com/hits/ExploreDNS/internal/traverse" "gitea.hansenits.com.au/hits/ExploreDNS/internal/traverse"
) )
type textFormatter struct { type textFormatter struct {
@@ -120,7 +120,8 @@ func (f *textFormatter) writeResults(results []traverse.TraversalResult) error {
} }
terminal := terminalResults(results) terminal := terminalResults(results)
for _, result := range terminal { deduped := DeduplicateResults(terminal)
for _, result := range deduped {
prefix := strings.Repeat(" ", result.Referral.Depth+1) prefix := strings.Repeat(" ", result.Referral.Depth+1)
line := prefix + f.formatResultLine(result) line := prefix + f.formatResultLine(result)
if _, err := fmt.Fprintln(f.w, line); err != nil { 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) prob := formatProbability(result.Referral.Prob)
switch result.Response.Type { switch result.Response.Type {
case traverse.RespAnswer: case traverse.RespAnswer:
key, rrs := answerKey(result.Response) key, _ := answerKey(result.Response)
if key == "" { if key == "" {
return fmt.Sprintf("%s resulted in answer", prob) return fmt.Sprintf("%s resulted in answer", prob)
} }
if len(rrs) == 1 { nsLabel := ""
return f.colorize(fmt.Sprintf("%s answered with %s", prob, rrs[0]), colorGreen) 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: case traverse.RespNODATA:
return fmt.Sprintf("%s found no such record", prob) return fmt.Sprintf("%s found no such record", prob)
case traverse.RespNXDOMAIN: case traverse.RespNXDOMAIN:
@@ -220,6 +222,12 @@ func (f *textFormatter) formatResultLine(result traverse.TraversalResult) string
msg = result.Response.ErrorMessage msg = result.Response.ErrorMessage
} }
return f.colorize(fmt.Sprintf("%s %s", prob, msg), colorRed) 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: case traverse.RespError:
msg := "resulted in an error" msg := "resulted in an error"
if result.Response.ErrorMessage != "" { if result.Response.ErrorMessage != "" {
+3 -2
View File
@@ -6,8 +6,8 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/hits/ExploreDNS/internal/dns" "gitea.hansenits.com.au/hits/ExploreDNS/internal/dns"
"github.com/hits/ExploreDNS/internal/traverse" "gitea.hansenits.com.au/hits/ExploreDNS/internal/traverse"
miekgdns "github.com/miekg/dns" miekgdns "github.com/miekg/dns"
) )
@@ -121,6 +121,7 @@ Answers: []miekgdns.RR{
{"refused", traverse.RespREFUSED, nil, ""}, {"refused", traverse.RespREFUSED, nil, ""},
{"notimp", traverse.RespNOTIMPL, nil, ""}, {"notimp", traverse.RespNOTIMPL, nil, ""},
{"cname_loop", traverse.RespCNAMELoop, nil, "loop detected"}, {"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"}, {"error", traverse.RespError, nil, "something went wrong"},
} }
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"testing" "testing"
"time" "time"
dnsinternal "github.com/hits/ExploreDNS/internal/dns" dnsinternal "gitea.hansenits.com.au/hits/ExploreDNS/internal/dns"
"github.com/miekg/dns" "github.com/miekg/dns"
) )
+5 -2
View File
@@ -6,7 +6,7 @@ import (
"net" "net"
"strings" "strings"
"github.com/hits/ExploreDNS/internal/dns" "gitea.hansenits.com.au/hits/ExploreDNS/internal/dns"
miekgdns "github.com/miekg/dns" miekgdns "github.com/miekg/dns"
"golang.org/x/net/idna" "golang.org/x/net/idna"
) )
@@ -230,7 +230,10 @@ func (r *Referral) Resolve(ctx context.Context, traverser *Traverser, cache *Inf
if resp.Type == RespReferral { if resp.Type == RespReferral {
children := resp.ChildReferrals() children := resp.ChildReferrals()
for _, child := range children { 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 continue
} }
if !stack.Push(child) { if !stack.Push(child) {
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"net" "net"
"testing" "testing"
"github.com/hits/ExploreDNS/internal/dns" "gitea.hansenits.com.au/hits/ExploreDNS/internal/dns"
) )
const TypeA = dns.TypeA const TypeA = dns.TypeA
+9 -2
View File
@@ -3,7 +3,7 @@ package traverse
import ( import (
"net" "net"
"github.com/hits/ExploreDNS/internal/dns" "gitea.hansenits.com.au/hits/ExploreDNS/internal/dns"
miekgdns "github.com/miekg/dns" miekgdns "github.com/miekg/dns"
) )
@@ -20,6 +20,11 @@ const (
RespNOTIMPL RespNOTIMPL
RespCNAMELoop RespCNAMELoop
RespError 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 { func (rt ResponseType) String() string {
@@ -44,6 +49,8 @@ func (rt ResponseType) String() string {
return "cname_loop" return "cname_loop"
case RespError: case RespError:
return "error" return "error"
case RespNSResolutionFailed:
return "ns_error"
default: default:
return "unknown" return "unknown"
} }
@@ -231,7 +238,7 @@ func (r *Response) resolveGlue(child *Referral) {
func (r *Response) IsTerminal() bool { func (r *Response) IsTerminal() bool {
switch r.Type { switch r.Type {
case RespAnswer, RespNODATA, RespNXDOMAIN, RespSERVFAIL, RespREFUSED, RespNOTIMPL, RespCNAMELoop, RespError: case RespAnswer, RespNODATA, RespNXDOMAIN, RespSERVFAIL, RespREFUSED, RespNOTIMPL, RespCNAMELoop, RespError, RespNSResolutionFailed:
return true return true
default: default:
return false return false
+2
View File
@@ -246,6 +246,7 @@ func TestResponseIsTerminal(t *testing.T) {
{RespNXDOMAIN, true}, {RespNXDOMAIN, true},
{RespSERVFAIL, true}, {RespSERVFAIL, true},
{RespError, true}, {RespError, true},
{RespNSResolutionFailed, true},
{RespReferral, false}, {RespReferral, false},
{RespCNAMEFollow, false}, {RespCNAMEFollow, false},
} }
@@ -272,6 +273,7 @@ func TestResponseTypeString(t *testing.T) {
{RespNXDOMAIN, "nxdomain"}, {RespNXDOMAIN, "nxdomain"},
{RespSERVFAIL, "servfail"}, {RespSERVFAIL, "servfail"},
{RespError, "error"}, {RespError, "error"},
{RespNSResolutionFailed, "ns_error"},
} }
for _, tt := range tests { for _, tt := range tests {
+1 -1
View File
@@ -3,7 +3,7 @@ package traverse
import ( import (
"testing" "testing"
"github.com/hits/ExploreDNS/internal/dns" "gitea.hansenits.com.au/hits/ExploreDNS/internal/dns"
) )
func TestNewStack(t *testing.T) { func TestNewStack(t *testing.T) {
+21 -7
View File
@@ -4,10 +4,11 @@ import (
"context" "context"
"fmt" "fmt"
"net" "net"
"strings"
"sync" "sync"
"time" "time"
"github.com/hits/ExploreDNS/internal/dns" "gitea.hansenits.com.au/hits/ExploreDNS/internal/dns"
miekgdns "github.com/miekg/dns" miekgdns "github.com/miekg/dns"
) )
@@ -226,15 +227,24 @@ func (t *Traverser) processReferral(ctx context.Context, ref *Referral, cache *I
} }
t.mu.Unlock() 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 { if len(ref.Addresses) > 0 {
ref.State = StateResolved ref.State = StateResolved
} else { } 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 { if err != nil {
return &Response{ return &Response{
Referral: ref, Referral: ref,
Type: RespError, Type: RespNSResolutionFailed,
ErrorMessage: fmt.Sprintf("nameserver %s could not be resolved", nsName),
} }
} }
if len(addrs) > 0 { if len(addrs) > 0 {
@@ -243,7 +253,8 @@ func (t *Traverser) processReferral(ctx context.Context, ref *Referral, cache *I
} else { } else {
return &Response{ return &Response{
Referral: ref, Referral: ref,
Type: RespError, 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 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) lastErr = fmt.Errorf("server error resolving %s: %s", nsName, resp.Type)
continue continue
} }
@@ -368,7 +379,10 @@ func (t *Traverser) ResolveNS(ctx context.Context, nsName string, cache *InfoCac
if resp.Type == RespReferral { if resp.Type == RespReferral {
children := resp.ChildReferrals() children := resp.ChildReferrals()
for _, child := range children { 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 continue
} }
if !stack.Push(child) { if !stack.Push(child) {
+3 -3
View File
@@ -10,9 +10,9 @@ import (
"sync" "sync"
"time" "time"
"github.com/hits/ExploreDNS/internal/config" "gitea.hansenits.com.au/hits/ExploreDNS/internal/config"
idns "github.com/hits/ExploreDNS/internal/dns" idns "gitea.hansenits.com.au/hits/ExploreDNS/internal/dns"
"github.com/hits/ExploreDNS/internal/traverse" "gitea.hansenits.com.au/hits/ExploreDNS/internal/traverse"
) )
// Job status values. // Job status values.
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/hits/ExploreDNS/web/api" "gitea.hansenits.com.au/hits/ExploreDNS/web/api"
) )
// newTestServer starts a Server on a random port and returns it. // newTestServer starts a Server on a random port and returns it.