Turning My ESP32 into a DNS Sinkhole to Fight Doomscrolling

Amanvir ParharFebruary 28, 2025
A demo of Tarpan

Smartphones are great, but lately, it feels like my iPhone has been a net negative in my life.

Of course, my phone itself is fine: it's a marvelous piece of engineering that's kept up with me for years now. The problem has more to do with me and my sometimes embarrassingly low resistance to doomscrolling.

For the uninitiated, doomscrolling is essentially when one passively scrolls through endless feeds of content on social media until eventually stopping to realize that they've wasted the last five minutes of their life doing something entirely unproductive. Scrolling of this kind sucks because it literally takes away time one could've spent socializing, exercising, or learning new concepts and skills.

Earlier this week, one particular social media doomscroll bothered me enough, to the point where I decided that things had to change! I wanted to create a fix for this problem.

So, for the past few days, I've been working on turning my ESP32 (a low-power, $8 microcontroller) into a DNS server that routes social media-related web traffic from my iPhone to a non-routable IP (0.0.0.0).

I wrote the following article to document my experience building this nifty "DNS sinkhole," and to share what I've learned about DNS, networking, and low-level programming in the process.

What's DNS?

The Domain Name System (DNS) is a protocol that allows humans to more effectively navigate the internet. It's the piece of networking glue that's responsible for DNS resolution (i.e., translating domain names into their corresponding IP addresses):

google.com ➡️ 64.233.185.101

Oftentimes, this domain "lookup" is not something that most users of the internet concern themselves with. That's because DNS resolution—on your phone, computer, or any other device—is typically handled automatically, by way of your ISP's DNS server.

Fortunately, though, you can change your DNS server, and in fact, companies like Google and Cloudflare operate their own public DNS servers that you can configure your device to use. Cloudflare touts that its offering (1.1.1.1) is the "fastest DNS resolver available," so that's what I'll be using as an example throughout this article.

But, what's a sinkhole?

A DNS sinkhole is a special type of DNS server.

Although it, like any other DNS server, returns an IP address when asked about a particular domain name, it actually doesn't lookup the real address for certain domains.

Instead, it deliberately responds with an "invalid", non-routable (otherwise known as "private") IP address (typically 0.0.0.0) for specific domains—domains that we don't want to properly resolve for whatever reason. This is how devices like the Pi-hole are able to block ads on all of the devices connected to a particular network.

For our use-case, we'll be resolving a set of domains used by social media websites to 0.0.0.0 instead of their actual IP addresses.

That should be all the background information you need for now; let's dive into the code!

Connecting to the Internet

The first step is getting our ESP32 connected to the internet, and thankfully, the ESP32 comes with built-in Wi-Fi support.

For simplicity's sake, I'm going to be using the Arduino IDE to program our microcontroller, but of course, you should probably use something more extensive for more complex projects.

Here's how we can establish a Wi-Fi connection:

wifi.ino
1
#include "WiFi.h"
2

3
// wifi credentials
4
const char* ssid = "*****";
5
const char* password = "*****";
6

7
void setup() {
8
  Serial.begin(115200);
9
  
10
  // get connected
11
  WiFi.mode(WIFI_STA);
12
  WiFi.begin(ssid, password);
13
  if (WiFi.waitForConnectResult() != WL_CONNECTED) {
14
    Serial.println("WiFi failed; trying again...");
15
    while (1) delay(1000);
16
  }
17
}
18

19
void loop() {
20
  // using this to get the ip from the serial monitor!
21
  Serial.println(WiFi.localIP());
22
  delay(10000);
23
}

Pretty simple stuff, right? The setup() method runs once when the program starts, and it's where we attempt to connect to our desired Wi-Fi network.

