<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: MikeInfra</title>
    <description>The latest articles on DEV Community by MikeInfra (@infratally).</description>
    <link>https://dev.to/infratally</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3900990%2F82cdc113-ce12-4a91-b34e-010285e447ba.png</url>
      <title>DEV Community: MikeInfra</title>
      <link>https://dev.to/infratally</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/infratally"/>
    <language>en</language>
    <item>
      <title>Why Your DNS Checker Returns Empty Results for Large TXT Records</title>
      <dc:creator>MikeInfra</dc:creator>
      <pubDate>Mon, 27 Apr 2026 18:58:09 +0000</pubDate>
      <link>https://dev.to/infratally/why-your-dns-checker-returns-empty-results-for-large-txt-records-3cnp</link>
      <guid>https://dev.to/infratally/why-your-dns-checker-returns-empty-results-for-large-txt-records-3cnp</guid>
      <description>&lt;p&gt;I ran into this while building the &lt;a href="https://www.infratally.com/tools/dns-checker.html" rel="noopener noreferrer"&gt;InfraTally DNS Propagation Checker&lt;/a&gt;. During testing I queried apple.com for TXT records and noticed something strange: Google (8.8.8.8), Cloudflare (1.1.1.1), Quad9, and Verisign all came back empty. OpenDNS returned eight records. Same domain, same record type, five different answers from six resolvers.&lt;/p&gt;

&lt;p&gt;The packets were genuinely reaching each resolver's IP address independently via raw UDP socket queries. So why the split result?&lt;/p&gt;

&lt;p&gt;The answer is one of those things that is obvious in hindsight and completely invisible until you know to look for it: the 512-byte UDP limit and the truncation flag that most tools ignore.&lt;/p&gt;




&lt;h2&gt;
  
  
  The 512-Byte Problem
&lt;/h2&gt;

&lt;p&gt;DNS over UDP has a default maximum response size of 512 bytes. This was established in RFC 1035 back in 1987, when DNS was being designed for networks with much smaller maximum transmission units. The limit has never been formally raised in the base spec.&lt;/p&gt;

&lt;p&gt;For most DNS queries this is fine. An A record response for a typical domain fits comfortably in 512 bytes. MX records are slightly larger but still usually fit. TXT records are the problem.&lt;/p&gt;

&lt;p&gt;Enterprise domains accumulate TXT records over time the way old servers accumulate cruft. Apple.com has eight of them: SPF records, multiple domain verification tokens for Apple services, Cerner client IDs, Miro verification, Apple domain verification. Each one is a string of 50 to 150 characters. Add them up and you are looking at 800 to 900 bytes of answer data, well over the limit.&lt;/p&gt;




&lt;h2&gt;
  
  
  Reading the DNS Header
&lt;/h2&gt;

&lt;p&gt;Every DNS response starts with a 12-byte header. Most DNS code extracts the transaction ID and answer count and ignores everything in between. The TC bit lives in byte 3, bit 9 of the flags field, and it is the one bit that tells you whether to trust the rest of the response.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// DNS response header — 12 bytes
Bytes 0-1: Transaction ID
Bytes 2-3: Flags (QR, Opcode, AA, TC, RD, RA, RCODE)
Bytes 4-5: QDCOUNT (questions)
Bytes 6-7: ANCOUNT (answers)
Bytes 8-9: NSCOUNT
Bytes 10-11: ARCOUNT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Extracting the TC bit in Python is two lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;struct&lt;/span&gt;

