Module 18 — DNS Sinkhole

Pure C — fully runnable. No DPDK, no Hyperscan.

What you learn

How to rewrite a DNS query packet into a sinkhole response in-place — the exact technique used in the DP application to redirect blocked DNS queries to a walled-garden IP without allocating a new packet buffer.

Covers all four variants:


What the sinkhole does

Client sends DNS A query:
  ETH[client→gateway]  IPv4[198.51.100.5→8.8.8.8]  UDP[54321→53]
  DNS: QUERY blocked.example.com A

the DP application rewrites the mbuf in-place:
  ETH[gateway→client]  IPv4[8.8.8.8→198.51.100.5]  UDP[53→54321]
  DNS: RESPONSE  ancount=1
       ANSWER: blocked.example.com A 60 10.1.1.1  ← walled garden

The client never reaches the blocked domain. From their perspective, the DNS query got a response — just not the IP they expected.


Where this fits in the real application

Worker lcore:
  │
  ├─► dns_parse_message()         → domain, qtype, question_wire_end
  ├─► url_policy_for_dns()  → PROCESS_WORKFLOW
  │
  └─► if PROCESS_WORKFLOW:
        if qtype == A:
          dns_build_sinkhole_v4(mbuf, wg_ipv4)
        │
        ├─ Swap ETH/IP/UDP headers
        ├─ Set DNS QR=1, ancount=1
        ├─ rte_pktmbuf_append(mbuf, answer_len)  ← extend tail
        ├─ Build answer section (0xC00C | type | TTL | wg_ip)
        ├─ Update IP total_len, UDP dgram_len
        └─ m->ol_flags |= TX_IPV4 | TX_IP_CKSUM | TX_UDP_CKSUM

Build and run

make
./dns_sinkhole

Expected output:

=== Module 18: DNS Sinkhole ===
Walled garden IPv4: 10.1.1.1

Test 1: Sinkhole UDP/IPv4 A query
  Before: src=198.51.100.5:54321 → dst=8.8.8.8:53
  After:
    src=8.8.8.8:53 → dst=198.51.100.5:54321
    QR=1  RA=1  RCODE=0
    Answer RR: type=1 (A)  ttl=60
    RDATA (A): 10.1.1.1
  PASS ✓

=== All tests passed ===

Key concepts

1. Why in-place rewrite (no new allocation)

Option A (naive): alloc new mbuf → build response → free query mbuf
  Cost: rte_pktmbuf_alloc(~10ns) + memcpy(~200ns) + rte_pktmbuf_free(~10ns)
  At 200K blocked DNS/sec: 200,000 × 220ns = 44ms/sec CPU overhead

Option B (in-place): modify the query mbuf directly
  Cost: ~30ns (a few memcpy + pointer arithmetic)
  At 200K blocked DNS/sec: 200,000 × 30ns = 6ms/sec — 7× less overhead

2. Header swap — the correct way

/* WRONG: overwrites src before you can read it */
memcpy(eth->dst_mac, eth->src_mac, 6);
memcpy(eth->src_mac, eth->dst_mac, 6);  /* dst_mac is already gone! */

/* CORRECT: save one side first */
uint8_t tmp[6];
memcpy(tmp,          eth->dst_mac, 6);
memcpy(eth->dst_mac, eth->src_mac, 6);
memcpy(eth->src_mac, tmp,          6);

3. DNS answer section layout

Offset  Size  Value           Meaning
------  ----  -----           -------
0       2     0xC0 0x0C       Name: pointer to offset 12 (question name)
2       2     0x00 0x01       Type: A (or 0x001C for AAAA)
4       2     0x00 0x01       Class: IN
6       4     0x00 0x00 0x00 0x3C  TTL: 60 seconds
10      2     0x00 0x04       RDLENGTH: 4 (or 16 for AAAA)
12      4     10.1.1.1        RDATA: walled garden IPv4

The pointer 0xC00C means “the name for this answer record is the same as the name at offset 12 in the DNS message (the question qname)”.

4. question_wire_end — where to append

DNS message structure:
  [DNS header 12B] [question section ...] [← answer appended here]
                                          ^
                                   question_wire_end

Getting this offset wrong corrupts the DNS question section or appends the answer to the wrong location.

5. Length field cascade

After appending the answer, three length fields must be updated:

udp->dgram_len  += answer_len;   /* UDP header + payload */
ip4->total_len  += answer_len;   /* entire IP packet */
/* For TCP: dns_tcp_prefix += answer_len  (2-byte DNS length prefix) */

Missing any one causes the receiver to parse the wrong number of bytes, producing a malformed response that the client DNS resolver discards.

6. Hardware checksum offload (DPDK only)

/* After in-place rewrite in DPDK: */
ip4->checksum  = 0;    /* NIC will compute */
udp->checksum  = rte_ipv4_phdr_cksum(ip4, m->ol_flags);
m->ol_flags   |= RTE_MBUF_F_TX_IPV4 | RTE_MBUF_F_TX_IP_CKSUM
               | RTE_MBUF_F_TX_UDP_CKSUM;

Without this (or correct software checksum), the client’s network stack discards the response as a bad checksum. This was one of the trickiest bugs to diagnose in the real DP application sinkhole implementation.


DPDK mbuf append (what replaces memcpy in real app)

/* Step 1: extend the mbuf tail */
char *answer_ptr = rte_pktmbuf_append(mbuf, answer_len);
if (!answer_ptr) {
    LOG_ERROR("Cannot append DNS answer: tailroom exhausted");
    rte_pktmbuf_free(mbuf);
    return;
}

/* Step 2: write answer bytes */
build_dns_answer((uint8_t *)answer_ptr, qtype, wg_ipv4, wg_ipv6);

/* mbuf->data_len and mbuf->pkt_len are updated automatically by append */

Next module

Module 19 — Kafka Producer (CDR Export): Every policy decision generates a Charging Data Record that is exported to Kafka.


Source files

File Download
dns_sinkhole.c dns_sinkhole.c
Makefile Makefile