Blog Home   Blog Home

March 28, 2019   •   Chad Retz

Serverless DNS Over HTTPS (DoH) at the Edge

 
 

TL;DR: EdgeEngine scripts can serve DNS records opening up lots of possibilities. See the code here.

Introduction

RFC8484 defines DNS over HTTPS which is a way to do DNS queries over HTTPS. There are many reasons why querying over HTTPS has benefits, one of which is that we can leverage existing HTTP technologies. One of those technologies is EdgeEngine which runs serverless scripts at the CDN edge allowing logic execution much closer to users than the traditional cloud. Combining these two technologies enables us to act upon and respond to DNS queries at the edge around the world.

In this article, we’ll leverage EdgeEngine scripts to respond to DNS to resolve a custom TLD. It will walk through all steps from a simple proxy JS script to a full-fledged TypeScript script responding to DNS queries. By the end of the article, we’ll have learned how to:

  • Create a simple script to respond to HTTP calls
  • Test the script locally behind HTTPS
  • Set up DNS over HTTPS (i.e. Trusted Recursive Resolvers) in Firefox
  • Use TypeScript and webpack to include Node.js modules and make a single script
  • Deploy the script to EdgeEngine

Feel free to skip/skim sections you are already familiar with the concepts. Afterward, we’ll contemplate other DNS uses at the edge and their value. The final code is available here.

NOTE: All configurations, APIs, and settings were valid at the time of writing and may have deviated since.

Walkthrough

This guide assumes some knowledge about JavaScript, HTTP, and system concepts. Our goal is to create a DNS over HTTPS server that will respond to our custom .stackpath TLD.

Hello, World!

First, we need to set up an environment for running our scripts. You can do this via the StackPath portal and via the EdgeEngine Sandbox, but working locally is ideal for this project. We’ll use the cloudworker project to do this.

Assuming Node.js is installed (I recommend NVM or NVM windows) and we’re in an empty directory, let’s setup a simple project:

	
npm init -y
	

This creates a bunch of default settings in a package.json. Let’s install the cloudworker dev tool local to the project which will also update the package.json:

	
npm install @dollarshaveclub/cloudworker --save-dev
	

NOTE: The cloudworker project requires a C++ compiler on your system. For Windows, if Visual Studio C++ compiler is not readily available, you’ll need to install it. I recommend windows-build-tools if you don’t already have it installed.

Create a simple script named index.js that responds to all HTTP calls made to our worker:

	
addEventListener("fetch", event => {
  event.respondWith(handleRequest(event.request))
})
async function handleRequest(request) {
  console.log('Request called at URL: ', request.url)
  return new Response('Hello, World!')
}
	

The details of the script are beyond the scope of this article. In general, it just logs the URL and then returns “Hello, World” to the browser. Now fire up the >cloudworker with the script:

	
cloudworker index.js
	

NOTE: This expects .node_modules/.bin to be on the PATH. If it’s not, it can execute referencing that path directly or added to the PATH.

It will respond that it started on port 3000. Now, in your browser, type http://127.0.0.1:3000 and you’ll see “Hello, World!”. Also in the terminal where cloudworker is running, you’ll see the URL requested.

HTTPS Localhost Workers

Since what we want is a DNS over HTTPS server (emphasis on the trailing S), we need to serve our worker over HTTPS. If you try to access https://127.0.0.1:3000 you’ll get an error. Browsers rely on “Certificate Authorities” (CA) to give you a certificate that they’ll verify and use to serve an HTTPS page. Since we don’t have a certificate for localhost, we need to make one. But first we need to make up a fake CA and tell our browser to trust it so when we create the certificate for localhost from it, it will be accepted.

I recommend mkcert to do this. Simply download the latest release and run install:

	
mkcert -install
	

This should show you where it placed the root CA certificate and key. This created a trusted CA on our system we can now issue certificates from but may not have put it in Firefox. In Firefox, open the Options from the menu (or visit about:preferences). Click the Privacy & Security section, scroll all the way down to the Certificates section and click View Certificates.... In the dialog under the section Authorities, if an entry for mkcert development CA is not present, click Import... and load the rootCA.pem from where the mkcert tool said it put it. Check Trust this CA to identify websites. and click OK. The mkcert development CA will now appear in the authorities list, click OK.

