Write a DNS server with Node.js

How DNS works


I don't know if you have considered, why do you have a domain name?
We know that identifying a computer is using an IP address, IPv4 has 32 bits, and IPv6 has 128 bits.
IPv4 is generally expressed in decimal:
192.10.128.240

IPv6 is too long, generally expressed in hexadecimal:
3C0B:0000:2667:BC2F:0000:0000:4669:AB4D

Whether it is IPv4 or IPv6, this string of numbers is too difficult to remember, and it is too troublesome to enter such a string of numbers when visiting a web page.
Moreover, the IP is not fixed. If the computer room is relocated, the IP will also change.
How to access the target server in a way that is easy to remember and not limited to a fixed IP?
A name can be given, and the client can access the target machine through this name instead of IP.
The binding relationship between the name and the IP can be changed, and each visit has to go through the process of resolving the IP corresponding to the name.
This name is called a domain name.
How to maintain the mapping relationship between this domain name and IP?
The easiest way is to record the correspondence between all domain names and IPs in a file, and check this file every time a domain name is resolved.
It was really designed like this at the beginning. Such a file is called the hosts file, which records all the hosts in the world.
At that time, there were not many machines in the world, so this method was feasible.
Of course, the configuration of this hosts is maintained uniformly. When a new host needs to be connected to the Internet, register your own domain name and IP here. Other machines can access this host by pulling the latest configuration.
However, as the number of machines increases, this method does not work well, and there are two outstanding problems:


The whole world has to synchronize the configuration from a certain machine, and this machine will be under too much pressure.


When there are many domain names, it is easy to conflict with the naming.


Therefore, the domain name server must be distributed, providing services through multiple servers, and it is best to divide it by namespace to reduce naming conflicts.
Therefore, domain names were created. For example, the com baidu.com is a domain called the top-level domain, and baidu is the second-level domain of the com domain.
In this way, if there is another baidu.xyz, it is also possible, because xyz and com are different domains, and there are separate namespaces under them.
This reduces naming conflicts.

If it is distributed, it is necessary to divide the domain name and let which server handle it, so as to spread the pressure of the request.
It is easy to think that the top-level domain, second-level domain, and third-level domain are placed on different servers for analysis.
All top-level domain servers also have a directory called the root domain server.
In this way, when querying the IP of a domain name, first check the address of the top-level domain with the root domain name server, and then check the address of the corresponding server if there is a second-level domain, until the final IP is found.
Of course, the previous hosts method has not been completely abandoned. We will still check the hosts first, and then request the domain name server if we cannot find it.
That's it:

For example, to check the IP of the domain name www.baidu.com, first check the local hosts, if not found, check the address of the generic top-level domain name server of the com domain from the root domain name server, and then query the top-level domain name server for baidu.com II. The address of the top-level domain name server is checked layer by layer until the final IP is found.
In this way, the pressure on the server is distributed in a distributed manner.
However, there is still a problem with this design. There is one server for each level of domain. If there are too many levels of domain names, it will require multiple round-trip queries, and the efficiency is not high.
Therefore, DNS (Domain Name System) is only divided into three-level domain name servers:

Root domain name server: records the addresses of all top-level domain name servers and is the entrance of domain name resolution
Top-level domain name server: records the address of the server corresponding to each second-level domain name
Authoritative Domain Name Server: The second-level, third-level and even more domain names under the domain are resolved here

In fact, the second, third, fourth, fifth or even more domain names are merged into one server for resolution, which is called the Authoritative Domain Name Server (Authoritative Domain Name Server).
This not only reduces the pressure on the server through distribution, but also avoids the slow parsing caused by too many layers.

Of course, each query is still time-consuming. After the query is completed, the result should be cached and an expiration time should be set. The cache time of the domain name resolution record on the DNS server is called TTL (Time-To-Live).
But now this parsing result is only cached on a certain machine, and other machines in a certain area may still need to parse it when accessing it.
Therefore, DNS has designed a layer of local domain name server, which is responsible for completing domain name resolution and caching the results.
In this way, a specific machine only needs to send a request to the local domain name server, and other machines can use the parsing result directly.

Such local domain name servers are provided by ISPs (Internet Service Providers) such as China Mobile and China Unicom, and generally have one in each city. When a certain machine accesses a certain domain name, the result will be cached after parsing, and other machines will not need to parse the domain name again when accessing this domain name.
The address of this local domain name server can be modified. In mac, you can open System Preferences --> Network --> Advanced --> DNS to view and modify the address of the local domain name server.

