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 CNAME
s 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:
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.