Now that we have a CA loaded into Firefox, let’s create a certificate from it for localhost and put the certificate at local.pem and the private key at local-key.pem:

	
mkcert -cert-file local.pem -key-file local-key.pem localhost 127.0.0.1 ::1
	

This created the certificate and key at those locations and made them work for localhost, 127.0.0.1, and ::1.

Now we need to start an HTTPS server and send it to port 3000 for our HTTP server. I made a simple one in Go that you can download. I also made it execute cloudworker watching for script changes as well. With Go installed and our cloudworker not running, run it:

	
go run https-worker.go index.js
	

This will start cloudworker on its normal 3000 and also start an HTTPS proxy to it on 3001. Now browse to https://127.0.0.1:3001 and see the worker executing over HTTPS properly.

Setting Up DNS over HTTPS in Firefox

Setting the above script aside for a moment, we’re going to configure Firefox to use DNS over HTTPS instead of its regular system default.

Note: This will be changing advanced underlying configuration in Firefox. It is suggested to run this in a separate profile which is done by visiting about:profiles, creating a new profile, and running the browser in that profile. But you’ll have to add the CA certificate again in that profile as we did before.

Firefox supports DNS over HTTPS as a Trusted Recursive Resolver. We’ll be editing two options available in about:config. As explained in the aforementioned link, they are:

  • network.trr.mode - Whether/how to use DNS over HTTPS. Some values are:
    • 0 - Only system DNS (default)
    • 1 - Firefox picks which DNS method is faster, TRR or system DNS
    • 2 - Try TRR first and fall back to system DNS on failure
    • 3 - Only use TRR
  • network.trr.uri - The URL to a DNS over HTTPS server

For our use, we’ll have it try DNS over HTTPS and fall back on failure. So in about:config, set network.trr.mode to 2 and either leave network.trr.uri as the default of https://mozilla.cloudflare-dns.com/dns-query or set it to https://dns.google.com/experimental.

The about:networking page shows network information in Firefox. The DNS page in particular shows known resolved domains and there is a TRR column saying if TRR was used to resolve it. All of them are false initially, but now that we have setup TRR to be tried, any valid future DNS resolutions will use that by default. Simply open another tab (or use the DNS Lookup tool there in about:networking) and go to stackpath.com. When revisiting and refreshing the DNS page in about:networking you’ll see true under the column of TRR. This means the URL we put in the configuration was used to resolve the domain instead of the system’s DNS.

Proxy Google’s DNS over HTTPS Server

Now that we can run an HTTPS worker locally and we know how to have Firefox call an HTTPS URL to resolve domains, we can have Firefox use our worker to resolve domains. But instead of writing a full-fledged DNS server right now, we’ll proxy all requests to Google’s DNS over HTTPS server and send the results back, logging each way.

While the RFC supports GET and POST requests, Firefox does POST by default, so we’ll stick with that. The DNS request is POSTed to a URL with the content type of application/dns-message. We’ll handle it if POSTed to /dns-query and forward it on to https://dns.google.com/experimental. Change handleRequest of our previous worker in index.js to:

	
async function handleRequest(request) {
  try {
    console.log('Request: ', request.method, request.url)

    // DNS queries are POSTed to /dns-query
    const url = new URL(request.url)
    if (request.method === 'POST' && url.pathname === '/dns-query') {
      return await handleDnsQuery(request)
    }

    return new Response('Hello, World!')
  } catch (e) {
    console.error('Error', e)
    return new Response(e.stack || e, { status: 500 })
  }
}
	

This now calls handleDnsQuery on DNS requests. So let’s create the new handleDnsQuery function:

	
async function handleDnsQuery(request) {
  const requestBody = await request.arrayBuffer()
  console.log('Request body: ', requestBody)

  // Create the new request and send it off
  const newRequest = new Request('https://dns.google.com/experimental', {
    method: 'POST',
    headers: { 'Content-Type': 'application/dns-message' },
    body: requestBody,
  })
  const response = await fetch(newRequest)

  // Log the response (cloning since body is read more than once)
  console.log('Response body: ', await response.clone().arrayBuffer())
  // Error if it isn't a DNS response
  if (response.headers.get('content-type') !== 'application/dns-message') {
    throw new Error('Got unexpected response: ' + response.text())
  }
  return response
}
	

