Escreva um servidor DNS com Node.js
Como o DNS funciona
Não sei se você já se perguntou: por que temos nomes de domínio?
Sabemos que a identificação de um computador é feita por meio de um endereço IP. O IPv4 tem 32 bits e o IPv6 tem 128 bits.
O IPv4 geralmente é expresso em decimal:
192.10.128.240
O IPv6 é muito longo e geralmente é expresso em hexadecimal:
3C0B:0000:2667:BC2F:0000:0000:4669:AB4D
Seja IPv4 ou IPv6, essa sequência de números é muito difícil de memorizar, e digitar uma string assim para acessar uma página web é extremamente trabalhoso.
Além disso, o IP não é fixo. Se o data center for relocado, o IP também mudará.
Como acessar o servidor de destino de forma fácil de lembrar e sem depender de um IP fixo?
Pode-se atribuir um nome, e o cliente acessa a máquina de destino por esse nome em vez do IP.
A relação de vínculo entre o nome e o IP pode ser alterada, e cada acesso precisa passar pelo processo de resolução do IP correspondente ao nome.
Esse nome é chamado de nome de domínio.
Como manter a relação de mapeamento entre o nome de domínio e o IP?
A maneira mais simples é registrar a correspondência entre todos os nomes de domínio e IPs em um arquivo e consultar esse arquivo toda vez que um nome de domínio precisar ser resolvido.
No início, foi realmente projetado assim. Esse arquivo é chamado de arquivo hosts, que registrava todos os hosts do mundo.
Naquela época, não havia muitas máquinas no mundo, então esse método era viável.
Claro, a configuração desse hosts era mantida de forma centralizada. Quando um novo host precisava se conectar à internet, registrava seu nome de domínio e IP ali. Outras máquinas podiam acessar esse host baixando a configuração mais recente.
No entanto, à medida que o número de máquinas aumentou, esse método deixou de funcionar bem, e surgiram dois problemas principais:
O mundo inteiro precisava sincronizar a configuração a partir de uma única máquina, e essa máquina ficaria sob muita pressão.
Quando há muitos nomes de domínio, é fácil haver conflitos de nomenclatura.
Por isso, o servidor de nomes de domínio precisava ser distribuído, fornecendo serviços por meio de vários servidores, e o ideal era dividir por espaço de nomes para reduzir conflitos de nomenclatura.
Assim, os nomes de domínio foram criados. Por exemplo, o .com em baidu.com é um domínio chamado domínio de nível superior, e baidu é o domínio de segundo nível do domínio com.
Dessa forma, se existir outro baidu.xyz, também é possível, pois xyz e com são domínios diferentes, cada um com seu próprio espaço de nomes.
Isso reduz os conflitos de nomenclatura.
Se o sistema é distribuído, é necessário dividir o nome de domínio e definir qual servidor será responsável por processá-lo, de modo a distribuir a pressão das requisições.
É natural pensar que o domínio de nível superior, o domínio de segundo nível e o domínio de terceiro nível sejam colocados em servidores diferentes para resolução.
Todos os servidores de domínio de nível superior também possuem um diretório chamado servidor de nomes de domínio raiz.
Assim, ao consultar o IP de um nome de domínio, primeiro verifica-se o endereço do domínio de nível superior com o servidor de nomes de domínio raiz e, em seguida, consulta-se o endereço do servidor correspondente se houver um domínio de segundo nível, até encontrar o IP final.
Claro, o método anterior de hosts não foi completamente abandonado. Ainda consultamos primeiro o hosts e só solicitamos ao servidor de nomes de domínio se não encontrarmos o resultado.
Funciona assim:
Por exemplo, para consultar o IP do nome de domínio www.baidu.com, primeiro verifica-se o hosts local; se não for encontrado, consulta-se o endereço do servidor de nomes de domínio genérico de nível superior do domínio com a partir do servidor de nomes de domínio raiz e, em seguida, consulta-se o servidor de nomes de domínio de nível superior para baidu.com. O endereço do servidor de nomes de domínio de nível superior é verificado camada por camada até encontrar o IP final.
Dessa forma, a pressão sobre o servidor é distribuída de maneira descentralizada.
No entanto, ainda há um problema com esse design. Há um servidor para cada nível de domínio. Se houver muitos níveis de nomes de domínio, serão necessárias várias consultas de ida e volta, e a eficiência não será alta.
Por isso, o DNS (Domain Name System) é dividido em apenas três níveis de servidores de nomes de domínio:
Servidor de nomes de domínio raiz: registra os endereços de todos os servidores de nomes de domínio de nível superior e é o ponto de entrada da resolução de nomes de domínio
Servidor de nomes de domínio de nível superior: registra o endereço do servidor correspondente a cada nome de domínio de segundo nível
Servidor de nomes de domínio autoritativo: os domínios de segundo, terceiro nível e além sob o domínio são resolvidos aqui
Na verdade, os domínios de segundo, terceiro, quarto, quinto nível e além são consolidados em um único servidor para resolução, chamado de Servidor de Nomes de Domínio Autoritativo.
Isso não apenas reduz a pressão sobre o servidor por meio da distribuição, mas também evita a lentidão na resolução causada por camadas excessivas.
Claro, cada consulta ainda consome tempo. Após a conclusão da consulta, o resultado deve ser armazenado em cache com um tempo de expiração definido. O tempo de cache do registro de resolução de nome de domínio no servidor DNS é chamado de TTL (Time-To-Live).
Mas agora esse resultado de resolução está armazenado em cache apenas em uma determinada máquina, e outras máquinas em uma determinada região ainda podem precisar resolvê-lo ao acessá-lo.
Por isso, o DNS projetou uma camada de servidor de nomes de domínio local, responsável por concluir a resolução de nomes de domínio e armazenar os resultados em cache.
Dessa forma, uma máquina específica só precisa enviar uma solicitação ao servidor de nomes de domínio local, e outras máquinas podem usar o resultado de resolução diretamente.
Esses servidores de nomes de domínio locais são fornecidos por ISPs (Internet Service Providers), como China Mobile e China Unicom, e geralmente há um em cada cidade. Quando uma determinada máquina acessa um determinado nome de domínio, o resultado é armazenado em cache após a resolução, e outras máquinas não precisam resolver o nome de domínio novamente ao acessá-lo.
O endereço desse servidor de nomes de domínio local pode ser modificado. No Mac, você pode abrir Preferências do Sistema --> Rede --> Avançado --> DNS para visualizar e modificar o endereço do servidor de nomes de domínio local.
É assim que o DNS funciona.
Será que todo mundo sente vontade de implementar um servidor DNS por conta própria ao descobrir que o endereço do servidor de nomes de domínio local pode ser modificado?
De fato, esse servidor DNS pode ser implementado por conta própria, e a seguir vamos usar o Node.js para implementá-lo.
Vamos analisar as ideias primeiro:
Análise das ideias de implementação do servidor DNS
O DNS é um protocolo da camada de aplicação, e a transmissão do conteúdo do protocolo ainda precisa passar pelo UDP ou TCP da camada de transporte.
Sabemos que o TCP primeiro estabelece uma conexão com um handshake de três vias e depois envia um pacote; se houver perda, ele retransmite para garantir que os dados sejam entregues em ordem.
É adequado para comunicações que exigem múltiplas requisições e respostas, pois essa comunicação precisa garantir a ordem de processamento, tipicamente HTTP.
No entanto, essa garantia de confiabilidade também sacrifica um certo desempenho, e a eficiência é relativamente baixa.
O UDP não estabelece conexão, mas envia datagramas diretamente à outra parte, sendo mais eficiente. É adequado para cenários que não precisam garantir a ordem.
Obviamente, cada requisição de consulta do DNS é independente, não há requisito de ordem, sendo mais adequado para UDP.
Portanto, precisamos usar o Node.js para iniciar um serviço UDP que receba o datagrama DNS do cliente, realizar a resolução de nomes de domínio por conta própria ou encaminhar para outros servidores de nomes de domínio para processamento. Em seguida, enviamos o resultado da resolução ao cliente.
Criamos serviços UDP e enviamos dados usando o pacote dgram do Node.js.
Algo assim:
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);
O código específico será discutido em detalhes mais adiante. Aqui sabemos que o serviço UDP precisa ser ativado para receber dados do protocolo DNS.
O servidor DNS armazena registros da correspondência entre nomes de domínio e IPs. Esses registros são de quatro tipos:
A: O IP correspondente ao nome de domínio
CNAME: O alias correspondente ao nome de domínio
MX: O nome de domínio ou IP correspondente ao sufixo do nome de e-mail
NS: O nome de domínio precisa ser resolvido em outro servidor DNS
PTR: O nome de domínio correspondente ao IP
Na verdade, é bastante fácil de entender:
O tipo A serve para consultar o IP correspondente ao nome de domínio, que pode ser informado diretamente ao cliente.
O tipo NS precisa ser resolvido em outro servidor DNS. Por exemplo, o servidor de nomes de domínio de nível superior precisa ir além, até o servidor de nomes de domínio autoritativo, para resolução.
CNAME é um alias para o nome de domínio atual, e ambos os nomes de domínio serão resolvidos para o mesmo IP.
PTR é usado pelo IP para consultar nomes de domínio, e o DNS suporta resolução reversa.
MX é o nome de domínio ou IP correspondente à caixa de correio, usado para resolver endereços de e-mail como @xxx.com.
Quando o servidor DNS recebe os dados do protocolo DNS, ele consulta essa tabela de registros para encontrar o registro correspondente e, em seguida, retorna no formato do protocolo DNS.
Qual é o formato do protocolo DNS?
Mais ou menos assim:
Ainda há muito conteúdo, vamos destacar alguns pontos importantes:
O Transaction ID é usado para associar a requisição à resposta.
Flags são alguns bits de sinalização:
Por exemplo, QR identifica se é uma requisição ou uma resposta. OPCODE identifica se é uma consulta direta, ou seja, de nome de domínio para IP, ou uma consulta reversa, ou seja, de IP para nome de domínio.
Depois, temos o número de perguntas, o número de respostas, o número de autorizações e o número de informações adicionais.
Em seguida, vem o conteúdo específico das perguntas e respostas.
O formato da seção de perguntas é este:
Primeiro vem o nome da consulta, como baidu.com, depois o tipo de consulta, que são os tipos A, NS, CNAME, PTR mencionados acima. A última classe de consulta geralmente é 1, que significa dados da internet.
O formato da resposta é este:
Name também é o nome de domínio a ser consultado, Type é A, NS, CNAME, PTR etc., e Class é igual à parte da pergunta, que é 1.
Depois, é necessário especificar o Time to live, ou seja, por quanto tempo esse registro de resolução será armazenado em cache. O DNS usa isso para controlar o tempo de expiração do cache dos clientes e dos servidores DNS locais.
Por último, vem o comprimento e o conteúdo dos dados.
Esse é o formato do protocolo DNS.
Sabemos como iniciar o serviço UDP e qual é o formato dos dados do protocolo DNS recebidos; então podemos começar a implementar o servidor DNS. Analisamos o nome de domínio da parte da pergunta, realizamos a resolução por conta própria e retornamos os dados de resposta correspondentes.
O princípio provavelmente está esclarecido; vamos escrever o código:
Servidor DNS escrito à mão
Primeiro, criamos um serviço UDP que escuta na porta 53, que é a porta padrão do protocolo DNS.
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)
Criamos um serviço UDP por meio do módulo dgram, iniciamos na porta 53, processamos o evento de início de escuta, imprimimos o endereço e a porta do servidor, processamos o evento de erro e imprimimos o stack de erro. Imprimimos diretamente quando uma mensagem é recebida.
Modifique o endereço do servidor DNS local nas Preferências do Sistema para apontar para esta máquina:
Dessa forma, quando acessarmos a página web novamente, nosso console de serviço imprimirá a mensagem recebida:
Um monte de dados Buffer — esta é a mensagem do protocolo DNS.
Analisamos e imprimimos o nome de domínio consultado a partir dela, ou seja, esta parte:
A primeira parte da pergunta tem 12 bytes, então vamos truncá-la e analisá-la:
server.on('message', (msg, rinfo) => {
const host = parseHost(msg.subarray(12))
console.log(`query: ${host}`)
})
msg é do tipo Buffer, um subtipo de Uint8Array, que é um inteiro sem sinal. (O armazenamento de inteiros pode ser com ou sem sinal, e o número que pode ser armazenado sem sinal será o dobro.)
Chamamos seu método subarray para truncar os primeiros 12 bytes.
Depois, analisamos a parte da pergunta:
O primeiro elemento é o nome de domínio; precisamos apenas resolver o nome de domínio.
Indicamos que o nome de domínio é separado por ., mas no armazenamento não é assim; o formato é:
Comprimento do campo atual + conteúdo do campo atual + comprimento do campo atual + conteúdo do campo atual + comprimento do campo atual + conteúdo do campo atual + 0
Nesse formato, o nome de domínio termina com 0.
Então a lógica de análise é assim:
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
}
Lemos um inteiro sem sinal por meio do método readUInt8 do Buffer e interceptamos um determinado segmento de conteúdo por meio do método subarray do Buffer.
Ambos os métodos especificam o offset, que indica onde começar.
Primeiro lemos um número, que é o comprimento do domínio atual, depois lemos o conteúdo desse comprimento e continuamos lendo o próximo segmento até lermos 0, que indica o fim do nome de domínio.
Conectamos os campos intermediários com .. Por exemplo, 3 www 5 baidu 3 com torna-se www.baidu.com após o processamento.
Depois, reiniciamos o servidor para testar o efeito:
Conseguimos analisar com sucesso o nome de domínio da consulta a partir dos dados do protocolo DNS!
Analisar a parte da consulta é apenas o primeiro passo; o próximo é retornar a resposta correspondente.
Aqui, tratamos apenas alguns dos nomes de domínio por conta própria, e o restante é entregue a outros servidores DNS locais para processamento:
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)
}
});
Se o nome de domínio resolvido contiver guangguangguang, ele será processado internamente, e a mensagem correspondente do protocolo DNS será construída e retornada.
Caso contrário, é encaminhado para outro servidor DNS local para processamento, e o resultado é retornado ao cliente.
Primeiro, implementamos a parte de encaminhamento:
Encaminhar para outro servidor DNS significa criar um cliente UDP, passar a mensagem recebida para ele e, após receber a resposta, encaminhá-la ao cliente.
Funciona assim:
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()
}
});
}
Criamos um cliente UDP por meio de dgram.createSocket; o parâmetro udp4 representa o endereço IPv4.
Tratamos erros, monitoramos mensagens e encaminhamos msg para o servidor DNS de destino (o endereço do servidor DNS aqui pode ser alterado para outro).
Após receber a mensagem de retorno, ela é passada ao cliente.
O IP e a porta do cliente são passados por parâmetros.
Dessa forma, a transferência do protocolo DNS é realizada. Vamos testar o efeito atual primeiro.
Use o comando nslookup para consultar o endereço de um nome de domínio:
Como pode ser visto, o endereço IP correspondente pode ser obtido consultando baidu.com, que pode ser acessado no navegador.
E guangguangguang.ddd.com não encontrou o IP correspondente.
A seguir, implementamos o método resolve e construímos uma mensagem de retorno do protocolo DNS.
Ainda neste formato:
É construído mais ou menos assim:
O ID da sessão é extraído da msg passada, e as flags também são definidas. O número de perguntas e respostas é 1, e o número de autorizações e informações adicionais é 0.
A área de perguntas e a área de respostas são definidas conforme o formato correspondente:
É necessário criar um objeto buffer com Buffer.alloc.
O processo também usará buffer.writeUInt16BE para gravar alguns inteiros sem sinal de dois bytes.
O BE aqui significa Big Endian, ou seja, o byte mais significativo é colocado à esquerda e o menos significativo à direita.
Por exemplo, 00000000 00000001 é um inteiro sem sinal de dois bytes em big-endian representando 1. O little-endian 1 seria 00000001 00000000, ou seja, o byte mais significativo é colocado à direita.
Montar a mensagem do protocolo DNS é bastante trabalhoso. Basta dar uma olhada:
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()
}
})
}
Por fim, a mensagem montada do protocolo DNS é enviada à outra parte.
Dessa forma, a resolução do nome de domínio guangguangguang é realizada.
No código acima, resolvi para o IP 11.22.33.44.
Vamos testar com nslookup:
Como pode ser visto, a resolução do nome de domínio correspondente foi bem-sucedida!
Assim, implementamos o servidor DNS por meio do Node.js.
Segue o código completo; você pode executá-lo por conta própria e, em seguida, apontar o servidor DNS local do seu computador para ele e experimentar:
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)
Resumo
Neste artigo, aprendemos os princípios do DNS e implementamos um servidor DNS local por conta própria usando Node.js.
Quando o nome de domínio é resolvido, o arquivo hosts será consultado primeiro. Se não for encontrado, o servidor de nomes de domínio local será solicitado. Este é fornecido pelo ISP. Geralmente, cada cidade tem um.
O servidor de nomes de domínio local é responsável por resolver o IP correspondente ao nome de domínio. Ele solicitará ao servidor de nomes de domínio raiz, ao servidor de nomes de domínio de nível superior e ao servidor de nomes de domínio autoritativo, sucessivamente, para obter o IP final e retorná-lo ao cliente.
O computador pode definir o endereço do servidor de nomes de domínio local, e nós o apontamos para o servidor DNS local implementado com Node.js.
O protocolo DNS é baseado em transmissão UDP, então iniciamos o serviço UDP na porta 53 por meio do módulo dgram.
Depois, conforme o formato do protocolo DNS, o nome de domínio é analisado, o nome de domínio de destino é processado internamente e a mensagem de retorno do protocolo DNS é construída. Outros nomes de domínio são encaminhados para outro servidor DNS local para resolução, e a mensagem retornada por ele é passada ao cliente.
Dessa forma, implementamos um servidor DNS local em Node.js.
Artigos relacionados
-
6 tecnologias opcionais para armazenamento de dados
Equipe da Base de Conhecimento
Explorar mais ofertas especiais
-
Short Message Service (SMS) e Mail Service
Pacote de 50.000 e-mails a partir de apenas US$ 1,99; 120 mensagens curtas a partir de apenas US$ 1,00