I've just put some code in the loop(), which repeatedly runs right after the setup method, and it prints out the ESP32's local IP every 10 seconds (take note of this IP - we'll be using it later).

Setting up a simple DNS server

It's time to start working on the core functionality of our sinkhole.

Full diagram of Tarpan

As you can see in the diagram above, we want our ESP32 to sit between an "actual" DNS server (Cloudflare's 1.1.1.1) and the client (the iPhone), so that it can make DNS lookups when it sees a valid domain that it shouldn't block. So, we'll need to receive requests on the standard DNS port (port 53) on the ESP32, and send requests to 1.1.1.1:53.

Coding this up is fairly straightforward:

dns_basics.ino
1
#include "WiFi.h"
2
#include "AsyncUDP.h"
3

4
// wifi credentials
5
const char* ssid = "*****";
6
const char* password = "*****";
7

8
// cloudflare dns (ip and port)
9
const IPAddress DNS_SERVER(1, 1, 1, 1);
10
const int DNS_PORT = 53;
11

12
// use udp for dns
13
AsyncUDP clientDnsUdp;
14
AsyncUDP cloudflareDnsUdp;
15

16
void handleIncomingDnsQuery(AsyncUDPPacket packet) {
17
  // TODO
18
}
19

20
void handleCloudflareResponse(AsyncUDPPacket packet) {
21
  // TODO
22
}
23

24
void setup() {
25
  Serial.begin(115200);
26

27
  // get connected
28
  WiFi.mode(WIFI_STA);
29
  WiFi.begin(ssid, password);
30
  if (WiFi.waitForConnectResult() != WL_CONNECTED) {
31
    Serial.println("WiFi failed; trying again...");
32
    while (1) delay(1000);
33
  }
34

35
  // listen on port 53...
36
  if (clientDnsUdp.listen(DNS_PORT)) {
37
    Serial.println("Started listening...");
38
    clientDnsUdp.onPacket(handleIncomingDnsQuery);
39
  }
40

41
  // call handleCloudflareResponse any time cloudflare returns a response
42
  if (cloudflareDnsUdp.connect(DNS_SERVER, DNS_PORT)) {
43
    Serial.println("Connected to 1.1.1.1");
44
    cloudflareDnsUdp.onPacket(handleCloudflareResponse);
45
  }
46
}
47

48
void loop() {
49
  // using this to get the ip from the serial monitor!
50
  Serial.println(WiFi.localIP());
51
  delay(10000);
52
}

Now, when we get a DNS query on port 53, the handleIncomingDnsQuery() method is called, and when we get a response from Cloudflare's DNS, handleCloudflareResponse() is called. As it stands, we don't actually do anything in the bodies of either of these methods, but that'll change soon.

For now, take note of the fact that we're using UDP for speed and performance reasons (although TCP would work just fine, as well).

Understanding DNS packets

Take a look at the handleIncomingDnsQuery() method.

You'll see that it takes in one parameter: AsyncUDPPacket packet. We'll be responsible for mutating this packet and returning the modified version.

To help explain the structure of this DNS packet, I'll be using ASCII diagrams sourced from Prof. Alan Mislove's (Northeastern University) incredible PDF covering the DNS protocol (please check out this amazing resource here).

DNS packets start off with a header:

  0  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                      ID                       |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|QR|   Opcode  |AA|TC|RD|RA|   Z    |   RCODE   |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                    QDCOUNT                    |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                    ANCOUNT                    |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                    NSCOUNT                    |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                    ARCOUNT                    |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+

As you can see, there are a lot of fields, each serving a different purpose, but we'll primarily be interested in the...

  • ID: a unique identifier that makes up the first set of 16 bits; used to match received responses with queries.
  • Flags: the entire second set of 16 bits, but we're particularly interested in QR (set to 0 if the message is a query, 1 if it's a response) and RCODE (set to 0000 if there was no error in returning a response, 0010 for server failure).
  • ANCOUNT: number of records in the answer section; for all practical purposes, will be set to 1 in our response (while it's possible to fit multiple IPs within a single DNS query—and therefore have multiple records within a response—it's pretty much never done).

In our code, we can represent each of the fields in the DNS header using a struct:

1
struct DNSHeader {
2
  uint16_t id;
3
  uint16_t flags;
4
  uint16_t qdcount;
5
  uint16_t ancount;
6
  uint16_t nscount;
7
  uint16_t arcount;
8
};

After the header comes the DNS question:

  0  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                                               |
/                     QNAME                     /
/                                               /
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                     QTYPE                     |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                     QCLASS                    |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+

We'll just be focusing on QNAME, which starts off with a length octet (sequence of 8 bits) describing how many subsequent octets—each containing an ASCII character—must be read to form the first label. There may be several of these bit sequences (length octets followed by a series of ASCII octets) up until a null octet appears (00000000), marking the end of the QNAME section.

Following the DNS question comes the final section we're interested in: the DNS answer. Here's what that looks like:

  0  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                                               |
/                      NAME                     /
/                                               /
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                      TYPE                     |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                     CLASS                     |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                      TTL                      |
|                                               |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                    RDLENGTH                   |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--|
/                     RDATA                     /
/                                               /
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+

We're interested in every field in the answer section, since we're building a DNS server that should respond with a complete DNS answer.

Most of these fields aren't that hard to grasp, though, so let's specifically focus on the NAME: the same domain name found in QNAME. You might think that including the full domain name a second time is repetitive and wastes precious bits, and you'd be right! This is why DNS compression is used: essentially, we can place a pointer in this field to QNAME in the question section instead of repeating the domain name, thereby saving space.

And, that's it - those are the DNS sections we'll be reading and mutating! Although there are other sections that can be included in a DNS packet, we won't concern ourselves with them for this project.

Handling incoming DNS queries

Now that we have a good understanding of the structure of DNS packets, it's time to start actually implementing the handleIncomingDnsQuery() method. Remember, this method takes in a DNS packet, and either:

  1. Returns 0.0.0.0 if the query is for a domain corresponding to a social media site.
  2. Returns the actual IP address for the domain per usual (accomplished by forwarding the request to 1.1.1.1).

Let's handle #1 (the domain blocking) first.

To begin, we'll need a list of domains that correspond to each of the social media websites we're trying to block.

Fortunately, there's a really nice GitHub repository that compiles lists of such domains for popular online services. For instance, here's the list of primary domains for Reddit:

redd.it
reddit.com
redditblog.com
reddithelp.com
redditinc.com
redditmail.com
redditmedia.com
redditspace.com
redditstatic.com
redditstatus.com

We take such domains for sites like Facebook, Instagram, Reddit, TikTok, X/Twitter, and YouTube, and put them in a file separate from our Arduino code:

blocklist.h
1
#ifndef BLOCKLIST_H
2
#define BLOCKLIST_H
3

4
// list of all primary domains to block
5
const char* BLOCKED_DOMAINS[] = {
6
  /* ... */
7
};
8

9
const int BLOCKED_DOMAINS_COUNT = sizeof(BLOCKED_DOMAINS) / sizeof(BLOCKED_DOMAINS[0]);
10

11
#endif

...and turn it into a set so that we can perform constant-time lookup in our program:

1
#include "blocklist.h"
2

3
// set of all primary domains to block (online distractions!)
4
std::set<String> BLOCKLIST;
5

6
/* ... */
7

8
void setup() {
9
  // add all blocked domains to the set for O(1) lookup
10
  for (int i = 0; i < BLOCKED_DOMAINS_COUNT; i++) {
11
    BLOCKLIST.insert(String(BLOCKED_DOMAINS[i]));
12
  }
13

14
  /* ... */
15
}
16

17
/* ... */

In order to perform a lookup in this set, we now need to extract the primary domain from the question in the DNS packet. Let's use what we've learned about DNS packet structure to help us do this!

We can start by casting the AsyncUDPPacket packet to a DNSHeader pointer, and we can get ready to read the full domain into a char array with length 253 (this buffer is big enough to fit the largest possible domain name):

1
// maximum length of a full domain name
2
char fullDomainName[253];
3

4
int idx = 0;           // used to index into fullDomainName
5
bool needDot = false;  // used to add dots between labels

Time to read the domain name into the buffer! This is accomplished by reading a value (k) out of a length octet for a label, consuming k-many character octets and populating the fullDomainName array with them, and placing a dot ('.') to separate the current label from the next:

1
int idx = 0; // used to index into fullDomainName
2
bool needDot = false; // used to add dots between labels
3

4
// go through the question by starting right after where the header ends
5
for (int i = sizeof(DNSHeader); i < packet.length(); i++) {
6
  // read length octet
7
  uint8_t len = packet.data()[i];
8

9
  // break if we've reached the end of the domain name
10
  if (len == 0) break;
11

12
  // add a dot only if we've gone past the first label
13
  if (needDot) fullDomainName[idx++] = '.';
14

15
  // add each character in the label text to the fullDomainName
16
  for (int j = 0; j < len && (i + 1 + j) < packet.length(); j++) {
17
    fullDomainName[idx++] = (char)packet.data()[i + 1 + j];
18
  }
19

20
  // skip past the label text we've just read
21
  i += len;
22

23
  // set flag to true for next iteration (if there is one)
24
  needDot = true;
25
}
26

27
// null terminate the string
28
fullDomainName[idx] = '\0';

Yay! We've successfully read the entire domain into our buffer, but we now have to extract the primary domain from this C-string, so that we can perform a lookup in our set:

<second_level_domain>.<TLD>

This isn't too hard to do: we can just traverse fullDomainName backwards until...

  1. We see a second dot ('.').
  2. We're at the beginning of fullDomainName (i.e., fullDomainName is a primary domain).

Here's what that'd look like as code:

1
int dotsSeen = 0; // track number of dots
2

3
// run through the string backwards
4
while (--idx >= 0) {
5
  if (fullDomainName[idx] == '.') {
6
    dotsSeen++;
7
    if (dotsSeen == 2) {
8
      idx += 1;
9
      break;
10
    }
11
  }
12
}
13

14
// <second_level_domain>.<TLD>
15
char* primaryDomain = fullDomainName + idx;

Easy peasy! We're almost ready to perform a set lookup and respond with a non-routable IP address if the domain exists in the set. Before we do that, though, we need a bigger buffer for our packet—a buffer that'll be big enough to fit our existing DNS header and question, as well as the answer section we're about to add.

We know the answer section is going to take up 16 bytes, so we just have to create a new buffer that's 16 bytes bigger than our packet:

1
// copy response into a buffer that's 16 bytes bigger (will write the answer section to this buffer)
2
int position = packet.length();
3
uint8_t response[position + 16];
4
memcpy(response, packet.data(), position);
5
DNSHeader* responseHeader = (DNSHeader*) response;

Okay, now we're ready to perform the lookup, and modify the response if the domain is in the BLOCKLIST!

1
if (BLOCKLIST.count(String(primaryDomain))) {
2
  responseHeader->flags = htons(ntohs(dnsHeader->flags) | 0x8000); // sets the QR bit to 1
3
  responseHeader->ancount = htons(1); // for one answer
4

5
  // for dns answer fields below: set 1 byte at a time for each octet (so twice for a 16-bit field)
6

7
  // name
8
  response[position++] = 0xC0;
9
  response[position++] = 0x0C;
10

11
  // record: A record
12
  response[position++] = 0x00;
13
  response[position++] = 0x01;
14

15
  // class: set to standard
16
  response[position++] = 0x00;
17
  response[position++] = 0x01;
18

19
  // ttl: set to standard
20
  response[position++] = 0x00;
21
  response[position++] = 0x00;
22
  response[position++] = 0x00;
23
  response[position++] = 0x3C; // 60s
24

25
  // data length (4 bytes for IPv4)
26
  response[position++] = 0x00;
27
  response[position++] = 0x04;
28

29
  // IP address (0.0.0.0)
30
  response[position++] = 0x00;
31
  response[position++] = 0x00;
32
  response[position++] = 0x00;
33
  response[position++] = 0x00;
34

35
  // send response
36
  packet.write(response, position);
37

38
  return;
39
}

Woah... a lot is happening in the code block above, so let's go over the most crucial parts in detail:

  • Line 2 seems a bit crazy, but all we're doing is performing a bitwise OR on the DNS header flags we already had. You'll notice that we're just setting the QR bit (the most significant bit) to 1, explicitly indicating that the packet we're about to send is a response and not a query. If the ntohs / htons methods seem unfamiliar to you, just know that they're used to flip the byte orderings from network byte order (big-endian) to host byte order (little-endian), and vice-versa.
  • In line 3, ancount is set to one, since we're sending one IP address.
  • Lines 8-9 are where we make use of DNS compression, the concept we touched upon earlier. The answer section simply has a pointer to the domain name in the question section.
  • We set the IP address to 0.0.0.0 in lines 30-33, and we send off this response on line 36!
  • Finally, we use the "return early" pattern on line 38, so any code we write after this conditional won't be executed.

That last part is important, because it allows us to handle domains that aren't in the BLOCKLIST by including the following code directly after the if statement:

1
// if domain not in blocklist, save client info in map for later lookup
2
ClientInfo client = { packet.remoteIP(), packet.remotePort() };
3
dnsRequests[ntohs(dnsHeader->id)] = client;
4

5
// query cloudflare to ask about the domain :)
6
cloudflareDnsUdp.writeTo(packet.data(), packet.length(), DNS_SERVER, DNS_PORT);

On line 6, we make a request to 1.1.1.1 in order to get the actual IP address for the domain, but before that, we make use of a map called dnsRequests. We can define this map as a global variable...

1
// struct to keep track of client's ip and port
2
struct ClientInfo {
3
  IPAddress addr;
4
  uint16_t port;
5
};
6

7
// map used for looking up info of client that made the original request to esp32
8
std::map<uint16_t, ClientInfo> dnsRequests;

...that keeps track of pending DNS requests made by the client to the ESP32, so that responses to queries made by the ESP32 to Cloudflare DNS may be correctly matched and forwarded.

That's all for the handleIncomingDnsQuery() method; we're good to move on to the final step of our project.

Dealing with responses from 1.1.1.1

When Cloudflare DNS comes back with a response, we've got to handle it! After all, our request from the ESP32 to 1.1.1.1 won't magically relay itself to the client that made the original request to the ESP32.

We have to use handleCloudflareResponse()—the other method we needed to implement—to forward the AsyncUDPPacket packet we received from Cloudflare DNS:

1
void handleCloudflareResponse(AsyncUDPPacket packet) {
2
  DNSHeader* responseHeader = (DNSHeader*) packet.data();
3
  uint16_t id = ntohs(responseHeader->id);
4

5
  // to be safe, just check that the request is still in the map
6
  if (dnsRequests.count(id)) {
7
    // if it is, respond to the client with the packet we received
8
    ClientInfo& client = dnsRequests[id];
9
    clientDnsUdp.writeTo(packet.data(), packet.length(), client.addr, client.port);
10

11
    // remove request from map
12
    dnsRequests.erase(id);
13
  }
14
}

Notice that we use the ID from the responseHeader to check if the request is still in our map: if this is the case, we go ahead and respond to the client with the response we received from Cloudflare DNS.

Believe it or not, that's all the code we have to write - we're finally done!

Bringing it all together

Phew, that was a lot, but if you made it till this point, I'm hoping you learned something new!

We're ready to test out the code! If you upload this program onto your ESP32 (and change your iPhone's DNS server appropriately), you should see something like this when you try to access Instagram:

Tarpan blocking Instagram

Hey, it works! 🙃

Signing off

I sincerely hope you've had as good of a time reading this article as I did writing it.

If you'd like to check out some of my other work, please go here. If you want to get in touch, you can (ironically enough) reach me on social media—Instagram, X/Twitter, Bluesky, LinkedIn—or through email: amanvir.com [at] gmail [dot] com.

Full code

For your reference, you can find the entirety of the code from the .ino file for this project below. Feel free to make your own blocklist.h for the sites you personally want to block!

blocker.ino
1
#include "WiFi.h"
2
#include "AsyncUDP.h"
3
#include "blocklist.h"
4
#include <set>
5
#include <map>
6

7
// wifi credentials
8
const char* ssid = "*****";
9
const char* password = "*****";
10

11
// cloudflare dns (ip and port)
12
const IPAddress DNS_SERVER(1, 1, 1, 1);
13
const int DNS_PORT = 53;
14

15
// use udp for dns
16
AsyncUDP clientDnsUdp;
17
AsyncUDP cloudflareDnsUdp;
18

19
// set of all primary domains to block (online distractions!)
20
std::set<String> BLOCKLIST;
21

22
// struct to keep track of client's ip and port
23
struct ClientInfo {
24
  IPAddress addr;
25
  uint16_t port;
26
};
27

