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.
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.101Oftentimes, 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.
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!
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:
1 | |
2 | |
3 | |
4 | |
5 | |
6 | |
7 | |
8 | |
9 | |
10 | |
11 | |
12 | |
13 | |
14 | |
15 | |
16 | |
17 | |
18 | |
19 | |
20 | |
21 | |
22 | |
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).
It's time to start working on the core functionality of our sinkhole.
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:
1 | |
2 | |
3 | |
4 | |
5 | |
6 | |
7 | |
8 | |
9 | |
10 | |
11 | |
12 | |
13 | |
14 | |
15 | |
16 | |
17 | |
18 | |
19 | |
20 | |
21 | |
22 | |
23 | |
24 | |
25 | |
26 | |
27 | |
28 | |
29 | |
30 | |
31 | |
32 | |
33 | |
34 | |
35 | |
36 | |
37 | |
38 | |
39 | |
40 | |
41 | |
42 | |
43 | |
44 | |
45 | |
46 | |
47 | |
48 | |
49 | |
50 | |
51 | |
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).
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...
In our code, we can represent each of the fields in the DNS header using a struct:
1 | |
2 | |
3 | |
4 | |
5 | |
6 | |
7 | |
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.
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:
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:
1 | |
2 | |
3 | |
4 | |
5 | |
6 | |
7 | |
8 | |
9 | |
10 | |
11 | |
...and turn it into a set so that we can perform constant-time lookup in our program:
1 | |
2 | |
3 | |
4 | |
5 | |
6 | |
7 | |
8 | |
9 | |
10 | |
11 | |
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 | |
2 | |
3 | |
4 | |
5 | |
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 | |
2 | |
3 | |
4 | |
5 | |
6 | |
7 | |
8 | |
9 | |
10 | |
11 | |
12 | |
13 | |
14 | |
15 | |
16 | |
17 | |
18 | |
19 | |
20 | |
21 | |
22 | |
23 | |
24 | |
25 | |
26 | |
27 | |
28 | |
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...
Here's what that'd look like as code:
1 | |
2 | |
3 | |
4 | |
5 | |
6 | |
7 | |
8 | |
9 | |
10 | |
11 | |
12 | |
13 | |
14 | |
15 | |
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 | |
2 | |
3 | |
4 | |
5 | |
Okay, now we're ready to perform the lookup, and modify the response if the domain is in the BLOCKLIST!
1 | |
2 | |
3 | |
4 | |
5 | |
6 | |
7 | |
8 | |
9 | |
10 | |
11 | |
12 | |
13 | |
14 | |
15 | |
16 | |
17 | |
18 | |
19 | |
20 | |
21 | |
22 | |
23 | |
24 | |
25 | |
26 | |
27 | |
28 | |
29 | |
30 | |
31 | |
32 | |
33 | |
34 | |
35 | |
36 | |
37 | |
38 | |
39 | |
Woah... a lot is happening in the code block above, so let's go over the most crucial parts in detail:
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 | |
2 | |
3 | |
4 | |
5 | |
6 | |
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 | |
2 | |
3 | |
4 | |
5 | |
6 | |
7 | |
8 | |
...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.
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 | |
2 | |
3 | |
4 | |
5 | |
6 | |
7 | |
8 | |
9 | |
10 | |
11 | |
12 | |
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!
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:
Hey, it works! 🙃
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.
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!
1 | |
2 | |
3 | |
4 | |
5 | |
6 | |
7 | |
8 | |
9 | |
10 | |
11 | |
12 | |
13 | |
14 | |
15 | |
16 | |
17 | |
18 | |
19 | |
20 | |
21 | |
22 | |
23 | |
24 | |
25 | |
26 | |
27 | |
28 | |
29 | |
30 | |
31 | |
32 | |
33 | |
34 | |
35 | |
36 | |
37 | |
38 | |
39 | |
40 | |
41 | |
42 | |
43 | |
44 | |
45 | |
46 | |
47 | |
48 | |
49 | |
50 | |
51 | |
52 | |
53 | |
54 | |
55 | |
56 | |
57 | |
58 | |
59 | |
60 | |
61 | |
62 | |
63 | |
64 | |
65 | |
66 | |
67 | |
68 | |
69 | |
70 | |
71 | |
72 | |
73 | |
74 | |
75 | |
76 | |
77 | |
78 | |
79 | |
80 | |
81 | |
82 | |
83 | |
84 | |
85 | |
86 | |
87 | |
88 | |
89 | |
90 | |
91 | |
92 | |
93 | |
94 | |
95 | |
96 | |
97 | |
98 | |
99 | |
100 | |
101 | |
102 | |
103 | |
104 | |
105 | |
106 | |
107 | |
108 | |
109 | |
110 | |
111 | |
112 | |
113 | |
114 | |
115 | |
116 | |
117 | |
118 | |
119 | |
120 | |
121 | |
122 | |
123 | |
124 | |
125 | |
126 | |
127 | |
128 | |
129 | |
130 | |
131 | |
132 | |
133 | |
134 | |
135 | |
136 | |
137 | |
138 | |
139 | |
140 | |
141 | |
142 | |
143 | |
144 | |
145 | |
146 | |
147 | |
148 | |
149 | |
150 | |
151 | |
152 | |
153 | |
154 | |
155 | |
156 | |
157 | |
158 | |
159 | |
160 | |
161 | |
162 | |
163 | |
164 | |
165 | |
166 | |
167 | |
168 | |
169 | |
170 | |
171 | |
172 | |
173 | |
174 | |
175 | |
176 | |
177 | |
178 | |
179 | |
180 | |
181 | |
182 | |
183 | |
184 | |
185 | |
186 | |
187 | |
188 | |
189 | |
190 | |
191 | |
192 | |
193 | |
194 | |
195 | |
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!