This calls Google’s DNS over HTTPS server with the same body we were asked, logs it, and returns if it’s the expected content type. Now if the local worker isn’t already running, run it. Then, in about:config change network.trr.uri to https://localhost:3001/dns-query. You’ll probably see logs right away. If you used about:networking’s DNS Lookup tool to lookup example.com, assuming it wasn’t already cached, you might get the following in the logs:

	
Request:  POST http://localhost:3000/dns-query
Request body:  ArrayBuffer {
  [Uint8Contents]: <00 00 01 00 00 01 00 00 00 00 00 01 07 65 78 61 6d 70 6c 65 03 63 6f 6d 00 00 01 00 01 00 00 29 10 00 00 00 00 00 00 08 00 08 00 04 00 01 00 00>, byteLength: 48 }
Response body:  ArrayBuffer {
  [Uint8Contents]: <00 00 01 00 00 01 00 00 00 00 00 01 07 65 78 61 6d 70 6c 65 03 63 6f 6d 00 00 01 00 01 00 00 29 10 00 00 00 00 00 00 08 00 08 00 04 00 01 00 00>, byteLength: 48 }
	

Now we have a local edge worker proxying DNS requests. We can do anything we want with the DNS requests now.

Introducing TypeScript and Webpack

Since we want to do something with those opaque DNS messages, we need to parse and write them. There is an NPM library called dns-packet that does this for us. However, it is a Node.js project, and we need it to run on the edge combined in a single script with our code. Now that our project has gotten this complex, we need webpack. Also since we are complicating our project a bit more, we’re going to use TypeScript to make our JS easier to develop.

Let’s add TypeScript and webpack to our package.json as development-time-only dependencies:

	
npm install typescript webpack --save-dev
	

To have webpack compile TypeScript, we’ll need ts-loader:

	
npm install ts-loader --save-dev
	

To call webpack easily from the terminal, we’ll need webpack-cli:

	
npm install webpack-cli --save-dev
	

Finally, to get type definitions for Node.js, we’ll add @types/node:

	
npm install @types/node --save-dev
	

Now that we have what we need, we have to configure TypeScript and webpack. We’re going to change our project to take a TypeScript file at src/index.ts and compile it to dist/index.js. To configure TypeScript, add the following to a tsconfig.json file adjacent to package.json:

	
{
  "compilerOptions": {
  "target": "esnext",
  "strict": true,
  "esModuleInterop": true,
  "lib": ["dom", "dom.iterable", "es2015", "webworker"],
  "sourceMap": true
  },
  "include": [
    "./src/**/*"
  ]
}
	

All of the settings are beyond the scope of this article. But in general note the libs we reference. They are needed to get the types we expect when working. Now to configure webpack, add the following to a webpack.config.js file in the same directory:

	
module.exports = {
  mode: 'production',
  entry: './src/index.ts',
  output: {
    filename: 'index.js'
  },
  resolve: {
    extensions: ['.js', '.ts']
  },
  module: {
    rules: [
      { test: /\.ts$/, loader: "ts-loader" }
    ]
  }
}

if (process.env.NODE_ENV === 'development') {
  module.exports.devtool = 'inline-source-map'
}
	

Again, the details of the configuration are beyond the scope of this article. But in general, this will compile our src/index.ts file with ts-loader and combine with other referenced JS files into index.js in the default output directory (which is dist/). To make things easier to run repeatedly, we’ll add the webpack terminal calls as NPM scripts. While we’re here, we’ll also add a helper command to run our HTTPS worker. Update the package.json top-level scripts setting with the following:

	
{
  // ...
  "scripts": {
    "dev": "webpack --mode=development -w",
    "build": "webpack --mode=production",
    "httpsworker": "go build https-worker.go && https-worker"
  },
  // ...
}
	

So now when we run npm run dev it will run webpack in development mode (doesn’t do some optimizations) and watch for updates to re-compile it. When we run npm run build it will use production mode which does all of the optimizations. Also when we run httpsworker it will run our HTTPS worker which, remember, also listens for script changes. Note the script name is not given to the HTTPS worker script because dist/index.js is already the default.

