peteris.rocks

DNS Proxy Server in node.js with UI

Building a simple DNS proxy server in node.js with basic UI in Angular.js

Last updated on

Let's suppose that you want to redirect a domain that you have no control over to a different address or add a subdomain to a domain that you don't own.

One way to do that is to modify the /etc/hosts file. But what if you want other people to see your changes? You'd need to ask that they synchronize your hosts file with theirs every time you make changes. Another option is to set up your own DNS server.

In this post, we're going to implement a simple DNS server in node.js that will forward all requests to the real DNS server but hijack requests to domains that you want to control. I'm going to call it a DNS proxy but I know that it's not a real term.

I came across this problem at work a while ago when I needed ci.gitlab.company.com to point to gitlab.company.com but the sysadmins were unable to do this for some reason in the near future.

DNS server in node.js

We are going to use native-dns and some other libraries:

npm install native-dns async express body-parser

Let's create a basic DNS server:

'use strict';

let dns = require('native-dns');
let server = dns.createServer();

server.on('listening', () => console.log('server listening on', server.address()));
server.on('close', () => console.log('server closed', server.address()));
server.on('error', (err, buff, req, res) => console.error(err.stack));
server.on('socketError', (err, socket) => console.error(err));

server.serve(53);

Go ahead and start it (need to use sudo since 53 is a privileged port):

sudo node dns-server.js &

Let's use our DNS server

echo nameserver 127.0.0.1 | sudo tee /etc/resolv.conf

and try to resolve peteris.rocks

$ host peteris.rocks
;; connection timed out; no servers could be reached

Right, it timed out because we are not handling requests.

What we want to do now is set up proxying. When a DNS request comes in, we want to forward it to another DNS server (authority) and respond with the answers from that.

8.8.8.8 is a public DNS server operated by Google that we're going to use. The reason I chose it is that its address is very easy to remember.

Let's add the following lines of code for proxying requests:

let authority = { address: '8.8.8.8', port: 53, type: 'udp' };

function proxy(question, response, cb) {
  console.log('proxying', question.name);

  var request = dns.Request({
    question: question, // forwarding the question
    server: authority,  // this is the DNS server we are asking
    timeout: 1000
  });

  // when we get answers, append them to the response
  request.on('message', (err, msg) => {
    msg.answer.forEach(a => response.answer.push(a));
  });

  request.on('end', cb);
  request.send();
}

This is the code for handling requests:

let async = require('async');

function handleRequest(request, response) {
  console.log('request from', request.address.address, 'for', request.question[0].name);

  let f = []; // array of functions

  // proxy all questions
  // since proxying is asynchronous, store all callbacks
  request.question.forEach(question => {
    f.push(cb => proxy(question, response, cb));
  });

  // do the proxying in parallel
  // when done, respond to the request by sending the response
  async.parallel(f, function() { response.send(); });
}

server.on('request', handleRequest);

Alright, let's try it:

$ host peteris.rocks
peteris.rocks has address 178.79.154.235

We have a working DNS proxy.

Now let's modify the handleRequest event handler to examine the request and check if it's for a domain we are insterested in. If so, we can craft a custom response, otherwise proxy it as before.

let entries = [
  {
    domain: "^hello.peteris.*",
    records: [
      { type: "A", address: "127.0.0.99", ttl: 1800 }
    ]
  }
];

function handleRequest(request, response) {
  console.log('request from', request.address.address, 'for', request.question[0].name);

  let f = [];

  request.question.forEach(question => {
    let entry = entries.filter(r => new RegExp(r.domain, 'i').exec(question.name));
    if (entry.length) {
      entry[0].records.forEach(record => {
        record.name = question.name;
        record.ttl = record.ttl || 1800;
        response.answer.push(dns[record.type](record));
      });
    } else {
      f.push(cb => proxy(question, response, cb));
    }
  });

  async.parallel(f, function() { response.send(); });
}

Now any domain that matches the hello.peteris.* regex should resolve to 127.0.0.99.

Try it

$ host hello.peteris.rocks
hello.peteris.rocks has address 127.0.0.99

$ host hello.peteris.yo
hello.peteris.yo has address 127.0.0.99

and it works as expected.

Let's handle CNAMEs as well:

entry[0].records.forEach(record => {
  record.name = question.name;
  record.ttl = record.ttl || 1800;
  if (record.type == 'CNAME') {
    record.data = record.address;
    f.push(cb => proxy({ name: record.data, type: dns.consts.NAME_TO_QTYPE.A, class: 1 }, response, cb));
  }
  response.answer.push(dns[record.type](record));
});

