-
Notifications
You must be signed in to change notification settings - Fork 1
/
server.js
executable file
·295 lines (263 loc) · 9.25 KB
/
server.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
#!/usr/bin/env node
//import libs
var http = require('http'),
fs = require('fs'),
url = require('url'),
process = require('process'),
child_process = require('child_process')
function extend(obj, props) {
for(var prop in props) {
if(props.hasOwnProperty(prop)) {
obj[prop] = props[prop];
}
}
}
function isEmptyObject(obj) {
return Object.getOwnPropertyNames(obj).length == 0;
}
//read the config
var config = {
// these credentials must be sent in HTTP basic auth to access the API at all
// unless this first parameter is true
'api_anonymous_allowed': false,
'api_username': 'ddns',
'api_password': 'password',
// if registration is not open, admin password is required for registering new domain
// while the domain update password can be used just for updating
'open_registration': true,
// if this is set, admin password can be passed to allow updating or deleting domains
// even without their domain specific password
'admin_password': null,
// if set, records more than this many seconds old will be omitted from the zone file
// though they are kept in the DB in case they need to be recovered.
'max_age': null,
// listen port for the web server
'port': 8080,
// serve this file in response to GET with no params
'index_file': 'index.txt',
// set these to match your NSD settings
'dns_pid_file': '/run/nsd/nsd.pid',
'zone_output_path': '/tmp/example.com.zone',
// paths to the template file & JSON flat-file DNS database
'zone_template_path': 'conf/example.com.zonetemplate',
'database_path': 'dnsDB.json',
// hostnames that may not be registered
'domain_blacklist': ['www', '@', 'smtp', 'imap', 'ns', 'pop', 'pop3', 'ftp', 'm', 'mail', 'blog', 'wiki', 'ns1', 'ns2', 'ns3'],
// values that may not be provided by the api client and must be inferred by the ddns server
'param_blacklist': ['type', 'ip'],
// if true, allow dots in domain names
'allow_subdomains': false,
//'param_whitelist': ['domain', 'ttl', 'password'], // Does nothing, for documentation
}
try {
var loadedConfig = JSON.parse(fs.readFileSync('config.json', 'utf8'))
extend(config, loadedConfig)
console.log('Read config file')
} catch (error) {
console.error('Failed to read config file:' + error)
}
// validation for provided values
loadedConfig.param_validation = {
'domain': loadedConfig.allow_subdomains ? /^[-a-zA-Z0-9\.]{0,200}$/ : /^[-a-zA-Z0-9]{0,200}$/,
'ttl': /^[1-9][0-9]{1,15}$/,
'password': /^.{1,100}$/,
'type': /^(A|AAAA|CNAME)$/i
},
//utils
function decodeBase64(str) {
return new Buffer(str, 'base64').toString()
}
function respond(res, code, json){
res.writeHead(code, {"Content-Type": "application/json"});
res.write(JSON.stringify(json));
res.write("\n");
res.end();
}
function respondFile(res, path) {
fs.readFile(path, 'utf8', function(err, content) {
if (err) {
res.writeHead(500, {"Content-Type": "text/plain"});
} else {
res.writeHead(200, {"Content-Type": "text/plain"});
res.write(content);
res.end();
}
});
}
var credentialsRegExp = /^ *(?:[Bb][Aa][Ss][Ii][Cc]) +([A-Za-z0-9\-\._~\+\/]+=*) *$/
function zone_record(record) {
if (record.ttl) {
return record.domain + " IN " + record.ttl + " " + record.type + " " + record.ip;
} else {
return record.domain + " IN " + record.type + " " + record.ip;
}
}
function zone_file(template, records) {
template_records = []
var typ, domain, record;
for (typ in records){
for (domain in records[typ]){
record = records[typ][domain];
if (!record.expired) {
template_records.push(zone_record(record));
}
}
}
var dynamic_dns_records = template_records.join('\n') + "\n";
var zone = template.replace("__DYNAMIC_DNS_RECORDS__", dynamic_dns_records).replace("__SERIAL_NUMBER__", Math.floor((new Date()).getTime()/1000));
return zone;
}
//read the dnsDB
var records;
try {
records = JSON.parse(fs.readFileSync(config.database_path, 'utf8'))
console.log('Read database from previous session')
} catch (error) {
console.error('Failed to read database from previous session: ' + error)
records = {}
}
function handleRequest(req, res){
//check basic auth
if (!config.api_anonymous_allowed) {
var header=req.headers['authorization']||'', // get the header
token=header.split(/\s+/).pop()||'', // and the encoded auth token
auth=new Buffer(token, 'base64').toString(), // convert from base64
parts=auth.split(/:/), // split on colon
username=parts[0],
password=parts[1];
if(username !== config.api_username || password !== config.api_password){
respond(res, 401, {error:'unauthorized'});
return;
}
}
var queryParams = url.parse(req.url,true).query || {};
if (isEmptyObject(queryParams)) {
respondFile(res, config.index_file);
return;
}
for (var param of config.param_blacklist) {
delete queryParams[param];
}
for (var param in config.param_validation) {
var regex = config.param_validation[param];
if (queryParams[param] && ! queryParams[param].match(regex)) {
delete queryParams[param];
}
}
//get ip
var ip = queryParams.ip ||
req.headers['x-forwarded-for'] ||
req.connection.remoteAddress ||
req.socket.remoteAddress ||
req.connection.socket.remoteAddress;
//get domain from query
var domain = queryParams.domain;
if(!domain){
respond(res, 404, {error:'no domain'});
return;
}
if(config.domain_blacklist.indexOf(domain) != -1) {
respond(res, 401, {error:'unauthorized'});
return;
}
//update parse ttl and ipv6
var ttl = parseInt(queryParams.ttl);
var ipv6 = ip.indexOf("::ffff:")!==0 && ip.indexOf(":")!==-1;
if (!ipv6) ip = ip.replace(/^::ffff:/, '');
//update record object
var type = queryParams.type || (ipv6 ? "AAAA" : "A");
var password = queryParams.password;
var adminPassword = queryParams.adminpassword;
var record = {ip: ip, domain:domain, type: type};
if (ttl) record.ttl = ttl;
if (password) record.password = password;
record.last_update_time = (new Date()).toISOString();
var existingRecord = records[record.type] && records[record.type][domain];
// If we're registering a new domain & reg password is enabled, ensure it's correct
if (!config.open_registration && !existingRecord && config.admin_password !== adminPassword) {
respond(res, 401, {error:'registration password incorrect'});
return;
}
// If we're adding a domain & we have a registration password, require a password for update as well
if (!config.open_registration && !existingRecord && !record.password) {
respond(res, 400, {error:'must supply update password'});
return;
}
// If we're updating a domain & it has a password, ensure it's correct
// Unless the admin password is configured & passed as adminPassword
var existingPassword = existingRecord && existingRecord.password;
var domainPwBad = existingPassword && existingPassword !== record.password;
var adminPwBad = !config.admin_password || config.admin_password !== adminPassword;
if (domainPwBad && adminPwBad) {
respond(res, 401, {error:'unauthorized'})
return;
}
records[record.type] = records[record.type] || {}
records[record.type][domain] = record;
//delete if that's our method
if (req.method === 'DELETE' && domain) {
delete records[record.type][domain];
record = {deleted: true};
}
//purge aged-out records
if (config.max_age) {
var now = new Date();
for (var type in records) {
for (var domain in records[type]) {
var rec = records[type][domain];
var recDate = new Date(rec.last_update_time);
if ((now - recDate) / 1000 > config.max_age) {
rec.expired = true;
}
}
}
}
//save bind file
fs.readFile(config.zone_template_path, 'utf8', function(err, template) {
if (err) {
console.error("Error reading zonetemplate file: " + err);
} else {
var zone = zone_file(template, records);
fs.writeFile(config.zone_output_path, zone, function(err) {
if (err) {
console.error("Error writing zone file: " + err);
} else {
sendSIGHUP();
}
});
}
});
//save dnsDB.json
fs.writeFile(config.database_path, JSON.stringify(records,null,4), function(err){
if(err){
console.error('Error writing dnsDB.json: ' + err);
}
})
respond(res, 200, record);
}
function fallbackSIGHUP() {
// try to use killall from shell to HUP nsd
child_process.exec("killall -HUP nsd", () => null);
}
function sendSIGHUP(){
//send SIGHUP to nsd
if(config['dns_pid_file']){
fs.readFile(config['dns_pid_file'], 'utf8', function(err, pid){
if(err){
console.warn('Error reading dns pid file: ' + err);
fallbackSIGHUP();
return;
}
try {
process.kill(parseInt(pid), 'SIGHUP');
} catch (error) {
console.error('Error sending SIGHUP to dns process: ' + error)
}
})
}
}
//Lets start our server
http.createServer(handleRequest).listen(config.port, function(){
//Callback triggered when server is successfully listening. Hurray!
console.log("Server listening on: http://localhost:%s", config.port);
});