Now, move and rename the index.js file to src/index.ts. If you are using an editor like Visual Studio Code (highly recommended), you’ll see that there are a couple of type errors. Specifically the addEventListener’s arrow function and the handleRequest/handleDnsQuery functions are all missing types for their parameters. Change addEventListener to type the parameter and the entire arrow function:

	
// ...
addEventListener("fetch", ((event: FetchEvent) => {
  event.respondWith(handleRequest(event.request))
}) as EventListener)
// ...
	

Also set the type of the handleRequest and handleDnsQuery functions to use Request:

	
// ...
async function handleRequest(request: Request) {
// ...
	
	
// ...
async function handleDnsQuery(request: Request) {
// ...
	

Ok, after all of that our project is now set for code growth. First, let’s run it to see if it works. In one terminal we’ll keep a compile running:

	
npm run dev
	

Now in another terminal, let’s run the HTTPS worker that will listen for changes too:

	
npm run httpsworker
	

Now do another DNS lookup check and confirm we are getting logs. Got em? Good.

DNS Message Parsing

Now we’re ready to use the dns-packet library to parse those messages and see what’s in them. First, let’s add it to our runtime dependencies:

	
npm install dns-packet --save
	

Then we can import it at the top of our index.ts file:

	
import dnsPacket from 'dns-packet'
	

But we get a TypeScript error: “Cannot find module ‘dns-packet’”. This means TypeScript doesn’t know what the types are. For more popular libraries, we would head to TypeSearch and find a set of type definitions written by others to help us. But in this case there are none, so we must create them. Reading dns-packet’s documentation, the API is simple. We won’t use it all, so we’ll only define types for the pieces we need.

While we could put the definitions in index.ts, it is better practice to separate them. Create a src/dns-packet.d.ts file with:

	
declare module 'dns-packet' {
  const AUTHORITATIVE_ANSWER: number
  const TRUNCATED_RESPONSE: number
  const RECURSION_DESIRED: number
  const RECURSION_AVAILABLE: number
  const AUTHENTIC_DATA: number
  const CHECKING_DISABLED: number
  const DNSSEC_OK: number
	
  function decode(buf: Buffer, offset?: number): Packet
  function encode(packet: Packet, buf?: Buffer, offset?: number): Buffer
	
  interface Packet {
    type: 'query' | 'response'
    id?: number
    flags?: number
    questions: Question[]
    answers: Answer[]
    additionals?: object[]
  }
	
  interface Question {
    type: string
    class: string
    name: string
  }
	
  interface Answer {
    type: string
    class: string
    name: string
    data?: string
    ttl?: number
  }
}
	

That defines the full set of types we’ll use. And now the error is gone. Now that we have the library, let’s log request DNS messages and response DNS messages. In the handleDnsQuery function, change the logging of the request body to:

	
try {
  console.log('Request DNS: ', dnsPacket.decode(Buffer.from(new Uint8Array(requestBody))))
  } catch (e) {
  console.error('Failed to parse message', e)
}

This takes that requestBody and creates a Uint8Array from it, then creates a Node.js Buffer from that. Even though the dns-packet library expects a Node.js Buffer, webpack includes code to simulate this for us. We need to also do this for the response body, but we want it to happen after we check the content type. This is because if there is an error, Google returns it as HTML. So now the function looks like:

	
async function handleDnsQuery(request: Request) {
  const requestBody = await request.arrayBuffer()
  try {
    console.log('Request DNS: ', dnsPacket.decode(Buffer.from(new Uint8Array(requestBody))))
  } catch (e) {
    console.error('Failed to parse message', e)
  }
	
  // Create the new request and send it off
  const newRequest = new Request('https://dns.google.com/experimental', {
    method: 'POST',
    headers: { 'Content-Type': 'application/dns-message' },
    body: requestBody,
  })
  const response = await fetch(newRequest)
  // Error if it isn't a DNS response
  if (response.headers.get('content-type') !== 'application/dns-message') {
    throw new Error('Got unexpected response: ' + response.text())
  }
  // Log the response (cloning since body is read more than once)
  try {
    const responseBody = await newRequest.clone().arrayBuffer()
    console.log('Response DNS: ', dnsPacket.decode(Buffer.from(new Uint8Array(responseBody))))
  } catch (e) {
    console.error('Failed to parse message', e)
  }
  return response
}
	

Now, we we run our worker and look at the logs for an example.com request, we get:

Expand to see logs
		
Request:  POST http://localhost:3000/dns-query
Request DNS:  { id: 0,
  type: 'query',
  flags: 256,
  flag_qr: false,
  opcode: 'QUERY',
  flag_aa: false,
  flag_tc: false,
  flag_rd: true,
  flag_ra: false,
  flag_z: false,
  flag_ad: false,
  flag_cd: false,
  rcode: 'NOERROR',
  questions: [ { name: 'example.com', type: 'AAAA', class: 'IN' } ],
  answers: [],
  authorities: [],
  additionals:
  [ { name: '.',
      type: 'OPT',
      udpPayloadSize: 4096,
      extendedRcode: 0,
      ednsVersion: 0,
      flags: 0,
      flag_do: false,
      options: [Array] } ] }
Request:  POST http://localhost:3000/dns-query
Request DNS:  { id: 0,
  type: 'query',
  flags: 256,
  flag_qr: false,
  opcode: 'QUERY',
  flag_aa: false,
  flag_tc: false,
  flag_rd: true,
  flag_ra: false,
  flag_z: false,
  flag_ad: false,
  flag_cd: false,
  rcode: 'NOERROR',
  questions: [ { name: 'example.com', type: 'A', class: 'IN' } ],
  answers: [],
  authorities: [],
  additionals:
  [ { name: '.',
      type: 'OPT',
      udpPayloadSize: 4096,
      extendedRcode: 0,
      ednsVersion: 0,
      flags: 0,
      flag_do: false,
      options: [Array] } ] }
Response DNS:  { id: 0,
  type: 'response',
  flags: 384,
  flag_qr: true,
  opcode: 'QUERY',
  flag_aa: false,
  flag_tc: false,
  flag_rd: true,
  flag_ra: true,
  flag_z: false,
  flag_ad: false,
  flag_cd: false,
  rcode: 'NOERROR',
  questions: [ { name: 'example.com', type: 'A', class: 'IN' } ],
  answers:
  [ { name: 'example.com',
      type: 'A',
      ttl: 18677,
      class: 'IN',
      flush: false,
      data: '93.184.216.34' } ],
  authorities: [],
  additionals:
  [ { name: '.',
      type: 'OPT',
      udpPayloadSize: 512,
      extendedRcode: 0,
      ednsVersion: 0,
      flags: 0,
      flag_do: false,
      options: [Array] } ] }
Response DNS:  { id: 0,
  type: 'response',
  flags: 384,
  flag_qr: true,
  opcode: 'QUERY',
  flag_aa: false,
  flag_tc: false,
  flag_rd: true,
  flag_ra: true,
  flag_z: false,
  flag_ad: false,
  flag_cd: false,
  rcode: 'NOERROR',
  questions: [ { name: 'example.com', type: 'AAAA', class: 'IN' } ],
  answers:
  [ { name: 'example.com',
      type: 'AAAA',
      ttl: 4775,
      class: 'IN',
      flush: false,
      data: '2606:2800:220:1:248:1893:25c8:1946' } ],
  authorities: [],
  additionals:
  [ { name: '.',
      type: 'OPT',
      udpPayloadSize: 512,
      extendedRcode: 0,
      ednsVersion: 0,
      flags: 0,
      flag_do: false,
      options: [Array] } ] }
  	
  

Neat, we get DNS messages and can see what they’re made of. We can see that Firefox asks for the A and AAAA records for example.com which is asking for the IPv4 and IPv6 addresses respectively.

Responding to Custom TLD

Finally, after all of that, we can start intercepting requests to our new .stackpath TLD. To keep our constants in order lets put a config constant near the top of index.ts after the import:

	
const newTld = 'stackpath'
	

Now, let’s check in the DNS call if it’s asking for our TLD, and if so, return the localhost IPs. Change the request logging code to:

	
const requestPacket = dnsPacket.decode(Buffer.from(new Uint8Array(requestBody)))
console.log('Request DNS: ', requestPacket)
	
// If it's for our new TLD, return local IPs
const question = (requestPacket.questions && requestPacket.questions.length === 1) ?
    requestPacket.questions[0] : null
const newTldRequest = question && question.name.endsWith('.' + newTld) &&
    (question.type === 'A' || question.type === 'AAAA')
if (question && newTldRequest) {
  console.log('Got request for new TLD')
  const responsePacket: dnsPacket.Packet = {
    id: requestPacket.id,
    type: 'response',
    flags: dnsPacket.RECURSION_DESIRED | dnsPacket.RECURSION_AVAILABLE,
    questions: requestPacket.questions,
    answers: [{
      type: question.type,
      class: 'IN',
      name: question.name,
      data: question.type === 'A' ? '127.0.0.1' : '::1',
      ttl: 600
    }]
  }
  return new Response(dnsPacket.encode(responsePacket).buffer, {
    status: 200,
    headers: { 'Content-Type': 'application/dns-message' }
  })
}
	

What’s happening here is we are checking if there is a single A or AAAA DNS query for a domain ending in .stackpath. If there is, return a response with either the IPv4 or IPv6 address. Now with the worker running, when we go to the about:networking page and query mywebsite.stackpath we get 127.0.0.1 and ::1. Awesome.

We want to show a response for that domain too. So in the main request handling code, check the URL is for our new TLD and return something:

	
const url = new URL(request.url)
if (request.method === 'POST' && url.pathname === '/dns-query') {
  return await handleDnsQuery(request)
} else if (url.hostname.endsWith('.' + newTld)) {
  return new Response('Thanks for accessing the new .' + newTld + ' TLD via URL ' + request.url)
}
	

Now, let’s open our browser and access http://mywebsite.stackpath. It just hangs. Oh yeah, we’re not running on the default HTTP port, we are running HTTP on 3000 and HTTPS on 3001. When trying http://mywebsite.stackpath:3000, we get:

	
Thanks for accessing the new .stackpath TLD via URL http://mywebsite.stackpath:3000/
	

Nice.

NOTE: https://mywebsite.stackpath:3001 would show a certificate error because our certificate doesn’t include that name. We could easily add it with mkcert but that won’t help when we deploy to EdgeEngine unless we uploaded it in the portal. That’s the subject for another article, but in general, it would require all users of the TLD to install your CA certificate on their computer which is a very intimate request :-)

Deploying to EdgeEngine

Ok, now we can run a local worker that responds to DNS requests for our custom TLD and returns localhost IPs. But localhost IPs aren’t going to work on the edge, so we’ll update the script to account for that.

Login to the StackPath portal and choose a stack or create one. Now, pick a CDN site you are not already using or create a new one with any site name (does not need to exist). In the settings of the CDN site, we’ll be given a CDN URL that is HTTPS protected and will look like a1b2c3d4.stackpathcdn.com (used henceforth as a placeholder for what your real CDN site is). We’ll use this address for our new DNS server deployed around the world, but we could easily set up another domain.

Now, in the portal for the chosen CDN site, let’s set up a simple EdgeEngine script to confirm it works. Click the EdgeEngine tab and under Scripts click Add Script. Name it whatever, leave the route alone, and set the code to something simple:

	
addEventListener('fetch', event => {
  event.respondWith(Promise.resolve(new Response('You have accessed ' + event.request.url)))
})
	

Now when we visit https://a1b2c3d4.stackpathcdn.com/some/path in a browser, we get:

	
You have accessed https://a1b2c3d4.stackpathcdn.com/some/path
	

Ok, so what we want our worker to do is respond with DNS information for that domain when a .stackpath domain is requested. We could forward .stackpath requests anywhere but for now we’ll respond with the same worker. The worker IP is available in the request headers, but the easiest way is probably to have the Google DNS resolve for `a1b2c3d4.stackpathcdn.com` and then change the response to the requested domain. So, let’s add a config val at the top of src/index.ts after the newTld val:

	
const proxyDnsTo: string = 'localhost'
	

And change the code that checks for a newTldRequest to only respond when we’re proxying to localhost:

	
// ...
if (question && newTldRequest && proxyDnsTo === 'localhost') {
  console.log('Got request for new TLD on localhost')
// ...
	

So now if our script is configured to proxy to localhost, it will do the hardcoded local IPs, but if it’s somewhere else, it will fall through to the Google DNS. You can test this with the local worker. Now stop the local worker and the webpack build, we’ll be publishing to EdgeEngine from here on out.

We need to update the script to change the question to our proxy before asking Google and change the response on the way back. Change requestBody to a let instead of a const because we’re gonna change it before sending it off to Google. Change the newRequest/fetch code to:

	
// Update the request if we're proxying
let origName = ''
if (question && newTldRequest) {
  origName = question.name
  question.name = proxyDnsTo
  requestBody = dnsPacket.encode(requestPacket).buffer
}
	
// Create the new request and send it off
const newRequest = new Request('https://dns.google.com/experimental', {
  method: 'POST',
  headers: { 'Content-Type': 'application/dns-message' },
  body: requestBody,
})
let response = await fetch(newRequest)
// Error if it isn't a DNS response
if (response.headers.get('content-type') !== 'application/dns-message') {
  throw new Error('Got unexpected response: ' + response.text())
}
// Log the response (cloning to since body is read more than once)
const responseBody = await response.clone().arrayBuffer()
let responsePacket: dnsPacket.Packet | undefined
try {
  responsePacket = dnsPacket.decode(Buffer.from(new Uint8Array(responseBody)))
  console.log('Response DNS: ', responsePacket)
} catch (e) {
  console.error('Failed to parse message', e)
}
	
// If we're proxying, change the name back
if (newTldRequest) {
  if (!responsePacket) throw new Error('Packet not parsed')
  responsePacket.questions.forEach(q => {
    if (q.name === proxyDnsTo) q.name = origName
  })
  responsePacket.answers.forEach(a => {
    if (a.name === proxyDnsTo) a.name = origName
  })
  response = new Response(dnsPacket.encode(responsePacket).buffer, {
    status: 200,
    headers: { 'Content-Type': 'application/dns-message' }
  })
}

return response
	

What’s happening here is, if this is part of a new TLD request, we change the request body on the way to Google and change it back on the way back. Now that we have done this, we can set the top conf to our CDN site:

	
const proxyDnsTo: string = 'a1b2c3d4.stackpathcdn.com'
	

Now when our custom TLD is asked for, the script will ask Google for our CDN site and change that response to be as though it is for the new TLD. Let’s build the script for deployment. Instead of npm run dev, run:

	
npm run build
	

This will create a more optimized dist/index.js (~56K in size). Take the new contents of that file and set it as your EdgeEngine script. Now when you go to http://a1b2c3d4.stackpathcdn.com you’ll get that Hello, World! we created originally.

Let’s change the DNS server. Go into Firefox’s about:config and change the network.trr.uri to https://a1b2c3d4.stackpathcdn.com/dns-query. Now go to about:networking’s DNS lookup tool and lookup mywebsite.stackpath and it should give a public IP which it would have given had you typed that CDN site name.

So, what happens when we visit http://mywebsite.stackpath/? We get a blank page (a 404), why? Well, the StackPath CDN doesn’t know that it should respond to that address. In the portal, go to the Settings of your CDN site. Under Delivery Domains add a new one for mywebsite.stackpath. Now try that URL again, and you’ll get:

	
Thanks for accessing the new .stackpath TLD via URL http://mywebsite.stackpath/
	

Success.

Recap

With only a little bit of code, we now have a DNS server deployed worldwide at the edge that gives a custom response to a website with a custom TLD. A more complete example of the code that properly has logging configurable and is a tad more modular is here.

Other Uses

This article showed how to set up a DNS over HTTPS server and point Firefox to it (while also teaching a bit about setting up a good local working environment). Obviously, more enterprise setups would use a traditional DNS server, but this is an easy way to customize one for simple uses. Basically, we created a DNS proxy/MITM which has plenty of use cases such as:

  • DNS logging
  • Browser/profile-specific DNS differences for testing
  • Easier to share this DNS server than passing around HOST files
  • More DNS privacy (a benefit of DNS over HTTPS in general, ala on the other side of a VPN)
  • Restricting unwanted DNS requests

On that last point some people might think, “Oh cool, I could do something like Pi-hole at the edge with this approach!” Well, you can with some requests, but those DNS filtering lists get really big for a single EdgeEngine script, and you’d have to rewrite the logic in JS.

To do that right, consider Edge Computing with a global Anycast IP to run Pi-hole at the edge and get all of its benefits, but that’s the subject of another article…

   
Topics

View All
Stay Informed

Receive our monthly blog newsletter.

Follow

Connect with us to stay updated.

Stay Informed

Receive our monthly blog newsletter.

Follow

Connect with us to stay updated.