Crea un servidor DNS con Node.js
Cómo funciona DNS
No sé si alguna vez se lo ha planteado: ¿por qué tiene un nombre de dominio?
Sabemos que un equipo se identifica con una dirección IP; IPv4 tiene 32 bits e IPv6 tiene 128 bits.
Por lo general, IPv4 se expresa en decimal:
192.10.128.240
IPv6 es demasiado largo y, por lo general, se expresa en hexadecimal:
3C0B:0000:2667:BC2F:0000:0000:4669:AB4D
Ya sea IPv4 o IPv6, esta cadena de números resulta muy difícil de recordar y es muy engorroso introducir una cadena así al visitar una página web.
Además, la IP no es fija: si el centro de datos cambia de ubicación, la IP también cambia.
¿Cómo acceder al servidor de destino de una forma fácil de recordar y sin quedar limitado a una IP fija?
Se puede asignar un nombre, y el cliente puede acceder a la máquina de destino mediante ese nombre en lugar de la IP.
La relación de vinculación entre el nombre y la IP puede cambiar, de modo que en cada visita se debe realizar el proceso de resolver la IP correspondiente al nombre.
Este nombre se denomina nombre de dominio.
¿Cómo mantener el mapeo entre este nombre de dominio y la IP?
La forma más sencilla es registrar en un archivo la correspondencia entre todos los nombres de dominio y sus IP, y consultar ese archivo cada vez que se resuelve un nombre de dominio.
Al principio se diseñó exactamente así. Ese archivo se llama archivo hosts y registra todos los anfitriones del mundo.
En aquella época no había muchas máquinas en el mundo, así que este método era viable.
Por supuesto, la configuración de este archivo hosts se mantiene de forma unificada: cuando un equipo nuevo necesita conectarse a Internet, registra ahí su propio nombre de dominio y su IP, y las demás máquinas pueden acceder a ese equipo descargando la configuración más reciente.
Sin embargo, a medida que aumenta el número de máquinas, este método deja de funcionar bien y aparecen dos problemas destacados:
Todo el mundo tiene que sincronizar la configuración desde una máquina concreta, y esa máquina quedaría sometida a demasiada presión.
Cuando hay muchos nombres de dominio, es fácil que se produzcan conflictos de nombres.
Por lo tanto, el servidor de nombres de dominio debe ser distribuido y prestar servicio a través de varios servidores; lo ideal es dividirlo por espacio de nombres para reducir los conflictos de nombres.
Por eso se crearon los dominios. Por ejemplo, en baidu.com, «com» es un dominio denominado dominio de nivel superior, y «baidu» es el dominio de segundo nivel del dominio «com».
De este modo también puede existir otro baidu.xyz, porque «xyz» y «com» son dominios distintos y debajo de cada uno hay espacios de nombres separados.
Esto reduce los conflictos de nombres.
Si se distribuye, es necesario dividir el nombre de dominio y determinar qué servidor lo procesa, de manera que la presión de las solicitudes se reparta.
Es fácil pensar que el dominio de nivel superior, el dominio de segundo nivel y el dominio de tercer nivel se ubican en servidores distintos para su análisis.
Todos los servidores de dominios de nivel superior cuentan además con un directorio llamado servidor de dominios raíz.
Así, al consultar la IP de un nombre de dominio, primero se verifica en el servidor de nombres de dominio raíz la dirección del dominio de nivel superior y, si hay un dominio de segundo nivel, después se verifica la dirección del servidor correspondiente, hasta encontrar la IP final.
Por supuesto, el método anterior del archivo hosts no se ha abandonado por completo: todavía se consulta primero el archivo hosts y, si no se encuentra, se solicita al servidor de nombres de dominio.
Es así:
Por ejemplo, para consultar la IP del nombre de dominio www.baidu.com, primero se revisa el archivo hosts local; si no se encuentra, se consulta en el servidor de nombres de dominio raíz la dirección del servidor de nombres del dominio genérico de nivel superior del dominio «com» y, después, se consulta en el servidor de nombres de dominio de nivel superior la dirección de baidu.com II. La dirección del servidor de nombres de dominio de nivel superior se comprueba capa por capa hasta encontrar la IP final.
De esta forma, la presión sobre el servidor se reparte de manera distribuida.
Sin embargo, este diseño todavía tiene un problema: hay un servidor para cada nivel de dominio, y si hay demasiados niveles de nombres de dominio se requieren varias consultas de ida y vuelta, por lo que la eficiencia no es alta.
Por eso, DNS (Sistema de nombres de dominio) se divide únicamente en servidores de nombres de dominio de tres niveles:
Servidor de nombres de dominio raíz: registra las direcciones de todos los servidores de nombres de dominio de nivel superior y es la puerta de entrada de la resolución de nombres de dominio
Servidor de nombres de dominio de nivel superior: registra la dirección del servidor que corresponde a cada nombre de dominio de segundo nivel
Servidor de nombres de dominio autoritativo: aquí se resuelven los nombres de dominio de segundo nivel, de tercer nivel e incluso de niveles superiores que dependen del dominio
De hecho, los nombres de dominio de segundo, tercer, cuarto, quinto nivel o incluso de más niveles se agrupan en un solo servidor para su resolución, que se denomina servidor de nombres de dominio autoritativo (Authoritative Domain Name Server).
Esto no solo reduce la presión sobre el servidor gracias a la distribución, sino que también evita la lentitud del parseo provocada por demasiadas capas.
Por supuesto, cada consulta sigue consumiendo tiempo: una vez completada, conviene almacenar el resultado en caché y establecer un tiempo de vencimiento. El tiempo de caché del registro de resolución de nombres de dominio en el servidor DNS se denomina tiempo de vida (TTL, Time-To-Live).
Pero ahora ese resultado del parseo solo se almacena en caché en una máquina concreta, y otras máquinas de una misma zona pueden tener que volver a analizarlo al acceder.
Por eso DNS incorpora una capa de servidor de nombres de dominio local, encargado de completar la resolución de nombres de dominio y de almacenar los resultados en caché.
De esta forma, una máquina concreta solo necesita enviar una solicitud al servidor de nombres de dominio local, y las demás máquinas pueden usar directamente el resultado del parseo.
Estos servidores de nombres de dominio locales los proporcionan los ISP (proveedores de servicios de Internet), como China Mobile y China Unicom, y por lo general hay uno en cada ciudad. Cuando una máquina accede a un nombre de dominio, el resultado se almacena en caché tras el parseo, y las demás máquinas ya no necesitan volver a resolver ese nombre de dominio al acceder a él.
La dirección de este servidor de nombres de dominio local se puede modificar. En mac puede abrir «Preferencias del Sistema» --> «Red» --> «Avanzado» --> «DNS» para consultar y modificar la dirección del servidor de nombres de dominio local.
Así es como funciona DNS.
Me pregunto si, al ver que la configuración del servidor de nombres de dominio local se puede modificar, a todos les dan ganas de implementar un servidor DNS por su cuenta.
En efecto, este servidor DNS se puede implementar por cuenta propia, y a continuación lo haremos con Node.js.
Primero analicemos la idea:
Análisis de las ideas de implementación del servidor DNS
DNS es un protocolo de la capa de aplicación, y la transmisión del contenido del protocolo todavía debe pasar por el UDP o el TCP de la capa de transporte.
Sabemos que TCP primero establece la conexión con un saludo de tres vías y luego envía un paquete; si se pierde, se retransmite para garantizar que los datos se entreguen en orden.
Resulta adecuado para las comunicaciones que requieren varias solicitudes y respuestas, porque necesitan garantizar el orden de procesamiento; el caso típico es HTTP.
Sin embargo, esa garantía de confiabilidad también sacrifica parte del rendimiento, y la eficiencia es relativamente baja.
UDP no establece una conexión, sino que envía directamente los datagramas a la otra parte, lo que es más eficiente. Es adecuado para los escenarios en los que no hace falta garantizar el orden.
Es evidente que cada solicitud de consulta de DNS es independiente y no exige un orden, así que encaja mejor con UDP.
Por lo tanto, necesitamos usar Node.js para iniciar un servicio UDP que reciba el datagrama DNS del cliente, resolver nosotros mismos el nombre de dominio o reenviarlo a otros servidores de nombres de dominio para que lo procesen, y después enviar el resultado del parseo al cliente.
Para crear servicios UDP y enviar datos se usa el paquete dgram de Node.js.
Algo así:
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);
El código concreto se explicará en detalle más adelante; aquí queda claro que el servicio UDP debe estar habilitado para recibir datos del protocolo DNS.
El servidor DNS almacena registros de la correspondencia entre nombres de dominio e IP. Estos registros son de cuatro tipos:
A: la IP que corresponde al nombre de dominio
CNAME: el alias que corresponde al nombre de dominio
MX: el nombre de dominio o la IP que corresponde al sufijo del nombre del correo electrónico
NS: el nombre de dominio debe acudir a otro servidor DNS para su resolución
PTR: el nombre de dominio que corresponde a la IP
En realidad es bastante fácil de entender:
El tipo A consulta la IP correspondiente al nombre de dominio, la cual se puede indicar directamente al cliente.
El tipo NS requiere acudir a otro servidor DNS para la resolución. Por ejemplo, el servidor de nombres de dominio de nivel superior debe continuar hacia el servidor de nombres de dominio autoritativo para resolverlo.
CNAME es un alias del nombre de dominio actual, y los dos nombres de dominio se resuelven a la misma IP.
PTR se usa para consultar nombres de dominio a partir de la IP, y DNS admite la resolución inversa
MX es el nombre de dominio o la IP que corresponde al buzón de correo, y se usa para resolver direcciones de correo electrónico como @xxx.com.
Cuando el servidor DNS recibe los datos del protocolo DNS, acude a esta tabla de registros para encontrar el registro correspondiente y después lo devuelve en el formato del protocolo DNS.
¿Cuál es el formato del protocolo DNS?
Probablemente sea algo así:
Aún queda mucho contenido; veamos algunos puntos destacados:
El Transaction ID se usa para asociar la solicitud con la respuesta.
Los Flags son bits de marcadores:
Por ejemplo, QR identifica si se trata de una solicitud o de una respuesta. OPCODE identifica si es una consulta directa, es decir, de nombre de dominio a IP, o una consulta inversa, es decir, de IP a nombre de dominio.
Después están el número de preguntas, el número de respuestas, el número de autorizaciones y el número de información adicional.
Y a continuación, el contenido concreto de las preguntas y las respuestas.
El formato de la sección de preguntas es este:
Lo primero es el nombre de la consulta, como baidu.com, y después el tipo de consulta, es decir, los tipos A, NS, CNAME y PTR mencionados antes, entre otros. La clase de la última consulta suele ser 1, que significa datos de Internet.
El formato de la respuesta es este:
Name también es el nombre de dominio que se va a consultar, Type es A, NS, CNAME, PTR, etc., y Class es igual que en la parte de la pregunta, que es 1.
Después hay que especificar Time to live, es decir, cuánto tiempo se almacenará en caché este registro del parseo. DNS lo usa para controlar el tiempo de vencimiento de la caché de los clientes y de los servidores DNS locales.
Por último, la longitud y el contenido de los datos.
Este es el formato del protocolo DNS.
Ya sabemos cómo iniciar el servicio UDP y qué formato tienen los datos del protocolo DNS que se reciben, así que podemos empezar a implementar el servidor DNS: analizar el nombre de dominio de la parte de la pregunta, realizar nosotros mismos la resolución y devolver los datos de la respuesta correspondiente.
El principio ya está más o menos claro; escribamos el código:
Servidor DNS escrito a mano
Primero creamos un servicio UDP que escucha en el puerto 53, que es el puerto predeterminado del 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)
Se crea un servicio UDP mediante el módulo dgram, se inicia en el puerto 53, se procesa el evento para empezar a escuchar y se imprime la dirección y el puerto del servidor; también se procesa el evento de error y se imprime la pila de errores. Cuando se recibe un mensaje, se imprime directamente.
Modifique la dirección del servidor DNS local en «Preferencias del Sistema» para que apunte a este equipo:
De esta forma, cuando volvamos a visitar la página web, la consola de nuestro servicio imprimirá el mensaje recibido:
Un montón de datos de tipo Buffer; este es el mensaje del protocolo DNS.
A partir de ahí hacemos el parseo del nombre de dominio consultado y lo imprimimos, es decir, esta parte:
La primera parte de la pregunta ocupa 12 bytes, así que la recortamos y hacemos el parseo:
server.on('message', (msg, rinfo) => {
const host = parseHost(msg.subarray(12))
console.log(`query: ${host}`)
})
msg es de tipo Buffer, un subtipo de Uint8Array, que es un entero sin signo. (El almacenamiento de enteros puede ser con o sin signo, y el número que se puede almacenar sin signo es el doble de grande.)
Se invoca su método subarray para recortar los primeros 12 bytes.
Después se analiza la parte de la pregunta:
La primera pregunta corresponde al nombre de dominio, y solo necesitamos resolver ese nombre de dominio.
Ya indicamos que el nombre de dominio se distingue con ., pero al almacenarlo no es así, sino por
Longitud del campo actual + contenido del campo actual + longitud del campo actual + contenido del campo actual + longitud del campo actual + contenido del campo actual + 0
En este formato, el nombre de dominio termina en 0.
Por lo tanto, la lógica de parseo es esta:
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
}
Se lee un entero sin signo mediante el método readUInt8 de Buffer y se captura un segmento de contenido mediante el método subarray de Buffer.
Ambos métodos especifican el desplazamiento, es decir, desde dónde empezar.
Primero leemos un número, que es la longitud del dominio actual; después leemos el contenido de esa longitud y seguimos leyendo el siguiente tramo hasta leer 0, que significa el final del nombre de dominio.
Los campos intermedios se unen con . . Por ejemplo, 3 www 5 baidu 3 com da como resultado www.baidu.com tras el procesamiento.
Después reiniciamos el servidor para probar el resultado:
¡Hemos logrado analizar correctamente el nombre de dominio de la consulta a partir de los datos del protocolo DNS!
Analizar la parte de la consulta es solo el primer paso; después hay que devolver la respuesta correspondiente.
Aquí solo procesamos nosotros mismos algunos nombres de dominio y cedemos el resto a otros servidores DNS locales para que los procesen:
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)
}
});
Si el nombre de dominio resuelto contiene guangguangguang, se procesa de forma local y se construye y devuelve el mensaje correspondiente del protocolo DNS.
En caso contrario, se reenvía a otro servidor DNS local para su procesamiento y el resultado se devuelve al cliente.
Primero implementamos la parte de reenvío:
Reenviar a otro servidor DNS consiste en crear un cliente UDP, pasarle el mensaje recibido y, una vez recibido el mensaje, reenviarlo al cliente.
Es así:
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()
}
});
}
Se crea un cliente UDP mediante dgram.createSocket; el parámetro udp4 representa la dirección IPv4.
Se gestionan los errores, se monitorean los mensajes y se reenvía msg al servidor DNS de destino (la dirección del servidor DNS se puede cambiar aquí por otra).
Después de recibir el mensaje devuelto, se pasa al cliente.
La IP y el puerto del cliente se pasan a través de parámetros.
De esta forma se realiza la transferencia del protocolo DNS. Primero probemos el resultado actual.
Use el comando nslookup para consultar la dirección de un nombre de dominio:
Se puede ver que al consultar baidu.com se obtiene la dirección IP correspondiente, a la que se puede acceder en el navegador.
En cambio, guangguangguang.ddd.com no encontró la IP correspondiente.
A continuación implementamos el método resolve y construimos un mensaje del protocolo DNS para devolverlo.
Seguimos con este formato:
Se construye aproximadamente así:
El ID de sesión se toma del msg que se pasa y los marcadores también se configuran. El número de preguntas y de respuestas es 1 en ambos casos, y el número de autorizaciones y de datos adicionales es 0.
La zona de preguntas y la zona de respuestas se configuran según el formato correspondiente:
Es necesario crear un objeto búfer con Buffer.alloc.
El proceso también usará buffer.writeUInt16BE para escribir algunos enteros de dos bytes sin signo.
BE aquí significa Big Endian, es decir, el byte de orden superior se coloca a la derecha y el de orden inferior a la izquierda.
Por ejemplo, 00000000 00000001 es el entero de dos bytes sin signo 1 en formato big-endian, mientras que el 1 en little-endian es 00000001 00000000, es decir, el byte de orden superior se coloca a la izquierda.
Ensamblar el mensaje del protocolo DNS resulta bastante engorroso. Solo hay que verlo:
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()
}
})
}
Finalmente, el mensaje ensamblado del protocolo DNS se envía a la otra parte.
De esta forma se realiza la resolución del nombre de dominio de guangguangguang.
En el código anterior lo resuelvo a la IP 11.22.33.44.
Probémoslo con nslookup:
¡Como puede ver, la resolución del nombre de dominio correspondiente se realizó con éxito!
De esta forma hemos implementado el servidor DNS con Node.js.
Publicamos el código completo: puede ejecutarlo usted mismo y, a continuación, hacer que el servidor DNS local de su equipo apunte a él y probarlo:
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)
Resumen
En este artículo hemos aprendido los principios de DNS y hemos implementado nosotros mismos un servidor DNS local con Node.js.
Cuando se resuelve el nombre de dominio, primero se consulta el archivo hosts; si no se encuentra, se solicita al servidor de nombres de dominio local. Este lo proporciona el ISP y, por lo general, hay uno en cada ciudad.
El servidor de nombres de dominio local se encarga de resolver la IP que corresponde al nombre de dominio; consulta por turnos el servidor de nombres de dominio raíz, el servidor de nombres de dominio de nivel superior y el servidor de nombres de dominio autoritativo para obtener la IP final y devolverla al cliente.
El equipo puede configurar la dirección del servidor de nombres de dominio local, y nosotros la hacemos apuntar al servidor de nombres de dominio local implementado con Node.js.
El protocolo DNS se transmite con base en UDP, por lo que iniciamos el servicio UDP en el puerto 53 mediante el módulo dgram.
Después, según el formato del protocolo DNS, se analiza el nombre de dominio, el nombre de dominio de destino se procesa de forma local y se construye el mensaje de retorno del protocolo DNS. Los demás nombres de dominio se reenvían a otro servidor DNS local para su resolución, y el mensaje que este devuelve se pasa al cliente.
De esta forma hemos implementado un servidor DNS local en Node.js.
Artículos relacionados
-
6 tecnologías opcionales de almacenamiento de datos
Equipo editorial
Ver más ofertas especiales
-
Short Message Service (SMS) y servicio de correo
Paquete de 50,000 correos electrónicos desde USD $1.99; 120 mensajes de texto desde solo USD $1.00