28
// map used for looking up info of client that made the original request to esp32
29
std::map<uint16_t, ClientInfo> dnsRequests;
30

31
// struct for managing dns header
32
struct DNSHeader {
33
  uint16_t id;
34
  uint16_t flags;
35
  uint16_t qdcount;
36
  uint16_t ancount;
37
  uint16_t nscount;
38
  uint16_t arcount;
39
};
40

41
void handleIncomingDnsQuery(AsyncUDPPacket packet) {
42
  // parse dns header
43
  DNSHeader* dnsHeader = (DNSHeader*) packet.data();
44

45
  // maximum length of a full domain name
46
  char fullDomainName[253];
47

48
  int idx = 0; // used to index into fullDomainName
49
  bool needDot = false; // used to add dots between labels
50

51
  // go through the question by starting right after where the header ends
52
  for (int i = sizeof(DNSHeader); i < packet.length(); i++) {
53
    // read length octet
54
    uint8_t len = packet.data()[i];
55

56
    // break if we've reached the end of the domain name
57
    if (len == 0) break;
58

59
    // add a dot only if we've gone past the first label
60
    if (needDot) fullDomainName[idx++] = '.';
61

62
    // add each character in the label text to the fullDomainName
63
    for (int j = 0; j < len && (i + 1 + j) < packet.length(); j++) {
64
      fullDomainName[idx++] = (char)packet.data()[i + 1 + j];
65
    }
66

67
    // skip past the label text we've just read
68
    i += len;
69

70
    // set flag to true for next iteration (if there is one)
71
    needDot = true;
72
  }
73

74
  // null terminate the string
75
  fullDomainName[idx] = '\0';
76

77
  int dotsSeen = 0; // track number of dots
78

79
  // run through the string backwards
80
  while (--idx >= 0) {
81
    if (fullDomainName[idx] == '.') {
82
      dotsSeen++;
83
      if (dotsSeen == 2) {
84
        idx += 1;
85
        break;
86
      }
87
    }
88
  }
89

90
  // <second_level_domain>.<TLD>
91
  char* primaryDomain = fullDomainName + idx;
92

93
  // copy response into a buffer that's 16 bytes bigger (will write the answer section to this buffer)
94
  int position = packet.length();
95
  uint8_t response[position + 16];
96
  memcpy(response, packet.data(), position);
97
  DNSHeader* responseHeader = (DNSHeader*) response;
98

99
  if (BLOCKLIST.count(String(primaryDomain))) {
100
    responseHeader->flags = htons(ntohs(dnsHeader->flags) | 0x8000); // sets the QR bit to 1
101
    responseHeader->ancount = htons(1); // for one answer
102

103
    // for dns answer fields below: set 1 byte at a time for each octet (so twice for a 16-bit field)
104

105
    // name
106
    response[position++] = 0xC0;
107
    response[position++] = 0x0C;
108

109
    // record: A record
110
    response[position++] = 0x00;
111
    response[position++] = 0x01;
112

113
    // class: set to standard
114
    response[position++] = 0x00;
115
    response[position++] = 0x01;
116

117
    // ttl: set to standard
118
    response[position++] = 0x00;
119
    response[position++] = 0x00;
120
    response[position++] = 0x00;
121
    response[position++] = 0x3C; // 60s
122

123
    // data length (4 bytes for IPv4)
124
    response[position++] = 0x00;
125
    response[position++] = 0x04;
126

127
    // IP address (0.0.0.0)
128
    response[position++] = 0x00;
129
    response[position++] = 0x00;
130
    response[position++] = 0x00;
131
    response[position++] = 0x00;
132

133
    // send response
134
    packet.write(response, position);
135

136
    return;
137
  }
138
  
139
  // if domain not in blocklist, save client info in map for later lookup
140
  ClientInfo client = { packet.remoteIP(), packet.remotePort() };
141
  dnsRequests[ntohs(dnsHeader->id)] = client;
142

143
  // query cloudflare to ask about the domain :)
144
  cloudflareDnsUdp.writeTo(packet.data(), packet.length(), DNS_SERVER, DNS_PORT);
145
}
146