This is how DNS works.
I wonder if everyone has the urge to implement a DNS server by themselves when they see that the configuration of the local domain name server can be modified.
Indeed, this DNS server can be implemented by itself, and then we will use Node.js to implement it.
Let's analyze the ideas first:
Analysis of DNS Server Implementation Ideas
DNS is an application layer protocol, and the transmission of the protocol content still needs to pass through the UDP or TCP of the transport layer.
We know that TCP will first establish a connection with a three-way handshake, and then send a packet, and if it is lost, it will be retransmitted to ensure that the data is delivered in order.
It is suitable for some communication that requires multiple requests and responses, because this communication needs to ensure the processing order, typically HTTP.
However, such a reliability guarantee also sacrifices a certain performance, and the efficiency is relatively low.
UDP does not establish a connection, but directly sends datagrams to the other party, which is more efficient. It is suitable for some scenarios that do not need to guarantee the order.
Obviously, each query request of DNS is independent, there is no order requirement, it is more suitable for UDP.
So we need to use Node.js to start a UDP service to receive the client's DNS datagram, realize the domain name resolution by ourselves, or forward it to other domain name servers for processing. Then send the parsed result to the client.
Create UDP services and send data using Node.js's dgram package.
Something like this:
const dgram = require('dgram');

const server = dgram.createSocket('udp4')

server.on('message', (msg, rinfo) => {
// handle DNS protocol messages
})

server.on('error', (err) => {
// handle errors
})

server.on('listening', () => {
// when the recipient address is determined
});

server.bind(53);

The specific code will be discussed in detail later. Here we know that the UDP service needs to be enabled to receive DNS protocol data.
The DNS server stores records of the correspondence between domain names and IPs. These records are of four types:

A: The IP corresponding to the domain name
CNAME: The alias corresponding to the domain name
MX: The domain name or IP corresponding to the suffix of the email name
NS: The domain name needs to go to another DNS server for resolution
PTR: The domain name corresponding to the IP

It's actually quite easy to understand:
Type A is to query the IP corresponding to the domain name, which can be directly told to the client.
Type NS needs to go to another DNS server for resolution. For example, the top-level domain name server needs to go further to the authoritative domain name server for resolution.
CNAME is an alias for the current domain name, and the two domain names will resolve to the same IP.
PTR is used by IP to query domain names, and DNS supports reverse resolution
MX is the domain name or IP corresponding to the mailbox, which is used to resolve email addresses like @xxx.com.
When the DNS server receives the DNS protocol data, it will go to this record table to find the corresponding record, and then return it in the format of the DNS protocol.
What is the format of the DNS protocol?
Probably something like this:

There is still a lot of content, let's pick a few highlights to look at:
The Transaction ID is used to associate the request with the response.
Flags are some flag bits:

For example, QR is to identify whether it is a request or a response. OPCODE identifies whether it is forward query, that is, domain name to IP, or reverse query, that is, IP to domain name.
Then there are the number of questions, the number of answers, the number of authorizations, and the number of additional information.
Then there are the specific content of questions and answers.
The format of the question section is this:

The first is the name of the query, such as baidu.com, and then the type of query, which is the A, NS, CNAME, PTR and other types mentioned above. The last query class is generally 1, which means internet data.
The format of the answer is this:

Name is also the domain name to be queried, Type is A, NS, CNAME, PTR, etc., and Class is the same as the question part, which is 1.
Then you need to specify Time to live, that is, how long this parsing record will be cached. DNS uses this to control the cache expiration time of clients and local DNS servers.
The last is the length and content of the data.
This is the format of the DNS protocol.
We know how to start the UDP service and what format the received DNS protocol data is in, then we can start implementing the DNS server. Parse the domain name of the problem part, implement the parsing by yourself, and return the corresponding response data.
The principle is probably clarified, let's write the code:
Handwritten DNS server
First, we create a UDP service that listens on port 53, which is the default port for the DNS protocol.
const dgram = require('dgram')

const server = dgram.createSocket('udp4')

server.on('message', (msg, rinfo) => {
console.log(msg)
});

server.on('error', (err) => {
console.log(`server error: ${err.stack}`)
server.close()
})

server.on('listening', () => {
const address = server.address()
console.log(`server listening ${address.address}:${address.port}`)
})

server.bind(53)

Create a UDP service through the dgram module, start it on port 53, process the event to start listening, print the server address and port, process the error event, and print the error stack. Print directly when a message is received.

Modify the local DNS server address in System Preferences to point to this machine:

In this way, when we visit the web page again, our service console will print the received message:

A bunch of Buffer data, this is the message of the DNS protocol.
We parse and print out the queried domain name from it, that is, this part:

The first part of the question is 12 bytes, so let's truncate it and parse it:
server.on('message', (msg, rinfo) => {
const host = parseHost(msg.subarray(12))
console.log(`query: ${host}`)
})

msg is of type Buffer, a subtype of Uint8Array, which is an unsigned integer. (Integer storage can be signed or unsigned, and the number that can be stored without a sign will be twice as large.)
Call its subarray method to truncate the first 12 bytes.
Then parse the question part:

The first problem is the domain name, we just need to resolve the domain name.
We indicate that the domain name is distinguished by ., but not when it is stored, it is by
Current field length + current field content + current field length + current field content + current field length + current field content + 0
In this format, the domain name ends with 0.
So the parsing logic is like this:
function parseHost(msg) {
let num = msg.readUInt8(0);
let offset = 1;
let host = "";
while (num !== 0) {
host += msg.subarray(offset, offset + num).toString();
offset += num;

num = msg.readUInt8(offset);
offset += 1;
if (num !== 0) {
host += '.'
}
}
return host
}

Read an unsigned integer through Buffer's readUInt8 method, and intercept a certain segment of content through Buffer's subarray method.
Both methods specify the offset, which is where to start.
We first read a number, which is the length of the current domain, then read the content of this length, and then continue to read the next paragraph until we read 0, which means the end of the domain name.
Connect the fields in the middle with . . For example, 3 www 5 baidu 3 com is www.baidu.com after processing.
Then we restart the server to test the effect:

We successfully parsed the domain name of query from the DNS protocol data!
Parsing the query part is only the first step, and then returning the corresponding response.
Here we only handle some of the domain names by ourselves, and the rest of the domain names are handed over to other local DNS servers for processing:
server.on('message', (msg, rinfo) => {
const host = parseHost(msg.subarray(12))
console.log(`query: ${host}`);

if (/guangguangguang/.test(host)) {
resolve(msg, rinfo)
} else {
forward(msg, rinfo)
}
});

If the resolved domain name contains guangguangguang, it will be processed by itself, and the corresponding DNS protocol message will be constructed and returned.
Otherwise, it is forwarded to another local DNS server for processing, and the result is returned to the client.
First implement the forward part:
To forward to another DNS server, that is to create a UDP client, pass the received message to it, and then forward it to the client after receiving the message.
That's it:
function forward(msg, rinfo) {
const client = dgram.createSocket('udp4');

client.on('error', (err) => {
console.log(`client error: ${err.stack}`);
client.close();
});

client.on('message', (fbMsg, fbRinfo) => {
server.send(fbMsg, rinfo.port, rinfo.address, (err) => {
err && console.log(err)
})
client.close();
});

client.send(msg, 53, '192.168.199.1', (err) => {
if (err) {
console.log(err)
client.close()
}
});
}

Create a UDP client through dgram.createSocket, the parameter udp4 represents the IPv4 address.
Handle errors, monitor messages, and forward msg to the target DNS server (the DNS server address here can be changed to something else).
After receiving the returned message, it is passed to the client.
The ip and port of the client are passed in through parameters.
In this way, the transfer of the DNS protocol is realized. Let's test the current effect first.
Use the nslookup command to query the address of a domain name:

It can be seen that the corresponding IP address can be obtained by querying baidu.com, which can be accessed in the browser.
And guangguangguang.ddd.com did not find the corresponding IP.
Next, implement the resolve method and construct a DNS protocol message return.
Still in this format:

It is roughly constructed like this:
The session ID is taken from the passed msg, and the flags are also set. The number of questions and answers are all 1, and the number of authorizations and additional numbers are all 0.
The question area and the answer area are set according to the corresponding format:


A buffer object needs to be created with Buffer.alloc.
The process will also use buffer.writeUInt16BE to write some unsigned double-byte integers.
The BE here is Big Endian, big endian, that is, the high order is placed on the right, and the low order is placed on the left.
For example, 00000000 00000001 is a big-endian double-byte unsigned integer 1. The little-endian 1 is 00000001 00000000, that is, the high order is placed on the left.
It is quite troublesome to assemble the news of the DNS protocol. Just take a look at it:
function copyBuffer(src, offset, dst) {
for (let i = 0; i < src.length; ++i) {
dst.writeUInt8(src.readUInt8(i), offset + i)
}
}

function resolve(msg, rinfo) {
const queryInfo = msg.subarray(12)
const response = Buffer.alloc(28 + queryInfo.length)
let offset = 0


// Transaction ID
const id = msg.subarray(0, 2)
copyBuffer(id, 0, response)
offset += id.length

// Flags
response.writeUInt16BE(0x8180, offset)
offset += 2

// Questions
response.writeUInt16BE(1, offset)
offset += 2

// Answer RRs
response.writeUInt16BE(1, offset)
offset += 2

// Authority RRs & Additional RRs
response.writeUInt32BE(0, offset)
offset += 4
copyBuffer(queryInfo, offset, response)
offset += queryInfo.length

// offset to domain name
response.writeUInt16BE(0xC00C, offset)
offset += 2
const typeAndClass = msg.subarray(msg.length - 4)
copyBuffer(typeAndClass, offset, response)
offset += typeAndClass.length

// TTL, in seconds
response.writeUInt32BE(600, offset)
offset += 4

// Length of IP
response.writeUInt16BE(4, offset)
offset += 2
'11.22.33.44'.split('.').forEach(value => {
response.writeUInt8(parseInt(value), offset)
offset += 1
})
server.send(response, rinfo.port, rinfo.address, (err) => {
if (err) {
console.log(err)
server.close()
}
})
}


