├── .gitattributes ├── .github └── workflows │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── config.ts ├── dns_packet.ts ├── dns_packet_test.ts ├── dns_record_class.ts ├── dns_record_type.ts ├── dns_record_types_test.ts ├── dns_server.ts ├── dns_server_config.ts ├── dns_server_test.ts ├── main.ts ├── utils.ts └── utils_test.ts /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: push 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | deno-version: [1.x] 12 | 13 | steps: 14 | - name: Git Checkout Deno Module 15 | uses: actions/checkout@v2 16 | - name: Use Deno Version ${{ matrix.deno-version }} 17 | uses: denolib/setup-deno@v2 18 | with: 19 | deno-version: ${{ matrix.deno-version }} 20 | - name: Run tests 21 | run: deno test --allow-none 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 matt1 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `deno-nameserver` is a DNS Server written using Deno 2 | 3 | ![Tests](https://github.com/matt1/deno-nameserver/workflows/Tests/badge.svg) 4 | 5 | This is a very basic experiment at writing a simple DNS server using Deno, using 6 | the currently-unstable UDP datagram support in Deno (i.e. you have to run with 7 | `--unstable`). Vaguely following [RFC1035](https://tools.ietf.org/html/rfc1035), 8 | but mostly implemented by looking at Wireshark captures. Does not support TCP, so messages need to fit within a single UDP packet. 9 | 10 | It is intended to act as a local server to respond to basic queries, and does 11 | not offer recursive lookups. 12 | 13 | Obviously this is **not stable and not for production use**! It seems like `dig` 14 | et al though seem to be happy enough with the responses it sends. 15 | 16 | ## Usage 17 | 18 | Update the content of `config.ts` as required (see below), then run it: 19 | 20 | ```bash 21 | deno run --unstable --allow-net main.ts 22 | ``` 23 | 24 | ### Configuring the server via `config.ts` 25 | 26 | #### `IP` & `PORT` 27 | Typically you'll want to leave these alone and keep them at their defaults of 28 | `0.0.0.0` and `53` - this will make the server listen on all IPs at the usual 29 | port for DNS servers. 30 | 31 | #### `NAMES` 32 | This is where you configure the names you want to respond to. 33 | 34 | In this example there are two names that the server is configured to respond to. 35 | 36 | ```javascript 37 | public static readonly NAMES:DNSConfig = { 38 | 'MyNas.whatever': { 39 | ttl: 3600, 40 | class: { 41 | 'IN': { 42 | 'A': '192.168.0.17', 43 | } 44 | } 45 | }, 46 | 'HomePrinter.something.cool': { 47 | ttl: 3600, 48 | class: { 49 | 'IN': { 50 | 'A': '192.168.0.123', 51 | } 52 | } 53 | }, 54 | }; 55 | ``` 56 | 57 | Add as many records as you need. Typically you'll want them all to be `IN` class 58 | (i.e. internet) and `A` record types (i.e. IPv4 address). Note that your OS or 59 | router's DHCP settings might automatically append a suffix to any name you try 60 | to lookup - either disable that, or add an entry with the suffix if you want to 61 | serve those names. 62 | 63 | Since the config is actually included as a typesccript module, you can do 64 | cunning stuff in the `config.ts` file if you really want to, such as dynamic 65 | names or addresses. As far as I am aware (and I have done *zero* research here) 66 | this is something fairly unique among DNS servers. The flip side is that it has 67 | absolutely zero fault tolerance since it needs to be compiled as it is actually 68 | typescript code. 69 | 70 | # Why? Why don't you use `dnsmasq` or whatever? 71 | 72 | This was just written for the fun of it. I needed a simple DNS server for use 73 | with tailscale and I thought it would be more fun to write my own than read the 74 | docs for dnsmasq. -------------------------------------------------------------------------------- /config.ts: -------------------------------------------------------------------------------- 1 | import { DNSConfig } from "./dns_server_config.ts"; 2 | 3 | export class ServerConfig { 4 | /** The IP Address which should be used to listen for requests on. */ 5 | public static readonly IP = '0.0.0.0'; 6 | 7 | /** The UDP Port that the server will listen on - usually 53 for DNS. */ 8 | public static readonly PORT = 53; 9 | 10 | /** The list of DNS records that the server will serve records for. */ 11 | public static readonly RECORDS:DNSConfig = { 12 | 'example.com': { 13 | ttl: 3600, 14 | class: { 15 | 'IN': { 16 | 'A': '127.0.0.1', 17 | // TODO: Currently only A is returned as logic in dns_server shortcircuits the AAAA record. 18 | 'AAAA': '::1', 19 | 'TXT': 'This is some text.', 20 | } 21 | } 22 | }, 23 | 'alias.example.com': { 24 | ttl: 3600, 25 | class: { 26 | 'IN': { 27 | 'CNAME': 'example.com', 28 | } 29 | } 30 | } 31 | }; 32 | } -------------------------------------------------------------------------------- /dns_packet.ts: -------------------------------------------------------------------------------- 1 | import { hex } from "./utils.ts"; 2 | import { DNSRecordType, ResourceRecord } from "./dns_record_type.ts"; 3 | import { DNSRecordClass } from "./dns_record_class.ts"; 4 | 5 | // Handy picture: https://www.securityartwork.es/wp-content/uploads/2013/02/DNS.jpg 6 | 7 | /** DNS Packet Header. */ 8 | export class DNSHeader { 9 | Identification = 0; 10 | Flags = 0; 11 | TotalQuestions = 0; 12 | TotalAnswers = 0; 13 | TotalAuthorityResourceRecords = 0; 14 | TotalAdditionalResourceRecords = 0; 15 | 16 | public toString(): string { 17 | return ` 18 | Identification: ${hex(this.Identification)} 19 | Flags: ${hex(this.Flags)} 20 | Total Questions: ${hex(this.TotalQuestions)} 21 | Total Answers: ${hex(this.TotalAnswers)} 22 | Total Auth RR: ${hex(this.TotalAuthorityResourceRecords)} 23 | Total Additional RR: ${hex(this.TotalAdditionalResourceRecords)}`; 24 | } 25 | 26 | /** Get the protocol bytes for the header. */ 27 | get Bytes(): Uint8Array { 28 | const result = new Uint8Array(12); 29 | const view = new DataView(result.buffer); 30 | 31 | view.setUint16(0, this.Identification); 32 | view.setUint16(2, this.Flags); 33 | 34 | view.setUint16(4, this.TotalQuestions); 35 | view.setUint16(6, this.TotalAnswers); 36 | 37 | view.setUint16(8, this.TotalAuthorityResourceRecords); 38 | view.setUint16(10, this.TotalAdditionalResourceRecords); 39 | 40 | return result; 41 | } 42 | 43 | /** Parse the DNS header out of the raw packet bytes. */ 44 | static Parse (data: DataView): DNSHeader { 45 | const header = new DNSHeader(); 46 | header.Identification = data.getInt16(0); 47 | header.Flags = data.getInt16(2); 48 | header.TotalQuestions = data.getInt16(4); 49 | header.TotalAnswers = data.getInt16(6); 50 | header.TotalAuthorityResourceRecords = data.getInt16(8); 51 | header.TotalAdditionalResourceRecords = data.getInt16(10); 52 | return header; 53 | } 54 | } 55 | 56 | /** A DNS Packet's Question. */ 57 | export class DNSQuestion { 58 | /** The human-friendly name */ 59 | Name = ""; 60 | /** The separate parts of the name. */ 61 | NameParts: string[] = []; 62 | /** The Record Type (e.g. A, AAAA etc). */ 63 | RecordType = 0; 64 | /** The Record Class - typically only IN. */ 65 | RecordClass = 0; 66 | 67 | constructor(name = "", type = DNSRecordType.A, cls = DNSRecordClass.IN) { 68 | if (name === "") return; 69 | 70 | this.Name = name; 71 | this.NameParts = name.split('.'); 72 | this.RecordType = type; 73 | this.RecordClass = cls; 74 | } 75 | 76 | public toString() { 77 | const recordType = DNSRecordType[this.RecordType]; 78 | const recordClass = DNSRecordClass[this.RecordClass]; 79 | return `Name: ${this.Name} Type: ${recordType} Class: ${recordClass}`; 80 | } 81 | 82 | /** Get the protocol bytes for the question. */ 83 | get Bytes(): Uint8Array { 84 | const result = new Uint8Array(this.Name.length + 6); 85 | const view = new DataView(result.buffer); 86 | 87 | let index = 0; 88 | for (const part of this.NameParts) { 89 | view.setUint8(index, part.length); 90 | for (let i = 0; i < part.length; i++) { 91 | view.setUint8(index + 1 + i, part.charCodeAt(i)); 92 | } 93 | index = index + 1 + part.length; 94 | } 95 | 96 | view.setUint16(index += 1, this.RecordType); 97 | view.setUint16(index += 2, this.RecordClass); 98 | return result; 99 | } 100 | 101 | /** Parse the DNS question out of the raw packet bytes. */ 102 | static Parse(data: DataView): DNSQuestion { 103 | const question = new DNSQuestion(); 104 | 105 | let index = 12; // DNS header is always 12 bytes 106 | 107 | // Questions always contain the name split into separate parts, with a 108 | // leading byte per part indicating its length. A 0x00 byte indicates the 109 | // end of the name section. 110 | // 111 | // E.g. www.example.com ends up as: 112 | // Size Name Part 113 | // 0x03 www 114 | // 0x07 example 115 | // 0x03 com 116 | // 0x00 117 | let length = data.getUint8(index); 118 | while (length != 0) { 119 | const labelPart = new Uint8Array(data.buffer, index + 1, length); 120 | const labelPartString = String.fromCharCode.apply( 121 | null, 122 | Array.from(labelPart), 123 | ); 124 | question.NameParts.push(labelPartString); 125 | index += length + 1; 126 | length = data.getUint8(index); 127 | } 128 | 129 | question.Name = question.NameParts.join("."); 130 | question.RecordType = data.getUint16(index += 1); 131 | question.RecordClass = data.getUint16(index += 2); 132 | return question; 133 | } 134 | } 135 | 136 | /** Represents a DNS packet. */ 137 | export class DNSPacket { 138 | /** Copy of the raw data. */ 139 | private rawData!: Uint8Array; 140 | 141 | /** Data view onto the raw data. */ 142 | private data!: DataView; 143 | 144 | /** Private copy of the header. */ 145 | private header!: DNSHeader; 146 | 147 | /** Private copy of the question. */ 148 | private question!: DNSQuestion; 149 | 150 | /** Private copy of the answer (there may not be an answer). */ 151 | private answers: ResourceRecord[] = []; 152 | 153 | /** Get the header for this packet. */ 154 | get Header(): DNSHeader { 155 | return this.header; 156 | } 157 | 158 | /** Get the question for this packet. */ 159 | get Question(): DNSQuestion { 160 | return this.question; 161 | } 162 | 163 | /** Sets the question for this packet. */ 164 | set Question(question:DNSQuestion) { 165 | this.question = question; 166 | this.Header.TotalQuestions++; 167 | } 168 | 169 | /** Get the answer for this packet, if available. */ 170 | get Answers(): ResourceRecord[] { 171 | return this.answers; 172 | } 173 | 174 | /** Sets the answer for this packet. */ 175 | set Answers(answers:ResourceRecord[]) { 176 | this.answers = answers; 177 | this.Header.TotalAnswers++; 178 | } 179 | 180 | /** 181 | * Get the protocol bytes for this packet. Set any packet fields before 182 | * calling. 183 | */ 184 | get Bytes(): Uint8Array { 185 | const header = this.Header?.Bytes; 186 | const question = this.Question?.Bytes; 187 | 188 | if (!header || !question) { 189 | console.warn('Potentially invalid DNSPacket - missing header or question section'); 190 | return new Uint8Array(); 191 | } 192 | 193 | const parts = [header, question]; 194 | let length = header.length + question.length; 195 | for (const answer of this.Answers) { 196 | const bytes = answer.Bytes; 197 | length += bytes.length; 198 | parts.push(bytes); 199 | } 200 | 201 | const result = new Uint8Array(length); 202 | 203 | let offset = 0; 204 | for (const array of parts) { 205 | result.set(array, offset); 206 | offset += array.length; 207 | } 208 | return result; 209 | } 210 | 211 | constructor() { 212 | this.header = new DNSHeader(); 213 | this.question = new DNSQuestion(); 214 | 215 | } 216 | 217 | /** 218 | * Construct a new DNSPacket from the provided UInt8Array byte array. Use this to convert 219 | * data from the network into a DNSPacket. 220 | */ 221 | static fromBytes(data:Uint8Array): DNSPacket { 222 | const packet = new DNSPacket(); 223 | packet.rawData = data; 224 | packet.data = new DataView(data.buffer); 225 | 226 | packet.header = DNSHeader.Parse(packet.data); 227 | packet.question = DNSQuestion.Parse(packet.data); 228 | 229 | return packet; 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /dns_packet_test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "https://deno.land/std/testing/asserts.ts"; 2 | import { DNSPacket, DNSQuestion} from "./dns_packet.ts"; 3 | import { DNSRecordClass } from "./dns_record_class.ts"; 4 | import { DNSRecordType } from "./dns_record_type.ts"; 5 | 6 | // Direct from a wireshark DNS capture made by Win10 `nslookup`. 7 | const sample_packet = Uint8Array.from([ 8 | 0x00, 0x04, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0x65, 0x78, 0x61, 9 | 0x6d, 0x70, 0x6c, 0x65, 0x03, 0x63, 0x6f, 0x6d, 0x00, 0x00, 0x01, 0x00, 0x01, 10 | ]); 11 | 12 | Deno.test('DNSPacket can be created from raw request bytes', () => { 13 | const packet = DNSPacket.fromBytes(sample_packet); 14 | 15 | assertEquals(packet.Header.Identification, 4); 16 | assertEquals(packet.Header.TotalQuestions, 1); 17 | assertEquals(packet.Header.TotalAnswers, 0); 18 | assertEquals(packet.Header.TotalAdditionalResourceRecords, 0); 19 | assertEquals(packet.Header.TotalAuthorityResourceRecords, 0); 20 | assertEquals(packet.Header.Flags, 256); 21 | 22 | assertEquals(packet.Question.Name, 'example.com'); 23 | assertEquals(packet.Question.NameParts, ['example', 'com']); 24 | assertEquals(packet.Question.RecordClass, DNSRecordClass.IN); 25 | assertEquals(packet.Question.RecordType, DNSRecordType.A); 26 | 27 | assertEquals(packet.Answers.length, 0); 28 | }); 29 | 30 | Deno.test('DNSPacket can be created programatically', () => { 31 | const packet = new DNSPacket(); 32 | 33 | packet.Header.Identification = 4; 34 | packet.Header.TotalQuestions = 1; 35 | packet.Header.TotalAnswers = 0; 36 | packet.Header.TotalAdditionalResourceRecords = 0; 37 | packet.Header.TotalAuthorityResourceRecords = 0; 38 | packet.Header.Flags = 256; 39 | 40 | packet.Question.Name = 'example.com'; 41 | packet.Question.NameParts = ['example', 'com']; 42 | packet.Question.RecordClass = DNSRecordClass.IN; 43 | packet.Question.RecordType = DNSRecordType.A; 44 | 45 | assertEquals(packet.Bytes, sample_packet); 46 | }); 47 | 48 | Deno.test('DNSQuestion can be created from constructor', () => { 49 | const packet = DNSPacket.fromBytes(sample_packet); 50 | const q = new DNSQuestion('example.com'); 51 | 52 | assertEquals(packet.Question, q); 53 | }); -------------------------------------------------------------------------------- /dns_record_class.ts: -------------------------------------------------------------------------------- 1 | /** DNS Class Types. */ 2 | export enum DNSRecordClass { 3 | UNKNOWN = 0, 4 | IN = 1, // Internet 5 | // Ignore Chaosnet and Hesiod stuff from the 1980s... 6 | } -------------------------------------------------------------------------------- /dns_record_type.ts: -------------------------------------------------------------------------------- 1 | import { DNSRecordClass } from "./dns_record_class.ts"; 2 | 3 | /** DNS Record Types. */ 4 | export enum DNSRecordType { 5 | UNKNOWN = 0, 6 | A = 1, // IPv4 address record 7 | AAAA = 28, // IPv6 address record 8 | CNAME = 5, // Canonical name record 9 | PTR = 12, // Pointer to a canonical name 10 | } 11 | 12 | /** 13 | * An abstract class that contains a resource record. 14 | * 15 | * Classes that extend this class must provide a `Payload` that will be added to 16 | * this resource record when the `Bytes` are generated. 17 | * 18 | * See https://tools.ietf.org/html/rfc1035#section-4.1.3 for details. 19 | */ 20 | export abstract class ResourceRecord { 21 | constructor( 22 | readonly Name = '', 23 | readonly NameParts: string[] = [], 24 | readonly RecordType = DNSRecordType.UNKNOWN, 25 | readonly RecordClass = DNSRecordClass.UNKNOWN, 26 | readonly TTL = 0, 27 | ){} 28 | 29 | /** Get the bytes for this resource record. */ 30 | get Bytes(): Uint8Array { 31 | const common = new Uint8Array(this.Name.length + 10); 32 | let view = new DataView(common.buffer); 33 | 34 | let index = 0; 35 | for (const part of this.NameParts) { 36 | view.setUint8(index, part.length); 37 | for (let i = 0; i < part.length; i++) { 38 | view.setUint8(index + 1 + i, part.charCodeAt(i)); 39 | } 40 | index = index + 1 + part.length; 41 | } 42 | 43 | view.setUint16(index += 1, this.RecordType); 44 | view.setUint16(index += 2, this.RecordClass); 45 | view.setUint32(index += 2, this.TTL); 46 | 47 | const payload = this.Payload; 48 | const result = new Uint8Array(common.length + 2 + payload.length); 49 | result.set(common, 0); 50 | view = new DataView(result.buffer); 51 | 52 | view.setUint16(index +=4, payload.length); 53 | result.set(payload, index += 2); 54 | 55 | return result; 56 | } 57 | 58 | /** Get the payload for this resource record. */ 59 | abstract get Payload(): Uint8Array; 60 | } 61 | 62 | /** A Resource Record for 'A' record types. */ 63 | export class AResourceRecord extends ResourceRecord { 64 | /** The IPv4 address (as a number). */ 65 | Address = 0; 66 | 67 | /** Returns the IPv4 address as an unsigned 32 bit int. */ 68 | get Payload():Uint8Array { 69 | const result = new Uint8Array(4); 70 | const view = new DataView(result.buffer); 71 | 72 | view.setUint32(0, this.Address); 73 | return result; 74 | } 75 | } 76 | 77 | /** A Resource Record for 'AAAA' record types. */ 78 | export class AAAAResourceRecord extends ResourceRecord { 79 | /** The IPv6 address */ 80 | Address: Uint16Array = Uint16Array.from([]); 81 | 82 | /** Returns the IPv6 address as Uint8Array */ 83 | get Payload():Uint8Array { 84 | const result = new Uint8Array(16); 85 | const inView = new DataView(this.Address.buffer); 86 | const outView = new DataView(result.buffer); 87 | 88 | for (let i = 0; i<16; i += 2) { 89 | outView.setUint16(i, inView.getUint16(i), true); 90 | } 91 | return result; 92 | } 93 | } 94 | 95 | /** A Resource Record for CNAMEs. */ 96 | export class CNameResourceRecord extends ResourceRecord { 97 | /** The CNAME alias. */ 98 | CName = ''; 99 | 100 | get Payload():Uint8Array { 101 | const result = new Uint8Array(this.CName.length + 2); 102 | const view = new DataView(result.buffer); 103 | 104 | let index = 0; 105 | for (const part of this.CName.split('.')) { 106 | view.setUint8(index, part.length); 107 | for (let i = 0; i < part.length; i++) { 108 | view.setUint8(index + 1 + i, part.charCodeAt(i)); 109 | } 110 | index = index + 1 + part.length; 111 | } 112 | 113 | return result; 114 | } 115 | } 116 | 117 | /** A Resource Record for TXT. */ 118 | export class TxtResourceRecord extends ResourceRecord { 119 | /** The TXT value. */ 120 | Txt = ''; 121 | 122 | get Payload():Uint8Array { 123 | const result = new Uint8Array(this.Txt.length); 124 | const view = new DataView(result.buffer); 125 | 126 | for (let i = 0; i { 5 | const cname = new CNameResourceRecord(); 6 | cname.CName = 'testing'; 7 | 8 | const expected = Uint8Array.from([7, 116, 101, 115, 116, 105, 110, 103, 0]); 9 | 10 | assertEquals(cname.Payload, expected); 11 | }); 12 | 13 | Deno.test('Txt correctly returns payload bytes', () => { 14 | const txt = new TxtResourceRecord(); 15 | txt.Txt = 'testing'; 16 | 17 | const expected = Uint8Array.from([116, 101, 115, 116, 105, 110, 103]); 18 | 19 | assertEquals(txt.Payload, expected); 20 | }); 21 | -------------------------------------------------------------------------------- /dns_server.ts: -------------------------------------------------------------------------------- 1 | import { DNSPacket, DNSQuestion } from "./dns_packet.ts"; 2 | import { DNSConfigRecord, DNSConfig } from "./dns_server_config.ts"; 3 | import { DNSRecordClass } from "./dns_record_class.ts"; 4 | import { DNSRecordType, AResourceRecord, ResourceRecord, CNameResourceRecord, AAAAResourceRecord } from "./dns_record_type.ts"; 5 | import { ipv4ToNumber, ipv6ToBytes } from "./utils.ts"; 6 | 7 | /** A simple DNS Server. */ 8 | export class DNSServer { 9 | /** 10 | * Creates a new DNSServer. 11 | * 12 | * @param records The records that should be served by this server. 13 | */ 14 | constructor(private readonly records:DNSConfig){} 15 | /** 16 | * Handles a raw DNS request. Request payload should be the raw datagram 17 | * content. 18 | * 19 | * Returns the raw bytes for a DNS response to the request. 20 | */ 21 | public HandleRequest(request: Uint8Array): Uint8Array { 22 | const packet = DNSPacket.fromBytes(request); 23 | const header = packet.Header; 24 | const question = packet.Question; 25 | 26 | let records:DNSConfig[] = []; 27 | try { 28 | // Special handling for A records: if we don't have an A record, check to 29 | // see if we have a CNAME for it, then get the A record for the CNAME 30 | // destination if we do. 31 | // 32 | // This is special processing only for CNAMEs - see the RFC for details: 33 | // https://tools.ietf.org/html/rfc1034#section-3.6.2 34 | if (question.RecordType == DNSRecordType.A || 35 | question.RecordType == DNSRecordType.AAAA) { 36 | // This was an A/AAAA record request and we *don't* have an A/AAA record 37 | // then handle the CNAME special case. 38 | if (!this.hasRecord(question.Name, question.RecordType)) { 39 | // No A/AAAA record found for this name - look for a CNAME instead. 40 | let cnameRecord = this.getRecord(question.Name, DNSRecordType.CNAME); 41 | if (cnameRecord) { 42 | // We have a CNAME - add it to the response and then see if we have 43 | // an A/AAAA for the CNAME's destination. 44 | records.push(cnameRecord); 45 | const key = Object.keys(cnameRecord)[0]; // This feels wrong? 46 | const cnameDestination = cnameRecord[key].class[DNSRecordClass[question.RecordClass]][DNSRecordType[DNSRecordType.CNAME]]; 47 | if (this.hasRecord(cnameDestination, question.RecordType)) { 48 | // Yes we have a A/AAAA for this CNAME dest - add it to response. 49 | records.push(this.getRecord(cnameDestination, question.RecordType)); 50 | } 51 | } 52 | } else { 53 | // A/AAAA was found - just add that to the response. 54 | records.push(this.getRecord(question.Name, question.RecordType)); 55 | } 56 | 57 | } else { 58 | // Not an A record request - carry on as usual. 59 | records.push(this.getRecord(question.Name, question.RecordType)); 60 | } 61 | } catch (error) { 62 | console.error(`Error handling request: ${error}`); 63 | return request; 64 | } 65 | 66 | console.log(`Serving request: ${packet.Question}`); 67 | packet.Header.Flags = 32768; // 0x8000 68 | for (const record of records) { 69 | const rrType = this.getResourceRecordType(packet.Question, record); 70 | if (rrType) packet.Answers.push(rrType); 71 | } 72 | packet.Header.TotalAnswers = packet.Answers.length; 73 | return new Uint8Array(packet.Bytes); 74 | } 75 | 76 | /** 77 | * Checks for a config record by name, type, and optionally class (class 78 | * defaults to `IN` if not set). 79 | */ 80 | private hasRecord(name:string, 81 | recordType:DNSRecordType, 82 | recordClass:DNSRecordClass = DNSRecordClass.IN): boolean { 83 | const config = this.records[name]; 84 | if (!config) return false; 85 | if (!config.class[DNSRecordClass[recordClass]]) return false; 86 | if (!config.class[DNSRecordClass[recordClass]][DNSRecordType[recordType]]) return false; 87 | return true; 88 | } 89 | 90 | /** 91 | * Get the config record by name, type, and optionally class (class defaults 92 | * to `IN` if not set). 93 | */ 94 | private getRecord(name:string, 95 | recordType:DNSRecordType, 96 | recordClass:DNSRecordClass = DNSRecordClass.IN): DNSConfig { 97 | const config = this.records[name]; 98 | 99 | if (!config) throw new Error(`No config for ${name}`); 100 | if (!config.class[DNSRecordClass[recordClass]]) throw new Error(`No config for class '${recordClass}' for ${name}`); 101 | if (!config.class[DNSRecordClass[recordClass]][DNSRecordType[recordType]]) throw new Error(`No config for type '${recordType}' for ${name}`); 102 | 103 | return {[name]: config}; 104 | } 105 | 106 | /** Get an appropriate record type for the question using the config. */ 107 | // TODO: refactor this whole thing - should be per-record, not relating to Question. 108 | // TODO: allow both A & AAAA to be returned at once. 109 | private getResourceRecordType(question:DNSQuestion, config:DNSConfig):ResourceRecord | undefined { 110 | const key = Object.keys(config)[0]; // This feels wrong? 111 | const classConfig = config[key].class[DNSRecordClass[question.RecordClass]]; 112 | let rr:ResourceRecord|undefined; 113 | 114 | // TODO: make records strongly typed to avoid this mess 115 | if (classConfig.hasOwnProperty(DNSRecordType[DNSRecordType.A])) { 116 | rr = new AResourceRecord(key, key.split('.'), 117 | question.RecordType, question.RecordClass, config[key].ttl); 118 | (rr as AResourceRecord).Address = 119 | ipv4ToNumber(classConfig[DNSRecordType[DNSRecordType.A]]); 120 | } else if (classConfig.hasOwnProperty(DNSRecordType[DNSRecordType.AAAA])) { 121 | rr = new AAAAResourceRecord(key, key.split('.'), 122 | question.RecordType, question.RecordClass, config[key].ttl); 123 | (rr as AAAAResourceRecord).Address = 124 | ipv6ToBytes(classConfig[DNSRecordType[DNSRecordType.AAAA]]); 125 | } else if (classConfig.hasOwnProperty(DNSRecordType[DNSRecordType.CNAME])) { 126 | const name = classConfig[DNSRecordType[DNSRecordType.CNAME]]; 127 | rr = new CNameResourceRecord(key, key.split('.'), 128 | DNSRecordType.CNAME, question.RecordClass, config[key].ttl); 129 | (rr as CNameResourceRecord).CName = name; 130 | } 131 | 132 | return rr; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /dns_server_config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Config for the server is a JSON file keyed by name, then record class (only 3 | * "IN" (aka Internet) is supported), then the record types and values. 4 | * 5 | * So if you wanted to resolve the IPv4 address `127.0.0.1` and IPv6 address 6 | * `::1` for requests for `example.com` you'd have a config like this: 7 | * 8 | * { 9 | * "example.com" : { 10 | * "ttl": 3600, 11 | * "class": { 12 | * "IN" : { 13 | * "A": "127.0.0.1", 14 | * "AAAA": "::1", 15 | * }, 16 | * }, 17 | * }, 18 | }; 19 | */ 20 | 21 | export interface DNSConfig { 22 | [key:string]: DNSConfigRecord; 23 | } 24 | 25 | export interface DNSConfigRecord { 26 | ttl: number, 27 | class: { 28 | [key:string]: DNSConfigRecordClass; 29 | } 30 | } 31 | 32 | interface DNSConfigRecordClass { 33 | [key:string]: string; 34 | } 35 | -------------------------------------------------------------------------------- /dns_server_test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals} from "https://deno.land/std/testing/asserts.ts"; 2 | import { DNSServer } from "./dns_server.ts"; 3 | import { DNSConfig } from "./dns_server_config.ts"; 4 | 5 | Deno.test('DNSServer correctly answers basic A record request', () => { 6 | // Direct from a wireshark DNS capture made by Win10 `nslookup`. 7 | const data = Uint8Array.from([ 8 | 0x00, 0x04, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0x65, 0x78, 0x61, 9 | 0x6d, 0x70, 0x6c, 0x65, 0x03, 0x63, 0x6f, 0x6d, 0x00, 0x00, 0x01, 0x00, 0x01, 10 | ]); 11 | 12 | // Based on a wireshark DNS capture - TTL, IP and response code changed. 13 | const expectedResponse = Uint8Array.from([ 14 | 0x00, 0x04, 0x80, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x07, 0x65, 0x78, 0x61, 15 | 0x6d, 0x70, 0x6c, 0x65, 0x03, 0x63, 0x6f, 0x6d, 0x00, 0x00, 0x01, 0x00, 0x01, 0x07, 0x65, 0x78, 16 | 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x03, 0x63, 0x6f, 0x6d, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 17 | 0x00, 0x64, 0x00, 0x04, 0x7f, 0x00, 0x00, 0x01 18 | ]); 19 | 20 | const config:DNSConfig = { 21 | 'example.com': { 22 | ttl: 100, 23 | class: { 24 | 'IN': { 25 | 'A': '127.0.0.1', 26 | } 27 | } 28 | } 29 | }; 30 | 31 | const server = new DNSServer(config); 32 | const response = server.HandleRequest(data); 33 | assertEquals(response, expectedResponse); 34 | }); 35 | 36 | Deno.test('DNSServer handles a CNAME + A record request.', () => { 37 | const request = Uint8Array.from([0x00, 0x04, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x77, 0x77, 0x77, 38 | 0x09, 0x62, 0x79, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x03, 0x78, 0x79, 0x7a, 0x00, 0x00, 39 | 0x01, 0x00, 0x01 40 | ]); 41 | 42 | const expectedResponse = Uint8Array.from([0x00, 0x04, 0x80, 0x00, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x03, 0x77, 0x77, 0x77, 43 | 0x09, 0x62, 0x79, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x03, 0x78, 0x79, 0x7a, 0x00, 0x00, 44 | 0x01, 0x00, 0x01, 0x03, 0x77, 0x77, 0x77, 0x09, 0x62, 0x79, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 45 | 0x65, 0x03, 0x78, 0x79, 0x7a, 0x00, 0x00, 0x05, 0x00, 0x01, 0x00, 0x00, 0x2a, 0x30, 0x00, 0x18, 46 | 0x08, 0x77, 0x65, 0x62, 0x72, 0x65, 0x64, 0x69, 0x72, 0x03, 0x76, 0x69, 0x70, 0x05, 0x67, 0x61, 47 | 0x6e, 0x64, 0x69, 0x03, 0x6e, 0x65, 0x74, 0x00, 0x08, 0x77, 0x65, 0x62, 0x72, 0x65, 0x64, 0x69, 48 | 0x72, 0x03, 0x76, 0x69, 0x70, 0x05, 0x67, 0x61, 0x6e, 0x64, 0x69, 0x03, 0x6e, 0x65, 0x74, 0x00, 49 | 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x2a, 0x30, 0x00, 0x04, 0xd9, 0x46, 0xb8, 0x32 50 | ]); 51 | 52 | const config:DNSConfig = { 53 | 'www.byexample.xyz': { 54 | ttl: 10800, 55 | class: { 56 | 'IN': { 57 | 'CNAME': 'webredir.vip.gandi.net', 58 | } 59 | } 60 | }, 61 | 'webredir.vip.gandi.net': { 62 | ttl: 10800, 63 | class: { 64 | 'IN': { 65 | 'A': '217.70.184.50', 66 | } 67 | } 68 | }, 69 | }; 70 | 71 | const server = new DNSServer(config); 72 | const response = server.HandleRequest(request); 73 | assertEquals(response, expectedResponse); 74 | }); -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | import { DNSServer } from "./dns_server.ts"; 2 | import { ServerConfig } from "./config.ts"; 3 | 4 | const dnsServer = new DNSServer(ServerConfig.RECORDS); 5 | const listener = Deno.listenDatagram({ 6 | port: ServerConfig.PORT, 7 | hostname: ServerConfig.IP, 8 | transport: 'udp', 9 | }); 10 | 11 | while (true) { 12 | try { 13 | const [data, remoteAddr] = await listener.receive(); 14 | const response = dnsServer.HandleRequest(data); 15 | await listener.send(response, remoteAddr); 16 | } catch (error) { 17 | console.error(error); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /utils.ts: -------------------------------------------------------------------------------- 1 | /** Print a number as a hex fomatted number. */ 2 | export function hex(num:number): string { 3 | return `0x${num.toString(16)}`; 4 | } 5 | 6 | /** Convert a IPv4 address like 1.2.3.4 to a number. */ 7 | export function ipv4ToNumber(ipv4:string):number { 8 | const bytes = ipv4.split('.'); 9 | let result = 0; 10 | result += Number(bytes[0]) << 24; 11 | result += Number(bytes[1]) << 16; 12 | result += Number(bytes[2]) << 8; 13 | result += Number(bytes[3]); 14 | return result; 15 | } 16 | 17 | /** Convert a IPv6 address like 2001:db8::2:1 to a BigInt. */ 18 | export function ipv6ToNumber(ipv6:string):BigInt { 19 | const normalised = normaliseIpv6(ipv6); 20 | const groups = normalised.split(':'); 21 | let result = 0n; 22 | result += BigInt(`0x${groups[0]}`) << 112n; 23 | result += BigInt(`0x${groups[1]}`) << 96n; 24 | result += BigInt(`0x${groups[2]}`) << 80n; 25 | result += BigInt(`0x${groups[3]}`) << 64n; 26 | result += BigInt(`0x${groups[4]}`) << 48n; 27 | result += BigInt(`0x${groups[5]}`) << 32n; 28 | result += BigInt(`0x${groups[6]}`) << 16n; 29 | result += BigInt(`0x${groups[7]}`); 30 | return result; 31 | } 32 | 33 | /** Convert a IPv6 address like 2001:db8::2:1 to a Uint16Array. */ 34 | export function ipv6ToBytes(ipv6:string):Uint16Array { 35 | const normalised = normaliseIpv6(ipv6); 36 | const groups = normalised.split(':'); 37 | const result = new Uint16Array(8); 38 | const view = new DataView(result.buffer); 39 | 40 | let i = 0; 41 | for (const group of groups) { 42 | const num = parseInt(group, 16); 43 | view.setUint16(i, num, true); 44 | i += 2; 45 | } 46 | 47 | return result; 48 | } 49 | 50 | /** 51 | * Normalise a IPv6 address so that `::` are replaced with zeros. 52 | */ 53 | export function normaliseIpv6(ipv6:string):string { 54 | if (ipv6.indexOf('::') < 0) return ipv6; 55 | 56 | // Use :: as the pivot and split into left and right. 57 | const parts = ipv6.split('::'); 58 | const leftParts = parts[0].length === 0 ? [] : parts[0].split(':'); 59 | const rightParts = parts[1].length === 0 ? [] : parts[1].split(':'); 60 | 61 | // Work out how many zero-groups were collapsed in :: by counting length of 62 | // the left and right parts, then create the missing groups. 63 | const missingZeros = 8 - (leftParts.length + rightParts.length); 64 | const newZeros = []; 65 | for (let i = 0; i < missingZeros; i++) { 66 | newZeros.push('0'); 67 | } 68 | return [...leftParts, ...newZeros, ...rightParts].join(':'); 69 | } -------------------------------------------------------------------------------- /utils_test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "https://deno.land/std/testing/asserts.ts"; 2 | import { hex, ipv4ToNumber, normaliseIpv6, ipv6ToNumber, ipv6ToBytes } from "./utils.ts"; 3 | 4 | Deno.test('Utils converts IPv4 addresses from strings to ints', () => { 5 | assertEquals(ipv4ToNumber('127.0.0.1'), 2130706433); 6 | assertEquals(ipv4ToNumber('0.0.0.0'), 0); 7 | assertEquals(ipv4ToNumber('1.2.3.4'), 16909060); 8 | }); 9 | 10 | Deno.test('Utils converts numbers to hex', () => { 11 | assertEquals(hex(0), '0x0'); 12 | assertEquals(hex(100), '0x64'); 13 | }); 14 | 15 | Deno.test('Utils normalises IPv6', () => { 16 | assertEquals(normaliseIpv6('0:0:0:0:0:0:0:1'), '0:0:0:0:0:0:0:1'); 17 | assertEquals(normaliseIpv6('::1'), '0:0:0:0:0:0:0:1'); 18 | assertEquals(normaliseIpv6('1::'), '1:0:0:0:0:0:0:0'); 19 | assertEquals(normaliseIpv6('2001:db8::2:1'), '2001:db8:0:0:0:0:2:1'); 20 | assertEquals(normaliseIpv6('2001:db8:0:1:1:1:1:1'), '2001:db8:0:1:1:1:1:1'); 21 | }); 22 | 23 | Deno.test('Utils converts IPv6 address to number', () => { 24 | assertEquals(ipv6ToNumber('::1'), 1n); 25 | assertEquals(ipv6ToNumber('2001:db8:0:0:0:0:2:1'), 42540766411282592856903984951653957633n); 26 | assertEquals(ipv6ToNumber('2001:db8:0:1:1:1:1:1'), 42540766411282592875351010504635121665n); 27 | }); 28 | 29 | Deno.test('Utils converts IPv6 address to byte array', () => { 30 | assertEquals(ipv6ToBytes('::1'), Uint16Array.from([0, 0, 0, 0, 0, 0, 0, 1])); 31 | assertEquals(ipv6ToBytes('8:7:6:5:4:3:2:1'), Uint16Array.from([8, 7, 6, 5, 4, 3, 2, 1])); 32 | assertEquals(ipv6ToBytes('8193:7:6:fff5:4:3:2:1'), Uint16Array.from([33171, 7, 6, 65525, 4, 3, 2, 1])); 33 | }); --------------------------------------------------------------------------------