Skip to content
Open
69 changes: 69 additions & 0 deletions dns-strict-resolver/README.md
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
```
Comment on lines +57 to +62

Copilot AI Apr 21, 2026

Copy link

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 run go build . (producing ./dns-strict-resolver). Consider adding an explicit build step (or use -c "go run .").

Copilot uses AI. Check for mistakes.

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`. |
22 changes: 22 additions & 0 deletions dns-strict-resolver/curl.sh
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 ==="
3 changes: 3 additions & 0 deletions dns-strict-resolver/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module dns-strict-resolver

go 1.22.0
232 changes: 232 additions & 0 deletions dns-strict-resolver/main.go
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

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

buildQuery ignores the returned errors from binary.Write. With errcheck enabled in this repo, this will be reported (and it also makes error propagation inconsistent if the writer ever changes). Please handle the errors (and return them) or build the header using byte-slice appends that don’t involve error-returning writes.

Copilot uses AI. Check for mistakes.
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

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

buildQuery also ignores errors from WriteByte / WriteString (and later binary.Write calls). With errcheck enabled, this will be flagged. Please handle these errors (or restructure to avoid error-returning writes).

Copilot uses AI. Check for mistakes.
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

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseReply can return nil error for truncated/malformed replies: in the question section, it does off += 4 without checking off+4 <= len(reply), so malformed packets can silently produce empty results. Consider validating bounds and returning an error when the message is structurally invalid.

Copilot uses AI. Check for mistakes.
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

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveStrict returns early on several error paths without setting ElapsedMS, so the JSON response can show elapsed_ms: 0 even though work was done (and it differs from later error paths where ElapsedMS is set). For consistency and easier CI diagnostics, consider setting ElapsedMS on all returns (e.g., via a defer that updates it).

Copilot uses AI. Check for mistakes.
conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

net.ListenUDP is currently bound to 0.0.0.0 (IPv4 only). If /etc/resolv.conf (or the nameserver query param) provides an IPv6 nameserver, the lookup will fail due to address-family mismatch. Consider selecting udp4 vs udp6 and binding to the matching unspecified address based on ns.IP.To4() (or use a dual-stack approach).

Suggested change
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 uses AI. Check for mistakes.
if err != nil {
r.Error = err.Error()
return r
}
defer conn.Close()

Copilot AI Apr 21, 2026

Copy link

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 uses AI. Check for mistakes.

query, err := buildQuery(domain, 0x4242)
if err != nil {
r.Error = err.Error()
return r
}

deadline := time.Now().Add(3 * time.Second)
for attempt := 1; attempt <= 3 && time.Now().Before(deadline); attempt++ {
r.Attempts = attempt
if _, err := conn.WriteToUDP(query, ns); err != nil {
r.Error = err.Error()
r.ElapsedMS = time.Since(start).Milliseconds()
return r
}
for time.Now().Before(deadline) {
_ = conn.SetReadDeadline(time.Now().Add(800 * time.Millisecond))

Copilot AI Apr 21, 2026

Copy link

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.

Suggested change
_ = 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 uses AI. Check for mistakes.
buf := make([]byte, 1500)
n, src, rerr := conn.ReadFromUDP(buf)
if rerr != nil {
break
}
if !src.IP.Equal(ns.IP) || src.Port != ns.Port {
r.SourceMismatches++
continue
}
p, perr := parseReply(buf[:n])
if perr != nil {
r.Error = perr.Error()
r.ElapsedMS = time.Since(start).Milliseconds()
return r
}
r.Rcode = p.Rcode
r.IPs = p.Answers
r.ElapsedMS = time.Since(start).Milliseconds()
return r
}
}
r.Error = fmt.Sprintf("no accepted reply from %s after %d attempts", nsAddr, r.Attempts)
r.ElapsedMS = time.Since(start).Milliseconds()
return r
}

func defaultNameserver() string {
data, err := os.ReadFile("/etc/resolv.conf")
if err != nil {
return "8.8.8.8:53"
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "nameserver ") {
return net.JoinHostPort(strings.TrimPrefix(line, "nameserver "), "53")
Comment on lines +476 to +478

Copilot AI Apr 21, 2026

Copy link

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.

Suggested change
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 uses AI. Check for mistakes.
}
}
return "8.8.8.8:53"
}

func main() {
ns := defaultNameserver()

http.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "ok")

Copilot AI Apr 21, 2026

Copy link

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).

Suggested change
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)
}

Copilot uses AI. Check for mistakes.
})

http.HandleFunc("/resolve", func(w http.ResponseWriter, r *http.Request) {
domain := r.URL.Query().Get("domain")
if domain == "" {
domain = "google.com"
}
server := r.URL.Query().Get("nameserver")
if server == "" {
server = ns
}
res := resolveStrict(domain, server)
w.Header().Set("Content-Type", "application/json")
if res.Error != "" {
w.WriteHeader(http.StatusBadGateway)
}
if err := json.NewEncoder(w).Encode(res); err != nil {
fmt.Fprintf(os.Stderr, "encode error: %v\n", err)
}
})

port := "8086"
fmt.Printf("dns-strict-resolver listening on :%s (default nameserver=%s)\n", port, ns)
if err := http.ListenAndServe(":"+port, nil); err != nil {
fmt.Fprintf(os.Stderr, "server error: %v\n", err)
os.Exit(1)
}
}
Loading