Finally, the message of the spliced ​​DNS protocol is sent to the other party.
In this way, the resolution of the domain name of guangguangguang is realized.
In the above code, I resolve it to the IP of 11.22.33.44.
Let's test it with nslookup:

As you can see, the corresponding domain name resolution is successful!
In this way, we have implemented the DNS server through Node.js.
Post a complete code, you can run it yourself, and then point your computer's local DNS server to it and try:
const dgram = require('dgram')

const server = dgram.createSocket('udp4')

function parseHost(msg) {
let num = msg.readUInt8(0);
let offset = 1;
let host = "";
while (num !== 0) {
host += msg.subarray(offset, offset + num).toString();
offset += num;

num = msg.readUInt8(offset);
offset += 1;

if (num !== 0) {
host += '.'
}
}
return host
}

function copyBuffer(src, offset, dst) {
for (let i = 0; i < src.length; ++i) {
dst.writeUInt8(src.readUInt8(i), offset + i)
}
}

function resolve(msg, rinfo) {
const queryInfo = msg.subarray(12)
const response = Buffer.alloc(28 + queryInfo.length)
let offset = 0

// Transaction ID
const id = msg.subarray(0, 2)
copyBuffer(id, 0, response)
offset += id.length

// Flags
response.writeUInt16BE(0x8180, offset)
offset += 2

// Questions
response.writeUInt16BE(1, offset)
offset += 2

// Answer RRs
response.writeUInt16BE(1, offset)
offset += 2

// Authority RRs & Additional RRs
response.writeUInt32BE(0, offset)
offset += 4
copyBuffer(queryInfo, offset, response)
offset += queryInfo.length

// offset to domain name
response.writeUInt16BE(0xC00C, offset)
offset += 2
const typeAndClass = msg.subarray(msg.length - 4)
copyBuffer(typeAndClass, offset, response)
offset += typeAndClass.length

// TTL, in seconds
response.writeUInt32BE(600, offset)
offset += 4

// Length of IP
response.writeUInt16BE(4, offset)
offset += 2
'11.22.33.44'.split('.').forEach(value => {
response.writeUInt8(parseInt(value), offset)
offset += 1
})
server.send(response, rinfo.port, rinfo.address, (err) => {
if (err) {
console.log(err)
server.close()
}
})
}

function forward(msg, rinfo) {
const client = dgram.createSocket('udp4')
client.on('error', (err) => {
console.log(`client error: ${err.stack}`)
client.close()
})
client.on('message', (fbMsg, fbRinfo) => {
server.send(fbMsg, rinfo.port, rinfo.address, (err) => {
err && console.log(err)
})
client.close()
})
client.send(msg, 53, '192.168.199.1', (err) => {
if (err) {
console.log(err)
client.close()
}
})
}

server.on('message', (msg, rinfo) => {
const host = parseHost(msg.subarray(12))
console.log(`query: ${host}`);

if (/guangguangguang/.test(host)) {
resolve(msg, rinfo)
} else {
forward(msg, rinfo)
}
});

server.on('error', (err) => {
console.log(`server error: ${err.stack}`)
server.close()
})

server.on('listening', () => {
const address = server.address()
console.log(`server listening ${address.address}:${address.port}`)
})

server.bind(53)

Summarize
In this article, we learned the principles of DNS and implemented a local DNS server by ourselves using Node.js.
When the domain name is resolved, the hosts file will be queried first. If it is not found, the local domain name server will be requested. This is provided by the ISP. Generally, each city has one.
The local domain name server is responsible for resolving the IP corresponding to the domain name. It will request the root domain name server, top-level domain name server, and authoritative domain name server in turn to get the final IP and return it to the client.

The computer can set the address of the local domain name server, and we point it to the local domain name server implemented with Node.js.
The DNS protocol is based on UDP transmission, so we started the UDP service on port 53 through the dgram module.
Then according to the format of the DNS protocol, the domain name is parsed, the target domain name is processed by itself, and the message return of the DNS protocol is constructed. Other domain names are forwarded to another local DNS server for resolution, and the message returned by it is passed to the client.
In this way, we have implemented a local DNS server in Node.js.

Related Articles

Explore More Special Offers

  1. Short Message Service(SMS) & Mail Service

    50,000 email package starts as low as USD 1.99, 120 short messages start at only USD 1.00