-
Notifications
You must be signed in to change notification settings - Fork 95
feat(dns-strict-resolver): add unconnected-UDP strict DNS sample #223
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
0af73d4
8df6ea8
41518fe
b61910d
d8e51ef
52906f6
c535af0
77670ef
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| # dns-strict-resolver | ||
|
|
||
| Minimal Go HTTP server that exercises the **unconnected-UDP + RFC 5452 | ||
| strict-source-validation** DNS client path. Used by Keploy's e2e CI as a | ||
| regression guard for the `cgroup/recvmsg{4,6}` SNAT fix. | ||
|
|
||
| - Tracking issue: https://github.com/keploy/keploy/issues/4092 | ||
| - Keploy fix: https://github.com/keploy/keploy/pull/4093 | ||
| - eBPF fix: https://github.com/keploy/ebpf/pull/97 | ||
|
|
||
| ## Why a raw UDP client? | ||
|
|
||
| `net.LookupHost` on glibc (cgo) uses connected UDP most of the time, and | ||
| connected-UDP clients are rescued by Keploy's existing | ||
| `cgroup/getpeername4` hook — so they never exposed this bug. The | ||
| production failure mode (`java.net.UnknownHostException: Temporary | ||
| failure in name resolution` / `EAI_AGAIN`) only surfaces on the | ||
| unconnected-UDP path, where the client is responsible for validating the | ||
| reply's source address itself. | ||
|
|
||
| This sample sends a DNS A query over an **unconnected** UDP socket, | ||
| reads replies with `ReadFromUDP`, and **discards any reply whose source | ||
| does not match the nameserver it queried** — the same check that | ||
| `dnspython`, raw `recvfrom`-based clients, and glibc's `res_send` | ||
| unconnected path perform. | ||
|
|
||
| ## Running | ||
|
|
||
| ```bash | ||
| go run . & | ||
| curl -sS "http://localhost:8086/resolve?domain=google.com" | ||
| ``` | ||
|
|
||
| Expected shape (post-fix): | ||
| ```json | ||
| { | ||
| "domain": "google.com", | ||
| "nameserver": "127.0.0.11:53", | ||
| "rcode": 0, | ||
| "ips": ["142.250.x.x", "..."], | ||
| "source_mismatches": 0, | ||
| "attempts": 1, | ||
| "elapsed_ms": 4 | ||
| } | ||
| ``` | ||
|
|
||
| Under the **buggy** (pre-fix) Keploy, replies arrive from | ||
| `<agent_ip>:<keploy_dns_port>` instead of the configured nameserver, the | ||
| source check rejects them, and `/resolve` eventually returns HTTP 502 | ||
| with a non-zero `source_mismatches` counter and no answers. | ||
|
|
||
| ## Under Keploy | ||
|
|
||
| ```bash | ||
| sudo -E env PATH=$PATH keploy record -c "./dns-strict-resolver" | ||
| # hit /resolve endpoints, then stop keploy | ||
|
|
||
| sudo -E env PATH=$PATH keploy test -c "./dns-strict-resolver" --delay 10 | ||
| ``` | ||
|
|
||
| Both record and test must complete with `source_mismatches: 0` and a | ||
| non-empty `ips` list for the sample to pass. | ||
|
|
||
| ## Endpoints | ||
|
|
||
| | Path | Description | | ||
| | --------------------------------------------- | ------------------------------------------------------ | | ||
| | `GET /health` | Liveness probe used by the CI script. | | ||
| | `GET /resolve?domain=<d>&nameserver=<ip:53>` | Strict A-record lookup. `domain` defaults to `google.com`; `nameserver` defaults to the first entry in `/etc/resolv.conf`. | | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| #!/bin/bash | ||
| # Traffic generation for the dns-strict-resolver E2E test. | ||
| # Exercises the unconnected-UDP + RFC 5452 strict-source-validation path | ||
| # that surfaces keploy/keploy#4092. | ||
|
|
||
| set -euo pipefail | ||
|
|
||
| BASE="http://localhost:8086" | ||
|
|
||
| echo "=== strict resolve: google.com ===" | ||
| curl -sS --max-time 10 "$BASE/resolve?domain=google.com" | ||
| echo | ||
|
|
||
| echo "=== strict resolve: cloudflare.com ===" | ||
| curl -sS --max-time 10 "$BASE/resolve?domain=cloudflare.com" | ||
| echo | ||
|
|
||
| echo "=== strict resolve: example.com ===" | ||
| curl -sS --max-time 10 "$BASE/resolve?domain=example.com" | ||
| echo | ||
|
|
||
| echo "=== Done ===" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| module dns-strict-resolver | ||
|
|
||
| go 1.22.0 |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,232 @@ | ||||||||||||||||||||||
| // Package main is a minimal HTTP server that exercises the RFC 5452 | ||||||||||||||||||||||
| // "strict source address validation" DNS client path used by dnspython, | ||||||||||||||||||||||
| // raw recvfrom-based clients, and glibc res_send on its unconnected UDP | ||||||||||||||||||||||
| // path. It is the smallest self-contained reproducer of the failure | ||||||||||||||||||||||
| // mode fixed by https://github.com/keploy/keploy/pull/4093 / | ||||||||||||||||||||||
| // https://github.com/keploy/ebpf/pull/97 (tracking issue | ||||||||||||||||||||||
| // https://github.com/keploy/keploy/issues/4092). | ||||||||||||||||||||||
| // | ||||||||||||||||||||||
| // Why we do raw UDP here instead of net.LookupHost: | ||||||||||||||||||||||
| // - net.LookupHost on glibc (cgo) uses connected UDP most of the time. | ||||||||||||||||||||||
| // Connected-UDP clients are rescued by Keploy's existing | ||||||||||||||||||||||
| // cgroup/getpeername4 hook and therefore never exposed the bug. | ||||||||||||||||||||||
| // - The production symptom ("Temporary failure in name resolution" / | ||||||||||||||||||||||
| // EAI_AGAIN) only surfaces on the unconnected UDP path, where the | ||||||||||||||||||||||
| // client validates the reply's source address itself. | ||||||||||||||||||||||
| // | ||||||||||||||||||||||
| // With the buggy version of Keploy, /resolve returns with a non-zero | ||||||||||||||||||||||
| // "source_mismatches" counter and eventually HTTP 502. After the fix, | ||||||||||||||||||||||
| // the reply's source is rewritten back to the nameserver the client | ||||||||||||||||||||||
| // queried, the source check passes, and /resolve returns the A records. | ||||||||||||||||||||||
| package main | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| import ( | ||||||||||||||||||||||
| "bytes" | ||||||||||||||||||||||
| "encoding/binary" | ||||||||||||||||||||||
| "encoding/json" | ||||||||||||||||||||||
| "fmt" | ||||||||||||||||||||||
| "net" | ||||||||||||||||||||||
| "net/http" | ||||||||||||||||||||||
| "os" | ||||||||||||||||||||||
| "strings" | ||||||||||||||||||||||
| "time" | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| func buildQuery(domain string, txid uint16) ([]byte, error) { | ||||||||||||||||||||||
| var b bytes.Buffer | ||||||||||||||||||||||
| binary.Write(&b, binary.BigEndian, txid) | ||||||||||||||||||||||
| binary.Write(&b, binary.BigEndian, uint16(0x0100)) // RD=1 | ||||||||||||||||||||||
| binary.Write(&b, binary.BigEndian, uint16(1)) // QDCOUNT | ||||||||||||||||||||||
| binary.Write(&b, binary.BigEndian, uint16(0)) // ANCOUNT | ||||||||||||||||||||||
| binary.Write(&b, binary.BigEndian, uint16(0)) // NSCOUNT | ||||||||||||||||||||||
| binary.Write(&b, binary.BigEndian, uint16(0)) // ARCOUNT | ||||||||||||||||||||||
|
Comment on lines
+41
to
+48
|
||||||||||||||||||||||
| for _, label := range strings.Split(strings.TrimSuffix(domain, "."), ".") { | ||||||||||||||||||||||
| if label == "" { | ||||||||||||||||||||||
| continue | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| if len(label) > 63 { | ||||||||||||||||||||||
| return nil, fmt.Errorf("label too long: %q", label) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| b.WriteByte(byte(len(label))) | ||||||||||||||||||||||
| b.WriteString(label) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| b.WriteByte(0) | ||||||||||||||||||||||
| binary.Write(&b, binary.BigEndian, uint16(1)) // QTYPE A | ||||||||||||||||||||||
| binary.Write(&b, binary.BigEndian, uint16(1)) // QCLASS IN | ||||||||||||||||||||||
|
Comment on lines
+56
to
+61
|
||||||||||||||||||||||
| return b.Bytes(), nil | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // skipName walks past a DNS name at offset i, respecting compression | ||||||||||||||||||||||
| // pointers, and returns the byte index just past the name. | ||||||||||||||||||||||
| func skipName(buf []byte, i int) int { | ||||||||||||||||||||||
| for i < len(buf) { | ||||||||||||||||||||||
| l := buf[i] | ||||||||||||||||||||||
| if l == 0 { | ||||||||||||||||||||||
| return i + 1 | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| if l&0xc0 == 0xc0 { | ||||||||||||||||||||||
| return i + 2 | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| i += 1 + int(l) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| return i | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| type parsed struct { | ||||||||||||||||||||||
| Rcode int | ||||||||||||||||||||||
| Answers []string | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| func parseReply(reply []byte) (parsed, error) { | ||||||||||||||||||||||
| if len(reply) < 12 { | ||||||||||||||||||||||
| return parsed{}, fmt.Errorf("reply too short") | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| flags := binary.BigEndian.Uint16(reply[2:4]) | ||||||||||||||||||||||
| qd := binary.BigEndian.Uint16(reply[4:6]) | ||||||||||||||||||||||
| an := binary.BigEndian.Uint16(reply[6:8]) | ||||||||||||||||||||||
| out := parsed{Rcode: int(flags & 0x000F)} | ||||||||||||||||||||||
| off := 12 | ||||||||||||||||||||||
| for q := uint16(0); q < qd && off < len(reply); q++ { | ||||||||||||||||||||||
| off = skipName(reply, off) | ||||||||||||||||||||||
| off += 4 | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
Comment on lines
+96
to
+100
|
||||||||||||||||||||||
| for a := uint16(0); a < an && off+10 <= len(reply); a++ { | ||||||||||||||||||||||
| off = skipName(reply, off) | ||||||||||||||||||||||
| if off+10 > len(reply) { | ||||||||||||||||||||||
| break | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| atype := binary.BigEndian.Uint16(reply[off : off+2]) | ||||||||||||||||||||||
| rdlen := int(binary.BigEndian.Uint16(reply[off+8 : off+10])) | ||||||||||||||||||||||
| off += 10 | ||||||||||||||||||||||
| if atype == 1 && rdlen == 4 && off+rdlen <= len(reply) { | ||||||||||||||||||||||
| out.Answers = append(out.Answers, | ||||||||||||||||||||||
| net.IPv4(reply[off], reply[off+1], reply[off+2], reply[off+3]).String()) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| off += rdlen | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| return out, nil | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| type result struct { | ||||||||||||||||||||||
| Domain string `json:"domain"` | ||||||||||||||||||||||
| Nameserver string `json:"nameserver"` | ||||||||||||||||||||||
| Rcode int `json:"rcode"` | ||||||||||||||||||||||
| IPs []string `json:"ips,omitempty"` | ||||||||||||||||||||||
| SourceMismatches int `json:"source_mismatches"` | ||||||||||||||||||||||
| Attempts int `json:"attempts"` | ||||||||||||||||||||||
| ElapsedMS int64 `json:"elapsed_ms"` | ||||||||||||||||||||||
| Error string `json:"error,omitempty"` | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // resolveStrict sends an A-record query for domain to nsAddr over | ||||||||||||||||||||||
| // unconnected UDP and accepts the reply only if its source matches | ||||||||||||||||||||||
| // nsAddr (RFC 5452 §9.1 "birthday attack" mitigation / anti-spoofing). | ||||||||||||||||||||||
| // Replies whose source does not match are counted in SourceMismatches | ||||||||||||||||||||||
| // and silently discarded, mirroring what dnspython and glibc's | ||||||||||||||||||||||
| // unconnected-UDP path do. | ||||||||||||||||||||||
| func resolveStrict(domain, nsAddr string) result { | ||||||||||||||||||||||
| start := time.Now() | ||||||||||||||||||||||
| r := result{Domain: domain, Nameserver: nsAddr} | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| ns, err := net.ResolveUDPAddr("udp", nsAddr) | ||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||
| r.Error = err.Error() | ||||||||||||||||||||||
| return r | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
Comment on lines
+166
to
+170
|
||||||||||||||||||||||
| conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0}) | ||||||||||||||||||||||
|
||||||||||||||||||||||
| conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0}) | |
| listenNetwork := "udp4" | |
| listenAddr := &net.UDPAddr{IP: net.IPv4zero, Port: 0} | |
| if ns.IP.To4() == nil { | |
| listenNetwork = "udp6" | |
| listenAddr = &net.UDPAddr{IP: net.IPv6unspecified, Port: 0} | |
| } | |
| conn, err := net.ListenUDP(listenNetwork, listenAddr) |
Copilot
AI
Apr 21, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
defer conn.Close() drops the returned error, which errcheck will report in this repo. Please either handle the close error in a deferred func or explicitly suppress it with //nolint:errcheck (as done elsewhere in the repo).
Copilot
AI
Apr 21, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The error from SetReadDeadline is intentionally ignored (_ = ...), but with errcheck enabled this will be flagged, and if setting the deadline fails the read loop behavior becomes unpredictable. Please handle the error (e.g., return it via res.Error) or explicitly justify/suppress it.
| _ = conn.SetReadDeadline(time.Now().Add(800 * time.Millisecond)) | |
| if err := conn.SetReadDeadline(time.Now().Add(800 * time.Millisecond)); err != nil { | |
| r.Error = err.Error() | |
| r.ElapsedMS = time.Since(start).Milliseconds() | |
| return r | |
| } |
Copilot
AI
Apr 21, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
defaultNameserver only matches lines starting with the literal prefix "nameserver ", so it will miss valid resolv.conf entries that use tabs/multiple spaces, and it may also break if the line has an inline comment (e.g. nameserver 1.1.1.1 # ...). Consider parsing with strings.Fields and stripping # comments before extracting the IP.
| line = strings.TrimSpace(line) | |
| if strings.HasPrefix(line, "nameserver ") { | |
| return net.JoinHostPort(strings.TrimPrefix(line, "nameserver "), "53") | |
| line, _, _ = strings.Cut(line, "#") | |
| fields := strings.Fields(line) | |
| if len(fields) >= 2 && fields[0] == "nameserver" { | |
| return net.JoinHostPort(fields[1], "53") |
Copilot
AI
Apr 21, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The /health handler ignores the error return from fmt.Fprint. With errcheck enabled, this will be flagged. Please check the returned error (or explicitly suppress with a justified //nolint:errcheck).
| fmt.Fprint(w, "ok") | |
| if _, err := fmt.Fprint(w, "ok"); err != nil { | |
| fmt.Fprintf(os.Stderr, "failed to write /health response: %v; check whether the client disconnected before retrying the request\n", err) | |
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The “Under Keploy” section runs
keploy record -c "./dns-strict-resolver"/keploy test -c "./dns-strict-resolver"but doesn’t mention building the binary first. As written, these commands will fail unless the user has already rungo build .(producing./dns-strict-resolver). Consider adding an explicit build step (or use-c "go run .").