Adds DoH capabilities

This commit is contained in:
Pierre Lannoy 2023-12-13 17:39:24 +01:00
commit 2a41c40dfd
Signed by: Pierre Lannoy
GPG key ID: D27231EF87D53F31
3 changed files with 202 additions and 4 deletions

View file

@ -1,7 +1,7 @@
{ {
"name": "domain-forrward", "name": "domain-forward",
"version": "1.0.0", "version": "1.0.0",
"description": "Public service to forward domain for free", "description": "Service to forward domain via DoH",
"main": "app.js", "main": "app.js",
"type": "module", "type": "module",
"engines": { "engines": {
@ -21,7 +21,9 @@
"await-lock": "^2.2.2", "await-lock": "^2.2.2",
"dotenv": "^16.0.3", "dotenv": "^16.0.3",
"jose": "^3.20.4", "jose": "^3.20.4",
"pem": "^1.14.7" "pem": "^1.14.7",
"base32-encode": "^1.1.1",
"dns-packet": "^5.2.1"
}, },
"devDependencies": { "devDependencies": {
"pm2": "^5.3.0" "pm2": "^5.3.0"

173
src/doh.js Normal file
View file

@ -0,0 +1,173 @@
import dnsPacket from 'dns-packet';
import https from 'https';
import http from 'http';
import base32Encode from 'base32-encode';
/**
* A super lame DNS over HTTPS stub resolver
*/
class DohResolver {
/**
* Creates a new DoH resolver
* @param nameserver_url {string} The URL we're going to be sending DNS requests to
*/
constructor(nameserver_url) {
this.nameserver_url = nameserver_url;
}
/**
* Perform a DNS lookup for the given query name and type.
*
* @param qname {string} the domain name to query for (e.g. example.com)
* @param qtype {string} the type of record we're looking for (e.g. A, AAAA, TXT, MX)
* @param timeout {number} the number of milliseconds to wait for a response before aborting the request
* @returns {Promise<object>} The DNS response received
*/
query(qname, qtype='TXT', timeout=null) {
return new Promise((resolve, reject) => {
let dnsMessage = makeQuery(qname, qtype);
sendDohMsg(dnsMessage, this.nameserver_url, timeout)
.then(resolve)
.catch(reject)
});
}
}
/**
* Make a DNS query message of type {@link object} (see [dns-packet]{@link https://github.com/mafintosh/dns-packet}). Use this before calling {@link sendDohMsg}
*
* The recursion desired flag will be set, and the ID in the header will be set to zero, per the RFC ([section 4.1](https://tools.ietf.org/html/rfc8484#section-4.1)).
* @param qname {string} the domain name to put in the query message (e.g. example.com)
* @param qtype {string} the query type to put in the query message (e.g. A, AAAA, DS, DNSKEY)
* @returns {object} The DNS query message
*/
function makeQuery(qname, qtype='TXT') {
return {
type: 'query',
id: 0,
flags: dnsPacket.RECURSION_DESIRED,
questions: [{
type: qtype,
name: qname,
}]
};
}
/**
* Send a DNS message over HTTPS to `url` using the given request method
*
* @param packet {object} the DNS message to send
* @param url {string} the url to send the DNS message to
* @param timeout {number} the number of milliseconds to wait for a response before aborting the request
* @returns {Promise<object>} the response (if we got any)
*/
function sendDohMsg(packet, url, timeout) {
return new Promise((resolve, reject) => {
const transport = url.startsWith('https://') ? https : http;
const buf = dnsPacket.encode(packet);
let requestOptions;
const headers = {
'Accept': 'application/dns-message',
'User-Agent': 'domain-forward/1.0.0'
};
const dnsQueryParam = buf.toString('base64').toString('utf-8').replace(/=/g, '');
url = `${url}?dns=${dnsQueryParam}`;
url = new URL(url);
requestOptions = {
method: 'GET',
hostname: url.hostname,
port: url.port || 443,
path: url.pathname + url.search,
headers: headers
};
let data;
let timer;
const request = transport.request(requestOptions, (response) => {
response.on('data', (d) => {
if (!data) {
data = d;
} else {
data = Buffer.concat([data, d]);
}
});
response.on('end', () => {
if (timer) {
clearTimeout(timer);
}
try {
const decoded = dnsPacket.decode(data);
resolve(decoded);
} catch (e) {
reject(e);
}
});
});
request.on('error', (err) => {
request.destroy();
return reject(err);
});
if (timeout) {
timer = setTimeout(() => {
request.destroy();
return reject(new Error(`Query timed out after ${timeout} milliseconds of inactivity`));
}, timeout);
}
request.end()
});
}
/**
* 'Prettifies' a dnsPacket message.
*
* Namely, this convert Buffers the the appropriate presentation format.
* This is useful to make json human readable.
*
* *NOTE* This function may modify the message such that it no longer works with dnsPacket.
* Caution should be used when calling this object on query packets before sending them
*
* @param msg {object} a dnsPacket
* @returns {object} the msg which has been modified in-place. *May not be a valid <dnsPacket> afterwards*
*/
function prettify(msg) {
for (const rr of (msg['answers'] || []).concat((msg['authorities'] || []))) {
if (rr.hasOwnProperty('data')) {
switch (rr.type) {
case 'TXT':
rr.data = rr.data.toString('utf8');
break;
case 'DNSKEY':
rr.data.key = rr.data.key.toString('base64').replace('=', '');
break;
case 'DS':
rr.data.digest = rr.data.digest.toString('hex');
break;
case 'NSEC3':
rr.data.salt = rr.data.salt.toString('hex');
rr.data.nextDomain = base32Encode(rr.data.nextDomain, 'RFC4648-HEX').replace('=', '')
break;
case 'RRSIG':
rr.data.signature = rr.data.signature.toString('base64').replace('=', '');
break;
}
}
}
for (const rr of (msg['additionals'] || [])) {
if (rr.type === 'OPT') {
for (const opt of rr['options']) {
switch(opt.code) {
case 12:
opt.length = opt.data.length;
opt.data = opt.data.toString('hex').substring(0, 80);
if (opt.data.length === 80 ) {
opt.data += '...'
}
break;
}
}
}
}
return msg
}
export default DohResolver;

View file

@ -2,6 +2,7 @@ import request from "./certnode/lib/request.js";
import crypto from "crypto"; import crypto from "crypto";
import fs from "fs"; import fs from "fs";
import {fileURLToPath} from "url"; import {fileURLToPath} from "url";
import DohResolver from "./doh.js";
const recordParamDestUrl = 'forward-to'; const recordParamDestUrl = 'forward-to';
const recordParamHttpStatus = 'http-status'; const recordParamHttpStatus = 'http-status';
@ -62,6 +63,28 @@ const parseTxtRecordData = (value) => {
* @return {Promise<{url: string, httpStatus?: string} | null>} * @return {Promise<{url: string, httpStatus?: string} | null>}
*/ */
export async function findTxtRecord(host) { export async function findTxtRecord(host) {
const resolver = new DohResolver('https://doh.hosterra.tech/dns-query');
const resolved = await resolver.query( host);
if (resolved.response.answers) {
for (const head of resolve.data.Answer) {
const txtData = parseTxtRecordData(head.data);
if (!txtData[recordParamDestUrl]) continue;
return {
url: txtData[recordParamDestUrl],
httpStatus: txtData[recordParamHttpStatus],
};
}
}
/*resolver.query( host)
.then(response => {
response.answers.forEach(ans => console.log(ans.data));
})
.catch(err => console.error(err));
const resolve = await request(`https://dns.google/resolve?name=_.${encodeURIComponent(host)}&type=TXT`); const resolve = await request(`https://dns.google/resolve?name=_.${encodeURIComponent(host)}&type=TXT`);
if (resolve.data.Answer) { if (resolve.data.Answer) {
for (const head of resolve.data.Answer) { for (const head of resolve.data.Answer) {
@ -72,7 +95,7 @@ export async function findTxtRecord(host) {
httpStatus: txtData[recordParamHttpStatus], httpStatus: txtData[recordParamHttpStatus],
}; };
} }
} }*/
return null; return null;
} }