&lt;span class="n"&gt;flags&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unpack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;H&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;])[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;tc_bit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;flags&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;   &lt;span class="c1"&gt;# 1 = truncated, 0 = complete response
&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tc_bit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Do not trust this response — retry over TCP
&lt;/span&gt;    &lt;span class="k"&gt;pass&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the check most DNS tools skip. If tc_bit is 1, the response is incomplete and should not be presented to the user as a result.&lt;/p&gt;




&lt;h2&gt;
  
  
  EDNS0: The Right First Fix
&lt;/h2&gt;

&lt;p&gt;Before jumping straight to TCP, there is a better first option. EDNS0 (Extension Mechanisms for DNS, RFC 2671, later updated by RFC 6891) was designed specifically to solve the 512-byte problem. It lets a DNS client signal to the resolver that it can handle larger UDP responses, up to 4096 bytes in practice.&lt;/p&gt;

&lt;p&gt;The way you signal EDNS0 support is by appending an OPT pseudo-record to the additional section of your query. This record tells the resolver: "I can receive UDP payloads up to N bytes. Send me the full response." Most major resolvers, including Google and Cloudflare, will honor this and return the complete answer in a single UDP packet without truncation.&lt;/p&gt;

&lt;p&gt;The OPT record is 11 bytes appended to the query packet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;edns0_opt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\x00&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;              &lt;span class="c1"&gt;# Name: root (.)
&lt;/span&gt;    &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\x00\x29&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;          &lt;span class="c1"&gt;# Type: OPT (41)
&lt;/span&gt;    &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\x10\x00&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;          &lt;span class="c1"&gt;# Class field = UDP payload size (0x1000 = 4096 bytes)
&lt;/span&gt;    &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\x00\x00\x00\x00&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;  &lt;span class="c1"&gt;# TTL field = extended RCODE + flags (all zero)
&lt;/span&gt;    &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\x00\x00&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;          &lt;span class="c1"&gt;# RDLENGTH: 0 (no options data)
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You also need to set ARCOUNT to 1 in the query header (there is now one additional record) and increase your receive buffer from 512 to 4096 bytes so you can actually accept the larger response.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Build query header with ARCOUNT=1 for EDNS0
&lt;/span&gt;&lt;span class="n"&gt;arcount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="n"&gt;header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;HHHHHH&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;# transaction ID
&lt;/span&gt;    &lt;span class="mh"&gt;0x0100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# flags: standard query, recursion desired
&lt;/span&gt;    &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;# QDCOUNT: 1 question
&lt;/span&gt;    &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;# ANCOUNT: 0 answers
&lt;/span&gt;    &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;# NSCOUNT: 0 authority records
&lt;/span&gt;    &lt;span class="n"&gt;arcount&lt;/span&gt;   &lt;span class="c1"&gt;# ARCOUNT: 1 additional record (the OPT record)
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Receive buffer: 4096 bytes instead of 512
&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recvfrom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4096&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With EDNS0 in place, Google and Cloudflare return the full apple.com TXT record set in a single UDP packet. The truncation problem disappears for the vast majority of real-world cases. Apple.com's 800-900 bytes of TXT records fit comfortably within the 4096-byte buffer.&lt;/p&gt;




&lt;h2&gt;
  
  
  TCP Fallback: The Safety Net
&lt;/h2&gt;

&lt;p&gt;EDNS0 solves mosTCP Fallback: The Safety Net&lt;br&gt;
t cases but not all. Some resolvers do not honor the extended buffer size. Some domains have TXT record sets that exceed even 4096 bytes (rare, but possible for large enterprises with aggressive domain verification requirements). And some network paths have issues with large UDP packets that do not affect TCP.&lt;/p&gt;

&lt;p&gt;The correct fallback when you still get a truncated response after EDNS0 is to retry over TCP. DNS over TCP works identically to UDP except for two things: it uses SOCK_STREAM instead of SOCK_DGRAM, and each message is prefixed with a 2-byte big-endian length field. TCP has no inherent response size limit.&lt;/p&gt;

&lt;p&gt;def query_resolver_tcp(resolver_ip, domain, qtype, timeout=10):&lt;br&gt;
    tid, packet = build_dns_query(domain, qtype, use_edns0=False)&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# TCP DNS: prefix message with 2-byte length
tcp_packet = struct.pack('&amp;gt;H', len(packet)) + packet

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(timeout)

try:
    sock.connect((resolver_ip, 53))
    sock.sendall(tcp_packet)

    # Read 2-byte length prefix first
    length_data = b''
    while len(length_data) &amp;lt; 2:
        chunk = sock.recv(2 - len(length_data))
        if not chunk:
            raise ConnectionError("Connection closed")
        length_data += chunk

    msg_length = struct.unpack('&amp;gt;H', length_data)[0]

    # Read the full response
    response = b''
    while len(response) &amp;lt; msg_length:
        chunk = sock.recv(msg_length - len(response))
        if not chunk:
            raise ConnectionError("Connection closed early")
        response += chunk

    return parse_dns_response(response, qtype)

finally:
    sock.close()
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;




&lt;h2&gt;
  
  
  The Complete Flow
&lt;/h2&gt;

&lt;p&gt;Putting it all together, the correct approach for a robust DNS checker is a three-step process:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Send UDP query with EDNS0 OPT record (4096-byte buffer signaled)&lt;/li&gt;
&lt;li&gt;If TC bit is set in response: retry over TCP&lt;/li&gt;
&lt;li&gt;Parse and return answer records from whichever transport succeeded&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The UDP path handles 95%+ of queries with low latency. TCP fallback catches the rest. Together they handle every TXT record set I have thrown at them in production including apple.com, google.com, and several large enterprise domains with eight or more TXT records.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Resolver Quality Problem
&lt;/h2&gt;

&lt;p&gt;While debugging this I discovered a second problem unrelated to EDNS0. Two of the resolvers I initially included in the checker turned out to be unreliable.&lt;/p&gt;

&lt;p&gt;OpenDNS timed out consistently during testing. OpenDNS is a content-filtering resolver and appears to rate limit or block certain traffic patterns, making it an unreliable choice for a diagnostic tool that needs consistent results.&lt;/p&gt;

&lt;p&gt;Comodo Secure DNS was more problematic: it was returning incomplete TXT record sets for some domains, filtering records based on its content policy without indicating it was doing so. For a propagation checker this is worse than a timeout. A timeout tells you something went wrong. A silent partial result looks like real data and misleads anyone using the tool to verify their DNS configuration.&lt;/p&gt;

&lt;p&gt;Both were dropped. The final resolver list in InfraTally's DNS checker is Google (8.8.8.8), Cloudflare (1.1.1.1), Level3 (4.2.2.1), Quad9 (9.9.9.9), Verisign (64.6.64.6), and Hurricane Electric (74.82.42.42). All six return consistent, unfiltered results for TXT records.&lt;/p&gt;




&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;If you are building a DNS tool or debugging one that returns empty results on enterprise domains, check these things in order.&lt;/p&gt;

&lt;p&gt;First, check the TC bit before reporting zero records. An empty result with TC=1 is a truncated response, not a missing record. Reporting it as "no records found" is wrong.&lt;/p&gt;

&lt;p&gt;Second, add EDNS0 to your outgoing queries. The OPT record is 11 bytes and tells resolvers you can handle up to 4096 bytes over UDP. Most truncation problems disappear with this one change.&lt;/p&gt;

&lt;p&gt;Third, implement TCP fallback for the cases EDNS0 does not cover. The code is more involved than UDP but not complex, and it makes your tool correct for 100% of cases rather than 95%.&lt;/p&gt;

&lt;p&gt;Finally, be selective about which resolvers you include. Content-filtering resolvers like Comodo that silently modify results are worse than no resolver at all in a diagnostic tool. Partial results that look like real data cause more confusion than an honest error.&lt;/p&gt;

</description>
      <category>dns</category>
      <category>networking</category>
      <category>programming</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