Add a CNAME entry to our hijack list:

let entries = [
  { domain: "^hello.peteris.*", records: [ { type: "A", address: "127.0.0.99", ttl: 1800 } ] },
  { domain: "^cname.peteris.*", records: [ { type: "CNAME", address: "hello.peteris.rocks", ttl: 1800 } ] }
];

Try it

$ host cname.peteris.rocks
cname.peteris.rocks is an alias for hello.peteris.rocks.
hello.peteris.rocks has address 127.0.0.99

and it works.

Let's add a simple UI for managing the domains to take over and their records.

We're going to have a simple HTTP server that will serve a very basic UI for editing records. There will be no authentication and the only protection will be a simple hardcoded password that you need to enter when making changes.

It's going to be so basic that we're going to store all records in a file.

echo "[]" > records.json

We're going to serve a static index.html and also handle /load and /save:

let fs = require('fs');
let express = require('express');
let bodyParser = require('body-parser');

let entries = require('./records.json');
let password = 'ilovekittens';

let app = express();

app.use(bodyParser.json());
app.use(express.static(__dirname));

app.get('/load', (req, res) => {
  res.send(entries);
});

app.post('/save', (req, res) => {
  if (req.query.password == password) {
    entries = req.body;
    fs.writeFileSync('records.json', JSON.stringify(entries));
    res.send('ok');
  } else {
    res.status(401).send('wrong');
  }
});

app.listen(5380);

We will be using Angular.js to edit the records.json file in the browser. We've also added some Bootstrap styles to make it look nicer.

<!doctype html>
<html ng-app="dns">
<head>
  <meta charset="utf-8">
  <title>DNS Editor</title>
  <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css">
  <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap-theme.min.css">
  <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.4.7/angular.js"></script>
  <style>
    body { margin: 30px 30px; }
    ul { margin: 0; padding: 0; list-style-type: none; }
  </style>
</head>
<body ng-controller="Controller">

<h1>DNS</h1>

<div class="alert alert-success" role="alert" ng-click="success = null" ng-show="success">Changes saved</div>
<div class="alert alert-danger" role="alert" ng-click="error = null" ng-show="error">Error</div>

<input type="password" ng-model="password" placeholder="Password" class="form-control"><br>

<table class="table table-bordered">
<tr ng-repeat-start="entry in entries">
  <td colspan="3">
    <input type="text" class="form-control" ng-model="entry.domain" placeholder="Domain e.g. blah.example.org" />
  </td>
</tr>
<tr ng-repeat="record in entry.records">
  <td>
    <select class="form-control" ng-model="record.type">
      <option value="A">A</option>
      <option value="CNAME">CNAME</option>
    </select>
  </td>
  <td>
    <input type="text" placeholder="Address" class="form-control" ng-model="record.address">
  </td>
  <td>
    <input type="number" placeholder="TTL" class="form-control" ng-model="record.ttl">
  </td>
</tr>
<tr ng-repeat-end>
  <td colspan="3">
    <a href ng-click="entry.records.push({})">Add Record</a>
  </td>
</tr>
</table>

<a href ng-click="entries.push({ records: [] })">Add Domain</a>

<button class="pull-right btn btn-primary" ng-click="save()">Save Changes</button>

<script>
angular.module('dns', [])
  .controller('Controller', ['$scope', '$http', function($scope, $http) {
    $scope.entries = [];
    $scope.password = '';

    $http.get('/load').success(function(entries) {
      $scope.entries = entries;
    });

    $scope.save = function() {
      $scope.success = $scope.error = null;
      $http.post('/save?password=' + $scope.password, $scope.entries).success(function() {
        $scope.success = true;
      }).error(function() {
        $scope.error = true;
      });
    };
  }]);
</script>

</body>
</html>

Launch the server again

sudo node dns-server.js

and navigate to http://localhost:5380.

It will look like this:

DNS proxy server UI

Enter the password ilovekittens before saving changes.

Using dig

This is very barbaric

echo nameserver 127.0.0.1 > /etc/resolv.conf

since you will have no internet access once you hit Ctrl+C.

Alternatively, use dig for testing. You can use `@127.0.0.1` to specify your DNS server.

dig will also give you more detailed output about the DNS server responses.

dig @127.0.0.1 peteris.rocks
dig @127.0.0.1 peteris.yo

Final remarks

This took me less than an hour to build (thanks to native-dns, angular.js and other libraries) and was pretty fun.

Needless to say, you should not use this in production.

The code published in this blog post is licensed under the Apache 2.0 license, if you want to use any part of it.