147
void handleCloudflareResponse(AsyncUDPPacket packet) {
148
  DNSHeader* responseHeader = (DNSHeader*) packet.data();
149
  uint16_t id = ntohs(responseHeader->id);
150

151
  // to be safe, just check that the request is still in the map
152
  if (dnsRequests.count(id)) {
153
    // if it is, respond to the client with the packet we received
154
    ClientInfo& client = dnsRequests[id];
155
    clientDnsUdp.writeTo(packet.data(), packet.length(), client.addr, client.port);
156

157
    // remove request from map
158
    dnsRequests.erase(id);
159
  }
160
}
161

162
void setup() {
163
  Serial.begin(115200);
164

165
  // add all blocked domains to the set for O(1) lookup
166
  for (int i = 0; i < BLOCKED_DOMAINS_COUNT; i++) {
167
    BLOCKLIST.insert(String(BLOCKED_DOMAINS[i]));
168
  }
169

170
  // get connected
171
  WiFi.mode(WIFI_STA);
172
  WiFi.begin(ssid, password);
173
  if (WiFi.waitForConnectResult() != WL_CONNECTED) {
174
    Serial.println("WiFi failed; trying again...");
175
    while (1) delay(1000);
176
  }
177

178
  // listen on port 53...
179
  if (clientDnsUdp.listen(DNS_PORT)) {
180
    Serial.println("Started listening...");
181
    clientDnsUdp.onPacket(handleIncomingDnsQuery);
182
  }
183

184
  // call handleCloudflareResponse any time cloudflare returns a response
185
  if (cloudflareDnsUdp.connect(DNS_SERVER, DNS_PORT)) {
186
    Serial.println("Connected to 1.1.1.1");
187
    cloudflareDnsUdp.onPacket(handleCloudflareResponse);
188
  }
189
}
190

191
void loop() {
192
  // using this to get the ip from the serial monitor!
193
  Serial.println(WiFi.localIP());
194
  delay(10000);
195
}

References


Hey, my name is Amanvir Parhar (but you can call me Aman)! I'm an undergrad studying C.S. at the University of Maryland, College Park.

If you liked this post, feel free to check out my other work, and follow me on X/Twitter!

© 2025 Amanvir Parhar. All rights reserved.