#!/usr/bin/env bash # SPDX-FileCopyrightText: © 2022—2026 Vladimir Zorin # SPDX-License-Identifier: LicenseRef-OWL-1.0-or-later OR GPL-3.0-or-later # Dual-licensed under OWL v1.0+ and GPLv3+. See LICENSE and LICENSE-GPL3. # # Capture real DNS packets as hex strings for test fixtures. # Requires root (tcpdump), dig, and tshark for payload extraction. # # Usage: sudo bash tests/dns/capture_fixtures.bash [output_dir] # # Output: # fixtures.lua -- hex-encoded DNS payloads # fixtures_expected.lua -- dig-parsed expected values set -euo pipefail OUTPUT_DIR="${1:-$(dirname "$0")}" FIXTURES_FILE="${OUTPUT_DIR}/fixtures.lua" EXPECTED_FILE="${OUTPUT_DIR}/fixtures_expected.lua" TMPDIR=$(mktemp -d) trap 'rm -rf "${TMPDIR}"' EXIT # ─── Helpers ────────────────────────────────────────────────────────── die() { echo "ERROR: $*" >&2; exit 1; } # Extract hand-crafted section from an existing Lua fixture file. # Captures from an indented "-- Hand-crafted" marker (inside the return table) # to the line before closing "}". The preamble comment at column 0 is skipped # because the regex requires leading whitespace (tab or spaces). extract_handcrafted() { local file="$1" [ -f "$file" ] || return 0 sed -n '/^[[:blank:]][[:blank:]]*-- Hand-crafted/,/^}$/{ /^}$/d; p; }' "$file" } check_deps() { command -v dig >/dev/null || die "dig not found (install dnsutils/bind-utils)" command -v tcpdump >/dev/null || die "tcpdump not found" command -v tshark >/dev/null || die "tshark not found (install wireshark-cli/tshark)" } # Extract DNS payload (skip Ethernet/IP/UDP headers) from pcap via tshark. # Outputs one hex string per DNS message, query first then response. extract_dns_hex() { local pcap="$1" tshark -r "$pcap" -T fields -e udp.payload 2>/dev/null \ | tr -d ':' | sed 's/[[:blank:]]//g; /^$/d' | tr '[:upper:]' '[:lower:]' } # Parse dig output to extract expected values for a fixture. parse_dig_expected() { local label="$1" local dig_output="$2" local qname qtype qclass rcode local qr=1 rd=1 ra=1 aa="false" tc="false" local qdcount=0 ancount=0 nscount=0 arcount=0 # Parse flags line: ";; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 12345" local status_line status_line=$(grep "HEADER" "$dig_output" || true) if [ -n "$status_line" ]; then rcode=$(echo "$status_line" | sed -n 's/.*status: \([A-Z]*\).*/\1/p') case "$rcode" in NOERROR) rcode=0 ;; FORMERR) rcode=1 ;; SERVFAIL) rcode=2 ;; NXDOMAIN) rcode=3 ;; NOTIMP) rcode=4 ;; REFUSED) rcode=5 ;; *) rcode=0 ;; esac fi # Parse flags: ";; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 0" local flags_line flags_line=$(grep "^;; flags:" "$dig_output" || true) if [ -n "$flags_line" ]; then echo "$flags_line" | grep -q " aa" && aa="true" echo "$flags_line" | grep -q " tc" && tc="true" qdcount=$(echo "$flags_line" | sed -n 's/.*QUERY: \([0-9]*\).*/\1/p') ancount=$(echo "$flags_line" | sed -n 's/.*ANSWER: \([0-9]*\).*/\1/p') nscount=$(echo "$flags_line" | sed -n 's/.*AUTHORITY: \([0-9]*\).*/\1/p') arcount=$(echo "$flags_line" | sed -n 's/.*ADDITIONAL: \([0-9]*\).*/\1/p') fi # Parse question section: ";example.com. IN A" local question_line question_line=$(grep "^;" "$dig_output" | grep -v "^;;" | head -1 || true) if [ -n "$question_line" ]; then qname=$(echo "$question_line" | awk '{print $1}' | sed 's/^;//') qclass_str=$(echo "$question_line" | awk '{print $2}') qtype_str=$(echo "$question_line" | awk '{print $3}') case "$qtype_str" in A) qtype=1 ;; AAAA) qtype=28 ;; CNAME) qtype=5 ;; MX) qtype=15 ;; NS) qtype=2 ;; PTR) qtype=12 ;; SOA) qtype=6 ;; SRV) qtype=33 ;; TXT) qtype=16 ;; *) qtype=1 ;; esac case "$qclass_str" in IN) qclass=1 ;; CH) qclass=3 ;; HS) qclass=4 ;; *) qclass=1 ;; esac fi # Collect answer names local answer_names="" while IFS= read -r line; do local name name=$(echo "$line" | awk '{print $1}') if [ -n "$answer_names" ]; then answer_names="$answer_names, \"$name\"" else answer_names="\"$name\"" fi done < <(sed -n '/^;; ANSWER SECTION:/,/^$/{ /^[^;]/p }' "$dig_output") # Collect authority names local authority_names="" while IFS= read -r line; do local name name=$(echo "$line" | awk '{print $1}') if [ -n "$authority_names" ]; then authority_names="$authority_names, \"$name\"" else authority_names="\"$name\"" fi done < <(sed -n '/^;; AUTHORITY SECTION:/,/^$/{ /^[^;]/p }' "$dig_output") # Extract first answer RDATA based on query type (for validation) local answer_rdata="" local first_answer first_answer=$(sed -n '/^;; ANSWER SECTION:/,/^$/{ /^[^;]/p }' "$dig_output" | head -1) if [ -n "$first_answer" ]; then local rr_type rr_type=$(echo "$first_answer" | awk '{print $4}') case "$rr_type" in A) local addr addr=$(echo "$first_answer" | awk '{print $NF}') answer_rdata="first_a = \"$addr\"," ;; AAAA) local addr addr=$(echo "$first_answer" | awk '{print $NF}') answer_rdata="first_aaaa = \"$addr\"," ;; MX) local pref exchange pref=$(echo "$first_answer" | awk '{print $(NF-1)}') exchange=$(echo "$first_answer" | awk '{print $NF}') answer_rdata="first_mx_pref = $pref, first_mx_exchange = \"$exchange\"," ;; NS) local nsdname nsdname=$(echo "$first_answer" | awk '{print $NF}') answer_rdata="first_nsdname = \"$nsdname\"," ;; TXT) # TXT rdata is everything after the 4th field, in quotes local txt_val txt_val=$(echo "$first_answer" | awk '{for(i=5;i<=NF;i++) printf "%s ", $i}' | sed 's/ *$//') answer_rdata="first_txt = '$txt_val'," ;; SOA) local mname rname serial refresh retry expire minimum mname=$(echo "$first_answer" | awk '{print $5}') rname=$(echo "$first_answer" | awk '{print $6}') serial=$(echo "$first_answer" | awk '{print $7}') refresh=$(echo "$first_answer" | awk '{print $8}') retry=$(echo "$first_answer" | awk '{print $9}') expire=$(echo "$first_answer" | awk '{print $10}') minimum=$(echo "$first_answer" | awk '{print $11}') answer_rdata="soa = { mname = \"$mname\", rname = \"$rname\", serial = $serial, refresh = $refresh, retry = $retry, expire = $expire, minimum = $minimum }," ;; PTR) local ptrdname ptrdname=$(echo "$first_answer" | awk '{print $NF}') answer_rdata="first_ptrdname = \"$ptrdname\"," ;; esac fi # Detect EDNS OPT from dig output (";; OPT PSEUDOSECTION:" block) local edns_info="" local opt_line opt_line=$(grep "EDNS:" "$dig_output" 2>/dev/null || true) if [ -n "$opt_line" ]; then local edns_version udp_size edns_version=$(echo "$opt_line" | sed -n 's/.*version: \([0-9]*\).*/\1/p') udp_size=$(echo "$opt_line" | sed -n 's/.*udp: \([0-9]*\).*/\1/p') local do_bit="false" echo "$opt_line" | grep -q "do" && do_bit="true" edns_info="edns = { version = ${edns_version:-0}, udp_size = ${udp_size:-0}, do_bit = $do_bit }," fi # Output Lua table entry (tab-indented to match project style) printf '\t%s = {\n' "$label" printf '\t\tqname = "%s",\n' "$qname" printf '\t\tqtype = %s,\n' "$qtype" printf '\t\tqclass = %s,\n' "${qclass:-1}" printf '\t\trcode = %s,\n' "$rcode" printf '\t\tflags = { qr = %s, rd = %s, ra = %s, aa = %s, tc = %s },\n' "$qr" "$rd" "$ra" "$aa" "$tc" printf '\t\tcounts = { qdcount = %s, ancount = %s, nscount = %s, arcount = %s },\n' "$qdcount" "$ancount" "$nscount" "$arcount" [ -n "$answer_names" ] && printf '\t\tanswer_names = { %s },\n' "$answer_names" [ -n "$authority_names" ] && printf '\t\tauthority_names = { %s },\n' "$authority_names" [ -n "$answer_rdata" ] && printf '\t\t%s\n' "$answer_rdata" [ -n "$edns_info" ] && printf '\t\t%s\n' "$edns_info" printf '\t},\n' } # Capture a single DNS query and its response. # Args: label nameserver domain type [dig_flags] # dig_flags defaults to "+noedns +nocookie" when omitted. capture_query() { local label="$1" ns="$2" domain="$3" qtype="$4" local dig_flags="${5:-+noedns +nocookie}" local pcap="$TMPDIR/${label}.pcap" local dig_out="$TMPDIR/${label}.dig" echo " Capturing $label: dig @$ns $domain $qtype $dig_flags ..." # Start tcpdump in background tcpdump -i any -w "$pcap" -c 2 "udp port 53 and host $ns" 2>"$TMPDIR/${label}.tcpdump.log" & local tcpdump_pid=$! sleep 0.5 # let tcpdump initialize # Run dig # shellcheck disable=SC2086 dig $dig_flags +tries=1 +time=5 @"$ns" "$domain" "$qtype" > "$dig_out" 2>/dev/null || true # Wait for tcpdump to capture both packets (or timeout) local waited=0 while kill -0 "$tcpdump_pid" 2>/dev/null && [ $waited -lt 5 ]; do sleep 0.5 waited=$((waited + 1)) done kill "$tcpdump_pid" 2>/dev/null || true wait "$tcpdump_pid" 2>/dev/null || true if [ ! -f "$pcap" ] || [ ! -s "$pcap" ]; then echo " WARNING: no capture for $label" return 1 fi # Extract hex payloads (query then response) local hex_lines hex_lines=$(extract_dns_hex "$pcap") local query_hex response_hex query_hex=$(echo "$hex_lines" | sed -n '1p') response_hex=$(echo "$hex_lines" | sed -n '2p') if [ -z "$query_hex" ] || [ -z "$response_hex" ]; then echo " WARNING: incomplete capture for $label (got $(echo "$hex_lines" | wc -l) packets)" return 1 fi # Format as Lua string with line wrapping for readability (tab-indented) format_lua_hex() { local hex="$1" name="$2" local len=${#hex} printf '\t%s = "' "$name" local i=0 while [ $i -lt $len ]; do local chunk="${hex:$i:64}" if [ $((i + 64)) -lt $len ]; then printf '"\n' printf '\t\t.. "' fi printf '%s' "$chunk" i=$((i + 64)) done printf '",\n' } FIXTURES_ENTRIES+="$(format_lua_hex "$query_hex" "${label}_query")"$'\n' FIXTURES_ENTRIES+="$(format_lua_hex "$response_hex" "${label}_response")"$'\n' # Parse expected values EXPECTED_ENTRIES+="$(parse_dig_expected "$label" "$dig_out")"$'\n' echo " OK (query=${#query_hex}/2 bytes, response=${#response_hex}/2 bytes)" } # ─── Main ───────────────────────────────────────────────────────────── check_deps echo "Capturing DNS fixtures..." echo "" FIXTURES_ENTRIES="" EXPECTED_ENTRIES="" # Phase 1 fixture set (wire-level variety) capture_query a_simple 1.1.1.1 example.com A || true capture_query cname_chain 8.8.8.8 www.github.com A || true capture_query multi_answer 8.8.8.8 google.com A || true capture_query nxdomain 1.1.1.1 nonexistent.example.com A || true echo "" # Phase 2 fixture set (RDATA-focused, using deviant.guru) capture_query aaaa_deviant 1.1.1.1 deviant.guru AAAA || true capture_query mx_deviant 1.1.1.1 deviant.guru MX || true capture_query ns_deviant 1.1.1.1 deviant.guru NS || true capture_query txt_deviant 1.1.1.1 deviant.guru TXT || true capture_query soa_deviant 1.1.1.1 deviant.guru SOA || true # PTR: reverse lookup for 1.1.1.1 (Cloudflare) — reliable known PTR record capture_query ptr_one 1.1.1.1 1.1.1.1.in-addr.arpa PTR || true # EDNS: same A query but with EDNS enabled to capture OPT pseudo-record capture_query edns_deviant 1.1.1.1 deviant.guru A "+edns=0 +nocookie" || true echo "" # Preserve hand-crafted (hc_*) sections from existing files before overwriting HC_FIXTURES=$(extract_handcrafted "$FIXTURES_FILE") HC_EXPECTED=$(extract_handcrafted "$EXPECTED_FILE") echo "Writing $FIXTURES_FILE ..." cat > "$FIXTURES_FILE" < "$EXPECTED_FILE" <